snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
import { parse } from '../src';
import { paragraph, plain, bold, italic, strike } from './helpers';
test.each([
[
`This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()_+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok~, from now on we repeat some. , REPEATx2 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()_+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok~, from now on we repeat some. REPEAT x3 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()_+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok~, from now on we repeat some. REPEAT x4 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()_+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok~, from now on we repeat some. REPEATx 5 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()_+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok~, from now on we repeat some. , REPEAT x6 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()_+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok~, from now on we repeat some. this can go long for some time, repeat x7 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()_+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok~, from now on we repeat some. ,repeat x8 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()_+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok~, from now on we repeat some.`,
[
paragraph([
plain(
'This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&',
),
bold([
plain('()'),
italic([
plain(`+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok`),
strike([
plain(
`, from now on we repeat some. , REPEATx2 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()_+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok`,
),
]),
plain(
', from now on we repeat some. REPEAT x3 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()',
),
]),
plain(`+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok`),
strike([
plain(
', from now on we repeat some. REPEAT x4 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()',
),
italic([
plain(
`+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok~, from now on we repeat some. REPEATx 5 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()`,
),
]),
plain(`+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok`),
]),
plain(
`, from now on we repeat some. , REPEAT x6 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&`,
),
]),
plain(`()_+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok`),
strike([
plain(
`, from now on we repeat some. this can go long for some time, repeat x7 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()`,
),
italic([
plain(
`+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok~, from now on we repeat some. ,repeat x8 This a message designed to stress test the message parser, trying to force several rules to stack at the same time !!@#$%^&*()`,
),
]),
plain(`+, overloading the symbols {}:"|<>?, some more text ,./;'\\[], numbers too 1234567890-= let it call s o s ok`),
]),
plain(', from now on we repeat some.'),
]),
],
],
[
'**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__**_**__',
[
paragraph([
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
italic([bold([plain('_')])]),
bold([italic([plain('**')]), italic([plain('**')])]),
plain('__'),
]),
],
],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,10 @@
import { parse } from '../src';
import { paragraph, plain } from './helpers';
test.each([
['free text', [paragraph([plain('free text')])]],
['free text, with comma', [paragraph([plain('free text, with comma')])]],
['free text with unxpected/unfinished blocks *bold_', [paragraph([plain('free text with unxpected/unfinished blocks *bold_')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,141 @@
import { BlockSplitter, BlockType } from '../src/BlockSplitter';
describe('BlockSplitter', () => {
it('should split simple paragraphs', () => {
const input = 'Hello\nWorld';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe(BlockType.PARAGRAPH);
expect(blocks[0].content).toBe('Hello\nWorld');
});
it('should identify headings', () => {
const input = '# Heading 1\n## Heading 2\nContent';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(3);
expect(blocks[0].type).toBe(BlockType.HEADING);
expect(blocks[0].level).toBe(1);
expect(blocks[1].type).toBe(BlockType.HEADING);
expect(blocks[1].level).toBe(2);
});
it('should identify code blocks', () => {
const input = 'Pre\n```javascript\nconst a = 1;\n```\nPost';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(3);
expect(blocks[1].type).toBe(BlockType.CODE);
expect(blocks[1].language).toBe('javascript');
expect(blocks[1].content).toBe('const a = 1;');
});
it('should handle list splitting and preserve full syntax', () => {
const input = '- item 1\n* item 2\n1. item 3';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe(BlockType.LIST);
expect(blocks[0].content).toBe('- item 1\n* item 2\n1. item 3');
});
it('should handle nested lists via indentation', () => {
const input = '- Level 1\n - Level 2\n - Level 3';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].content).toBe('- Level 1\n - Level 2\n - Level 3');
});
it('should allow indented blank lines to continue a list', () => {
const input = '- item 1\n \n- item 2';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].content).toBe('- item 1\n \n- item 2');
});
it('should correctly detect boundaries: list followed by heading', () => {
const input = '- list item\n\n# Heading';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(2);
expect(blocks[0].type).toBe(BlockType.LIST);
expect(blocks[1].type).toBe(BlockType.HEADING);
});
it('should identify blockquotes and preserve markers', () => {
const input = '> quote line 1\n> quote line 2';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe(BlockType.QUOTE);
expect(blocks[0].content).toBe('> quote line 1\n> quote line 2');
});
it('should support nested blockquotes', () => {
const input = '> outer\n>> inner';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe(BlockType.QUOTE);
expect(blocks[0].content).toBe('> outer\n>> inner');
});
it('should set ordered to undefined for mixed ordered and unordered list items', () => {
const input = '- unordered\n1. ordered';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe(BlockType.LIST);
expect(blocks[0].ordered).toBeUndefined();
});
it('should keep ordered=true for fully ordered lists', () => {
const input = '1. first\n2. second';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe(BlockType.LIST);
expect(blocks[0].ordered).toBe(true);
});
it('should keep ordered=false for fully unordered lists', () => {
const input = '- first\n* second';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe(BlockType.LIST);
expect(blocks[0].ordered).toBe(false);
});
it('should create a new paragraph block after a list block', () => {
const input = '- list item\n\nParagraph text';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(2);
expect(blocks[0].type).toBe(BlockType.LIST);
expect(blocks[1].type).toBe(BlockType.PARAGRAPH);
expect(blocks[1].content).toBe('Paragraph text');
});
it('should create a new paragraph block after a quote block', () => {
const input = '> blockquote\n\nParagraph text';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(2);
expect(blocks[0].type).toBe(BlockType.QUOTE);
expect(blocks[1].type).toBe(BlockType.PARAGRAPH);
expect(blocks[1].content).toBe('Paragraph text');
});
it('should handle empty input correctly', () => {
const input = '';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(0);
});
it('should yield a CODE block with incomplete flag for an unclosed code fence', () => {
const input = '```js\ncode';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe(BlockType.CODE);
expect(blocks[0].content).toBe('code');
expect(blocks[0].incomplete).toBe(true);
});
it('should treat a heading without a space as a paragraph', () => {
const input = '#NoSpace';
const blocks = BlockSplitter.split(input);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe(BlockType.PARAGRAPH);
expect(blocks[0].content).toBe('#NoSpace');
});
});

View File

@@ -0,0 +1,47 @@
import { parse } from '../src';
import { paragraph, plain, quote, bold } from './helpers';
test.each([
[
`
As Rocket Cat said:
> meowww
> grr.
`.trim(),
[paragraph([plain('As Rocket Cat said:')]), quote([paragraph([plain('meowww')]), paragraph([plain('grr.')])])],
],
[
`
As Rocket Cat said:
> *meowww*
> grr.
`.trim(),
[paragraph([plain('As Rocket Cat said:')]), quote([paragraph([bold([plain('meowww')])]), paragraph([plain('grr.')])])],
],
[
`
As Rocket Cat said:
>meowww
>grr.
`.trim(),
[paragraph([plain('As Rocket Cat said:')]), quote([paragraph([plain('meowww')]), paragraph([plain('grr.')])])],
],
[
`
> meowww
>
> grr.
`.trim(),
[quote([paragraph([plain('meowww')]), paragraph([plain('')]), paragraph([plain('grr.')])])],
],
[
`
> meowww
>
> grr.
`.trim(),
[quote([paragraph([plain('meowww')]), paragraph([plain('')]), paragraph([plain('grr.')])])],
],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,92 @@
import { parse } from '../src';
import { paragraph, plain, codeLine, code } from './helpers';
const multiply = <T>(a: number, element: T): Array<T> => Array.from({ length: a }, () => element);
test.each([
[
`\`\`\`
code
code
\`\`\``,
[code([codeLine(plain('code')), ...multiply(4, codeLine(plain(''))), codeLine(plain('code'))])],
],
[
`\`\`\`
code
\`\`\``,
[code([codeLine(plain('code')), ...multiply(2, codeLine(plain('')))])],
],
[
`\`\`\`
code
\`\`\``,
[code([codeLine(plain('code'))])],
],
[
`\`\`\`
var a = "teste";
\`\`\``,
[code([codeLine(plain('var a = "teste";'))])],
],
[
`\`\`\`javascript
code
\`\`\``,
[code([codeLine(plain('code'))], 'javascript')],
],
[
`\`\`\`bash c
code
\`\`\``,
[code([codeLine(plain('code'))], 'bash c')],
],
[
` \`\`\`
code
\`\`\``,
[paragraph([plain(` \`\`\``)]), paragraph([plain(`code`)]), paragraph([plain(`\`\`\``)])],
],
[
`\`\`\`
code
code
\`\`\``,
[code([codeLine(plain(`code`)), codeLine(plain(`code`))])],
],
[
`\`\`\`
# code
**code**
\`\`\``,
[code([codeLine(plain(`# code`)), codeLine(plain(`**code**`))])],
],
// lines ending in inline code (trailing backtick) must not break the fence
[
`\`\`\`
- **Node**: \`22.22.3\`
- **Apps-Engine**: \`1.64.0\`
\`\`\``,
[code([codeLine(plain('- **Node**: `22.22.3`')), codeLine(plain('- **Apps-Engine**: `1.64.0`'))])],
],
[
`\`\`\`
foo\`
\`\`\``,
[code([codeLine(plain('foo`'))])],
],
[
`\`\`\`
foo\`\`
\`\`\``,
[code([codeLine(plain('foo``'))])],
],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,15 @@
import { parse } from '../src';
import { color, paragraph, plain } from './helpers';
test.each([
['color:#ccc', [paragraph([color(0xcc, 0xcc, 0xcc)])], [paragraph([plain('color:#ccc')])]],
['color:#cccc', [paragraph([color(0xcc, 0xcc, 0xcc, 0xcc)])], [paragraph([plain('color:#cccc')])]],
['color:#c7c7c7', [paragraph([color(0xc7, 0xc7, 0xc7)])], [paragraph([plain('color:#c7c7c7')])]],
['color:#c7c7c7c7', [paragraph([color(0xc7, 0xc7, 0xc7, 0xc7)])], [paragraph([plain('color:#c7c7c7c7')])]],
['color:#c7c7c7c7c7', [paragraph([plain('color:#c7c7c7c7c7')])], undefined],
['color:#c7', [paragraph([plain('color:#c7')])], undefined],
['color:#zzz', [paragraph([plain('color:#zzz')])], undefined],
])('parses %p', (input, output, disabledOutput) => {
expect(parse(input, { colors: true })).toEqual(output);
expect(parse(input, { colors: false })).toEqual(disabledOutput ?? output);
});

View File

@@ -0,0 +1,51 @@
import { parse } from '../src';
import { link, paragraph, plain } from './helpers';
test.each([
['joe@joe.com', [paragraph([link('mailto:joe@joe.com', [plain('joe@joe.com')])])]],
["joe@joe.com is Joe's email", [paragraph([link('mailto:joe@joe.com', [plain('joe@joe.com')]), plain(" is Joe's email")])]],
[
"Joe's email is joe@joe.com because it is",
[paragraph([plain("Joe's email is "), link('mailto:joe@joe.com', [plain('joe@joe.com')]), plain(' because it is')])],
],
["Joe's email is joe@joe.com", [paragraph([plain("Joe's email is "), link('mailto:joe@joe.com', [plain('joe@joe.com')])])]],
[
"Joe's email is joe@joe.com. Try emailing him",
[paragraph([plain("Joe's email is "), link('mailto:joe@joe.com', [plain('joe@joe.com')]), plain('. Try emailing him')])],
],
[
"Joe's email is joe.smith@joe.com",
[paragraph([plain("Joe's email is "), link('mailto:joe.smith@joe.com', [plain('joe.smith@joe.com')])])],
],
["Joe's email is JOE@JOE.COM", [paragraph([plain("Joe's email is "), link('mailto:JOE@JOE.COM', [plain('JOE@JOE.COM')])])]],
[
"Joe's email is (joe@joe.com)",
[paragraph([plain("Joe's email is ("), link('mailto:joe@joe.com', [plain('joe@joe.com')]), plain(')')])],
],
[
"Joe's email is (joe_roe@joe.com)",
[paragraph([plain("Joe's email is ("), link('mailto:joe_roe@joe.com', [plain('joe_roe@joe.com')]), plain(')')])],
],
[
"Joe's email is (joe'roe@joe.com)",
[paragraph([plain("Joe's email is ("), link("mailto:joe'roe@joe.com", [plain("joe'roe@joe.com")]), plain(')')])],
],
[
"Joe's email is mañana@mañana.com",
[paragraph([plain("Joe's email is "), link('mailto:mañana@mañana.com', [plain('mañana@mañana.com')])])],
],
[
"Joe's email is Кириллица@Кириллица.com",
[paragraph([plain("Joe's email is "), link('mailto:Кириллица@Кириллица.com', [plain('Кириллица@Кириллица.com')])])],
],
['Hi there@stuff', [paragraph([plain('Hi there@stuff')])]],
[
'My email is busueng.kim@aaa.com',
[paragraph([plain('My email is '), link('mailto:busueng.kim@aaa.com', [plain('busueng.kim@aaa.com')])])],
],
['My email is mailto:asdf@asdf.com', [paragraph([plain('My email is '), link('mailto:asdf@asdf.com', [plain('asdf@asdf.com')])])]],
['My email is fake@gmail.c', [paragraph([plain('My email is fake@gmail.c')])]],
['My email is fake@gmail.comf', [paragraph([plain('My email is fake@gmail.comf')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,160 @@
import { parse } from '../src';
import { ALL_EMOJI, EMOJIBASE_VERSION } from './fixtures/allEmoji';
import { emoji, bigEmoji, paragraph, plain, emojiUnicode } from './helpers';
test.each([
[':smile: asd', [paragraph([emoji('smile'), plain(' asd')])]],
[':smile:asd', [paragraph([plain(':smile:asd')])]],
['text:inner:outer', [paragraph([plain('text:inner:outer')])]],
['10:20:30', [paragraph([plain('10:20:30')])]],
['10:20:30:', [paragraph([plain('10:20:30:')])]],
['":smile:"', [paragraph([plain('":smile:"')])]],
['":smile: "', [paragraph([plain('":smile: "')])]],
['" :smile: "', [paragraph([plain('" '), emoji('smile'), plain(' "')])]],
[
`:smile:
:smile:
`,
[bigEmoji([emoji('smile'), emoji('smile')])],
],
['asdas :smile: asd', [paragraph([plain('asdas '), emoji('smile'), plain(' asd')])]],
[
'normal emojis :smile: :smile: :smile:',
[paragraph([plain('normal emojis '), emoji('smile'), plain(' '), emoji('smile'), plain(' '), emoji('smile')])],
],
[':smile::smile::smile:', [bigEmoji([emoji('smile'), emoji('smile'), emoji('smile')])]],
[' :smile::smile::smile: ', [bigEmoji([emoji('smile'), emoji('smile'), emoji('smile')])]],
['\n :smile::smile::smile: \n', [bigEmoji([emoji('smile'), emoji('smile'), emoji('smile')])]],
[':smile: :smile: :smile:', [bigEmoji([emoji('smile'), emoji('smile'), emoji('smile')])]],
[':smile::smile:', [bigEmoji([emoji('smile'), emoji('smile')])]],
[':smile:a:smile:', [paragraph([plain(':smile:a:smile:')])]],
[':smile:', [bigEmoji([emoji('smile')])]],
['Hi :+1:', [paragraph([plain('Hi '), emoji('+1')])]],
['Hi :+1_tone4:', [paragraph([plain('Hi '), emoji('+1_tone4')])]],
['Hi :thumbsup_tone3:', [paragraph([plain('Hi '), emoji('thumbsup_tone3')])]],
['Hi :yes_tone3:', [paragraph([plain('Hi '), emoji('yes_tone3')])]],
['Hi :clapping_hands_tone2:', [paragraph([plain('Hi '), emoji('clapping_hands_tone2')])]],
['Hi :pray_tone4:', [paragraph([plain('Hi '), emoji('pray_tone4')])]],
['Hi :regional_indicator_a:', [paragraph([plain('Hi '), emoji('regional_indicator_a')])]],
['Hi :digit_zero:', [paragraph([plain('Hi '), emoji('digit_zero')])]],
['Hi :pound_symbol:', [paragraph([plain('Hi '), emoji('pound_symbol')])]],
['Hi :asterisk_symbol:', [paragraph([plain('Hi '), emoji('asterisk_symbol')])]],
['Hi :tone3:', [paragraph([plain('Hi '), emoji('tone3')])]],
[':thumbsup_tone3:', [bigEmoji([emoji('thumbsup_tone3')])]],
[':regional_indicator_a:', [bigEmoji([emoji('regional_indicator_a')])]],
[':digit_zero:', [bigEmoji([emoji('digit_zero')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});
// Tests for unicode emojis
test.each([
['😀', [bigEmoji([emojiUnicode('😀')])]],
['😃', [bigEmoji([emojiUnicode('😃')])]],
['🥵', [bigEmoji([emojiUnicode('🥵')])]],
['🧿', [bigEmoji([emojiUnicode('🧿')])]],
['🐶', [bigEmoji([emojiUnicode('🐶')])]],
['🍏', [bigEmoji([emojiUnicode('🍏')])]],
['⚽', [bigEmoji([emojiUnicode('⚽')])]],
['⚽️', [bigEmoji([emojiUnicode('⚽️')])]],
['👨‍👩‍👧‍👦', [bigEmoji([emojiUnicode('👨‍👩‍👧‍👦')])]],
['🚗', [bigEmoji([emojiUnicode('🚗')])]],
['⌚️', [bigEmoji([emojiUnicode('⌚️')])]],
['❤️', [bigEmoji([emojiUnicode('❤️')])]],
['🏳️', [bigEmoji([emojiUnicode('🏳️')])]],
['🧑🏾‍💻', [bigEmoji([emojiUnicode('🧑🏾‍💻')])]],
['🧑🏾‍💻🧑🏾‍💻', [bigEmoji([emojiUnicode('🧑🏾‍💻'), emojiUnicode('🧑🏾‍💻')])]],
['🧑🏾‍💻🧑🏾‍💻🧑🏾‍💻', [bigEmoji([emojiUnicode('🧑🏾‍💻'), emojiUnicode('🧑🏾‍💻'), emojiUnicode('🧑🏾‍💻')])]],
['👆🏽', [bigEmoji([emojiUnicode('👆🏽')])]],
['👆🏽👆🏽', [bigEmoji([emojiUnicode('👆🏽'), emojiUnicode('👆🏽')])]],
['👆🏽👆🏽👆🏽', [bigEmoji([emojiUnicode('👆🏽'), emojiUnicode('👆🏽'), emojiUnicode('👆🏽')])]],
['👆🏺', [bigEmoji([emojiUnicode('👆'), emojiUnicode('🏺')])]],
['✋🏽', [bigEmoji([emojiUnicode('✋🏽')])]],
['✌🏽', [bigEmoji([emojiUnicode('✌🏽')])]],
['✍🏽', [bigEmoji([emojiUnicode('✍🏽')])]],
['☝🏽', [bigEmoji([emojiUnicode('☝🏽')])]],
['⛹🏽', [bigEmoji([emojiUnicode('⛹🏽')])]],
['✋🏽✌🏽', [bigEmoji([emojiUnicode('✋🏽'), emojiUnicode('✌🏽')])]],
['✋🏺', [bigEmoji([emojiUnicode('✋'), emojiUnicode('🏺')])]],
['Hi ✋🏽', [paragraph([plain('Hi '), emojiUnicode('✋🏽')])]],
['Hi 👍', [paragraph([plain('Hi '), emojiUnicode('👍')])]],
// Symbols and Pictographs Extended-A (U+1FA00–U+1FAFF) — Unicode 13+/14+
['🫠', [bigEmoji([emojiUnicode('🫠')])]],
['🫡', [bigEmoji([emojiUnicode('🫡')])]],
['🫶', [bigEmoji([emojiUnicode('🫶')])]],
['Hi 🫠', [paragraph([plain('Hi '), emojiUnicode('🫠')])]],
// ZWJ sequences involving Dingbats (❤️ U+2764) — couple/kiss emojis
['👨‍❤️‍💋‍👨', [bigEmoji([emojiUnicode('👨‍❤️‍💋‍👨')])]],
['👩‍❤️‍💋‍👩', [bigEmoji([emojiUnicode('👩‍❤️‍💋‍👩')])]],
['👨‍❤️‍👨', [bigEmoji([emojiUnicode('👨‍❤️‍👨')])]],
['💑', [bigEmoji([emojiUnicode('💑')])]],
['Hi 👨‍❤️‍💋‍👨', [paragraph([plain('Hi '), emojiUnicode('👨‍❤️‍💋‍👨')])]],
// ZWJ sequences involving Misc Symbols (⚕️, ⚖️, ✈️) — profession emojis
['👨‍⚕️', [bigEmoji([emojiUnicode('👨‍⚕️')])]],
['👩‍⚖️', [bigEmoji([emojiUnicode('👩‍⚖️')])]],
['👨‍✈️', [bigEmoji([emojiUnicode('👨‍✈️')])]],
['🩷', [bigEmoji([emojiUnicode('🩷')])]], // v15
['🫱🏿‍🫲🏻', [bigEmoji([emojiUnicode('🫱🏿‍🫲🏻')])]], // v14
['🫩', [bigEmoji([emojiUnicode('🫩')])]], // v16
['🇨🇶', [bigEmoji([emojiUnicode('🇨🇶')])]], // v16
// ZWJ sequences anchored by an Emoticon (U+1F600-U+1F64F)
['😶‍🌫️', [bigEmoji([emojiUnicode('😶‍🌫️')])]],
['😮‍💨', [bigEmoji([emojiUnicode('😮‍💨')])]],
['😵‍💫', [bigEmoji([emojiUnicode('😵‍💫')])]],
['Hi 😶‍🌫️', [paragraph([plain('Hi '), emojiUnicode('😶‍🌫️')])]],
['😶‍🌫️😮‍💨', [bigEmoji([emojiUnicode('😶‍🌫️'), emojiUnicode('😮‍💨')])]],
['🙋🏽‍♂️', [bigEmoji([emojiUnicode('🙋🏽‍♂️')])]],
['😀', [bigEmoji([emojiUnicode('😀')])]], // standalone emoticon still matches
// Emoji tag sequences — England/Scotland/Wales flags
['🏴󠁧󠁢󠁥󠁮󠁧󠁿', [bigEmoji([emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿')])]],
['🏴󠁧󠁢󠁳󠁣󠁴󠁿', [bigEmoji([emojiUnicode('🏴󠁧󠁢󠁳󠁣󠁴󠁿')])]],
['🏴󠁧󠁢󠁷󠁬󠁳󠁿', [bigEmoji([emojiUnicode('🏴󠁧󠁢󠁷󠁬󠁳󠁿')])]],
['Hi 🏴󠁧󠁢󠁥󠁮󠁧󠁿', [paragraph([plain('Hi '), emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿')])]],
['🏴', [bigEmoji([emojiUnicode('🏴')])]], // standalone black flag still matches
['🏴‍☠️', [bigEmoji([emojiUnicode('🏴‍☠️')])]], // pirate flag (ZWJ, not tag sequence) still matches
// Consecutive sequence emojis — each sequence must be consumed whole, without stealing from its neighbor
['🏴󠁧󠁢󠁥󠁮󠁧󠁿🏴󠁧󠁢󠁳󠁣󠁴󠁿🏴󠁧󠁢󠁷󠁬󠁳󠁿', [bigEmoji([emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿'), emojiUnicode('🏴󠁧󠁢󠁳󠁣󠁴󠁿'), emojiUnicode('🏴󠁧󠁢󠁷󠁬󠁳󠁿')])]],
['🏴🏴󠁧󠁢󠁥󠁮󠁧󠁿', [bigEmoji([emojiUnicode('🏴'), emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿')])]],
['🏴󠁧󠁢󠁥󠁮󠁧󠁿🏴', [bigEmoji([emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿'), emojiUnicode('🏴')])]],
['😶‍🌫️😮‍💨😵‍💫', [bigEmoji([emojiUnicode('😶‍🌫️'), emojiUnicode('😮‍💨'), emojiUnicode('😵‍💫')])]],
['😀😶‍🌫️', [bigEmoji([emojiUnicode('😀'), emojiUnicode('😶‍🌫️')])]],
['😶‍🌫️🏴󠁧󠁢󠁥󠁮󠁧󠁿🏴‍☠️', [bigEmoji([emojiUnicode('😶‍🌫️'), emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿'), emojiUnicode('🏴‍☠️')])]],
// More than 3 emojis: no BigEmoji, plain paragraph of emoji tokens
['😶‍🌫️😮‍💨😵‍💫👍', [paragraph([emojiUnicode('😶‍🌫️'), emojiUnicode('😮‍💨'), emojiUnicode('😵‍💫'), emojiUnicode('👍')])]],
['England 🏴󠁧󠁢󠁥󠁮󠁧󠁿 and Scotland 🏴󠁧󠁢󠁳󠁣󠁴󠁿', [paragraph([plain('England '), emojiUnicode('🏴󠁧󠁢󠁥󠁮󠁧󠁿'), plain(' and Scotland '), emojiUnicode('🏴󠁧󠁢󠁳󠁣󠁴󠁿')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});
// KNOWN_UNSUPPORTED: the 26 regional indicators. Alone they're just letters, not emojis; combined
// in a pair they form a flag (🇺 + 🇸 = 🇺🇸). Real letter emojis (🅰️ Ⓜ️) are supported. Stale check
// below keeps the list honest.
// prettier-ignore
const KNOWN_UNSUPPORTED = new Set('🇦 🇧 🇨 🇩 🇪 🇫 🇬 🇭 🇮 🇯 🇰 🇱 🇲 🇳 🇴 🇵 🇶 🇷 🇸 🇹 🇺 🇻 🇼 🇽 🇾 🇿'.split(' '));
function parsesWhole(input: string): boolean {
const nodes = parse(input);
if (!Array.isArray(nodes) || nodes.length !== 1) {
return false;
}
const [node] = nodes;
if (node.type !== 'BIG_EMOJI' || node.value.length !== 1) {
return false;
}
const [child] = node.value;
if (child.type !== 'EMOJI' || !('unicode' in child)) {
return false;
}
return child.unicode === input;
}
test(`every emoji parses as one token, except documented components (emojibase@${EMOJIBASE_VERSION})`, () => {
expect(ALL_EMOJI.length).toBeGreaterThan(3000);
const inFixture = new Set(ALL_EMOJI);
expect(ALL_EMOJI.filter((e) => !KNOWN_UNSUPPORTED.has(e) && !parsesWhole(e))).toEqual([]);
expect([...KNOWN_UNSUPPORTED].filter((e) => !inFixture.has(e) || parsesWhole(e))).toEqual([]);
});

View File

@@ -0,0 +1,70 @@
import { parse } from '../src';
import { bigEmoji, paragraph, plain, emoticon } from './helpers';
test.each([
// Should render normal Emojis
[
`test
:) test`,
[paragraph([plain('test')]), paragraph([plain(' '), emoticon(':)', 'slight_smile'), plain(' test')])],
],
[':) asd', [paragraph([emoticon(':)', 'slight_smile'), plain(' asd')])]],
[' :) asd', [paragraph([plain(' '), emoticon(':)', 'slight_smile'), plain(' asd')])]],
['Hi :)', [paragraph([plain('Hi '), emoticon(':)', 'slight_smile')])]],
['asdas :) asd', [paragraph([plain('asdas '), emoticon(':)', 'slight_smile'), plain(' asd')])]],
[
':) :) :) :)',
[
paragraph([
emoticon(':)', 'slight_smile'),
plain(' '),
emoticon(':)', 'slight_smile'),
plain(' '),
emoticon(':)', 'slight_smile'),
plain(' '),
emoticon(':)', 'slight_smile'),
]),
],
],
// Should render BigEmojis
[
`:)
:)
`,
[bigEmoji([emoticon(':)', 'slight_smile'), emoticon(':)', 'slight_smile')])],
],
[':):):)', [bigEmoji([emoticon(':)', 'slight_smile'), emoticon(':)', 'slight_smile'), emoticon(':)', 'slight_smile')])]],
[' :):):) ', [bigEmoji([emoticon(':)', 'slight_smile'), emoticon(':)', 'slight_smile'), emoticon(':)', 'slight_smile')])]],
['\n :):):) \n', [bigEmoji([emoticon(':)', 'slight_smile'), emoticon(':)', 'slight_smile'), emoticon(':)', 'slight_smile')])]],
[':) :) :)', [bigEmoji([emoticon(':)', 'slight_smile'), emoticon(':)', 'slight_smile'), emoticon(':)', 'slight_smile')])]],
[':) :)', [bigEmoji([emoticon(':)', 'slight_smile'), emoticon(':)', 'slight_smile')])]],
[':)', [bigEmoji([emoticon(':)', 'slight_smile')])]],
[' :)', [bigEmoji([emoticon(':)', 'slight_smile')])]],
[':) ', [bigEmoji([emoticon(':)', 'slight_smile')])]],
[' :) ', [bigEmoji([emoticon(':)', 'slight_smile')])]],
['D:', [bigEmoji([emoticon('D:', 'fearful')])]],
['D: D:', [bigEmoji([emoticon('D:', 'fearful'), emoticon('D:', 'fearful')])]],
[' D: D: D: ', [bigEmoji([emoticon('D:', 'fearful'), emoticon('D:', 'fearful'), emoticon('D:', 'fearful')])]],
['Hi D:', [paragraph([plain('Hi '), emoticon('D:', 'fearful')])]],
// Should not render Emojis or BigEmojis if they are not surrounded by spaces
['normal emojis :):):)', [paragraph([plain('normal emojis :):):)')])]],
[':)10:30', [paragraph([plain(':)10:30')])]],
[':smile::)text', [paragraph([plain(':smile::)text')])]],
['text:):smile:', [paragraph([plain('text:):smile:')])]],
['text:):)', [paragraph([plain('text:):)')])]],
[':):):) normal emojis', [paragraph([plain(':):):) normal emojis')])]],
[':):):):)', [paragraph([plain(':):):):)')])]],
['10:30', [paragraph([plain('10:30')])]],
['he:)llo', [paragraph([plain('he:)llo')])]],
[':)Hi', [paragraph([plain(':)Hi')])]],
['Hi:) Hi', [paragraph([plain('Hi:) Hi')])]],
['Hi:)', [paragraph([plain('Hi:)')])]],
['@#@#! :)@!@', [paragraph([plain('@#@#! :)@!@')])]],
])('parses %p', (input, output) => {
expect(parse(input, { emoticons: true })).toEqual(output);
});

View File

@@ -0,0 +1,130 @@
import { parse } from '../src';
import {
italic,
paragraph,
plain,
strike,
bold,
emoji,
link,
bigEmoji,
emojiUnicode,
mentionChannel,
mentionUser,
inlineCode,
} from './helpers';
test.each([
['_:smile:_', [paragraph([italic([emoji('smile')])])]],
['_:slight_smile:_', [paragraph([italic([emoji('slight_smile')])])]],
['_test :smile: test_', [paragraph([italic([plain('test '), emoji('smile'), plain(' test')])])]],
['_test :slight_smile: test_', [paragraph([italic([plain('test '), emoji('slight_smile'), plain(' test')])])]],
['_😀_', [paragraph([italic([emojiUnicode('😀')])])]],
['_test 😀_', [paragraph([italic([plain('test '), emojiUnicode('😀')])])]],
['_test @guilherme.gazzo test_', [paragraph([italic([plain('test '), mentionUser('guilherme.gazzo'), plain(' test')])])]],
['_test #GENERAL test_', [paragraph([italic([plain('test '), mentionChannel('GENERAL'), plain(' test')])])]],
[
'_[A brand new Gist](https://gist.github.com/24dddfa97bef58f46ac2ce0f80c58ba4)_',
[paragraph([italic([link('https://gist.github.com/24dddfa97bef58f46ac2ce0f80c58ba4', [plain('A brand new Gist')])])])],
],
['__italic__', [paragraph([italic([plain('italic')])])]],
['__italic__non', [paragraph([plain('__italic__non')])]],
['__test__test__', [paragraph([plain('__test__test__')])]],
['pre__italic__post', [paragraph([plain('pre__italic__post')])]],
[' pre__italic__post', [paragraph([plain(' pre__italic__post')])]],
[' pre__**~~boldstrikeitalic~~**__post ', [paragraph([plain(' pre__'), bold([strike([plain('boldstrikeitalic')])]), plain('__post ')])]],
['__', [paragraph([plain('__')])]],
['_ _', [paragraph([plain('_ _')])]],
['__ _', [paragraph([plain('__ _')])]],
['__ __', [paragraph([plain('__ __')])]],
['_ Hello_', [paragraph([italic([plain(' Hello')])])]],
['_Hello _', [paragraph([italic([plain('Hello ')])])]],
[':custom_emoji_case:', [bigEmoji([emoji('custom_emoji_case')])]],
['_Hel lo_', [paragraph([italic([plain('Hel lo')])])]],
['_Hello_', [paragraph([italic([plain('Hello')])])]],
['__Hello__', [paragraph([italic([plain('Hello')])])]],
['__Hello_', [paragraph([plain('_'), italic([plain('Hello')])])]],
['_Hello__', [paragraph([italic([plain('Hello')]), plain('_')])]],
['_Hello', [paragraph([plain('_Hello')])]],
['Hello_', [paragraph([plain('Hello_')])]],
['He_llo', [paragraph([plain('He_llo')])]],
['___Hello___', [paragraph([plain('_'), italic([plain('Hello')]), plain('_')])]],
['___Hello__', [paragraph([plain('_'), italic([plain('Hello')])])]],
['_Hello_ this is dog', [paragraph([italic([plain('Hello')]), plain(` this is dog`)])]],
['Rocket cat says _Hello_', [paragraph([plain(`Rocket cat says `), italic([plain('Hello')])])]],
['He said _Hello_ to her', [paragraph([plain(`He said `), italic([plain('Hello')]), plain(` to her`)])]],
['__Hello__ this is dog', [paragraph([italic([plain('Hello')]), plain(` this is dog`)])]],
['Rocket cat says __Hello__', [paragraph([plain(`Rocket cat says `), italic([plain('Hello')])])]],
['He said __Hello__ to her', [paragraph([plain(`He said `), italic([plain('Hello')]), plain(` to her`)])]],
['text_hello_text', [paragraph([plain('text_hello_text')])]],
['_hello_text', [paragraph([plain('_hello_text')])]],
['text_hello_', [paragraph([plain('text_hello_')])]],
['_italic@test_', [paragraph([italic([plain('italic@test')])])]],
['_italic#test_', [paragraph([italic([plain('italic#test')])])]],
['paragraph@test__', [paragraph([plain('paragraph@test__')])]],
['_ @guilherme_gazzo_ _', [paragraph([italic([plain(' '), mentionUser('guilherme_gazzo_'), plain(' ')])])]],
['_ @guilherme.gazzo _', [paragraph([italic([plain(' '), mentionUser('guilherme.gazzo'), plain(' ')])])]],
['_9797.76_', [paragraph([italic([plain('9797.76')])])]],
['_3.14_', [paragraph([italic([plain('3.14')])])]],
['_example.com_', [paragraph([italic([plain('example.com')])])]],
['_rocket.chat_', [paragraph([italic([plain('rocket.chat')])])]],
['_example.com', [paragraph([plain('_example.com')])]],
[
'**reference link inside [emphasis with more [references](https://rocket.chat)](https://rocket.chat)**',
[
paragraph([
bold([
plain('reference link inside '),
link('https://rocket.chat', [plain('emphasis with more [references')]),
plain('](https://rocket.chat)'),
]),
]),
],
],
['_ouch_ouch', [paragraph([plain('_ouch_ouch')])]],
[`_@mention _gone`, [paragraph([plain('_'), mentionUser('mention'), plain(' _gone')])]],
['_nothing `should` be _gone', [paragraph([plain('_nothing '), inlineCode(plain('should')), plain(' be _gone')])]],
[
'**bold ~~and strike~~** **not bold ~~but strike** ~~ not strike~~',
[
paragraph([
bold([plain('bold '), strike([plain('and strike')])]),
plain(' **not bold '),
strike([plain('but strike** ')]),
plain(' not strike~~'),
]),
],
],
[
'**bold** **another bold** ~~strike~~ ~~another strike~~ **bold ~~and strike~~** **not bold ~~but strike** ~~ not strike~~',
[
paragraph([
bold([plain('bold')]),
plain(' '),
bold([plain('another bold')]),
plain(' '),
strike([plain('strike')]),
plain(' '),
strike([plain('another strike')]),
plain(' '),
bold([plain('bold '), strike([plain('and strike')])]),
plain(' **not bold '),
strike([plain('but strike** ')]),
plain(' not strike~~'),
]),
],
],
['some_snake_case_text and even_more', [paragraph([plain('some_snake_case_text and even_more')])]],
[
'some_snake_case_text and some __italic__ text',
[paragraph([plain('some_snake_case_text and some '), italic([plain('italic')]), plain(' text')])],
],
['some__double__snake__case__text and even_more', [paragraph([plain('some__double__snake__case__text and even_more')])]],
[
'some__double__snake__case__text and some __italic__ text',
[paragraph([plain('some__double__snake__case__text and some '), italic([plain('italic')]), plain(' text')])],
],
['something__ __and italic__', [paragraph([plain('something__ '), italic([plain('and italic')])])]],
])('parses %p', (input, output) => {
expect(parse(input, { emoticons: false })).toEqual(output);
});

View File

@@ -0,0 +1,58 @@
import { parse } from '../src';
import { paragraph, plain, bold, italic, emoticon } from './helpers';
test.each([
['*test:*', [paragraph([bold([plain('test:')])])]],
['_test:_', [paragraph([italic([plain('test:')])])]],
[
// :* is an emoticon, but it shouldn't be parsed as one if its glued to a word
'*bold ending with colon:*',
[paragraph([bold([plain('bold ending with colon:')])])],
],
['*bold ending with colon:* and some more text', [paragraph([bold([plain('bold ending with colon:')]), plain(' and some more text')])]],
['*bold ending with colon :*and some more text', [paragraph([bold([plain('bold ending with colon :')]), plain('and some more text')])]],
[
'*bold with a kissing emoji :* *',
[paragraph([bold([plain('bold with a kissing emoji '), emoticon(':*', 'kissing_heart'), plain(' ')])])],
],
['*bold with a broken kissing emoji:**', [paragraph([bold([plain('bold with a broken kissing emoji:')]), plain('*')])]],
[
'*bold with a broken kissing emoji:*:*',
[paragraph([bold([plain('bold with a broken kissing emoji:')]), emoticon(':*', 'kissing_heart')])],
],
['*bold with a broken kissing emoji :*text *', [paragraph([bold([plain('bold with a broken kissing emoji :')]), plain('text *')])]],
['*broken bold with a kissing emoji :*', [paragraph([plain('*broken bold with a kissing emoji '), emoticon(':*', 'kissing_heart')])]],
[
'*broken bold with a kissing emoji :* ',
[paragraph([plain('*broken bold with a kissing emoji '), emoticon(':*', 'kissing_heart'), plain(' ')])],
],
['*two bolds**second*', [paragraph([bold([plain('two bolds')]), bold([plain('second')])])]],
[
'*two bolds*:**separated by kissing emoji*',
[paragraph([bold([plain('two bolds')]), emoticon(':*', 'kissing_heart'), bold([plain('separated by kissing emoji')])])],
],
['_test-_-', [paragraph([italic([plain('test-')]), plain('-')])]],
['_test -_- _', [paragraph([italic([plain('test '), emoticon('-_-', 'expressionless'), plain(' ')])])]],
['_test -_-_', [paragraph([italic([plain('test '), emoticon('-_-', 'expressionless')])])]],
['_two italics__second_', [paragraph([italic([plain('two italics')]), italic([plain('second')])])]],
['_italic with broken emoticon-_-_', [paragraph([italic([plain('italic with broken emoticon-')]), plain('-_')])]],
[
'_italic with broken emoticon-_-_ and more text',
[paragraph([italic([plain('italic with broken emoticon-')]), plain('-_ and more text')])],
],
[
'_italic with broken emoticon -_-and more text',
[paragraph([italic([plain('italic with broken emoticon -')]), plain('-and more text')])],
],
[
'_italic with broken emoticon -_-and more text -_-',
[paragraph([italic([plain('italic with broken emoticon -')]), plain('-and more text '), emoticon('-_-', 'expressionless')])],
],
[
'_italic with broken emoticon -_-and more text -_-_',
[paragraph([italic([plain('italic with broken emoticon -')]), plain('-and more text -'), italic([plain('-')])])],
],
['-_-italic content-_-', [paragraph([plain('-'), italic([plain('-italic content-')]), plain('-')])]],
])('parses emphasisWithEmoticons %p', (input, output) => {
expect(parse(input, { emoticons: true })).toEqual(output);
});

View File

@@ -0,0 +1,21 @@
import { parse } from '../src';
import { paragraph, plain, bold } from './helpers';
test.each([
['¯\\\\_(ツ)_/¯', [paragraph([plain('¯\\_(ツ)_/¯')])]],
['\\*escaped as*bold*escaped*', [paragraph([plain('*escaped as'), bold([plain('bold')]), plain('escaped*')])]],
['\\*not bold*', [paragraph([plain('*not bold*')])]],
['*_~`#.'.split('').join('\\'), [paragraph([plain('*_~`#.')])]],
['\\*not emphasized*', [paragraph([plain('*not emphasized*')])]],
['\\<br/> tag plain text', [paragraph([plain('\\<br/> tag plain text')])]],
['\\[it is not a link](/foo)', [paragraph([plain('\\[it is not a link](/foo)')])]],
['\\`not code`', [paragraph([plain('`not code`')])]],
['1\\. not a list', [paragraph([plain('1. not a list')])]],
['\\* not a list', [paragraph([plain('* not a list')])]],
['\\# not a heading', [paragraph([plain('# not a heading')])]],
['\\[foo]: /url "not a reference"', [paragraph([plain('\\[foo]: /url "not a reference"')])]],
['\\&ouml; not a character entity', [paragraph([plain('\\&ouml; not a character entity')])]],
['\\<t:1708551317:R>', [paragraph([plain('<t:1708551317:R>')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,52 @@
import fc from 'fast-check';
import { parse } from '../src';
const plainTextArbitrary = fc.stringMatching(/[a-zA-Z0-9 ]{1,20}/).filter((value) => value.length > 0);
const timestampArbitrary = fc.integer({ min: 1_000_000_000, max: 2_000_000_000 }).map((value) => `<t:${value}>`);
const relativeTimestampArbitrary = fc.integer({ min: 1_000_000_000, max: 2_000_000_000 }).map((value) => `<t:${value}:R>`);
const mentionArbitrary = fc.stringMatching(/[a-z0-9._-]{1,12}/).map((value) => `@${value}`);
const boldArbitrary = plainTextArbitrary.map((value) => `*${value}*`);
const strikeArbitrary = fc.oneof(timestampArbitrary, relativeTimestampArbitrary, plainTextArbitrary).map((value) => `~${value}~`);
const inlineCodeArbitrary = plainTextArbitrary.map((value) => `\`${value}\``);
const parserInputArbitrary = fc
.array(
fc.oneof(
plainTextArbitrary,
timestampArbitrary,
relativeTimestampArbitrary,
mentionArbitrary,
boldArbitrary,
strikeArbitrary,
inlineCodeArbitrary,
),
{ minLength: 1, maxLength: 8 },
)
.map((parts) => parts.join(' '));
describe('parser fuzz tests', () => {
test('parses valid parser-like inputs without throwing', () => {
fc.assert(
fc.property(parserInputArbitrary, (input) => {
expect(() => parse(input)).not.toThrow();
}),
{ numRuns: 300 },
);
});
test('is deterministic for valid parser-like inputs', () => {
fc.assert(
fc.property(parserInputArbitrary, (input) => {
expect(parse(input)).toEqual(parse(input));
}),
{ numRuns: 300 },
);
});
});

View File

@@ -0,0 +1,62 @@
import { isNodeOfType, parse } from '../src';
import type { Image, OrderedList, Timestamp } from '../src/definitions';
import { timestamp } from '../src/utils';
describe('isNodeOfType', () => {
it('narrows timestamp nodes', () => {
const value: unknown = timestamp('1708551317');
expect(isNodeOfType(value, 'TIMESTAMP')).toBe(true);
if (!isNodeOfType(value, 'TIMESTAMP')) {
throw new Error('expected TIMESTAMP node');
}
const narrowed: Timestamp = value;
expect(value.value.timestamp).toBe('1708551317');
expect(value.value.format).toBe('t');
expect(narrowed.value.format).toBe('t');
});
it('narrows ordered list nodes from parser output', () => {
const value: unknown = parse('1. one')[0];
expect(isNodeOfType(value, 'ORDERED_LIST')).toBe(true);
if (!isNodeOfType(value, 'ORDERED_LIST')) {
throw new Error('expected ORDERED_LIST node');
}
const narrowed: OrderedList = value;
expect(value.value[0].number).toBe(1);
expect(narrowed.value[0].number).toBe(1);
});
it('narrows image nodes from parser output', () => {
const paragraph: unknown = parse('![logo](https://rocket.chat/logo.png)')[0];
expect(isNodeOfType(paragraph, 'PARAGRAPH')).toBe(true);
if (!isNodeOfType(paragraph, 'PARAGRAPH')) {
throw new Error('expected PARAGRAPH node');
}
const imageNode: unknown = paragraph.value[0];
expect(isNodeOfType(imageNode, 'IMAGE')).toBe(true);
if (!isNodeOfType(imageNode, 'IMAGE')) {
throw new Error('expected IMAGE node');
}
const narrowed: Image = imageNode;
expect(imageNode.value.src.value).toBe('https://rocket.chat/logo.png');
expect(narrowed.value.src.value).toBe('https://rocket.chat/logo.png');
});
it('returns false for mismatched types', () => {
const value: unknown = timestamp('1708551317');
expect(isNodeOfType(value, 'ORDERED_LIST')).toBe(false);
});
});

View File

@@ -0,0 +1,52 @@
import { parse } from '../src';
import { emoji, heading, link, mentionChannel, paragraph, plain } from './helpers';
test.each([
['# h1', [heading([plain('h1')], 1)]],
['# Hello', [heading([plain('Hello')], 1)]],
['# Rocket.Cat', [heading([link('//Rocket.Cat', [plain('Rocket.Cat')])], 1)]],
['# Hi', [heading([plain('Hi')], 1)]],
['# Hello this is dog', [heading([plain('Hello this is dog')], 1)]],
['# Rocket cat says Hello', [heading([plain('Rocket cat says Hello')], 1)]],
['# He said Hello to her', [heading([plain('He said Hello to her')], 1)]],
['#Hello', [paragraph([mentionChannel('Hello')])]],
['#Hello#', [paragraph([mentionChannel('Hello'), plain('#')])]],
['He#llo', [paragraph([plain('He#llo')])]],
['## Hello', [heading([plain('Hello')], 2)]],
['## Rocket.Cat', [heading([link('//Rocket.Cat', [plain('Rocket.Cat')])], 2)]],
['## Hi', [heading([plain('Hi')], 2)]],
['## Hello this is dog', [heading([plain('Hello this is dog')], 2)]],
['## Rocket cat says Hello', [heading([plain('Rocket cat says Hello')], 2)]],
['## He said Hello to her', [heading([plain('He said Hello to her')], 2)]],
['##Hello', [paragraph([plain('##Hello')])]],
['##Hello##', [paragraph([plain('##Hello##')])]],
['He##llo', [paragraph([plain('He##llo')])]],
['### Hello', [heading([plain('Hello')], 3)]],
['### Rocket.Cat', [heading([link('//Rocket.Cat', [plain('Rocket.Cat')])], 3)]],
['### Hi', [heading([plain('Hi')], 3)]],
['### Hello this is dog', [heading([plain('Hello this is dog')], 3)]],
['### Rocket cat says Hello', [heading([plain('Rocket cat says Hello')], 3)]],
['### He said Hello to her', [heading([plain('He said Hello to her')], 3)]],
['###Hello', [paragraph([plain('###Hello')])]],
['###Hello###', [paragraph([plain('###Hello###')])]],
['He###llo', [paragraph([plain('He###llo')])]],
['#### Hello', [heading([plain('Hello')], 4)]],
['#### Rocket.Cat', [heading([link('//Rocket.Cat', [plain('Rocket.Cat')])], 4)]],
['#### Hi', [heading([plain('Hi')], 4)]],
['#### Hello this is dog', [heading([plain('Hello this is dog')], 4)]],
['#### Rocket cat says Hello', [heading([plain('Rocket cat says Hello')], 4)]],
['#### He said Hello to her', [heading([plain('He said Hello to her')], 4)]],
['####Hello', [paragraph([plain('####Hello')])]],
['####Hello####', [paragraph([plain('####Hello####')])]],
['He####llo', [paragraph([plain('He####llo')])]],
['# Hello\n', [heading([plain('Hello')], 1)]],
['# # Hello\n', [heading([plain('# Hello')], 1)]],
['# :newspaper: Headline', [heading([emoji('newspaper'), plain(' Headline')], 1)]],
['## Hello :smile:', [heading([plain('Hello '), emoji('smile')], 2)]],
['### :smile: text :newspaper:', [heading([emoji('smile'), plain(' text '), emoji('newspaper')], 3)]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,127 @@
const generate =
<Type extends string>(type: Type) =>
<Value>(value: Value) =>
({ type, value }) as const;
export const paragraph = generate('PARAGRAPH');
export const bold = generate('BOLD');
export const color = (r: number, g: number, b: number, a = 255) => ({
type: 'COLOR' as const,
value: { r, g, b, a },
});
export const heading = (value: unknown[], level = 1) => ({
type: 'HEADING' as const,
level,
value,
});
export const code = (value: unknown[], language = 'none') => ({
type: 'CODE' as const,
language,
value,
});
export const bigEmoji = generate('BIG_EMOJI');
export const task = (value: unknown[], status: boolean) => ({ type: 'TASK' as const, status, value });
export const inlineCode = generate('INLINE_CODE');
export const tasks = generate('TASKS');
export const italic = generate('ITALIC');
export const spoiler = generate('SPOILER');
export const plain = generate('PLAIN_TEXT');
export const strike = generate('STRIKE');
export const codeLine = generate('CODE_LINE');
export const link = (src: string, label?: unknown[]) => ({
type: 'LINK' as const,
value: { src: plain(src), label: label ?? [plain(src)] },
});
export const image = (src: string, label?: unknown) => ({
type: 'IMAGE' as const,
value: { src: plain(src), label: label ?? plain(src) },
});
export const quote = generate('QUOTE');
export const spoilerBlock = generate('SPOILER_BLOCK');
export const mentionChannel = (value: string) => ({
type: 'MENTION_CHANNEL' as const,
value: plain(value),
});
export const orderedList = generate('ORDERED_LIST');
export const unorderedList = generate('UNORDERED_LIST');
export const listItem = (value: unknown[], number?: number) => ({
type: 'LIST_ITEM' as const,
value,
...(number !== undefined ? { number } : {}),
});
export const mentionUser = (value: string) => ({
type: 'MENTION_USER' as const,
value: plain(value),
});
export const emoji = (shortCode: string) => ({
type: 'EMOJI' as const,
value: plain(shortCode),
shortCode,
});
export const emojiUnicode = (unicode: string) => ({
type: 'EMOJI' as const,
value: undefined,
unicode,
});
export const emoticon = (value: string, shortCode: string) => ({
type: 'EMOJI' as const,
value: plain(value),
shortCode,
});
export const lineBreak = () => ({
type: 'LINE_BREAK' as const,
value: undefined,
});
export const horizontalRule = (fallback?: [number, number]) => ({
type: 'HORIZONTAL_RULE' as const,
value: undefined,
...(fallback !== undefined ? { fallback } : {}),
});
type Align = 'left' | 'center' | 'right' | undefined;
export const tableCell = (value: unknown[], align: Align = undefined) => ({
type: 'TABLE_CELL' as const,
align,
value,
});
export const tableRow = (value: unknown[]) => ({
type: 'TABLE_ROW' as const,
value,
});
export const table = (header: unknown[], rows: unknown[], fallback?: [number, number]) => ({
type: 'TABLE' as const,
value: { header, rows },
...(fallback !== undefined ? { fallback } : {}),
});
export const katex = (value: string) => ({
type: 'KATEX' as const,
value,
});
export const inlineKatex = (value: string) => ({
type: 'INLINE_KATEX' as const,
value,
});

View File

@@ -0,0 +1,39 @@
import { parse } from '../src';
import { bold, horizontalRule, paragraph, plain } from './helpers';
// Only dashes form a horizontal rule; `fallback` is the [start, end] offset span of the run.
test.each([
['---', [0, 3] as [number, number]],
['----------', [0, 10] as [number, number]],
[' --- ', [3, 6] as [number, number]],
])('parses %p as a horizontal rule', (input, range) => {
expect(parse(input)).toEqual([horizontalRule(range)]);
// the offsets slice back to the markers
expect(input.slice(range[0], range[1])).toBe(input.trim());
});
test('parses a horizontal rule between paragraphs', () => {
expect(parse('above\n---\nbelow')).toEqual([paragraph([plain('above')]), horizontalRule([6, 9]), paragraph([plain('below')])]);
});
// `*` and `_` are NOT horizontal rules: they collide with emphasis and with censored
// words (bad-words masks a term as a run of `*`), so a bare `***` / `_______` / `*******`
// line must stay text/emphasis rather than becoming a divider.
test.each(['***', '___', '*******', '_______'])('does not treat %p as a horizontal rule', (input) => {
const [node] = parse(input);
expect(node.type).not.toBe('HORIZONTAL_RULE');
});
test.each([
// fewer than three markers -> plain text
['--', [paragraph([plain('--')])]],
['**', [paragraph([plain('**')])]],
// emphasis must not be swallowed by the rule (parsed as a paragraph, not a rule)
['***bold italic***', [paragraph([plain('*'), bold([plain('bold italic')]), plain('*')])]],
// spaced markers are whitespace-emphasis (plain), not a rule
['** **', [paragraph([plain('** **')])]],
// list item, not a rule
['- item', [{ type: 'UNORDERED_LIST', value: [{ type: 'LIST_ITEM', value: [plain('item')] }] }]],
])('does not treat %p as a horizontal rule', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,12 @@
import { parse } from '../src';
import { image, paragraph, plain } from './helpers';
test.each([
[
'![image](https://rocket.chat/assets/img/header/logo.svg)',
[paragraph([image('https://rocket.chat/assets/img/header/logo.svg', plain('image'))])],
],
['![](https://rocket.chat/assets/img/header/logo.svg)', [paragraph([image('https://rocket.chat/assets/img/header/logo.svg')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,12 @@
import { parse } from '../src';
import { inlineCode, paragraph, plain } from './helpers';
test.each([
['`[asd](https://localhost)`', [paragraph([inlineCode(plain('[asd](https://localhost)'))])]],
[`\`code\``, [paragraph([inlineCode(plain('code'))])]],
[`File extension (\`.mov\`)`, [paragraph([plain('File extension ('), inlineCode(plain('.mov')), plain(')')])]],
['`@rocket.chat`', [paragraph([inlineCode(plain('@rocket.chat'))])]],
['`@rocket.chat/message-parser`', [paragraph([inlineCode(plain('@rocket.chat/message-parser'))])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,14 @@
import { parse } from '../src';
import { bold, inlineCode, italic, paragraph, plain, strike } from './helpers';
test.each([
['~~`Striking Inline Code`~~', [paragraph([strike([inlineCode(plain('Striking Inline Code'))])])]],
['~~_`Striking Inline Code with Italics`_~~', [paragraph([strike([italic([inlineCode(plain('Striking Inline Code with Italics'))])])])]],
['~~**`Striking Inline Code with Bold`**~~', [paragraph([strike([bold([inlineCode(plain('Striking Inline Code with Bold'))])])])]],
[
'~~__*`Striking Inline Code with Bold`*__~~',
[paragraph([strike([italic([bold([inlineCode(plain('Striking Inline Code with Bold'))])])])])],
],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,25 @@
import { plain, emoji, reducePlainTexts } from '../src/utils';
describe('joinEmoji behavior through reducePlainTexts', () => {
it('keeps emoji when alone', () => {
const result = reducePlainTexts([emoji('smile')]);
expect(result[0].type).toBe('EMOJI');
});
it('merges consecutive plain texts', () => {
const result = reducePlainTexts([plain('hello '), plain('world')]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({ type: 'PLAIN_TEXT', value: 'hello world' });
});
it('converts emoji with plain text neighbors to shortCode and merges', () => {
const result = reducePlainTexts([plain('hello'), emoji('smile'), plain('world')]);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
type: 'PLAIN_TEXT',
value: 'hello:smile:world',
});
});
});

View File

@@ -0,0 +1,42 @@
import { parse } from '../src';
import { inlineKatex, katex, paragraph, plain } from './helpers';
test.each([
[
`\\[
\\f\\relax{x} = \\int_{-\\infty}^\\infty
\\f\\hat\\xi\\,e^{2 \\pi i \\xi x}
\\,d\\xi
\\]`,
[
katex(`
\\f\\relax{x} = \\int_{-\\infty}^\\infty
\\f\\hat\\xi\\,e^{2 \\pi i \\xi x}
\\,d\\xi
`),
],
],
['Easy as \\(E = mc^2\\), right?', [paragraph([plain('Easy as '), inlineKatex('E = mc^2'), plain(', right?')])]],
])('parses %p', (input, output) => {
expect(parse(input, { katex: { parenthesisSyntax: true } })).toEqual(output);
});
test.each([
['$$E = mc^2$$', [katex('E = mc^2')]],
['$E = mc^2$', [paragraph([inlineKatex('E = mc^2')])]],
['Easy as $E = mc^2$, right?', [paragraph([plain('Easy as '), inlineKatex('E = mc^2'), plain(', right?')])]],
['$$\n\\sum_{i=1}^{n} x_i\n$$', [katex('\n\\sum_{i=1}^{n} x_i\n')]],
['$$\\frac{a}{b} + \\sqrt{c}$$', [katex('\\frac{a}{b} + \\sqrt{c}')]],
['$a$ text $b$', [paragraph([inlineKatex('a'), plain(' text '), inlineKatex('b')])]],
['$x$ and $y$', [paragraph([inlineKatex('x'), plain(' and '), inlineKatex('y')])]],
])('parses %p', (input, output) => {
expect(parse(input, { katex: { dollarSyntax: true } })).toMatchObject(output);
});
test.each([
['$$E = mc^2$$', [paragraph([plain('$$E = mc^2$$')])]],
['$E = mc^2$', [paragraph([plain('$E = mc^2$')])]],
['\\(E = mc^2\\)', [paragraph([plain('\\(E = mc^2\\)')])]],
])('parses KaTeX as plain text when syntax options are disabled: %p', (input, output) => {
expect(parse(input)).toMatchObject(output);
});

View File

@@ -0,0 +1,29 @@
import { parse } from '../src';
import { lineBreak, paragraph, plain } from './helpers';
test.each([
[
`test
test2`,
[paragraph([plain('test')]), lineBreak(), paragraph([plain('test2')])],
],
[
`test
test2
`,
[paragraph([plain('test')]), lineBreak(), paragraph([plain('test2')])],
],
[
`test
test2
`,
[paragraph([plain('test')]), lineBreak(), lineBreak(), lineBreak(), paragraph([plain('test2')])],
],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,417 @@
import { parse } from '../src';
import { link, paragraph, plain, bold, strike, italic, quote, lineBreak, unorderedList, listItem, orderedList } from './helpers';
test.each([
['<https://domain.com|Test>', [paragraph([link('https://domain.com', [plain('Test')])])]],
[
`<https://domain.com|Test
>`,
[paragraph([plain('<https://domain.com|Test')]), paragraph([plain('>')])],
],
[
`<https://domain.com|Test
> quote here`,
[paragraph([plain('<https://domain.com|Test')]), quote([paragraph([plain('quote here')])])],
],
[
'[Link](https://domain.com/link?a=%28node_filesystem_avail_bytes%29)',
[paragraph([link('https://domain.com/link?a=%28node_filesystem_avail_bytes%29', [plain('Link')])])],
],
['[](https://rocket.chat)', [paragraph([link('https://rocket.chat')])]],
['[ ](https://rocket.chat)', [paragraph([link('https://rocket.chat', [plain(' ')])])]],
['[ test](https://rocket.chat)', [paragraph([link('https://rocket.chat', [plain(' test')])])]],
['[ test ](https://rocket.chat)', [paragraph([link('https://rocket.chat', [plain(' test ')])])]],
['[title](https://rocket.chat)', [paragraph([link('https://rocket.chat', [plain('title')])])]],
['[title](http://localhost)', [paragraph([link('http://localhost', [plain('title')])])]],
['[title](http://localhost?testing=true)', [paragraph([link('http://localhost?testing=true', [plain('title')])])]],
['[**title**](https://rocket.chat)', [paragraph([link('https://rocket.chat', [bold([plain('title')])])])]],
['[~~title~~](https://rocket.chat)', [paragraph([link('https://rocket.chat', [strike([plain('title')])])])]],
['[__title__](https://rocket.chat)', [paragraph([link('https://rocket.chat', [italic([plain('title')])])])]],
['[__**~~title~~**__](https://rocket.chat)', [paragraph([link('https://rocket.chat', [italic([bold([strike([plain('title')])])])])])]],
[
'https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351',
[paragraph([link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351')])],
],
[
'<https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351|Test>',
[paragraph([link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [plain('Test')])])],
],
[
'[title](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351)',
[paragraph([link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [plain('title')])])],
],
[
'[**title**](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [bold([plain('title')])]),
]),
],
],
[
'[~~title~~](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [strike([plain('title')])]),
]),
],
],
[
'[__title__](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [italic([plain('title')])]),
]),
],
],
[
'[__**~~title~~**__](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [
italic([bold([strike([plain('title')])])]),
]),
]),
],
],
[
'[title](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351?query=test12-34)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351?query=test12-34', [plain('title')]),
]),
],
],
[
'[title](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do?query=test12-34#Cases/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do?query=test12-34#Cases/dv/413244000073043351', [plain('title')]),
]),
],
],
[
'[title](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351?query=test12-34&query2=abc123)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351?query=test12-34&query2=abc123', [
plain('title'),
]),
]),
],
],
[
'[title](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases?query=test12-34&query2=abcd!e/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases?query=test12-34&query2=abcd!e/dv/413244000073043351', [
plain('title'),
]),
]),
],
],
[
'[title](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351?query=test12-34&query2=abcd!~-._%2B+)',
[
paragraph([
link(
'https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351?query=test12-34&query2=abcd!~-._%2B+',
[plain('title')],
),
]),
],
],
['google.com', [paragraph([link('//google.com', [plain('google.com')])])]],
['www.google.com', [paragraph([link('//www.google.com', [plain('www.google.com')])])]],
['rocket.chat:8080', [paragraph([link('rocket.chat:8080')])]],
['ShouldNotBeALink', [paragraph([plain('ShouldNotBeALink')])]],
['http:/ google.com', [paragraph([plain('http:/ '), link('//google.com', [plain('google.com')])])]],
['[custom](custom://google.com)', [paragraph([link('custom://google.com', [plain('custom')])])]],
[
'[thing](https://www.thingiverse.com/thing:5451684)',
[paragraph([link('https://www.thingiverse.com/thing:5451684', [plain('thing')])])],
],
['https://t.me/joinchat/chatexample', [paragraph([link('https://t.me/joinchat/chatexample')])]],
[
'[telegram invite](https://t.me/joinchat/chatexample)',
[paragraph([link('https://t.me/joinchat/chatexample', [plain('telegram invite')])])],
],
[
'[Github link with hash](https://github.com/RocketChat/Rocket.Chat/pull/26751/files#diff-c87b108ecf1ede549f8ede68eca840fbb330180b927df0b8a0b4df5d06cbd89b)',
[
paragraph([
link(
'https://github.com/RocketChat/Rocket.Chat/pull/26751/files#diff-c87b108ecf1ede549f8ede68eca840fbb330180b927df0b8a0b4df5d06cbd89b',
[plain('Github link with hash')],
),
]),
],
],
[
'[Github link with hash](https://github.com/RocketChat/Rocket.Chat/pull/26751/files#diff)',
[paragraph([link('https://github.com/RocketChat/Rocket.Chat/pull/26751/files#diff', [plain('Github link with hash')])])],
],
[
'[Github link without hash](https://github.com/RocketChat/Rocket.Chat/pull/26751/files)',
[paragraph([link('https://github.com/RocketChat/Rocket.Chat/pull/26751/files', [plain('Github link without hash')])])],
],
[
'[Link with special chars](https://github.com/RocketChat/Rocket.Chat*[/]^_`{}~)',
[paragraph([link('https://github.com/RocketChat/Rocket.Chat*[/]^_`{}~', [plain('Link with special chars')])])],
],
[
'[Google complex Link](https://www.google.com/url?rct=j&sa=t&url=https://ga.de/freizeit/region-erleben/bonn-und-region-tipps-fuers-wochenende-flohmarkt-rheinaue-weltkindertag-stadtfest_aid-53876987&ct=ga&cd=CAIyHDQ0NzEyYWE3MDA1MGNhNTQ6Y29tOmRlOkRFOlI&usg=AOvVaw3ySYrO9lM0iNSnk43gPVwZ)',
[
paragraph([
link(
'https://www.google.com/url?rct=j&sa=t&url=https://ga.de/freizeit/region-erleben/bonn-und-region-tipps-fuers-wochenende-flohmarkt-rheinaue-weltkindertag-stadtfest_aid-53876987&ct=ga&cd=CAIyHDQ0NzEyYWE3MDA1MGNhNTQ6Y29tOmRlOkRFOlI&usg=AOvVaw3ySYrO9lM0iNSnk43gPVwZ',
[plain('Google complex Link')],
),
]),
],
],
[
'[Rocket.Chat](https://rocket.chat) Inline Text',
[paragraph([link('https://rocket.chat', [plain('Rocket.Chat')]), plain(' Inline Text')])],
],
[
'https://analytics.zoho.com/open-view/123456789 Same Line',
[
paragraph([
link('https://analytics.zoho.com/open-view/123456789', [plain('https://analytics.zoho.com/open-view/123456789')]),
plain(' Same Line'),
]),
],
],
[
`[Rocket.Chat](https://rocket.chat)
Text after in a new line after link`,
[paragraph([link('https://rocket.chat', [plain('Rocket.Chat')])]), paragraph([plain('Text after in a new line after link')])],
],
[
`https://analytics.zoho.com/open-view/123456789
Second line`,
[
paragraph([link('https://analytics.zoho.com/open-view/123456789', [plain('https://analytics.zoho.com/open-view/123456789')])]),
paragraph([plain('Second line')]),
],
],
[
`[Rocket.Chat](https://rocket.chat)
Text after line break`,
[paragraph([link('https://rocket.chat', [plain('Rocket.Chat')])]), lineBreak(), paragraph([plain('Text after line break')])],
],
[
`
[List Header Link](https://rocket.chat)
- First item
- Second item
- Third item
- *Fourth item*
`.trim(),
[
paragraph([link('https://rocket.chat', [plain('List Header Link')])]),
unorderedList([
listItem([plain('First item')]),
listItem([plain('Second item')]),
listItem([plain('Third item')]),
listItem([bold([plain('Fourth item')])]),
]),
],
],
[
`[List Header Link](https://rocket.chat)
7. First item
2. Second item
8. Third item
4. *Fourth item*
15. *Fifteenth item*
`.trim(),
[
paragraph([link('https://rocket.chat', [plain('List Header Link')])]),
orderedList([
listItem([plain('First item')], 7),
listItem([plain('Second item')], 2),
listItem([plain('Third item')], 8),
listItem([bold([plain('Fourth item')])], 4),
listItem([bold([plain('Fifteenth item')])], 15),
]),
],
],
['[9gag](https://9gag.com/)', [paragraph([link('https://9gag.com/', [plain('9gag')])])]],
['[9gag](9gag.com)', [paragraph([link('9gag.com', [plain('9gag')])])]],
['<9gag.com|9gag>', [paragraph([link('9gag.com', [plain('9gag')])])]],
['9gag.com', [paragraph([link('//9gag.com', [plain('9gag.com')])])]],
[
'[notes link](notes://Server/C3257116002CAD60/0/CCAF6BE2824A1F49432588D2001FA73E)',
[paragraph([link('notes://Server/C3257116002CAD60/0/CCAF6BE2824A1F49432588D2001FA73E', [plain('notes link')])])],
],
[
'[File Path](C:/Users/user1/Documents/projects/file.js)',
[paragraph([link('C:/Users/user1/Documents/projects/file.js', [plain('File Path')])])],
],
[
'[Test with **bold** element](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [
plain('Test with '),
bold([plain('bold')]),
plain(' element'),
]),
]),
],
],
[
'[Test with *bold* element](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [
plain('Test with '),
bold([plain('bold')]),
plain(' element'),
]),
]),
],
],
[
'[Test with _italic_ element](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [
plain('Test with '),
italic([plain('italic')]),
plain(' element'),
]),
]),
],
],
[
'[Test with ~strike~ element](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [
plain('Test with '),
strike([plain('strike')]),
plain(' element'),
]),
]),
],
],
[
'[Test with __**~~title~~**__ element](https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351)',
[
paragraph([
link('https://desk.rocket.chat/support/rocketchat/ShowHomePage.do#Cases/dv/413244000073043351', [
plain('Test with '),
italic([bold([strike([plain('title')])])]),
plain(' element'),
]),
]),
],
],
[
'([Github Issue: #24929](https://github.com/RocketChat/Rocket.Chat/issues/24929))',
[paragraph([plain('('), link('https://github.com/RocketChat/Rocket.Chat/issues/24929', [plain('Github Issue: #24929')]), plain(')')])],
],
[
'the [audio_url and video_url for post message attachments](https://developer.rocket.chat/reference/api/rest-api/endpoints/core-endpoints/chat-endpoints/postmessage)',
[
paragraph([
plain('the '),
link('https://developer.rocket.chat/reference/api/rest-api/endpoints/core-endpoints/chat-endpoints/postmessage', [
plain('audio_url and video_url for post message attachments'),
]),
]),
],
],
[
'the [Jira [Task] parentheses not working](rocket.chat)',
[paragraph([plain('the '), link('rocket.chat', [plain('Jira [Task] parentheses not working')])])],
],
[
'the [Jira (Task) parentheses not working](rocket.chat)',
[paragraph([plain('the '), link('rocket.chat', [plain('Jira (Task) parentheses not working')])])],
],
[
'[Jira [Task] parentheses not working](rocket.chat)',
[paragraph([link('rocket.chat', [plain('Jira [Task] parentheses not working')])])],
],
[
'[Jira (Task) parentheses not working](rocket.chat)',
[paragraph([link('rocket.chat', [plain('Jira (Task) parentheses not working')])])],
],
// Should not parse as link
['77.77%', [paragraph([plain('77.77%')])]],
['77.77', [paragraph([plain('77.77')])]],
['https://77.77', [paragraph([plain('https://77.77')])]],
['test.9gag', [paragraph([plain('test.9gag')])]],
[
'[here](https://github.com/RocketChat/Rocket.Chat/releases/tag/6.0.0-rc.3)',
[paragraph([link('https://github.com/RocketChat/Rocket.Chat/releases/tag/6.0.0-rc.3', [plain('here')])])],
],
[
'[ ~ [ ~ [ ~ [ ~ [ ~ [ ~ [ ~ [ ~ [ ~ [ ~ [ ~ [ ~ [ ~ [ ~ [test](https://rocket.chat)',
[
paragraph([
link('https://rocket.chat', [
plain(' '),
strike([plain(' [ ')]),
plain(' [ '),
strike([plain(' [ ')]),
plain(' [ '),
strike([plain(' [ ')]),
plain(' [ '),
strike([plain(' [ ')]),
plain(' [ '),
strike([plain(' [ ')]),
plain(' [ '),
strike([plain(' [ ')]),
plain(' [ '),
strike([plain(' [ ')]),
plain(' [test'),
]),
]),
],
],
[
'[test **bold** and __italic__](https://rocket.chat)',
[paragraph([link('https://rocket.chat', [plain('test '), bold([plain('bold')]), plain(' and '), italic([plain('italic')])])])],
],
[
'[test **bold with __italic__**](https://rocket.chat)',
[paragraph([link('https://rocket.chat', [plain('test '), bold([plain('bold with '), italic([plain('italic')])])])])],
],
// Test case for issue #31418 - text in brackets between two links should not break markdown
[
'[Rocket.Chat] [New release](https://www.rocket.chat/blog/new-starter-pro-plans)',
[paragraph([plain('[Rocket.Chat] '), link('https://www.rocket.chat/blog/new-starter-pro-plans', [plain('New release')])])],
],
// Test case for issue #31766 - multiple links with bracketed text between them
[
'[BUG #11111](https://github.com/) - [BACK] changelog description',
[paragraph([link('https://github.com/', [plain('BUG #11111')]), plain(' - [BACK] changelog description')])],
],
[
'[BUG #11111](https://github.com/) - [BACK] [Another link](https://github.com/)',
[
paragraph([
link('https://github.com/', [plain('BUG #11111')]),
plain(' - [BACK] '),
link('https://github.com/', [plain('Another link')]),
]),
],
],
// Test case for brackets/parentheses in URL parameters
[
'[link](https://example.com/query?this=(is)&a=problem)',
[paragraph([link('https://example.com/query?this=(is)&a=problem', [plain('link')])])],
],
['[link](https://example.com/path/to/func(param))', [paragraph([link('https://example.com/path/to/func(param)', [plain('link')])])]],
['[link](https://example.com/path/(section)/page)', [paragraph([link('https://example.com/path/(section)/page', [plain('link')])])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,22 @@
import { parse } from '../src';
import { paragraph, plain, mentionUser, mentionChannel } from './helpers';
test.each([
['@guilherme.gazzo', [paragraph([mentionUser('guilherme.gazzo')])]],
['@guilherme.gazzo. ', [paragraph([mentionUser('guilherme.gazzo.'), plain(' ')])]],
['#GENERAL', [paragraph([mentionChannel('GENERAL')])]],
['@user:server.com', [paragraph([mentionUser('user:server.com')])]],
['@marcos.defendi:matrix.org', [paragraph([mentionUser('marcos.defendi:matrix.org')])]],
['@username@example.com', [paragraph([mentionUser('username@example.com')])]],
['@099fnd2ee@example.com', [paragraph([mentionUser('099fnd2ee@example.com')])]],
['@téstãçâò', [paragraph([mentionUser('téstãçâò')])]],
['@สมชาย', [paragraph([mentionUser('สมชาย')])]],
['@李祖阳', [paragraph([mentionUser('李祖阳')])]],
['@あおい', [paragraph([mentionUser('あおい')])]],
['@アオイ', [paragraph([mentionUser('アオイ')])]],
['@Владимир', [paragraph([mentionUser('Владимир')])]],
['@Кириллица', [paragraph([mentionUser('Кириллица')])]],
['test @Кириллица test', [paragraph([plain('test '), mentionUser('Кириллица'), plain(' test')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,29 @@
import { parse } from '../src';
import { bold, plain, orderedList, listItem, emoji } from './helpers';
test.each([
[
`
0. Zeroth item
7. First item
2. Second item
8. Third item
4. *Fourth item*
15. *Fifteenth item*
20. :smile:
`.trim(),
[
orderedList([
listItem([plain('Zeroth item')], 0),
listItem([plain('First item')], 7),
listItem([plain('Second item')], 2),
listItem([plain('Third item')], 8),
listItem([bold([plain('Fourth item')])], 4),
listItem([bold([plain('Fifteenth item')])], 15),
listItem([emoji('smile')], 20),
]),
],
],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,26 @@
import { parse } from '../src';
import { link, paragraph, plain, bold } from './helpers';
test.each([
['+07563546725', [paragraph([link('tel:07563546725', [plain('+07563546725')])])]],
['+075-63546725', [paragraph([link('tel:07563546725', [plain('+075-63546725')])])]],
['+(075)-63546725', [paragraph([link('tel:07563546725', [plain('+(075)-63546725')])])]],
['+(075)63546725', [paragraph([link('tel:07563546725', [plain('+(075)63546725')])])]],
['[here](+(075)63546725)', [paragraph([link('tel:07563546725', [plain('here')])])]],
['[**here**](+(075)63546725)', [paragraph([link('tel:07563546725', [bold([plain('here')])])])]],
['+(11)99999-9999', [paragraph([link('tel:11999999999', [plain('+(11)99999-9999')])])]],
['5 +51231', [paragraph([plain('5 '), link('tel:51231', [plain('+51231')])])]],
['5 +51231 5', [paragraph([plain('5 '), link('tel:51231', [plain('+51231')]), plain(' 5')])]],
['+(12)3-45', [paragraph([link('tel:12345', [plain('+(12)3-45')])])]],
['+1.599123', [paragraph([plain('+1.599123')])]],
['1+1=2', [paragraph([plain('1+1=2')])]],
['1+1=2 text', [paragraph([plain('1+1=2 text')])]],
['+1000,00', [paragraph([plain('+1000,00')])]],
['+ 1199999999', [paragraph([plain('+ 1199999999')])]],
['+1234', [paragraph([plain('+1234')])]],
['+(12)3-4', [paragraph([plain('+(12)3-4')])]],
['+123-4', [paragraph([plain('+123-4')])]],
['5+51231', [paragraph([plain('5+51231')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,15 @@
import { parse } from '../src';
describe('Skip Flags Regression (Complexity Audit)', () => {
it('should parse nested formatting across multiple depths without throwing', () => {
for (let d = 1; d <= 50; d++) {
const input = `${'*'.repeat(d)}text${'*'.repeat(d)}`;
expect(() => parse(input)).not.toThrow();
}
});
it('should handle pathological unmatched markers without crashing', () => {
const pathological = '*_~*_~*_~*_~*_~ hello'.repeat(5);
expect(() => parse(pathological)).not.toThrow();
});
});

View File

@@ -0,0 +1,50 @@
import { parse } from '../src';
import { bold, emoji, italic, link, mentionChannel, mentionUser, paragraph, plain, spoiler, strike } from './helpers';
describe('spoiler parsing', () => {
test.each([
['||spoiler||', [paragraph([spoiler([plain('spoiler')])])]],
['||spoiler **bold**||', [paragraph([spoiler([plain('spoiler '), bold([plain('bold')])])])]],
['||__i__ ~~s~~||', [paragraph([spoiler([italic([plain('i')]), plain(' '), strike([plain('s')])])])]],
['||unclosed', [paragraph([plain('||unclosed')])]],
])('parses basic spoilers: %p', (input, output) => {
expect(parse(input)).toEqual(output);
});
test.each([
// Nested markup
['||**bold __italic__**||', [paragraph([spoiler([bold([plain('bold '), italic([plain('italic')])])])])]],
['||~~**strike bold**~~||', [paragraph([spoiler([strike([bold([plain('strike bold')])])])])]],
// With mentions
['||@user mention||', [paragraph([spoiler([mentionUser('user'), plain(' mention')])])]],
['||#channel mention||', [paragraph([spoiler([mentionChannel('channel'), plain(' mention')])])]],
// With links
['||[link text](https://example.com)||', [paragraph([spoiler([link('https://example.com', [plain('link text')])])])]],
// With emoji
['||text :emoji: text||', [paragraph([spoiler([plain('text '), emoji('emoji'), plain(' text')])])]],
// Empty spoiler
['||||', [paragraph([plain('||||')])]],
// Multiple spoilers in text
['||first|| and ||second||', [paragraph([spoiler([plain('first')]), plain(' and '), spoiler([plain('second')])])]],
// Spoiler with special characters
['||special: !@#$%^&*()||', [paragraph([spoiler([plain('special: !@#$%^&*()')])])]],
// Unclosed spoilers should be treated as plain text
['||unclosed spoiler', [paragraph([plain('||unclosed spoiler')])]],
['text ||unclosed', [paragraph([plain('text ||unclosed')])]],
// Single pipe should not be treated as spoiler
['|single pipe|', [paragraph([plain('|single pipe|')])]],
// Spoiler at start and end
['||start|| middle ||end||', [paragraph([spoiler([plain('start')]), plain(' middle '), spoiler([plain('end')])])]],
])('parses edge cases: %p', (input, output) => {
expect(parse(input)).toEqual(output);
});
});

View File

@@ -0,0 +1,30 @@
import { parse } from '../src';
import { lineBreak, paragraph, plain, spoilerBlock } from './helpers';
describe('block spoiler parsing', () => {
test.each([
[
`||
line one
||`,
[spoilerBlock([paragraph([plain('line one')])])],
],
[
`||
line one
line two
||`,
[spoilerBlock([paragraph([plain('line one')]), paragraph([plain('line two')])])],
],
[
`before
||
hidden
||
after`,
[paragraph([plain('before')]), spoilerBlock([paragraph([plain('hidden')])]), lineBreak(), paragraph([plain('after')])],
],
])('parses block spoilers: %p', (input, output) => {
expect(parse(input)).toEqual(output);
});
});

View File

@@ -0,0 +1,48 @@
import { parse } from '../src';
import { emoji, emojiUnicode, link, mentionChannel, mentionUser, paragraph, plain, strike } from './helpers';
test.each([
['~:smile:~', [paragraph([strike([emoji('smile')])])]],
['~test :smile: test~', [paragraph([strike([plain('test '), emoji('smile'), plain(' test')])])]],
['~😀~', [paragraph([strike([emojiUnicode('😀')])])]],
['~test 😀~', [paragraph([strike([plain('test '), emojiUnicode('😀')])])]],
['~@guilherme.gazzo~', [paragraph([strike([mentionUser('guilherme.gazzo')])])]],
['~#GENERAL~', [paragraph([strike([mentionChannel('GENERAL')])])]],
['~test @guilherme.gazzo~', [paragraph([strike([plain('test '), mentionUser('guilherme.gazzo')])])]],
['~test #GENERAL~', [paragraph([strike([plain('test '), mentionChannel('GENERAL')])])]],
[
'~~[A brand new Gist](https://gist.github.com/24dddfa97bef58f46ac2ce0f80c58ba4)~~',
[paragraph([strike([link('https://gist.github.com/24dddfa97bef58f46ac2ce0f80c58ba4', [plain('A brand new Gist')])])])],
],
['~~strike~~', [paragraph([strike([plain('strike')])])]],
['pre~~strike~~post', [paragraph([plain('pre'), strike([plain('strike')]), plain('post')])]],
[' pre~~strike~~post ', [paragraph([plain(' pre'), strike([plain('strike')]), plain('post ')])]],
['~~', [paragraph([plain('~~')])]],
['~ ~', [paragraph([plain('~ ~')])]],
['~~ ~', [paragraph([plain('~~ ~')])]],
['~~ ~~', [paragraph([plain('~~ ~~')])]],
['~ Hello~', [paragraph([strike([plain(' Hello')])])]],
['~Hello ~', [paragraph([strike([plain('Hello ')])])]],
[':custom~emoji~case:', [paragraph([plain(`:custom`), strike([plain('emoji')]), plain(`case:`)])]],
['text~hello~text', [paragraph([plain(`text`), strike([plain('hello')]), plain(`text`)])]],
['~hello~text', [paragraph([strike([plain('hello')]), plain(`text`)])]],
['text~hello~', [paragraph([plain(`text`), strike([plain('hello')])])]],
['~Hel lo~', [paragraph([strike([plain('Hel lo')])])]],
['~Hello~', [paragraph([strike([plain('Hello')])])]],
['~~Hello~~', [paragraph([strike([plain('Hello')])])]],
['~~Hello~', [paragraph([plain(`~`), strike([plain('Hello')])])]],
['~Hello~~', [paragraph([strike([plain('Hello')]), plain(`~`)])]],
['~Hello', [paragraph([plain('~Hello')])]],
['Hello~', [paragraph([plain('Hello~')])]],
['He~llo', [paragraph([plain('He~llo')])]],
['~~~Hello~~~', [paragraph([plain(`~`), strike([plain('Hello')]), plain(`~`)])]],
['~~~Hello~~', [paragraph([plain(`~`), strike([plain('Hello')])])]],
['~Hello~ this is dog', [paragraph([strike([plain('Hello')]), plain(` this is dog`)])]],
['Rocket cat says ~Hello~', [paragraph([plain(`Rocket cat says `), strike([plain('Hello')])])]],
['He said ~Hello~ to her', [paragraph([plain(`He said `), strike([plain('Hello')]), plain(` to her`)])]],
['~~Hello~~ this is dog', [paragraph([strike([plain('Hello')]), plain(` this is dog`)])]],
['Rocket cat says ~~Hello~~', [paragraph([plain(`Rocket cat says `), strike([plain('Hello')])])]],
['He said ~~Hello~~ to her', [paragraph([plain(`He said `), strike([plain('Hello')]), plain(` to her`)])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,68 @@
import { parse } from '../src';
import { bold, link, paragraph, plain, italic, strike, emoji, emojiUnicode, mentionChannel, mentionUser } from './helpers';
test.each([
['*:smile:*', [paragraph([bold([emoji('smile')])])]],
['*test :smile: test*', [paragraph([bold([plain('test '), emoji('smile'), plain(' test')])])]],
['*😀*', [paragraph([bold([emojiUnicode('😀')])])]],
['*test 😀*', [paragraph([bold([plain('test '), emojiUnicode('😀')])])]],
['*@guilherme.gazzo*', [paragraph([bold([mentionUser('guilherme.gazzo')])])]],
['*#GENERAL*', [paragraph([bold([mentionChannel('GENERAL')])])]],
['*test @guilherme.gazzo*', [paragraph([bold([plain('test '), mentionUser('guilherme.gazzo')])])]],
['*test #GENERAL*', [paragraph([bold([plain('test '), mentionChannel('GENERAL')])])]],
[
'*[A brand new Gist](https://gist.github.com/24dddfa97bef58f46ac2ce0f80c58ba4)*',
[paragraph([bold([link('https://gist.github.com/24dddfa97bef58f46ac2ce0f80c58ba4', [plain('A brand new Gist')])])])],
],
['**bold**', [paragraph([bold([plain('bold')])])]],
['** bold**', [paragraph([bold([plain(' bold')])])]],
['** bold **', [paragraph([bold([plain(' bold ')])])]],
['** bo ld **', [paragraph([bold([plain(' bo ld ')])])]],
['pre**bold**', [paragraph([plain('pre'), bold([plain('bold')])])]],
['**bold**pos', [paragraph([bold([plain('bold')]), plain('pos')])]],
['**bold****bold**', [paragraph([bold([plain('bold')]), bold([plain('bold')])])]],
['**bold** **bold**', [paragraph([bold([plain('bold')]), plain(' '), bold([plain('bold')])])]],
['**bold** __italic__', [paragraph([bold([plain('bold')]), plain(' '), italic([plain('italic')])])]],
['**__italicbold__**', [paragraph([bold([italic([plain('italicbold')])])])]],
['plain **__italicbold__**', [paragraph([plain('plain '), bold([italic([plain('italicbold')])])])]],
['plain **__~~strikeitalicbold~~__**', [paragraph([plain('plain '), bold([italic([strike([plain('strikeitalicbold')])])])])]],
['**', [paragraph([plain('**')])]],
['* *', [paragraph([plain('* *')])]],
['** *', [paragraph([plain('** *')])]],
['** **', [paragraph([plain('** **')])]],
['** **', [paragraph([plain('** **')])]],
['* Hello*', [paragraph([bold([plain(' Hello')])])]],
['*Hello *', [paragraph([bold([plain('Hello ')])])]],
[':custom*emoji*case:', [paragraph([plain(':custom'), bold([plain('emoji')]), plain('case:')])]],
['text*hello*text', [paragraph([plain('text'), bold([plain('hello')]), plain('text')])]],
['*hello*text', [paragraph([bold([plain('hello')]), plain('text')])]],
['text*hello*', [paragraph([plain('text'), bold([plain('hello')])])]],
['*Hel lo*', [paragraph([bold([plain('Hel lo')])])]],
['*Hello*', [paragraph([bold([plain('Hello')])])]],
['**Hello*', [paragraph([plain('*'), bold([plain('Hello')])])]],
['*Hello**', [paragraph([bold([plain('Hello')]), plain('*')])]],
['*Hello', [paragraph([plain('*Hello')])]],
['Hello*', [paragraph([plain('Hello*')])]],
['He*llo', [paragraph([plain('He*llo')])]],
['***Hello***', [paragraph([plain('*'), bold([plain('Hello')]), plain('*')])]],
['***Hello**', [paragraph([plain('*'), bold([plain('Hello')])])]],
['*Hello* this is dog', [paragraph([bold([plain('Hello')]), plain(' this is dog')])]],
['Rocket cat says *Hello*', [paragraph([plain('Rocket cat says '), bold([plain('Hello')])])]],
['He said *Hello* to her', [paragraph([plain('He said '), bold([plain('Hello')]), plain(' to her')])]],
['**Hello** this is dog', [paragraph([bold([plain('Hello')]), plain(' this is dog')])]],
['Rocket cat says **Hello**', [paragraph([plain('Rocket cat says '), bold([plain('Hello')])])]],
['He said **Hello** to her', [paragraph([plain('He said '), bold([plain('Hello')]), plain(' to her')])]],
['He was a**nn**oyed', [paragraph([plain('He was a'), bold([plain('nn')]), plain('oyed')])]],
['There are two o in f*oo*tball', [paragraph([plain('There are two o in f'), bold([plain('oo')]), plain('tball')])]],
['*(teste*', [paragraph([bold([plain('(teste')])])]],
['*(teste)*', [paragraph([bold([plain('(teste)')])])]],
['*__~bolditalicstrike~_*', [paragraph([bold([plain('_'), italic([strike([plain('bolditalicstrike')])])])])]],
['*test:*', [paragraph([bold([plain('test:')])])]],
['*bold ending with colon:*', [paragraph([bold([plain('bold ending with colon:')])])]],
['*bold ending with colon:* and some more text', [paragraph([bold([plain('bold ending with colon:')]), plain(' and some more text')])]],
['*bold ending with colon :*and some more text', [paragraph([bold([plain('bold ending with colon :')]), plain('and some more text')])]],
['*bold with a kissing emoji :* *', [paragraph([bold([plain('bold with a kissing emoji :')]), plain(' *')])]],
['*bold with a kissing emoji :* ', [paragraph([bold([plain('bold with a kissing emoji :')]), plain(' ')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,106 @@
import type { Table } from '../src';
import { parse } from '../src';
import { bold, link, plain, table, tableCell, tableRow } from './helpers';
// `fallback` is the [start, end] offset span of the table in the source; since
// each input is exactly the table, that span is [0, input.length].
test.each([
// basic table, no alignment
[
`
| Header 1 | Header 2 |
| -------- | -------- |
| Cell 1 | Cell 2 |
`.trim(),
[tableCell([plain('Header 1')]), tableCell([plain('Header 2')])],
[tableRow([tableCell([plain('Cell 1')]), tableCell([plain('Cell 2')])])],
],
// alignment markers
[
`
| Left | Center | Right |
| :--- | :----: | ----: |
| a | b | c |
`.trim(),
[tableCell([plain('Left')], 'left'), tableCell([plain('Center')], 'center'), tableCell([plain('Right')], 'right')],
[tableRow([tableCell([plain('a')], 'left'), tableCell([plain('b')], 'center'), tableCell([plain('c')], 'right')])],
],
// multiple body rows
[
`
| h |
| - |
| 1 |
| 2 |
`.trim(),
[tableCell([plain('h')])],
[tableRow([tableCell([plain('1')])]), tableRow([tableCell([plain('2')])])],
],
// header only (no body rows)
[
`
| a | b |
| - | - |
`.trim(),
[tableCell([plain('a')]), tableCell([plain('b')])],
[],
],
// inline markup inside cells
[
`
| Name | Link |
| ---- | ---- |
| **bold** | [rc](https://rocket.chat) |
`.trim(),
[tableCell([plain('Name')]), tableCell([plain('Link')])],
[tableRow([tableCell([bold([plain('bold')])]), tableCell([link('https://rocket.chat', [plain('rc')])])])],
],
// escaped pipe inside a cell stays literal
[
`
| a | b |
| - | - |
| x \\| y | z |
`.trim(),
[tableCell([plain('a')]), tableCell([plain('b')])],
[tableRow([tableCell([plain('x | y')]), tableCell([plain('z')])])],
],
])('parses %p', (input, header, rows) => {
expect(parse(input)).toEqual([table(header, rows, [0, input.length])]);
});
test('normalizes ragged body rows to the header column count', () => {
const input = ['| a | b |', '| - | - |', '| 1 |', '| 1 | 2 | 3 |'].join('\n');
const [node] = parse(input) as [Table];
// every row has exactly 2 cells (header count): short row padded, long row truncated
expect(node.value.rows.map((row) => row.value.length)).toEqual([2, 2]);
expect(node.value.rows[0].value[1].value).toEqual([]); // padded empty cell
expect(node.value.rows[1].value.map((cell) => cell.value)).toEqual([[plain('1')], [plain('2')]]); // extra 3rd cell dropped
});
test('exposes the source offsets as fallback for renderers without table support', () => {
const input = '| a | b |\n| - | - |\n| 1 | 2 |';
const [node] = parse(input) as [ReturnType<typeof table>];
expect(node.fallback).toEqual([0, input.length]);
// the offsets slice back to the original markup
expect(input.slice(node.fallback![0], node.fallback![1])).toBe(input);
});
test.each([
// no delimiter row -> not a table, plain paragraph
['| just | text |', [{ type: 'PARAGRAPH', value: [plain('| just | text |')] }]],
// missing trailing pipe -> not a table (v1 requires surrounding pipes)
[
`
| a | b
| - | -
`.trim(),
[
{ type: 'PARAGRAPH', value: [plain('| a | b')] },
{ type: 'PARAGRAPH', value: [plain('| - | -')] },
],
],
])('does not parse %p as table', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,36 @@
import { parse } from '../src';
import { plain, tasks, task, mentionUser, mentionChannel, link, bold, emoji } from './helpers';
test.each([
[
`
- [ ] this is an incomplete item
- [x] this is a complete item
- [x] @mentions, #refs, [links](http://localhost), **formatting**
- [x] list syntax required (any unordered or ordered list supported)
- [ ] :smile:
`.trim(),
[
tasks([
task([plain('this is an incomplete item')], false),
task([plain('this is a complete item')], true),
task(
[
mentionUser('mentions'),
plain(', '),
mentionChannel('refs'),
plain(', '),
link('http://localhost', [plain('links')]),
plain(', '),
bold([plain('formatting')]),
],
true,
),
task([plain('list syntax required (any unordered or ordered list supported)')], true),
task([emoji('smile')], false),
]),
],
],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,80 @@
import { parse } from '../src';
const plain = (value: string) => ({ type: 'PLAIN_TEXT' as const, value });
const paragraph = (value: Array<Record<string, unknown>>) => ({ type: 'PARAGRAPH' as const, value });
const bold = (value: Array<Record<string, unknown>>) => ({ type: 'BOLD' as const, value });
const strike = (value: Array<Record<string, unknown>>) => ({ type: 'STRIKE' as const, value });
// `fallback` is the [start, end] offset span of the raw `<t:...>` in the source.
const timestampNode = (value: string, format: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R' = 't', fallback?: [number, number]) => ({
type: 'TIMESTAMP' as const,
value: {
timestamp: value,
format,
},
...(fallback !== undefined ? { fallback } : {}),
});
const spanOf = (input: string, raw: string): [number, number] => {
const start = input.indexOf(raw);
return [start, start + raw.length];
};
test.each([
['<t:1708551317>', '<t:1708551317>', '1708551317', 't' as const],
['<t:1708551317:R>', '<t:1708551317:R>', '1708551317', 'R' as const],
['hello <t:1708551317>', '<t:1708551317>', '1708551317', 't' as const],
])('parses %p', (input, raw, value, format) => {
const node = timestampNode(value, format, spanOf(input, raw));
const prefix = input.slice(0, input.indexOf(raw));
expect(parse(input)).toEqual([paragraph(prefix ? [plain(prefix), node] : [node])]);
});
test.each([
['<t:1708551317:I>', [paragraph([plain('<t:1708551317:I>')])]],
['<t:17>', [paragraph([plain('<t:17>')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});
test.each([
['~<t:1708551317>~', '<t:1708551317>', '1708551317', 't' as const, strike],
['~<t:1708551317:R>~', '<t:1708551317:R>', '1708551317', 'R' as const, strike],
['*<t:1708551317>*', '<t:1708551317>', '1708551317', 't' as const, bold],
])('parses %p', (input, raw, value, format, wrapper) => {
const node = timestampNode(value, format, spanOf(input, raw));
expect(parse(input)).toEqual([paragraph([wrapper([node])])]);
});
test.each([
['<t:2025-07-22T10:00:00.000+00:00:R>', '1753178400', 'R' as const],
['<t:2025-07-22T10:00:00+00:00:R>', '1753178400', 'R' as const],
['<t:2025-07-24T20:19:58.154+00:00:R>', '1753388398', 'R' as const],
])('parses %p', (input, value, format) => {
const node = timestampNode(value, format, [0, input.length]);
expect(parse(input)).toEqual([paragraph([node])]);
});
describe('relative hour timestamp parsing', () => {
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2025-07-22T00:00:00.000Z'));
});
afterAll(() => {
jest.useRealTimers();
});
test.each([
['<t:10:00:00+00:00:R>', '1753178400', 'R' as const],
['<t:10:00+00:00:R>', '1753178400', 'R' as const],
['<t:10:00:05+00:00>', '1753178405', 't' as const],
['<t:10:00+00:00>', '1753178400', 't' as const],
])('parses %p', (input, value, format) => {
const node = timestampNode(value, format, [0, input.length]);
expect(parse(input)).toEqual([paragraph([node])]);
});
});

View File

@@ -0,0 +1,76 @@
import { parse } from '../src';
import { unorderedList, plain, listItem, bold, emoji } from './helpers';
test.each([
[
`
- First item
- Second item
- Third item
- *Fourth item*
- :smile:
`.trim(),
[
unorderedList([
listItem([plain('First item')]),
listItem([plain('Second item')]),
listItem([plain('Third item')]),
listItem([bold([plain('Fourth item')])]),
listItem([emoji('smile')]),
]),
],
],
[
`
* First item
* Second item
* Third item
* *Fourth item*
`.trim(),
[
unorderedList([
listItem([plain('First item')]),
listItem([plain('Second item')]),
listItem([plain('Third item')]),
listItem([bold([plain('Fourth item')])]),
]),
],
],
[
`
- First item
* Second item
* Third item
* *Fourth item*
`.trim(),
[
unorderedList([listItem([plain('First item')])]),
unorderedList([listItem([plain('Second item')]), listItem([plain('Third item')]), listItem([bold([plain('Fourth item')])])]),
],
],
// [
// `
// * First item
// * Second item
// * Third item
// * Indented item
// * Indented item
// * Fourth item
// `.trim(),
// [paragraph([])],
// ],
// [
// `
// - First item
// - Second item
// - Third item
// - Indented item
// - Indented item
// - Fourth item
// `.trim(),
// [paragraph([])],
// ],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});

View File

@@ -0,0 +1,188 @@
import { parse } from '../src';
import { lineBreak, paragraph, plain, link } from './helpers';
import { autoLink } from '../src/utils';
test.each([
[
'https://pt.wikipedia.org/wiki/Condi%C3%A7%C3%A3o_de_corrida#:~:text=Uma%20condi%C3%A7%C3%A3o%20de%20corrida%20%C3%A9,sequ%C3%AAncia%20ou%20sincronia%20doutros%20eventos',
[
paragraph([
link(
'https://pt.wikipedia.org/wiki/Condi%C3%A7%C3%A3o_de_corrida#:~:text=Uma%20condi%C3%A7%C3%A3o%20de%20corrida%20%C3%A9,sequ%C3%AAncia%20ou%20sincronia%20doutros%20eventos',
),
]),
],
],
['https://pt.wikipedia.org/', [paragraph([link('https://pt.wikipedia.org/')])]],
['https://pt.wikipedia.org/with-hyphen', [paragraph([link('https://pt.wikipedia.org/with-hyphen')])]],
['https://pt.wikipedia.org/with_underscore', [paragraph([link('https://pt.wikipedia.org/with_underscore')])]],
[
'https://www.npmjs.com/package/@rocket.chat/message-parser',
[paragraph([link('https://www.npmjs.com/package/@rocket.chat/message-parser')])],
],
['http:/rocket.chat/teste', [paragraph([plain('http:/rocket.chat/teste')])]],
['https:/rocket.chat/', [paragraph([plain('https:/rocket.chat/')])]],
['https://test', [paragraph([plain('https://test')])]],
['httpsss://rocket.chat/test', [paragraph([link('httpsss://rocket.chat/test')])]],
['https://rocket.chat:3000/test', [paragraph([link('https://rocket.chat:3000/test')])]],
['https://rocket.chat:99999', [paragraph([link('https://rocket.chat:99999')])]],
['https://rocket.chat:99999/test', [paragraph([link('https://rocket.chat:99999/test')])]],
['https://rocket.chat/test?search', [paragraph([link('https://rocket.chat/test?search')])]],
['https://rocket.chat/test?search=test', [paragraph([link('https://rocket.chat/test?search=test')])]],
['https://rocket.chat', [paragraph([link('https://rocket.chat')])]],
['http://127.0.0.1:3000/images/logo/logo.png', [paragraph([link('http://127.0.0.1:3000/images/logo/logo.png')])]],
['https://localhost', [paragraph([link('https://localhost')])]],
['https://localhost:3000', [paragraph([link('https://localhost:3000')])]],
['https://localhost:3000#fragment', [paragraph([link('https://localhost:3000#fragment')])]],
['https://localhost:3000#', [paragraph([link('https://localhost:3000#')])]],
['https://localhost:3000?', [paragraph([link('https://localhost:3000?')])]],
['https://localhost:3000/', [paragraph([link('https://localhost:3000/')])]],
['ftp://user:pass@localhost:21/etc/hosts', [paragraph([link('ftp://user:pass@localhost:21/etc/hosts')])]],
['ssh://test@example.com', [paragraph([link('ssh://test@example.com')])]],
['custom://test@example.com', [paragraph([link('custom://test@example.com')])]],
['ftp://example.com', [paragraph([link('ftp://example.com')])]],
['https://www.thingiverse.com/thing:5451684', [paragraph([link('https://www.thingiverse.com/thing:5451684')])]],
['http://📙.la/❤️', [paragraph([link('http://📙.la/❤️')])]],
[
'https://developer.rocket.chat/reference/api/rest-api#production-security-concerns look at this',
[paragraph([link('https://developer.rocket.chat/reference/api/rest-api#production-security-concerns'), plain(' look at this')])],
],
[
'https://developer.rocket.chat/reference/api/rest-api look at this',
[paragraph([link('https://developer.rocket.chat/reference/api/rest-api'), plain(' look at this')])],
],
[
'https://developer.rocket.chat/reference/api/rest-api#fragment?query=query look at this',
[paragraph([link('https://developer.rocket.chat/reference/api/rest-api#fragment?query=query'), plain(' look at this')])],
],
['https://developer.rocket.chat look at this', [paragraph([link('https://developer.rocket.chat'), plain(' look at this')])]],
[
'https://developer.rocket.chat?query=query look at this',
[paragraph([link('https://developer.rocket.chat?query=query'), plain(' look at this')])],
],
[
'https://developer.rocket.chat?query=query\nline break',
[paragraph([link('https://developer.rocket.chat?query=query')]), paragraph([plain('line break')])],
],
[
'https://developer.rocket.chat?query=query\n\nline break',
[paragraph([link('https://developer.rocket.chat?query=query')]), lineBreak(), paragraph([plain('line break')])],
],
[
'https://developer.rocket.chat?query=query_with_underscore look at this',
[paragraph([link('https://developer.rocket.chat?query=query_with_underscore'), plain(' look at this')])],
],
[
'https://developer.rocket.chat/path_with_underscore look at this',
[paragraph([link('https://developer.rocket.chat/path_with_underscore'), plain(' look at this')])],
],
[
'https://developer.rocket.chat#fragment_with_underscore look at this',
[paragraph([link('https://developer.rocket.chat#fragment_with_underscore'), plain(' look at this')])],
],
['https://developer.rocket.chat followed by text', [paragraph([link('https://developer.rocket.chat'), plain(' followed by text')])]],
[
'two urls https://developer.rocket.chat , https://rocket.chat',
[paragraph([plain('two urls '), link('https://developer.rocket.chat'), plain(' , '), link('https://rocket.chat')])],
],
['https://1developer.rocket.chat', [paragraph([link('https://1developer.rocket.chat')])]],
['https://en.m.wikipedia.org/wiki/Main_Page', [paragraph([link('https://en.m.wikipedia.org/wiki/Main_Page')])]],
['test.1test.com', [paragraph([link('//test.1test.com', [plain('test.1test.com')])])]],
['http://test.e-xample.com', [paragraph([link('http://test.e-xample.com')])]],
['www.n-tv.de', [paragraph([link('//www.n-tv.de', [plain('www.n-tv.de')])])]],
['www.n-tv.de/test, test', [paragraph([link('//www.n-tv.de/test', [plain('www.n-tv.de/test')]), plain(', test')])]],
['www.n-tv.de/, test', [paragraph([link('//www.n-tv.de/', [plain('www.n-tv.de/')]), plain(', test')])]],
['www.n-tv.de, test', [paragraph([link('//www.n-tv.de', [plain('www.n-tv.de')]), plain(', test')])]],
['https://www.n-tv.de, test', [paragraph([link('https://www.n-tv.de', [plain('https://www.n-tv.de')]), plain(', test')])]],
['http://te_st.com', [paragraph([link('http://te_st.com', [plain('http://te_st.com')])])]],
['www.te_st.com', [paragraph([link('//www.te_st.com', [plain('www.te_st.com')])])]],
['[google_search](http://google.com)', [paragraph([link('http://google.com', [plain('google_search')])])]],
['app...https://rocket.chat https://rocket.chat', [paragraph([plain('app...https://rocket.chat '), link('https://rocket.chat')])]],
[
'Hey check it out the best communication platform https://rocket.chat! There is not discussion about it.',
[
paragraph([
plain('Hey check it out the best communication platform '),
link('https://rocket.chat'),
plain('! There is not discussion about it.'),
]),
],
],
['This is a normal phrase.This in another phrase.', [paragraph([plain('This is a normal phrase.This in another phrase.')])]],
[
'https://github.com/RocketChat/Rocket.Chat/releases/tag/6.0.0-rc.3',
[paragraph([link('https://github.com/RocketChat/Rocket.Chat/releases/tag/6.0.0-rc.3')])],
],
[
'https://www.rocket.chat/(W(601))/Main?ScreenId=GI000027',
[paragraph([link('https://www.rocket.chat/(W(601))/Main?ScreenId=GI000027')])],
],
[
'https://rocketchat.atlassian.net/browse/OC-718?filter=10078&jql=%22Defect%20from%5BVersion%20Picker%20(multiple%20versions)%5D%22%20%3D%206.0.0%20AND%20%22Defect%20from%5BVersion%20Picker%20(multiple%20versions)%5D%22%20%3D%206.0.0%20AND%20created%20%3E%3D%20-48h%20ORDER%20BY%20cf%5B10070%5D%20ASC%2C%20status%20ASC%2C%20created%20DESC',
[
paragraph([
link(
'https://rocketchat.atlassian.net/browse/OC-718?filter=10078&jql=%22Defect%20from%5BVersion%20Picker%20(multiple%20versions)%5D%22%20%3D%206.0.0%20AND%20%22Defect%20from%5BVersion%20Picker%20(multiple%20versions)%5D%22%20%3D%206.0.0%20AND%20created%20%3E%3D%20-48h%20ORDER%20BY%20cf%5B10070%5D%20ASC%2C%20status%20ASC%2C%20created%20DESC',
),
]),
],
],
['go to https://www.google.com.', [paragraph([plain('go to '), link('https://www.google.com'), plain('.')])]],
['https://www.google.com.', [paragraph([link('https://www.google.com'), plain('.')])]],
['https://www.google.com!', [paragraph([link('https://www.google.com'), plain('!')])]],
['visit www.google.com.', [paragraph([plain('visit '), link('//www.google.com', [plain('www.google.com')]), plain('.')])]],
])('parses %p', (input, output) => {
expect(parse(input)).toEqual(output);
});
describe('autoLink with custom hosts settings comming from Rocket.Chat', () => {
test.each([
['http://gitlab.local', [paragraph([link('http://gitlab.local', [plain('http://gitlab.local')])])]],
['gitlab.local', [paragraph([link('//gitlab.local', [plain('gitlab.local')])])]],
['internaltool.intranet', [paragraph([link('//internaltool.intranet', [plain('internaltool.intranet')])])]],
])('parses %p', (input, output) => {
expect(parse(input, { customDomains: ['local', 'intranet'] })).toEqual(output);
});
});
describe('autoLink WITHOUT custom hosts settings comming from Rocket.Chat', () => {
test.each([['https://internaltool.testt', [paragraph([plain('https://internaltool.testt')])]]])('parses %p', (input, output) => {
expect(parse(input, { customDomains: ['local'] })).toEqual(output);
});
});
describe('autoLink helper function', () => {
it('should preserve the original protocol if the protocol is http or https', () => {
expect(autoLink('https://rocket.chat/test')).toEqual(link('https://rocket.chat/test'));
expect(autoLink('http://rocket.chat/test')).toEqual(link('http://rocket.chat/test'));
});
it('should preserve the original protocol for invalid absolute URLs', () => {
expect(autoLink('https://rocket.chat:99999')).toMatchObject(link('https://rocket.chat:99999'));
expect(autoLink('https://rocket.chat:65536')).toMatchObject(link('https://rocket.chat:65536'));
});
it('should preserve the original protocol even if for custom protocols', () => {
expect(autoLink('custom://rocket.chat/test')).toEqual(link('custom://rocket.chat/test'));
});
it('should return // as the protocol if // is the protocol specified', () => {
expect(autoLink('//rocket.chat/test')).toEqual(link('//rocket.chat/test'));
});
it("should return an url concatenated '//' if the url has no protocol", () => {
expect(autoLink('rocket.chat/test')).toEqual(link('//rocket.chat/test', [plain('rocket.chat/test')]));
});
it("should return an url concatenated '//' if the url has no protocol and has sub-domain", () => {
expect(autoLink('spark-public.s3.amazonaws.com')).toEqual(
link('//spark-public.s3.amazonaws.com', [plain('spark-public.s3.amazonaws.com')]),
);
});
it("should return an plain text url due to invalid TLD that's validate with the external library TLDTS", () => {
expect(autoLink('rocket.chattt/url_path')).toEqual(plain('rocket.chattt/url_path'));
});
});