nexo/apps/mattermost/app/components/markdown/transform.test.ts

3761 lines
138 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import assert from 'assert';
import {Node, Parser} from 'commonmark';
import {
addListItemIndices,
combineTextNodes,
getFirstMatch,
highlightMentions,
highlightTextNode,
mentionKeysToPatterns,
pullOutImages,
highlightWithoutNotification,
highlightKeysToPatterns,
} from '@components/markdown/transform';
import {logError} from '@utils/log';
import type {UserMentionKey} from '@typings/global/markdown';
/* eslint-disable max-lines, no-underscore-dangle */
describe('Components.Markdown.transform', () => {
const parser = new Parser();
describe('combineTextNodes', () => {
const tests = [{
name: 'no text nodes',
input: {
type: 'document',
children: [{
type: 'thematic_break',
}],
},
expected: {
type: 'document',
children: [{
type: 'thematic_break',
}],
},
}, {
name: 'one text node',
input: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is a sentence',
}],
}],
},
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is a sentence',
}],
}],
},
}, {
name: 'multiple text nodes',
input: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This ',
}, {
type: 'text',
literal: 'is a',
}, {
type: 'text',
literal: ' sen',
}, {
type: 'text',
literal: 'tence',
}],
}],
},
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is a sentence',
}],
}],
},
}, {
name: 'mixed formatting',
input: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This ',
}, {
type: 'emph',
children: [{
type: 'text',
literal: 'is a',
}],
}, {
type: 'text',
literal: ' sen',
}, {
type: 'text',
literal: 'tence',
}],
}],
},
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This ',
}, {
type: 'emph',
children: [{
type: 'text',
literal: 'is a',
}],
}, {
type: 'text',
literal: ' sentence',
}],
}],
},
}, {
name: 'multiple paragraphs',
input: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is a ',
}, {
type: 'text',
literal: 'paragraph',
}],
}, {
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is',
}, {
type: 'text',
literal: ' another ',
}, {
type: 'text',
literal: 'paragraph',
}],
}],
},
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is a paragraph',
}],
}, {
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is another paragraph',
}],
}],
},
}, {
name: 'MM-12880 merging text followed by non-text',
input: 'test: *italics*',
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'test: ',
}, {
type: 'emph',
children: [{
type: 'text',
literal: 'italics',
}],
}],
}],
},
}, {
name: 'MM-12880 merging text followed by image',
input: 'test: ![image](https://example.com/image)',
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'test: ',
}, {
type: 'image',
destination: 'https://example.com/image',
title: '',
children: [{
type: 'text',
literal: 'image',
}],
}],
}],
},
}];
for (const test of tests) {
it(test.name, () => {
const input = typeof test.input === 'string' ? parser.parse(test.input) : makeAst(test.input);
const expected = makeAst(test.expected);
const actual = combineTextNodes(input);
assert.ok(verifyAst(actual));
assert.deepStrictEqual(astToString(actual), astToString(expected));
assert.deepStrictEqual(stripUnusedFields(actual), stripUnusedFields(expected));
});
}
});
describe('addListItemIndices', () => {
it('unordered list', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'bullet',
listTight: true,
children: [{
type: 'item',
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'one',
}],
}],
}, {
type: 'item',
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'two',
}],
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'bullet',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'one',
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'two',
}],
}],
}],
}],
});
const actual = addListItemIndices(input);
assert.ok(verifyAst(actual));
assert.deepStrictEqual(actual, expected);
});
it('ordered list', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'bullet',
listTight: true,
listStart: 7,
listDelimiter: 'period',
children: [{
type: 'item',
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'one',
}],
}],
}, {
type: 'item',
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'two',
}],
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'bullet',
listTight: true,
listStart: 7,
listDelimiter: 'period',
children: [{
type: 'item',
index: 7,
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'one',
}],
}],
}, {
type: 'item',
index: 8,
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'two',
}],
}],
}],
}],
});
const actual = addListItemIndices(input);
assert.ok(verifyAst(actual));
assert.deepStrictEqual(actual, expected);
});
it('nested lists', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'bullet',
listTight: true,
listStart: 7,
listDelimiter: 'period',
children: [{
type: 'item',
children: [{
type: 'list',
listType: 'bullet',
listTight: true,
listStart: 3,
listDelimiter: 'period',
children: [{
type: 'item',
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'one',
}],
}],
}, {
type: 'item',
children: [{
type: 'list',
listTight: true,
children: [{
type: 'item',
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'one',
}],
}],
}, {
type: 'item',
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'two',
}],
}],
}],
}],
}],
}],
}, {
type: 'item',
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'two',
}],
}],
}],
}, {
type: 'list',
listTight: true,
children: [{
type: 'item',
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'one',
}],
}],
}, {
type: 'item',
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'two',
}],
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'bullet',
listTight: true,
listStart: 7,
listDelimiter: 'period',
children: [{
type: 'item',
index: 7,
children: [{
type: 'list',
listType: 'bullet',
listTight: true,
listStart: 3,
listDelimiter: 'period',
children: [{
type: 'item',
index: 3,
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'one',
}],
}],
}, {
type: 'item',
index: 4,
children: [{
type: 'list',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'one',
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'two',
}],
}],
}],
}],
}],
}],
}, {
type: 'item',
index: 8,
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'two',
}],
}],
}],
}, {
type: 'list',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'one',
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'pargraph',
children: [{
type: 'text',
literal: 'two',
}],
}],
}],
}],
});
const actual = addListItemIndices(input);
assert.ok(verifyAst(actual));
assert.deepStrictEqual(actual, expected);
});
});
describe('pullOutImages', () => {
it('simple example with no images', () => {
const input = parser.parse('test');
const expected = parser.parse('test');
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('complex example with no images', () => {
const inputString = '- abc\n 1. def\n\n 2. ghi\n\n3. jkl\n- mno\n 1. pqr\n---\n# vwx\n\nyz';
const input = parser.parse(inputString);
const expected = parser.parse(inputString);
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('paragraph', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('paragraph with surrounding text', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text with ',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: ' in it',
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text with ',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: ' in it',
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('paragraph with multiple images', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'image',
destination: 'http://example.com/image2',
children: [{
type: 'text',
literal: 'another image',
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'image',
destination: 'http://example.com/image2',
children: [{
type: 'text',
literal: 'another image',
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('headings', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'heading',
level: 1,
children: [{
type: 'text',
literal: 'This is the start 1',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image 1',
}],
}],
}, {
type: 'heading',
level: 4,
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image 2',
}],
}, {
type: 'text',
literal: 'This is the end 2',
}],
}, {
type: 'heading',
level: 2,
children: [{
type: 'text',
literal: 'This is the start 3',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image 3',
}],
}, {
type: 'text',
literal: 'This is the end 3',
}],
}, {
type: 'heading',
level: 3,
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image 4a',
}],
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image 4b',
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'heading',
level: 1,
children: [{
type: 'text',
literal: 'This is the start 1',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image 1',
}],
}],
}, {
type: 'heading',
level: 4,
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image 2',
}],
}, {
type: 'text',
literal: 'This is the end 2',
}],
}, {
type: 'heading',
level: 2,
children: [{
type: 'text',
literal: 'This is the start 3',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image 3',
}],
}, {
type: 'text',
literal: 'This is the end 3',
}],
}, {
type: 'heading',
level: 3,
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image 4a',
}],
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image 4b',
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('block quote', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'block_quote',
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'block_quote',
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('block quote with other text', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'block_quote',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is ',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: ' in a sentence',
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'block_quote',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is ',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: ' in a sentence',
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('unordered list', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'bullet',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}, {
type: 'item',
index: 3,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is moretext',
}],
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'bullet',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}, {
type: 'item',
index: 3,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is moretext',
}],
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('ordered list', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'ordered',
listTight: false,
children: [{
type: 'item',
index: 7,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 8,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'ordered',
listTight: false,
children: [{
type: 'item',
index: 7,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 8,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('complicated list', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'ordered',
listTight: false,
children: [{
type: 'item',
index: 7,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 8,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 9,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}, {
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 10,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}, {
type: 'item',
index: 11,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'ordered',
listTight: false,
children: [{
type: 'item',
index: 7,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 8,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 9,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}, {
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 10,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}, {
type: 'item',
index: 11,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}],
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('nested lists', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'ordered',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'list',
listType: 'bulleted',
listTight: false,
children: [{
type: 'item',
index: 3,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}, {
type: 'item',
index: 4,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: 'This is text',
}],
}],
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'list',
listType: 'ordered',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}],
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'ordered',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'list',
listType: 'bulleted',
listTight: false,
children: [{
type: 'item',
index: 3,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}, {
type: 'item',
index: 4,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: 'This is text',
}],
}],
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'list',
listType: 'ordered',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: 'This is text',
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is text',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}],
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('complex example with images', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'bulleted',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/abc',
children: [{
type: 'text',
literal: 'abc',
}],
}],
}, {
type: 'list',
listType: 'numbered',
listTight: false,
children: [{
type: 'item',
index: 1,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/def',
children: [{
type: 'text',
literal: 'def',
}],
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/ghi',
children: [{
type: 'text',
literal: 'ghi',
}],
}],
}],
}],
}],
}],
}, {
type: 'list',
listType: 'ordered',
listTight: true,
children: [{
type: 'item',
index: 3,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/jkl',
children: [{
type: 'text',
literal: 'jkl',
}],
}],
}],
}],
}, {
type: 'block_quote',
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/mno',
children: [{
type: 'text',
literal: 'mno',
}],
}, {
type: 'softbreak',
}, {
type: 'image',
destination: 'http://example.com/pqr',
children: [{
type: 'text',
literal: 'pqr',
}],
}],
}],
}, {
type: 'thematic_break',
}, {
type: 'heading',
level: 1,
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'vw',
}],
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'x',
}],
}],
}, {
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'yz',
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'list',
listType: 'bulleted',
listTight: true,
children: [{
type: 'item',
index: 1,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/abc',
children: [{
type: 'text',
literal: 'abc',
}],
}],
}, {
type: 'list',
listType: 'numbered',
listTight: false,
children: [{
type: 'item',
index: 1,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/def',
children: [{
type: 'text',
literal: 'def',
}],
}],
}],
}, {
type: 'item',
index: 2,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/ghi',
children: [{
type: 'text',
literal: 'ghi',
}],
}],
}],
}],
}],
}],
}, {
type: 'list',
listType: 'ordered',
listTight: true,
children: [{
type: 'item',
index: 3,
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/jkl',
children: [{
type: 'text',
literal: 'jkl',
}],
}],
}],
}],
}, {
type: 'block_quote',
children: [{
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/mno',
children: [{
type: 'text',
literal: 'mno',
}],
}, {
type: 'softbreak',
}, {
type: 'image',
destination: 'http://example.com/pqr',
children: [{
type: 'text',
literal: 'pqr',
}],
}],
}],
}, {
type: 'thematic_break',
}, {
type: 'heading',
level: 1,
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'vw',
}],
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'x',
}],
}],
}, {
type: 'paragraph',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'yz',
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('simple link', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'link',
destination: 'http://example.com',
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'link',
destination: 'http://example.com',
children: [{
type: 'image',
destination: 'http://example.com/image',
linkDestination: 'http://example.com',
children: [{
type: 'text',
literal: 'an image',
}],
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('link in sentence', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'link',
destination: 'http://example.com',
children: [{
type: 'text',
literal: 'This is ',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: ' in a sentence.',
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'link',
destination: 'http://example.com',
children: [{
type: 'text',
literal: 'This is ',
}, {
type: 'image',
destination: 'http://example.com/image',
linkDestination: 'http://example.com',
children: [{
type: 'text',
literal: 'an image',
}],
}, {
type: 'text',
literal: ' in a sentence.',
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('table', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'table',
children: [{
type: 'table_row',
isHeading: true,
children: [{
type: 'table_cell',
isHeading: true,
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'image1',
}],
}],
}, {
type: 'table_cell',
isHeading: true,
children: [{
type: 'text',
literal: 'This is ',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'image1',
}],
}, {
type: 'text',
literal: ' in a sentence.',
}],
}],
}, {
type: 'table_row',
isHeading: true,
children: [{
type: 'table_cell',
isHeading: true,
children: [{
type: 'text',
literal: 'This is ',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'image1',
}],
}, {
type: 'text',
literal: ' in a sentence.',
}],
}, {
type: 'table_cell',
isHeading: true,
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'image1',
}],
}],
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'table',
children: [{
type: 'table_row',
isHeading: true,
children: [{
type: 'table_cell',
isHeading: true,
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'image1',
}],
}],
}, {
type: 'table_cell',
isHeading: true,
children: [{
type: 'text',
literal: 'This is ',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'image1',
}],
}, {
type: 'text',
literal: ' in a sentence.',
}],
}],
}, {
type: 'table_row',
isHeading: true,
children: [{
type: 'table_cell',
isHeading: true,
children: [{
type: 'text',
literal: 'This is ',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'image1',
}],
}, {
type: 'text',
literal: ' in a sentence.',
}],
}, {
type: 'table_cell',
isHeading: true,
children: [{
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'image1',
}],
}],
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
it('adjacent images and text', () => {
const input = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'First:',
}, {
type: 'softbreak',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'Image',
}],
}, {
type: 'softbreak',
}, {
type: 'text',
literal: 'Second:',
}, {
type: 'softbreak',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'Image',
}],
}],
}],
});
const expected = makeAst({
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'First:',
}, {
type: 'softbreak',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'Image',
}],
}, {
type: 'softbreak',
}, {
type: 'text',
literal: 'Second:',
}, {
type: 'softbreak',
}, {
type: 'image',
destination: 'http://example.com/image',
children: [{
type: 'text',
literal: 'Image',
}],
}],
}],
});
const actual = pullOutImages(input);
assert.ok(verifyAst(actual));
assert.equal(astToString(actual), astToString(expected));
assert.deepStrictEqual(actual, expected);
});
});
describe('highlightMentions', () => {
const tests = [{
name: 'no mentions',
input: 'These are words',
mentionKeys: [],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'These are words',
}],
}],
},
}, {
name: 'not an at-mention',
input: 'These are words',
mentionKeys: [{key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'These are ',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}],
}],
},
}, {
name: 'at-mention for another user',
input: 'This is @user',
mentionKeys: [{key: '@words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is ',
}, {
type: 'at_mention',
_mentionName: 'user',
}],
}],
},
}, {
name: 'at-mention',
input: 'These are @words',
mentionKeys: [{key: '@words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'These are ',
}, {
type: 'mention_highlight',
children: [{
type: 'at_mention',
_mentionName: 'words',
}],
}],
}],
},
}, {
name: 'at-mention and non-at-mention for same word',
input: 'These are @words',
mentionKeys: [{key: 'words'}, {key: '@words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'These are ',
}, {
type: 'mention_highlight',
children: [{
type: 'at_mention',
_mentionName: 'words',
}],
}],
}],
},
}, {
name: 'case insensitive mentions',
input: 'These are Words and wORDS',
mentionKeys: [{key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'These are ',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'Words',
}],
}, {
type: 'text',
literal: ' and ',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'wORDS',
}],
}],
}],
},
}, {
name: 'case sesitive mentions',
input: 'These are Words and wORDS',
mentionKeys: [{key: 'Words', caseSensitive: true}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'These are ',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'Words',
}],
}, {
type: 'text',
literal: ' and wORDS',
}],
}],
},
}, {
name: 'bold',
input: 'These are **words** in a sentence',
mentionKeys: [{key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'These are ',
}, {
type: 'strong',
children: [{
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}],
}, {
type: 'text',
literal: ' in a sentence',
}],
}],
},
}, {
name: 'italics',
input: 'These _are Words in_ a sentence',
mentionKeys: [{key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'These ',
}, {
type: 'emph',
children: [{
type: 'text',
literal: 'are ',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'Words',
}],
}, {
type: 'text',
literal: ' in',
}],
}, {
type: 'text',
literal: ' a sentence',
}],
}],
},
}, {
name: 'code span',
input: 'These are `words`',
mentionKeys: [{key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'These are ',
}, {
type: 'code',
literal: 'words',
}],
}],
},
}, {
name: 'code block',
input: '```\nThese are\nwords\n```',
mentionKeys: [{key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'code_block',
literal: 'These are\nwords\n',
}],
},
}, {
name: 'link text',
input: 'These are [words words](https://example.com)',
mentionKeys: [{key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'These are ',
}, {
type: 'link',
destination: 'https://example.com',
title: '',
children: [{
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}, {
type: 'text',
literal: ' ',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}],
}],
}],
},
}, {
name: 'link url',
input: 'This is [a link](https://example.com/words)',
mentionKeys: [{key: 'example'}, {key: 'com'}, {key: 'https'}, {key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is ',
}, {
type: 'link',
destination: 'https://example.com/words',
title: '',
children: [{
type: 'text',
literal: 'a link',
}],
}],
}],
},
}, {
name: 'autolinked url',
input: 'https://example.com/words',
mentionKeys: [{key: 'example'}, {key: 'com'}, {key: 'https'}, {key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'link',
destination: 'https://example.com/words',
title: '',
children: [{
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'https',
}],
}, {
type: 'text',
literal: '://',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'example',
}],
}, {
type: 'text',
literal: '.',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'com',
}],
}, {
type: 'text',
literal: '/',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}],
}],
}],
},
}, {
name: 'words with punctuation',
input: 'words. (words) words/words/words words:words',
mentionKeys: [{key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}, {
type: 'text',
literal: '. (',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}, {
type: 'text',
literal: ') ',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}, {
type: 'text',
literal: '/',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}, {
type: 'text',
literal: '/',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}, {
type: 'text',
literal: ' ',
}, {
type: 'link',
destination: 'words:words',
title: '',
children: [{
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}, {
type: 'text',
literal: ':',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: 'words',
}],
}],
}],
}],
},
}, {
name: 'multibyte keyword',
input: '我爱吃番茄炒饭',
mentionKeys: [{key: '番茄'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: '我爱吃',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: '番茄',
}],
}, {
type: 'text',
literal: '炒饭',
}],
}],
},
}, {
name: 'multiple multibyte keywords',
input: 'CJK is 中國日本한국.',
mentionKeys: [{key: '中國'}, {key: '日本'}, {key: '한국'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'CJK is ',
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: '中國',
}],
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: '日本',
}],
}, {
type: 'mention_highlight',
children: [{
type: 'text',
literal: '한국',
}],
}, {
type: 'text',
literal: '.',
}],
}],
},
}, {
name: 'Mention followed by a period',
input: 'This is a mention for @channel.',
mentionKeys: [{key: 'channel'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is a mention for ',
}, {
type: 'mention_highlight',
children: [{
type: 'at_mention',
_mentionName: 'channel.',
}],
}],
}],
},
}, {
name: 'Do not mention partial keys',
input: 'This is a mention for @Gvn.',
mentionKeys: [{key: 'gv'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'This is a mention for ',
}, {
type: 'at_mention',
_mentionName: 'Gvn.',
}],
}],
},
}];
for (const test of tests) {
it(test.name, () => {
const input = combineTextNodes(parser.parse(test.input));
const expected = makeAst(test.expected);
const actual = highlightMentions(input, test.mentionKeys);
assert.ok(verifyAst(actual));
assert.deepStrictEqual(stripUnusedFields(actual), stripUnusedFields(expected));
});
}
});
describe('getFirstMention with mentionKeysToPatterns', () => {
const tests = [{
name: 'no mention keys',
input: 'apple banana orange',
mentionKeys: [],
expected: {index: -1, length: -1},
}, {
name: 'single mention',
input: 'apple banana orange',
mentionKeys: [{key: 'banana'}],
expected: {index: 6, length: 6},
}, {
name: 'multiple mentions',
input: 'apple banana orange',
mentionKeys: [{key: 'apple'}, {key: 'orange'}],
expected: {index: 0, length: 5},
}, {
name: 'case sensitive',
input: 'apple APPLE Apple aPPle',
mentionKeys: [{key: 'Apple', caseSensitive: true}],
expected: {index: 12, length: 5},
}, {
name: 'followed by period',
input: 'banana.',
mentionKeys: [{key: 'banana'}],
expected: {index: 0, length: 6},
}, {
name: 'followed by underscores',
input: 'banana__',
mentionKeys: [{key: 'banana'}],
expected: {index: 0, length: 6},
}, {
name: 'in brackets',
input: '(banana)',
mentionKeys: [{key: 'banana'}],
expected: {index: 1, length: 6},
}, {
name: 'following punctuation',
input: ':banana',
mentionKeys: [{key: 'banana'}],
expected: {index: 1, length: 6},
}, {
name: 'not part of another word',
input: 'pineapple',
mentionKeys: [{key: 'apple'}],
expected: {index: -1, length: -1},
}, {
name: 'no error from weird mention keys',
input: 'apple banana orange',
mentionKeys: [{key: '*\\3_.'}],
expected: {index: -1, length: -1},
}, {
name: 'no blank mention keys',
input: 'apple banana orange',
mentionKeys: [{key: ''}],
expected: {index: -1, length: -1},
}, {
name: 'multibyte key',
input: '좋은 하루 되세요.',
mentionKeys: [{key: '하루'}],
expected: {index: 3, length: 2},
}];
function getFirstMention(str: string, mentionKeys: UserMentionKey[]) {
const patterns = mentionKeysToPatterns(mentionKeys);
return getFirstMatch(str, patterns);
}
for (const test of tests) {
it(test.name, () => {
const actual = getFirstMention(test.input, test.mentionKeys);
assert.deepStrictEqual(actual, test.expected);
});
}
});
describe('highlightTextNode', () => {
const type = 'paragraph';
const literal = 'This is a sentence';
const tests = [{
name: 'highlight entire text',
start: 0,
end: literal.length,
expected: [{
type,
children: [{
type: 'text',
literal,
}],
}],
}, {
name: 'highlight start of text',
start: 0,
end: 6,
expected: [{
type,
children: [{
type: 'text',
literal: 'This i',
}],
}, {
type: 'text',
literal: 's a sentence',
}],
}, {
name: 'highlight end of text',
start: 8,
end: literal.length,
expected: [{
type: 'text',
literal: 'This is ',
}, {
type,
children: [{
type: 'text',
literal: 'a sentence',
}],
}],
}, {
name: 'highlight middle of text',
start: 5,
end: 12,
expected: [{
type: 'text',
literal: 'This ',
}, {
type,
children: [{
type: 'text',
literal: 'is a se',
}],
}, {
type: 'text',
literal: 'ntence',
}],
}];
for (const test of tests) {
it(test.name + ', without siblings', () => {
const actual = makeAst({
type: 'parent',
children: [{
type: 'text',
literal,
}],
});
const expected = makeAst({
type: 'parent',
children: test.expected,
});
const node = actual.firstChild;
assert.equal(node!.type, 'text');
const highlighted = highlightTextNode(node!, test.start, test.end, type);
assert.equal(highlighted.type, type);
assert.ok(verifyAst(actual));
assert.deepStrictEqual(actual, expected);
});
it(test.name + ', with siblings', () => {
const actual = makeAst({
type: 'parent',
children: [{
type: 'previous',
}, {
type: 'text',
literal,
}, {
type: 'next',
}],
});
const expected = makeAst({
type: 'parent',
children: [
{
type: 'previous',
},
...test.expected,
{
type: 'next',
},
],
});
const node = actual.firstChild!.next;
assert.equal(node!.type, 'text');
const highlighted = highlightTextNode(node!, test.start, test.end, type);
assert.equal(highlighted.type, type);
assert.ok(verifyAst(actual));
assert.deepStrictEqual(actual, expected);
});
}
});
describe('highlightWithoutNotification', () => {
const tests = [{
name: 'no highlights',
input: 'Cant put down an anti gravity book',
highlightKeys: [],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'Cant put down an anti gravity book',
}],
}],
},
}, {
name: 'key bigger than input',
input: 'incredible',
highlightKeys: [{key: 'incredible and industructable'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'incredible',
}],
}],
},
}, {
name: 'key part of the word',
input: 'Sesquipedalian',
highlightKeys: [{key: 'quipedalian'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'Sesquipedalian',
}],
}],
},
}, {
name: 'word part of key',
input: 'floccinauc',
highlightKeys: [{key: 'floccinaucinihilipilification'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'floccinauc',
}],
}],
},
}, {
name: 'a word highlight',
input: 'Cant put down an anti gravity book',
highlightKeys: [{key: 'anti'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [
{
type: 'text',
literal: 'Cant put down an ',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'anti',
}],
},
{
type: 'text',
literal: ' gravity book',
},
],
}],
},
}, {
name: 'a sentence highlight',
input: 'Cant put down an anti gravity book',
highlightKeys: [{key: 'anti gravity'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [
{
type: 'text',
literal: 'Cant put down an ',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'anti gravity',
}],
},
{
type: 'text',
literal: ' book',
},
],
}],
},
}, {
name: 'insensitive keywords',
input: 'Cant put down an anti gravity book',
highlightKeys: [{key: 'dOwN'}, {key: 'Anti'}, {key: 'BOOK'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [
{
type: 'text',
literal: 'Cant put ',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'down',
}],
},
{
type: 'text',
literal: ' an ',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'anti',
}],
},
{
type: 'text',
literal: ' gravity ',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'book',
}],
},
],
}],
},
}, {
name: 'insensitive keywords',
input: 'Cant put down an anti gravity book',
highlightKeys: [{key: 'dOwN'}, {key: 'Anti'}, {key: 'BOOK'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [
{
type: 'text',
literal: 'Cant put ',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'down',
}],
},
{
type: 'text',
literal: ' an ',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'anti',
}],
},
{
type: 'text',
literal: ' gravity ',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'book',
}],
},
],
}],
},
}, {
name: 'words with characters surrounding them',
input: 'peace& ^peace -peace-',
highlightKeys: [{key: 'PEACE'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'peace',
}],
},
{
type: 'text',
literal: '& ^',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'peace',
}],
},
{
type: 'text',
literal: ' -',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'peace',
}],
},
{
type: 'text',
literal: '-',
},
],
}],
},
}, {
name: 'input in code block',
input: '```\nTurning it off and\non\n```',
highlightKeys: [{key: 'words'}],
expected: {
type: 'document',
children: [{
type: 'code_block',
literal: 'Turning it off and\non\n',
}],
},
}, {
name: 'key in bold',
input: 'Actions speak **louder** than words',
highlightKeys: [{key: 'louder'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'Actions speak ',
}, {
type: 'strong',
children: [{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'louder',
}],
}],
}, {
type: 'text',
literal: ' than words',
}],
}],
},
}, {
name: 'key in italic',
input: 'Actions speak *louder* than words',
highlightKeys: [{key: 'louder'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'Actions speak ',
}, {
type: 'emph',
children: [{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'louder',
}],
}],
}, {
type: 'text',
literal: ' than words',
}],
}],
},
}, {
name: 'key in heading',
input: '### Actions speak louder than words',
highlightKeys: [{key: 'Actions'}],
expected: {
type: 'document',
children: [{
type: 'heading',
level: 3,
children: [
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: 'Actions',
}],
},
{
type: 'text',
literal: ' speak louder than words',
}],
}],
},
}, {
name: 'Do not mention partial keys',
input: 'Adding more memory wont help @bob',
highlightKeys: [{key: 'bob'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [{
type: 'text',
literal: 'Adding more memory wont help ',
}, {
type: 'at_mention',
_mentionName: 'bob',
}],
}],
},
}, {
name: 'CJK word highlight',
input: '我确实喜欢我的同事。',
highlightKeys: [{key: '喜欢'}],
expected: {
type: 'document',
children: [{
type: 'paragraph',
children: [
{
type: 'text',
literal: '我确实',
},
{
type: 'highlight_without_notification',
children: [{
type: 'text',
literal: '喜欢',
}],
},
{
type: 'text',
literal: '我的同事。',
},
],
}],
},
}];
for (const test of tests) {
it(test.name, () => {
const input = combineTextNodes(parser.parse(test.input));
const expected = makeAst(test.expected);
const actual = highlightWithoutNotification(input, test.highlightKeys);
assert.ok(verifyAst(actual));
assert.deepStrictEqual(stripUnusedFields(actual), stripUnusedFields(expected));
});
}
});
describe('getFirstMatch with highlightKeysToPatterns', () => {
const tests = [{
name: 'text with space before',
input: ' lol',
patterns: highlightKeysToPatterns([{key: 'lol'}]),
expected: {index: 1, length: 3},
},
{
name: 'text with space afterwards and before',
input: ' Lol ',
patterns: highlightKeysToPatterns([{key: 'lol'}]),
expected: {index: 1, length: 3},
},
{
name: 'text with a non word character before (?)',
input: '?Lol',
patterns: highlightKeysToPatterns([{key: 'lol'}]),
expected: {index: 1, length: 3},
},
{
name: 'text with a non word character before (.)',
input: '?Lol',
patterns: highlightKeysToPatterns([{key: 'lol'}]),
expected: {index: 1, length: 3},
},
{
name: 'text with a non word character before (_)',
input: '?Lol',
patterns: highlightKeysToPatterns([{key: 'lol'}]),
expected: {index: 1, length: 3},
},
{
name: 'text with non word character after (.)',
input: 'Lol.',
patterns: highlightKeysToPatterns([{key: 'lol'}]),
expected: {index: 0, length: 3},
},
{
name: 'text with non word character after (?)',
input: 'Lol?',
patterns: highlightKeysToPatterns([{key: 'lol'}]),
expected: {index: 0, length: 3},
},
{
name: 'text with non word character after (_)',
input: 'Lol_',
patterns: highlightKeysToPatterns([{key: 'lol'}]),
expected: {index: 0, length: 3},
},
{
name: 'text with non word character before and after',
input: '?Lol?',
patterns: highlightKeysToPatterns([{key: 'lol'}]),
expected: {index: 1, length: 3},
}];
for (const test of tests) {
it(test.name, () => {
const actual = getFirstMatch(test.input, test.patterns);
assert.deepStrictEqual(actual, test.expected);
});
}
});
describe('parseCitationUrl', () => {
const {parseCitationUrl} = require('@components/markdown/transform');
// Valid 26-character post ID for testing
const validPostId1 = 'hod5do9pc38q7e7ofcc7e3hzae';
it('should return null for URLs without view=citation query param', () => {
expect(parseCitationUrl(`http://localhost:8066/team/pl/${validPostId1}`)).toBeNull();
expect(parseCitationUrl('http://localhost:8066/team/channels/general')).toBeNull();
expect(parseCitationUrl('')).toBeNull();
});
it('should return null when view=citation appears in path but not as query param', () => {
// view=citation must be a query parameter, not just appear anywhere in URL
expect(parseCitationUrl('http://localhost:8066/view=citation/team/pl/abc123')).toBeNull();
});
it('should parse post citation URLs with valid 26-char post ID', () => {
const result = parseCitationUrl(`http://localhost:8066/missionops/pl/${validPostId1}?view=citation`);
expect(result).toEqual({
entityType: 'POST',
entityId: validPostId1,
linkUrl: `http://localhost:8066/missionops/pl/${validPostId1}?view=citation`,
});
});
it('should return null for post IDs that are not exactly 26 characters', () => {
// Too short
expect(parseCitationUrl('http://localhost:8066/team/pl/abc123?view=citation')).toBeNull();
// Too long
expect(parseCitationUrl('http://localhost:8066/team/pl/abc123def456ghi789jkl012mnopq?view=citation')).toBeNull();
});
it('should parse channel citation URLs with valid channel names', () => {
const result = parseCitationUrl('http://localhost:8066/missionops/channels/mission-planning?view=citation');
expect(result).toEqual({
entityType: 'CHANNEL',
entityId: 'mission-planning',
linkUrl: 'http://localhost:8066/missionops/channels/mission-planning?view=citation',
});
});
it('should parse channel citation URLs with underscores and dots', () => {
const result = parseCitationUrl('http://localhost:8066/team/channels/my_channel.name?view=citation');
expect(result).toEqual({
entityType: 'CHANNEL',
entityId: 'my_channel.name',
linkUrl: 'http://localhost:8066/team/channels/my_channel.name?view=citation',
});
});
it('should return null for team-only URLs (not a supported citation entity)', () => {
const result = parseCitationUrl('http://localhost:8066/missionops?view=citation');
expect(result).toBeNull();
});
it('should handle view=citation with additional query params', () => {
const result = parseCitationUrl(`http://localhost:8066/team/pl/${validPostId1}?foo=bar&view=citation`);
expect(result).toEqual({
entityType: 'POST',
entityId: validPostId1,
linkUrl: `http://localhost:8066/team/pl/${validPostId1}?foo=bar&view=citation`,
});
});
describe('with serverUrl for internal link validation', () => {
const serverUrl = 'http://localhost:8066';
it('should parse internal links when serverUrl matches', () => {
const result = parseCitationUrl(
`http://localhost:8066/team/pl/${validPostId1}?view=citation`,
serverUrl,
);
expect(result).toEqual({
entityType: 'POST',
entityId: validPostId1,
linkUrl: `http://localhost:8066/team/pl/${validPostId1}?view=citation`,
});
});
it('should return null for external links when serverUrl is provided', () => {
const result = parseCitationUrl(
`http://external-server.com/team/pl/${validPostId1}?view=citation`,
serverUrl,
);
expect(result).toBeNull();
});
it('should return null when ports do not match', () => {
const result = parseCitationUrl(
`http://localhost:9000/team/pl/${validPostId1}?view=citation`,
serverUrl,
);
expect(result).toBeNull();
});
});
describe('with server subpaths', () => {
const serverUrlWithSubpath = 'http://localhost:8066/mattermost';
it('should handle server URLs with subpaths', () => {
const result = parseCitationUrl(
`http://localhost:8066/mattermost/team/pl/${validPostId1}?view=citation`,
serverUrlWithSubpath,
);
expect(result).toEqual({
entityType: 'POST',
entityId: validPostId1,
linkUrl: `http://localhost:8066/mattermost/team/pl/${validPostId1}?view=citation`,
});
});
it('should handle channel URLs with subpaths', () => {
const result = parseCitationUrl(
'http://localhost:8066/mattermost/team/channels/general?view=citation',
serverUrlWithSubpath,
);
expect(result).toEqual({
entityType: 'CHANNEL',
entityId: 'general',
linkUrl: 'http://localhost:8066/mattermost/team/channels/general?view=citation',
});
});
it('should return null for team-only URLs with subpaths (not a supported citation entity)', () => {
const result = parseCitationUrl(
'http://localhost:8066/mattermost/myteam?view=citation',
serverUrlWithSubpath,
);
expect(result).toBeNull();
});
});
it('should handle encoded URLs', () => {
const encodedUrl = `http://localhost:8066/team/channels/${encodeURIComponent('channel-name')}?view=citation`;
const result = parseCitationUrl(encodedUrl);
expect(result).toEqual({
entityType: 'CHANNEL',
entityId: 'channel-name',
linkUrl: encodedUrl,
});
});
describe('non-internal URLs with similar shape', () => {
const serverUrl = 'http://localhost:8066';
it('should reject external URLs that mimic internal link structure', () => {
// External server with same path structure as a Mattermost permalink
const result = parseCitationUrl(
`http://evil-site.com/team/pl/${validPostId1}?view=citation`,
serverUrl,
);
expect(result).toBeNull();
});
it('should reject URLs from similar-looking domains', () => {
// Subdomain that might try to look like the real server
const result = parseCitationUrl(
`http://localhost.evil.com:8066/team/pl/${validPostId1}?view=citation`,
serverUrl,
);
expect(result).toBeNull();
});
it('should reject URLs with matching path but different protocol handling', () => {
// Same structure but on a different server entirely
const result = parseCitationUrl(
'http://mattermost-lookalike.com/team/channels/general?view=citation',
serverUrl,
);
expect(result).toBeNull();
});
});
describe('URLs with escaped slashes', () => {
it('should handle URLs with encoded slashes in channel names', () => {
// Channel name with encoded slash - should not match since %2F is not a valid channel character
const result = parseCitationUrl('http://localhost:8066/team/channels/channel%2Fname?view=citation');
// The URL decodes to channel/name which has a slash, making it invalid
expect(result).toBeNull();
});
it('should handle URLs with encoded characters that decode to valid patterns', () => {
// %2D is encoded hyphen, which should decode and work
const result = parseCitationUrl('http://localhost:8066/team/channels/my%2Dchannel?view=citation');
expect(result).toEqual({
entityType: 'CHANNEL',
entityId: 'my-channel',
linkUrl: 'http://localhost:8066/team/channels/my%2Dchannel?view=citation',
});
});
it('should handle URLs with escaped slashes that decode to valid paths', () => {
// URL with %2F in team name decodes to a valid path structure
// After decoding: /team/evil/channels/general - the channel part is still valid
const result = parseCitationUrl('http://localhost:8066/team%2Fevil/channels/general?view=citation');
// The channel regex matches at the end of the decoded path
expect(result).toEqual({
entityType: 'CHANNEL',
entityId: 'general',
linkUrl: 'http://localhost:8066/team%2Fevil/channels/general?view=citation',
});
});
it('should reject URLs where escaped slashes break the expected pattern', () => {
// Escaped slash in the post ID itself - after decoding this creates an invalid path
const result = parseCitationUrl('http://localhost:8066/team/pl/abc%2F123?view=citation');
// The post ID pattern requires exactly 26 alphanumeric chars, abc/123 doesn't match
expect(result).toBeNull();
});
});
});
describe('processInlineEntities', () => {
const {processInlineEntities} = require('@components/markdown/transform');
// Valid 26-character post IDs for testing
const validPostId1 = 'hod5do9pc38q7e7ofcc7e3hzae';
const validPostId2 = 'abc123def456ghi789jkl012mn';
it('should not modify links without view=citation', () => {
const input = parser.parse(`[link text](http://example.com/team/pl/${validPostId1})`);
const result = processInlineEntities(input);
const walker = result.walker();
let foundLink = false;
let foundInlineEntity = false;
let e;
while ((e = walker.next())) {
if (e.node.type === 'link') {
foundLink = true;
expect(e.node.destination).toBe(`http://example.com/team/pl/${validPostId1}`);
}
if (e.node.type === 'inline_entity_link') {
foundInlineEntity = true;
}
}
expect(foundLink).toBe(true);
expect(foundInlineEntity).toBe(false);
});
it('should transform post citation links to inline_entity_link nodes', () => {
const input = parser.parse(`[permalink](http://localhost:8066/team/pl/${validPostId1}?view=citation)`);
const result = processInlineEntities(input);
const walker = result.walker();
let foundInlineEntity = false;
let foundLink = false;
let e;
while ((e = walker.next())) {
if (e.node.type === 'inline_entity_link') {
foundInlineEntity = true;
expect(e.node.entityType).toBe('POST');
expect(e.node.entityId).toBe(validPostId1);
expect(e.node.linkUrl).toBe(`http://localhost:8066/team/pl/${validPostId1}?view=citation`);
}
if (e.node.type === 'link') {
foundLink = true;
}
}
expect(foundInlineEntity).toBe(true);
expect(foundLink).toBe(false);
});
it('should transform channel citation links to inline_entity_link nodes', () => {
const input = parser.parse('[permalink](http://localhost:8066/team/channels/general?view=citation)');
const result = processInlineEntities(input);
const walker = result.walker();
let foundInlineEntity = false;
let foundLink = false;
let e;
while ((e = walker.next())) {
if (e.node.type === 'inline_entity_link') {
foundInlineEntity = true;
expect(e.node.entityType).toBe('CHANNEL');
expect(e.node.entityId).toBe('general');
}
if (e.node.type === 'link') {
foundLink = true;
}
}
expect(foundInlineEntity).toBe(true);
expect(foundLink).toBe(false);
});
it('should handle multiple citation links in one document', () => {
const input = parser.parse(`First [link](http://localhost:8066/team/pl/${validPostId1}?view=citation) and second [link](http://localhost:8066/team/pl/${validPostId2}?view=citation)`);
const result = processInlineEntities(input);
const walker = result.walker();
const entityLinks: any[] = [];
const regularLinks: any[] = [];
let e;
while ((e = walker.next())) {
if (e.node.type === 'inline_entity_link') {
entityLinks.push(e.node);
}
if (e.node.type === 'link') {
regularLinks.push(e.node);
}
}
expect(entityLinks.length).toBe(2);
expect(entityLinks[0].entityId).toBe(validPostId1);
expect(entityLinks[1].entityId).toBe(validPostId2);
expect(regularLinks.length).toBe(0);
});
it('should only process internal links when serverUrl is provided', () => {
const serverUrl = 'http://localhost:8066';
const input = parser.parse(`[external](http://external.com/team/pl/${validPostId1}?view=citation) and [internal](http://localhost:8066/team/pl/${validPostId2}?view=citation)`);
const result = processInlineEntities(input, serverUrl);
const walker = result.walker();
const entityLinks: any[] = [];
const regularLinks: any[] = [];
let e;
while ((e = walker.next())) {
// Only count on entering to avoid duplicates
if (!e.entering) {
continue;
}
if (e.node.type === 'inline_entity_link') {
entityLinks.push(e.node);
} else if (e.node.type === 'link') {
regularLinks.push(e.node);
}
}
// Only the internal link should be transformed
expect(entityLinks.length).toBe(1);
expect(entityLinks[0].entityId).toBe(validPostId2);
// Verify the internal link is NOT a regular link
expect(regularLinks.some((link) => link.destination.includes(validPostId2))).toBe(false);
// The external link should remain as a regular link
expect(regularLinks.length).toBe(1);
expect(regularLinks[0].destination).toBe(`http://external.com/team/pl/${validPostId1}?view=citation`);
// Verify the external link is NOT an inline_entity_link
expect(entityLinks.some((link) => link.entityId === validPostId1)).toBe(false);
});
});
});
// Testing and debugging functions
// Confirms that all parent, child, and sibling linkages are correct and go both ways.
function verifyAst(node: Node) {
if (node.prev && node.prev.next !== node) {
logError('node is not linked properly to prev');
return false;
}
if (node.next && node.next.prev !== node) {
logError('node is not linked properly to next');
return false;
}
if (!node.firstChild && node.lastChild) {
logError('node has children, but is not linked to first child');
return false;
}
if (node.firstChild && !node.lastChild) {
logError('node has children, but is not linked to last child');
return false;
}
for (let child = node.firstChild; child; child = child.next) {
if (child.parent !== node) {
logError('node is not linked properly to child');
return false;
}
if (!verifyAst(child)) {
return false;
}
if (!child.next && child !== node.lastChild) {
logError('node children are not linked correctly');
return false;
}
}
if (node.firstChild && node.firstChild.prev) {
logError('node\'s first child has previous sibling');
return false;
}
if (node.lastChild && node.lastChild.next) {
logError('node\'s last child has next sibling');
return false;
}
return true;
}
function astToString(node: Node, indent = '') {
if (!node) {
return '';
}
let out = '';
out += indent + nodeToString(node) + '\n';
for (let child = node.firstChild; child !== null; child = child.next) {
out += astToString(child, indent + ' ');
}
return out;
}
const neighbours = ['parent', 'prev', 'next', 'firstChild', 'lastChild'];
const importantFields = ['literal', 'destination', 'title', 'level', 'listType', 'listTight', 'listDelimiter', 'mentionName', 'channelName', 'emojiName', 'continue', 'index'];
function nodeToString(node: any) {
let out = node.type;
for (const neighbour of neighbours) {
if (node[neighbour]) {
out += ' ' + neighbour + '=`' + node[neighbour].type;
if (node[neighbour].type === 'text') {
out += ':' + node[neighbour].literal;
}
out += '`';
}
}
for (const field of importantFields) {
if (node[field]) {
out += ' ' + field + '=`' + node[field] + '`';
}
}
return out;
}
// Converts an AST represented as a JavaScript object into a full Commonmark-compatitle AST.
// This function is intended for use while testing. An example of input would be:
// {
// type: 'document',
// children: [{
// type: 'heading',
// level: 2,
// children: [{
// type: 'text',
// literal: 'This is a heading'
// }]
// }, {
// type: 'paragraph',
// children: [{
// type: 'text',
// literal: 'This is a paragraph'
// }]
// }]
// }
function makeAst(input: any) {
const {type, children, ...other} = input;
const node = new Node(type);
for (const key of Object.keys(other)) {
// @ts-expect-error key
node[key] = other[key];
}
if (children) {
for (const child of children) {
node.appendChild(makeAst(child));
}
}
return node;
}
// Remove any fields from the AST that are only used while parsing to make testing equality easier.
function stripUnusedFields(node: any) {
const walker = node.walker();
let e;
while ((e = walker.next())) {
e.node._open = false;
e.node._size = null;
e.node._sourcepos = null;
e.node._fenceChar = null;
e.node._fenceLength = 0;
e.node._fenceOffset = 0;
e.node._info = '';
e.node._isFenced = false;
}
return node;
}