diff --git a/app/components/emoji/emoji.js b/app/components/emoji/emoji.js
index 48fa36557..53da6c09f 100644
--- a/app/components/emoji/emoji.js
+++ b/app/components/emoji/emoji.js
@@ -138,7 +138,7 @@ export default class Emoji extends React.PureComponent {
let marginTop = 0;
if (fontSize) {
// Center the image vertically on iOS (does nothing on Android)
- marginTop = (height - fontSize) / 2;
+ marginTop = (height - 16) / 2;
// hack to get the vertical alignment looking better
if (fontSize === 17) {
diff --git a/app/components/markdown/index.js b/app/components/markdown/index.js
index c9ea99103..b95fe3eab 100644
--- a/app/components/markdown/index.js
+++ b/app/components/markdown/index.js
@@ -21,9 +21,11 @@ import {concatStyles} from 'app/utils/theme';
import MarkdownBlockQuote from './markdown_block_quote';
import MarkdownCodeBlock from './markdown_code_block';
+import MarkdownImage from './markdown_image';
import MarkdownLink from './markdown_link';
import MarkdownList from './markdown_list';
import MarkdownListItem from './markdown_list_item';
+import {addListItemIndices, pullOutImages} from './transform';
export default class Markdown extends PureComponent {
static propTypes = {
@@ -116,15 +118,35 @@ export default class Markdown extends PureComponent {
editedIndicator: this.renderEditedIndicator
},
- renderParagraphsInLists: true
+ renderParagraphsInLists: true,
+ getExtraPropsForNode: this.getExtraPropsForNode
});
}
+ getExtraPropsForNode = (node) => {
+ const extraProps = {
+ continue: node.continue,
+ index: node.index
+ };
+
+ if (node.type === 'image') {
+ extraProps.reactChildren = node.react.children;
+ extraProps.linkDestination = node.linkDestination;
+ }
+
+ return extraProps;
+ }
+
computeTextStyle = (baseStyle, context) => {
return concatStyles(baseStyle, context.map((type) => this.props.textStyles[type]));
}
renderText = ({context, literal}) => {
+ if (context.indexOf('image') !== -1) {
+ // If this text is displayed, it will be styled by the image component
+ return {literal};
+ }
+
// Construct the text style based off of the parents of this node since RN's inheritance is limited
return {literal};
}
@@ -133,16 +155,16 @@ export default class Markdown extends PureComponent {
return {literal};
}
- renderImage = ({children, context, src}) => {
- // TODO This will be properly implemented for PLT-5736
+ renderImage = ({linkDestination, reactChildren, context, src}) => {
return (
-
- {''}
-
+
+ {reactChildren}
+
);
}
@@ -194,6 +216,10 @@ export default class Markdown extends PureComponent {
}
renderParagraph = ({children, first}) => {
+ if (!children || children.length === 0) {
+ return null;
+ }
+
const blockStyle = [style.block];
if (!first) {
blockStyle.push(this.props.blockStyles.adjacentParagraph);
@@ -244,11 +270,10 @@ export default class Markdown extends PureComponent {
);
}
- renderList = ({children, start, tight, type}) => {
+ renderList = ({children, tight, type}) => {
return (
{children}
@@ -332,14 +357,17 @@ export default class Markdown extends PureComponent {
}
render() {
- const ast = this.parser.parse(this.props.value);
+ let ast = this.parser.parse(this.props.value);
+
+ ast = addListItemIndices(ast);
+ ast = pullOutImages(ast);
if (this.props.isEdited) {
const editIndicatorNode = new Node('edited_indicator');
- if (['code_block', 'thematic_break', 'block_quote'].includes(ast.lastChild.type)) {
- ast.appendChild(editIndicatorNode);
- } else {
+ if (['heading', 'paragraph'].includes(ast.lastChild.type)) {
ast.lastChild.appendChild(editIndicatorNode);
+ } else {
+ ast.appendChild(editIndicatorNode);
}
}
diff --git a/app/components/markdown/markdown_block_quote.js b/app/components/markdown/markdown_block_quote.js
index e2d49fd7e..9be6c5ec3 100644
--- a/app/components/markdown/markdown_block_quote.js
+++ b/app/components/markdown/markdown_block_quote.js
@@ -2,6 +2,7 @@
// See License.txt for license information.
import React, {PureComponent} from 'react';
+import PropTypes from 'prop-types';
import {
StyleSheet,
View
@@ -12,19 +13,27 @@ import CustomPropTypes from 'app/constants/custom_prop_types';
export default class MarkdownBlockQuote extends PureComponent {
static propTypes = {
+ continue: PropTypes.bool,
iconStyle: CustomPropTypes.Style,
children: CustomPropTypes.Children.isRequired
};
render() {
+ let icon;
+ if (!this.props.continue) {
+ icon = (
+
+ );
+ }
+
return (
-
-
+
+ {icon}
{this.props.children}
@@ -41,5 +50,8 @@ const style = StyleSheet.create({
},
childContainer: {
flex: 1
+ },
+ icon: {
+ width: 23
}
});
diff --git a/app/components/markdown/markdown_image.js b/app/components/markdown/markdown_image.js
index d3a338f70..68d398a4b 100644
--- a/app/components/markdown/markdown_image.js
+++ b/app/components/markdown/markdown_image.js
@@ -1,36 +1,90 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
-import {Image} from 'react-native';
+import React from 'react';
+import {intlShape} from 'react-intl';
+import {
+ Clipboard,
+ Image,
+ Linking,
+ Platform,
+ StyleSheet,
+ Text,
+ TouchableHighlight,
+ View
+} from 'react-native';
-export default class MarkdownLink extends PureComponent {
+import FormattedText from 'app/components/formatted_text';
+
+import CustomPropTypes from 'app/constants/custom_prop_types';
+import mattermostManaged from 'app/mattermost_managed';
+import {normalizeProtocol} from 'app/utils/url';
+
+const MAX_IMAGE_HEIGHT = 150;
+
+const ANDROID_MAX_HEIGHT = 4096;
+const ANDROID_MAX_WIDTH = 4096;
+
+export default class MarkdownImage extends React.Component {
static propTypes = {
- src: PropTypes.string.isRequired
+ children: PropTypes.node,
+ linkDestination: PropTypes.string,
+ onLongPress: PropTypes.func,
+ source: PropTypes.string.isRequired,
+ errorTextStyle: CustomPropTypes.Style
+ };
+
+ static contextTypes = {
+ intl: intlShape.isRequired
};
constructor(props) {
super(props);
this.state = {
- width: 10000,
- maxWidth: 10000,
- height: 0
+ width: 0,
+ height: 0,
+ maxWidth: Math.MAX_INT,
+ failed: false
};
+
+ this.mounted = false;
}
componentWillMount() {
- Image.getSize(this.props.src, this.handleSizeReceived, this.handleSizeFailed);
+ this.loadImageSize(this.props.source);
+ }
+
+ componentDidMount() {
+ this.mounted = true;
}
componentWillReceiveProps(nextProps) {
- if (this.props.src !== nextProps.src) {
- Image.getSize(nextProps.src, this.handleSizeReceived, this.handleSizeFailed);
+ if (this.props.source !== nextProps.source) {
+ this.setState({
+ width: 0,
+ height: 0,
+ failed: false
+ });
+
+ this.loadImageSize(nextProps.source);
}
}
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ loadImageSize = (source) => {
+ Image.getSize(source, this.handleSizeReceived, this.handleSizeFailed);
+ };
+
handleSizeReceived = (width, height) => {
+ if (!this.mounted) {
+ return;
+ }
+
this.setState({
width,
height
@@ -38,33 +92,140 @@ export default class MarkdownLink extends PureComponent {
};
handleSizeFailed = () => {
+ if (!this.mounted) {
+ return;
+ }
+
this.setState({
- width: 0,
- height: 0
+ failed: true
});
- }
+ };
handleLayout = (event) => {
+ if (!this.mounted) {
+ return;
+ }
+
this.setState({
maxWidth: event.nativeEvent.layout.width
});
- }
+ };
- render() {
- let {width, maxWidth, height} = this.state; // eslint-disable-line prefer-const
+ handleLinkPress = () => {
+ const url = normalizeProtocol(this.props.linkDestination);
- if (width > maxWidth) {
- height = height * (maxWidth / width);
- width = maxWidth;
+ Linking.canOpenURL(url).then((supported) => {
+ if (supported) {
+ Linking.openURL(url);
+ }
+ });
+ };
+
+ handleLinkLongPress = async () => {
+ const {formatMessage} = this.context.intl;
+
+ const config = await mattermostManaged.getLocalConfig();
+
+ let action;
+ if (config.copyAndPasteProtection !== 'true') {
+ action = {
+ text: formatMessage({id: 'mobile.markdown.link.copy_url', defaultMessage: 'Copy URL'}),
+ onPress: this.handleLinkCopy
+ };
+ }
+
+ this.props.onLongPress(action);
+ };
+
+ handleLinkCopy = () => {
+ Clipboard.setString(this.props.linkDestination);
+ };
+
+ render() {
+ let image = null;
+
+ if (this.state.width && this.state.height && this.state.maxWidth) {
+ let {width, height} = this.state;
+
+ if (Platform.OS === 'android' && (width > ANDROID_MAX_WIDTH || height > ANDROID_MAX_HEIGHT)) {
+ // Android has a cap on the max image size that can be displayed
+
+ image = (
+
+
+ {' '}
+ {this.props.children}
+
+ );
+ } else {
+ const maxWidth = this.state.maxWidth;
+ if (width > maxWidth) {
+ height = height * (maxWidth / width);
+ width = maxWidth;
+ }
+
+ const maxHeight = MAX_IMAGE_HEIGHT;
+ if (height > maxHeight) {
+ width = width * (maxHeight / height);
+ height = maxHeight;
+ }
+
+ // React Native complains if we try to pass resizeMode as a style
+ image = (
+
+ );
+ }
+ } else if (this.state.failed) {
+ image = (
+
+
+ {' '}
+ {this.props.children}
+
+ );
+ }
+
+ if (image && this.props.linkDestination) {
+ image = (
+
+ {image}
+
+ );
}
- // React Native complains if we try to pass resizeMode into a StyleSheet
return (
-
+ >
+ {image}
+
);
}
}
+
+const style = StyleSheet.create({
+ container: {
+ flex: 1
+ },
+ image: {
+ marginVertical: 5
+ }
+});
diff --git a/app/components/markdown/markdown_link.js b/app/components/markdown/markdown_link.js
index c9847e8f5..9fb29c019 100644
--- a/app/components/markdown/markdown_link.js
+++ b/app/components/markdown/markdown_link.js
@@ -5,27 +5,32 @@ import React, {Children, PureComponent} from 'react';
import PropTypes from 'prop-types';
import {Clipboard, Linking, Text} from 'react-native';
import urlParse from 'url-parse';
-import {injectIntl, intlShape} from 'react-intl';
+import {intlShape} from 'react-intl';
import CustomPropTypes from 'app/constants/custom_prop_types';
-import Config from 'assets/config';
import mattermostManaged from 'app/mattermost_managed';
-class MarkdownLink extends PureComponent {
+import Config from 'assets/config';
+
+import {normalizeProtocol} from 'app/utils/url';
+
+export default class MarkdownLink extends PureComponent {
static propTypes = {
children: CustomPropTypes.Children.isRequired,
href: PropTypes.string.isRequired,
- intl: intlShape.isRequired,
onLongPress: PropTypes.func
};
static defaultProps = {
onLongPress: () => true
- }
+ };
+
+ static contextTypes = {
+ intl: intlShape.isRequired
+ };
handlePress = () => {
- // Android doesn't like the protocol being upper case
- const url = this.props.href;
+ const url = normalizeProtocol(this.props.href);
Linking.canOpenURL(url).then((supported) => {
if (supported) {
@@ -69,7 +74,7 @@ class MarkdownLink extends PureComponent {
}
handleLongPress = async () => {
- const {formatMessage} = this.props.intl;
+ const {formatMessage} = this.context.intl;
const config = await mattermostManaged.getLocalConfig();
@@ -101,5 +106,3 @@ class MarkdownLink extends PureComponent {
);
}
}
-
-export default injectIntl(MarkdownLink);
\ No newline at end of file
diff --git a/app/components/markdown/markdown_list.js b/app/components/markdown/markdown_list.js
index e859d7a5f..8c70226d1 100644
--- a/app/components/markdown/markdown_list.js
+++ b/app/components/markdown/markdown_list.js
@@ -3,30 +3,33 @@
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
-import {View} from 'react-native';
+import {StyleSheet, View} from 'react-native';
export default class MarkdownList extends PureComponent {
static propTypes = {
- children: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf([PropTypes.node])]).isRequired,
+ children: PropTypes.node.isRequired,
ordered: PropTypes.bool.isRequired,
- startAt: PropTypes.number,
tight: PropTypes.bool
};
render() {
- const children = React.Children.map(this.props.children, (child, i) => {
+ const children = React.Children.map(this.props.children, (child) => {
return React.cloneElement(child, {
ordered: this.props.ordered,
- startAt: this.props.startAt,
- index: i,
tight: this.props.tight
});
});
return (
-
+
{children}
);
}
}
+
+const style = StyleSheet.create({
+ indent: {
+ marginRight: 20
+ }
+});
diff --git a/app/components/markdown/markdown_list_item.js b/app/components/markdown/markdown_list_item.js
index 2f9e6cf7f..130963b9c 100644
--- a/app/components/markdown/markdown_list_item.js
+++ b/app/components/markdown/markdown_list_item.js
@@ -15,21 +15,18 @@ export default class MarkdownListItem extends PureComponent {
static propTypes = {
children: CustomPropTypes.Children.isRequired,
ordered: PropTypes.bool.isRequired,
- startAt: PropTypes.number,
+ continue: PropTypes.bool,
index: PropTypes.number.isRequired,
- tight: PropTypes.bool,
bulletStyle: CustomPropTypes.Style,
level: PropTypes.number
};
- static defaultProps = {
- startAt: 1
- };
-
render() {
let bullet;
- if (this.props.ordered) {
- bullet = (this.props.startAt + this.props.index) + '. ';
+ if (this.props.continue) {
+ bullet = '';
+ } else if (this.props.ordered) {
+ bullet = this.props.index + '. ';
} else if (this.props.level % 2 === 0) {
bullet = '◦';
} else {
diff --git a/app/components/markdown/transform.js b/app/components/markdown/transform.js
new file mode 100644
index 000000000..830d74d3b
--- /dev/null
+++ b/app/components/markdown/transform.js
@@ -0,0 +1,203 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+/* eslint-disable no-underscore-dangle */
+
+// Add indices to the items of every list
+export function addListItemIndices(ast) {
+ const walker = ast.walker();
+
+ let e;
+ while ((e = walker.next())) {
+ if (e.entering) {
+ const node = e.node;
+
+ if (node.type === 'list') {
+ let i = node.listStart || 1; // List indices match what would be displayed in the UI
+
+ for (let child = node.firstChild; child; child = child.next) {
+ child.index = i;
+
+ i += 1;
+ }
+ }
+ }
+ }
+
+ return ast;
+}
+
+// Take all images and move them to be children of the root document node. When this happens, their
+// parent nodes are split into two, if necessary, with the version that follows the image having its
+// "continue" field set to true to indicate that things like bullet points don't need to be rendered.
+export function pullOutImages(ast) {
+ for (let block = ast.firstChild; block !== null; block = block.next) {
+ let node = block.firstChild;
+
+ let cameFromChild = false;
+
+ while (node && node !== block) {
+ if (node.type === 'image' && node.parent.type !== 'document') {
+ const image = node;
+
+ let parent = image.parent;
+ let prev = image.prev;
+ let next = image.next;
+
+ // Remove image from its siblings
+ if (prev) {
+ prev._next = next;
+ }
+ if (next) {
+ next._prev = prev;
+
+ // Since the following text will be on a new line, a preceding space would cause the
+ // alignment to be incorrect
+ if (next.type === 'text' && next.literal.startsWith(' ')) {
+ next.literal = next.literal.substring(1);
+ }
+ }
+
+ // And from its parents
+ if (parent._firstChild === image) {
+ // image was the first child (ie prev is null), so the next sibling is now the first child
+ parent._firstChild = next;
+ }
+ if (parent._lastChild === image) {
+ // image was the last child (ie next is null), so the previous sibling is now the last child
+ parent._lastChild = prev;
+ }
+
+ // Split the tree between the previous and next siblings, where the image would've been
+ while (parent && parent.type !== 'document') {
+ // We only need to split the parent if there's anything on the right of where we're splitting
+ // in the current branch
+ let parentCopy = null;
+
+ // Split if we have children to the right of the split (next) or if we have any siblings to the
+ // right of the parent (parent.next)
+ if (next) {
+ parentCopy = copyNodeWithoutNeighbors(parent);
+
+ // Set an additional flag so we know not to re-render things like bullet points
+ parentCopy.continue = true;
+
+ // Re-assign the children to the right of the split to belong to the copy
+ parentCopy._firstChild = next;
+ parentCopy._lastChild = getLastSibling(next);
+
+ if (parent._firstChild === next) {
+ parent._firstChild = null;
+ parent._lastChild = null;
+ } else {
+ parent._lastChild = prev;
+ }
+
+ // And re-assign the parent of all of those to be the copy
+ for (let child = parentCopy.firstChild; child; child = child.next) {
+ child._parent = parentCopy;
+ }
+
+ // Insert the copy as parent's next sibling
+ if (parent.next) {
+ parent.next._prev = parentCopy;
+ parentCopy._next = parent.next;
+ parent._next = parentCopy;
+ } else /* if (parent.parent.lastChild === parent) */ {
+ // Since parent has no next sibling, parent is the last child of its parent, so
+ // we need to set the copy as the last child
+ parent.parent.lastChild = parentCopy;
+ }
+ }
+
+ // Change prev and next to no longer be siblings
+ if (prev) {
+ prev._next = null;
+ }
+
+ if (next) {
+ next._prev = null;
+ }
+
+ // This image is part of a link so include the destination along with it
+ if (parent.type === 'link') {
+ image.linkDestination = parent.destination;
+ }
+
+ // Move up the tree
+ next = parentCopy || parent.next;
+ prev = parent;
+ parent = parent.parent;
+ }
+
+ // Re-insert the image now that we have a tree split down to the root with the image's ancestors.
+ // Note that parent is the root node, prev is the ancestor of image, and next is the ancestor of the copy
+
+ // Add image to its parent
+ image._parent = parent;
+ if (next) {
+ parent._lastChild = next;
+ } else {
+ // image is the last child of the root node now
+ parent._lastChild = image;
+ }
+
+ // Add image to its siblings
+ image._prev = prev;
+ prev._next = image;
+
+ image._next = next;
+ if (next) {
+ next._prev = image;
+ }
+
+ // The copy still needs its parent set to the root node
+ if (next) {
+ next._parent = parent;
+ }
+ }
+
+ // Walk through tree to next node
+ if (node.firstChild && !cameFromChild) {
+ node = node.firstChild;
+ cameFromChild = false;
+ } else if (node.next) {
+ node = node.next;
+ cameFromChild = false;
+ } else {
+ node = node.parent;
+ cameFromChild = true;
+ }
+ }
+ }
+
+ return ast;
+}
+
+// Copies a Node without its parent, children, or siblings
+function copyNodeWithoutNeighbors(node) {
+ // commonmark uses classes so it takes a bit of work to copy them
+ const copy = Object.assign(Object.create(Reflect.getPrototypeOf(node)), node);
+
+ copy._parent = null;
+ copy._firstChild = null;
+ copy._lastChild = null;
+ copy._prev = null;
+ copy._next = null;
+
+ // Deep copy list data since it's an object
+ copy._listData = {...node._listData};
+
+ return copy;
+}
+
+// Gets the last sibling of a given node
+function getLastSibling(node) {
+ let sibling = node;
+
+ while (sibling && sibling.next) {
+ sibling = sibling.next;
+ }
+
+ return sibling;
+}
diff --git a/app/utils/markdown.js b/app/utils/markdown.js
index 749fcf590..f8dc9ad64 100644
--- a/app/utils/markdown.js
+++ b/app/utils/markdown.js
@@ -64,6 +64,9 @@ export const getMarkdownTextStyles = makeStyleSheetFromTheme((theme) => {
},
mention: {
color: theme.linkColor
+ },
+ error: {
+ color: theme.errorTextColor
}
};
});
diff --git a/app/utils/url.js b/app/utils/url.js
index 13152b038..dd684024e 100644
--- a/app/utils/url.js
+++ b/app/utils/url.js
@@ -43,3 +43,16 @@ export function isImageLink(link) {
const match = link.trim().match(imgRegex);
return Boolean(match && match[1]);
}
+
+// Converts the protocol of a link (eg. http, ftp) to be lower case since
+// Android doesn't handle uppercase links.
+export function normalizeProtocol(url) {
+ const index = url.indexOf(':');
+ if (index === -1) {
+ // There's no protocol on the link to be normalized
+ return url;
+ }
+
+ const protocol = url.substring(0, index);
+ return protocol.toLowerCase() + url.substring(index);
+}
diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json
index a7af695c2..3c1c5f326 100644
--- a/assets/base/i18n/en.json
+++ b/assets/base/i18n/en.json
@@ -2008,6 +2008,8 @@
"mobile.managed.secured_by": "Secured by {vendor}",
"mobile.markdown.code.copy_code": "Copy Code",
"mobile.markdown.code.plusMoreLines": "+{count, number} more lines",
+ "mobile.markdown.image.error": "Image failed to load:",
+ "mobile.markdown.image.too_large": "Image exceeds max dimensions of {maxWidth} by {maxHeight}:",
"mobile.markdown.link.copy_url": "Copy URL",
"mobile.mention.copy_mention": "Copy Mention",
"mobile.more_dms.start": "Start",
diff --git a/test/app/components/markdown/transform.test.js b/test/app/components/markdown/transform.test.js
new file mode 100644
index 000000000..7bdb0da7f
--- /dev/null
+++ b/test/app/components/markdown/transform.test.js
@@ -0,0 +1,1963 @@
+// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import assert from 'assert';
+import {Node, Parser} from 'commonmark';
+
+import {
+ addListItemIndices,
+ pullOutImages
+} from 'app/components/markdown/transform';
+
+describe('Components.Markdown.transform', () => {
+ const parser = new Parser();
+
+ 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.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.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.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: 'paragraph',
+ continue: true,
+ children: [{
+ type: 'text',
+ literal: 'in it' // Note that we remove the preceding whitespace
+ }]
+ }]
+ });
+ const actual = pullOutImages(input);
+
+ assert.ok(verifyAst(actual));
+ assert.deepEqual(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: 'paragraph',
+ continue: true,
+ children: []
+ }, {
+ type: 'image',
+ destination: 'http://example.com/image2',
+ children: [{
+ type: 'text',
+ literal: 'another image'
+ }]
+ }]
+ });
+ const actual = pullOutImages(input);
+
+ assert.ok(verifyAst(actual));
+ 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: 'heading',
+ level: 4,
+ continue: true,
+ children: [{
+ 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: 'heading',
+ level: 2,
+ continue: true,
+ children: [{
+ 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: 'heading',
+ level: 3,
+ continue: true,
+ children: []
+ }, {
+ type: 'image',
+ destination: 'http://example.com/image',
+ children: [{
+ type: 'text',
+ literal: 'an image 4b'
+ }]
+ }]
+ });
+ const actual = pullOutImages(input);
+
+ assert.ok(verifyAst(actual));
+ 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.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: 'block_quote',
+ continue: true,
+ children: [{
+ type: 'paragraph',
+ continue: true,
+ children: [{
+ type: 'text',
+ literal: 'in a sentence'
+ }]
+ }]
+ }]
+ });
+ const actual = pullOutImages(input);
+
+ assert.ok(verifyAst(actual));
+ 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: 'list',
+ listType: 'bullet',
+ listTight: true,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 3,
+ children: [{
+ type: 'paragraph',
+ children: [{
+ type: 'text',
+ literal: 'This is moretext'
+ }]
+ }]
+ }]
+ }]
+ });
+ const actual = pullOutImages(input);
+
+ assert.ok(verifyAst(actual));
+ 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.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: 'list',
+ listType: 'ordered',
+ listTight: false,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 8,
+ continue: true,
+ children: [{
+ type: 'paragraph',
+ continue: true
+ }]
+ }]
+ }, {
+ type: 'image',
+ destination: 'http://example.com/image',
+ children: [{
+ type: 'text',
+ literal: 'an image'
+ }]
+ }, {
+ type: 'list',
+ listType: 'ordered',
+ listTight: false,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 8,
+ continue: true,
+ children: [{
+ type: 'paragraph',
+ continue: true,
+ children: [{
+ 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: 'list',
+ listType: 'ordered',
+ listTight: false,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 11,
+ children: [{
+ type: 'paragraph',
+ children: [{
+ type: 'text',
+ literal: 'This is text'
+ }]
+ }]
+ }]
+ }]
+ });
+ const actual = pullOutImages(input);
+
+ assert.ok(verifyAst(actual));
+ 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: 'list',
+ listType: 'ordered',
+ listTight: true,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 1,
+ continue: true,
+ children: [{
+ type: 'list',
+ listType: 'bulleted',
+ listTight: false,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 4,
+ children: [{
+ type: 'paragraph',
+ children: []
+ }]
+ }]
+ }]
+ }]
+ }, {
+ type: 'image',
+ destination: 'http://example.com/image',
+ children: [{
+ type: 'text',
+ literal: 'an image'
+ }]
+ }, {
+ type: 'list',
+ listType: 'ordered',
+ listTight: true,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 1,
+ continue: true,
+ children: [{
+ type: 'list',
+ listType: 'bulleted',
+ listTight: false,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 4,
+ continue: true,
+ children: [{
+ type: 'paragraph',
+ continue: true,
+ children: [{
+ 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: 'list',
+ listType: 'ordered',
+ listTight: true,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 2,
+ continue: true,
+ children: [{
+ type: 'list',
+ listType: 'ordered',
+ listTight: true,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 1,
+ continue: true,
+ children: [{
+ type: 'paragraph',
+ continue: true,
+ children: [{
+ 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.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: 'bulleted',
+ listTight: true,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 1,
+ continue: true,
+ children: [{
+ 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: 'list',
+ listType: 'bulleted',
+ listTight: true,
+ continue: true,
+ children: [{
+ type: 'item',
+ index: 1,
+ continue: true,
+ children: [{
+ type: 'list',
+ listType: 'numbered',
+ listTight: false,
+ continue: true,
+ children: [{
+ 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: 'block_quote',
+ continue: true,
+ children: [{
+ type: 'paragraph',
+ continue: true,
+ children: [{
+ 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: 'heading',
+ level: 1,
+ continue: true
+ }, {
+ 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.deepEqual(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.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: 'paragraph',
+ continue: true,
+ children: [{
+ type: 'link',
+ destination: 'http://example.com',
+ continue: true,
+ children: [{
+ type: 'text',
+ literal: 'in a sentence.'
+ }]
+ }]
+ }]
+ });
+ const actual = pullOutImages(input);
+
+ assert.ok(verifyAst(actual));
+ assert.deepStrictEqual(actual, expected);
+ });
+ });
+});
+
+// Testing and debugging functions
+
+function verifyAst(node) {
+ if (node.prev && node.prev.next !== node) {
+ console.error('node is not linked properly to prev');
+ }
+
+ if (node.next && node.next.prev !== node) {
+ console.error('node is not linked properly to prev');
+ }
+
+ for (let child = node.firstChild; child; child = child.next) {
+ if (child.parent !== node) {
+ console.error('node is not linked properly to child');
+ }
+ }
+
+ if (node.firstChild && node.firstChild.prev) {
+ console.error('node\'s first child has previous sibling');
+ }
+
+ if (node.lastChild && node.lastChild.next) {
+ console.error('node\'s last child has next sibling');
+ }
+
+ return true;
+}
+
+function astToString(node, indent = '') { // eslint-disable-line no-unused-vars
+ 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) {
+ 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;
+}
+
+const ignoredKeys = {_sourcepos: true, _lastLineBlank: true, _open: true, _string_content: true, _info: true, _isFenced: true, _fenceChar: true, _fenceLength: true, _fenceOffset: true, _onEnter: true, _onExit: true};
+function astToJson(node, visited = [], indent = '') { // eslint-disable-line no-unused-vars
+ let out = '{';
+
+ const myVisited = [...visited];
+ myVisited.push(node);
+
+ const keys = Object.keys(node).filter((key) => !ignoredKeys[key]);
+ if (keys.length > 0) {
+ out += '\n';
+ }
+
+ for (const [i, key] of keys.entries()) {
+ out += indent + ' "' + key + '":';
+
+ const value = node[key];
+ if (myVisited.indexOf(value) !== -1) {
+ out += '[Circular]';
+ } else if (value === null) {
+ out += 'null';
+ } else if (typeof value === 'number') {
+ out += value;
+ } else if (typeof value === 'string') {
+ out += '"' + value + '"';
+ } else if (typeof value === 'boolean') {
+ out += String(value);
+ } else if (typeof value === 'object') {
+ out += astToJson(value, myVisited, indent + ' ');
+ }
+
+ if (i !== keys.length - 1) {
+ out += ',\n';
+ }
+ }
+
+ if (keys.length > 0) {
+ out += '\n' + indent;
+ }
+
+ out += '}';
+
+ 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) {
+ const {type, children, ...other} = input;
+
+ const node = new Node(type);
+
+ for (const key of Object.keys(other)) {
+ node[key] = other[key];
+ }
+
+ if (children) {
+ for (const child of children) {
+ node.appendChild(makeAst(child));
+ }
+ }
+
+ return node;
+}