diff --git a/lib/model/content.dart b/lib/model/content.dart index 88afbf9114..66ed09a011 100644 --- a/lib/model/content.dart +++ b/lib/model/content.dart @@ -120,9 +120,26 @@ class UnimplementedBlockContentNode extends BlockContentNode class BlockInlineContainerNode extends BlockContentNode { const BlockInlineContainerNode({ super.debugHtmlNode, + required this.links, required this.nodes, }); + /// A list of all [LinkNode] descendants. + /// + /// An empty list is represented as null. + /// + /// Because this lists all descendants that are [LinkNode]s, + /// it carries no information that couldn't be computed from [nodes]. + /// It exists as an optimization, to allow a widget interpreting this node + /// to obtain that list during build without having to walk the [nodes] tree. + // + // We leave [links] out of [debugFillProperties], because it should carry + // no information that's not already in [nodes]. + // Our tests validate that invariant systematically + // (see `_checkLinks` in `test/model/content_checks.dart`), + // and give a specialized error message if it fails. + final List? links; // TODO perhaps use `const []` instead of null + final List nodes; @override @@ -153,8 +170,12 @@ class LineBreakNode extends BlockContentNode { /// /// See also [parseImplicitParagraphBlockContentList]. class ParagraphNode extends BlockInlineContainerNode { - const ParagraphNode( - {super.debugHtmlNode, required super.nodes, this.wasImplicit = false}); + const ParagraphNode({ + super.debugHtmlNode, + this.wasImplicit = false, + required super.links, + required super.nodes, + }); /// True when there was no corresponding `p` element in the original HTML. final bool wasImplicit; @@ -171,6 +192,7 @@ enum HeadingLevel { h1, h2, h3, h4, h5, h6 } class HeadingNode extends BlockInlineContainerNode { const HeadingNode({ super.debugHtmlNode, + required super.links, required super.nodes, required this.level, }); @@ -375,8 +397,22 @@ class InlineCodeNode extends InlineContainerNode { } class LinkNode extends InlineContainerNode { - const LinkNode({super.debugHtmlNode, required super.nodes}); - // TODO: final String hrefUrl; + const LinkNode({super.debugHtmlNode, required super.nodes, required this.url}); + + // TODO(#71): Use [LinkNode.url] to open links + final String url; // Left as a string, to defer parsing until link actually followed. + + // Unlike other [ContentNode]s, the identity is useful to show in debugging + // because the identical [LinkNode]s are expected in the enclosing + // [BlockInlineContainerNode.links]. + @override + String toStringShort() => "${objectRuntimeType(this, 'LinkNode')}#${shortHash(this)}"; + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(StringProperty('url', url)); + } } enum UserMentionType { user, userGroup } @@ -446,305 +482,370 @@ class ImageEmojiNode extends EmojiNode { //////////////////////////////////////////////////////////////// -final _emojiClassRegexp = RegExp(r"^emoji(-[0-9a-f]+)?$"); +/// What sort of nodes a [_ZulipContentParser] is currently expecting to find. +enum _ParserContext { + /// The parser is currently looking for block nodes. + block, -InlineContentNode parseInlineContent(dom.Node node) { - final debugHtmlNode = kDebugMode ? node : null; - InlineContentNode unimplemented() => UnimplementedInlineContentNode(htmlNode: node); + /// The parser is currently looking for inline nodes. + inline, +} - if (node is dom.Text) { - return TextNode(node.text, debugHtmlNode: debugHtmlNode); - } - if (node is! dom.Element) { - return unimplemented(); - } +class _ZulipContentParser { + /// The current state of what sort of nodes the parser is looking for. + /// + /// This exists for the sake of debug-mode checks, + /// and should be read or updated only inside an assertion. + _ParserContext _debugParserContext = _ParserContext.block; - final element = node; - final localName = element.localName; - final classes = element.classes; - List nodes() { - return element.nodes.map(parseInlineContent).toList(growable: false); - } + /// The links found so far in the current block inline container. + /// + /// Empty is represented as null. + /// This is also null when not within a block inline container. + List? _linkNodes; - if (localName == 'br' && classes.isEmpty) { - return LineBreakInlineNode(debugHtmlNode: debugHtmlNode); - } - if (localName == 'strong' && classes.isEmpty) { - return StrongNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); - } - if (localName == 'em' && classes.isEmpty) { - return EmphasisNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); - } - if (localName == 'code' && classes.isEmpty) { - return InlineCodeNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); + List? _takeLinkNodes() { + final result = _linkNodes; + _linkNodes = null; + return result; } - if (localName == 'a' - && (classes.isEmpty - || (classes.length == 1 - && (classes.contains('stream-topic') - || classes.contains('stream'))))) { - // TODO parse link's href - return LinkNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); - } + static final _emojiClassRegexp = RegExp(r"^emoji(-[0-9a-f]+)?$"); - if (localName == 'span' - && (classes.contains('user-mention') - || classes.contains('user-group-mention')) - && (classes.length == 1 - || (classes.length == 2 && classes.contains('silent')))) { - return UserMentionNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); - } + InlineContentNode parseInlineContent(dom.Node node) { + assert(_debugParserContext == _ParserContext.inline); + final debugHtmlNode = kDebugMode ? node : null; + InlineContentNode unimplemented() => UnimplementedInlineContentNode(htmlNode: node); - if (localName == 'span' - && classes.length == 2 - && classes.contains('emoji') - && classes.every(_emojiClassRegexp.hasMatch)) { - return UnicodeEmojiNode(text: element.text, debugHtmlNode: debugHtmlNode); - } + if (node is dom.Text) { + return TextNode(node.text, debugHtmlNode: debugHtmlNode); + } + if (node is! dom.Element) { + return unimplemented(); + } - if (localName == 'img' - && classes.contains('emoji') - && classes.length == 1) { - final alt = element.attributes['alt']; - if (alt == null) return unimplemented(); - final src = element.attributes['src']; - if (src == null) return unimplemented(); - return ImageEmojiNode(src: src, alt: alt, debugHtmlNode: debugHtmlNode); - } + final element = node; + final localName = element.localName; + final classes = element.classes; + List nodes() => parseInlineContentList(element.nodes); - // TODO more types of node - return unimplemented(); -} + if (localName == 'br' && classes.isEmpty) { + return LineBreakInlineNode(debugHtmlNode: debugHtmlNode); + } + if (localName == 'strong' && classes.isEmpty) { + return StrongNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); + } + if (localName == 'em' && classes.isEmpty) { + return EmphasisNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); + } + if (localName == 'code' && classes.isEmpty) { + return InlineCodeNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); + } + + if (localName == 'a' + && (classes.isEmpty + || (classes.length == 1 + && (classes.contains('stream-topic') + || classes.contains('stream'))))) { + final href = element.attributes['href']; + if (href == null) return unimplemented(); + final link = LinkNode(nodes: nodes(), url: href, debugHtmlNode: debugHtmlNode); + (_linkNodes ??= []).add(link); + return link; + } + + if (localName == 'span' + && (classes.contains('user-mention') + || classes.contains('user-group-mention')) + && (classes.length == 1 + || (classes.length == 2 && classes.contains('silent')))) { + return UserMentionNode(nodes: nodes(), debugHtmlNode: debugHtmlNode); + } -BlockContentNode parseListNode(dom.Element element) { - ListStyle? listStyle; - switch (element.localName) { - case 'ol': listStyle = ListStyle.ordered; break; - case 'ul': listStyle = ListStyle.unordered; break; - } - assert(listStyle != null); - assert(element.classes.isEmpty); - - final debugHtmlNode = kDebugMode ? element : null; - final List> items = []; - for (final item in element.nodes) { - if (item is dom.Text && item.text == '\n') continue; - if (item is! dom.Element || item.localName != 'li' || item.classes.isNotEmpty) { - items.add([UnimplementedBlockContentNode(htmlNode: item)]); + if (localName == 'span' + && classes.length == 2 + && classes.contains('emoji') + && classes.every(_emojiClassRegexp.hasMatch)) { + return UnicodeEmojiNode(text: element.text, debugHtmlNode: debugHtmlNode); } - items.add(parseImplicitParagraphBlockContentList(item.nodes)); + + if (localName == 'img' + && classes.contains('emoji') + && classes.length == 1) { + final alt = element.attributes['alt']; + if (alt == null) return unimplemented(); + final src = element.attributes['src']; + if (src == null) return unimplemented(); + return ImageEmojiNode(src: src, alt: alt, debugHtmlNode: debugHtmlNode); + } + + // TODO more types of node + return unimplemented(); } - return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode); -} + List parseInlineContentList(List nodes) { + assert(_debugParserContext == _ParserContext.inline); + return nodes.map(parseInlineContent).toList(growable: false); + } + + ({List nodes, List? links}) parseBlockInline(List nodes) { + assert(_debugParserContext == _ParserContext.block); + assert(() { + _debugParserContext = _ParserContext.inline; + return true; + }()); + final resultNodes = parseInlineContentList(nodes); + assert(() { + _debugParserContext = _ParserContext.block; + return true; + }()); + return (nodes: resultNodes, links: _takeLinkNodes()); + } -BlockContentNode parseCodeBlock(dom.Element divElement) { - final mainElement = () { - assert(divElement.localName == 'div' - && divElement.classes.length == 1 - && divElement.classes.contains("codehilite")); - - if (divElement.nodes.length != 1) return null; - final child = divElement.nodes[0]; - if (child is! dom.Element) return null; - if (child.localName != 'pre') return null; - - if (child.nodes.length > 2) return null; - if (child.nodes.length == 2) { - final first = child.nodes[0]; - if (first is! dom.Element - || first.localName != 'span' - || first.nodes.isNotEmpty) return null; + BlockContentNode parseListNode(dom.Element element) { + assert(_debugParserContext == _ParserContext.block); + ListStyle? listStyle; + switch (element.localName) { + case 'ol': listStyle = ListStyle.ordered; break; + case 'ul': listStyle = ListStyle.unordered; break; } - final grandchild = child.nodes[child.nodes.length - 1]; - if (grandchild is! dom.Element) return null; - if (grandchild.localName != 'code') return null; - - return grandchild; - }(); - - final debugHtmlNode = kDebugMode ? divElement : null; - if (mainElement == null) { - return UnimplementedBlockContentNode(htmlNode: divElement); - } - - final buffer = StringBuffer(); - for (int i = 0; i < mainElement.nodes.length; i++) { - final child = mainElement.nodes[i]; - if (child is dom.Text) { - String text = child.text; - if (i == mainElement.nodes.length - 1) { - // The HTML tends to have a final newline here. If included in the - // [Text] widget, that would make a trailing blank line. So cut it out. - text = text.replaceFirst(RegExp(r'\n$'), ''); + assert(listStyle != null); + assert(element.classes.isEmpty); + + final debugHtmlNode = kDebugMode ? element : null; + final List> items = []; + for (final item in element.nodes) { + if (item is dom.Text && item.text == '\n') continue; + if (item is! dom.Element || item.localName != 'li' || item.classes.isNotEmpty) { + items.add([UnimplementedBlockContentNode(htmlNode: item)]); } - buffer.write(text); - } else if (child is dom.Element && child.localName == 'span') { - // TODO(#191) parse the code-highlighting spans, to style them - buffer.write(child.text); - } else { - return UnimplementedBlockContentNode(htmlNode: divElement); + items.add(parseImplicitParagraphBlockContentList(item.nodes)); } + + return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode); } - final text = buffer.toString(); - return CodeBlockNode(text: text, debugHtmlNode: debugHtmlNode); -} + BlockContentNode parseCodeBlock(dom.Element divElement) { + assert(_debugParserContext == _ParserContext.block); + final mainElement = () { + assert(divElement.localName == 'div' + && divElement.classes.length == 1 + && divElement.classes.contains("codehilite")); -BlockContentNode parseImageNode(dom.Element divElement) { - final imgElement = () { - assert(divElement.localName == 'div' - && divElement.classes.length == 1 - && divElement.classes.contains('message_inline_image')); + if (divElement.nodes.length != 1) return null; + final child = divElement.nodes[0]; + if (child is! dom.Element) return null; + if (child.localName != 'pre') return null; - if (divElement.nodes.length != 1) return null; - final child = divElement.nodes[0]; - if (child is! dom.Element) return null; - if (child.localName != 'a') return null; - if (child.classes.isNotEmpty) return null; + if (child.nodes.length > 2) return null; + if (child.nodes.length == 2) { + final first = child.nodes[0]; + if (first is! dom.Element + || first.localName != 'span' + || first.nodes.isNotEmpty) return null; + } + final grandchild = child.nodes[child.nodes.length - 1]; + if (grandchild is! dom.Element) return null; + if (grandchild.localName != 'code') return null; - if (child.nodes.length != 1) return null; - final grandchild = child.nodes[0]; - if (grandchild is! dom.Element) return null; - if (grandchild.localName != 'img') return null; - if (grandchild.classes.isNotEmpty) return null; - return grandchild; - }(); + return grandchild; + }(); - final debugHtmlNode = kDebugMode ? divElement : null; - if (imgElement == null) { - return UnimplementedBlockContentNode(htmlNode: divElement); - } + final debugHtmlNode = kDebugMode ? divElement : null; + if (mainElement == null) { + return UnimplementedBlockContentNode(htmlNode: divElement); + } - final src = imgElement.attributes['src']; - if (src == null) { - return UnimplementedBlockContentNode(htmlNode: divElement); - } + final buffer = StringBuffer(); + for (int i = 0; i < mainElement.nodes.length; i++) { + final child = mainElement.nodes[i]; + if (child is dom.Text) { + String text = child.text; + if (i == mainElement.nodes.length - 1) { + // The HTML tends to have a final newline here. If included in the + // [Text] widget, that would make a trailing blank line. So cut it out. + text = text.replaceFirst(RegExp(r'\n$'), ''); + } + buffer.write(text); + } else if (child is dom.Element && child.localName == 'span') { + // TODO(#191) parse the code-highlighting spans, to style them + buffer.write(child.text); + } else { + return UnimplementedBlockContentNode(htmlNode: divElement); + } + } + final text = buffer.toString(); + + return CodeBlockNode(text: text, debugHtmlNode: debugHtmlNode); + } + + BlockContentNode parseImageNode(dom.Element divElement) { + assert(_debugParserContext == _ParserContext.block); + final imgElement = () { + assert(divElement.localName == 'div' + && divElement.classes.length == 1 + && divElement.classes.contains('message_inline_image')); + + if (divElement.nodes.length != 1) return null; + final child = divElement.nodes[0]; + if (child is! dom.Element) return null; + if (child.localName != 'a') return null; + if (child.classes.isNotEmpty) return null; + + if (child.nodes.length != 1) return null; + final grandchild = child.nodes[0]; + if (grandchild is! dom.Element) return null; + if (grandchild.localName != 'img') return null; + if (grandchild.classes.isNotEmpty) return null; + return grandchild; + }(); + + final debugHtmlNode = kDebugMode ? divElement : null; + if (imgElement == null) { + return UnimplementedBlockContentNode(htmlNode: divElement); + } - return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode); -} + final src = imgElement.attributes['src']; + if (src == null) { + return UnimplementedBlockContentNode(htmlNode: divElement); + } -BlockContentNode parseBlockContent(dom.Node node) { - final debugHtmlNode = kDebugMode ? node : null; - if (node is! dom.Element) { - return UnimplementedBlockContentNode(htmlNode: node); - } - final element = node; - final localName = element.localName; - final classes = element.classes; - List blockNodes() => parseBlockContentList(element.nodes); - List inlineNodes() { - return element.nodes.map(parseInlineContent).toList(growable: false); + return ImageNode(srcUrl: src, debugHtmlNode: debugHtmlNode); } - if (localName == 'br' && classes.isEmpty) { - return LineBreakNode(debugHtmlNode: debugHtmlNode); - } + BlockContentNode parseBlockContent(dom.Node node) { + assert(_debugParserContext == _ParserContext.block); + final debugHtmlNode = kDebugMode ? node : null; + if (node is! dom.Element) { + return UnimplementedBlockContentNode(htmlNode: node); + } + final element = node; + final localName = element.localName; + final classes = element.classes; + List blockNodes() => parseBlockContentList(element.nodes); - if (localName == 'p' && classes.isEmpty) { - return ParagraphNode(nodes: inlineNodes(), debugHtmlNode: debugHtmlNode); - } + if (localName == 'br' && classes.isEmpty) { + return LineBreakNode(debugHtmlNode: debugHtmlNode); + } - HeadingLevel? headingLevel; - switch (localName) { - case 'h1': headingLevel = HeadingLevel.h1; break; - case 'h2': headingLevel = HeadingLevel.h2; break; - case 'h3': headingLevel = HeadingLevel.h3; break; - case 'h4': headingLevel = HeadingLevel.h4; break; - case 'h5': headingLevel = HeadingLevel.h5; break; - case 'h6': headingLevel = HeadingLevel.h6; break; - } - if (headingLevel == HeadingLevel.h6 && classes.isEmpty) { - // TODO(#192) handle h1, h2, h3, h4, h5 - return HeadingNode( - level: headingLevel!, nodes: inlineNodes(), debugHtmlNode: debugHtmlNode); - } + if (localName == 'p' && classes.isEmpty) { + final parsed = parseBlockInline(element.nodes); + return ParagraphNode(debugHtmlNode: debugHtmlNode, + links: parsed.links, + nodes: parsed.nodes); + } - if ((localName == 'ol' || localName == 'ul') && classes.isEmpty) { - return parseListNode(element); - } + HeadingLevel? headingLevel; + switch (localName) { + case 'h1': headingLevel = HeadingLevel.h1; break; + case 'h2': headingLevel = HeadingLevel.h2; break; + case 'h3': headingLevel = HeadingLevel.h3; break; + case 'h4': headingLevel = HeadingLevel.h4; break; + case 'h5': headingLevel = HeadingLevel.h5; break; + case 'h6': headingLevel = HeadingLevel.h6; break; + } + if (headingLevel == HeadingLevel.h6 && classes.isEmpty) { + // TODO(#192) handle h1, h2, h3, h4, h5 + final parsed = parseBlockInline(element.nodes); + return HeadingNode(debugHtmlNode: debugHtmlNode, + level: headingLevel!, + links: parsed.links, + nodes: parsed.nodes); + } - if (localName == 'blockquote' && classes.isEmpty) { - return QuotationNode(blockNodes(), debugHtmlNode: debugHtmlNode); - } + if ((localName == 'ol' || localName == 'ul') && classes.isEmpty) { + return parseListNode(element); + } - if (localName == 'div' - && classes.length == 1 && classes.contains('codehilite')) { - return parseCodeBlock(element); + if (localName == 'blockquote' && classes.isEmpty) { + return QuotationNode(blockNodes(), debugHtmlNode: debugHtmlNode); + } + + if (localName == 'div' + && classes.length == 1 && classes.contains('codehilite')) { + return parseCodeBlock(element); + } + + if (localName == 'div' + && classes.length == 1 && classes.contains('message_inline_image')) { + return parseImageNode(element); + } + + // TODO more types of node + return UnimplementedBlockContentNode(htmlNode: node); } - if (localName == 'div' - && classes.length == 1 && classes.contains('message_inline_image')) { - return parseImageNode(element); + bool _isPossibleInlineNode(dom.Node node) { + // TODO: find a way to assert that this matches parsing, or refactor away + if (node is dom.Text) return true; + if (node is! dom.Element) return false; + switch (node.localName) { + case 'p': + case 'ol': + case 'ul': + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': + case 'blockquote': + case 'div': + return false; + default: + return true; + } } - // TODO more types of node - return UnimplementedBlockContentNode(htmlNode: node); -} + /// Parse where block content is expected, but paragraphs may be implicit. + /// + /// See [ParagraphNode]. + List parseImplicitParagraphBlockContentList(dom.NodeList nodes) { + assert(_debugParserContext == _ParserContext.block); + final List result = []; + final List currentParagraph = []; + void consumeParagraph() { + final parsed = parseBlockInline(currentParagraph); + result.add(ParagraphNode( + wasImplicit: true, + links: parsed.links, + nodes: parsed.nodes)); + currentParagraph.clear(); + } -bool _isPossibleInlineNode(dom.Node node) { - // TODO: find a way to assert that this matches parsing, or refactor away - if (node is dom.Text) return true; - if (node is! dom.Element) return false; - switch (node.localName) { - case 'p': - case 'ol': - case 'ul': - case 'h1': - case 'h2': - case 'h3': - case 'h4': - case 'h5': - case 'h6': - case 'blockquote': - case 'div': - return false; - default: - return true; - } -} + for (final node in nodes) { + if (node is dom.Text && (node.text == '\n')) continue; -/// Parse where block content is expected, but paragraphs may be implicit. -/// -/// See [ParagraphNode]. -List parseImplicitParagraphBlockContentList(dom.NodeList nodes) { - final List result = []; - final List currentParagraph = []; - void consumeParagraph() { - result.add(ParagraphNode( - wasImplicit: true, - nodes: currentParagraph.map(parseInlineContent).toList(growable: false))); - currentParagraph.clear(); - } - - for (final node in nodes) { - if (node is dom.Text && (node.text == '\n')) continue; - - if (_isPossibleInlineNode(node)) { - currentParagraph.add(node); - continue; + if (_isPossibleInlineNode(node)) { + currentParagraph.add(node); + continue; + } + if (currentParagraph.isNotEmpty) consumeParagraph(); + result.add(parseBlockContent(node)); } if (currentParagraph.isNotEmpty) consumeParagraph(); - result.add(parseBlockContent(node)); + + return result; } - if (currentParagraph.isNotEmpty) consumeParagraph(); - return result; -} + List parseBlockContentList(dom.NodeList nodes) { + assert(_debugParserContext == _ParserContext.block); + final acceptedNodes = nodes.where((node) { + // We get a bunch of newline Text nodes between paragraphs. + // A browser seems to ignore these; let's do the same. + if (node is dom.Text && (node.text == '\n')) return false; + return true; + }); + return acceptedNodes.map(parseBlockContent).toList(growable: false); + } -List parseBlockContentList(dom.NodeList nodes) { - final acceptedNodes = nodes.where((node) { - // We get a bunch of newline Text nodes between paragraphs. - // A browser seems to ignore these; let's do the same. - if (node is dom.Text && (node.text == '\n')) return false; - return true; - }); - return acceptedNodes.map(parseBlockContent).toList(growable: false); + ZulipContent parse(String html) { + final fragment = HtmlParser(html, parseMeta: false).parseFragment(); + final nodes = parseBlockContentList(fragment.nodes); + return ZulipContent(nodes: nodes, debugHtmlNode: kDebugMode ? fragment : null); + } } ZulipContent parseContent(String html) { - final fragment = HtmlParser(html, parseMeta: false).parseFragment(); - final nodes = parseBlockContentList(fragment.nodes); - return ZulipContent(nodes: nodes, debugHtmlNode: kDebugMode ? fragment : null); + return _ZulipContentParser().parse(html); } diff --git a/test/model/content_checks.dart b/test/model/content_checks.dart index c9f38af1a7..da993c7423 100644 --- a/test/model/content_checks.dart +++ b/test/model/content_checks.dart @@ -3,6 +3,11 @@ import 'package:flutter/foundation.dart'; import 'package:zulip/model/content.dart'; extension ContentNodeChecks on Subject { + // In [expected], for the `links` field of [ParagraphNode] or + // any other [BlockInlineContainerNode] subclass, use `null`. + // This field will be ignored in [expected], and instead the + // field's value in [actual] will be checked for accuracy against + // the [BlockInlineContainerNode.nodes] field on the same node. void equalsNode(ContentNode expected) { return context.expect(() => prefixFirst('equals ', literal(expected)), (actual) { final which = _compareDiagnosticsNodes( @@ -58,5 +63,55 @@ Iterable? _compareDiagnosticsNodes(DiagnosticsNode actual, DiagnosticsNo } } + if (actual.value is BlockInlineContainerNode) { + final failure = _checkLinks(actual.value as BlockInlineContainerNode); + if (failure != null) { + return failure; + } + } + return null; } + +Iterable? _checkLinks(BlockInlineContainerNode node) { + final foundLinks = _findLinkNodes(node.nodes).toList(); + final which = () { + var actualLinks = node.links; + if (actualLinks != null && actualLinks.isEmpty) { + return ['has empty non-null links']; + } + actualLinks ??= []; + if (actualLinks.length != foundLinks.length) { + return ['has ${actualLinks.length} links while nodes has ${foundLinks.length}']; + } + for (int i = 0; i < foundLinks.length; i++) { + if (!identical(actualLinks[i], foundLinks[i])) { + return ['has a mismatch in links at element $i']; + } + } + }(); + + if (which == null) return null; + + return [ + ...which, + 'Actual links property:', + ...indent(literal(node.links)), + 'Expected links, from actual nodes:', + ...indent(literal(foundLinks)), + ]; +} + +Iterable _findLinkNodes(Iterable nodes) { + return nodes.expand((node) { + if (node is! InlineContainerNode) return const []; + if (node is LinkNode) { + // HTML disallows `a` as a descendant of `a`: + // https://html.spec.whatwg.org/#the-a-element (see "Content model") + // and Dart's HTML parser seems not to produce it in the DOM. + assert(_findLinkNodes(node.nodes).isEmpty); + return [node]; + } + return _findLinkNodes(node.nodes); + }); +} diff --git a/test/model/content_test.dart b/test/model/content_test.dart index 208c5d6ffc..274496d48e 100644 --- a/test/model/content_test.dart +++ b/test/model/content_test.dart @@ -52,18 +52,18 @@ void main() { // void testParseInline(String name, String html, InlineContentNode node) { - testParse(name, html, [ParagraphNode(nodes: [node])]); + testParse(name, html, [ParagraphNode(links: null, nodes: [node])]); } testParse('parse a plain-text paragraph', // "hello world" - '

hello world

', const [ParagraphNode(nodes: [ + '

hello world

', const [ParagraphNode(links: null, nodes: [ TextNode('hello world'), ])]); testParse('parse
inside a paragraph', // "a\nb" - '

a
\nb

', const [ParagraphNode(nodes: [ + '

a
\nb

', const [ParagraphNode(links: null, nodes: [ TextNode('a'), LineBreakInlineNode(), TextNode('\nb'), @@ -90,32 +90,42 @@ void main() { const StrongNode(nodes: [EmphasisNode(nodes: [InlineCodeNode(nodes: [ TextNode('word')])])])); - testParseInline('parse link', - // "[text](https://example/)" - '

text

', - const LinkNode(nodes: [TextNode('text')])); - - testParseInline('parse #-mention of stream', - // "#**general**" - '

' - '#general

', - const LinkNode(nodes: [TextNode('#general')])); - - testParseInline('parse #-mention of topic', - // "#**mobile-team>zulip-flutter**" - '

' - '#mobile-team > zulip-flutter

', - const LinkNode(nodes: [TextNode('#mobile-team > zulip-flutter')])); + group('LinkNode', () { + testParseInline('parse link', + // "[text](https://example/)" + '

text

', + const LinkNode(url: 'https://example/', nodes: [TextNode('text')])); + + testParseInline('parse #-mention of stream', + // "#**general**" + '

' + '#general

', + const LinkNode(url: '/#narrow/stream/2-general', + nodes: [TextNode('#general')])); + + testParseInline('parse #-mention of topic', + // "#**mobile-team>zulip-flutter**" + '

' + '#mobile-team > zulip-flutter

', + const LinkNode(url: '/#narrow/stream/243-mobile-team/topic/zulip-flutter', + nodes: [TextNode('#mobile-team > zulip-flutter')])); + }); testParseInline('parse nested link, strong, em, code', // "[***`word`***](https://example/)" '

word' '

', - const LinkNode(nodes: [StrongNode(nodes: [ - EmphasisNode(nodes: [InlineCodeNode(nodes: [ + const LinkNode(url: 'https://example/', + nodes: [StrongNode(nodes: [EmphasisNode(nodes: [InlineCodeNode(nodes: [ TextNode('word')])])])])); + testParseInline('parse nested strong, em, link', + // "***[t](/u)***" + '

t

', + const StrongNode(nodes: [EmphasisNode(nodes: [LinkNode(url: '/u', + nodes: [TextNode('t')])])])); + group('parse @-mentions', () { testParseInline('plain user @-mention', // "@**Greg Price**" @@ -154,40 +164,40 @@ void main() { testParse('parse
in block context', '

a


', const [ // TODO not sure how to reproduce this example LineBreakNode(), - ParagraphNode(nodes: [TextNode('a')]), + ParagraphNode(links: null, nodes: [TextNode('a')]), LineBreakNode(), ]); testParse('parse two plain-text paragraphs', // "hello\n\nworld" '

hello

\n

world

', const [ - ParagraphNode(nodes: [TextNode('hello')]), - ParagraphNode(nodes: [TextNode('world')]), + ParagraphNode(links: null, nodes: [TextNode('hello')]), + ParagraphNode(links: null, nodes: [TextNode('world')]), ]); group('parse headings', () { testParse('plain h6', // "###### six" '
six
', const [ - HeadingNode(level: HeadingLevel.h6, nodes: [TextNode('six')])]); + HeadingNode(level: HeadingLevel.h6, links: null, nodes: [TextNode('six')])]); testParse('containing inline markup', // "###### one [***`two`***](https://example/)" '
one two' '
', const [ - HeadingNode(level: HeadingLevel.h6, nodes: [ + HeadingNode(level: HeadingLevel.h6, links: null, nodes: [ TextNode('one '), - LinkNode(nodes: [StrongNode(nodes: [ - EmphasisNode(nodes: [InlineCodeNode(nodes: [ - TextNode('two')])])])]), + LinkNode(url: 'https://example/', + nodes: [StrongNode(nodes: [EmphasisNode(nodes: [ + InlineCodeNode(nodes: [TextNode('two')])])])]), ])]); testParse('amidst paragraphs', // "intro\n###### section\ntext" "

intro

\n
section
\n

text

", const [ - ParagraphNode(nodes: [TextNode('intro')]), - HeadingNode(level: HeadingLevel.h6, nodes: [TextNode('section')]), - ParagraphNode(nodes: [TextNode('text')]), + ParagraphNode(links: null, nodes: [TextNode('intro')]), + HeadingNode(level: HeadingLevel.h6, links: null, nodes: [TextNode('section')]), + ParagraphNode(links: null, nodes: [TextNode('text')]), ]); testParse('h1, h2, h3, h4, h5 unimplemented', @@ -206,8 +216,8 @@ void main() { // "1. first\n2. then" '
    \n
  1. first
  2. \n
  3. then
  4. \n
', const [ ListNode(ListStyle.ordered, [ - [ParagraphNode(wasImplicit: true, nodes: [TextNode('first')])], - [ParagraphNode(wasImplicit: true, nodes: [TextNode('then')])], + [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('first')])], + [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('then')])], ]), ]); @@ -215,8 +225,8 @@ void main() { // "* something\n* another" '
    \n
  • something
  • \n
  • another
  • \n
', const [ ListNode(ListStyle.unordered, [ - [ParagraphNode(wasImplicit: true, nodes: [TextNode('something')])], - [ParagraphNode(wasImplicit: true, nodes: [TextNode('another')])], + [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('something')])], + [ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('another')])], ]), ]); @@ -224,7 +234,7 @@ void main() { // "* a\n b" '
    \n
  • a
    \n b
  • \n
', const [ ListNode(ListStyle.unordered, [ - [ParagraphNode(wasImplicit: true, nodes: [ + [ParagraphNode(wasImplicit: true, links: null, nodes: [ TextNode('a'), LineBreakInlineNode(), TextNode('\n b'), // TODO: this renders misaligned @@ -237,17 +247,49 @@ void main() { '
    \n
  • \n

    a

    \n

    b

    \n
  • \n
', const [ ListNode(ListStyle.unordered, [ [ - ParagraphNode(nodes: [TextNode('a')]), - ParagraphNode(nodes: [TextNode('b')]), + ParagraphNode(links: null, nodes: [TextNode('a')]), + ParagraphNode(links: null, nodes: [TextNode('b')]), ], ]), ]); }); + group('track links inside block-inline containers', () { + testParse('multiple links in paragraph', + // "before[text](/there)mid[other](/else)after" + '

beforetextmid' + 'otherafter

', const [ + ParagraphNode(links: null, nodes: [ + TextNode('before'), + LinkNode(url: '/there', nodes: [TextNode('text')]), + TextNode('mid'), + LinkNode(url: '/else', nodes: [TextNode('other')]), + TextNode('after'), + ])]); + + testParse('link in heading', + // "###### [t](/u)\nhi" + '
t
\n

hi

', const [ + HeadingNode(links: null, level: HeadingLevel.h6, nodes: [ + LinkNode(url: '/u', nodes: [TextNode('t')]), + ]), + ParagraphNode(links: null, nodes: [TextNode('hi')]), + ]); + + testParse('link in list item', + // "* [t](/u)" + '
    \n
  • t
  • \n
', const [ + ListNode(ListStyle.unordered, [ + [ParagraphNode(links: null, wasImplicit: true, nodes: [ + LinkNode(url: '/u', nodes: [TextNode('t')]), + ])], + ])]); + }); + testParse('parse quotations', // "```quote\nwords\n```" '
\n

words

\n
', const [ - QuotationNode([ParagraphNode(nodes: [TextNode('words')])]), + QuotationNode([ParagraphNode(links: null, nodes: [TextNode('words')])]), ]); testParse('parse code blocks, no language', @@ -281,13 +323,13 @@ void main() { 'four\n\n\n\n', const [ ListNode(ListStyle.ordered, [[ QuotationNode([ - HeadingNode(level: HeadingLevel.h6, nodes: [TextNode('two')]), + HeadingNode(level: HeadingLevel.h6, links: null, nodes: [TextNode('two')]), ListNode(ListStyle.unordered, [[ - ParagraphNode(wasImplicit: true, nodes: [TextNode('three')]), + ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('three')]), ]]), ]), CodeBlockNode(text: 'four'), - ParagraphNode(wasImplicit: true, nodes: [TextNode('\n\n')]), // TODO avoid this; it renders wrong + ParagraphNode(wasImplicit: true, links: null, nodes: [TextNode('\n\n')]), // TODO avoid this; it renders wrong ]]), ]); }