blob: 4b7619ff6d3615add83c093b758218017336d6bf [file] [log] [blame]
// 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;
}
}