refactor progress
This commit is contained in:
24
packages/markdown_parser/package.json
Normal file
24
packages/markdown_parser/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@fluxer/markdown_parser",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./src/*": "./src/*",
|
||||
"./*": "./*"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"idna-uts46-hx": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"emoji-regex": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
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>'}]);
|
||||
});
|
||||
});
|
||||
57
packages/markdown_parser/src/parser/FormattingContext.tsx
Normal file
57
packages/markdown_parser/src/parser/FormattingContext.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MAX_INLINE_DEPTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
|
||||
|
||||
export class FormattingContext {
|
||||
private readonly activeFormattingTypes = new Map<string, boolean>();
|
||||
private readonly formattingStack: Array<[string, boolean]> = [];
|
||||
private currentDepth = 0;
|
||||
|
||||
canEnterFormatting(delimiter: string, isDouble: boolean): boolean {
|
||||
const key = this.getFormattingKey(delimiter, isDouble);
|
||||
if (this.activeFormattingTypes.has(key)) return false;
|
||||
return this.currentDepth < MAX_INLINE_DEPTH;
|
||||
}
|
||||
|
||||
isFormattingActive(delimiter: string, isDouble: boolean): boolean {
|
||||
return this.activeFormattingTypes.has(this.getFormattingKey(delimiter, isDouble));
|
||||
}
|
||||
|
||||
pushFormatting(delimiter: string, isDouble: boolean): void {
|
||||
this.formattingStack.push([delimiter, isDouble]);
|
||||
this.activeFormattingTypes.set(this.getFormattingKey(delimiter, isDouble), true);
|
||||
this.currentDepth++;
|
||||
}
|
||||
|
||||
popFormatting(): [string, boolean] | undefined {
|
||||
const removed = this.formattingStack.pop();
|
||||
if (removed) {
|
||||
this.activeFormattingTypes.delete(this.getFormattingKey(removed[0], removed[1]));
|
||||
this.currentDepth--;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
setCurrentText(_text: string): void {}
|
||||
|
||||
private getFormattingKey(delimiter: string, isDouble: boolean): string {
|
||||
return `${delimiter}${isDouble ? '2' : '1'}`;
|
||||
}
|
||||
}
|
||||
35
packages/markdown_parser/src/parser/Parser.tsx
Normal file
35
packages/markdown_parser/src/parser/Parser.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {parseMarkdownAst} from '@fluxer/markdown_parser/src/parser/ParserEngine';
|
||||
import type {Node} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
|
||||
export class Parser {
|
||||
private readonly input: string;
|
||||
private readonly parserFlags: number;
|
||||
|
||||
constructor(input: string, flags: number) {
|
||||
this.input = input;
|
||||
this.parserFlags = flags;
|
||||
}
|
||||
|
||||
parse(): {nodes: Array<Node>} {
|
||||
return parseMarkdownAst(this.input, this.parserFlags);
|
||||
}
|
||||
}
|
||||
208
packages/markdown_parser/src/parser/ParserEngine.tsx
Normal file
208
packages/markdown_parser/src/parser/ParserEngine.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createParserInput} from '@fluxer/markdown_parser/src/parser/ParserInput';
|
||||
import * as BlockParsers from '@fluxer/markdown_parser/src/parsers/BlockParsers';
|
||||
import * as EmojiParsers from '@fluxer/markdown_parser/src/parsers/EmojiParsers';
|
||||
import * as InlineParsers from '@fluxer/markdown_parser/src/parsers/InlineParsers';
|
||||
import * as ListParsers from '@fluxer/markdown_parser/src/parsers/ListParsers';
|
||||
import {NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import {MAX_AST_NODES, MAX_LINE_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
|
||||
import type {Node} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
import * as ASTUtils from '@fluxer/markdown_parser/src/utils/AstUtils';
|
||||
|
||||
interface ParserRuntimeState {
|
||||
lines: Array<string>;
|
||||
currentLineIndex: number;
|
||||
totalLineCount: number;
|
||||
parserFlags: number;
|
||||
nodeCount: number;
|
||||
}
|
||||
|
||||
export function parseMarkdownAst(input: string, parserFlags: number): {nodes: Array<Node>} {
|
||||
const parserInput = createParserInput(input);
|
||||
const state: ParserRuntimeState = {
|
||||
lines: parserInput.lines,
|
||||
currentLineIndex: 0,
|
||||
totalLineCount: parserInput.totalLineCount,
|
||||
parserFlags,
|
||||
nodeCount: 0,
|
||||
};
|
||||
|
||||
return parseWithRuntimeState(state);
|
||||
}
|
||||
|
||||
function parseWithRuntimeState(state: ParserRuntimeState): {nodes: Array<Node>} {
|
||||
const ast: Array<Node> = [];
|
||||
if (state.totalLineCount === 0) {
|
||||
return {nodes: ast};
|
||||
}
|
||||
|
||||
const blockParserDependencies = createBlockParserDependencies(state);
|
||||
|
||||
while (state.currentLineIndex < state.totalLineCount && state.nodeCount <= MAX_AST_NODES) {
|
||||
const line = state.lines[state.currentLineIndex];
|
||||
if (line.length > MAX_LINE_LENGTH) {
|
||||
state.lines[state.currentLineIndex] = line.slice(0, MAX_LINE_LENGTH);
|
||||
}
|
||||
|
||||
const trimmedLine = line.trimStart();
|
||||
if (trimmedLine === '') {
|
||||
const blankLineCount = countBlankLines(state.lines, state.currentLineIndex, state.totalLineCount);
|
||||
if (ast.length > 0 && state.currentLineIndex + blankLineCount < state.totalLineCount) {
|
||||
const nextLine = state.lines[state.currentLineIndex + blankLineCount];
|
||||
const nextTrimmed = nextLine.trimStart();
|
||||
const isNextHeading = nextTrimmed
|
||||
? BlockParsers.parseHeading(nextTrimmed, blockParserDependencies.parseInline) !== null
|
||||
: false;
|
||||
const isPreviousHeading = ast[ast.length - 1]?.type === NodeType.Heading;
|
||||
|
||||
if (!isNextHeading && !isPreviousHeading) {
|
||||
const newlines = '\n'.repeat(blankLineCount);
|
||||
ast.push({type: NodeType.Text, content: newlines});
|
||||
state.nodeCount++;
|
||||
}
|
||||
}
|
||||
state.currentLineIndex += blankLineCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockResult = BlockParsers.parseBlock(
|
||||
state.lines,
|
||||
state.currentLineIndex,
|
||||
state.parserFlags,
|
||||
state.nodeCount,
|
||||
blockParserDependencies,
|
||||
);
|
||||
|
||||
if (blockResult.node) {
|
||||
ast.push(blockResult.node);
|
||||
if (blockResult.extraNodes) {
|
||||
for (const extraNode of blockResult.extraNodes) {
|
||||
ast.push(extraNode);
|
||||
}
|
||||
}
|
||||
state.currentLineIndex = blockResult.newLineIndex;
|
||||
state.nodeCount = blockResult.newNodeCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
parseInlineLine(ast, state, blockParserDependencies.parseInline);
|
||||
state.currentLineIndex++;
|
||||
}
|
||||
|
||||
ASTUtils.flattenAST(ast);
|
||||
|
||||
for (const node of ast) {
|
||||
EmojiParsers.applyTextPresentation(node);
|
||||
}
|
||||
|
||||
return {nodes: ast};
|
||||
}
|
||||
|
||||
function createBlockParserDependencies(state: ParserRuntimeState): BlockParsers.BlockParserDependencies {
|
||||
return {
|
||||
parseInline(text: string): Array<Node> {
|
||||
return InlineParsers.parseInline(text, state.parserFlags);
|
||||
},
|
||||
parseNested(text: string, parserFlags: number): Array<Node> {
|
||||
return parseMarkdownAst(text, parserFlags).nodes;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countBlankLines(lines: Array<string>, startLine: number, totalLineCount: number): number {
|
||||
let count = 0;
|
||||
let current = startLine;
|
||||
while (current < totalLineCount && lines[current].trim() === '') {
|
||||
count++;
|
||||
current++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function parseInlineLine(
|
||||
ast: Array<Node>,
|
||||
state: ParserRuntimeState,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
): void {
|
||||
let text = state.lines[state.currentLineIndex];
|
||||
let linesConsumed = 1;
|
||||
|
||||
while (state.currentLineIndex + linesConsumed < state.totalLineCount) {
|
||||
const nextLine = state.lines[state.currentLineIndex + linesConsumed];
|
||||
const trimmedNext = nextLine.trimStart();
|
||||
|
||||
if (isBlockStart(trimmedNext, state.parserFlags)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (trimmedNext === '') {
|
||||
break;
|
||||
}
|
||||
|
||||
text += `\n${nextLine}`;
|
||||
linesConsumed++;
|
||||
}
|
||||
|
||||
if (state.currentLineIndex + linesConsumed < state.totalLineCount) {
|
||||
const nextLine = state.lines[state.currentLineIndex + linesConsumed];
|
||||
const trimmedNext = nextLine.trimStart();
|
||||
const isNextLineHeading = isHeadingStart(trimmedNext, state.parserFlags);
|
||||
const isNextLineBlockquote = trimmedNext.startsWith('>');
|
||||
if (trimmedNext === '' || (!isNextLineHeading && !isNextLineBlockquote)) {
|
||||
text += '\n';
|
||||
}
|
||||
}
|
||||
|
||||
const inlineNodes = parseInline(text);
|
||||
|
||||
for (const node of inlineNodes) {
|
||||
ast.push(node);
|
||||
state.nodeCount++;
|
||||
if (state.nodeCount > MAX_AST_NODES) break;
|
||||
}
|
||||
|
||||
state.currentLineIndex += linesConsumed - 1;
|
||||
}
|
||||
|
||||
function isBlockStart(line: string, parserFlags: number): boolean {
|
||||
return !!(
|
||||
line.startsWith('#') ||
|
||||
(parserFlags & ParserFlags.ALLOW_SUBTEXT && line.startsWith('-#')) ||
|
||||
(parserFlags & ParserFlags.ALLOW_CODE_BLOCKS && line.startsWith('```')) ||
|
||||
(parserFlags & ParserFlags.ALLOW_LISTS && ListParsers.matchListItem(line) != null) ||
|
||||
(parserFlags & (ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES) &&
|
||||
(line.startsWith('>') || line.startsWith('>>> ')))
|
||||
);
|
||||
}
|
||||
|
||||
const MAX_HEADING_LEVEL = 4;
|
||||
|
||||
function isHeadingStart(trimmedLine: string, parserFlags: number): boolean {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_HEADINGS)) return false;
|
||||
if (!trimmedLine.startsWith('#')) return false;
|
||||
|
||||
let level = 0;
|
||||
while (level < trimmedLine.length && level < MAX_HEADING_LEVEL && trimmedLine[level] === '#') {
|
||||
level++;
|
||||
}
|
||||
|
||||
return level >= 1 && level <= MAX_HEADING_LEVEL && trimmedLine[level] === ' ';
|
||||
}
|
||||
45
packages/markdown_parser/src/parser/ParserInput.tsx
Normal file
45
packages/markdown_parser/src/parser/ParserInput.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {MAX_LINES} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
|
||||
|
||||
export interface ParserInput {
|
||||
lines: Array<string>;
|
||||
totalLineCount: number;
|
||||
}
|
||||
|
||||
export function createParserInput(input: string): ParserInput {
|
||||
if (!input || input === '') {
|
||||
return {lines: [], totalLineCount: 0};
|
||||
}
|
||||
|
||||
const lines = input['split']('\n');
|
||||
if (lines.length > MAX_LINES) {
|
||||
lines.length = MAX_LINES;
|
||||
}
|
||||
|
||||
if (lines.length === 1 && lines[0] === '') {
|
||||
return {lines: [], totalLineCount: 0};
|
||||
}
|
||||
|
||||
return {
|
||||
lines,
|
||||
totalLineCount: lines.length,
|
||||
};
|
||||
}
|
||||
815
packages/markdown_parser/src/parsers/BlockParsers.tsx
Normal file
815
packages/markdown_parser/src/parsers/BlockParsers.tsx
Normal file
@@ -0,0 +1,815 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {Parser} from '@fluxer/markdown_parser/src/parser/Parser';
|
||||
import * as InlineParsers from '@fluxer/markdown_parser/src/parsers/InlineParsers';
|
||||
import * as ListParsers from '@fluxer/markdown_parser/src/parsers/ListParsers';
|
||||
import * as TableParsers from '@fluxer/markdown_parser/src/parsers/TableParsers';
|
||||
import {AlertType, NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import {MAX_AST_NODES, MAX_LINE_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
|
||||
import type {
|
||||
AlertNode,
|
||||
CodeBlockNode,
|
||||
HeadingNode,
|
||||
Node,
|
||||
SpoilerNode,
|
||||
SubtextNode,
|
||||
TextNode,
|
||||
} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
import {flattenChildren} from '@fluxer/markdown_parser/src/utils/AstUtils';
|
||||
|
||||
const ALERT_PATTERN = /^\[!([A-Z]+)\]\s*\n?/;
|
||||
|
||||
interface BlockParseResult {
|
||||
node: Node | null;
|
||||
newLineIndex: number;
|
||||
newNodeCount: number;
|
||||
extraNodes?: Array<Node>;
|
||||
}
|
||||
|
||||
export interface BlockParserDependencies {
|
||||
parseInline: (text: string) => Array<Node>;
|
||||
parseNested: (text: string, parserFlags: number) => Array<Node>;
|
||||
}
|
||||
|
||||
const stringCache = new Map<string, boolean>();
|
||||
|
||||
function hasOpenInlineCode(text: string): boolean {
|
||||
if (!text.includes('`')) return false;
|
||||
|
||||
let openLength: number | null = null;
|
||||
let index = 0;
|
||||
|
||||
while (index < text.length) {
|
||||
if (text[index] !== '`') {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let runLength = 0;
|
||||
while (index + runLength < text.length && text[index + runLength] === '`') {
|
||||
runLength++;
|
||||
}
|
||||
|
||||
if (openLength === null) {
|
||||
openLength = runLength;
|
||||
} else if (runLength === openLength) {
|
||||
openLength = null;
|
||||
}
|
||||
|
||||
index += runLength;
|
||||
}
|
||||
|
||||
return openLength !== null;
|
||||
}
|
||||
|
||||
function cachedStartsWith(str: string, search: string): boolean {
|
||||
const key = `${str}:${search}:startsWith`;
|
||||
if (!stringCache.has(key)) {
|
||||
stringCache.set(key, str.startsWith(search));
|
||||
}
|
||||
return stringCache.get(key)!;
|
||||
}
|
||||
|
||||
export function parseBlock(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parserFlags: number,
|
||||
nodeCount: number,
|
||||
_dependencies: BlockParserDependencies,
|
||||
): BlockParseResult {
|
||||
if (currentLineIndex >= lines.length) {
|
||||
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
|
||||
const line = lines[currentLineIndex];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
if (cachedStartsWith(trimmed, '>>> ')) {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES)) {
|
||||
const result = {
|
||||
node: parseBlockAsText(lines, currentLineIndex, '>>> '),
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
return result;
|
||||
}
|
||||
const result = parseMultilineBlockquote(lines, currentLineIndex, parserFlags, nodeCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
if (cachedStartsWith(trimmed, '>')) {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_BLOCKQUOTES)) {
|
||||
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
const result = parseBlockquote(lines, currentLineIndex, parserFlags, nodeCount);
|
||||
return result;
|
||||
}
|
||||
|
||||
const listMatch = ListParsers.matchListItem(line);
|
||||
|
||||
if (listMatch) {
|
||||
const [isOrdered, indentLevel, _content] = listMatch;
|
||||
if (parserFlags & ParserFlags.ALLOW_LISTS) {
|
||||
const result = ListParsers.parseList(
|
||||
lines,
|
||||
currentLineIndex,
|
||||
isOrdered,
|
||||
indentLevel,
|
||||
1,
|
||||
parserFlags,
|
||||
nodeCount,
|
||||
(text) => InlineParsers.parseInline(text, parserFlags),
|
||||
);
|
||||
|
||||
const finalResult = {
|
||||
node: result.node,
|
||||
newLineIndex: result.newLineIndex,
|
||||
newNodeCount: result.newNodeCount,
|
||||
};
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
const textNode: TextNode = {type: NodeType.Text, content: line};
|
||||
const result: BlockParseResult = {
|
||||
node: textNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith('||') && !trimmed.slice(2).includes('||')) {
|
||||
if (parserFlags & ParserFlags.ALLOW_SPOILERS) {
|
||||
const result = parseSpoiler(lines, currentLineIndex, parserFlags);
|
||||
|
||||
const finalResult = {
|
||||
node: result.node,
|
||||
newLineIndex: result.newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
const textNode: TextNode = {type: NodeType.Text, content: line};
|
||||
const result: BlockParseResult = {
|
||||
node: textNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (parserFlags & ParserFlags.ALLOW_CODE_BLOCKS) {
|
||||
const fencePosition = line.indexOf('```');
|
||||
|
||||
if (fencePosition !== -1) {
|
||||
const startsWithFence = cachedStartsWith(trimmed, '```') && fencePosition === line.length - trimmed.length;
|
||||
if (startsWithFence) {
|
||||
const result = parseCodeBlock(lines, currentLineIndex);
|
||||
|
||||
const finalResult: BlockParseResult = {
|
||||
node: result.node,
|
||||
newLineIndex: result.newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
if (result.extraContent) {
|
||||
finalResult.extraNodes = [{type: NodeType.Text, content: result.extraContent}];
|
||||
finalResult.newNodeCount = nodeCount + 2;
|
||||
}
|
||||
|
||||
return finalResult;
|
||||
}
|
||||
|
||||
const prefixText = line.slice(0, fencePosition);
|
||||
if (hasOpenInlineCode(prefixText)) {
|
||||
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
const inlineNodes = InlineParsers.parseInline(prefixText, parserFlags);
|
||||
|
||||
const codeLines = [line.slice(fencePosition), ...lines.slice(currentLineIndex + 1)];
|
||||
const codeResult = parseCodeBlock(codeLines, 0);
|
||||
const newLineIndex = currentLineIndex + codeResult.newLineIndex;
|
||||
|
||||
const extraNodes: Array<Node> = [];
|
||||
if (inlineNodes.length > 1) {
|
||||
extraNodes.push(...inlineNodes.slice(1));
|
||||
}
|
||||
extraNodes.push(codeResult.node);
|
||||
if (codeResult.extraContent) {
|
||||
extraNodes.push({type: NodeType.Text, content: codeResult.extraContent});
|
||||
}
|
||||
|
||||
const firstNode = inlineNodes[0] ?? codeResult.node;
|
||||
const newNodeCount = nodeCount + inlineNodes.length + 1 + (codeResult.extraContent ? 1 : 0);
|
||||
|
||||
return {
|
||||
node: firstNode,
|
||||
extraNodes: extraNodes.length > 0 ? extraNodes : undefined,
|
||||
newLineIndex,
|
||||
newNodeCount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!(parserFlags & ParserFlags.ALLOW_CODE_BLOCKS) && cachedStartsWith(trimmed, '```')) {
|
||||
let codeBlockText = lines[currentLineIndex];
|
||||
let endLineIndex = currentLineIndex + 1;
|
||||
|
||||
while (endLineIndex < lines.length) {
|
||||
const nextLine = lines[endLineIndex];
|
||||
|
||||
if (nextLine.trim() === '```') {
|
||||
codeBlockText += `\n${nextLine}`;
|
||||
endLineIndex++;
|
||||
break;
|
||||
}
|
||||
|
||||
codeBlockText += `\n${nextLine}`;
|
||||
endLineIndex++;
|
||||
}
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Text, content: codeBlockText} as TextNode,
|
||||
newLineIndex: endLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (cachedStartsWith(trimmed, '-#')) {
|
||||
if (parserFlags & ParserFlags.ALLOW_SUBTEXT) {
|
||||
const subtextNode = parseSubtext(trimmed, (text) => InlineParsers.parseInline(text, parserFlags));
|
||||
|
||||
if (subtextNode) {
|
||||
const result = {
|
||||
node: subtextNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
const result = {
|
||||
node: {type: NodeType.Text, content: handleLineAsText(lines, currentLineIndex)} as TextNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
if (cachedStartsWith(trimmed, '#')) {
|
||||
if (parserFlags & ParserFlags.ALLOW_HEADINGS) {
|
||||
const headingNode = parseHeading(trimmed, (text) => InlineParsers.parseInline(text, parserFlags));
|
||||
|
||||
if (headingNode) {
|
||||
const result = {
|
||||
node: headingNode,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
// Not a heading, treat it as inline text so links/formatting still parse.
|
||||
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
|
||||
if (trimmed.includes('|') && parserFlags & ParserFlags.ALLOW_TABLES) {
|
||||
const startIndex = currentLineIndex;
|
||||
|
||||
const tableResult = TableParsers.parseTable(lines, currentLineIndex, parserFlags, (text) =>
|
||||
InlineParsers.parseInline(text, parserFlags),
|
||||
);
|
||||
|
||||
if (tableResult.node) {
|
||||
const result = {
|
||||
node: tableResult.node,
|
||||
newLineIndex: tableResult.newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
currentLineIndex = startIndex;
|
||||
}
|
||||
|
||||
return {node: null, newLineIndex: currentLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
|
||||
function handleLineAsText(lines: Array<string>, currentLineIndex: number): string {
|
||||
const isLastLine = currentLineIndex === lines.length - 1;
|
||||
return isLastLine ? lines[currentLineIndex] : `${lines[currentLineIndex]}\n`;
|
||||
}
|
||||
|
||||
function parseBlockAsText(lines: Array<string>, currentLineIndex: number, marker: string): TextNode {
|
||||
const originalContent = lines[currentLineIndex];
|
||||
|
||||
if (marker === '>' || marker === '>>> ') {
|
||||
return {
|
||||
type: NodeType.Text,
|
||||
content: originalContent + (currentLineIndex < lines.length - 1 ? '\n' : ''),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: NodeType.Text,
|
||||
content: originalContent,
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_HEADING_LEVEL = 4;
|
||||
|
||||
export function parseHeading(trimmed: string, parseInline: (text: string) => Array<Node>): HeadingNode | null {
|
||||
let level = 0;
|
||||
for (let i = 0; i < trimmed.length && i < MAX_HEADING_LEVEL; i++) {
|
||||
if (trimmed[i] === '#') level++;
|
||||
else break;
|
||||
}
|
||||
|
||||
if (level >= 1 && level <= MAX_HEADING_LEVEL && trimmed[level] === ' ') {
|
||||
const content = trimmed.slice(level + 1);
|
||||
const inlineNodes = parseInline(content);
|
||||
|
||||
const result: HeadingNode = {
|
||||
type: NodeType.Heading,
|
||||
level,
|
||||
children: inlineNodes,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseSubtext(trimmed: string, parseInline: (text: string) => Array<Node>): SubtextNode | null {
|
||||
if (trimmed.startsWith('-#')) {
|
||||
if ((trimmed.length > 2 && trimmed[2] !== ' ') || (trimmed.length > 3 && trimmed[3] === ' ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = trimmed.slice(3);
|
||||
const inlineNodes = parseInline(content);
|
||||
|
||||
const result: SubtextNode = {
|
||||
type: NodeType.Subtext,
|
||||
children: inlineNodes,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseBlockquote(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parserFlags: number,
|
||||
nodeCount: number,
|
||||
): BlockParseResult {
|
||||
let blockquoteContent = '';
|
||||
const startLine = currentLineIndex;
|
||||
let newLineIndex = currentLineIndex;
|
||||
|
||||
while (newLineIndex < lines.length) {
|
||||
if (nodeCount > MAX_AST_NODES) break;
|
||||
const line = lines[newLineIndex];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
if (trimmed === '> ' || trimmed === '> ') {
|
||||
if (blockquoteContent.length > 0) blockquoteContent += '\n';
|
||||
newLineIndex++;
|
||||
} else if (trimmed.startsWith('> ')) {
|
||||
const content = trimmed.slice(2);
|
||||
if (blockquoteContent.length > 0) blockquoteContent += '\n';
|
||||
blockquoteContent += content;
|
||||
newLineIndex++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (blockquoteContent.length > MAX_LINE_LENGTH * 100) break;
|
||||
}
|
||||
|
||||
if (blockquoteContent === '' && newLineIndex === startLine) {
|
||||
return {node: null, newLineIndex, newNodeCount: nodeCount};
|
||||
}
|
||||
|
||||
if (parserFlags & ParserFlags.ALLOW_ALERTS) {
|
||||
const alertNode = parseAlert(blockquoteContent, parserFlags);
|
||||
|
||||
if (alertNode) {
|
||||
return {
|
||||
node: alertNode,
|
||||
newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const childFlags = parserFlags & ~ParserFlags.ALLOW_BLOCKQUOTES;
|
||||
const childParser = new Parser(blockquoteContent, childFlags);
|
||||
const {nodes: childNodes} = childParser.parse();
|
||||
|
||||
flattenChildren(childNodes, true);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Blockquote,
|
||||
children: childNodes,
|
||||
},
|
||||
newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function parseMultilineBlockquote(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parserFlags: number,
|
||||
nodeCount: number,
|
||||
): BlockParseResult {
|
||||
const line = lines[currentLineIndex];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
if (!trimmed.startsWith('>>> ')) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: ''},
|
||||
newLineIndex: currentLineIndex,
|
||||
newNodeCount: nodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
let content = trimmed.slice(4);
|
||||
let newLineIndex = currentLineIndex + 1;
|
||||
|
||||
while (newLineIndex < lines.length) {
|
||||
const current = lines[newLineIndex];
|
||||
content += `\n${current}`;
|
||||
newLineIndex++;
|
||||
if (content.length > MAX_LINE_LENGTH * 100) break;
|
||||
}
|
||||
|
||||
const childFlags = (parserFlags & ~ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES) | ParserFlags.ALLOW_BLOCKQUOTES;
|
||||
const childParser = new Parser(content, childFlags);
|
||||
const {nodes: childNodes} = childParser.parse();
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Blockquote,
|
||||
children: childNodes,
|
||||
},
|
||||
newLineIndex,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCodeBlock(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
): {node: CodeBlockNode; newLineIndex: number; extraContent?: string} {
|
||||
const line = lines[currentLineIndex];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
const indentSpaces = line.length - trimmed.length;
|
||||
const listIndent = indentSpaces > 0 ? ' '.repeat(indentSpaces) : '';
|
||||
|
||||
let fenceLength = 0;
|
||||
for (let i = 0; i < trimmed.length && trimmed[i] === '`'; i++) {
|
||||
fenceLength++;
|
||||
}
|
||||
|
||||
const languagePart = trimmed.slice(fenceLength);
|
||||
|
||||
const closingFence = '`'.repeat(fenceLength);
|
||||
const closingFenceIndex = languagePart.indexOf(closingFence);
|
||||
|
||||
let language: string | undefined;
|
||||
if (closingFenceIndex !== -1) {
|
||||
const inlineContent = languagePart.slice(0, closingFenceIndex);
|
||||
const trailingInline = languagePart.slice(closingFenceIndex + fenceLength);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.CodeBlock,
|
||||
language: undefined,
|
||||
content: inlineContent,
|
||||
},
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
extraContent: trailingInline || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
language = languagePart.trim() || undefined;
|
||||
let newLineIndex = currentLineIndex + 1;
|
||||
|
||||
let tempIndex = newLineIndex;
|
||||
let lineCount = 0;
|
||||
|
||||
while (tempIndex < lines.length) {
|
||||
const trimmedLine = lines[tempIndex].trimStart();
|
||||
if (trimmedLine.startsWith(closingFence)) {
|
||||
let backtickCount = 0;
|
||||
for (let i = 0; i < trimmedLine.length && trimmedLine[i] === '`'; i++) {
|
||||
backtickCount++;
|
||||
}
|
||||
|
||||
const charAfterBackticks = trimmedLine[backtickCount];
|
||||
if (
|
||||
backtickCount >= fenceLength &&
|
||||
(!charAfterBackticks || charAfterBackticks === ' ' || charAfterBackticks === '\t' || charAfterBackticks === '`')
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
tempIndex++;
|
||||
lineCount++;
|
||||
if (lineCount > 1000) break;
|
||||
}
|
||||
|
||||
const contentParts: Array<string> = [];
|
||||
let contentLength = 0;
|
||||
|
||||
while (newLineIndex < lines.length) {
|
||||
const current = lines[newLineIndex];
|
||||
const trimmedLine = current.trimStart();
|
||||
|
||||
const fenceIndex = trimmedLine.indexOf(closingFence);
|
||||
if (fenceIndex !== -1) {
|
||||
let backtickCount = 0;
|
||||
let idx = fenceIndex;
|
||||
while (idx < trimmedLine.length && trimmedLine[idx] === '`') {
|
||||
backtickCount++;
|
||||
idx++;
|
||||
}
|
||||
|
||||
const charAfterBackticks = trimmedLine[idx];
|
||||
const onlyWhitespaceAfter =
|
||||
!charAfterBackticks || charAfterBackticks === ' ' || charAfterBackticks === '\t' || charAfterBackticks === '`';
|
||||
|
||||
if (backtickCount >= fenceLength && onlyWhitespaceAfter) {
|
||||
const contentPrefix = current.slice(0, current.indexOf(closingFence));
|
||||
let contentLine = contentPrefix;
|
||||
if (indentSpaces > 0 && contentPrefix.startsWith(listIndent)) {
|
||||
contentLine = contentPrefix.slice(indentSpaces);
|
||||
}
|
||||
|
||||
if (contentLine.length > 0) {
|
||||
contentParts.push(contentLine);
|
||||
contentParts.push('\n');
|
||||
}
|
||||
|
||||
let extraContent: string | undefined;
|
||||
const trailingText = trimmedLine.slice(idx);
|
||||
if (trailingText) {
|
||||
extraContent = trailingText;
|
||||
} else if (backtickCount > fenceLength) {
|
||||
extraContent = trimmedLine.slice(fenceLength);
|
||||
}
|
||||
|
||||
newLineIndex++;
|
||||
|
||||
if (extraContent) {
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.CodeBlock,
|
||||
language,
|
||||
content: contentParts.join(''),
|
||||
},
|
||||
newLineIndex,
|
||||
extraContent,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let contentLine = current;
|
||||
if (indentSpaces > 0 && current.startsWith(listIndent)) {
|
||||
contentLine = current.slice(indentSpaces);
|
||||
}
|
||||
|
||||
contentParts.push(contentLine);
|
||||
contentParts.push('\n');
|
||||
contentLength += contentLine.length + 1;
|
||||
|
||||
if (contentLength > MAX_LINE_LENGTH * 100) break;
|
||||
newLineIndex++;
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.CodeBlock,
|
||||
language,
|
||||
content: contentParts.join(''),
|
||||
},
|
||||
newLineIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function parseSpoiler(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parserFlags: number,
|
||||
): {node: SpoilerNode | TextNode; newLineIndex: number} {
|
||||
const startLine = currentLineIndex;
|
||||
let foundEnd = false;
|
||||
let blockContent = '';
|
||||
let newLineIndex = currentLineIndex;
|
||||
|
||||
while (newLineIndex < lines.length) {
|
||||
const line = lines[newLineIndex];
|
||||
|
||||
if (newLineIndex === startLine) {
|
||||
const startIdx = line.indexOf('||');
|
||||
if (startIdx !== -1) {
|
||||
blockContent += line.slice(startIdx + 2);
|
||||
}
|
||||
} else {
|
||||
const endIdx = line.indexOf('||');
|
||||
if (endIdx !== -1) {
|
||||
blockContent += line.slice(0, endIdx);
|
||||
foundEnd = true;
|
||||
newLineIndex++;
|
||||
break;
|
||||
}
|
||||
blockContent += line;
|
||||
}
|
||||
|
||||
blockContent += '\n';
|
||||
newLineIndex++;
|
||||
|
||||
if (blockContent.length > MAX_LINE_LENGTH * 10) break;
|
||||
}
|
||||
|
||||
if (!foundEnd) {
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Text,
|
||||
content: `||${blockContent.trimEnd()}`,
|
||||
},
|
||||
newLineIndex,
|
||||
};
|
||||
}
|
||||
|
||||
const childParser = new Parser(blockContent.trim(), parserFlags);
|
||||
const {nodes: innerNodes} = childParser.parse();
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Spoiler,
|
||||
children: innerNodes,
|
||||
isBlock: true,
|
||||
},
|
||||
newLineIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function parseAlert(blockquoteText: string, parserFlags: number): AlertNode | null {
|
||||
const alertMatch = blockquoteText.match(ALERT_PATTERN);
|
||||
if (!alertMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const alertTypeStr = alertMatch[1].toUpperCase();
|
||||
let alertType: AlertType;
|
||||
|
||||
switch (alertTypeStr) {
|
||||
case 'NOTE':
|
||||
alertType = AlertType.Note;
|
||||
break;
|
||||
case 'TIP':
|
||||
alertType = AlertType.Tip;
|
||||
break;
|
||||
case 'IMPORTANT':
|
||||
alertType = AlertType.Important;
|
||||
break;
|
||||
case 'WARNING':
|
||||
alertType = AlertType.Warning;
|
||||
break;
|
||||
case 'CAUTION':
|
||||
alertType = AlertType.Caution;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = blockquoteText.slice(alertMatch[0].length);
|
||||
|
||||
const childFlags =
|
||||
(parserFlags & ~ParserFlags.ALLOW_BLOCKQUOTES) | ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_HEADINGS;
|
||||
|
||||
const lines = content.split('\n');
|
||||
const processedLines = lines.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('-') || /^\d+\./.test(trimmed)) {
|
||||
return line;
|
||||
}
|
||||
return trimmed;
|
||||
});
|
||||
|
||||
const processedContent = processedLines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
const childParser = new Parser(processedContent, childFlags);
|
||||
const {nodes: childNodes} = childParser.parse();
|
||||
|
||||
const mergedNodes: Array<Node> = [];
|
||||
let currentText = '';
|
||||
|
||||
for (const node of childNodes) {
|
||||
if (node.type === NodeType.Text) {
|
||||
if (currentText) {
|
||||
currentText += node.content;
|
||||
} else {
|
||||
currentText = node.content;
|
||||
}
|
||||
} else {
|
||||
if (currentText) {
|
||||
mergedNodes.push({type: NodeType.Text, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
mergedNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
mergedNodes.push({type: NodeType.Text, content: currentText});
|
||||
}
|
||||
|
||||
const finalNodes = postProcessAlertNodes(mergedNodes);
|
||||
|
||||
return {
|
||||
type: NodeType.Alert,
|
||||
alertType,
|
||||
children: finalNodes,
|
||||
};
|
||||
}
|
||||
|
||||
function postProcessAlertNodes(nodes: Array<Node>): Array<Node> {
|
||||
if (nodes.length <= 1) return nodes;
|
||||
|
||||
const result: Array<Node> = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < nodes.length) {
|
||||
const node = nodes[i];
|
||||
|
||||
if (node.type === NodeType.Text && i + 1 < nodes.length) {
|
||||
if (nodes[i + 1].type === NodeType.List) {
|
||||
const trimmedContent = node.content.replace(/\s+$/, '\n');
|
||||
if (trimmedContent) {
|
||||
result.push({type: NodeType.Text, content: trimmedContent});
|
||||
}
|
||||
} else {
|
||||
result.push(node);
|
||||
}
|
||||
} else if (node.type === NodeType.List && i + 1 < nodes.length) {
|
||||
result.push(node);
|
||||
|
||||
const nextNode = nodes[i + 1];
|
||||
if (nextNode.type === NodeType.Text) {
|
||||
const content = nextNode.content.trim();
|
||||
if (content) {
|
||||
result.push({type: NodeType.Text, content: `\n${content}`});
|
||||
i++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(node);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
364
packages/markdown_parser/src/parsers/EmojiParsers.tsx
Normal file
364
packages/markdown_parser/src/parsers/EmojiParsers.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EmojiKind, NodeType} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import type {Node, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
|
||||
export interface UnicodeEmoji {
|
||||
surrogates: string;
|
||||
}
|
||||
|
||||
export interface EmojiProvider {
|
||||
getSurrogateName(surrogate: string): string | null;
|
||||
findEmojiByName(name: string): UnicodeEmoji | null;
|
||||
findEmojiWithSkinTone(baseName: string, skinToneSurrogate: string): UnicodeEmoji | null;
|
||||
}
|
||||
|
||||
export interface EmojiParserConfig {
|
||||
emojiProvider?: EmojiProvider;
|
||||
emojiRegex?: RegExp;
|
||||
skinToneSurrogates?: ReadonlyArray<string>;
|
||||
convertToCodePoints?: (emoji: string) => string;
|
||||
}
|
||||
|
||||
const VALID_EMOJI_NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
||||
const CUSTOM_EMOJI_REGEX = /^<(a)?:([a-zA-Z0-9_-]+):(\d+)>/;
|
||||
|
||||
const PLAINTEXT_SYMBOLS = new Set(['™', '™️', '©', '©️', '®', '®️']);
|
||||
|
||||
const TEXT_PRESENTATION_MAP: Record<string, string> = {
|
||||
'™️': '™',
|
||||
'©️': '©',
|
||||
'®️': '®',
|
||||
};
|
||||
|
||||
const NEEDS_VARIATION_SELECTOR_CACHE = new Map<number, boolean>();
|
||||
|
||||
const SPECIAL_SHORTCODES: Record<string, string> = {
|
||||
tm: '™',
|
||||
copyright: '©',
|
||||
registered: '®',
|
||||
};
|
||||
|
||||
const EMOJI_NAME_CACHE = new Map<string, string | null>();
|
||||
const EMOJI_BY_NAME_CACHE = new Map<string, UnicodeEmoji | null>();
|
||||
|
||||
let globalEmojiConfig: EmojiParserConfig | null = null;
|
||||
|
||||
export function setEmojiParserConfig(config: EmojiParserConfig): void {
|
||||
globalEmojiConfig = config;
|
||||
}
|
||||
|
||||
export function getEmojiParserConfig(): EmojiParserConfig | null {
|
||||
return globalEmojiConfig;
|
||||
}
|
||||
|
||||
function needsVariationSelector(codePoint: number): boolean {
|
||||
if (NEEDS_VARIATION_SELECTOR_CACHE.has(codePoint)) {
|
||||
return NEEDS_VARIATION_SELECTOR_CACHE.get(codePoint)!;
|
||||
}
|
||||
|
||||
const result =
|
||||
(codePoint >= 0x2190 && codePoint <= 0x21ff) ||
|
||||
(codePoint >= 0x2300 && codePoint <= 0x23ff) ||
|
||||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
|
||||
(codePoint >= 0x2900 && codePoint <= 0x297f);
|
||||
|
||||
NEEDS_VARIATION_SELECTOR_CACHE.set(codePoint, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function removeVariationSelectors(text: string): string {
|
||||
if (text.length < 2 || text.indexOf('\uFE0F') === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let result = '';
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (text.charCodeAt(i) === 0x2122 && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
|
||||
result += '™';
|
||||
i += 2;
|
||||
} else if (text.charCodeAt(i) === 0xa9 && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
|
||||
result += '©';
|
||||
i += 2;
|
||||
} else if (text.charCodeAt(i) === 0xae && i + 1 < text.length && text.charCodeAt(i + 1) === 0xfe0f) {
|
||||
result += '®';
|
||||
i += 2;
|
||||
} else {
|
||||
result += text.charAt(i);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function defaultConvertToCodePoints(emoji: string): string {
|
||||
const containsZWJ = emoji.includes('\u200D');
|
||||
const processedEmoji = containsZWJ ? emoji : emoji.replace(/\uFE0F/g, '');
|
||||
return Array.from(processedEmoji)
|
||||
.map((char) => char.codePointAt(0)?.toString(16).replace(/^0+/, '') || '')
|
||||
.join('-');
|
||||
}
|
||||
|
||||
export function parseStandardEmoji(text: string, start: number): ParserResult | null {
|
||||
if (!globalEmojiConfig?.emojiProvider || !globalEmojiConfig?.emojiRegex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!text || start >= text.length || text.length - start < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstChar = text.charAt(start);
|
||||
if (PLAINTEXT_SYMBOLS.has(firstChar)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstCharCode = text.charCodeAt(start);
|
||||
if (
|
||||
firstCharCode < 0x80 &&
|
||||
firstCharCode !== 0x23 &&
|
||||
firstCharCode !== 0x2a &&
|
||||
firstCharCode !== 0x30 &&
|
||||
firstCharCode !== 0x31 &&
|
||||
(firstCharCode < 0x32 || firstCharCode > 0x39)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const emojiRegex = globalEmojiConfig.emojiRegex;
|
||||
emojiRegex.lastIndex = 0;
|
||||
const match = emojiRegex.exec(text.slice(start));
|
||||
|
||||
if (match && match.index === 0) {
|
||||
const candidate = match[0];
|
||||
|
||||
if (PLAINTEXT_SYMBOLS.has(candidate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PLAINTEXT_SYMBOLS.has(candidate)) {
|
||||
const textPresentation = TEXT_PRESENTATION_MAP[candidate];
|
||||
if (textPresentation) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: textPresentation},
|
||||
advance: candidate.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Text, content: candidate},
|
||||
advance: candidate.length,
|
||||
};
|
||||
}
|
||||
|
||||
const hasVariationSelector = candidate.indexOf('\uFE0F') !== -1;
|
||||
const codePoint = candidate.codePointAt(0) || 0;
|
||||
|
||||
const isDingbat = codePoint >= 0x2600 && codePoint <= 0x27bf;
|
||||
if (!isDingbat && needsVariationSelector(codePoint) && !hasVariationSelector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let name = EMOJI_NAME_CACHE.get(candidate);
|
||||
if (name === undefined) {
|
||||
name = globalEmojiConfig.emojiProvider.getSurrogateName(candidate);
|
||||
EMOJI_NAME_CACHE.set(candidate, name);
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const convertToCodePoints = globalEmojiConfig.convertToCodePoints || defaultConvertToCodePoints;
|
||||
const codepoints = convertToCodePoints(candidate);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: candidate,
|
||||
codepoints,
|
||||
name,
|
||||
},
|
||||
},
|
||||
advance: candidate.length,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const SKIN_TONE_SUFFIX_REGEX = /^:skin-tone-([1-5]):/;
|
||||
|
||||
export function parseEmojiShortcode(text: string): ParserResult | null {
|
||||
if (!text.startsWith(':') || text.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const endPos = text.indexOf(':', 1);
|
||||
if (endPos === -1 || endPos === 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseName = text.substring(1, endPos);
|
||||
|
||||
const specialSymbol = SPECIAL_SHORTCODES[baseName];
|
||||
if (specialSymbol) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: specialSymbol},
|
||||
advance: endPos + 1,
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalEmojiConfig?.emojiProvider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!baseName || !VALID_EMOJI_NAME_REGEX.test(baseName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let emoji = EMOJI_BY_NAME_CACHE.get(baseName);
|
||||
if (emoji === undefined) {
|
||||
emoji = globalEmojiConfig.emojiProvider.findEmojiByName(baseName);
|
||||
EMOJI_BY_NAME_CACHE.set(baseName, emoji);
|
||||
}
|
||||
|
||||
if (!emoji) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const emojiSurrogate = emoji.surrogates;
|
||||
if (PLAINTEXT_SYMBOLS.has(emojiSurrogate)) {
|
||||
const textPresentation = TEXT_PRESENTATION_MAP[emojiSurrogate];
|
||||
if (textPresentation) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: textPresentation},
|
||||
advance: endPos + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Text, content: emojiSurrogate},
|
||||
advance: endPos + 1,
|
||||
};
|
||||
}
|
||||
|
||||
let finalEmoji = emoji;
|
||||
let totalAdvance = endPos + 1;
|
||||
|
||||
const afterEmoji = text.slice(endPos + 1);
|
||||
const skinToneMatch = SKIN_TONE_SUFFIX_REGEX.exec(afterEmoji);
|
||||
if (skinToneMatch && globalEmojiConfig.skinToneSurrogates) {
|
||||
const skinTone = Number.parseInt(skinToneMatch[1], 10);
|
||||
const skinToneKey = `${baseName}:tone-${skinTone}`;
|
||||
let skinToneEmoji = EMOJI_BY_NAME_CACHE.get(skinToneKey);
|
||||
|
||||
if (skinToneEmoji === undefined) {
|
||||
const skinToneSurrogate = globalEmojiConfig.skinToneSurrogates[skinTone - 1];
|
||||
skinToneEmoji = globalEmojiConfig.emojiProvider.findEmojiWithSkinTone(baseName, skinToneSurrogate);
|
||||
EMOJI_BY_NAME_CACHE.set(skinToneKey, skinToneEmoji);
|
||||
}
|
||||
|
||||
if (skinToneEmoji) {
|
||||
finalEmoji = skinToneEmoji;
|
||||
totalAdvance += skinToneMatch[0].length;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalEmoji) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const convertToCodePoints = globalEmojiConfig.convertToCodePoints || defaultConvertToCodePoints;
|
||||
const codepoints = convertToCodePoints(finalEmoji.surrogates);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Standard,
|
||||
raw: finalEmoji.surrogates,
|
||||
codepoints,
|
||||
name: baseName,
|
||||
},
|
||||
},
|
||||
advance: totalAdvance,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseCustomEmoji(text: string): ParserResult | null {
|
||||
if (!(text.startsWith('<:') || text.startsWith('<a:'))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lastIdx = text.indexOf('>');
|
||||
if (lastIdx === -1 || lastIdx < 4) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = CUSTOM_EMOJI_REGEX.exec(text);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const animated = Boolean(match[1]);
|
||||
const name = match[2];
|
||||
const id = match[3];
|
||||
const advance = match[0].length;
|
||||
|
||||
if (!name || !id || id.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
const charCode = id.charCodeAt(i);
|
||||
if (charCode < 48 || charCode > 57) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Emoji,
|
||||
kind: {
|
||||
kind: EmojiKind.Custom,
|
||||
name,
|
||||
id,
|
||||
animated,
|
||||
},
|
||||
},
|
||||
advance,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyTextPresentation(node: Node): void {
|
||||
if (node && node.type === NodeType.Text && typeof node.content === 'string') {
|
||||
if (node.content.indexOf('\uFE0F') !== -1) {
|
||||
node.content = removeVariationSelectors(node.content);
|
||||
}
|
||||
} else if (node && 'children' in node && Array.isArray(node.children)) {
|
||||
for (const child of node.children) {
|
||||
applyTextPresentation(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
856
packages/markdown_parser/src/parsers/InlineParsers.tsx
Normal file
856
packages/markdown_parser/src/parsers/InlineParsers.tsx
Normal file
@@ -0,0 +1,856 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {FormattingContext} from '@fluxer/markdown_parser/src/parser/FormattingContext';
|
||||
import * as EmojiParsers from '@fluxer/markdown_parser/src/parsers/EmojiParsers';
|
||||
import * as LinkParsers from '@fluxer/markdown_parser/src/parsers/LinkParsers';
|
||||
import * as MentionParsers from '@fluxer/markdown_parser/src/parsers/MentionParsers';
|
||||
import * as TimestampParsers from '@fluxer/markdown_parser/src/parsers/TimestampParsers';
|
||||
import {MentionKind, NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import {MAX_LINE_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
|
||||
import type {Node, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
import * as ASTUtils from '@fluxer/markdown_parser/src/utils/AstUtils';
|
||||
import * as StringUtils from '@fluxer/markdown_parser/src/utils/StringUtils';
|
||||
|
||||
const BACKSLASH = 92;
|
||||
const UNDERSCORE = 95;
|
||||
const ASTERISK = 42;
|
||||
const TILDE = 126;
|
||||
const PIPE = 124;
|
||||
const BACKTICK = 96;
|
||||
const LESS_THAN = 60;
|
||||
const AT_SIGN = 64;
|
||||
const HASH = 35;
|
||||
const SLASH = 47;
|
||||
const OPEN_BRACKET = 91;
|
||||
const COLON = 58;
|
||||
const LETTER_A = 97;
|
||||
const LETTER_I = 105;
|
||||
const LETTER_M = 109;
|
||||
const LETTER_S = 115;
|
||||
const LETTER_T = 116;
|
||||
const PLUS_SIGN = 43;
|
||||
|
||||
const FORMATTING_CHARS = new Set([ASTERISK, UNDERSCORE, TILDE, PIPE, BACKTICK]);
|
||||
|
||||
const parseInlineCache = new Map<string, Array<Node>>();
|
||||
const formattingMarkerCache = new Map<string, ReturnType<typeof getFormattingMarkerInfo>>();
|
||||
const MAX_CACHE_SIZE = 500;
|
||||
const cacheHitCount = new Map<string, number>();
|
||||
|
||||
export function parseInline(text: string, parserFlags: number): Array<Node> {
|
||||
if (!text || text.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const cacheKey = `${text}:${parserFlags}`;
|
||||
if (parseInlineCache.has(cacheKey)) {
|
||||
const cachedResult = parseInlineCache.get(cacheKey)!;
|
||||
|
||||
const hitCount = cacheHitCount.get(cacheKey) || 0;
|
||||
cacheHitCount.set(cacheKey, hitCount + 1);
|
||||
|
||||
return [...cachedResult];
|
||||
}
|
||||
|
||||
const context = new FormattingContext();
|
||||
|
||||
const nodes = parseInlineWithContext(text, context, parserFlags);
|
||||
|
||||
ASTUtils.flattenAST(nodes);
|
||||
|
||||
if (text.length < 1000) {
|
||||
parseInlineCache.set(cacheKey, [...nodes]);
|
||||
cacheHitCount.set(cacheKey, 1);
|
||||
|
||||
if (parseInlineCache.size > MAX_CACHE_SIZE) {
|
||||
const entries = Array.from(cacheHitCount.entries())
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.slice(0, 100);
|
||||
|
||||
for (const [key] of entries) {
|
||||
parseInlineCache.delete(key);
|
||||
cacheHitCount.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function parseInlineWithContext(text: string, context: FormattingContext, parserFlags: number): Array<Node> {
|
||||
if (!text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const nodes: Array<Node> = [];
|
||||
let accumulatedText = '';
|
||||
let position = 0;
|
||||
|
||||
const textLength = text.length;
|
||||
|
||||
let characters: Array<string> | null = null;
|
||||
|
||||
while (position < textLength) {
|
||||
const currentChar = text.charAt(position);
|
||||
const currentCharCode = text.charCodeAt(position);
|
||||
|
||||
if (currentCharCode === BACKSLASH && position + 1 < textLength) {
|
||||
const nextChar = text.charAt(position + 1);
|
||||
|
||||
if (nextChar === '_' && position > 0 && text.charAt(position - 1) === '¯') {
|
||||
accumulatedText += `\\${nextChar}`;
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (StringUtils.isEscapableCharacter(nextChar)) {
|
||||
accumulatedText += nextChar;
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const remainingText = text.slice(position);
|
||||
|
||||
const insideQuotedAngleBracket = accumulatedText.endsWith('<"') || accumulatedText.endsWith("<'");
|
||||
|
||||
if (
|
||||
!insideQuotedAngleBracket &&
|
||||
parserFlags & ParserFlags.ALLOW_AUTOLINKS &&
|
||||
StringUtils.startsWithUrl(remainingText)
|
||||
) {
|
||||
const urlResult = LinkParsers.extractUrlSegment(remainingText, parserFlags);
|
||||
|
||||
if (urlResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(urlResult.node);
|
||||
position += urlResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCharCode === UNDERSCORE) {
|
||||
if (characters == null) {
|
||||
characters = [...text];
|
||||
}
|
||||
const isDoubleUnderscore = position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE;
|
||||
if (!isDoubleUnderscore) {
|
||||
const isWordUnderscore = StringUtils.isWordUnderscore(characters, position);
|
||||
if (isWordUnderscore) {
|
||||
accumulatedText += '_';
|
||||
position += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emojiResult = EmojiParsers.parseStandardEmoji(text, position);
|
||||
if (emojiResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(emojiResult.node);
|
||||
position += emojiResult.advance;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentCharCode === LESS_THAN && position + 2 < textLength) {
|
||||
const nextCharCode = text.charCodeAt(position + 1);
|
||||
const thirdCharCode = position + 2 < textLength ? text.charCodeAt(position + 2) : 0;
|
||||
if (nextCharCode === COLON || (nextCharCode === LETTER_A && thirdCharCode === COLON)) {
|
||||
const customEmojiResult = EmojiParsers.parseCustomEmoji(remainingText);
|
||||
|
||||
if (customEmojiResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(customEmojiResult.node);
|
||||
position += customEmojiResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentCharCode === LESS_THAN &&
|
||||
position + 3 < textLength &&
|
||||
text.charCodeAt(position + 1) === LETTER_T &&
|
||||
text.charCodeAt(position + 2) === COLON
|
||||
) {
|
||||
const timestampResult = TimestampParsers.parseTimestamp(remainingText);
|
||||
|
||||
if (timestampResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(timestampResult.node);
|
||||
position += timestampResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCharCode === COLON) {
|
||||
const shortcodeEmojiResult = EmojiParsers.parseEmojiShortcode(remainingText);
|
||||
|
||||
if (shortcodeEmojiResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(shortcodeEmojiResult.node);
|
||||
position += shortcodeEmojiResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentCharCode === LESS_THAN &&
|
||||
position + 1 < textLength &&
|
||||
text.charCodeAt(position + 1) === PLUS_SIGN &&
|
||||
parserFlags & ParserFlags.ALLOW_AUTOLINKS
|
||||
) {
|
||||
const phoneResult = LinkParsers.parsePhoneLink(remainingText, parserFlags);
|
||||
|
||||
if (phoneResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(phoneResult.node);
|
||||
position += phoneResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentCharCode === LESS_THAN &&
|
||||
position + 4 < textLength &&
|
||||
text.charCodeAt(position + 1) === LETTER_S &&
|
||||
text.charCodeAt(position + 2) === LETTER_M &&
|
||||
text.charCodeAt(position + 3) === LETTER_S &&
|
||||
text.charCodeAt(position + 4) === COLON &&
|
||||
parserFlags & ParserFlags.ALLOW_AUTOLINKS
|
||||
) {
|
||||
const smsResult = LinkParsers.parseSmsLink(remainingText, parserFlags);
|
||||
|
||||
if (smsResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(smsResult.node);
|
||||
position += smsResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCharCode === LESS_THAN && position + 1 < textLength) {
|
||||
const nextCharCode = text.charCodeAt(position + 1);
|
||||
|
||||
if (nextCharCode === AT_SIGN || nextCharCode === HASH || nextCharCode === SLASH || nextCharCode === LETTER_I) {
|
||||
if (
|
||||
nextCharCode === AT_SIGN &&
|
||||
position + 2 < textLength &&
|
||||
text.charCodeAt(position + 2) === 38 &&
|
||||
parserFlags & ParserFlags.ALLOW_ROLE_MENTIONS
|
||||
) {
|
||||
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
|
||||
if (mentionResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(mentionResult.node);
|
||||
position += mentionResult.advance;
|
||||
continue;
|
||||
}
|
||||
} else if (nextCharCode === AT_SIGN && parserFlags & ParserFlags.ALLOW_USER_MENTIONS) {
|
||||
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
|
||||
if (mentionResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(mentionResult.node);
|
||||
position += mentionResult.advance;
|
||||
continue;
|
||||
}
|
||||
} else if (nextCharCode === HASH && parserFlags & ParserFlags.ALLOW_CHANNEL_MENTIONS) {
|
||||
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
|
||||
if (mentionResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(mentionResult.node);
|
||||
position += mentionResult.advance;
|
||||
continue;
|
||||
}
|
||||
} else if (nextCharCode === SLASH && parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) {
|
||||
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
|
||||
if (mentionResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(mentionResult.node);
|
||||
position += mentionResult.advance;
|
||||
continue;
|
||||
}
|
||||
} else if (
|
||||
nextCharCode === LETTER_I &&
|
||||
remainingText.startsWith('<id:') &&
|
||||
parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS
|
||||
) {
|
||||
const mentionResult = MentionParsers.parseMention(remainingText, parserFlags);
|
||||
if (mentionResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(mentionResult.node);
|
||||
position += mentionResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parserFlags & ParserFlags.ALLOW_AUTOLINKS) {
|
||||
const autolinkResult = LinkParsers.parseAutolink(remainingText, parserFlags);
|
||||
|
||||
if (autolinkResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(autolinkResult.node);
|
||||
position += autolinkResult.advance;
|
||||
continue;
|
||||
}
|
||||
|
||||
const emailLinkResult = LinkParsers.parseEmailLink(remainingText, parserFlags);
|
||||
|
||||
if (emailLinkResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(emailLinkResult.node);
|
||||
position += emailLinkResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentCharCode === AT_SIGN && parserFlags & ParserFlags.ALLOW_EVERYONE_MENTIONS) {
|
||||
const isEscaped = position > 0 && text.charCodeAt(position - 1) === BACKSLASH;
|
||||
|
||||
if (!isEscaped && remainingText.startsWith('@everyone')) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push({
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Everyone},
|
||||
});
|
||||
position += 9;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isEscaped && remainingText.startsWith('@here')) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push({
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Here},
|
||||
});
|
||||
position += 5;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const isDoubleUnderscore =
|
||||
currentCharCode === UNDERSCORE && position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE;
|
||||
|
||||
if (
|
||||
(FORMATTING_CHARS.has(currentCharCode) || currentCharCode === OPEN_BRACKET) &&
|
||||
(isDoubleUnderscore ||
|
||||
!(
|
||||
currentCharCode === UNDERSCORE &&
|
||||
accumulatedText.length > 0 &&
|
||||
StringUtils.isAlphaNumeric(accumulatedText.charCodeAt(accumulatedText.length - 1))
|
||||
))
|
||||
) {
|
||||
context.setCurrentText(accumulatedText);
|
||||
|
||||
const specialResult = parseSpecialSequence(remainingText, context, parserFlags);
|
||||
|
||||
if (specialResult) {
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
}
|
||||
nodes.push(specialResult.node);
|
||||
position += specialResult.advance;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
accumulatedText += currentChar;
|
||||
position += 1;
|
||||
|
||||
if (accumulatedText.length > MAX_LINE_LENGTH) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
accumulatedText = '';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (accumulatedText.length > 0) {
|
||||
ASTUtils.addTextNode(nodes, accumulatedText);
|
||||
}
|
||||
|
||||
const result = ASTUtils.mergeTextNodes(nodes);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseSpecialSequence(text: string, context: FormattingContext, parserFlags: number): ParserResult | null {
|
||||
if (text.length === 0) return null;
|
||||
|
||||
const firstCharCode = text.charCodeAt(0);
|
||||
|
||||
switch (firstCharCode) {
|
||||
case LESS_THAN:
|
||||
if (text.length > 1) {
|
||||
const nextCharCode = text.charCodeAt(1);
|
||||
|
||||
if (nextCharCode === SLASH) {
|
||||
if (parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) {
|
||||
const mentionResult = MentionParsers.parseMention(text, parserFlags);
|
||||
|
||||
if (mentionResult) return mentionResult;
|
||||
}
|
||||
} else if (nextCharCode === LETTER_I && text.startsWith('<id:')) {
|
||||
if (parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS) {
|
||||
const mentionResult = MentionParsers.parseMention(text, parserFlags);
|
||||
|
||||
if (mentionResult) return mentionResult;
|
||||
}
|
||||
} else if (nextCharCode === PLUS_SIGN && parserFlags & ParserFlags.ALLOW_AUTOLINKS) {
|
||||
const phoneResult = LinkParsers.parsePhoneLink(text, parserFlags);
|
||||
|
||||
if (phoneResult) return phoneResult;
|
||||
} else if (
|
||||
nextCharCode === LETTER_S &&
|
||||
text.length > 4 &&
|
||||
text.charCodeAt(2) === LETTER_S &&
|
||||
text.charCodeAt(3) === COLON &&
|
||||
parserFlags & ParserFlags.ALLOW_AUTOLINKS
|
||||
) {
|
||||
const smsResult = LinkParsers.parseSmsLink(text, parserFlags);
|
||||
|
||||
if (smsResult) return smsResult;
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case ASTERISK:
|
||||
case UNDERSCORE:
|
||||
case TILDE:
|
||||
case PIPE:
|
||||
case BACKTICK: {
|
||||
const formattingResult = parseFormatting(text, context, parserFlags);
|
||||
|
||||
if (formattingResult) return formattingResult;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case AT_SIGN:
|
||||
if (parserFlags & ParserFlags.ALLOW_EVERYONE_MENTIONS) {
|
||||
if (text.startsWith('@everyone')) {
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Everyone},
|
||||
},
|
||||
advance: 9,
|
||||
};
|
||||
}
|
||||
|
||||
if (text.startsWith('@here')) {
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Here},
|
||||
},
|
||||
advance: 5,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case OPEN_BRACKET: {
|
||||
const timestampResult = TimestampParsers.parseTimestamp(text);
|
||||
|
||||
if (timestampResult) return timestampResult;
|
||||
|
||||
if (parserFlags & ParserFlags.ALLOW_MASKED_LINKS) {
|
||||
const linkResult = LinkParsers.parseLink(text, parserFlags, (t) => parseInline(t, parserFlags));
|
||||
|
||||
if (linkResult) return linkResult;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (firstCharCode !== OPEN_BRACKET) {
|
||||
const timestampResult = TimestampParsers.parseTimestamp(text);
|
||||
|
||||
if (timestampResult) return timestampResult;
|
||||
}
|
||||
|
||||
if (firstCharCode !== LESS_THAN && firstCharCode !== OPEN_BRACKET && parserFlags & ParserFlags.ALLOW_MASKED_LINKS) {
|
||||
const linkResult = LinkParsers.parseLink(text, parserFlags, (t) => parseInline(t, parserFlags));
|
||||
|
||||
if (linkResult) return linkResult;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseFormatting(text: string, context: FormattingContext, parserFlags: number): ParserResult | null {
|
||||
if (text.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let markerInfo: FormattingMarkerInfo | null | undefined;
|
||||
const prefix = text.slice(0, Math.min(3, text.length));
|
||||
|
||||
if (formattingMarkerCache.has(prefix)) {
|
||||
markerInfo = formattingMarkerCache.get(prefix);
|
||||
const hitCount = cacheHitCount.get(prefix) || 0;
|
||||
cacheHitCount.set(prefix, hitCount + 1);
|
||||
} else {
|
||||
markerInfo = getFormattingMarkerInfo(text);
|
||||
formattingMarkerCache.set(prefix, markerInfo);
|
||||
cacheHitCount.set(prefix, 1);
|
||||
|
||||
if (formattingMarkerCache.size > MAX_CACHE_SIZE) {
|
||||
const entries = Array.from(cacheHitCount.entries())
|
||||
.filter(([key]) => formattingMarkerCache.has(key))
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.slice(0, 50);
|
||||
|
||||
for (const [key] of entries) {
|
||||
formattingMarkerCache.delete(key);
|
||||
cacheHitCount.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!markerInfo) return null;
|
||||
|
||||
const {marker, nodeType, markerLength} = markerInfo;
|
||||
|
||||
if (nodeType === NodeType.Spoiler && !(parserFlags & ParserFlags.ALLOW_SPOILERS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!context.canEnterFormatting(marker[0], marker.length > 1)) return null;
|
||||
|
||||
const endResult = findFormattingEnd(text, marker, markerLength, nodeType);
|
||||
|
||||
if (!endResult) return null;
|
||||
|
||||
const {endPosition, innerContent} = endResult;
|
||||
const isBlock = context.isFormattingActive(marker[0], marker.length > 1);
|
||||
|
||||
const formattingNode = createFormattingNode(
|
||||
nodeType,
|
||||
innerContent,
|
||||
marker,
|
||||
isBlock,
|
||||
(text: string, ctx: FormattingContext) => parseInlineWithContext(text, ctx, parserFlags),
|
||||
);
|
||||
|
||||
return {node: formattingNode, advance: endPosition + markerLength};
|
||||
}
|
||||
|
||||
interface FormattingMarkerInfo {
|
||||
marker: string;
|
||||
nodeType: NodeType;
|
||||
markerLength: number;
|
||||
}
|
||||
|
||||
function getFormattingMarkerInfo(text: string): FormattingMarkerInfo | null {
|
||||
if (!text || text.length === 0) return null;
|
||||
|
||||
const firstCharCode = text.charCodeAt(0);
|
||||
|
||||
if (!FORMATTING_CHARS.has(firstCharCode)) return null;
|
||||
|
||||
const secondCharCode = text.length > 1 ? text.charCodeAt(1) : 0;
|
||||
const thirdCharCode = text.length > 2 ? text.charCodeAt(2) : 0;
|
||||
|
||||
if (firstCharCode === ASTERISK && secondCharCode === ASTERISK && thirdCharCode === ASTERISK) {
|
||||
return {marker: '***', nodeType: NodeType.Emphasis, markerLength: 3};
|
||||
}
|
||||
if (firstCharCode === UNDERSCORE && secondCharCode === UNDERSCORE && thirdCharCode === UNDERSCORE) {
|
||||
return {marker: '___', nodeType: NodeType.Emphasis, markerLength: 3};
|
||||
}
|
||||
|
||||
if (firstCharCode === PIPE && secondCharCode === PIPE) {
|
||||
return {marker: '||', nodeType: NodeType.Spoiler, markerLength: 2};
|
||||
}
|
||||
if (firstCharCode === TILDE && secondCharCode === TILDE) {
|
||||
return {marker: '~~', nodeType: NodeType.Strikethrough, markerLength: 2};
|
||||
}
|
||||
if (firstCharCode === ASTERISK && secondCharCode === ASTERISK) {
|
||||
return {marker: '**', nodeType: NodeType.Strong, markerLength: 2};
|
||||
}
|
||||
if (firstCharCode === UNDERSCORE && secondCharCode === UNDERSCORE) {
|
||||
return {marker: '__', nodeType: NodeType.Underline, markerLength: 2};
|
||||
}
|
||||
|
||||
if (firstCharCode === BACKTICK) {
|
||||
let backtickCount = 1;
|
||||
while (backtickCount < text.length && text.charCodeAt(backtickCount) === BACKTICK) {
|
||||
backtickCount++;
|
||||
}
|
||||
return {marker: '`'.repeat(backtickCount), nodeType: NodeType.InlineCode, markerLength: backtickCount};
|
||||
}
|
||||
if (firstCharCode === ASTERISK) {
|
||||
return {marker: '*', nodeType: NodeType.Emphasis, markerLength: 1};
|
||||
}
|
||||
if (firstCharCode === UNDERSCORE) {
|
||||
return {marker: '_', nodeType: NodeType.Emphasis, markerLength: 1};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findFormattingEnd(
|
||||
text: string,
|
||||
marker: string,
|
||||
markerLength: number,
|
||||
nodeType: NodeType,
|
||||
): {endPosition: number; innerContent: string} | null {
|
||||
let position = markerLength;
|
||||
let nestedLevel = 0;
|
||||
let endPosition: number | null = null;
|
||||
const textLength = text.length;
|
||||
|
||||
if (textLength < markerLength * 2) return null;
|
||||
|
||||
if (nodeType === NodeType.InlineCode && markerLength > 1) {
|
||||
while (position < textLength) {
|
||||
if (text.charCodeAt(position) === BACKTICK) {
|
||||
let backtickCount = 0;
|
||||
let checkPos = position;
|
||||
while (checkPos < textLength && text.charCodeAt(checkPos) === BACKTICK) {
|
||||
backtickCount++;
|
||||
checkPos++;
|
||||
}
|
||||
|
||||
if (backtickCount === markerLength) {
|
||||
endPosition = position;
|
||||
break;
|
||||
}
|
||||
|
||||
position = checkPos;
|
||||
continue;
|
||||
}
|
||||
position++;
|
||||
if (position > MAX_LINE_LENGTH) break;
|
||||
}
|
||||
|
||||
if (endPosition == null) return null;
|
||||
|
||||
return {
|
||||
endPosition,
|
||||
innerContent: text.slice(markerLength, endPosition),
|
||||
};
|
||||
}
|
||||
|
||||
if (markerLength === 1 && (nodeType === NodeType.Emphasis || nodeType === NodeType.InlineCode)) {
|
||||
const markerChar = marker.charCodeAt(0);
|
||||
while (position < textLength) {
|
||||
const currentChar = text.charCodeAt(position);
|
||||
|
||||
if (currentChar === BACKSLASH && position + 1 < textLength) {
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentChar === markerChar) {
|
||||
if (markerChar === BACKTICK && position + 1 < textLength && text.charCodeAt(position + 1) === BACKTICK) {
|
||||
let checkPos = position;
|
||||
while (checkPos < textLength && text.charCodeAt(checkPos) === BACKTICK) {
|
||||
checkPos++;
|
||||
}
|
||||
position = checkPos;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (markerChar === UNDERSCORE && position + 1 < textLength && text.charCodeAt(position + 1) === UNDERSCORE) {
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
endPosition = position;
|
||||
break;
|
||||
}
|
||||
|
||||
position++;
|
||||
if (position > MAX_LINE_LENGTH) break;
|
||||
}
|
||||
|
||||
if (endPosition == null) return null;
|
||||
|
||||
return {
|
||||
endPosition,
|
||||
innerContent: text.slice(markerLength, endPosition),
|
||||
};
|
||||
}
|
||||
|
||||
if (nodeType === NodeType.InlineCode) {
|
||||
while (position < textLength) {
|
||||
if (text.charCodeAt(position) === BACKTICK) {
|
||||
endPosition = position;
|
||||
break;
|
||||
}
|
||||
position++;
|
||||
if (position > MAX_LINE_LENGTH) break;
|
||||
}
|
||||
} else {
|
||||
const firstMarkerChar = marker.charCodeAt(0);
|
||||
const isDoubleMarker = marker.length > 1;
|
||||
|
||||
while (position < textLength) {
|
||||
if (text.charCodeAt(position) === BACKSLASH && position + 1 < textLength) {
|
||||
position += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
let isClosingMarker = true;
|
||||
if (position + marker.length <= textLength) {
|
||||
for (let i = 0; i < marker.length; i++) {
|
||||
if (text.charCodeAt(position + i) !== marker.charCodeAt(i)) {
|
||||
isClosingMarker = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isClosingMarker = false;
|
||||
}
|
||||
|
||||
if (isClosingMarker) {
|
||||
if (nestedLevel === 0) {
|
||||
if (nodeType === NodeType.Spoiler && position === markerLength && position + marker.length < textLength) {
|
||||
position += 1;
|
||||
continue;
|
||||
}
|
||||
endPosition = position;
|
||||
break;
|
||||
}
|
||||
nestedLevel--;
|
||||
position += marker.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isDoubleMarker &&
|
||||
position + 1 < textLength &&
|
||||
text.charCodeAt(position) === firstMarkerChar &&
|
||||
text.charCodeAt(position + 1) === firstMarkerChar
|
||||
) {
|
||||
nestedLevel++;
|
||||
}
|
||||
|
||||
position++;
|
||||
if (position > MAX_LINE_LENGTH) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endPosition == null) return null;
|
||||
|
||||
const innerContent = text.slice(markerLength, endPosition);
|
||||
return {endPosition, innerContent};
|
||||
}
|
||||
|
||||
type NodeWithChildren = Extract<Node, {children: Array<Node>}>;
|
||||
type FormattingNodeType = NodeWithChildren['type'];
|
||||
|
||||
function createFormattingNode(
|
||||
nodeType: NodeType,
|
||||
innerContent: string,
|
||||
marker: string,
|
||||
isBlock: boolean,
|
||||
parseInlineWithContext: (text: string, context: FormattingContext) => Array<Node>,
|
||||
): Node {
|
||||
if (nodeType === NodeType.InlineCode) {
|
||||
return {type: NodeType.InlineCode, content: innerContent};
|
||||
}
|
||||
|
||||
if (innerContent.length === 0) {
|
||||
return {
|
||||
type: nodeType as FormattingNodeType,
|
||||
children: [],
|
||||
...(isBlock ? {isBlock} : {}),
|
||||
} as NodeWithChildren;
|
||||
}
|
||||
|
||||
const newContext = new FormattingContext();
|
||||
newContext.pushFormatting(marker[0], marker.length > 1);
|
||||
|
||||
if (marker === '***' || marker === '___') {
|
||||
const emphasisContext = new FormattingContext();
|
||||
emphasisContext.pushFormatting('*', true);
|
||||
|
||||
const innerNodes = parseInlineWithContext(innerContent, emphasisContext);
|
||||
|
||||
return {
|
||||
type: NodeType.Emphasis,
|
||||
children: [{type: NodeType.Strong, children: innerNodes}],
|
||||
};
|
||||
}
|
||||
|
||||
const innerNodes = parseInlineWithContext(innerContent, newContext);
|
||||
|
||||
return {
|
||||
type: nodeType as FormattingNodeType,
|
||||
children: innerNodes,
|
||||
...(isBlock || nodeType === NodeType.Spoiler ? {isBlock} : {}),
|
||||
} as NodeWithChildren;
|
||||
}
|
||||
513
packages/markdown_parser/src/parsers/LinkParsers.tsx
Normal file
513
packages/markdown_parser/src/parsers/LinkParsers.tsx
Normal file
@@ -0,0 +1,513 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import {MAX_LINK_URL_LENGTH} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
|
||||
import type {Node, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
import * as StringUtils from '@fluxer/markdown_parser/src/utils/StringUtils';
|
||||
import * as URLUtils from '@fluxer/markdown_parser/src/utils/UrlUtils';
|
||||
|
||||
const SPOOFED_LINK_PATTERN = /^\[https?:\/\/[^\s[\]]+\]\(https?:\/\/[^\s[\]]+\)$/;
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const OPEN_BRACKET = 91;
|
||||
const CLOSE_BRACKET = 93;
|
||||
const OPEN_PAREN = 40;
|
||||
const CLOSE_PAREN = 41;
|
||||
const BACKSLASH = 92;
|
||||
const LESS_THAN = 60;
|
||||
const GREATER_THAN = 62;
|
||||
const DOUBLE_QUOTE = 34;
|
||||
const SINGLE_QUOTE = 39;
|
||||
const PLUS_SIGN = 43;
|
||||
|
||||
function containsLinkSyntax(text: string): boolean {
|
||||
const bracketIndex = text.indexOf('[');
|
||||
if (bracketIndex === -1) return false;
|
||||
|
||||
const closeBracketIndex = text.indexOf(']', bracketIndex);
|
||||
if (closeBracketIndex === -1) return false;
|
||||
|
||||
if (closeBracketIndex + 1 < text.length && text[closeBracketIndex + 1] === '(') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return containsLinkSyntax(text.substring(closeBracketIndex + 1));
|
||||
}
|
||||
|
||||
export function parseLink(
|
||||
text: string,
|
||||
_parserFlags: number,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
): ParserResult | null {
|
||||
if (text.charCodeAt(0) !== OPEN_BRACKET) return null;
|
||||
|
||||
const linkParts = extractLinkParts(text);
|
||||
|
||||
if (!linkParts) {
|
||||
if (SPOOFED_LINK_PATTERN.test(text)) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: text},
|
||||
advance: text.length,
|
||||
};
|
||||
}
|
||||
|
||||
const bracketResult = findClosingBracket(text);
|
||||
|
||||
if (bracketResult) {
|
||||
const {bracketPosition, linkText} = bracketResult;
|
||||
|
||||
if (containsLinkSyntax(linkText)) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: text},
|
||||
advance: text.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Text, content: text.slice(0, bracketPosition + 1)},
|
||||
advance: bracketPosition + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const normalizedUrl = URLUtils.normalizeUrl(linkParts.url);
|
||||
const isValid = URLUtils.isValidUrl(normalizedUrl);
|
||||
|
||||
if (isValid) {
|
||||
if (linkParts.url.startsWith('/') && !linkParts.url.startsWith('//')) {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: text.slice(0, linkParts.advanceBy)},
|
||||
advance: linkParts.advanceBy,
|
||||
};
|
||||
}
|
||||
|
||||
let finalUrl = normalizedUrl;
|
||||
|
||||
if (finalUrl.startsWith('tel:') || finalUrl.startsWith('sms:')) {
|
||||
const protocol = finalUrl.substring(0, finalUrl.indexOf(':') + 1);
|
||||
const phoneNumber = finalUrl.substring(finalUrl.indexOf(':') + 1);
|
||||
|
||||
if (phoneNumber.startsWith('+')) {
|
||||
const normalizedPhone = URLUtils.normalizePhoneNumber(phoneNumber);
|
||||
finalUrl = protocol + normalizedPhone;
|
||||
}
|
||||
} else {
|
||||
finalUrl = URLUtils.convertToAsciiUrl(finalUrl);
|
||||
}
|
||||
|
||||
const inlineNodes = parseInline(linkParts.linkText);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Link,
|
||||
text: inlineNodes.length === 1 ? inlineNodes[0] : {type: NodeType.Sequence, children: inlineNodes},
|
||||
url: finalUrl,
|
||||
escaped: linkParts.isEscaped,
|
||||
},
|
||||
advance: linkParts.advanceBy,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
node: {type: NodeType.Text, content: text.slice(0, linkParts.advanceBy)},
|
||||
advance: linkParts.advanceBy,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractLinkParts(text: string): {linkText: string; url: string; isEscaped: boolean; advanceBy: number} | null {
|
||||
const bracketResult = findClosingBracket(text);
|
||||
if (!bracketResult) return null;
|
||||
|
||||
const {bracketPosition, linkText} = bracketResult;
|
||||
|
||||
if (bracketPosition + 1 >= text.length || text.charCodeAt(bracketPosition + 1) !== OPEN_PAREN) return null;
|
||||
|
||||
const trimmedLinkText = linkText.trim();
|
||||
|
||||
if (containsLinkSyntax(trimmedLinkText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isEmailSpoofing = EMAIL_PATTERN.test(trimmedLinkText);
|
||||
|
||||
if (isEmailSpoofing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const urlInfo = extractUrl(text, bracketPosition + 2);
|
||||
if (!urlInfo) return null;
|
||||
|
||||
if (urlInfo.url.includes('"') || urlInfo.url.includes("'")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isLinkTextUrlWithProtocol = StringUtils.startsWithUrl(trimmedLinkText);
|
||||
|
||||
if (isLinkTextUrlWithProtocol) {
|
||||
if (shouldTreatAsMaskedLink(trimmedLinkText, urlInfo.url)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
linkText,
|
||||
...urlInfo,
|
||||
};
|
||||
}
|
||||
|
||||
function findClosingBracket(text: string): {bracketPosition: number; linkText: string} | null {
|
||||
let position = 1;
|
||||
let nestedBrackets = 0;
|
||||
const textLength = text.length;
|
||||
|
||||
while (position < textLength) {
|
||||
const currentChar = text.charCodeAt(position);
|
||||
|
||||
if (currentChar === OPEN_BRACKET) {
|
||||
nestedBrackets++;
|
||||
position++;
|
||||
} else if (currentChar === CLOSE_BRACKET) {
|
||||
if (nestedBrackets > 0) {
|
||||
nestedBrackets--;
|
||||
position++;
|
||||
} else {
|
||||
return {
|
||||
bracketPosition: position,
|
||||
linkText: text.slice(1, position),
|
||||
};
|
||||
}
|
||||
} else if (currentChar === BACKSLASH && position + 1 < textLength) {
|
||||
position += 2;
|
||||
} else {
|
||||
position++;
|
||||
}
|
||||
|
||||
if (position > MAX_LINK_URL_LENGTH) break;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractUrl(text: string, startPos: number): {url: string; isEscaped: boolean; advanceBy: number} | null {
|
||||
if (startPos >= text.length) return null;
|
||||
|
||||
return text.charCodeAt(startPos) === LESS_THAN
|
||||
? extractEscapedUrl(text, startPos + 1)
|
||||
: extractUnescapedUrl(text, startPos);
|
||||
}
|
||||
|
||||
function extractEscapedUrl(
|
||||
text: string,
|
||||
urlStart: number,
|
||||
): {url: string; isEscaped: boolean; advanceBy: number} | null {
|
||||
const textLength = text.length;
|
||||
let currentPos = urlStart;
|
||||
|
||||
while (currentPos < textLength) {
|
||||
if (text.charCodeAt(currentPos) === GREATER_THAN) {
|
||||
const url = text.slice(urlStart, currentPos);
|
||||
|
||||
currentPos++;
|
||||
while (currentPos < textLength && text.charCodeAt(currentPos) !== CLOSE_PAREN) {
|
||||
currentPos++;
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
isEscaped: true,
|
||||
advanceBy: currentPos + 1,
|
||||
};
|
||||
}
|
||||
currentPos++;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractUnescapedUrl(
|
||||
text: string,
|
||||
urlStart: number,
|
||||
): {url: string; isEscaped: boolean; advanceBy: number} | null {
|
||||
const textLength = text.length;
|
||||
let currentPos = urlStart;
|
||||
let nestedParens = 0;
|
||||
|
||||
while (currentPos < textLength) {
|
||||
const currentChar = text.charCodeAt(currentPos);
|
||||
|
||||
if (currentChar === OPEN_PAREN) {
|
||||
nestedParens++;
|
||||
currentPos++;
|
||||
} else if (currentChar === CLOSE_PAREN) {
|
||||
if (nestedParens > 0) {
|
||||
nestedParens--;
|
||||
currentPos++;
|
||||
} else {
|
||||
const url = text.slice(urlStart, currentPos);
|
||||
|
||||
return {
|
||||
url,
|
||||
isEscaped: false,
|
||||
advanceBy: currentPos + 1,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
currentPos++;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractUrlSegment(text: string, parserFlags: number): ParserResult | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
|
||||
|
||||
let prefixLength = 0;
|
||||
if (text.startsWith('https://')) {
|
||||
prefixLength = 8;
|
||||
} else if (text.startsWith('http://')) {
|
||||
prefixLength = 7;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
let end = prefixLength;
|
||||
const textLength = text.length;
|
||||
let parenthesesDepth = 0;
|
||||
|
||||
while (end < textLength) {
|
||||
const currentChar = text[end];
|
||||
|
||||
if (currentChar === '(') {
|
||||
parenthesesDepth++;
|
||||
end++;
|
||||
} else if (currentChar === ')') {
|
||||
if (parenthesesDepth > 0) {
|
||||
parenthesesDepth--;
|
||||
end++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (StringUtils.isUrlTerminationChar(currentChar)) {
|
||||
break;
|
||||
} else {
|
||||
end++;
|
||||
}
|
||||
|
||||
if (end - prefixLength > MAX_LINK_URL_LENGTH) {
|
||||
end = prefixLength + MAX_LINK_URL_LENGTH;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let urlString = text.slice(0, end);
|
||||
|
||||
const punctuation = '.,;:!?';
|
||||
while (
|
||||
urlString.length > 0 &&
|
||||
punctuation.includes(urlString[urlString.length - 1]) &&
|
||||
!urlString.match(/\.[a-zA-Z]{2,}$/)
|
||||
) {
|
||||
urlString = urlString.slice(0, -1);
|
||||
end--;
|
||||
}
|
||||
|
||||
const isInQuotes =
|
||||
text.charAt(0) === '"' ||
|
||||
text.charAt(0) === "'" ||
|
||||
(end < textLength && (text.charAt(end) === '"' || text.charAt(end) === "'"));
|
||||
|
||||
try {
|
||||
const normalizedUrl = URLUtils.normalizeUrl(urlString);
|
||||
const isValid = URLUtils.isValidUrl(normalizedUrl);
|
||||
|
||||
if (isValid) {
|
||||
if (normalizedUrl.startsWith('mailto:') || normalizedUrl.startsWith('tel:') || normalizedUrl.startsWith('sms:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const finalUrl = URLUtils.convertToAsciiUrl(normalizedUrl);
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Link, text: undefined, url: finalUrl, escaped: isInQuotes},
|
||||
advance: urlString.length,
|
||||
};
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseAutolink(text: string, parserFlags: number): ParserResult | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
|
||||
|
||||
if (text.charCodeAt(0) !== LESS_THAN) return null;
|
||||
|
||||
if (text.length > 1 && (text.charCodeAt(1) === DOUBLE_QUOTE || text.charCodeAt(1) === SINGLE_QUOTE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!StringUtils.startsWithUrl(text.slice(1))) return null;
|
||||
|
||||
const end = text.indexOf('>', 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
const urlString = text.slice(1, end);
|
||||
if (urlString.length > MAX_LINK_URL_LENGTH) return null;
|
||||
|
||||
try {
|
||||
const normalizedUrl = URLUtils.normalizeUrl(urlString);
|
||||
const isValid = URLUtils.isValidUrl(normalizedUrl);
|
||||
|
||||
if (isValid) {
|
||||
if (normalizedUrl.startsWith('mailto:') || normalizedUrl.startsWith('tel:') || normalizedUrl.startsWith('sms:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const finalUrl = URLUtils.convertToAsciiUrl(normalizedUrl);
|
||||
|
||||
return {
|
||||
node: {type: NodeType.Link, text: undefined, url: finalUrl, escaped: true},
|
||||
advance: end + 1,
|
||||
};
|
||||
}
|
||||
} catch (_e) {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseEmailLink(text: string, parserFlags: number): ParserResult | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
|
||||
|
||||
if (text.charCodeAt(0) !== LESS_THAN) return null;
|
||||
|
||||
const end = text.indexOf('>', 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
const content = text.slice(1, end);
|
||||
|
||||
if (content.startsWith('http://') || content.startsWith('https://')) return null;
|
||||
if (content.charCodeAt(0) === PLUS_SIGN) return null;
|
||||
if (content.indexOf('@') === -1) return null;
|
||||
|
||||
const isValid = URLUtils.isValidEmail(content);
|
||||
|
||||
if (isValid) {
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: content},
|
||||
url: `mailto:${content}`,
|
||||
escaped: true,
|
||||
},
|
||||
advance: end + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parsePhoneLink(text: string, parserFlags: number): ParserResult | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
|
||||
|
||||
if (text.charCodeAt(0) !== LESS_THAN) return null;
|
||||
|
||||
const end = text.indexOf('>', 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
const content = text.slice(1, end);
|
||||
|
||||
if (content.charCodeAt(0) !== PLUS_SIGN) return null;
|
||||
|
||||
const isValid = URLUtils.isValidPhoneNumber(content);
|
||||
|
||||
if (isValid) {
|
||||
const normalizedPhone = URLUtils.normalizePhoneNumber(content);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: content},
|
||||
url: `tel:${normalizedPhone}`,
|
||||
escaped: true,
|
||||
},
|
||||
advance: end + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseSmsLink(text: string, parserFlags: number): ParserResult | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_AUTOLINKS)) return null;
|
||||
|
||||
if (text.charCodeAt(0) !== LESS_THAN) return null;
|
||||
|
||||
if (!text.startsWith('<sms:')) return null;
|
||||
|
||||
const end = text.indexOf('>', 1);
|
||||
if (end === -1) return null;
|
||||
|
||||
const content = text.slice(1, end);
|
||||
const phoneNumber = content.slice(4);
|
||||
|
||||
if (phoneNumber.charCodeAt(0) !== PLUS_SIGN || !URLUtils.isValidPhoneNumber(phoneNumber)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPhone = URLUtils.normalizePhoneNumber(phoneNumber);
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Link,
|
||||
text: {type: NodeType.Text, content: phoneNumber},
|
||||
url: `sms:${normalizedPhone}`,
|
||||
escaped: true,
|
||||
},
|
||||
advance: end + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldTreatAsMaskedLink(trimmedLinkText: string, url: string): boolean {
|
||||
const normalizedText = trimmedLinkText.trim();
|
||||
|
||||
try {
|
||||
const normalizedUrl = URLUtils.normalizeUrl(url);
|
||||
const urlObj = new URL(normalizedUrl);
|
||||
const textUrl = new URL(normalizedText);
|
||||
|
||||
if (
|
||||
urlObj.origin === textUrl.origin &&
|
||||
urlObj.pathname === textUrl.pathname &&
|
||||
urlObj.search === textUrl.search &&
|
||||
urlObj.hash === textUrl.hash
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return true;
|
||||
}
|
||||
452
packages/markdown_parser/src/parsers/ListParsers.tsx
Normal file
452
packages/markdown_parser/src/parsers/ListParsers.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {parseCodeBlock} from '@fluxer/markdown_parser/src/parsers/BlockParsers';
|
||||
import {NodeType} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import {MAX_AST_NODES} from '@fluxer/markdown_parser/src/types/MarkdownConstants';
|
||||
import type {ListItem, ListNode, Node} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
|
||||
interface ListParseResult {
|
||||
node: ListNode;
|
||||
newLineIndex: number;
|
||||
newNodeCount: number;
|
||||
}
|
||||
|
||||
export function parseList(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
isOrdered: boolean,
|
||||
indentLevel: number,
|
||||
depth: number,
|
||||
parserFlags: number,
|
||||
nodeCount: number,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
): ListParseResult {
|
||||
const items: Array<ListItem> = [];
|
||||
const startLine = currentLineIndex;
|
||||
const firstLineContent = lines[startLine];
|
||||
let newLineIndex = currentLineIndex;
|
||||
let newNodeCount = nodeCount;
|
||||
|
||||
while (newLineIndex < lines.length) {
|
||||
if (newNodeCount > MAX_AST_NODES) break;
|
||||
const currentLine = lines[newLineIndex];
|
||||
const trimmed = currentLine.trimStart();
|
||||
|
||||
if (isBlockBreak(trimmed)) break;
|
||||
|
||||
const listMatch = matchListItem(currentLine);
|
||||
|
||||
if (listMatch) {
|
||||
const [itemOrdered, itemIndent, content, ordinal] = listMatch;
|
||||
const normalisedOrdinal = getNormalisedOrdinal(items, ordinal, isOrdered);
|
||||
|
||||
if (itemIndent < indentLevel) break;
|
||||
|
||||
if (itemIndent === indentLevel) {
|
||||
if (itemOrdered !== isOrdered) {
|
||||
if (newLineIndex === startLine) {
|
||||
const simpleList = createSimpleList(firstLineContent);
|
||||
|
||||
return {
|
||||
node: simpleList,
|
||||
newLineIndex: newLineIndex + 1,
|
||||
newNodeCount: newNodeCount + 1,
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const result = handleSameIndentLevel(
|
||||
items,
|
||||
content,
|
||||
indentLevel,
|
||||
depth,
|
||||
parseInline,
|
||||
(parentIndent, depth) => {
|
||||
const tryResult = tryParseNestedContent(
|
||||
lines,
|
||||
newLineIndex + 1,
|
||||
parentIndent,
|
||||
depth,
|
||||
(isOrdered, indentLevel, depth) =>
|
||||
parseList(
|
||||
lines,
|
||||
newLineIndex + 1,
|
||||
isOrdered,
|
||||
indentLevel,
|
||||
depth,
|
||||
parserFlags,
|
||||
newNodeCount,
|
||||
parseInline,
|
||||
),
|
||||
);
|
||||
|
||||
return tryResult;
|
||||
},
|
||||
newLineIndex,
|
||||
normalisedOrdinal,
|
||||
);
|
||||
|
||||
newLineIndex = result.newLineIndex;
|
||||
newNodeCount = result.newNodeCount;
|
||||
} else if (itemIndent === indentLevel + 1) {
|
||||
const result = handleNestedIndentLevel(
|
||||
items,
|
||||
currentLine,
|
||||
itemOrdered,
|
||||
itemIndent,
|
||||
depth,
|
||||
(isOrdered, indentLevel, depth) =>
|
||||
parseList(lines, newLineIndex, isOrdered, indentLevel, depth, parserFlags, newNodeCount, parseInline),
|
||||
newLineIndex,
|
||||
newNodeCount,
|
||||
);
|
||||
|
||||
newLineIndex = result.newLineIndex;
|
||||
newNodeCount = result.newNodeCount;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (isBulletPointText(currentLine)) {
|
||||
const result = handleBulletPointText(items, currentLine, newLineIndex, newNodeCount);
|
||||
|
||||
newLineIndex = result.newLineIndex;
|
||||
newNodeCount = result.newNodeCount;
|
||||
} else if (isListContinuation(currentLine, indentLevel)) {
|
||||
const result = handleListContinuation(items, currentLine, newLineIndex, newNodeCount, parseInline);
|
||||
|
||||
newLineIndex = result.newLineIndex;
|
||||
newNodeCount = result.newNodeCount;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (items.length > MAX_AST_NODES) break;
|
||||
}
|
||||
|
||||
if (items.length === 0 && newLineIndex === startLine) {
|
||||
const simpleList = createSimpleList(firstLineContent);
|
||||
|
||||
return {
|
||||
node: simpleList,
|
||||
newLineIndex: newLineIndex + 1,
|
||||
newNodeCount: newNodeCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.List,
|
||||
ordered: isOrdered,
|
||||
items,
|
||||
},
|
||||
newLineIndex,
|
||||
newNodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function isBlockBreak(trimmed: string): boolean {
|
||||
return trimmed.startsWith('#') || trimmed.startsWith('>') || trimmed.startsWith('>>> ');
|
||||
}
|
||||
|
||||
function createSimpleList(content: string): ListNode {
|
||||
return {
|
||||
type: NodeType.List,
|
||||
ordered: false,
|
||||
items: [{children: [{type: NodeType.Text, content}]}],
|
||||
};
|
||||
}
|
||||
|
||||
function handleSameIndentLevel(
|
||||
items: Array<ListItem>,
|
||||
content: string,
|
||||
indentLevel: number,
|
||||
depth: number,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
tryParseNestedContent: (parentIndent: number, depth: number) => {node: Node | null; newLineIndex: number},
|
||||
currentLineIndex: number,
|
||||
ordinal?: number,
|
||||
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
|
||||
const itemNodes: Array<Node> = [];
|
||||
let newNodeCount = 0;
|
||||
let newLineIndex = currentLineIndex + 1;
|
||||
|
||||
const contentListMatch = matchListItem(content);
|
||||
if (contentListMatch) {
|
||||
const nestedContent = tryParseNestedContent(indentLevel, depth);
|
||||
|
||||
const [isInlineOrdered, _, inlineItemContent] = contentListMatch;
|
||||
const inlineItemNodes = parseInline(inlineItemContent);
|
||||
|
||||
const nestedListItems: Array<ListItem> = [{children: inlineItemNodes}];
|
||||
|
||||
if (nestedContent.node && nestedContent.node.type === NodeType.List) {
|
||||
const nestedList = nestedContent.node as ListNode;
|
||||
nestedListItems.push(...nestedList.items);
|
||||
newLineIndex = nestedContent.newLineIndex;
|
||||
}
|
||||
|
||||
const nestedList: ListNode = {
|
||||
type: NodeType.List,
|
||||
ordered: isInlineOrdered,
|
||||
items: nestedListItems,
|
||||
};
|
||||
|
||||
itemNodes.push(nestedList);
|
||||
newNodeCount++;
|
||||
} else {
|
||||
const parsedNodes = parseInline(content);
|
||||
itemNodes.push(...parsedNodes);
|
||||
newNodeCount = itemNodes.length;
|
||||
|
||||
const nestedContent = tryParseNestedContent(indentLevel, depth);
|
||||
if (nestedContent.node) {
|
||||
itemNodes.push(nestedContent.node);
|
||||
newNodeCount++;
|
||||
newLineIndex = nestedContent.newLineIndex;
|
||||
}
|
||||
}
|
||||
|
||||
items.push({
|
||||
children: itemNodes,
|
||||
...(ordinal !== undefined ? {ordinal} : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex,
|
||||
newNodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function handleNestedIndentLevel(
|
||||
items: Array<ListItem>,
|
||||
currentLine: string,
|
||||
isOrdered: boolean,
|
||||
indentLevel: number,
|
||||
depth: number,
|
||||
parseList: (isOrdered: boolean, indentLevel: number, depth: number) => ListParseResult,
|
||||
currentLineIndex: number,
|
||||
nodeCount: number,
|
||||
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
|
||||
if (depth >= 9) {
|
||||
if (items.length > 0) {
|
||||
items[items.length - 1].children.push({
|
||||
type: NodeType.Text,
|
||||
content: currentLine.trim(),
|
||||
});
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
const nested = parseList(isOrdered, indentLevel, depth + 1);
|
||||
|
||||
if (items.length > 0) {
|
||||
items[items.length - 1].children.push(nested.node);
|
||||
}
|
||||
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: nested.newLineIndex,
|
||||
newNodeCount: nested.newNodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function handleBulletPointText(
|
||||
items: Array<ListItem>,
|
||||
currentLine: string,
|
||||
currentLineIndex: number,
|
||||
nodeCount: number,
|
||||
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
|
||||
if (items.length > 0) {
|
||||
items[items.length - 1].children.push({
|
||||
type: NodeType.Text,
|
||||
content: currentLine.trim(),
|
||||
});
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function handleListContinuation(
|
||||
items: Array<ListItem>,
|
||||
currentLine: string,
|
||||
currentLineIndex: number,
|
||||
nodeCount: number,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
): {newItems: Array<ListItem>; newLineIndex: number; newNodeCount: number} {
|
||||
if (items.length > 0) {
|
||||
const content = currentLine.trimStart();
|
||||
const parsedNodes = parseInline(content);
|
||||
items[items.length - 1].children.push(...parsedNodes);
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount + parsedNodes.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
newItems: items,
|
||||
newLineIndex: currentLineIndex + 1,
|
||||
newNodeCount: nodeCount,
|
||||
};
|
||||
}
|
||||
|
||||
function tryParseNestedContent(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
parentIndent: number,
|
||||
depth: number,
|
||||
parseListFactory: (isOrdered: boolean, indentLevel: number, depth: number) => ListParseResult,
|
||||
): {node: Node | null; newLineIndex: number} {
|
||||
if (currentLineIndex >= lines.length) return {node: null, newLineIndex: currentLineIndex};
|
||||
|
||||
const line = lines[currentLineIndex];
|
||||
const trimmed = line.trimStart();
|
||||
|
||||
if (trimmed.startsWith('```')) {
|
||||
const result = parseCodeBlock(lines, currentLineIndex);
|
||||
|
||||
return {
|
||||
node: result.node,
|
||||
newLineIndex: result.newLineIndex,
|
||||
};
|
||||
}
|
||||
|
||||
const listMatch = matchListItem(line);
|
||||
|
||||
if (listMatch) {
|
||||
const [isOrdered, indent, _] = listMatch;
|
||||
if (indent > parentIndent && depth < 9) {
|
||||
const result = parseListFactory(isOrdered, indent, depth + 1);
|
||||
return {
|
||||
node: result.node,
|
||||
newLineIndex: result.newLineIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {node: null, newLineIndex: currentLineIndex};
|
||||
}
|
||||
|
||||
function isListContinuation(line: string, indentLevel: number): boolean {
|
||||
let spaceCount = 0;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === ' ') spaceCount++;
|
||||
else break;
|
||||
}
|
||||
return spaceCount > indentLevel * 2;
|
||||
}
|
||||
|
||||
function isBulletPointText(text: string): boolean {
|
||||
const listMatch = matchListItem(text);
|
||||
if (listMatch) return false;
|
||||
|
||||
const trimmed = text.trimStart();
|
||||
return trimmed.startsWith('- ') && !text.startsWith(' ');
|
||||
}
|
||||
|
||||
function getNormalisedOrdinal(
|
||||
items: Array<ListItem>,
|
||||
ordinal: number | undefined,
|
||||
isOrdered: boolean,
|
||||
): number | undefined {
|
||||
if (!isOrdered) return undefined;
|
||||
if (items.length === 0) return ordinal ?? 1;
|
||||
|
||||
const startOrdinal = items[0]?.ordinal ?? ordinal ?? 1;
|
||||
return startOrdinal + items.length;
|
||||
}
|
||||
|
||||
export function matchListItem(line: string): [boolean, number, string, number?] | null {
|
||||
let indent = 0;
|
||||
let pos = 0;
|
||||
|
||||
while (pos < line.length && line[pos] === ' ') {
|
||||
indent++;
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (indent > 0 && indent < 2) return null;
|
||||
const indentLevel = Math.floor(indent / 2);
|
||||
|
||||
if (pos >= line.length) return null;
|
||||
|
||||
const marker = line[pos];
|
||||
if (marker === '*' || marker === '-') {
|
||||
return handleUnorderedListMarker(line, pos, indentLevel);
|
||||
}
|
||||
if (/[0-9]/.test(marker)) {
|
||||
return handleOrderedListMarker(line, pos, indentLevel);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleUnorderedListMarker(
|
||||
line: string,
|
||||
pos: number,
|
||||
indentLevel: number,
|
||||
): [boolean, number, string, undefined] | null {
|
||||
if (line[pos + 1] === ' ') {
|
||||
return [false, indentLevel, line.slice(pos + 2), undefined];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function handleOrderedListMarker(
|
||||
line: string,
|
||||
pos: number,
|
||||
indentLevel: number,
|
||||
): [boolean, number, string, number] | null {
|
||||
let currentPos = pos;
|
||||
let ordinalStr = '';
|
||||
|
||||
while (currentPos < line.length && /[0-9]/.test(line[currentPos])) {
|
||||
ordinalStr += line[currentPos];
|
||||
currentPos++;
|
||||
}
|
||||
|
||||
if (line[currentPos] === '.' && line[currentPos + 1] === ' ') {
|
||||
const ordinal = Number.parseInt(ordinalStr, 10);
|
||||
return [true, indentLevel, line.slice(currentPos + 2), ordinal];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
206
packages/markdown_parser/src/parsers/MentionParsers.tsx
Normal file
206
packages/markdown_parser/src/parsers/MentionParsers.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {GuildNavKind, MentionKind, NodeType, ParserFlags} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import type {MentionNode, ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
|
||||
const LESS_THAN = 60;
|
||||
const AT_SIGN = 64;
|
||||
const HASH = 35;
|
||||
const AMPERSAND = 38;
|
||||
const SLASH = 47;
|
||||
const LETTER_I = 105;
|
||||
const LETTER_D = 100;
|
||||
const COLON = 58;
|
||||
const DIGIT_ZERO = 48;
|
||||
const DIGIT_NINE = 57;
|
||||
|
||||
export function parseMention(text: string, parserFlags: number): ParserResult | null {
|
||||
if (text.length < 2 || text.charCodeAt(0) !== LESS_THAN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const end = text.indexOf('>');
|
||||
if (end === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secondCharCode = text.charCodeAt(1);
|
||||
|
||||
let mentionNode: MentionNode | null = null;
|
||||
|
||||
if (secondCharCode === AT_SIGN) {
|
||||
mentionNode = parseUserOrRoleMention(text.slice(1, end), parserFlags);
|
||||
} else if (secondCharCode === HASH) {
|
||||
mentionNode = parseChannelMention(text.slice(1, end), parserFlags);
|
||||
} else if (secondCharCode === SLASH) {
|
||||
mentionNode = parseCommandMention(text.slice(1, end), parserFlags);
|
||||
} else if (
|
||||
secondCharCode === LETTER_I &&
|
||||
text.length > 3 &&
|
||||
text.charCodeAt(2) === LETTER_D &&
|
||||
text.charCodeAt(3) === COLON
|
||||
) {
|
||||
mentionNode = parseGuildNavigation(text.slice(1, end), parserFlags);
|
||||
}
|
||||
|
||||
return mentionNode ? {node: mentionNode, advance: end + 1} : null;
|
||||
}
|
||||
|
||||
function isDigitOnly(text: string): boolean {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const charCode = text.charCodeAt(i);
|
||||
if (charCode < DIGIT_ZERO || charCode > DIGIT_NINE) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return text.length > 0;
|
||||
}
|
||||
|
||||
function parseUserOrRoleMention(inner: string, parserFlags: number): MentionNode | null {
|
||||
if (inner.length < 2 || inner.charCodeAt(0) !== AT_SIGN) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (inner.length > 2 && inner.charCodeAt(1) === AMPERSAND) {
|
||||
const roleId = inner.slice(2);
|
||||
if (isDigitOnly(roleId) && parserFlags & ParserFlags.ALLOW_ROLE_MENTIONS) {
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Role, id: roleId},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const userId = inner.startsWith('@!') ? inner.slice(2) : inner.slice(1);
|
||||
if (isDigitOnly(userId) && parserFlags & ParserFlags.ALLOW_USER_MENTIONS) {
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.User, id: userId},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseChannelMention(inner: string, parserFlags: number): MentionNode | null {
|
||||
if (inner.length < 2 || inner.charCodeAt(0) !== HASH || !(parserFlags & ParserFlags.ALLOW_CHANNEL_MENTIONS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const channelId = inner.slice(1);
|
||||
if (isDigitOnly(channelId)) {
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {kind: MentionKind.Channel, id: channelId},
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCommandMention(inner: string, parserFlags: number): MentionNode | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_COMMAND_MENTIONS) || inner.length < 2 || inner.charCodeAt(0) !== SLASH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const colonIndex = inner.indexOf(':');
|
||||
if (colonIndex === -1) return null;
|
||||
|
||||
const commandPart = inner.slice(0, colonIndex);
|
||||
const idPart = inner.slice(colonIndex + 1);
|
||||
|
||||
if (!idPart || !isDigitOnly(idPart)) return null;
|
||||
|
||||
const segments = commandPart.slice(1).trim().split(' ');
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.Command,
|
||||
name: segments[0],
|
||||
subcommandGroup: segments.length === 3 ? segments[1] : undefined,
|
||||
subcommand: segments.length >= 2 ? segments[segments.length - 1] : undefined,
|
||||
id: idPart,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseGuildNavigation(inner: string, parserFlags: number): MentionNode | null {
|
||||
if (!(parserFlags & ParserFlags.ALLOW_GUILD_NAVIGATIONS) || inner.length < 5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (inner.charCodeAt(0) !== LETTER_I || inner.charCodeAt(1) !== LETTER_D || inner.charCodeAt(2) !== COLON) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = inner.split(':');
|
||||
if (parts.length < 2 || parts.length > 3) return null;
|
||||
|
||||
const [idLabel, navType, navId] = parts;
|
||||
if (idLabel !== 'id') return null;
|
||||
|
||||
const navigationType = getNavigationType(navType);
|
||||
if (!navigationType) return null;
|
||||
|
||||
if (navigationType === GuildNavKind.LinkedRoles) {
|
||||
return createLinkedRolesNavigation(parts.length === 3 ? navId : undefined);
|
||||
}
|
||||
|
||||
if (parts.length !== 2) return null;
|
||||
return createBasicNavigation(navigationType);
|
||||
}
|
||||
|
||||
function getNavigationType(navTypeLower: string): GuildNavKind | null {
|
||||
switch (navTypeLower) {
|
||||
case 'customize':
|
||||
return GuildNavKind.Customize;
|
||||
case 'browse':
|
||||
return GuildNavKind.Browse;
|
||||
case 'guide':
|
||||
return GuildNavKind.Guide;
|
||||
case 'linked-roles':
|
||||
return GuildNavKind.LinkedRoles;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function createLinkedRolesNavigation(id?: string): MentionNode {
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.GuildNavigation,
|
||||
navigationType: GuildNavKind.LinkedRoles,
|
||||
id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createBasicNavigation(navigationType: GuildNavKind): MentionNode {
|
||||
return {
|
||||
type: NodeType.Mention,
|
||||
kind: {
|
||||
kind: MentionKind.GuildNavigation,
|
||||
navigationType,
|
||||
},
|
||||
};
|
||||
}
|
||||
329
packages/markdown_parser/src/parsers/TableParsers.tsx
Normal file
329
packages/markdown_parser/src/parsers/TableParsers.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {NodeType, TableAlignment} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import type {Node, TableCellNode, TableNode, TableRowNode} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
|
||||
interface TableParseResult {
|
||||
node: TableNode | null;
|
||||
newLineIndex: number;
|
||||
}
|
||||
|
||||
const PIPE = 124;
|
||||
const SPACE = 32;
|
||||
const BACKSLASH = 92;
|
||||
const DASH = 45;
|
||||
const COLON = 58;
|
||||
const HASH = 35;
|
||||
const GREATER_THAN = 62;
|
||||
const ASTERISK = 42;
|
||||
const DIGIT_0 = 48;
|
||||
const DIGIT_9 = 57;
|
||||
const PERIOD = 46;
|
||||
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
|
||||
const inlineContentCache = new Map<string, Array<Node>>();
|
||||
|
||||
export function parseTable(
|
||||
lines: Array<string>,
|
||||
currentLineIndex: number,
|
||||
_parserFlags: number,
|
||||
parseInline: (text: string) => Array<Node>,
|
||||
): TableParseResult {
|
||||
const startIndex = currentLineIndex;
|
||||
|
||||
if (startIndex + 2 >= lines.length) {
|
||||
return {node: null, newLineIndex: currentLineIndex};
|
||||
}
|
||||
|
||||
const headerLine = lines[currentLineIndex];
|
||||
const alignmentLine = lines[currentLineIndex + 1];
|
||||
|
||||
if (!containsPipe(headerLine) || !containsPipe(alignmentLine)) {
|
||||
return {node: null, newLineIndex: currentLineIndex};
|
||||
}
|
||||
|
||||
try {
|
||||
const headerCells = fastSplitTableCells(headerLine.trim());
|
||||
if (headerCells.length === 0 || !hasContent(headerCells)) {
|
||||
return {node: null, newLineIndex: currentLineIndex};
|
||||
}
|
||||
|
||||
const headerRow = createTableRow(headerCells, parseInline);
|
||||
|
||||
const columnCount = headerRow.cells.length;
|
||||
currentLineIndex++;
|
||||
|
||||
const alignmentCells = fastSplitTableCells(alignmentLine.trim());
|
||||
|
||||
if (!validateAlignmentRow(alignmentCells)) {
|
||||
return {node: null, newLineIndex: startIndex};
|
||||
}
|
||||
|
||||
const alignments = parseAlignments(alignmentCells);
|
||||
|
||||
if (!alignments || headerRow.cells.length !== alignments.length) {
|
||||
return {node: null, newLineIndex: startIndex};
|
||||
}
|
||||
|
||||
currentLineIndex++;
|
||||
|
||||
const rows: Array<TableRowNode> = [];
|
||||
|
||||
while (currentLineIndex < lines.length) {
|
||||
const line = lines[currentLineIndex];
|
||||
|
||||
if (!containsPipe(line)) break;
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (isBlockBreakFast(trimmed)) break;
|
||||
|
||||
const cellContents = fastSplitTableCells(trimmed);
|
||||
|
||||
if (cellContents.length !== columnCount) {
|
||||
normalizeColumnCount(cellContents, columnCount);
|
||||
}
|
||||
|
||||
const row = createTableRow(cellContents, parseInline);
|
||||
|
||||
rows.push(row);
|
||||
currentLineIndex++;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
return {node: null, newLineIndex: startIndex};
|
||||
}
|
||||
|
||||
let hasAnyContent = hasRowContent(headerRow);
|
||||
|
||||
if (!hasAnyContent) {
|
||||
for (const row of rows) {
|
||||
if (hasRowContent(row)) {
|
||||
hasAnyContent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasAnyContent) {
|
||||
return {node: null, newLineIndex: startIndex};
|
||||
}
|
||||
|
||||
if (inlineContentCache.size > MAX_CACHE_SIZE) {
|
||||
inlineContentCache.clear();
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Table,
|
||||
header: headerRow,
|
||||
alignments: alignments,
|
||||
rows,
|
||||
},
|
||||
newLineIndex: currentLineIndex,
|
||||
};
|
||||
} catch (_err) {
|
||||
return {node: null, newLineIndex: startIndex};
|
||||
}
|
||||
}
|
||||
|
||||
function containsPipe(text: string): boolean {
|
||||
return text.indexOf('|') !== -1;
|
||||
}
|
||||
|
||||
function hasContent(cells: Array<string>): boolean {
|
||||
for (const cell of cells) {
|
||||
if (cell.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasRowContent(row: TableRowNode): boolean {
|
||||
for (const cell of row.cells) {
|
||||
if (
|
||||
cell.children.length > 0 &&
|
||||
!(cell.children.length === 1 && cell.children[0].type === NodeType.Text && cell.children[0].content.trim() === '')
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateAlignmentRow(cells: Array<string>): boolean {
|
||||
if (cells.length === 0) return false;
|
||||
|
||||
for (const cell of cells) {
|
||||
const trimmed = cell.trim();
|
||||
|
||||
if (trimmed.length === 0 || trimmed.indexOf('-') === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < trimmed.length; i++) {
|
||||
const charCode = trimmed.charCodeAt(i);
|
||||
if (charCode !== SPACE && charCode !== COLON && charCode !== DASH && charCode !== PIPE) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function fastSplitTableCells(line: string): Array<string> {
|
||||
let start = 0;
|
||||
let end = line.length;
|
||||
|
||||
if (line.length > 0 && line.charCodeAt(0) === PIPE) {
|
||||
start = 1;
|
||||
}
|
||||
|
||||
if (line.length > 0 && end > start && line.charCodeAt(end - 1) === PIPE) {
|
||||
end--;
|
||||
}
|
||||
|
||||
if (start >= end) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const content = line.substring(start, end);
|
||||
const cells: Array<string> = [];
|
||||
let currentCell = '';
|
||||
let i = 0;
|
||||
|
||||
while (i < content.length) {
|
||||
if (content.charCodeAt(i) === BACKSLASH && i + 1 < content.length && content.charCodeAt(i + 1) === PIPE) {
|
||||
currentCell += '|';
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.charCodeAt(i) === PIPE) {
|
||||
cells.push(currentCell);
|
||||
currentCell = '';
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
currentCell += content[i];
|
||||
i++;
|
||||
}
|
||||
|
||||
cells.push(currentCell);
|
||||
return cells;
|
||||
}
|
||||
|
||||
function parseAlignments(cells: Array<string>): Array<TableAlignment> | null {
|
||||
if (cells.length === 0) return null;
|
||||
|
||||
const alignments: Array<TableAlignment> = [];
|
||||
|
||||
for (const cell of cells) {
|
||||
const trimmed = cell.trim();
|
||||
if (!trimmed || trimmed.indexOf('-') === -1) return null;
|
||||
|
||||
const left = trimmed.charCodeAt(0) === COLON;
|
||||
const right = trimmed.charCodeAt(trimmed.length - 1) === COLON;
|
||||
|
||||
if (left && right) {
|
||||
alignments.push(TableAlignment.Center);
|
||||
} else if (left) {
|
||||
alignments.push(TableAlignment.Left);
|
||||
} else if (right) {
|
||||
alignments.push(TableAlignment.Right);
|
||||
} else {
|
||||
alignments.push(TableAlignment.None);
|
||||
}
|
||||
}
|
||||
|
||||
return alignments;
|
||||
}
|
||||
|
||||
function createTableRow(cellContents: Array<string>, parseInline: (text: string) => Array<Node>): TableRowNode {
|
||||
const cells: Array<TableCellNode> = [];
|
||||
|
||||
for (const cellContent of cellContents) {
|
||||
const trimmed = cellContent.trim();
|
||||
|
||||
let inlineNodes: Array<Node>;
|
||||
if (inlineContentCache.has(trimmed)) {
|
||||
inlineNodes = inlineContentCache.get(trimmed)!;
|
||||
} else {
|
||||
inlineNodes = parseInline(trimmed);
|
||||
inlineContentCache.set(trimmed, inlineNodes);
|
||||
}
|
||||
|
||||
cells.push({
|
||||
type: NodeType.TableCell,
|
||||
children: inlineNodes.length > 0 ? inlineNodes : [{type: NodeType.Text, content: trimmed}],
|
||||
});
|
||||
}
|
||||
|
||||
return {type: NodeType.TableRow, cells};
|
||||
}
|
||||
|
||||
function normalizeColumnCount(cells: Array<string>, expectedColumns: number): void {
|
||||
if (cells.length > expectedColumns) {
|
||||
const lastCellIndex = expectedColumns - 1;
|
||||
cells[lastCellIndex] = `${cells[lastCellIndex]}|${cells.slice(expectedColumns).join('|')}`;
|
||||
cells.length = expectedColumns;
|
||||
} else {
|
||||
while (cells.length < expectedColumns) {
|
||||
cells.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBlockBreakFast(text: string): boolean {
|
||||
if (!text || text.length === 0) return false;
|
||||
|
||||
const firstChar = text.charCodeAt(0);
|
||||
|
||||
if (firstChar === HASH || firstChar === GREATER_THAN || firstChar === DASH || firstChar === ASTERISK) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
text.length >= 4 &&
|
||||
text.charCodeAt(0) === GREATER_THAN &&
|
||||
text.charCodeAt(1) === GREATER_THAN &&
|
||||
text.charCodeAt(2) === GREATER_THAN &&
|
||||
text.charCodeAt(3) === SPACE
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (text.length >= 2 && text.charCodeAt(0) === DASH && text.charCodeAt(1) === HASH) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (firstChar >= DIGIT_0 && firstChar <= DIGIT_9) {
|
||||
for (let i = 1; i < Math.min(text.length, 4); i++) {
|
||||
if (text.charCodeAt(i) === PERIOD) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
113
packages/markdown_parser/src/parsers/TimestampParsers.tsx
Normal file
113
packages/markdown_parser/src/parsers/TimestampParsers.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {NodeType, TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import type {ParserResult} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
|
||||
const LESS_THAN = 60;
|
||||
const LETTER_T = 116;
|
||||
const COLON = 58;
|
||||
|
||||
export function parseTimestamp(text: string): ParserResult | null {
|
||||
if (
|
||||
text.length < 4 ||
|
||||
text.charCodeAt(0) !== LESS_THAN ||
|
||||
text.charCodeAt(1) !== LETTER_T ||
|
||||
text.charCodeAt(2) !== COLON
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const end = text.indexOf('>');
|
||||
if (end === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inner = text.slice(3, end);
|
||||
|
||||
const allParts = inner.split(':');
|
||||
if (allParts.length > 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [timestampPart, stylePart] = allParts;
|
||||
|
||||
if (!/^\d+$/.test(timestampPart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = Number(timestampPart);
|
||||
|
||||
if (timestamp === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let style: TimestampStyle;
|
||||
if (stylePart !== undefined) {
|
||||
if (stylePart === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const styleChar = stylePart[0];
|
||||
|
||||
const parsedStyle = getTimestampStyle(styleChar);
|
||||
|
||||
if (!parsedStyle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
style = parsedStyle;
|
||||
} else {
|
||||
style = TimestampStyle.ShortDateTime;
|
||||
}
|
||||
|
||||
return {
|
||||
node: {
|
||||
type: NodeType.Timestamp,
|
||||
timestamp,
|
||||
style,
|
||||
},
|
||||
advance: end + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function getTimestampStyle(char: string): TimestampStyle | null {
|
||||
switch (char) {
|
||||
case 't':
|
||||
return TimestampStyle.ShortTime;
|
||||
case 'T':
|
||||
return TimestampStyle.LongTime;
|
||||
case 'd':
|
||||
return TimestampStyle.ShortDate;
|
||||
case 'D':
|
||||
return TimestampStyle.LongDate;
|
||||
case 'f':
|
||||
return TimestampStyle.ShortDateTime;
|
||||
case 'F':
|
||||
return TimestampStyle.LongDateTime;
|
||||
case 's':
|
||||
return TimestampStyle.ShortDateShortTime;
|
||||
case 'S':
|
||||
return TimestampStyle.ShortDateMediumTime;
|
||||
case 'R':
|
||||
return TimestampStyle.RelativeTime;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
120
packages/markdown_parser/src/types/Enums.tsx
Normal file
120
packages/markdown_parser/src/types/Enums.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 type {ValueOf} from '@fluxer/constants/src/ValueOf';
|
||||
|
||||
export const ParserFlags = {
|
||||
ALLOW_SPOILERS: 1 << 0,
|
||||
ALLOW_HEADINGS: 1 << 1,
|
||||
ALLOW_LISTS: 1 << 2,
|
||||
ALLOW_CODE_BLOCKS: 1 << 3,
|
||||
ALLOW_MASKED_LINKS: 1 << 4,
|
||||
ALLOW_COMMAND_MENTIONS: 1 << 5,
|
||||
ALLOW_GUILD_NAVIGATIONS: 1 << 6,
|
||||
ALLOW_USER_MENTIONS: 1 << 7,
|
||||
ALLOW_ROLE_MENTIONS: 1 << 8,
|
||||
ALLOW_CHANNEL_MENTIONS: 1 << 9,
|
||||
ALLOW_EVERYONE_MENTIONS: 1 << 10,
|
||||
ALLOW_BLOCKQUOTES: 1 << 11,
|
||||
ALLOW_MULTILINE_BLOCKQUOTES: 1 << 12,
|
||||
ALLOW_SUBTEXT: 1 << 13,
|
||||
ALLOW_TABLES: 1 << 14,
|
||||
ALLOW_ALERTS: 1 << 15,
|
||||
ALLOW_AUTOLINKS: 1 << 16,
|
||||
} as const;
|
||||
export type ParserFlags = ValueOf<typeof ParserFlags>;
|
||||
|
||||
export const NodeType = {
|
||||
Text: 'Text',
|
||||
Blockquote: 'Blockquote',
|
||||
Strong: 'Strong',
|
||||
Emphasis: 'Emphasis',
|
||||
Underline: 'Underline',
|
||||
Strikethrough: 'Strikethrough',
|
||||
Spoiler: 'Spoiler',
|
||||
Heading: 'Heading',
|
||||
Subtext: 'Subtext',
|
||||
List: 'List',
|
||||
CodeBlock: 'CodeBlock',
|
||||
InlineCode: 'InlineCode',
|
||||
Sequence: 'Sequence',
|
||||
Link: 'Link',
|
||||
Mention: 'Mention',
|
||||
Timestamp: 'Timestamp',
|
||||
Emoji: 'Emoji',
|
||||
Table: 'Table',
|
||||
TableRow: 'TableRow',
|
||||
TableCell: 'TableCell',
|
||||
Alert: 'Alert',
|
||||
} as const;
|
||||
export type NodeType = ValueOf<typeof NodeType>;
|
||||
|
||||
export const AlertType = {
|
||||
Note: 'Note',
|
||||
Tip: 'Tip',
|
||||
Important: 'Important',
|
||||
Warning: 'Warning',
|
||||
Caution: 'Caution',
|
||||
} as const;
|
||||
export type AlertType = ValueOf<typeof AlertType>;
|
||||
|
||||
export const TableAlignment = {
|
||||
Left: 'Left',
|
||||
Center: 'Center',
|
||||
Right: 'Right',
|
||||
None: 'None',
|
||||
} as const;
|
||||
export type TableAlignment = ValueOf<typeof TableAlignment>;
|
||||
|
||||
export const TimestampStyle = {
|
||||
ShortTime: 'ShortTime',
|
||||
LongTime: 'LongTime',
|
||||
ShortDate: 'ShortDate',
|
||||
LongDate: 'LongDate',
|
||||
ShortDateTime: 'ShortDateTime',
|
||||
LongDateTime: 'LongDateTime',
|
||||
ShortDateShortTime: 'ShortDateShortTime',
|
||||
ShortDateMediumTime: 'ShortDateMediumTime',
|
||||
RelativeTime: 'RelativeTime',
|
||||
} as const;
|
||||
export type TimestampStyle = ValueOf<typeof TimestampStyle>;
|
||||
|
||||
export const GuildNavKind = {
|
||||
Customize: 'Customize',
|
||||
Browse: 'Browse',
|
||||
Guide: 'Guide',
|
||||
LinkedRoles: 'LinkedRoles',
|
||||
} as const;
|
||||
export type GuildNavKind = ValueOf<typeof GuildNavKind>;
|
||||
|
||||
export const MentionKind = {
|
||||
User: 'User',
|
||||
Channel: 'Channel',
|
||||
Role: 'Role',
|
||||
Command: 'Command',
|
||||
GuildNavigation: 'GuildNavigation',
|
||||
Everyone: 'Everyone',
|
||||
Here: 'Here',
|
||||
} as const;
|
||||
export type MentionKind = ValueOf<typeof MentionKind>;
|
||||
|
||||
export const EmojiKind = {
|
||||
Standard: 'Standard',
|
||||
Custom: 'Custom',
|
||||
} as const;
|
||||
export type EmojiKind = ValueOf<typeof EmojiKind>;
|
||||
24
packages/markdown_parser/src/types/MarkdownConstants.tsx
Normal file
24
packages/markdown_parser/src/types/MarkdownConstants.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const MAX_AST_NODES = 10000;
|
||||
export const MAX_INLINE_DEPTH = 10;
|
||||
export const MAX_LINES = 10000;
|
||||
export const MAX_LINE_LENGTH = 4096;
|
||||
export const MAX_LINK_URL_LENGTH = 2048;
|
||||
168
packages/markdown_parser/src/types/Nodes.tsx
Normal file
168
packages/markdown_parser/src/types/Nodes.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {AlertType, NodeType, TableAlignment, TimestampStyle} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
|
||||
interface BaseNode {
|
||||
type: NodeType;
|
||||
}
|
||||
|
||||
export interface TextNode extends BaseNode {
|
||||
type: 'Text';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BlockquoteNode extends BaseNode {
|
||||
type: 'Blockquote';
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface FormattingNode extends BaseNode {
|
||||
type: 'Strong' | 'Emphasis' | 'Underline' | 'Strikethrough' | 'Spoiler' | 'Sequence';
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface HeadingNode extends BaseNode {
|
||||
type: 'Heading';
|
||||
level: number;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface SubtextNode extends BaseNode {
|
||||
type: 'Subtext';
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface ListNode extends BaseNode {
|
||||
type: 'List';
|
||||
ordered: boolean;
|
||||
items: Array<ListItem>;
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
children: Array<Node>;
|
||||
ordinal?: number;
|
||||
}
|
||||
|
||||
export interface CodeBlockNode extends BaseNode {
|
||||
type: 'CodeBlock';
|
||||
language?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface InlineCodeNode extends BaseNode {
|
||||
type: 'InlineCode';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface LinkNode extends BaseNode {
|
||||
type: 'Link';
|
||||
text?: Node;
|
||||
url: string;
|
||||
escaped: boolean;
|
||||
}
|
||||
|
||||
export interface MentionNode extends BaseNode {
|
||||
type: 'Mention';
|
||||
kind: MentionType;
|
||||
}
|
||||
|
||||
export interface TimestampNode extends BaseNode {
|
||||
type: 'Timestamp';
|
||||
timestamp: number;
|
||||
style: TimestampStyle;
|
||||
}
|
||||
|
||||
export interface EmojiNode extends BaseNode {
|
||||
type: 'Emoji';
|
||||
kind: EmojiType;
|
||||
}
|
||||
|
||||
export interface SequenceNode extends BaseNode {
|
||||
type: 'Sequence';
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface TableNode extends BaseNode {
|
||||
type: 'Table';
|
||||
header: TableRowNode;
|
||||
alignments: Array<TableAlignment>;
|
||||
rows: Array<TableRowNode>;
|
||||
}
|
||||
|
||||
export interface TableRowNode extends BaseNode {
|
||||
type: 'TableRow';
|
||||
cells: Array<TableCellNode>;
|
||||
}
|
||||
|
||||
export interface TableCellNode extends BaseNode {
|
||||
type: 'TableCell';
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface AlertNode extends BaseNode {
|
||||
type: 'Alert';
|
||||
alertType: AlertType;
|
||||
children: Array<Node>;
|
||||
}
|
||||
|
||||
export interface SpoilerNode extends BaseNode {
|
||||
type: 'Spoiler';
|
||||
children: Array<Node>;
|
||||
isBlock: boolean;
|
||||
}
|
||||
|
||||
export type Node =
|
||||
| TextNode
|
||||
| BlockquoteNode
|
||||
| FormattingNode
|
||||
| HeadingNode
|
||||
| SubtextNode
|
||||
| ListNode
|
||||
| CodeBlockNode
|
||||
| InlineCodeNode
|
||||
| LinkNode
|
||||
| MentionNode
|
||||
| TimestampNode
|
||||
| EmojiNode
|
||||
| SequenceNode
|
||||
| TableNode
|
||||
| TableRowNode
|
||||
| TableCellNode
|
||||
| AlertNode
|
||||
| SpoilerNode;
|
||||
|
||||
export type MentionType =
|
||||
| {kind: 'User'; id: string}
|
||||
| {kind: 'Channel'; id: string}
|
||||
| {kind: 'Role'; id: string}
|
||||
| {kind: 'Command'; name: string; subcommandGroup?: string; subcommand?: string; id: string}
|
||||
| {kind: 'GuildNavigation'; navigationType: 'Customize' | 'Browse' | 'Guide'}
|
||||
| {kind: 'GuildNavigation'; navigationType: 'LinkedRoles'; id?: string}
|
||||
| {kind: 'Everyone'}
|
||||
| {kind: 'Here'};
|
||||
|
||||
export type EmojiType =
|
||||
| {kind: 'Standard'; raw: string; codepoints: string; name: string}
|
||||
| {kind: 'Custom'; name: string; id: string; animated: boolean};
|
||||
|
||||
export interface ParserResult {
|
||||
node: Node;
|
||||
advance: number;
|
||||
}
|
||||
522
packages/markdown_parser/src/utils/AstUtils.tsx
Normal file
522
packages/markdown_parser/src/utils/AstUtils.tsx
Normal file
@@ -0,0 +1,522 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {NodeType} from '@fluxer/markdown_parser/src/types/Enums';
|
||||
import type {
|
||||
AlertNode,
|
||||
BlockquoteNode,
|
||||
FormattingNode,
|
||||
HeadingNode,
|
||||
LinkNode,
|
||||
ListNode,
|
||||
Node,
|
||||
SequenceNode,
|
||||
SubtextNode,
|
||||
TableCellNode,
|
||||
TableNode,
|
||||
TableRowNode,
|
||||
TextNode,
|
||||
} from '@fluxer/markdown_parser/src/types/Nodes';
|
||||
|
||||
const NT_TEXT = NodeType.Text;
|
||||
const NT_STRONG = NodeType.Strong;
|
||||
const NT_EMPHASIS = NodeType.Emphasis;
|
||||
const NT_UNDERLINE = NodeType.Underline;
|
||||
const NT_STRIKETHROUGH = NodeType.Strikethrough;
|
||||
const NT_SPOILER = NodeType.Spoiler;
|
||||
const NT_SEQUENCE = NodeType.Sequence;
|
||||
const NT_HEADING = NodeType.Heading;
|
||||
const NT_SUBTEXT = NodeType.Subtext;
|
||||
const NT_BLOCKQUOTE = NodeType.Blockquote;
|
||||
const NT_LIST = NodeType.List;
|
||||
const NT_LINK = NodeType.Link;
|
||||
const NT_TABLE = NodeType.Table;
|
||||
const NT_TABLE_ROW = NodeType.TableRow;
|
||||
const NT_TABLE_CELL = NodeType.TableCell;
|
||||
const NT_ALERT = NodeType.Alert;
|
||||
|
||||
const FORMATTING_NODE_TYPES: Set<NodeType> = new Set([
|
||||
NT_STRONG,
|
||||
NT_EMPHASIS,
|
||||
NT_UNDERLINE,
|
||||
NT_STRIKETHROUGH,
|
||||
NT_SPOILER,
|
||||
NT_SEQUENCE,
|
||||
]);
|
||||
|
||||
export function flattenAST(nodes: Array<Node>): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
flattenNode(nodes[i]);
|
||||
}
|
||||
|
||||
flattenChildren(nodes, false);
|
||||
}
|
||||
|
||||
function flattenNode(node: Node): void {
|
||||
const nodeType = node.type;
|
||||
|
||||
if (nodeType === NT_TEXT) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (FORMATTING_NODE_TYPES.has(nodeType)) {
|
||||
const formattingNode = node as FormattingNode;
|
||||
const children = formattingNode.children;
|
||||
const childCount = children.length;
|
||||
|
||||
if (childCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
flattenNode(children[i]);
|
||||
}
|
||||
|
||||
flattenChildren(children, false);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (nodeType) {
|
||||
case NT_HEADING:
|
||||
case NT_SUBTEXT: {
|
||||
const typedNode = node as HeadingNode | SubtextNode;
|
||||
const children = typedNode.children;
|
||||
const childCount = children.length;
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
flattenNode(children[i]);
|
||||
}
|
||||
|
||||
flattenChildren(children, false);
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_BLOCKQUOTE: {
|
||||
const blockquoteNode = node as BlockquoteNode;
|
||||
const children = blockquoteNode.children;
|
||||
const childCount = children.length;
|
||||
|
||||
for (let i = 0; i < childCount; i++) {
|
||||
flattenNode(children[i]);
|
||||
}
|
||||
|
||||
flattenChildren(children, true);
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_LIST: {
|
||||
const listNode = node as ListNode;
|
||||
const items = listNode.items;
|
||||
const itemCount = items.length;
|
||||
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
const item = items[i];
|
||||
const itemChildren = item.children;
|
||||
const itemChildCount = itemChildren.length;
|
||||
|
||||
for (let j = 0; j < itemChildCount; j++) {
|
||||
flattenNode(itemChildren[j]);
|
||||
}
|
||||
|
||||
flattenChildren(itemChildren, false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_LINK: {
|
||||
const linkNode = node as LinkNode;
|
||||
const text = linkNode.text;
|
||||
if (text) {
|
||||
flattenNode(text);
|
||||
if (text.type === NT_SEQUENCE) {
|
||||
const sequenceNode = text as SequenceNode;
|
||||
const seqChildren = sequenceNode.children;
|
||||
const seqChildCount = seqChildren.length;
|
||||
|
||||
for (let i = 0; i < seqChildCount; i++) {
|
||||
flattenNode(seqChildren[i]);
|
||||
}
|
||||
|
||||
flattenChildren(seqChildren, false);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_TABLE: {
|
||||
const tableNode = node as TableNode;
|
||||
flattenTableRow(tableNode.header);
|
||||
|
||||
const rows = tableNode.rows;
|
||||
const rowCount = rows.length;
|
||||
for (let i = 0; i < rowCount; i++) {
|
||||
flattenTableRow(rows[i]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_TABLE_ROW:
|
||||
flattenTableRow(node as TableRowNode);
|
||||
break;
|
||||
|
||||
case NT_TABLE_CELL: {
|
||||
const cellNode = node as TableCellNode;
|
||||
const cellChildren = cellNode.children;
|
||||
const cellChildCount = cellChildren.length;
|
||||
|
||||
for (let i = 0; i < cellChildCount; i++) {
|
||||
flattenNode(cellChildren[i]);
|
||||
}
|
||||
|
||||
flattenChildren(cellChildren, false);
|
||||
break;
|
||||
}
|
||||
|
||||
case NT_ALERT: {
|
||||
const alertNode = node as AlertNode;
|
||||
const alertChildren = alertNode.children;
|
||||
const alertChildCount = alertChildren.length;
|
||||
|
||||
for (let i = 0; i < alertChildCount; i++) {
|
||||
flattenNode(alertChildren[i]);
|
||||
}
|
||||
|
||||
flattenChildren(alertChildren, false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenTableRow(row: TableRowNode): void {
|
||||
const cells = row.cells;
|
||||
const cellCount = cells.length;
|
||||
|
||||
for (let i = 0; i < cellCount; i++) {
|
||||
const cell = cells[i];
|
||||
const cellChildren = cell.children;
|
||||
const childCount = cellChildren.length;
|
||||
|
||||
if (childCount === 0) continue;
|
||||
|
||||
for (let j = 0; j < childCount; j++) {
|
||||
flattenNode(cellChildren[j]);
|
||||
}
|
||||
|
||||
flattenChildren(cellChildren, false);
|
||||
}
|
||||
}
|
||||
|
||||
export function flattenChildren(nodes: Array<Node>, insideBlockquote = false): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
flattenFormattingNodes(nodes);
|
||||
|
||||
combineAdjacentTextNodes(nodes, insideBlockquote);
|
||||
|
||||
removeEmptyTextNodesBetweenAlerts(nodes);
|
||||
}
|
||||
|
||||
function flattenFormattingNodes(nodes: Array<Node>): void {
|
||||
if (nodes.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
while (i < nodes.length) {
|
||||
const node = nodes[i];
|
||||
if (FORMATTING_NODE_TYPES.has(node.type)) {
|
||||
const formattingNode = node as FormattingNode;
|
||||
flattenSameType(formattingNode.children, node.type);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
export function isFormattingNode(node: Node): boolean {
|
||||
return FORMATTING_NODE_TYPES.has(node.type);
|
||||
}
|
||||
|
||||
export function flattenSameType(children: Array<Node>, nodeType: NodeType): void {
|
||||
if (children.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let needsFlattening = false;
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
if (children[i].type === nodeType) {
|
||||
needsFlattening = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!needsFlattening) {
|
||||
return;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
const result: Array<Node> = [];
|
||||
|
||||
while (i < children.length) {
|
||||
const child = children[i];
|
||||
if (child.type === nodeType && 'children' in child) {
|
||||
const innerNodes = (child as FormattingNode).children;
|
||||
for (let j = 0; j < innerNodes.length; j++) {
|
||||
result.push(innerNodes[j]);
|
||||
}
|
||||
} else {
|
||||
result.push(child);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
children.length = 0;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
children.push(result[i]);
|
||||
}
|
||||
}
|
||||
|
||||
export function combineAdjacentTextNodes(nodes: Array<Node>, insideBlockquote = false): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hasAdjacentTextNodes = false;
|
||||
let lastWasText = false;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const isText = nodes[i].type === NT_TEXT;
|
||||
if (isText && lastWasText) {
|
||||
hasAdjacentTextNodes = true;
|
||||
break;
|
||||
}
|
||||
lastWasText = isText;
|
||||
}
|
||||
|
||||
if (!hasAdjacentTextNodes && !insideBlockquote) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result: Array<Node> = [];
|
||||
let currentText = '';
|
||||
let nonTextNodeSeen = false;
|
||||
|
||||
if (insideBlockquote) {
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = nodes[i];
|
||||
const isTextNode = node.type === NT_TEXT;
|
||||
|
||||
if (isTextNode) {
|
||||
if (nonTextNodeSeen) {
|
||||
if (currentText) {
|
||||
result.push({type: NT_TEXT, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
nonTextNodeSeen = false;
|
||||
}
|
||||
currentText += (node as TextNode).content;
|
||||
} else {
|
||||
if (currentText) {
|
||||
result.push({type: NT_TEXT, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
result.push(node);
|
||||
nonTextNodeSeen = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
result.push({type: NT_TEXT, content: currentText});
|
||||
}
|
||||
} else {
|
||||
let currentTextNode: TextNode | null = null;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = nodes[i];
|
||||
if (node.type === NT_TEXT) {
|
||||
const textNode = node as TextNode;
|
||||
const content = textNode.content;
|
||||
|
||||
let isMalformedContent = false;
|
||||
if (content && (content[0] === '#' || (content[0] === '-' && content.length > 1 && content[1] === '#'))) {
|
||||
const trimmed = content.trim();
|
||||
isMalformedContent = trimmed.startsWith('#') || trimmed.startsWith('-#');
|
||||
}
|
||||
|
||||
if (isMalformedContent) {
|
||||
if (currentTextNode) {
|
||||
result.push(currentTextNode);
|
||||
currentTextNode = null;
|
||||
}
|
||||
result.push({type: NT_TEXT, content});
|
||||
} else if (currentTextNode) {
|
||||
const hasDoubleNewline = content.includes('\n\n');
|
||||
|
||||
if (hasDoubleNewline) {
|
||||
result.push(currentTextNode);
|
||||
result.push({type: NT_TEXT, content});
|
||||
currentTextNode = null;
|
||||
} else {
|
||||
currentTextNode.content += content;
|
||||
}
|
||||
} else {
|
||||
currentTextNode = {type: NT_TEXT, content};
|
||||
}
|
||||
} else {
|
||||
if (currentTextNode) {
|
||||
result.push(currentTextNode);
|
||||
currentTextNode = null;
|
||||
}
|
||||
result.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTextNode) {
|
||||
result.push(currentTextNode);
|
||||
}
|
||||
}
|
||||
|
||||
nodes.length = 0;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
nodes.push(result[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function removeEmptyTextNodesBetweenAlerts(nodes: Array<Node>): void {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hasAlert = false;
|
||||
let hasTextNode = false;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const type = nodes[i].type;
|
||||
hasAlert ||= type === NT_ALERT;
|
||||
hasTextNode ||= type === NT_TEXT;
|
||||
|
||||
if (hasAlert && hasTextNode) break;
|
||||
}
|
||||
|
||||
if (!hasAlert || !hasTextNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
let emptyTextBetweenAlerts = false;
|
||||
for (let i = 1; i < nodeCount - 1; i++) {
|
||||
const current = nodes[i];
|
||||
if (
|
||||
current.type === NT_TEXT &&
|
||||
nodes[i - 1].type === NT_ALERT &&
|
||||
nodes[i + 1].type === NT_ALERT &&
|
||||
(current as TextNode).content.trim() === ''
|
||||
) {
|
||||
emptyTextBetweenAlerts = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!emptyTextBetweenAlerts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result: Array<Node> = [];
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const current = nodes[i];
|
||||
|
||||
if (
|
||||
i > 0 &&
|
||||
i < nodeCount - 1 &&
|
||||
current.type === NT_TEXT &&
|
||||
(current as TextNode).content.trim() === '' &&
|
||||
nodes[i - 1].type === NT_ALERT &&
|
||||
nodes[i + 1].type === NT_ALERT
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(current);
|
||||
}
|
||||
|
||||
nodes.length = 0;
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
nodes.push(result[i]);
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeTextNodes(nodes: Array<Node>): Array<Node> {
|
||||
const nodeCount = nodes.length;
|
||||
if (nodeCount <= 1) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
let hasConsecutiveTextNodes = false;
|
||||
let prevWasText = false;
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const isText = nodes[i].type === NT_TEXT;
|
||||
if (isText && prevWasText) {
|
||||
hasConsecutiveTextNodes = true;
|
||||
break;
|
||||
}
|
||||
prevWasText = isText;
|
||||
}
|
||||
|
||||
if (!hasConsecutiveTextNodes) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const mergedNodes: Array<Node> = [];
|
||||
let currentText = '';
|
||||
|
||||
for (let i = 0; i < nodeCount; i++) {
|
||||
const node = nodes[i];
|
||||
if (node.type === NT_TEXT) {
|
||||
currentText += (node as TextNode).content;
|
||||
} else {
|
||||
if (currentText) {
|
||||
mergedNodes.push({type: NT_TEXT, content: currentText});
|
||||
currentText = '';
|
||||
}
|
||||
mergedNodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentText) {
|
||||
mergedNodes.push({type: NT_TEXT, content: currentText});
|
||||
}
|
||||
|
||||
return mergedNodes;
|
||||
}
|
||||
|
||||
export function addTextNode(nodes: Array<Node>, text: string): void {
|
||||
if (text && text.length > 0) {
|
||||
nodes.push({type: NT_TEXT, content: text});
|
||||
}
|
||||
}
|
||||
92
packages/markdown_parser/src/utils/StringUtils.tsx
Normal file
92
packages/markdown_parser/src/utils/StringUtils.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const HTTP_PREFIX = 'http://';
|
||||
const HTTPS_PREFIX = 'https://';
|
||||
|
||||
const WORD_CHARS = new Set('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_');
|
||||
|
||||
const ESCAPABLE_CHARS = new Set('[]()\\*_~`@!#$%^&+-={}|:;"\'<>,.?/');
|
||||
|
||||
const URL_TERMINATION_CHARS = new Set(' \t\n\r)\'"');
|
||||
|
||||
function isWordCharacter(char: string): boolean {
|
||||
return char.length === 1 && WORD_CHARS.has(char);
|
||||
}
|
||||
|
||||
export function isEscapableCharacter(char: string): boolean {
|
||||
return char.length === 1 && ESCAPABLE_CHARS.has(char);
|
||||
}
|
||||
|
||||
export function isUrlTerminationChar(char: string): boolean {
|
||||
return char.length === 1 && URL_TERMINATION_CHARS.has(char);
|
||||
}
|
||||
|
||||
export function isWordUnderscore(chars: Array<string>, pos: number): boolean {
|
||||
if (chars[pos] !== '_') return false;
|
||||
|
||||
const prevChar = pos > 0 ? chars[pos - 1] : '';
|
||||
const nextChar = pos + 1 < chars.length ? chars[pos + 1] : '';
|
||||
|
||||
return isWordCharacter(prevChar) && isWordCharacter(nextChar);
|
||||
}
|
||||
|
||||
export function isAlphaNumeric(charCode: number): boolean {
|
||||
return (
|
||||
(charCode >= 48 && charCode <= 57) || (charCode >= 65 && charCode <= 90) || (charCode >= 97 && charCode <= 122)
|
||||
);
|
||||
}
|
||||
|
||||
export function isAlphaNumericChar(char: string): boolean {
|
||||
return char.length === 1 && isAlphaNumeric(char.charCodeAt(0));
|
||||
}
|
||||
|
||||
export function startsWithUrl(text: string): boolean {
|
||||
if (text.length < 8) return false;
|
||||
|
||||
if (text.startsWith(HTTP_PREFIX)) {
|
||||
const prefixEnd = 7;
|
||||
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
|
||||
}
|
||||
|
||||
if (text.startsWith(HTTPS_PREFIX)) {
|
||||
const prefixEnd = 8;
|
||||
return !text.substring(0, prefixEnd).includes('"') && !text.substring(0, prefixEnd).includes("'");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function matchMarker(chars: Array<string>, pos: number, marker: string): boolean {
|
||||
if (pos + marker.length > chars.length) return false;
|
||||
|
||||
if (marker.length === 1) {
|
||||
return chars[pos] === marker;
|
||||
}
|
||||
|
||||
if (marker.length === 2) {
|
||||
return chars[pos] === marker[0] && chars[pos + 1] === marker[1];
|
||||
}
|
||||
|
||||
for (let i = 0; i < marker.length; i++) {
|
||||
if (chars[pos + i] !== marker[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
119
packages/markdown_parser/src/utils/UrlUtils.tsx
Normal file
119
packages/markdown_parser/src/utils/UrlUtils.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import * as idna from 'idna-uts46-hx';
|
||||
|
||||
const HTTP_PROTOCOL = 'http:';
|
||||
const HTTPS_PROTOCOL = 'https:';
|
||||
const MAILTO_PROTOCOL = 'mailto:';
|
||||
const TEL_PROTOCOL = 'tel:';
|
||||
const SMS_PROTOCOL = 'sms:';
|
||||
|
||||
const EMAIL_REGEX =
|
||||
/^[a-zA-Z0-9._%+-]+@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
|
||||
|
||||
const PHONE_REGEX = /^\+[1-9][\d\s\-()]+$/;
|
||||
|
||||
const SPECIAL_PROTOCOLS_REGEX = /^(mailto:|tel:|sms:)/;
|
||||
|
||||
const PROTOCOL_REGEX = /:\/\//;
|
||||
|
||||
const TRAILING_SLASH_REGEX = /\/+$/;
|
||||
|
||||
const NORMALIZE_PHONE_REGEX = /[\s\-()]/g;
|
||||
|
||||
function createUrlObject(url: string): URL | null {
|
||||
if (typeof url !== 'string') return null;
|
||||
|
||||
try {
|
||||
if (!PROTOCOL_REGEX.test(url)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new URL(url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return typeof email === 'string' && EMAIL_REGEX.test(email);
|
||||
}
|
||||
|
||||
export function normalizePhoneNumber(phoneNumber: string): string {
|
||||
return phoneNumber.replace(NORMALIZE_PHONE_REGEX, '');
|
||||
}
|
||||
|
||||
export function isValidPhoneNumber(phoneNumber: string): boolean {
|
||||
if (typeof phoneNumber !== 'string' || !PHONE_REGEX.test(phoneNumber)) return false;
|
||||
|
||||
return normalizePhoneNumber(phoneNumber).length >= 7;
|
||||
}
|
||||
|
||||
export function normalizeUrl(url: string): string {
|
||||
if (typeof url !== 'string') return url;
|
||||
|
||||
if (SPECIAL_PROTOCOLS_REGEX.test(url)) {
|
||||
return url.replace(TRAILING_SLASH_REGEX, '');
|
||||
}
|
||||
|
||||
const urlObj = createUrlObject(url);
|
||||
return urlObj ? urlObj.toString() : url;
|
||||
}
|
||||
|
||||
function idnaEncodeURL(url: string): string {
|
||||
const urlObj = createUrlObject(url);
|
||||
if (!urlObj) return url;
|
||||
|
||||
try {
|
||||
urlObj.hostname = idna.toAscii(urlObj.hostname).toLowerCase();
|
||||
|
||||
urlObj.username = '';
|
||||
urlObj.password = '';
|
||||
|
||||
return urlObj.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export function convertToAsciiUrl(url: string): string {
|
||||
if (SPECIAL_PROTOCOLS_REGEX.test(url)) return url;
|
||||
|
||||
const urlObj = createUrlObject(url);
|
||||
return urlObj ? idnaEncodeURL(url) : url;
|
||||
}
|
||||
|
||||
export function isValidUrl(urlStr: string): boolean {
|
||||
if (typeof urlStr !== 'string') return false;
|
||||
|
||||
if (SPECIAL_PROTOCOLS_REGEX.test(urlStr)) return true;
|
||||
|
||||
const urlObj = createUrlObject(urlStr);
|
||||
if (!urlObj) return false;
|
||||
|
||||
const {protocol} = urlObj;
|
||||
return (
|
||||
protocol === HTTP_PROTOCOL ||
|
||||
protocol === HTTPS_PROTOCOL ||
|
||||
protocol === MAILTO_PROTOCOL ||
|
||||
protocol === TEL_PROTOCOL ||
|
||||
protocol === SMS_PROTOCOL
|
||||
);
|
||||
}
|
||||
7
packages/markdown_parser/tsconfig.json
Normal file
7
packages/markdown_parser/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfigs/package.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
34
packages/markdown_parser/vitest.config.ts
Normal file
34
packages/markdown_parser/vitest.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* 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 {defineConfig} from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user