| // 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); |
| |
| /// "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); |
| } |