mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 02:18:56 +00:00
* Add phone number matching helpers for tel: autolinks. Detect E.164, grouped national numbers, and tel: URIs while skipping bare digit runs that look like ticket IDs or dates. Co-authored-by: joram <joram@mattermost.com> * Autolink phone numbers in post markdown as tel: links. Wrap recognized numbers in markdown link nodes after parsing so tapping opens the device dialer. Skip code, existing links, and unsafe posts. Co-authored-by: joram <joram@mattermost.com> * Copy phone numbers from tel: markdown links on long-press. Treat tel: like mailto: in link and channel-header sheets, and cover opening tel: URLs through the existing openLink path. Co-authored-by: joram <joram@mattermost.com> * Reject truncated phone matches inside longer digit sequences. Skip past hyphens and dots when checking match boundaries so overlong numbers and numeric prefixes are not autolinked. Keep spaces and parentheses as boundaries so adjacent numbers still match. Also address CodeRabbit test nits: assert match counts, prefix autolinkPhoneNumbers cases with should, and assert rendered tel: links. Co-authored-by: joram <joram@mattermost.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
3959 lines
145 KiB
TypeScript
3959 lines
145 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,
|
|
autolinkPhoneNumbers,
|
|
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: ',
|
|
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('autolinkPhoneNumbers', () => {
|
|
const tests = [{
|
|
name: 'should autolink a grouped national number',
|
|
input: 'Call me at 555-123-4567',
|
|
expected: {
|
|
type: 'document',
|
|
children: [{
|
|
type: 'paragraph',
|
|
children: [{
|
|
type: 'text',
|
|
literal: 'Call me at ',
|
|
}, {
|
|
type: 'link',
|
|
destination: 'tel:5551234567',
|
|
title: '',
|
|
children: [{
|
|
type: 'text',
|
|
literal: '555-123-4567',
|
|
}],
|
|
}],
|
|
}],
|
|
},
|
|
}, {
|
|
name: 'should autolink an E.164 number',
|
|
input: 'Call +15551234567',
|
|
expected: {
|
|
type: 'document',
|
|
children: [{
|
|
type: 'paragraph',
|
|
children: [{
|
|
type: 'text',
|
|
literal: 'Call ',
|
|
}, {
|
|
type: 'link',
|
|
destination: 'tel:+15551234567',
|
|
title: '',
|
|
children: [{
|
|
type: 'text',
|
|
literal: '+15551234567',
|
|
}],
|
|
}],
|
|
}],
|
|
},
|
|
}, {
|
|
name: 'should autolink a tel URI with plus that the parser does not autolink',
|
|
input: 'Call tel:+15551234567',
|
|
expected: {
|
|
type: 'document',
|
|
children: [{
|
|
type: 'paragraph',
|
|
children: [{
|
|
type: 'text',
|
|
literal: 'Call ',
|
|
}, {
|
|
type: 'link',
|
|
destination: 'tel:+15551234567',
|
|
title: '',
|
|
children: [{
|
|
type: 'text',
|
|
literal: 'tel:+15551234567',
|
|
}],
|
|
}],
|
|
}],
|
|
},
|
|
}, {
|
|
name: 'should autolink multiple numbers',
|
|
input: '555-123-4567 or 1-800-555-1234',
|
|
expected: {
|
|
type: 'document',
|
|
children: [{
|
|
type: 'paragraph',
|
|
children: [{
|
|
type: 'link',
|
|
destination: 'tel:5551234567',
|
|
title: '',
|
|
children: [{
|
|
type: 'text',
|
|
literal: '555-123-4567',
|
|
}],
|
|
}, {
|
|
type: 'text',
|
|
literal: ' or ',
|
|
}, {
|
|
type: 'link',
|
|
destination: 'tel:18005551234',
|
|
title: '',
|
|
children: [{
|
|
type: 'text',
|
|
literal: '1-800-555-1234',
|
|
}],
|
|
}],
|
|
}],
|
|
},
|
|
}, {
|
|
name: 'should not match inside an existing markdown link',
|
|
input: '[Call 555-123-4567](https://example.com)',
|
|
expected: {
|
|
type: 'document',
|
|
children: [{
|
|
type: 'paragraph',
|
|
children: [{
|
|
type: 'link',
|
|
destination: 'https://example.com',
|
|
title: '',
|
|
children: [{
|
|
type: 'text',
|
|
literal: 'Call 555-123-4567',
|
|
}],
|
|
}],
|
|
}],
|
|
},
|
|
}, {
|
|
name: 'should not match inside an existing tel markdown link',
|
|
input: '[Call](tel:+15551234567)',
|
|
expected: {
|
|
type: 'document',
|
|
children: [{
|
|
type: 'paragraph',
|
|
children: [{
|
|
type: 'link',
|
|
destination: 'tel:+15551234567',
|
|
title: '',
|
|
children: [{
|
|
type: 'text',
|
|
literal: 'Call',
|
|
}],
|
|
}],
|
|
}],
|
|
},
|
|
}, {
|
|
name: 'should not match inside a code span',
|
|
input: 'Use `555-123-4567`',
|
|
expected: {
|
|
type: 'document',
|
|
children: [{
|
|
type: 'paragraph',
|
|
children: [{
|
|
type: 'text',
|
|
literal: 'Use ',
|
|
}, {
|
|
type: 'code',
|
|
literal: '555-123-4567',
|
|
}],
|
|
}],
|
|
},
|
|
}, {
|
|
name: 'should not match a bare digit run',
|
|
input: 'Ticket 1234567890',
|
|
expected: {
|
|
type: 'document',
|
|
children: [{
|
|
type: 'paragraph',
|
|
children: [{
|
|
type: 'text',
|
|
literal: 'Ticket 1234567890',
|
|
}],
|
|
}],
|
|
},
|
|
}, {
|
|
name: 'should autolink a number inside emphasis',
|
|
input: 'Call **555-123-4567**',
|
|
expected: {
|
|
type: 'document',
|
|
children: [{
|
|
type: 'paragraph',
|
|
children: [{
|
|
type: 'text',
|
|
literal: 'Call ',
|
|
}, {
|
|
type: 'strong',
|
|
children: [{
|
|
type: 'link',
|
|
destination: 'tel:5551234567',
|
|
title: '',
|
|
children: [{
|
|
type: 'text',
|
|
literal: '555-123-4567',
|
|
}],
|
|
}],
|
|
}],
|
|
}],
|
|
},
|
|
}];
|
|
|
|
for (const test of tests) {
|
|
it(test.name, () => {
|
|
const input = combineTextNodes(parser.parse(test.input));
|
|
const expected = makeAst(test.expected);
|
|
const actual = autolinkPhoneNumbers(input);
|
|
|
|
assert.ok(verifyAst(actual));
|
|
assert.deepStrictEqual(stripUnusedFields(actual), stripUnusedFields(expected));
|
|
});
|
|
}
|
|
});
|
|
|
|
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;
|
|
}
|