| // Copyright (c) 2012, 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 'dart:convert'; | |
| import 'assets/case_folding.dart'; | |
| import 'assets/html_entities.dart'; | |
| import 'charcode.dart'; | |
| import 'line.dart'; | |
| import 'patterns.dart'; | |
| /// One or more whitespace, for compressing. | |
| final _oneOrMoreWhitespacePattern = RegExp('[ \n\r\t]+'); | |
| /// Escapes (`"`), (`<`), (`>`) and (`&`) characters. | |
| /// Escapes (`'`) if [escapeApos] is `true`. | |
| String escapeHtml(String html, {bool escapeApos = true}) => HtmlEscape( | |
| HtmlEscapeMode(escapeApos: escapeApos, escapeLtGt: true, escapeQuot: true), | |
| ).convert(html); | |
| /// Escapes (`"`), (`<`) and (`>`) characters. | |
| String escapeHtmlAttribute(String text) => | |
| const HtmlEscape(HtmlEscapeMode.attribute).convert(text); | |
| /// Escapes raw `"` (double quote), `'` (single quote), `<` (less than) and | |
| /// `>` (greater than) characters. | |
| /// | |
| /// Unlike [escapeHtmlAttribute], this does not escape `&` (ampersand) to | |
| /// avoid double-escaping already-escaped HTML entities. | |
| String escapeAttributeCharactersValue(String text) => text | |
| .replaceAll('"', '"') | |
| .replaceAll("'", ''') | |
| .replaceAll('<', '<') | |
| .replaceAll('>', '>'); | |
| /// "Normalizes" a link label, according to the [CommonMark spec]. | |
| /// | |
| /// [CommonMark spec] https://spec.commonmark.org/0.30/#link-label | |
| String normalizeLinkLabel(String label) { | |
| var text = label.trim().replaceAll(_oneOrMoreWhitespacePattern, ' '); | |
| for (var i = 0; i < text.length; i++) { | |
| final mapped = caseFoldingMap[text[i]]; | |
| if (mapped != null) { | |
| text = text.replaceRange(i, i + 1, mapped); | |
| } | |
| } | |
| return text; | |
| } | |
| /// Normalizes a link destination, including the process of HTML characters | |
| /// decoding and percent encoding. | |
| // See the description of these examples: | |
| // https://spec.commonmark.org/0.30/#example-501 | |
| // https://spec.commonmark.org/0.30/#example-502 | |
| String normalizeLinkDestination(String destination) { | |
| // Split by url escaping characters | |
| // Concatenate them with unmodified URL-escaping. | |
| // URL-escaping should be left alone inside the destination | |
| // Refer: https://spec.commonmark.org/0.30/#example-502. | |
| final regex = RegExp('%[0-9A-Fa-f]{2}'); | |
| return destination.splitMapJoin( | |
| regex, | |
| onMatch: (m) => m.match, | |
| onNonMatch: (e) { | |
| try { | |
| e = Uri.decodeFull(e); | |
| } catch (_) {} | |
| return Uri.encodeFull(decodeHtmlCharacters(e)); | |
| }, | |
| ); | |
| } | |
| /// Normalizes a link title, including the process of HTML characters decoding | |
| /// and HTML characters escaping. | |
| // See the description of these examples: | |
| // https://spec.commonmark.org/0.30/#example-505 | |
| // https://spec.commonmark.org/0.30/#example-506 | |
| // https://spec.commonmark.org/0.30/#example-507 | |
| // https://spec.commonmark.org/0.30/#example-508 | |
| String normalizeLinkTitle(String title) => | |
| escapeHtmlAttribute(decodeHtmlCharacters(title)); | |
| /// Decodes HTML entity and numeric character references, for example decode | |
| /// `#` to `#`. | |
| String decodeHtmlCharacters(String input) => | |
| input.replaceAllMapped(htmlCharactersPattern, decodeHtmlCharacterFromMatch); | |
| /// Decodes HTML entity and numeric character references from the given [match]. | |
| String decodeHtmlCharacterFromMatch(Match match) { | |
| final text = match.match; | |
| final entity = match[1]; | |
| final decimalNumber = match[2]; | |
| final hexadecimalNumber = match[3]; | |
| // Entity references, see | |
| // https://spec.commonmark.org/0.30/#entity-references. | |
| if (entity != null) { | |
| return htmlEntitiesMap[text] ?? text; | |
| } | |
| // Decimal numeric character references, see | |
| // https://spec.commonmark.org/0.30/#decimal-numeric-character-references. | |
| else if (decimalNumber != null) { | |
| final decimalValue = int.parse(decimalNumber); | |
| int hexValue; | |
| if (decimalValue < 1114112 && decimalValue > 1) { | |
| hexValue = int.parse(decimalValue.toRadixString(16), radix: 16); | |
| } else { | |
| hexValue = 0xFFFd; | |
| } | |
| return String.fromCharCode(hexValue); | |
| } | |
| // Hexadecimal numeric character references, see | |
| // https://spec.commonmark.org/0.30/#hexadecimal-numeric-character-references. | |
| else if (hexadecimalNumber != null) { | |
| var hexValue = int.parse(hexadecimalNumber, radix: 16); | |
| if (hexValue > 0x10ffff || hexValue == 0) { | |
| hexValue = 0xFFFd; | |
| } | |
| return String.fromCharCode(hexValue); | |
| } | |
| return text; | |
| } | |
| extension MatchExtensions on Match { | |
| /// Returns the whole match String | |
| String get match => this[0]!; | |
| } | |
| /// Escapes the ASCII punctuation characters after backslash(`\`). | |
| String escapePunctuation(String input) { | |
| final buffer = StringBuffer(); | |
| for (var i = 0; i < input.length; i++) { | |
| if (input.codeUnitAt(i) == $backslash) { | |
| final next = i + 1 < input.length ? input[i + 1] : null; | |
| if (next != null && asciiPunctuationCharacters.contains(next)) { | |
| i++; | |
| } | |
| } | |
| buffer.write(input[i]); | |
| } | |
| return buffer.toString(); | |
| } | |
| extension StringExtensions on String { | |
| /// Calculates the length of indentation a `String` has. | |
| /// | |
| // The behavior of tabs: https://spec.commonmark.org/0.30/#tabs | |
| int indentation() { | |
| var length = 0; | |
| for (final char in codeUnits) { | |
| if (char != $space && char != $tab) { | |
| break; | |
| } | |
| length += char == $tab ? 4 - (length % 4) : 1; | |
| } | |
| return length; | |
| } | |
| /// Removes up to [length] characters of leading whitespace. | |
| // The way of handling tabs: https://spec.commonmark.org/0.30/#tabs | |
| DedentedText dedent([int length = 4]) { | |
| final whitespaceMatch = RegExp('^[ \t]{0,$length}').firstMatch(this); | |
| const tabSize = 4; | |
| int? tabRemaining; | |
| var start = 0; | |
| final whitespaces = whitespaceMatch?[0]; | |
| if (whitespaces != null) { | |
| var indentLength = 0; | |
| for (; start < whitespaces.length; start++) { | |
| final isTab = whitespaces[start] == '\t'; | |
| if (isTab) { | |
| indentLength += tabSize; | |
| tabRemaining = 4; | |
| } else { | |
| indentLength += 1; | |
| } | |
| if (indentLength >= length) { | |
| if (tabRemaining != null) { | |
| tabRemaining = indentLength - length; | |
| } | |
| if (indentLength == length || isTab) { | |
| start += 1; | |
| } | |
| break; | |
| } | |
| if (tabRemaining != null) { | |
| tabRemaining = 0; | |
| } | |
| } | |
| } | |
| return DedentedText(substring(start), tabRemaining); | |
| } | |
| /// Adds [width] of spaces to the beginning of this string. | |
| String prependSpace(int width) => '${" " * width}$this'; | |
| /// Whether this string contains only whitespaces. | |
| bool get isBlank => trim().isEmpty; | |
| /// Converts this string to a list of [Line]. | |
| List<Line> toLines() => LineSplitter.split(this).map(Line.new).toList(); | |
| /// Returns the last character. | |
| String last([int n = 1]) => substring(length - n); | |
| } | |
| /// A class that describes a dedented text. | |
| class DedentedText { | |
| /// The dedented text. | |
| final String text; | |
| /// How many spaces of a tab that remains after part of it has been consumed. | |
| /// | |
| /// `null` means we did not read a `tab`. | |
| final int? tabRemaining; | |
| DedentedText(this.text, this.tabRemaining); | |
| } |