From c71b116c719c640d5c31fe50c01488f0501a6d53 Mon Sep 17 00:00:00 2001 From: Harrison Healey Date: Tue, 28 Nov 2017 09:00:28 -0500 Subject: [PATCH] RN-173 Added support for Markdown images (#1202) * RN-173 Added support for Markdown images * RN-173 Added proper alt text when image fails to load * Changed (edited) indicator not add itself as a child of an image * RN-173 Removed space between an image and the following text * Made sure MarkdownImage is mounted when setState is called * Fixed images in links having their location overwritten * Made images work in links * Fixed uppercase links not working on Android * Added vertical margin around images * RN-173 Added styling for markdown image error text * RN-173 Added error when image exceeds max dimensions on Android --- app/components/emoji/emoji.js | 2 +- app/components/markdown/index.js | 60 +- .../markdown/markdown_block_quote.js | 24 +- app/components/markdown/markdown_image.js | 209 +- app/components/markdown/markdown_link.js | 23 +- app/components/markdown/markdown_list.js | 17 +- app/components/markdown/markdown_list_item.js | 13 +- app/components/markdown/transform.js | 203 ++ app/utils/markdown.js | 3 + app/utils/url.js | 13 + assets/base/i18n/en.json | 2 + .../app/components/markdown/transform.test.js | 1963 +++++++++++++++++ 12 files changed, 2460 insertions(+), 72 deletions(-) create mode 100644 app/components/markdown/transform.js create mode 100644 test/app/components/markdown/transform.test.js 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 ( - - {'!['} - {children} - {']('} - {src} - {')'} - + + {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; +}