Files
fluxer/packages/markdown_parser/src/__tests__/BlockParsers.test.tsx
2026-02-17 12:22:36 +00:00

1280 lines
36 KiB
TypeScript

/*
* 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 {AlertType, NodeType, ParserFlags, TableAlignment} from '@fluxer/markdown_parser/src/types/Enums';
import type {BlockquoteNode, TextNode} from '@fluxer/markdown_parser/src/types/Nodes';
import {describe, expect, test} from 'vitest';
describe('Fluxer Markdown Parser', () => {
test('heading without newlines', () => {
const input = '# Heading 1\n## Heading 2\n### Heading 3';
const flags = ParserFlags.ALLOW_HEADINGS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(3);
expect(ast[0]).toEqual({
type: NodeType.Heading,
level: 1,
children: [{type: NodeType.Text, content: 'Heading 1'}],
});
expect(ast[1]).toEqual({
type: NodeType.Heading,
level: 2,
children: [{type: NodeType.Text, content: 'Heading 2'}],
});
expect(ast[2]).toEqual({
type: NodeType.Heading,
level: 3,
children: [{type: NodeType.Text, content: 'Heading 3'}],
});
});
test('heading with paragraph', () => {
const input = '# Heading 1\nParagraph text\n## Heading 2';
const flags = ParserFlags.ALLOW_HEADINGS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(3);
expect(ast[0]).toEqual({
type: NodeType.Heading,
level: 1,
children: [{type: NodeType.Text, content: 'Heading 1'}],
});
expect(ast[1]).toEqual({type: NodeType.Text, content: 'Paragraph text'});
expect(ast[2]).toEqual({
type: NodeType.Heading,
level: 2,
children: [{type: NodeType.Text, content: 'Heading 2'}],
});
});
test('multiple headings with blank lines', () => {
const input = '# Heading 1\n\n## Heading 2\n\n### Heading 3\n\n#### Heading 4';
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 1'}],
},
{
type: NodeType.Heading,
level: 2,
children: [{type: NodeType.Text, content: 'Heading 2'}],
},
{
type: NodeType.Heading,
level: 3,
children: [{type: NodeType.Text, content: 'Heading 3'}],
},
{
type: NodeType.Heading,
level: 4,
children: [{type: NodeType.Text, content: 'Heading 4'}],
},
]);
});
test('malformed headings', () => {
const input = '#Not a heading\n###### Too many hashes\n###No space';
const flags = ParserFlags.ALLOW_HEADINGS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: '#Not a heading\n'},
{type: NodeType.Text, content: '###### Too many hashes\n'},
{type: NodeType.Text, content: '###No space'},
]);
});
test('heading after blank line', () => {
const input = 'test\n\n# Test';
const flags = ParserFlags.ALLOW_HEADINGS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'test\n'},
{
type: NodeType.Heading,
level: 1,
children: [{type: NodeType.Text, content: 'Test'}],
},
]);
});
test('heading before blank line', () => {
const input = '# Test\n\ntest';
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: 'Test'}],
},
{type: NodeType.Text, content: 'test'},
]);
});
test('heading list spacing', () => {
const input = '# Heading\n\n- Item 1\n- Item 2';
const flags = ParserFlags.ALLOW_LISTS | 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.List,
ordered: false,
items: [
{children: [{type: NodeType.Text, content: 'Item 1'}]},
{children: [{type: NodeType.Text, content: 'Item 2'}]},
],
},
]);
});
test('list heading spacing', () => {
const input = '- Item 1\n- Item 2\n\n# Heading';
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_HEADINGS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.List,
ordered: false,
items: [
{children: [{type: NodeType.Text, content: 'Item 1'}]},
{children: [{type: NodeType.Text, content: 'Item 2'}]},
],
},
{
type: NodeType.Heading,
level: 1,
children: [{type: NodeType.Text, content: 'Heading'}],
},
]);
});
test('blockquote', () => {
const input = '> This is a blockquote.\n> It has two lines.';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'This is a blockquote.\nIt has two lines.'}],
},
]);
});
test('blockquote with preserved > character and newline', () => {
const input = '> fsdff\n> > fdfs';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'fsdff\n> fdfs'}],
},
]);
});
test('blockquote disabled', () => {
const input = '> This is a blockquote.\n> It has two lines.';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '> This is a blockquote.\n> It has two lines.'}]);
});
test('multiline blockquote', () => {
const input = ">>> This is an multiline blockquote.\nIt continues without '> ' prefix.";
const flags = ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [
{type: NodeType.Text, content: "This is an multiline blockquote.\nIt continues without '> ' prefix."},
],
},
]);
});
test('multiline blockquote disabled', () => {
const input = ">>> This is an multiline blockquote.\nIt continues without '> ' prefix.";
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: ">>> This is an multiline blockquote.\nIt continues without '> ' prefix."},
]);
});
test('code block', () => {
const input = '```rust\nfn main() {\n println!("Hello, world!");\n}\n```';
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0]).toEqual({
type: NodeType.CodeBlock,
language: 'rust',
content: 'fn main() {\n println!("Hello, world!");\n}\n',
});
});
test('code block edge cases', () => {
const input =
'```\nNo language specified\n```\n```invalid\nInvalid language\n```\n```rust no build\nWith extra tokens\n```';
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(3);
expect(ast[0]).toEqual({
type: NodeType.CodeBlock,
language: undefined,
content: 'No language specified\n',
});
expect(ast[1]).toEqual({
type: NodeType.CodeBlock,
language: 'invalid',
content: 'Invalid language\n',
});
expect(ast[2]).toEqual({
type: NodeType.CodeBlock,
language: 'rust no build',
content: 'With extra tokens\n',
});
});
test('inline fenced block after text', () => {
const input = 'a```b```';
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'a'},
{type: NodeType.CodeBlock, language: undefined, content: 'b'},
]);
});
test('inline fenced block with trailing text after closing fence', () => {
const input = 'a```b```c';
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'a'},
{type: NodeType.CodeBlock, language: undefined, content: 'b'},
{type: NodeType.Text, content: 'c'},
]);
});
test('fenced block following inline text with multiline content', () => {
const input = 'before ```\ncode line\n``` after';
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'before '},
{type: NodeType.CodeBlock, language: undefined, content: 'code line\n'},
{type: NodeType.Text, content: ' after'},
]);
});
test('captures trailing text after closing fence on its own line', () => {
const input = '```\ncode line\n``` trailing';
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: 'code line\n'},
{type: NodeType.Text, content: ' trailing'},
]);
});
test('multiple blockquotes', () => {
const input = '> First blockquote\n\n> Second blockquote\n> With multiple lines.';
const flags = ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'First blockquote'}],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'Second blockquote\nWith multiple lines.'}],
},
]);
});
test('list followed by blank line and text', () => {
const input = '- Test\n\nTest';
const flags = ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_HEADINGS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.List,
ordered: false,
items: [{children: [{type: NodeType.Text, content: 'Test'}]}],
},
{type: NodeType.Text, content: '\nTest'},
]);
});
test('blockquote without continuation line prefix', () => {
const input = '> **[Author](https://example.com)**\n> First line of quote.\nContinuation line without prefix.';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_MASKED_LINKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [
{
type: NodeType.Strong,
children: [
{
type: NodeType.Link,
text: {type: NodeType.Text, content: 'Author'},
url: 'https://example.com/',
escaped: false,
},
],
},
{type: NodeType.Text, content: '\nFirst line of quote.'},
],
},
{type: NodeType.Text, content: 'Continuation line without prefix.'},
]);
});
test('blockquote infinite loop', () => {
const input = '> >>';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toBeDefined();
expect(ast.length).toBeGreaterThan(0);
expect(ast.length).toBeLessThan(50);
});
test('blockquote infinite loop alternate', () => {
const input = '>> >';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toBeDefined();
expect(ast.length).toBeGreaterThan(0);
expect(ast.length).toBeLessThan(50);
});
test('blockquote list', () => {
const input = '> - test';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [
{
type: NodeType.List,
ordered: false,
items: [{children: [{type: NodeType.Text, content: 'test'}]}],
},
],
},
]);
});
test('multiline blockquote list', () => {
const input = '>>> - test';
const flags = ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES | ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [
{
type: NodeType.List,
ordered: false,
items: [{children: [{type: NodeType.Text, content: 'test'}]}],
},
],
},
]);
});
test('blockquote with multiple lists', () => {
const input = '> - test1\n> - test2';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [
{
type: NodeType.List,
ordered: false,
items: [
{children: [{type: NodeType.Text, content: 'test1'}]},
{children: [{type: NodeType.Text, content: 'test2'}]},
],
},
],
},
]);
});
test('blockquote with paragraph and list', () => {
const input = '> This is a paragraph.\n>\n> - Item 1\n> - Item 2';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'This is a paragraph.'}],
},
{type: NodeType.Text, content: '>'},
{
type: NodeType.Blockquote,
children: [
{
type: NodeType.List,
ordered: false,
items: [
{children: [{type: NodeType.Text, content: 'Item 1'}]},
{children: [{type: NodeType.Text, content: 'Item 2'}]},
],
},
],
},
]);
});
test('blockquote without continuation line prefix 2', () => {
const input = '> This is in a blockquote.\n>\n> This is in another blockquote.';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'This is in a blockquote.'}],
},
{type: NodeType.Text, content: '>'},
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'This is in another blockquote.'}],
},
]);
});
test('blockquote with blank lines', () => {
const input = '> First paragraph\n> \n> Second paragraph';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'First paragraph\n\nSecond paragraph'}],
},
]);
});
test('multiline blockquote with blank lines', () => {
const input = '>>> First paragraph\n\nSecond paragraph';
const flags = ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'First paragraph\n\nSecond paragraph'}],
},
]);
});
test('blockquote with multiple blank lines should create consistent newlines', () => {
const input = '> Line one\n> \n> \n> Line two';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'Line one\n\n\nLine two'}],
},
]);
});
test('blockquote vs multiline blockquote with blank lines should behave the same', () => {
const blockquoteInput = '> Test\n> \n> test';
const blockquoteFlags = ParserFlags.ALLOW_BLOCKQUOTES;
const blockquoteParser = new Parser(blockquoteInput, blockquoteFlags);
const {nodes: blockquoteAst} = blockquoteParser.parse();
const multilineInput = '>>> Test\n\ntest';
const multilineFlags = ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES;
const multilineParser = new Parser(multilineInput, multilineFlags);
const {nodes: multilineAst} = multilineParser.parse();
const blockquoteContent = (blockquoteAst[0] as BlockquoteNode).children[0] as TextNode;
const multilineContent = (multilineAst[0] as BlockquoteNode).children[0] as TextNode;
expect(blockquoteContent.content).toBe('Test\n\ntest');
expect(multilineContent.content).toBe('Test\n\ntest');
});
test('blockquote with > without space should not be parsed as part of blockquote', () => {
const input = '> First line\n>\n> Second line';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'First line'}],
},
{type: NodeType.Text, content: '>'},
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'Second line'}],
},
]);
});
test('blockquote with multiple > spaces', () => {
const input = '> First line\n> \n> \n> Second line';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'First line\n\n\nSecond line'}],
},
]);
});
test('blockquote nested list', () => {
const input = '> - Item 1\n> - Subitem 1\n> - Subitem 2\n> - Item 2';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [
{
type: NodeType.List,
ordered: false,
items: [
{
children: [
{type: NodeType.Text, content: 'Item 1'},
{
type: NodeType.List,
ordered: false,
items: [
{children: [{type: NodeType.Text, content: 'Subitem 1'}]},
{children: [{type: NodeType.Text, content: 'Subitem 2'}]},
],
},
],
},
{children: [{type: NodeType.Text, content: 'Item 2'}]},
],
},
],
},
]);
});
test('double arrow blockquote', () => {
const input = '>> test';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '>> test'}]);
});
test('double spaced arrow blockquote', () => {
const input = '> > test';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: '> test'}],
},
]);
});
test('multiple arrows blockquote', () => {
const input = '> > > > deeply nested';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: '> > > deeply nested'}],
},
]);
});
test('multiline blockquote with blockquote', () => {
const input = 'test\n>>> test\ntest\n\ntest\n> test';
const flags = ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'test'},
{
type: NodeType.Blockquote,
children: [
{type: NodeType.Text, content: 'test\ntest\n\ntest'},
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'test'}],
},
],
},
]);
});
test('multiline blockquote with double nested blockquote', () => {
const input = 'test\n>>> test\ntest\n\ntest\n> test\n> > should not nest';
const flags = ParserFlags.ALLOW_MULTILINE_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: 'test'},
{
type: NodeType.Blockquote,
children: [
{type: NodeType.Text, content: 'test\ntest\n\ntest'},
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'test\n> should not nest'}],
},
],
},
]);
});
test('basic subtext', () => {
const input = '-# This is subtext';
const flags = ParserFlags.ALLOW_SUBTEXT;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Subtext,
children: [{type: NodeType.Text, content: 'This is subtext'}],
},
]);
});
test('subtext disabled', () => {
const input = '-# This is subtext';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '-# This is subtext'}]);
});
test('subtext with formatting', () => {
const input = '-# This is *formatted* and **bold** subtext';
const flags = ParserFlags.ALLOW_SUBTEXT;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Subtext,
children: [
{type: NodeType.Text, content: 'This is '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'formatted'}]},
{type: NodeType.Text, content: ' and '},
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'bold'}]},
{type: NodeType.Text, content: ' subtext'},
],
},
]);
});
test('multiple subtexts', () => {
const input = '-# First subtext\n-# Second subtext';
const flags = ParserFlags.ALLOW_SUBTEXT;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Subtext,
children: [{type: NodeType.Text, content: 'First subtext'}],
},
{
type: NodeType.Subtext,
children: [{type: NodeType.Text, content: 'Second subtext'}],
},
]);
});
test('subtext with mixed content', () => {
const input = '# Heading\n-# Subtext below heading\nNormal text\n-# Another subtext';
const flags = ParserFlags.ALLOW_SUBTEXT | 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.Subtext,
children: [{type: NodeType.Text, content: 'Subtext below heading'}],
},
{type: NodeType.Text, content: 'Normal text\n'},
{
type: NodeType.Subtext,
children: [{type: NodeType.Text, content: 'Another subtext'}],
},
]);
});
test('malformed subtext', () => {
const input = '-#No space\n-# Two spaces\n-## Extra hash';
const flags = ParserFlags.ALLOW_SUBTEXT;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.Text, content: '-#No space\n'},
{type: NodeType.Text, content: '-# Two spaces\n'},
{type: NodeType.Text, content: '-## Extra hash'},
]);
});
test('spoiler with block elements', () => {
const input = '||\n# Heading\n- List item\n> Quote\n||';
const flags =
ParserFlags.ALLOW_SPOILERS | ParserFlags.ALLOW_HEADINGS | ParserFlags.ALLOW_LISTS | ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Spoiler,
isBlock: true,
children: [
{
type: NodeType.Heading,
level: 1,
children: [{type: NodeType.Text, content: 'Heading'}],
},
{
type: NodeType.List,
ordered: false,
items: [
{
children: [{type: NodeType.Text, content: 'List item'}],
},
],
},
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'Quote'}],
},
],
},
]);
});
test('spoiler with code blocks', () => {
const input = '||\n```rust\nfn main() {}\n```\n||';
const flags = ParserFlags.ALLOW_SPOILERS | ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Spoiler,
isBlock: true,
children: [
{
type: NodeType.CodeBlock,
language: 'rust',
content: 'fn main() {}\n',
},
],
},
]);
});
test('spoiler with tables', () => {
const input = '||\n| Header |\n|--------|\n| Cell |\n||';
const flags = ParserFlags.ALLOW_SPOILERS | ParserFlags.ALLOW_TABLES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Spoiler,
isBlock: true,
children: [
{
type: NodeType.Table,
header: {
type: NodeType.TableRow,
cells: [
{
type: NodeType.TableCell,
children: [{type: NodeType.Text, content: 'Header'}],
},
],
},
alignments: [TableAlignment.None],
rows: [
{
type: NodeType.TableRow,
cells: [
{
type: NodeType.TableCell,
children: [{type: NodeType.Text, content: 'Cell'}],
},
],
},
],
},
],
},
]);
});
test('spoiler with alerts', () => {
const input = '||\n> [!NOTE]\n> This is a note\n||';
const flags = ParserFlags.ALLOW_SPOILERS | ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_ALERTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Spoiler,
isBlock: true,
children: [
{
type: NodeType.Alert,
alertType: AlertType.Note,
children: [{type: NodeType.Text, content: 'This is a note'}],
},
],
},
]);
});
test('malformed spoiler blocks', () => {
const inputs = ['|| incomplete spoiler\n# heading', '||\n# heading\n| incomplete', '|| # not a heading ||'];
const flags = ParserFlags.ALLOW_SPOILERS | ParserFlags.ALLOW_HEADINGS;
for (const input of inputs) {
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(
ast.every(
(node) => node.type === NodeType.Text || node.type === NodeType.Heading || node.type === NodeType.Spoiler,
),
).toBe(true);
}
});
test('subtext with bracketed text followed by link', () => {
const input =
'-# [1] TL;DR: We use the [GNU Affero General Public License v3 (AGPLv3)](https://www.gnu.org/licenses/agpl-3.0.en.html) for our code.';
const flags = ParserFlags.ALLOW_SUBTEXT | ParserFlags.ALLOW_MASKED_LINKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Subtext,
children: [
{type: NodeType.Text, content: '[1] TL;DR: We use the '},
{
type: NodeType.Link,
text: {type: NodeType.Text, content: 'GNU Affero General Public License v3 (AGPLv3)'},
url: 'https://www.gnu.org/licenses/agpl-3.0.en.html',
escaped: false,
},
{type: NodeType.Text, content: ' for our code.'},
],
},
]);
});
describe('Alert Parser', () => {
test('basic note alert', () => {
const input = '> [!NOTE]\n> This is a note';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_ALERTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Alert,
alertType: AlertType.Note,
children: [{type: NodeType.Text, content: 'This is a note'}],
},
]);
});
test('alert with formatted content', () => {
const input = '> [!WARNING]\n> This is **important** and *emphasized*';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_ALERTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Alert,
alertType: AlertType.Warning,
children: [
{type: NodeType.Text, content: 'This is '},
{type: NodeType.Strong, children: [{type: NodeType.Text, content: 'important'}]},
{type: NodeType.Text, content: ' and '},
{type: NodeType.Emphasis, children: [{type: NodeType.Text, content: 'emphasized'}]},
],
},
]);
});
test('multiline alert', () => {
const input = '> [!IMPORTANT]\n> First line\n> Second line';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_ALERTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Alert,
alertType: AlertType.Important,
children: [{type: NodeType.Text, content: 'First line\nSecond line'}],
},
]);
});
test('alert with nested content', () => {
const input = '> [!TIP]\n> - List item 1\n> - List item 2\n> \n> Additional text';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_ALERTS | ParserFlags.ALLOW_LISTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Alert,
alertType: AlertType.Tip,
children: [
{
type: NodeType.List,
ordered: false,
items: [
{children: [{type: NodeType.Text, content: 'List item 1'}]},
{children: [{type: NodeType.Text, content: 'List item 2'}]},
],
},
{type: NodeType.Text, content: '\nAdditional text'},
],
},
]);
});
test('invalid alert types', () => {
const input = '> [!INVALID]\n> This should be a regular blockquote';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_ALERTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: '[!INVALID]\nThis should be a regular blockquote'}],
},
]);
});
test('alert with blank lines', () => {
const input = '> [!CAUTION]\n> First paragraph\n>\n> Second paragraph';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_ALERTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Alert,
alertType: AlertType.Caution,
children: [{type: NodeType.Text, content: 'First paragraph'}],
},
{type: NodeType.Text, content: '>'},
{
type: NodeType.Blockquote,
children: [{type: NodeType.Text, content: 'Second paragraph'}],
},
]);
});
test('alerts disabled with blockquotes', () => {
const input = '> [!NOTE]\n> This should be a regular blockquote';
const flags = 0;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '> [!NOTE]\n> This should be a regular blockquote'}]);
});
test('remove empty space between alerts', () => {
const input = '> [!NOTE]\n> This is a note\n\n> [!WARNING]\n> This is a warning';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_ALERTS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Alert,
alertType: AlertType.Note,
children: [{type: NodeType.Text, content: 'This is a note'}],
},
{
type: NodeType.Alert,
alertType: AlertType.Warning,
children: [{type: NodeType.Text, content: 'This is a warning'}],
},
]);
});
});
test('blockquote with bold, italic, nested quote and code block', () => {
const input = '> **bold in quote**\n> *italic in quote*\n> > nested quote **inside**\n> ```code inside quote```';
const flags = ParserFlags.ALLOW_BLOCKQUOTES | ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{
type: NodeType.Blockquote,
children: [
{
type: NodeType.Strong,
children: [{type: NodeType.Text, content: 'bold in quote'}],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.Emphasis,
children: [{type: NodeType.Text, content: 'italic in quote'}],
},
{type: NodeType.Text, content: '\n> nested quote '},
{
type: NodeType.Strong,
children: [{type: NodeType.Text, content: 'inside'}],
},
{type: NodeType.Text, content: '\n'},
{
type: NodeType.CodeBlock,
language: undefined,
content: 'code inside quote',
},
],
},
]);
});
test('inline code with backtick followed by code block', () => {
const input = '`single backtick with ` inside`\n````md\nnested ```';
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([
{type: NodeType.InlineCode, content: 'single backtick with '},
{type: NodeType.Text, content: ' inside`\n'},
{
type: NodeType.CodeBlock,
language: 'md',
content: 'nested ```\n',
},
]);
});
test('single-line code block with backticks inside and inline code with triple backticks', () => {
const input = '```code `inline inside` block```\n`inline ```block inside``` inline`';
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: 'code `inline inside` block',
},
{type: NodeType.InlineCode, content: 'inline ```block inside``` inline'},
]);
});
test('code block containing triple backticks with extra backtick', () => {
const input = '```multi\n```multi\n````';
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(2);
expect(ast[0]).toEqual({
type: NodeType.CodeBlock,
language: 'multi',
content: '```multi\n',
});
expect(ast[1]).toEqual({type: NodeType.Text, content: '`'});
});
describe('Edge cases and error handling', () => {
test('invalid heading levels', () => {
const input = '##### Too many hashes';
const flags = ParserFlags.ALLOW_HEADINGS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '##### Too many hashes'}]);
});
test('multiline blockquote content handling', () => {
const input = '>>> First line\nSecond line without prefix';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.Text);
});
test('invalid multiline blockquote start', () => {
const input = '>> Not a multiline blockquote';
const flags = ParserFlags.ALLOW_BLOCKQUOTES;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toEqual([{type: NodeType.Text, content: '>> Not a multiline blockquote'}]);
});
test('string cache performance optimization', () => {
const repeatedInput = '# Same heading\n# Same heading\n# Same heading';
const flags = ParserFlags.ALLOW_HEADINGS;
const parser = new Parser(repeatedInput, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(3);
expect(ast[0].type).toBe(NodeType.Heading);
expect(ast[1].type).toBe(NodeType.Heading);
expect(ast[2].type).toBe(NodeType.Heading);
});
test('code block without closing fence', () => {
const input = '```js\nconst x = 1;\n// no closing fence';
const flags = ParserFlags.ALLOW_CODE_BLOCKS;
const parser = new Parser(input, flags);
const {nodes: ast} = parser.parse();
expect(ast).toHaveLength(1);
expect(ast[0].type).toBe(NodeType.CodeBlock);
});
});
});