| // Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file | |
| // for details. All rights reserved. Use of this source code is governed by a | |
| // BSD-style license that can be found in the LICENSE file. | |
| import 'ast.dart'; | |
| import 'block_parser.dart'; | |
| import 'block_syntaxes/block_syntax.dart'; | |
| import 'extension_set.dart'; | |
| import 'inline_parser.dart'; | |
| import 'inline_syntaxes/inline_syntax.dart'; | |
| import 'line.dart'; | |
| import 'util.dart'; | |
| /// Maintains the context needed to parse a Markdown document. | |
| class Document { | |
| final Map<String, LinkReference> linkReferences = {}; | |
| /// Footnote ref count, keys are case-sensitive and added by define syntax. | |
| final footnoteReferences = <String, int>{}; | |
| /// Footnote labels by appearing order. | |
| /// | |
| /// They are case-insensitive and added by ref syntax. | |
| final footnoteLabels = <String>[]; | |
| final LinkBuilder? linkBuilder; | |
| final LinkBuilder? imageLinkBuilder; | |
| final bool encodeHtml; | |
| /// Whether to use default block syntaxes. | |
| final bool withDefaultBlockSyntaxes; | |
| /// Whether to use default inline syntaxes. | |
| /// | |
| /// Need to set both [withDefaultInlineSyntaxes] and [encodeHtml] to | |
| /// `false` to disable all inline syntaxes including html encoding syntaxes. | |
| final bool withDefaultInlineSyntaxes; | |
| final _blockSyntaxes = <BlockSyntax>{}; | |
| final _inlineSyntaxes = <InlineSyntax>{}; | |
| final bool hasCustomInlineSyntaxes; | |
| Iterable<BlockSyntax> get blockSyntaxes => _blockSyntaxes; | |
| Iterable<InlineSyntax> get inlineSyntaxes => _inlineSyntaxes; | |
| Document({ | |
| Iterable<BlockSyntax>? blockSyntaxes, | |
| Iterable<InlineSyntax>? inlineSyntaxes, | |
| ExtensionSet? extensionSet, | |
| Resolver? linkResolver, | |
| LinkBuilder? linkBuilder, | |
| Resolver? imageLinkResolver, | |
| LinkBuilder? imageLinkBuilder, | |
| this.encodeHtml = true, | |
| this.withDefaultBlockSyntaxes = true, | |
| this.withDefaultInlineSyntaxes = true, | |
| }) : linkBuilder = linkBuilder ?? linkBuilderFromResolver(linkResolver), | |
| imageLinkBuilder = | |
| imageLinkBuilder ?? linkBuilderFromResolver(imageLinkResolver), | |
| hasCustomInlineSyntaxes = | |
| (inlineSyntaxes?.isNotEmpty ?? false) || | |
| (extensionSet?.inlineSyntaxes.isNotEmpty ?? false) { | |
| if (blockSyntaxes != null) { | |
| _blockSyntaxes.addAll(blockSyntaxes); | |
| } | |
| if (inlineSyntaxes != null) { | |
| _inlineSyntaxes.addAll(inlineSyntaxes); | |
| } | |
| if (extensionSet == null) { | |
| if (withDefaultBlockSyntaxes) { | |
| _blockSyntaxes.addAll(ExtensionSet.commonMark.blockSyntaxes); | |
| } | |
| if (withDefaultInlineSyntaxes) { | |
| _inlineSyntaxes.addAll(ExtensionSet.commonMark.inlineSyntaxes); | |
| } | |
| } else { | |
| _blockSyntaxes.addAll(extensionSet.blockSyntaxes); | |
| _inlineSyntaxes.addAll(extensionSet.inlineSyntaxes); | |
| } | |
| } | |
| Resolver? get linkResolver => linkResolverFromBuilder(linkBuilder); | |
| Resolver? get imageLinkResolver => linkResolverFromBuilder(imageLinkBuilder); | |
| /// Parses the given [lines] of Markdown to a series of AST nodes. | |
| List<Node> parseLines(List<String> lines) => | |
| parseLineList(lines.map(Line.new).toList()); | |
| /// Parses the given [text] to a series of AST nodes. | |
| List<Node> parse(String text) => parseLineList(text.toLines()); | |
| /// Parses the given [lines] of [Line] to a series of AST nodes. | |
| List<Node> parseLineList(List<Line> lines) { | |
| final nodes = BlockParser(lines, this).parseLines(); | |
| _parseInlineContent(nodes); | |
| // Do filter after parsing inline as we need ref count. | |
| return _filterFootnotes(nodes); | |
| } | |
| /// Parses the given inline Markdown [text] to a series of AST nodes. | |
| List<Node> parseInline(String text) => InlineParser(text, this).parse(); | |
| void _parseInlineContent(List<Node> nodes) { | |
| for (var i = 0; i < nodes.length; i++) { | |
| final node = nodes[i]; | |
| if (node is UnparsedContent) { | |
| final inlineNodes = parseInline(node.textContent); | |
| nodes.removeAt(i); | |
| nodes.insertAll(i, inlineNodes); | |
| i += inlineNodes.length - 1; | |
| } else if (node is Element && node.children != null) { | |
| _parseInlineContent(node.children!); | |
| } | |
| } | |
| } | |
| /// Footnotes could be defined in arbitrary positions of a document, we need | |
| /// to distinguish them and put them behind; and every footnote definition | |
| /// may have multiple backrefs, we need to append backrefs for it. | |
| List<Node> _filterFootnotes(List<Node> nodes) { | |
| final footnotes = <Element>[]; | |
| final blocks = <Node>[]; | |
| for (final node in nodes) { | |
| if (node is Element && | |
| node.tag == 'li' && | |
| footnoteReferences.containsKey(node.footnoteLabel)) { | |
| final label = node.footnoteLabel; | |
| var count = 0; | |
| if (label != null && (count = footnoteReferences[label] ?? 0) > 0) { | |
| footnotes.add(node); | |
| final children = node.children; | |
| if (children != null) { | |
| _appendBackref(children, Uri.encodeComponent(label), count); | |
| } | |
| } | |
| } else { | |
| blocks.add(node); | |
| } | |
| } | |
| if (footnotes.isNotEmpty) { | |
| // Sort footnotes by appearing order. | |
| final ordinal = { | |
| for (var i = 0; i < footnoteLabels.length; i++) | |
| 'fn-${footnoteLabels[i]}': i, | |
| }; | |
| footnotes.sort((l, r) { | |
| final idl = l.attributes['id']?.toLowerCase() ?? ''; | |
| final idr = r.attributes['id']?.toLowerCase() ?? ''; | |
| return (ordinal[idl] ?? 0) - (ordinal[idr] ?? 0); | |
| }); | |
| final list = Element('ol', footnotes); | |
| // Ignore GFM attribute: <data-footnotes>. | |
| final section = Element('section', [list]) | |
| ..attributes['class'] = 'footnotes'; | |
| blocks.add(section); | |
| } | |
| return blocks; | |
| } | |
| /// Generate backref nodes, append them to footnote definition's last child. | |
| void _appendBackref(List<Node> children, String ref, int count) { | |
| final refs = [ | |
| for (var i = 0; i < count; i++) ...[ | |
| Text(' '), | |
| _ElementExt.footnoteAnchor(ref, i), | |
| ], | |
| ]; | |
| if (children.isEmpty) { | |
| children.addAll(refs); | |
| } else { | |
| final last = children.last; | |
| if (last is Element) { | |
| last.children?.addAll(refs); | |
| } else { | |
| children.last = Element('p', [last, ...refs]); | |
| } | |
| } | |
| } | |
| } | |
| extension _ElementExt on Element { | |
| static Element footnoteAnchor(String ref, int i) { | |
| final num = '${i + 1}'; | |
| final suffix = i > 0 ? '-$num' : ''; | |
| final e = Element.empty('tag'); | |
| e.match; | |
| return Element('a', [ | |
| Text('\u21a9'), | |
| if (i > 0) | |
| Element('sup', [Text(num)])..attributes['class'] = 'footnote-ref', | |
| ]) | |
| // Ignore GFM's attributes: | |
| // <data-footnote-backref aria-label="Back to content">. | |
| ..attributes['href'] = '#fnref-$ref$suffix' | |
| ..attributes['class'] = 'footnote-backref'; | |
| } | |
| String get match => tag; | |
| } | |
| /// A [link reference | |
| /// definition](https://spec.commonmark.org/0.30/#link-reference-definitions). | |
| class LinkReference { | |
| /// The [link label](https://spec.commonmark.org/0.30/#link-label). | |
| /// | |
| /// Temporarily, this class is also being used to represent the link data for | |
| /// an inline link (the destination and title), but this should change before | |
| /// the package is released. | |
| final String label; | |
| /// The [link destination](https://spec.commonmark.org/0.30/#link-destination). | |
| final String destination; | |
| /// The [link title](https://spec.commonmark.org/0.30/#link-title). | |
| final String? title; | |
| /// Construct a new [LinkReference], with all necessary fields. | |
| /// | |
| /// If the parsed link reference definition does not include a title, use | |
| /// `null` for the [title] parameter. | |
| LinkReference(this.label, this.destination, this.title); | |
| } |