| // Copyright (c) 2022, 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 '../patterns.dart'; |
| import 'block_syntax.dart'; |
| import 'ordered_list_with_checkbox_syntax.dart'; |
| import 'unordered_list_with_checkbox_syntax.dart'; |
| |
| /// As of Markdown 6.0.1 invisible indicators for checked/unchecked checkboxes are |
| /// no longer used. These constants are now empty strings to reflect that. |
| @Deprecated( |
| 'This string is no longer used internally. It will be removed in a future version.') |
| const indicatorForUncheckedCheckBox = ''; |
| |
| /// As of Markdown 6.0.1 invisible indicators for checked/unchecked checkboxes are |
| /// no longer used. These constants are now empty strings to reflect that. |
| @Deprecated( |
| 'This string is no longer used internally. It be will be removed in a future version.') |
| const indicatorForCheckedCheckBox = ''; |
| |
| class ListItem { |
| const ListItem( |
| this.lines, { |
| this.taskListItemState, |
| }); |
| |
| final List<String> lines; |
| final TaskListItemState? taskListItemState; |
| } |
| |
| enum TaskListItemState { checked, unchecked } |
| |
| /// Base class for both ordered and unordered lists. |
| abstract class ListSyntax extends BlockSyntax { |
| @override |
| bool canEndBlock(BlockParser parser) { |
| // An empty list cannot interrupt a paragraph. See |
| // https://spec.commonmark.org/0.29/#example-255. |
| // Ideally, [BlockSyntax.canEndBlock] should be changed to be a method |
| // which accepts a [BlockParser], but this would be a breaking change, |
| // so we're going with this temporarily. |
| final match = pattern.firstMatch(parser.current)!; |
| // The seventh group, in both [olPattern] and [ulPattern] is the text |
| // after the delimiter. |
| return match[7]?.isNotEmpty ?? false; |
| } |
| |
| String get listTag; |
| |
| const ListSyntax(); |
| |
| /// A list of patterns that can start a valid block within a list item. |
| static final blocksInList = [ |
| blockquotePattern, |
| headerPattern, |
| hrPattern, |
| indentPattern, |
| ulPattern, |
| olPattern |
| ]; |
| |
| static final _whitespaceRe = RegExp('[ \t]*'); |
| |
| @override |
| Node parse(BlockParser parser) { |
| final taskListParserEnabled = this is UnorderedListWithCheckboxSyntax || |
| this is OrderedListWithCheckboxSyntax; |
| final items = <ListItem>[]; |
| var childLines = <String>[]; |
| TaskListItemState? taskListItemState; |
| |
| void endItem() { |
| if (childLines.isNotEmpty) { |
| items.add(ListItem(childLines, taskListItemState: taskListItemState)); |
| childLines = <String>[]; |
| } |
| } |
| |
| String parseTaskListItem(String text) { |
| final pattern = RegExp(r'^ {0,3}\[([ xX])\][ \t]'); |
| |
| if (taskListParserEnabled && pattern.hasMatch(text)) { |
| return text.replaceFirstMapped(pattern, ((match) { |
| taskListItemState = match[1] == ' ' |
| ? TaskListItemState.unchecked |
| : TaskListItemState.checked; |
| |
| return ''; |
| })); |
| } else { |
| taskListItemState = null; |
| return text; |
| } |
| } |
| |
| late Match? possibleMatch; |
| bool tryMatch(RegExp pattern) { |
| possibleMatch = pattern.firstMatch(parser.current); |
| return possibleMatch != null; |
| } |
| |
| String? listMarker; |
| String? indent; |
| // In case the first number in an ordered list is not 1, use it as the |
| // "start". |
| int? startNumber; |
| |
| while (!parser.isDone) { |
| final leadingSpace = |
| _whitespaceRe.matchAsPrefix(parser.current)!.group(0)!; |
| final leadingExpandedTabLength = _expandedTabLength(leadingSpace); |
| if (emptyPattern.hasMatch(parser.current)) { |
| if (emptyPattern.hasMatch(parser.next ?? '')) { |
| // Two blank lines ends a list. |
| break; |
| } |
| // Add a blank line to the current list item. |
| childLines.add(''); |
| } else if (indent != null && indent.length <= leadingExpandedTabLength) { |
| // Strip off indent and add to current item. |
| final line = parser.current |
| .replaceFirst(leadingSpace, ' ' * leadingExpandedTabLength) |
| .replaceFirst(indent, ''); |
| childLines.add(parseTaskListItem(line)); |
| } else if (tryMatch(hrPattern)) { |
| // Horizontal rule takes precedence to a new list item. |
| break; |
| } else if (tryMatch(ulPattern) || tryMatch(olPattern)) { |
| final match = possibleMatch!; |
| final precedingWhitespace = match[1]!; |
| final digits = match[2] ?? ''; |
| if (startNumber == null && digits.isNotEmpty) { |
| startNumber = int.parse(digits); |
| } |
| final marker = match[3]!; |
| final firstWhitespace = match[5] ?? ''; |
| final restWhitespace = match[6] ?? ''; |
| final content = match[7] ?? ''; |
| final isBlank = content.isEmpty; |
| if (listMarker != null && listMarker != marker) { |
| // Changing the bullet or ordered list delimiter starts a new list. |
| break; |
| } |
| listMarker = marker; |
| final markerAsSpaces = ' ' * (digits.length + marker.length); |
| if (isBlank) { |
| // See http://spec.commonmark.org/0.28/#list-items under "3. Item |
| // starting with a blank line." |
| // |
| // If the list item starts with a blank line, the final piece of the |
| // indentation is just a single space. |
| indent = '$precedingWhitespace$markerAsSpaces '; |
| } else if (restWhitespace.length >= 4) { |
| // See http://spec.commonmark.org/0.28/#list-items under "2. Item |
| // starting with indented code." |
| // |
| // If the list item starts with indented code, we need to _not_ count |
| // any indentation past the required whitespace character. |
| indent = precedingWhitespace + markerAsSpaces + firstWhitespace; |
| } else { |
| indent = precedingWhitespace + |
| markerAsSpaces + |
| firstWhitespace + |
| restWhitespace; |
| } |
| // End the current list item and start a new one. |
| endItem(); |
| childLines.add(parseTaskListItem('$restWhitespace$content')); |
| } else if (BlockSyntax.isAtBlockEnd(parser)) { |
| // Done with the list. |
| break; |
| } else { |
| // If the previous item is a blank line, this means we're done with the |
| // list and are starting a new top-level paragraph. |
| if ((childLines.isNotEmpty) && (childLines.last == '')) { |
| parser.encounteredBlankLine = true; |
| break; |
| } |
| |
| // Anything else is paragraph continuation text. |
| childLines.add(parser.current); |
| } |
| parser.advance(); |
| } |
| |
| endItem(); |
| final itemNodes = <Element>[]; |
| |
| items.forEach(_removeLeadingEmptyLine); |
| final anyEmptyLines = _removeTrailingEmptyLines(items); |
| var anyEmptyLinesBetweenBlocks = false; |
| var containsTaskList = false; |
| |
| for (final item in items) { |
| Element? checkboxToInsert; |
| if (item.taskListItemState != null) { |
| containsTaskList = true; |
| checkboxToInsert = Element.withTag('input') |
| ..attributes['type'] = 'checkbox'; |
| if (item.taskListItemState == TaskListItemState.checked) { |
| checkboxToInsert.attributes['checked'] = 'true'; |
| } |
| } |
| |
| final itemParser = BlockParser(item.lines, parser.document); |
| final children = itemParser.parseLines(); |
| final itemElement = checkboxToInsert == null |
| ? Element('li', children) |
| : (Element('li', [checkboxToInsert, ...children]) |
| ..attributes['class'] = 'task-list-item'); |
| |
| itemNodes.add(itemElement); |
| anyEmptyLinesBetweenBlocks = |
| anyEmptyLinesBetweenBlocks || itemParser.encounteredBlankLine; |
| } |
| |
| // Must strip paragraph tags if the list is "tight". |
| // http://spec.commonmark.org/0.28/#lists |
| final listIsTight = !anyEmptyLines && !anyEmptyLinesBetweenBlocks; |
| |
| if (listIsTight) { |
| // We must post-process the list items, converting any top-level paragraph |
| // elements to just text elements. |
| for (final item in itemNodes) { |
| final children = item.children; |
| if (children != null) { |
| for (var i = 0; i < children.length; i++) { |
| final child = children[i]; |
| if (child is Element && child.tag == 'p') { |
| children.removeAt(i); |
| children.insertAll(i, child.children!); |
| } |
| } |
| } |
| } |
| } |
| |
| final listElement = Element(listTag, itemNodes); |
| if (listTag == 'ol' && startNumber != 1) { |
| listElement.attributes['start'] = '$startNumber'; |
| } |
| |
| if (containsTaskList) { |
| listElement.attributes['class'] = 'contains-task-list'; |
| } |
| return listElement; |
| } |
| |
| void _removeLeadingEmptyLine(ListItem item) { |
| if (item.lines.isNotEmpty && emptyPattern.hasMatch(item.lines.first)) { |
| item.lines.removeAt(0); |
| } |
| } |
| |
| /// Removes any trailing empty lines and notes whether any items are separated |
| /// by such lines. |
| bool _removeTrailingEmptyLines(List<ListItem> items) { |
| var anyEmpty = false; |
| for (var i = 0; i < items.length; i++) { |
| if (items[i].lines.length == 1) continue; |
| while (items[i].lines.isNotEmpty && |
| emptyPattern.hasMatch(items[i].lines.last)) { |
| if (i < items.length - 1) { |
| anyEmpty = true; |
| } |
| items[i].lines.removeLast(); |
| } |
| } |
| return anyEmpty; |
| } |
| |
| static int _expandedTabLength(String input) { |
| var length = 0; |
| for (final char in input.codeUnits) { |
| length += char == 0x9 ? 4 - (length % 4) : 1; |
| } |
| return length; |
| } |
| } |