From 0d362a4781a974b12ae18d0dd9bdb19063e34b73 Mon Sep 17 00:00:00 2001 From: appflowy Date: Wed, 10 Nov 2021 14:58:56 +0800 Subject: [PATCH] [flutter]: add markdown parser --- .../workspace/application/doc/share_bloc.dart | 9 +- .../markdown/delta_markdown.dart | 30 + .../infrastructure/markdown/src/ast.dart | 113 ++ .../markdown/src/block_parser.dart | 1096 ++++++++++++ .../markdown/src/delta_markdown_decoder.dart | 255 +++ .../markdown/src/delta_markdown_encoder.dart | 272 +++ .../infrastructure/markdown/src/document.dart | 88 + .../infrastructure/markdown/src/emojis.dart | 1510 +++++++++++++++++ .../markdown/src/extension_set.dart | 64 + .../markdown/src/html_renderer.dart | 121 ++ .../markdown/src/inline_parser.dart | 1271 ++++++++++++++ .../infrastructure/markdown/src/util.dart | 71 + .../infrastructure/markdown/src/version.dart | 2 + .../stack_page/doc/doc_stack_page.dart | 5 +- app_flowy/pubspec.lock | 7 - app_flowy/pubspec.yaml | 1 - 16 files changed, 4903 insertions(+), 12 deletions(-) create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/delta_markdown.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/ast.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/block_parser.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/delta_markdown_decoder.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/delta_markdown_encoder.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/document.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/emojis.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/extension_set.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/html_renderer.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/inline_parser.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/util.dart create mode 100644 app_flowy/lib/workspace/infrastructure/markdown/src/version.dart diff --git a/app_flowy/lib/workspace/application/doc/share_bloc.dart b/app_flowy/lib/workspace/application/doc/share_bloc.dart index dbe56e3557..f81495a15a 100644 --- a/app_flowy/lib/workspace/application/doc/share_bloc.dart +++ b/app_flowy/lib/workspace/application/doc/share_bloc.dart @@ -1,4 +1,5 @@ import 'package:app_flowy/workspace/domain/i_share.dart'; +import 'package:app_flowy/workspace/infrastructure/markdown/delta_markdown.dart'; import 'package:flowy_sdk/protobuf/flowy-workspace-infra/export.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-workspace-infra/view_create.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-workspace/errors.pb.dart'; @@ -16,7 +17,7 @@ class DocShareBloc extends Bloc { shareMarkdown: (ShareMarkdown value) async { await shareManager.exportMarkdown(view.id).then((result) { result.fold( - (value) => emit(DocShareState.finish(left(value))), + (value) => emit(DocShareState.finish(left(_convertDeltaToMarkdown(value)))), (error) => emit(DocShareState.finish(right(error))), ); }); @@ -28,6 +29,12 @@ class DocShareBloc extends Bloc { ); }); } + + ExportData _convertDeltaToMarkdown(ExportData value) { + final result = deltaToMarkdown(value.data); + value.data = result; + return value; + } } @freezed diff --git a/app_flowy/lib/workspace/infrastructure/markdown/delta_markdown.dart b/app_flowy/lib/workspace/infrastructure/markdown/delta_markdown.dart new file mode 100644 index 0000000000..ef723f2697 --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/delta_markdown.dart @@ -0,0 +1,30 @@ +library delta_markdown; + +import 'dart:convert'; + +import 'src/delta_markdown_decoder.dart'; +import 'src/delta_markdown_encoder.dart'; +import 'src/version.dart'; + +const version = packageVersion; + +/// Codec used to convert between Markdown and Quill deltas. +const DeltaMarkdownCodec _kCodec = DeltaMarkdownCodec(); + +String markdownToDelta(String markdown) { + return _kCodec.decode(markdown); +} + +String deltaToMarkdown(String delta) { + return _kCodec.encode(delta); +} + +class DeltaMarkdownCodec extends Codec { + const DeltaMarkdownCodec(); + + @override + Converter get decoder => DeltaMarkdownDecoder(); + + @override + Converter get encoder => DeltaMarkdownEncoder(); +} diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/ast.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/ast.dart new file mode 100644 index 0000000000..5356f1d05f --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/ast.dart @@ -0,0 +1,113 @@ +// 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. + +typedef Resolver = Node? Function(String name, [String? title]); + +/// Base class for any AST item. +/// +/// Roughly corresponds to Node in the DOM. Will be either an Element or Text. +class Node { + void accept(NodeVisitor visitor) {} + + bool isToplevel = false; + + String? get textContent { + return null; + } +} + +/// A named tag that can contain other nodes. +class Element extends Node { + /// Instantiates a [tag] Element with [children]. + Element(this.tag, this.children) : attributes = {}; + + /// Instantiates an empty, self-closing [tag] Element. + Element.empty(this.tag) + : children = null, + attributes = {}; + + /// Instantiates a [tag] Element with no [children]. + Element.withTag(this.tag) + : children = [], + attributes = {}; + + /// Instantiates a [tag] Element with a single Text child. + Element.text(this.tag, String text) + : children = [Text(text)], + attributes = {}; + + final String tag; + final List? children; + final Map attributes; + String? generatedId; + + /// Whether this element is self-closing. + bool get isEmpty => children == null; + + @override + void accept(NodeVisitor visitor) { + if (visitor.visitElementBefore(this)) { + if (children != null) { + for (final child in children!) { + child.accept(visitor); + } + } + visitor.visitElementAfter(this); + } + } + + @override + String get textContent => children == null + ? '' + : children!.map((child) => child.textContent).join(); +} + +/// A plain text element. +class Text extends Node { + Text(this.text); + + final String text; + + @override + void accept(NodeVisitor visitor) => visitor.visitText(this); + + @override + String get textContent => text; +} + +/// Inline content that has not been parsed into inline nodes (strong, links, +/// etc). +/// +/// These placeholder nodes should only remain in place while the block nodes +/// of a document are still being parsed, in order to gather all reference link +/// definitions. +class UnparsedContent extends Node { + UnparsedContent(this.textContent); + + @override + final String textContent; + + @override + void accept(NodeVisitor visitor); +} + +/// Visitor pattern for the AST. +/// +/// Renderers or other AST transformers should implement this. +abstract class NodeVisitor { + /// Called when a Text node has been reached. + void visitText(Text text); + + /// Called when an Element has been reached, before its children have been + /// visited. + /// + /// Returns `false` to skip its children. + bool visitElementBefore(Element element); + + /// Called when an Element has been reached, after its children have been + /// visited. + /// + /// Will not be called if [visitElementBefore] returns `false`. + void visitElementAfter(Element element); +} diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/block_parser.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/block_parser.dart new file mode 100644 index 0000000000..faac444b98 --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/block_parser.dart @@ -0,0 +1,1096 @@ +// 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 'ast.dart'; +import 'document.dart'; +import 'util.dart'; + +/// The line contains only whitespace or is empty. +final _emptyPattern = RegExp(r'^(?:[ \t]*)$'); + +/// A series of `=` or `-` (on the next line) define setext-style headers. +final _setextPattern = RegExp(r'^[ ]{0,3}(=+|-+)\s*$'); + +/// Leading (and trailing) `#` define atx-style headers. +/// +/// Starts with 1-6 unescaped `#` characters which must not be followed by a +/// non-space character. Line may end with any number of `#` characters,. +final _headerPattern = RegExp(r'^ {0,3}(#{1,6})[ \x09\x0b\x0c](.*?)#*$'); + +/// The line starts with `>` with one optional space after. +final _blockquotePattern = RegExp(r'^[ ]{0,3}>[ ]?(.*)$'); + +/// A line indented four spaces. Used for code blocks and lists. +final _indentPattern = RegExp(r'^(?: | {0,3}\t)(.*)$'); + +/// Fenced code block. +final _codePattern = RegExp(r'^[ ]{0,3}(`{3,}|~{3,})(.*)$'); + +/// Three or more hyphens, asterisks or underscores by themselves. Note that +/// a line like `----` is valid as both HR and SETEXT. In case of a tie, +/// SETEXT should win. +final _hrPattern = RegExp(r'^ {0,3}([-*_])[ \t]*\1[ \t]*\1(?:\1|[ \t])*$'); + +/// One or more whitespace, for compressing. +final _oneOrMoreWhitespacePattern = RegExp('[ \n\r\t]+'); + +/// A line starting with one of these markers: `-`, `*`, `+`. May have up to +/// three leading spaces before the marker and any number of spaces or tabs +/// after. +/// +/// Contains a dummy group at [2], so that the groups in [_ulPattern] and +/// [_olPattern] match up; in both, [2] is the length of the number that begins +/// the list marker. +final _ulPattern = RegExp(r'^([ ]{0,3})()([*+-])(([ \t])([ \t]*)(.*))?$'); + +/// A line starting with a number like `123.`. May have up to three leading +/// spaces before the marker and any number of spaces or tabs after. +final _olPattern = + RegExp(r'^([ ]{0,3})(\d{1,9})([\.)])(([ \t])([ \t]*)(.*))?$'); + +/// A line of hyphens separated by at least one pipe. +final _tablePattern = RegExp(r'^[ ]{0,3}\|?( *:?\-+:? *\|)+( *:?\-+:? *)?$'); + +/// Maintains the internal state needed to parse a series of lines into blocks +/// of Markdown suitable for further inline parsing. +class BlockParser { + BlockParser(this.lines, this.document) { + blockSyntaxes + ..addAll(document.blockSyntaxes) + ..addAll(standardBlockSyntaxes); + } + + final List lines; + + /// The Markdown document this parser is parsing. + final Document document; + + /// The enabled block syntaxes. + /// + /// To turn a series of lines into blocks, each of these will be tried in + /// turn. Order matters here. + final List blockSyntaxes = []; + + /// Index of the current line. + int _pos = 0; + + /// Whether the parser has encountered a blank line between two block-level + /// elements. + bool encounteredBlankLine = false; + + /// The collection of built-in block parsers. + final List standardBlockSyntaxes = [ + const EmptyBlockSyntax(), + const BlockTagBlockHtmlSyntax(), + LongBlockHtmlSyntax(r'^ {0,3}|$)', ''), + LongBlockHtmlSyntax(r'^ {0,3}|$)', ''), + LongBlockHtmlSyntax(r'^ {0,3}|$)', ''), + LongBlockHtmlSyntax('^ {0,3}'), + LongBlockHtmlSyntax('^ {0,3}<\\?', '\\?>'), + LongBlockHtmlSyntax('^ {0,3}'), + LongBlockHtmlSyntax('^ {0,3}'), + const OtherTagBlockHtmlSyntax(), + const SetextHeaderSyntax(), + const HeaderSyntax(), + const CodeBlockSyntax(), + const BlockquoteSyntax(), + const HorizontalRuleSyntax(), + const UnorderedListSyntax(), + const OrderedListSyntax(), + const ParagraphSyntax() + ]; + + /// Gets the current line. + String get current => lines[_pos]; + + /// Gets the line after the current one or `null` if there is none. + String? get next { + // Don't read past the end. + if (_pos >= lines.length - 1) { + return null; + } + return lines[_pos + 1]; + } + + /// Gets the line that is [linesAhead] lines ahead of the current one, or + /// `null` if there is none. + /// + /// `peek(0)` is equivalent to [current]. + /// + /// `peek(1)` is equivalent to [next]. + String? peek(int linesAhead) { + if (linesAhead < 0) { + throw ArgumentError('Invalid linesAhead: $linesAhead; must be >= 0.'); + } + // Don't read past the end. + if (_pos >= lines.length - linesAhead) { + return null; + } + return lines[_pos + linesAhead]; + } + + void advance() { + _pos++; + } + + bool get isDone => _pos >= lines.length; + + /// Gets whether or not the current line matches the given pattern. + bool matches(RegExp regex) { + if (isDone) { + return false; + } + return regex.firstMatch(current) != null; + } + + /// Gets whether or not the next line matches the given pattern. + bool matchesNext(RegExp regex) { + if (next == null) { + return false; + } + return regex.firstMatch(next!) != null; + } + + List parseLines() { + final blocks = []; + while (!isDone) { + for (final syntax in blockSyntaxes) { + if (syntax.canParse(this)) { + final block = syntax.parse(this); + if (block != null) { + blocks.add(block); + } + break; + } + } + } + + return blocks; + } +} + +abstract class BlockSyntax { + const BlockSyntax(); + + /// Gets the regex used to identify the beginning of this block, if any. + RegExp? get pattern => null; + + bool get canEndBlock => true; + + bool canParse(BlockParser parser) { + return pattern!.firstMatch(parser.current) != null; + } + + Node? parse(BlockParser parser); + + List parseChildLines(BlockParser parser) { + // Grab all of the lines that form the block element. + final childLines = []; + + while (!parser.isDone) { + final match = pattern!.firstMatch(parser.current); + if (match == null) { + break; + } + childLines.add(match[1]); + parser.advance(); + } + + return childLines; + } + + /// Gets whether or not [parser]'s current line should end the previous block. + static bool isAtBlockEnd(BlockParser parser) { + if (parser.isDone) { + return true; + } + return parser.blockSyntaxes.any((s) => s.canParse(parser) && s.canEndBlock); + } + + /// Generates a valid HTML anchor from the inner text of [element]. + static String generateAnchorHash(Element element) => + element.children!.first.textContent! + .toLowerCase() + .trim() + .replaceAll(RegExp(r'[^a-z0-9 _-]'), '') + .replaceAll(RegExp(r'\s'), '-'); +} + +class EmptyBlockSyntax extends BlockSyntax { + const EmptyBlockSyntax(); + + @override + RegExp get pattern => _emptyPattern; + + @override + Node? parse(BlockParser parser) { + parser + ..encounteredBlankLine = true + ..advance(); + + // Don't actually emit anything. + return null; + } +} + +/// Parses setext-style headers. +class SetextHeaderSyntax extends BlockSyntax { + const SetextHeaderSyntax(); + + @override + bool canParse(BlockParser parser) { + if (!_interperableAsParagraph(parser.current)) { + return false; + } + + var i = 1; + while (true) { + final nextLine = parser.peek(i); + if (nextLine == null) { + // We never reached an underline. + return false; + } + if (_setextPattern.hasMatch(nextLine)) { + return true; + } + // Ensure that we're still in something like paragraph text. + if (!_interperableAsParagraph(nextLine)) { + return false; + } + i++; + } + } + + @override + Node parse(BlockParser parser) { + final lines = []; + late String tag; + while (!parser.isDone) { + final match = _setextPattern.firstMatch(parser.current); + if (match == null) { + // More text. + lines.add(parser.current); + parser.advance(); + continue; + } else { + // The underline. + tag = (match[1]![0] == '=') ? 'h1' : 'h2'; + parser.advance(); + break; + } + } + + final contents = UnparsedContent(lines.join('\n')); + + return Element(tag, [contents]); + } + + bool _interperableAsParagraph(String line) => + !(_indentPattern.hasMatch(line) || + _codePattern.hasMatch(line) || + _headerPattern.hasMatch(line) || + _blockquotePattern.hasMatch(line) || + _hrPattern.hasMatch(line) || + _ulPattern.hasMatch(line) || + _olPattern.hasMatch(line) || + _emptyPattern.hasMatch(line)); +} + +/// Parses setext-style headers, and adds generated IDs to the generated +/// elements. +class SetextHeaderWithIdSyntax extends SetextHeaderSyntax { + const SetextHeaderWithIdSyntax(); + + @override + Node parse(BlockParser parser) { + final element = super.parse(parser) as Element; + element.generatedId = BlockSyntax.generateAnchorHash(element); + return element; + } +} + +/// Parses atx-style headers: `## Header ##`. +class HeaderSyntax extends BlockSyntax { + const HeaderSyntax(); + + @override + RegExp get pattern => _headerPattern; + + @override + Node parse(BlockParser parser) { + final match = pattern.firstMatch(parser.current)!; + parser.advance(); + final level = match[1]!.length; + final contents = UnparsedContent(match[2]!.trim()); + return Element('h$level', [contents]); + } +} + +/// Parses atx-style headers, and adds generated IDs to the generated elements. +class HeaderWithIdSyntax extends HeaderSyntax { + const HeaderWithIdSyntax(); + + @override + Node parse(BlockParser parser) { + final element = super.parse(parser) as Element; + element.generatedId = BlockSyntax.generateAnchorHash(element); + return element; + } +} + +/// Parses email-style blockquotes: `> quote`. +class BlockquoteSyntax extends BlockSyntax { + const BlockquoteSyntax(); + + @override + RegExp get pattern => _blockquotePattern; + + @override + List parseChildLines(BlockParser parser) { + // Grab all of the lines that form the blockquote, stripping off the ">". + final childLines = []; + + while (!parser.isDone) { + final match = pattern.firstMatch(parser.current); + if (match != null) { + childLines.add(match[1]!); + parser.advance(); + continue; + } + + // A paragraph continuation is OK. This is content that cannot be parsed + // as any other syntax except Paragraph, and it doesn't match the bar in + // a Setext header. + if (parser.blockSyntaxes.firstWhere((s) => s.canParse(parser)) + is ParagraphSyntax) { + childLines.add(parser.current); + parser.advance(); + } else { + break; + } + } + + return childLines; + } + + @override + Node parse(BlockParser parser) { + final childLines = parseChildLines(parser); + + // Recursively parse the contents of the blockquote. + final children = BlockParser(childLines, parser.document).parseLines(); + return Element('blockquote', children); + } +} + +/// Parses preformatted code blocks that are indented four spaces. +class CodeBlockSyntax extends BlockSyntax { + const CodeBlockSyntax(); + + @override + RegExp get pattern => _indentPattern; + + @override + bool get canEndBlock => false; + + @override + List parseChildLines(BlockParser parser) { + final childLines = []; + + while (!parser.isDone) { + final match = pattern.firstMatch(parser.current); + if (match != null) { + childLines.add(match[1]); + parser.advance(); + } else { + // If there's a codeblock, then a newline, then a codeblock, keep the + // code blocks together. + final nextMatch = + parser.next != null ? pattern.firstMatch(parser.next!) : null; + if (parser.current.trim() == '' && nextMatch != null) { + childLines..add('')..add(nextMatch[1]); + parser..advance()..advance(); + } else { + break; + } + } + } + return childLines; + } + + @override + Node parse(BlockParser parser) { + final childLines = parseChildLines(parser) + // The Markdown tests expect a trailing newline. + ..add(''); + + // Escape the code. + final escaped = escapeHtml(childLines.join('\n')); + + return Element('pre', [Element.text('code', escaped)]); + } +} + +/// Parses preformatted code blocks between two ~~~ or ``` sequences. +/// +/// See [Pandoc's documentation](http://pandoc.org/README.html#fenced-code-blocks). +class FencedCodeBlockSyntax extends BlockSyntax { + const FencedCodeBlockSyntax(); + + @override + RegExp get pattern => _codePattern; + + @override + List parseChildLines(BlockParser parser, [String? endBlock]) { + endBlock ??= ''; + + final childLines = []; + parser.advance(); + + while (!parser.isDone) { + final match = pattern.firstMatch(parser.current); + if (match == null || !match[1]!.startsWith(endBlock)) { + childLines.add(parser.current); + parser.advance(); + } else { + parser.advance(); + break; + } + } + + return childLines; + } + + @override + Node parse(BlockParser parser) { + // Get the syntax identifier, if there is one. + final match = pattern.firstMatch(parser.current)!; + final endBlock = match.group(1); + var infoString = match.group(2)!; + + final childLines = parseChildLines(parser, endBlock) + // The Markdown tests expect a trailing newline. + ..add(''); + + final code = Element.text('code', childLines.join('\n')); + + // the info-string should be trimmed + // http://spec.commonmark.org/0.22/#example-100 + infoString = infoString.trim(); + if (infoString.isNotEmpty) { + // only use the first word in the syntax + // http://spec.commonmark.org/0.22/#example-100 + infoString = infoString.split(' ').first; + code.attributes['class'] = 'language-$infoString'; + } + + final element = Element('pre', [code]); + return element; + } +} + +/// Parses horizontal rules like `---`, `_ _ _`, `* * *`, etc. +class HorizontalRuleSyntax extends BlockSyntax { + const HorizontalRuleSyntax(); + + @override + RegExp get pattern => _hrPattern; + + @override + Node parse(BlockParser parser) { + parser.advance(); + return Element.empty('hr'); + } +} + +/// Parses inline HTML at the block level. This differs from other Markdown +/// implementations in several ways: +/// +/// 1. This one is way way WAY simpler. +/// 2. Essentially no HTML parsing or validation is done. We're a Markdown +/// parser, not an HTML parser! +abstract class BlockHtmlSyntax extends BlockSyntax { + const BlockHtmlSyntax(); + + @override + bool get canEndBlock => true; +} + +class BlockTagBlockHtmlSyntax extends BlockHtmlSyntax { + const BlockTagBlockHtmlSyntax(); + + static final _pattern = RegExp( + r'^ {0,3}|/>|$)'); + + @override + RegExp get pattern => _pattern; + + @override + Node parse(BlockParser parser) { + final childLines = []; + + // Eat until we hit a blank line. + while (!parser.isDone && !parser.matches(_emptyPattern)) { + childLines.add(parser.current); + parser.advance(); + } + + return Text(childLines.join('\n')); + } +} + +class OtherTagBlockHtmlSyntax extends BlockTagBlockHtmlSyntax { + const OtherTagBlockHtmlSyntax(); + + @override + bool get canEndBlock => false; + + // Really hacky way to detect "other" HTML. This matches: + // + // * any opening spaces + // * open bracket and maybe a slash ("<" or " RegExp(r'^ {0,3}|\s+[^>]*>)\s*$'); +} + +/// A BlockHtmlSyntax that has a specific `endPattern`. +/// +/// In practice this means that the syntax dominates; it is allowed to eat +/// many lines, including blank lines, before matching its `endPattern`. +class LongBlockHtmlSyntax extends BlockHtmlSyntax { + LongBlockHtmlSyntax(String pattern, String endPattern) + : pattern = RegExp(pattern), + _endPattern = RegExp(endPattern); + + @override + final RegExp pattern; + final RegExp _endPattern; + + @override + Node parse(BlockParser parser) { + final childLines = []; + // Eat until we hit [endPattern]. + while (!parser.isDone) { + childLines.add(parser.current); + if (parser.matches(_endPattern)) { + break; + } + parser.advance(); + } + + parser.advance(); + return Text(childLines.join('\n')); + } +} + +class ListItem { + ListItem(this.lines); + + bool forceBlock = false; + final List lines; +} + +/// Base class for both ordered and unordered lists. +abstract class ListSyntax extends BlockSyntax { + const ListSyntax(); + + @override + bool get canEndBlock => true; + + String get listTag; + + /// 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 items = []; + var childLines = []; + + void endItem() { + if (childLines.isNotEmpty) { + items.add(ListItem(childLines)); + childLines = []; + } + } + + Match? match; + bool tryMatch(RegExp pattern) { + match = pattern.firstMatch(parser.current); + return match != 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 (tryMatch(_emptyPattern)) { + if (_emptyPattern.firstMatch(parser.next ?? '') != null) { + // 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(line); + } else if (tryMatch(_hrPattern)) { + // Horizontal rule takes precedence to a list item. + break; + } else if (tryMatch(_ulPattern) || tryMatch(_olPattern)) { + 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 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 one. + endItem(); + childLines.add(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 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 = []; + + items.forEach(removeLeadingEmptyLine); + final anyEmptyLines = removeTrailingEmptyLines(items); + var anyEmptyLinesBetweenBlocks = false; + + for (final item in items) { + final itemParser = BlockParser(item.lines, parser.document); + final children = itemParser.parseLines(); + itemNodes.add(Element('li', children)); + 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) { + for (var i = 0; i < item.children!.length; i++) { + final child = item.children![i]; + if (child is Element && child.tag == 'p') { + item.children!.removeAt(i); + item.children!.insertAll(i, child.children!); + } + } + } + } + + if (listTag == 'ol' && startNumber != 1) { + return Element(listTag, itemNodes)..attributes['start'] = '$startNumber'; + } else { + return Element(listTag, itemNodes); + } + } + + 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 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; + } +} + +/// Parses unordered lists. +class UnorderedListSyntax extends ListSyntax { + const UnorderedListSyntax(); + + @override + RegExp get pattern => _ulPattern; + + @override + String get listTag => 'ul'; +} + +/// Parses ordered lists. +class OrderedListSyntax extends ListSyntax { + const OrderedListSyntax(); + + @override + RegExp get pattern => _olPattern; + + @override + String get listTag => 'ol'; +} + +/// Parses tables. +class TableSyntax extends BlockSyntax { + const TableSyntax(); + + static final _pipePattern = RegExp(r'\s*\|\s*'); + static final _openingPipe = RegExp(r'^\|\s*'); + static final _closingPipe = RegExp(r'\s*\|$'); + + @override + bool get canEndBlock => false; + + @override + bool canParse(BlockParser parser) { + // Note: matches *next* line, not the current one. We're looking for the + // bar separating the head row from the body rows. + return parser.matchesNext(_tablePattern); + } + + /// Parses a table into its three parts: + /// + /// * a head row of head cells (`` cells) + /// * a divider of hyphens and pipes (not rendered) + /// * many body rows of body cells (`` cells) + @override + Node? parse(BlockParser parser) { + final alignments = parseAlignments(parser.next!); + final columnCount = alignments.length; + final headRow = parseRow(parser, alignments, 'th'); + if (headRow.children!.length != columnCount) { + return null; + } + final head = Element('thead', [headRow]); + + // Advance past the divider of hyphens. + parser.advance(); + + final rows = []; + while (!parser.isDone && !BlockSyntax.isAtBlockEnd(parser)) { + final row = parseRow(parser, alignments, 'td'); + while (row.children!.length < columnCount) { + // Insert synthetic empty cells. + row.children!.add(Element.empty('td')); + } + while (row.children!.length > columnCount) { + row.children!.removeLast(); + } + rows.add(row); + } + if (rows.isEmpty) { + return Element('table', [head]); + } else { + final body = Element('tbody', rows); + + return Element('table', [head, body]); + } + } + + List parseAlignments(String line) { + line = line.replaceFirst(_openingPipe, '').replaceFirst(_closingPipe, ''); + return line.split('|').map((column) { + column = column.trim(); + if (column.startsWith(':') && column.endsWith(':')) { + return 'center'; + } + if (column.startsWith(':')) { + return 'left'; + } + if (column.endsWith(':')) { + return 'right'; + } + return null; + }).toList(); + } + + Element parseRow( + BlockParser parser, List alignments, String cellType) { + final line = parser.current + .replaceFirst(_openingPipe, '') + .replaceFirst(_closingPipe, ''); + final cells = line.split(_pipePattern); + parser.advance(); + final row = []; + String? preCell; + + for (var cell in cells) { + if (preCell != null) { + cell = preCell + cell; + preCell = null; + } + if (cell.endsWith('\\')) { + preCell = '${cell.substring(0, cell.length - 1)}|'; + continue; + } + + final contents = UnparsedContent(cell); + row.add(Element(cellType, [contents])); + } + + for (var i = 0; i < row.length && i < alignments.length; i++) { + if (alignments[i] == null) { + continue; + } + row[i].attributes['style'] = 'text-align: ${alignments[i]};'; + } + + return Element('tr', row); + } +} + +/// Parses paragraphs of regular text. +class ParagraphSyntax extends BlockSyntax { + const ParagraphSyntax(); + + static final _reflinkDefinitionStart = RegExp(r'[ ]{0,3}\['); + + static final _whitespacePattern = RegExp(r'^\s*$'); + + @override + bool get canEndBlock => false; + + @override + bool canParse(BlockParser parser) => true; + + @override + Node parse(BlockParser parser) { + final childLines = []; + + // Eat until we hit something that ends a paragraph. + while (!BlockSyntax.isAtBlockEnd(parser)) { + childLines.add(parser.current); + parser.advance(); + } + + final paragraphLines = _extractReflinkDefinitions(parser, childLines); + if (paragraphLines == null) { + // Paragraph consisted solely of reference link definitions. + return Text(''); + } else { + final contents = UnparsedContent(paragraphLines.join('\n')); + return Element('p', [contents]); + } + } + + /// Extract reference link definitions from the front of the paragraph, and + /// return the remaining paragraph lines. + List? _extractReflinkDefinitions( + BlockParser parser, List lines) { + bool lineStartsReflinkDefinition(int i) => + lines[i].startsWith(_reflinkDefinitionStart); + + var i = 0; + loopOverDefinitions: + while (true) { + // Check for reflink definitions. + if (!lineStartsReflinkDefinition(i)) { + // It's paragraph content from here on out. + break; + } + var contents = lines[i]; + var j = i + 1; + while (j < lines.length) { + // Check to see if the _next_ line might start a reflink definition. + // Even if it turns out not to be, but it started with a '[', then it + // is not a part of _this_ possible reflink definition. + if (lineStartsReflinkDefinition(j)) { + // Try to parse [contents] as a reflink definition. + if (_parseReflinkDefinition(parser, contents)) { + // Loop again, starting at the next possible reflink definition. + i = j; + continue loopOverDefinitions; + } else { + // Could not parse [contents] as a reflink definition. + break; + } + } else { + contents = '$contents\n${lines[j]}'; + j++; + } + } + // End of the block. + if (_parseReflinkDefinition(parser, contents)) { + i = j; + break; + } + + // It may be that there is a reflink definition starting at [i], but it + // does not extend all the way to [j], such as: + // + // [link]: url // line i + // "title" + // garbage + // [link2]: url // line j + // + // In this case, [i, i+1] is a reflink definition, and the rest is + // paragraph content. + while (j >= i) { + // This isn't the most efficient loop, what with this big ole' + // Iterable allocation (`getRange`) followed by a big 'ole String + // allocation, but we + // must walk backwards, checking each range. + contents = lines.getRange(i, j).join('\n'); + if (_parseReflinkDefinition(parser, contents)) { + // That is the last reflink definition. The rest is paragraph + // content. + i = j; + break; + } + j--; + } + // The ending was not a reflink definition at all. Just paragraph + // content. + + break; + } + + if (i == lines.length) { + // No paragraph content. + return null; + } else { + // Ends with paragraph content. + return lines.sublist(i); + } + } + + // Parse [contents] as a reference link definition. + // + // Also adds the reference link definition to the document. + // + // Returns whether [contents] could be parsed as a reference link definition. + bool _parseReflinkDefinition(BlockParser parser, String contents) { + final pattern = RegExp( + // Leading indentation. + r'''^[ ]{0,3}''' + // Reference id in brackets, and URL. + r'''\[((?:\\\]|[^\]])+)\]:\s*(?:<(\S+)>|(\S+))\s*''' + // Title in double or single quotes, or parens. + r'''("[^"]+"|'[^']+'|\([^)]+\)|)\s*$''', + multiLine: true); + final match = pattern.firstMatch(contents); + if (match == null) { + // Not a reference link definition. + return false; + } + if (match[0]!.length < contents.length) { + // Trailing text. No good. + return false; + } + + var label = match[1]!; + final destination = match[2] ?? match[3]; + var title = match[4]; + + // The label must contain at least one non-whitespace character. + if (_whitespacePattern.hasMatch(label)) { + return false; + } + + if (title == '') { + // No title. + title = null; + } else { + // Remove "", '', or (). + title = title!.substring(1, title.length - 1); + } + + // References are case-insensitive, and internal whitespace is compressed. + label = + label.toLowerCase().trim().replaceAll(_oneOrMoreWhitespacePattern, ' '); + + parser.document.linkReferences + .putIfAbsent(label, () => LinkReference(label, destination!, title!)); + return true; + } +} diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/delta_markdown_decoder.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/delta_markdown_decoder.dart new file mode 100644 index 0000000000..fe9ae8f05b --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/delta_markdown_decoder.dart @@ -0,0 +1,255 @@ +import 'dart:collection'; +import 'dart:convert'; + +import 'package:flutter_quill/models/documents/attribute.dart'; +import 'package:flutter_quill/models/quill_delta.dart'; + +import 'ast.dart' as ast; +import 'document.dart'; + +class DeltaMarkdownDecoder extends Converter { + @override + String convert(String input) { + final lines = input.replaceAll('\r\n', '\n').split('\n'); + + final markdownDocument = Document().parseLines(lines); + + return jsonEncode(_DeltaVisitor().convert(markdownDocument).toJson()); + } +} + +class _DeltaVisitor implements ast.NodeVisitor { + static final _blockTags = + RegExp('h1|h2|h3|h4|h5|h6|hr|pre|ul|ol|blockquote|p|pre'); + + static final _embedTags = RegExp('hr|img'); + + late Delta delta; + + late Queue activeInlineAttributes; + Attribute? activeBlockAttribute; + late Set uniqueIds; + + ast.Element? previousElement; + late ast.Element previousToplevelElement; + + Delta convert(List nodes) { + delta = Delta(); + activeInlineAttributes = Queue(); + uniqueIds = {}; + + for (final node in nodes) { + node.accept(this); + } + + // Ensure the delta ends with a newline. + if (delta.length > 0 && delta.last.value != '\n') { + delta.insert('\n', activeBlockAttribute?.toJson()); + } + + return delta; + } + + @override + void visitText(ast.Text text) { + // Remove trailing newline + //final lines = text.text.trim().split('\n'); + + /* + final attributes = Map(); + for (final attr in activeInlineAttributes) { + attributes.addAll(attr.toJson()); + } + + for (final l in lines) { + delta.insert(l, attributes); + delta.insert('\n', activeBlockAttribute.toJson()); + }*/ + + final str = text.text; + //if (str.endsWith('\n')) str = str.substring(0, str.length - 1); + + final attributes = {}; + for (final attr in activeInlineAttributes) { + attributes.addAll(attr.toJson()); + } + + var newlineIndex = str.indexOf('\n'); + var startIndex = 0; + while (newlineIndex != -1) { + final previousText = str.substring(startIndex, newlineIndex); + if (previousText.isNotEmpty) { + delta.insert(previousText, attributes.isNotEmpty ? attributes : null); + } + delta.insert('\n', activeBlockAttribute?.toJson()); + + startIndex = newlineIndex + 1; + newlineIndex = str.indexOf('\n', newlineIndex + 1); + } + + if (startIndex < str.length) { + final lastStr = str.substring(startIndex); + delta.insert(lastStr, attributes.isNotEmpty ? attributes : null); + } + } + + @override + bool visitElementBefore(ast.Element element) { + // Hackish. Separate block-level elements with newlines. + final attr = _tagToAttribute(element); + + if (delta.isNotEmpty && _blockTags.firstMatch(element.tag) != null) { + if (element.isToplevel) { + // If the last active block attribute is not a list, we need to finish + // it off. + if (previousToplevelElement.tag != 'ul' && + previousToplevelElement.tag != 'ol' && + previousToplevelElement.tag != 'pre' && + previousToplevelElement.tag != 'hr') { + delta.insert('\n', activeBlockAttribute?.toJson()); + } + + // Only separate the blocks if both are paragraphs. + // + // TODO(kolja): Determine which behavior we really want here. + // We can either insert an additional newline or just have the + // paragraphs as single lines. Zefyr will by default render two lines + // are different paragraphs so for now we will not add an additonal + // newline here. + // + // if (previousToplevelElement != null && + // previousToplevelElement.tag == 'p' && + // element.tag == 'p') { + // delta.insert('\n'); + // } + } else if (element.tag == 'p' && + previousElement != null && + !previousElement!.isToplevel && + !previousElement!.children!.contains(element)) { + // Here we have two children of the same toplevel element. These need + // to be separated by additional newlines. + + delta + // Finish off the last lower-level block. + ..insert('\n', activeBlockAttribute?.toJson()) + // Add an empty line between the lower-level blocks. + ..insert('\n', activeBlockAttribute?.toJson()); + } + } + + // Keep track of the top-level block attribute. + if (element.isToplevel && element.tag != 'hr') { + // Hacky solution for horizontal rule so that the attribute is not added + // to the line feed at the end of the line. + activeBlockAttribute = attr; + } + + if (_embedTags.firstMatch(element.tag) != null) { + // We write out the element here since the embed has no children or + // content. + delta.insert(attr!.toJson()); + } else if (_blockTags.firstMatch(element.tag) == null && attr != null) { + activeInlineAttributes.addLast(attr); + } + + previousElement = element; + if (element.isToplevel) { + previousToplevelElement = element; + } + + if (element.isEmpty) { + // Empty element like
. + //buffer.write(' />'); + + if (element.tag == 'br') { + delta.insert('\n'); + } + + return false; + } else { + //buffer.write('>'); + return true; + } + } + + @override + void visitElementAfter(ast.Element element) { + if (element.tag == 'li' && + (previousToplevelElement.tag == 'ol' || + previousToplevelElement.tag == 'ul')) { + delta.insert('\n', activeBlockAttribute?.toJson()); + } + + final attr = _tagToAttribute(element); + if (attr == null || !attr.isInline || activeInlineAttributes.last != attr) { + return; + } + activeInlineAttributes.removeLast(); + + // Always keep track of the last element. + // This becomes relevant if we have something like + // + //
    + //
  • ...
  • + //
  • ...
  • + //
+ previousElement = element; + } + + /// Uniquifies an id generated from text. + String uniquifyId(String id) { + if (!uniqueIds.contains(id)) { + uniqueIds.add(id); + return id; + } + + var suffix = 2; + var suffixedId = '$id-$suffix'; + while (uniqueIds.contains(suffixedId)) { + suffixedId = '$id-${suffix++}'; + } + uniqueIds.add(suffixedId); + return suffixedId; + } + + Attribute? _tagToAttribute(ast.Element el) { + switch (el.tag) { + case 'em': + return Attribute.italic; + case 'strong': + return Attribute.bold; + case 'ul': + return Attribute.ul; + case 'ol': + return Attribute.ol; + case 'pre': + return Attribute.codeBlock; + case 'blockquote': + return Attribute.blockQuote; + case 'h1': + return Attribute.h1; + case 'h2': + return Attribute.h2; + case 'h3': + return Attribute.h3; + case 'a': + final href = el.attributes['href']; + return LinkAttribute(href); + case 'img': + final href = el.attributes['src']; + return ImageAttribute(href); + case 'hr': + return DividerAttribute(); + } + + return null; + } +} + +class ImageAttribute extends Attribute { + ImageAttribute(String? val) : super('image', AttributeScope.EMBEDS, val); +} + +class DividerAttribute extends Attribute { + DividerAttribute() : super('divider', AttributeScope.EMBEDS, 'hr'); +} diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/delta_markdown_encoder.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/delta_markdown_encoder.dart new file mode 100644 index 0000000000..12f57e6e8e --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/delta_markdown_encoder.dart @@ -0,0 +1,272 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart' show IterableExtension; +import 'package:flutter_quill/models/documents/attribute.dart'; +import 'package:flutter_quill/models/documents/nodes/embed.dart'; +import 'package:flutter_quill/models/documents/style.dart'; +import 'package:flutter_quill/models/quill_delta.dart'; + +class DeltaMarkdownEncoder extends Converter { + static const _lineFeedAsciiCode = 0x0A; + + late StringBuffer markdownBuffer; + late StringBuffer lineBuffer; + + Attribute? currentBlockStyle; + late Style currentInlineStyle; + + late List currentBlockLines; + + /// Converts the [input] delta to Markdown. + @override + String convert(String input) { + markdownBuffer = StringBuffer(); + lineBuffer = StringBuffer(); + currentInlineStyle = Style(); + currentBlockLines = []; + + final inputJson = jsonDecode(input) as List?; + if (inputJson is! List) { + throw ArgumentError('Unexpected formatting of the input delta string.'); + } + final delta = Delta.fromJson(inputJson); + final iterator = DeltaIterator(delta); + + while (iterator.hasNext) { + final operation = iterator.next(); + + if (operation.data is String) { + final operationData = operation.data as String; + + if (!operationData.contains('\n')) { + _handleInline(lineBuffer, operationData, operation.attributes); + } else { + _handleLine(operationData, operation.attributes); + } + } else if (operation.data is Map) { + _handleEmbed(operation.data as Map); + } else { + throw ArgumentError('Unexpected formatting of the input delta string.'); + } + } + + _handleBlock(currentBlockStyle); // Close the last block + + return markdownBuffer.toString(); + } + + void _handleInline( + StringBuffer buffer, + String text, + Map? attributes, + ) { + final style = Style.fromJson(attributes); + + // First close any current styles if needed + final markedForRemoval = []; + // Close the styles in reverse order, e.g. **_ for _**Test**_. + for (final value + in currentInlineStyle.attributes.values.toList().reversed) { + // TODO(tillf): Is block correct? + if (value.scope == AttributeScope.BLOCK) { + continue; + } + if (style.containsKey(value.key)) { + continue; + } + + final padding = _trimRight(buffer); + _writeAttribute(buffer, value, close: true); + if (padding.isNotEmpty) { + buffer.write(padding); + } + markedForRemoval.add(value); + } + + // Make sure to remove all attributes that are marked for removal. + for (final value in markedForRemoval) { + currentInlineStyle.attributes.removeWhere((_, v) => v == value); + } + + // Now open any new styles. + for (final attribute in style.attributes.values) { + // TODO(tillf): Is block correct? + if (attribute.scope == AttributeScope.BLOCK) { + continue; + } + if (currentInlineStyle.containsKey(attribute.key)) { + continue; + } + final originalText = text; + text = text.trimLeft(); + final padding = ' ' * (originalText.length - text.length); + if (padding.isNotEmpty) { + buffer.write(padding); + } + _writeAttribute(buffer, attribute); + } + + // Write the text itself + buffer.write(text); + currentInlineStyle = style; + } + + void _handleLine(String data, Map? attributes) { + final span = StringBuffer(); + + for (var i = 0; i < data.length; i++) { + if (data.codeUnitAt(i) == _lineFeedAsciiCode) { + if (span.isNotEmpty) { + // Write the span if it's not empty. + _handleInline(lineBuffer, span.toString(), attributes); + } + // Close any open inline styles. + _handleInline(lineBuffer, '', null); + + final lineBlock = Style.fromJson(attributes) + .attributes + .values + .singleWhereOrNull((a) => a.scope == AttributeScope.BLOCK); + + if (lineBlock == currentBlockStyle) { + currentBlockLines.add(lineBuffer.toString()); + } else { + _handleBlock(currentBlockStyle); + currentBlockLines + ..clear() + ..add(lineBuffer.toString()); + + currentBlockStyle = lineBlock; + } + lineBuffer.clear(); + + span.clear(); + } else { + span.writeCharCode(data.codeUnitAt(i)); + } + } + + // Remaining span + if (span.isNotEmpty) { + _handleInline(lineBuffer, span.toString(), attributes); + } + } + + void _handleEmbed(Map data) { + final embed = BlockEmbed(data.keys.first, data.values.first as String); + + if (embed.type == 'image') { + _writeEmbedTag(lineBuffer, embed); + _writeEmbedTag(lineBuffer, embed, close: true); + } else if (embed.type == 'divider') { + _writeEmbedTag(lineBuffer, embed); + _writeEmbedTag(lineBuffer, embed, close: true); + } + } + + void _handleBlock(Attribute? blockStyle) { + if (currentBlockLines.isEmpty) { + return; // Empty block + } + + // If there was a block before this one, add empty line between the blocks + if (markdownBuffer.isNotEmpty) { + markdownBuffer.writeln(); + } + + if (blockStyle == null) { + markdownBuffer + ..write(currentBlockLines.join('\n')) + ..writeln(); + } else if (blockStyle == Attribute.codeBlock) { + _writeAttribute(markdownBuffer, blockStyle); + markdownBuffer.write(currentBlockLines.join('\n')); + _writeAttribute(markdownBuffer, blockStyle, close: true); + markdownBuffer.writeln(); + } else { + // Dealing with lists or a quote. + for (final line in currentBlockLines) { + _writeBlockTag(markdownBuffer, blockStyle); + markdownBuffer + ..write(line) + ..writeln(); + } + } + } + + String _trimRight(StringBuffer buffer) { + final text = buffer.toString(); + if (!text.endsWith(' ')) { + return ''; + } + + final result = text.trimRight(); + buffer + ..clear() + ..write(result); + return ' ' * (text.length - result.length); + } + + void _writeAttribute( + StringBuffer buffer, + Attribute attribute, { + bool close = false, + }) { + if (attribute.key == Attribute.bold.key) { + buffer.write('**'); + } else if (attribute.key == Attribute.italic.key) { + buffer.write('_'); + } else if (attribute.key == Attribute.link.key) { + buffer.write(!close ? '[' : '](${attribute.value})'); + } else if (attribute == Attribute.codeBlock) { + buffer.write(!close ? '```\n' : '\n```'); + } else { + throw ArgumentError('Cannot handle $attribute'); + } + } + + void _writeBlockTag( + StringBuffer buffer, + Attribute block, { + bool close = false, + }) { + if (close) { + return; // no close tag needed for simple blocks. + } + + if (block == Attribute.blockQuote) { + buffer.write('> '); + } else if (block == Attribute.ul) { + buffer.write('* '); + } else if (block == Attribute.ol) { + buffer.write('1. '); + } else if (block.key == Attribute.h1.key && block.value == 1) { + buffer.write('# '); + } else if (block.key == Attribute.h2.key && block.value == 2) { + buffer.write('## '); + } else if (block.key == Attribute.h3.key && block.value == 3) { + buffer.write('### '); + } else { + throw ArgumentError('Cannot handle block $block'); + } + } + + void _writeEmbedTag( + StringBuffer buffer, + BlockEmbed embed, { + bool close = false, + }) { + const kImageType = 'image'; + const kDividerType = 'divider'; + + if (embed.type == kImageType) { + if (close) { + buffer.write('](${embed.data})'); + } else { + buffer.write('!['); + } + } else if (embed.type == kDividerType && close) { + buffer.write('\n---\n\n'); + } + } +} diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/document.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/document.dart new file mode 100644 index 0000000000..890b858cd2 --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/document.dart @@ -0,0 +1,88 @@ +// 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 'extension_set.dart'; +import 'inline_parser.dart'; + +/// Maintains the context needed to parse a Markdown document. +class Document { + Document({ + Iterable? blockSyntaxes, + Iterable? inlineSyntaxes, + ExtensionSet? extensionSet, + this.linkResolver, + this.imageLinkResolver, + }) : extensionSet = extensionSet ?? ExtensionSet.commonMark { + _blockSyntaxes + ..addAll(blockSyntaxes ?? []) + ..addAll(this.extensionSet.blockSyntaxes); + _inlineSyntaxes + ..addAll(inlineSyntaxes ?? []) + ..addAll(this.extensionSet.inlineSyntaxes); + } + + final Map linkReferences = {}; + final ExtensionSet extensionSet; + final Resolver? linkResolver; + final Resolver? imageLinkResolver; + final _blockSyntaxes = {}; + final _inlineSyntaxes = {}; + + Iterable get blockSyntaxes => _blockSyntaxes; + Iterable get inlineSyntaxes => _inlineSyntaxes; + + /// Parses the given [lines] of Markdown to a series of AST nodes. + List parseLines(List lines) { + final nodes = BlockParser(lines, this).parseLines(); + // Make sure to mark the top level nodes as such. + for (final n in nodes) { + n.isToplevel = true; + } + _parseInlineContent(nodes); + return nodes; + } + + /// Parses the given inline Markdown [text] to a series of AST nodes. + List? parseInline(String text) => InlineParser(text, this).parse(); + + void _parseInlineContent(List 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) + ..insertAll(i, inlineNodes); + i += inlineNodes.length - 1; + } else if (node is Element && node.children != null) { + _parseInlineContent(node.children!); + } + } + } +} + +/// A [link reference +/// definition](http://spec.commonmark.org/0.28/#link-reference-definitions). +class LinkReference { + /// Construct a [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); + + /// The [link label](http://spec.commonmark.org/0.28/#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](http://spec.commonmark.org/0.28/#link-destination). + final String destination; + + /// The [link title](http://spec.commonmark.org/0.28/#link-title). + final String title; +} diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/emojis.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/emojis.dart new file mode 100644 index 0000000000..cdb3b6940e --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/emojis.dart @@ -0,0 +1,1510 @@ +// GENERATED FILE. DO NOT EDIT. +// +// This file was generated from emojilib's emoji data file: +// https://github.com/muan/emojilib/raw/master/emojis.json +// at 2018-07-02 15:07:49.422933 by the script, tool/update_emojis.dart. + +const emojis = { + 'grinning': '๐Ÿ˜€', + 'grimacing': '๐Ÿ˜ฌ', + 'grin': '๐Ÿ˜', + 'joy': '๐Ÿ˜‚', + 'rofl': '๐Ÿคฃ', + 'smiley': '๐Ÿ˜ƒ', + 'smile': '๐Ÿ˜„', + 'sweat_smile': '๐Ÿ˜…', + 'laughing': '๐Ÿ˜†', + 'innocent': '๐Ÿ˜‡', + 'wink': '๐Ÿ˜‰', + 'blush': '๐Ÿ˜Š', + 'slightly_smiling_face': '๐Ÿ™‚', + 'upside_down_face': '๐Ÿ™ƒ', + 'relaxed': 'โ˜บ๏ธ', + 'yum': '๐Ÿ˜‹', + 'relieved': '๐Ÿ˜Œ', + 'heart_eyes': '๐Ÿ˜', + 'kissing_heart': '๐Ÿ˜˜', + 'kissing': '๐Ÿ˜—', + 'kissing_smiling_eyes': '๐Ÿ˜™', + 'kissing_closed_eyes': '๐Ÿ˜š', + 'stuck_out_tongue_winking_eye': '๐Ÿ˜œ', + 'zany': '๐Ÿคช', + 'raised_eyebrow': '๐Ÿคจ', + 'monocle': '๐Ÿง', + 'stuck_out_tongue_closed_eyes': '๐Ÿ˜', + 'stuck_out_tongue': '๐Ÿ˜›', + 'money_mouth_face': '๐Ÿค‘', + 'nerd_face': '๐Ÿค“', + 'sunglasses': '๐Ÿ˜Ž', + 'star_struck': '๐Ÿคฉ', + 'clown_face': '๐Ÿคก', + 'cowboy_hat_face': '๐Ÿค ', + 'hugs': '๐Ÿค—', + 'smirk': '๐Ÿ˜', + 'no_mouth': '๐Ÿ˜ถ', + 'neutral_face': '๐Ÿ˜', + 'expressionless': '๐Ÿ˜‘', + 'unamused': '๐Ÿ˜’', + 'roll_eyes': '๐Ÿ™„', + 'thinking': '๐Ÿค”', + 'lying_face': '๐Ÿคฅ', + 'hand_over_mouth': '๐Ÿคญ', + 'shushing': '๐Ÿคซ', + 'symbols_over_mouth': '๐Ÿคฌ', + 'exploding_head': '๐Ÿคฏ', + 'flushed': '๐Ÿ˜ณ', + 'disappointed': '๐Ÿ˜ž', + 'worried': '๐Ÿ˜Ÿ', + 'angry': '๐Ÿ˜ ', + 'rage': '๐Ÿ˜ก', + 'pensive': '๐Ÿ˜”', + 'confused': '๐Ÿ˜•', + 'slightly_frowning_face': '๐Ÿ™', + 'frowning_face': 'โ˜น', + 'persevere': '๐Ÿ˜ฃ', + 'confounded': '๐Ÿ˜–', + 'tired_face': '๐Ÿ˜ซ', + 'weary': '๐Ÿ˜ฉ', + 'triumph': '๐Ÿ˜ค', + 'open_mouth': '๐Ÿ˜ฎ', + 'scream': '๐Ÿ˜ฑ', + 'fearful': '๐Ÿ˜จ', + 'cold_sweat': '๐Ÿ˜ฐ', + 'hushed': '๐Ÿ˜ฏ', + 'frowning': '๐Ÿ˜ฆ', + 'anguished': '๐Ÿ˜ง', + 'cry': '๐Ÿ˜ข', + 'disappointed_relieved': '๐Ÿ˜ฅ', + 'drooling_face': '๐Ÿคค', + 'sleepy': '๐Ÿ˜ช', + 'sweat': '๐Ÿ˜“', + 'sob': '๐Ÿ˜ญ', + 'dizzy_face': '๐Ÿ˜ต', + 'astonished': '๐Ÿ˜ฒ', + 'zipper_mouth_face': '๐Ÿค', + 'nauseated_face': '๐Ÿคข', + 'sneezing_face': '๐Ÿคง', + 'vomiting': '๐Ÿคฎ', + 'mask': '๐Ÿ˜ท', + 'face_with_thermometer': '๐Ÿค’', + 'face_with_head_bandage': '๐Ÿค•', + 'sleeping': '๐Ÿ˜ด', + 'zzz': '๐Ÿ’ค', + 'poop': '๐Ÿ’ฉ', + 'smiling_imp': '๐Ÿ˜ˆ', + 'imp': '๐Ÿ‘ฟ', + 'japanese_ogre': '๐Ÿ‘น', + 'japanese_goblin': '๐Ÿ‘บ', + 'skull': '๐Ÿ’€', + 'ghost': '๐Ÿ‘ป', + 'alien': '๐Ÿ‘ฝ', + 'robot': '๐Ÿค–', + 'smiley_cat': '๐Ÿ˜บ', + 'smile_cat': '๐Ÿ˜ธ', + 'joy_cat': '๐Ÿ˜น', + 'heart_eyes_cat': '๐Ÿ˜ป', + 'smirk_cat': '๐Ÿ˜ผ', + 'kissing_cat': '๐Ÿ˜ฝ', + 'scream_cat': '๐Ÿ™€', + 'crying_cat_face': '๐Ÿ˜ฟ', + 'pouting_cat': '๐Ÿ˜พ', + 'palms_up': '๐Ÿคฒ', + 'raised_hands': '๐Ÿ™Œ', + 'clap': '๐Ÿ‘', + 'wave': '๐Ÿ‘‹', + 'call_me_hand': '๐Ÿค™', + '+1': '๐Ÿ‘', + '-1': '๐Ÿ‘Ž', + 'facepunch': '๐Ÿ‘Š', + 'fist': 'โœŠ', + 'fist_left': '๐Ÿค›', + 'fist_right': '๐Ÿคœ', + 'v': 'โœŒ', + 'ok_hand': '๐Ÿ‘Œ', + 'raised_hand': 'โœ‹', + 'raised_back_of_hand': '๐Ÿคš', + 'open_hands': '๐Ÿ‘', + 'muscle': '๐Ÿ’ช', + 'pray': '๐Ÿ™', + 'handshake': '๐Ÿค', + 'point_up': 'โ˜', + 'point_up_2': '๐Ÿ‘†', + 'point_down': '๐Ÿ‘‡', + 'point_left': '๐Ÿ‘ˆ', + 'point_right': '๐Ÿ‘‰', + 'fu': '๐Ÿ–•', + 'raised_hand_with_fingers_splayed': '๐Ÿ–', + 'love_you': '๐ŸคŸ', + 'metal': '๐Ÿค˜', + 'crossed_fingers': '๐Ÿคž', + 'vulcan_salute': '๐Ÿ––', + 'writing_hand': 'โœ', + 'selfie': '๐Ÿคณ', + 'nail_care': '๐Ÿ’…', + 'lips': '๐Ÿ‘„', + 'tongue': '๐Ÿ‘…', + 'ear': '๐Ÿ‘‚', + 'nose': '๐Ÿ‘ƒ', + 'eye': '๐Ÿ‘', + 'eyes': '๐Ÿ‘€', + 'brain': '๐Ÿง ', + 'bust_in_silhouette': '๐Ÿ‘ค', + 'busts_in_silhouette': '๐Ÿ‘ฅ', + 'speaking_head': '๐Ÿ—ฃ', + 'baby': '๐Ÿ‘ถ', + 'child': '๐Ÿง’', + 'boy': '๐Ÿ‘ฆ', + 'girl': '๐Ÿ‘ง', + 'adult': '๐Ÿง‘', + 'man': '๐Ÿ‘จ', + 'woman': '๐Ÿ‘ฉ', + 'blonde_woman': '๐Ÿ‘ฑโ€โ™€๏ธ', + 'blonde_man': '๐Ÿ‘ฑ', + 'bearded_person': '๐Ÿง”', + 'older_adult': '๐Ÿง“', + 'older_man': '๐Ÿ‘ด', + 'older_woman': '๐Ÿ‘ต', + 'man_with_gua_pi_mao': '๐Ÿ‘ฒ', + 'woman_with_headscarf': '๐Ÿง•', + 'woman_with_turban': '๐Ÿ‘ณโ€โ™€๏ธ', + 'man_with_turban': '๐Ÿ‘ณ', + 'policewoman': '๐Ÿ‘ฎโ€โ™€๏ธ', + 'policeman': '๐Ÿ‘ฎ', + 'construction_worker_woman': '๐Ÿ‘ทโ€โ™€๏ธ', + 'construction_worker_man': '๐Ÿ‘ท', + 'guardswoman': '๐Ÿ’‚โ€โ™€๏ธ', + 'guardsman': '๐Ÿ’‚', + 'female_detective': '๐Ÿ•ต๏ธโ€โ™€๏ธ', + 'male_detective': '๐Ÿ•ต', + 'woman_health_worker': '๐Ÿ‘ฉโ€โš•๏ธ', + 'man_health_worker': '๐Ÿ‘จโ€โš•๏ธ', + 'woman_farmer': '๐Ÿ‘ฉโ€๐ŸŒพ', + 'man_farmer': '๐Ÿ‘จโ€๐ŸŒพ', + 'woman_cook': '๐Ÿ‘ฉโ€๐Ÿณ', + 'man_cook': '๐Ÿ‘จโ€๐Ÿณ', + 'woman_student': '๐Ÿ‘ฉโ€๐ŸŽ“', + 'man_student': '๐Ÿ‘จโ€๐ŸŽ“', + 'woman_singer': '๐Ÿ‘ฉโ€๐ŸŽค', + 'man_singer': '๐Ÿ‘จโ€๐ŸŽค', + 'woman_teacher': '๐Ÿ‘ฉโ€๐Ÿซ', + 'man_teacher': '๐Ÿ‘จโ€๐Ÿซ', + 'woman_factory_worker': '๐Ÿ‘ฉโ€๐Ÿญ', + 'man_factory_worker': '๐Ÿ‘จโ€๐Ÿญ', + 'woman_technologist': '๐Ÿ‘ฉโ€๐Ÿ’ป', + 'man_technologist': '๐Ÿ‘จโ€๐Ÿ’ป', + 'woman_office_worker': '๐Ÿ‘ฉโ€๐Ÿ’ผ', + 'man_office_worker': '๐Ÿ‘จโ€๐Ÿ’ผ', + 'woman_mechanic': '๐Ÿ‘ฉโ€๐Ÿ”ง', + 'man_mechanic': '๐Ÿ‘จโ€๐Ÿ”ง', + 'woman_scientist': '๐Ÿ‘ฉโ€๐Ÿ”ฌ', + 'man_scientist': '๐Ÿ‘จโ€๐Ÿ”ฌ', + 'woman_artist': '๐Ÿ‘ฉโ€๐ŸŽจ', + 'man_artist': '๐Ÿ‘จโ€๐ŸŽจ', + 'woman_firefighter': '๐Ÿ‘ฉโ€๐Ÿš’', + 'man_firefighter': '๐Ÿ‘จโ€๐Ÿš’', + 'woman_pilot': '๐Ÿ‘ฉโ€โœˆ๏ธ', + 'man_pilot': '๐Ÿ‘จโ€โœˆ๏ธ', + 'woman_astronaut': '๐Ÿ‘ฉโ€๐Ÿš€', + 'man_astronaut': '๐Ÿ‘จโ€๐Ÿš€', + 'woman_judge': '๐Ÿ‘ฉโ€โš–๏ธ', + 'man_judge': '๐Ÿ‘จโ€โš–๏ธ', + 'mrs_claus': '๐Ÿคถ', + 'santa': '๐ŸŽ…', + 'sorceress': '๐Ÿง™โ€โ™€๏ธ', + 'wizard': '๐Ÿง™โ€โ™‚๏ธ', + 'woman_elf': '๐Ÿงโ€โ™€๏ธ', + 'man_elf': '๐Ÿงโ€โ™‚๏ธ', + 'woman_vampire': '๐Ÿง›โ€โ™€๏ธ', + 'man_vampire': '๐Ÿง›โ€โ™‚๏ธ', + 'woman_zombie': '๐ŸงŸโ€โ™€๏ธ', + 'man_zombie': '๐ŸงŸโ€โ™‚๏ธ', + 'woman_genie': '๐Ÿงžโ€โ™€๏ธ', + 'man_genie': '๐Ÿงžโ€โ™‚๏ธ', + 'mermaid': '๐Ÿงœโ€โ™€๏ธ', + 'merman': '๐Ÿงœโ€โ™‚๏ธ', + 'woman_fairy': '๐Ÿงšโ€โ™€๏ธ', + 'man_fairy': '๐Ÿงšโ€โ™‚๏ธ', + 'angel': '๐Ÿ‘ผ', + 'pregnant_woman': '๐Ÿคฐ', + 'breastfeeding': '๐Ÿคฑ', + 'princess': '๐Ÿ‘ธ', + 'prince': '๐Ÿคด', + 'bride_with_veil': '๐Ÿ‘ฐ', + 'man_in_tuxedo': '๐Ÿคต', + 'running_woman': '๐Ÿƒโ€โ™€๏ธ', + 'running_man': '๐Ÿƒ', + 'walking_woman': '๐Ÿšถโ€โ™€๏ธ', + 'walking_man': '๐Ÿšถ', + 'dancer': '๐Ÿ’ƒ', + 'man_dancing': '๐Ÿ•บ', + 'dancing_women': '๐Ÿ‘ฏ', + 'dancing_men': '๐Ÿ‘ฏโ€โ™‚๏ธ', + 'couple': '๐Ÿ‘ซ', + 'two_men_holding_hands': '๐Ÿ‘ฌ', + 'two_women_holding_hands': '๐Ÿ‘ญ', + 'bowing_woman': '๐Ÿ™‡โ€โ™€๏ธ', + 'bowing_man': '๐Ÿ™‡', + 'man_facepalming': '๐Ÿคฆ', + 'woman_facepalming': '๐Ÿคฆโ€โ™€๏ธ', + 'woman_shrugging': '๐Ÿคท', + 'man_shrugging': '๐Ÿคทโ€โ™‚๏ธ', + 'tipping_hand_woman': '๐Ÿ’', + 'tipping_hand_man': '๐Ÿ’โ€โ™‚๏ธ', + 'no_good_woman': '๐Ÿ™…', + 'no_good_man': '๐Ÿ™…โ€โ™‚๏ธ', + 'ok_woman': '๐Ÿ™†', + 'ok_man': '๐Ÿ™†โ€โ™‚๏ธ', + 'raising_hand_woman': '๐Ÿ™‹', + 'raising_hand_man': '๐Ÿ™‹โ€โ™‚๏ธ', + 'pouting_woman': '๐Ÿ™Ž', + 'pouting_man': '๐Ÿ™Žโ€โ™‚๏ธ', + 'frowning_woman': '๐Ÿ™', + 'frowning_man': '๐Ÿ™โ€โ™‚๏ธ', + 'haircut_woman': '๐Ÿ’‡', + 'haircut_man': '๐Ÿ’‡โ€โ™‚๏ธ', + 'massage_woman': '๐Ÿ’†', + 'massage_man': '๐Ÿ’†โ€โ™‚๏ธ', + 'woman_in_steamy_room': '๐Ÿง–โ€โ™€๏ธ', + 'man_in_steamy_room': '๐Ÿง–โ€โ™‚๏ธ', + 'couple_with_heart_woman_man': '๐Ÿ’‘', + 'couple_with_heart_woman_woman': '๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ‘ฉ', + 'couple_with_heart_man_man': '๐Ÿ‘จโ€โค๏ธโ€๐Ÿ‘จ', + 'couplekiss_man_woman': '๐Ÿ’', + 'couplekiss_woman_woman': '๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘ฉ', + 'couplekiss_man_man': '๐Ÿ‘จโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ', + 'family_man_woman_boy': '๐Ÿ‘ช', + 'family_man_woman_girl': '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ง', + 'family_man_woman_girl_boy': '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ', + 'family_man_woman_boy_boy': '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ', + 'family_man_woman_girl_girl': '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง', + 'family_woman_woman_boy': '๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ', + 'family_woman_woman_girl': '๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ง', + 'family_woman_woman_girl_boy': '๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ', + 'family_woman_woman_boy_boy': '๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ', + 'family_woman_woman_girl_girl': '๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง', + 'family_man_man_boy': '๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆ', + 'family_man_man_girl': '๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ง', + 'family_man_man_girl_boy': '๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ', + 'family_man_man_boy_boy': '๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ', + 'family_man_man_girl_girl': '๐Ÿ‘จโ€๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง', + 'family_woman_boy': '๐Ÿ‘ฉโ€๐Ÿ‘ฆ', + 'family_woman_girl': '๐Ÿ‘ฉโ€๐Ÿ‘ง', + 'family_woman_girl_boy': '๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ', + 'family_woman_boy_boy': '๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ', + 'family_woman_girl_girl': '๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง', + 'family_man_boy': '๐Ÿ‘จโ€๐Ÿ‘ฆ', + 'family_man_girl': '๐Ÿ‘จโ€๐Ÿ‘ง', + 'family_man_girl_boy': '๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ฆ', + 'family_man_boy_boy': '๐Ÿ‘จโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ', + 'family_man_girl_girl': '๐Ÿ‘จโ€๐Ÿ‘งโ€๐Ÿ‘ง', + 'coat': '๐Ÿงฅ', + 'womans_clothes': '๐Ÿ‘š', + 'tshirt': '๐Ÿ‘•', + 'jeans': '๐Ÿ‘–', + 'necktie': '๐Ÿ‘”', + 'dress': '๐Ÿ‘—', + 'bikini': '๐Ÿ‘™', + 'kimono': '๐Ÿ‘˜', + 'lipstick': '๐Ÿ’„', + 'kiss': '๐Ÿ’‹', + 'footprints': '๐Ÿ‘ฃ', + 'high_heel': '๐Ÿ‘ ', + 'sandal': '๐Ÿ‘ก', + 'boot': '๐Ÿ‘ข', + 'mans_shoe': '๐Ÿ‘ž', + 'athletic_shoe': '๐Ÿ‘Ÿ', + 'socks': '๐Ÿงฆ', + 'gloves': '๐Ÿงค', + 'scarf': '๐Ÿงฃ', + 'womans_hat': '๐Ÿ‘’', + 'tophat': '๐ŸŽฉ', + 'billed_hat': '๐Ÿงข', + 'rescue_worker_helmet': 'โ›‘', + 'mortar_board': '๐ŸŽ“', + 'crown': '๐Ÿ‘‘', + 'school_satchel': '๐ŸŽ’', + 'pouch': '๐Ÿ‘', + 'purse': '๐Ÿ‘›', + 'handbag': '๐Ÿ‘œ', + 'briefcase': '๐Ÿ’ผ', + 'eyeglasses': '๐Ÿ‘“', + 'dark_sunglasses': '๐Ÿ•ถ', + 'ring': '๐Ÿ’', + 'closed_umbrella': '๐ŸŒ‚', + 'dog': '๐Ÿถ', + 'cat': '๐Ÿฑ', + 'mouse': '๐Ÿญ', + 'hamster': '๐Ÿน', + 'rabbit': '๐Ÿฐ', + 'fox_face': '๐ŸฆŠ', + 'bear': '๐Ÿป', + 'panda_face': '๐Ÿผ', + 'koala': '๐Ÿจ', + 'tiger': '๐Ÿฏ', + 'lion': '๐Ÿฆ', + 'cow': '๐Ÿฎ', + 'pig': '๐Ÿท', + 'pig_nose': '๐Ÿฝ', + 'frog': '๐Ÿธ', + 'squid': '๐Ÿฆ‘', + 'octopus': '๐Ÿ™', + 'shrimp': '๐Ÿฆ', + 'monkey_face': '๐Ÿต', + 'gorilla': '๐Ÿฆ', + 'see_no_evil': '๐Ÿ™ˆ', + 'hear_no_evil': '๐Ÿ™‰', + 'speak_no_evil': '๐Ÿ™Š', + 'monkey': '๐Ÿ’', + 'chicken': '๐Ÿ”', + 'penguin': '๐Ÿง', + 'bird': '๐Ÿฆ', + 'baby_chick': '๐Ÿค', + 'hatching_chick': '๐Ÿฃ', + 'hatched_chick': '๐Ÿฅ', + 'duck': '๐Ÿฆ†', + 'eagle': '๐Ÿฆ…', + 'owl': '๐Ÿฆ‰', + 'bat': '๐Ÿฆ‡', + 'wolf': '๐Ÿบ', + 'boar': '๐Ÿ—', + 'horse': '๐Ÿด', + 'unicorn': '๐Ÿฆ„', + 'honeybee': '๐Ÿ', + 'bug': '๐Ÿ›', + 'butterfly': '๐Ÿฆ‹', + 'snail': '๐ŸŒ', + 'beetle': '๐Ÿž', + 'ant': '๐Ÿœ', + 'grasshopper': '๐Ÿฆ—', + 'spider': '๐Ÿ•ท', + 'scorpion': '๐Ÿฆ‚', + 'crab': '๐Ÿฆ€', + 'snake': '๐Ÿ', + 'lizard': '๐ŸฆŽ', + 't-rex': '๐Ÿฆ–', + 'sauropod': '๐Ÿฆ•', + 'turtle': '๐Ÿข', + 'tropical_fish': '๐Ÿ ', + 'fish': '๐ŸŸ', + 'blowfish': '๐Ÿก', + 'dolphin': '๐Ÿฌ', + 'shark': '๐Ÿฆˆ', + 'whale': '๐Ÿณ', + 'whale2': '๐Ÿ‹', + 'crocodile': '๐ŸŠ', + 'leopard': '๐Ÿ†', + 'zebra': '๐Ÿฆ“', + 'tiger2': '๐Ÿ…', + 'water_buffalo': '๐Ÿƒ', + 'ox': '๐Ÿ‚', + 'cow2': '๐Ÿ„', + 'deer': '๐ŸฆŒ', + 'dromedary_camel': '๐Ÿช', + 'camel': '๐Ÿซ', + 'giraffe': '๐Ÿฆ’', + 'elephant': '๐Ÿ˜', + 'rhinoceros': '๐Ÿฆ', + 'goat': '๐Ÿ', + 'ram': '๐Ÿ', + 'sheep': '๐Ÿ‘', + 'racehorse': '๐ŸŽ', + 'pig2': '๐Ÿ–', + 'rat': '๐Ÿ€', + 'mouse2': '๐Ÿ', + 'rooster': '๐Ÿ“', + 'turkey': '๐Ÿฆƒ', + 'dove': '๐Ÿ•Š', + 'dog2': '๐Ÿ•', + 'poodle': '๐Ÿฉ', + 'cat2': '๐Ÿˆ', + 'rabbit2': '๐Ÿ‡', + 'chipmunk': '๐Ÿฟ', + 'hedgehog': '๐Ÿฆ”', + 'paw_prints': '๐Ÿพ', + 'dragon': '๐Ÿ‰', + 'dragon_face': '๐Ÿฒ', + 'cactus': '๐ŸŒต', + 'christmas_tree': '๐ŸŽ„', + 'evergreen_tree': '๐ŸŒฒ', + 'deciduous_tree': '๐ŸŒณ', + 'palm_tree': '๐ŸŒด', + 'seedling': '๐ŸŒฑ', + 'herb': '๐ŸŒฟ', + 'shamrock': 'โ˜˜', + 'four_leaf_clover': '๐Ÿ€', + 'bamboo': '๐ŸŽ', + 'tanabata_tree': '๐ŸŽ‹', + 'leaves': '๐Ÿƒ', + 'fallen_leaf': '๐Ÿ‚', + 'maple_leaf': '๐Ÿ', + 'ear_of_rice': '๐ŸŒพ', + 'hibiscus': '๐ŸŒบ', + 'sunflower': '๐ŸŒป', + 'rose': '๐ŸŒน', + 'wilted_flower': '๐Ÿฅ€', + 'tulip': '๐ŸŒท', + 'blossom': '๐ŸŒผ', + 'cherry_blossom': '๐ŸŒธ', + 'bouquet': '๐Ÿ’', + 'mushroom': '๐Ÿ„', + 'chestnut': '๐ŸŒฐ', + 'jack_o_lantern': '๐ŸŽƒ', + 'shell': '๐Ÿš', + 'spider_web': '๐Ÿ•ธ', + 'earth_americas': '๐ŸŒŽ', + 'earth_africa': '๐ŸŒ', + 'earth_asia': '๐ŸŒ', + 'full_moon': '๐ŸŒ•', + 'waning_gibbous_moon': '๐ŸŒ–', + 'last_quarter_moon': '๐ŸŒ—', + 'waning_crescent_moon': '๐ŸŒ˜', + 'new_moon': '๐ŸŒ‘', + 'waxing_crescent_moon': '๐ŸŒ’', + 'first_quarter_moon': '๐ŸŒ“', + 'waxing_gibbous_moon': '๐ŸŒ”', + 'new_moon_with_face': '๐ŸŒš', + 'full_moon_with_face': '๐ŸŒ', + 'first_quarter_moon_with_face': '๐ŸŒ›', + 'last_quarter_moon_with_face': '๐ŸŒœ', + 'sun_with_face': '๐ŸŒž', + 'crescent_moon': '๐ŸŒ™', + 'star': 'โญ', + 'star2': '๐ŸŒŸ', + 'dizzy': '๐Ÿ’ซ', + 'sparkles': 'โœจ', + 'comet': 'โ˜„', + 'sunny': 'โ˜€๏ธ', + 'sun_behind_small_cloud': '๐ŸŒค', + 'partly_sunny': 'โ›…', + 'sun_behind_large_cloud': '๐ŸŒฅ', + 'sun_behind_rain_cloud': '๐ŸŒฆ', + 'cloud': 'โ˜๏ธ', + 'cloud_with_rain': '๐ŸŒง', + 'cloud_with_lightning_and_rain': 'โ›ˆ', + 'cloud_with_lightning': '๐ŸŒฉ', + 'zap': 'โšก', + 'fire': '๐Ÿ”ฅ', + 'boom': '๐Ÿ’ฅ', + 'snowflake': 'โ„๏ธ', + 'cloud_with_snow': '๐ŸŒจ', + 'snowman': 'โ›„', + 'snowman_with_snow': 'โ˜ƒ', + 'wind_face': '๐ŸŒฌ', + 'dash': '๐Ÿ’จ', + 'tornado': '๐ŸŒช', + 'fog': '๐ŸŒซ', + 'open_umbrella': 'โ˜‚', + 'umbrella': 'โ˜”', + 'droplet': '๐Ÿ’ง', + 'sweat_drops': '๐Ÿ’ฆ', + 'ocean': '๐ŸŒŠ', + 'green_apple': '๐Ÿ', + 'apple': '๐ŸŽ', + 'pear': '๐Ÿ', + 'tangerine': '๐ŸŠ', + 'lemon': '๐Ÿ‹', + 'banana': '๐ŸŒ', + 'watermelon': '๐Ÿ‰', + 'grapes': '๐Ÿ‡', + 'strawberry': '๐Ÿ“', + 'melon': '๐Ÿˆ', + 'cherries': '๐Ÿ’', + 'peach': '๐Ÿ‘', + 'pineapple': '๐Ÿ', + 'coconut': '๐Ÿฅฅ', + 'kiwi_fruit': '๐Ÿฅ', + 'avocado': '๐Ÿฅ‘', + 'broccoli': '๐Ÿฅฆ', + 'tomato': '๐Ÿ…', + 'eggplant': '๐Ÿ†', + 'cucumber': '๐Ÿฅ’', + 'carrot': '๐Ÿฅ•', + 'hot_pepper': '๐ŸŒถ', + 'potato': '๐Ÿฅ”', + 'corn': '๐ŸŒฝ', + 'sweet_potato': '๐Ÿ ', + 'peanuts': '๐Ÿฅœ', + 'honey_pot': '๐Ÿฏ', + 'croissant': '๐Ÿฅ', + 'bread': '๐Ÿž', + 'baguette_bread': '๐Ÿฅ–', + 'pretzel': '๐Ÿฅจ', + 'cheese': '๐Ÿง€', + 'egg': '๐Ÿฅš', + 'bacon': '๐Ÿฅ“', + 'steak': '๐Ÿฅฉ', + 'pancakes': '๐Ÿฅž', + 'poultry_leg': '๐Ÿ—', + 'meat_on_bone': '๐Ÿ–', + 'fried_shrimp': '๐Ÿค', + 'fried_egg': '๐Ÿณ', + 'hamburger': '๐Ÿ”', + 'fries': '๐ŸŸ', + 'stuffed_flatbread': '๐Ÿฅ™', + 'hotdog': '๐ŸŒญ', + 'pizza': '๐Ÿ•', + 'sandwich': '๐Ÿฅช', + 'canned_food': '๐Ÿฅซ', + 'spaghetti': '๐Ÿ', + 'taco': '๐ŸŒฎ', + 'burrito': '๐ŸŒฏ', + 'green_salad': '๐Ÿฅ—', + 'shallow_pan_of_food': '๐Ÿฅ˜', + 'ramen': '๐Ÿœ', + 'stew': '๐Ÿฒ', + 'fish_cake': '๐Ÿฅ', + 'fortune_cookie': '๐Ÿฅ ', + 'sushi': '๐Ÿฃ', + 'bento': '๐Ÿฑ', + 'curry': '๐Ÿ›', + 'rice_ball': '๐Ÿ™', + 'rice': '๐Ÿš', + 'rice_cracker': '๐Ÿ˜', + 'oden': '๐Ÿข', + 'dango': '๐Ÿก', + 'shaved_ice': '๐Ÿง', + 'ice_cream': '๐Ÿจ', + 'icecream': '๐Ÿฆ', + 'pie': '๐Ÿฅง', + 'cake': '๐Ÿฐ', + 'birthday': '๐ŸŽ‚', + 'custard': '๐Ÿฎ', + 'candy': '๐Ÿฌ', + 'lollipop': '๐Ÿญ', + 'chocolate_bar': '๐Ÿซ', + 'popcorn': '๐Ÿฟ', + 'dumpling': '๐ŸฅŸ', + 'doughnut': '๐Ÿฉ', + 'cookie': '๐Ÿช', + 'milk_glass': '๐Ÿฅ›', + 'beer': '๐Ÿบ', + 'beers': '๐Ÿป', + 'clinking_glasses': '๐Ÿฅ‚', + 'wine_glass': '๐Ÿท', + 'tumbler_glass': '๐Ÿฅƒ', + 'cocktail': '๐Ÿธ', + 'tropical_drink': '๐Ÿน', + 'champagne': '๐Ÿพ', + 'sake': '๐Ÿถ', + 'tea': '๐Ÿต', + 'cup_with_straw': '๐Ÿฅค', + 'coffee': 'โ˜•', + 'baby_bottle': '๐Ÿผ', + 'spoon': '๐Ÿฅ„', + 'fork_and_knife': '๐Ÿด', + 'plate_with_cutlery': '๐Ÿฝ', + 'bowl_with_spoon': '๐Ÿฅฃ', + 'takeout_box': '๐Ÿฅก', + 'chopsticks': '๐Ÿฅข', + 'soccer': 'โšฝ', + 'basketball': '๐Ÿ€', + 'football': '๐Ÿˆ', + 'baseball': 'โšพ', + 'tennis': '๐ŸŽพ', + 'volleyball': '๐Ÿ', + 'rugby_football': '๐Ÿ‰', + '8ball': '๐ŸŽฑ', + 'golf': 'โ›ณ', + 'golfing_woman': '๐ŸŒ๏ธโ€โ™€๏ธ', + 'golfing_man': '๐ŸŒ', + 'ping_pong': '๐Ÿ“', + 'badminton': '๐Ÿธ', + 'goal_net': '๐Ÿฅ…', + 'ice_hockey': '๐Ÿ’', + 'field_hockey': '๐Ÿ‘', + 'cricket': '๐Ÿ', + 'ski': '๐ŸŽฟ', + 'skier': 'โ›ท', + 'snowboarder': '๐Ÿ‚', + 'person_fencing': '๐Ÿคบ', + 'women_wrestling': '๐Ÿคผโ€โ™€๏ธ', + 'men_wrestling': '๐Ÿคผโ€โ™‚๏ธ', + 'woman_cartwheeling': '๐Ÿคธโ€โ™€๏ธ', + 'man_cartwheeling': '๐Ÿคธโ€โ™‚๏ธ', + 'woman_playing_handball': '๐Ÿคพโ€โ™€๏ธ', + 'man_playing_handball': '๐Ÿคพโ€โ™‚๏ธ', + 'ice_skate': 'โ›ธ', + 'curling_stone': '๐ŸฅŒ', + 'sled': '๐Ÿ›ท', + 'bow_and_arrow': '๐Ÿน', + 'fishing_pole_and_fish': '๐ŸŽฃ', + 'boxing_glove': '๐ŸฅŠ', + 'martial_arts_uniform': '๐Ÿฅ‹', + 'rowing_woman': '๐Ÿšฃโ€โ™€๏ธ', + 'rowing_man': '๐Ÿšฃ', + 'climbing_woman': '๐Ÿง—โ€โ™€๏ธ', + 'climbing_man': '๐Ÿง—โ€โ™‚๏ธ', + 'swimming_woman': '๐ŸŠโ€โ™€๏ธ', + 'swimming_man': '๐ŸŠ', + 'woman_playing_water_polo': '๐Ÿคฝโ€โ™€๏ธ', + 'man_playing_water_polo': '๐Ÿคฝโ€โ™‚๏ธ', + 'woman_in_lotus_position': '๐Ÿง˜โ€โ™€๏ธ', + 'man_in_lotus_position': '๐Ÿง˜โ€โ™‚๏ธ', + 'surfing_woman': '๐Ÿ„โ€โ™€๏ธ', + 'surfing_man': '๐Ÿ„', + 'bath': '๐Ÿ›€', + 'basketball_woman': 'โ›น๏ธโ€โ™€๏ธ', + 'basketball_man': 'โ›น', + 'weight_lifting_woman': '๐Ÿ‹๏ธโ€โ™€๏ธ', + 'weight_lifting_man': '๐Ÿ‹', + 'biking_woman': '๐Ÿšดโ€โ™€๏ธ', + 'biking_man': '๐Ÿšด', + 'mountain_biking_woman': '๐Ÿšตโ€โ™€๏ธ', + 'mountain_biking_man': '๐Ÿšต', + 'horse_racing': '๐Ÿ‡', + 'business_suit_levitating': '๐Ÿ•ด', + 'trophy': '๐Ÿ†', + 'running_shirt_with_sash': '๐ŸŽฝ', + 'medal_sports': '๐Ÿ…', + 'medal_military': '๐ŸŽ–', + '1st_place_medal': '๐Ÿฅ‡', + '2nd_place_medal': '๐Ÿฅˆ', + '3rd_place_medal': '๐Ÿฅ‰', + 'reminder_ribbon': '๐ŸŽ—', + 'rosette': '๐Ÿต', + 'ticket': '๐ŸŽซ', + 'tickets': '๐ŸŽŸ', + 'performing_arts': '๐ŸŽญ', + 'art': '๐ŸŽจ', + 'circus_tent': '๐ŸŽช', + 'woman_juggling': '๐Ÿคนโ€โ™€๏ธ', + 'man_juggling': '๐Ÿคนโ€โ™‚๏ธ', + 'microphone': '๐ŸŽค', + 'headphones': '๐ŸŽง', + 'musical_score': '๐ŸŽผ', + 'musical_keyboard': '๐ŸŽน', + 'drum': '๐Ÿฅ', + 'saxophone': '๐ŸŽท', + 'trumpet': '๐ŸŽบ', + 'guitar': '๐ŸŽธ', + 'violin': '๐ŸŽป', + 'clapper': '๐ŸŽฌ', + 'video_game': '๐ŸŽฎ', + 'space_invader': '๐Ÿ‘พ', + 'dart': '๐ŸŽฏ', + 'game_die': '๐ŸŽฒ', + 'slot_machine': '๐ŸŽฐ', + 'bowling': '๐ŸŽณ', + 'red_car': '๐Ÿš—', + 'taxi': '๐Ÿš•', + 'blue_car': '๐Ÿš™', + 'bus': '๐ŸšŒ', + 'trolleybus': '๐ŸšŽ', + 'racing_car': '๐ŸŽ', + 'police_car': '๐Ÿš“', + 'ambulance': '๐Ÿš‘', + 'fire_engine': '๐Ÿš’', + 'minibus': '๐Ÿš', + 'truck': '๐Ÿšš', + 'articulated_lorry': '๐Ÿš›', + 'tractor': '๐Ÿšœ', + 'kick_scooter': '๐Ÿ›ด', + 'motorcycle': '๐Ÿ', + 'bike': '๐Ÿšฒ', + 'motor_scooter': '๐Ÿ›ต', + 'rotating_light': '๐Ÿšจ', + 'oncoming_police_car': '๐Ÿš”', + 'oncoming_bus': '๐Ÿš', + 'oncoming_automobile': '๐Ÿš˜', + 'oncoming_taxi': '๐Ÿš–', + 'aerial_tramway': '๐Ÿšก', + 'mountain_cableway': '๐Ÿš ', + 'suspension_railway': '๐ŸšŸ', + 'railway_car': '๐Ÿšƒ', + 'train': '๐Ÿš‹', + 'monorail': '๐Ÿš', + 'bullettrain_side': '๐Ÿš„', + 'bullettrain_front': '๐Ÿš…', + 'light_rail': '๐Ÿšˆ', + 'mountain_railway': '๐Ÿšž', + 'steam_locomotive': '๐Ÿš‚', + 'train2': '๐Ÿš†', + 'metro': '๐Ÿš‡', + 'tram': '๐ŸšŠ', + 'station': '๐Ÿš‰', + 'flying_saucer': '๐Ÿ›ธ', + 'helicopter': '๐Ÿš', + 'small_airplane': '๐Ÿ›ฉ', + 'airplane': 'โœˆ๏ธ', + 'flight_departure': '๐Ÿ›ซ', + 'flight_arrival': '๐Ÿ›ฌ', + 'sailboat': 'โ›ต', + 'motor_boat': '๐Ÿ›ฅ', + 'speedboat': '๐Ÿšค', + 'ferry': 'โ›ด', + 'passenger_ship': '๐Ÿ›ณ', + 'rocket': '๐Ÿš€', + 'artificial_satellite': '๐Ÿ›ฐ', + 'seat': '๐Ÿ’บ', + 'canoe': '๐Ÿ›ถ', + 'anchor': 'โš“', + 'construction': '๐Ÿšง', + 'fuelpump': 'โ›ฝ', + 'busstop': '๐Ÿš', + 'vertical_traffic_light': '๐Ÿšฆ', + 'traffic_light': '๐Ÿšฅ', + 'checkered_flag': '๐Ÿ', + 'ship': '๐Ÿšข', + 'ferris_wheel': '๐ŸŽก', + 'roller_coaster': '๐ŸŽข', + 'carousel_horse': '๐ŸŽ ', + 'building_construction': '๐Ÿ—', + 'foggy': '๐ŸŒ', + 'tokyo_tower': '๐Ÿ—ผ', + 'factory': '๐Ÿญ', + 'fountain': 'โ›ฒ', + 'rice_scene': '๐ŸŽ‘', + 'mountain': 'โ›ฐ', + 'mountain_snow': '๐Ÿ”', + 'mount_fuji': '๐Ÿ—ป', + 'volcano': '๐ŸŒ‹', + 'japan': '๐Ÿ—พ', + 'camping': '๐Ÿ•', + 'tent': 'โ›บ', + 'national_park': '๐Ÿž', + 'motorway': '๐Ÿ›ฃ', + 'railway_track': '๐Ÿ›ค', + 'sunrise': '๐ŸŒ…', + 'sunrise_over_mountains': '๐ŸŒ„', + 'desert': '๐Ÿœ', + 'beach_umbrella': '๐Ÿ–', + 'desert_island': '๐Ÿ', + 'city_sunrise': '๐ŸŒ‡', + 'city_sunset': '๐ŸŒ†', + 'cityscape': '๐Ÿ™', + 'night_with_stars': '๐ŸŒƒ', + 'bridge_at_night': '๐ŸŒ‰', + 'milky_way': '๐ŸŒŒ', + 'stars': '๐ŸŒ ', + 'sparkler': '๐ŸŽ‡', + 'fireworks': '๐ŸŽ†', + 'rainbow': '๐ŸŒˆ', + 'houses': '๐Ÿ˜', + 'european_castle': '๐Ÿฐ', + 'japanese_castle': '๐Ÿฏ', + 'stadium': '๐ŸŸ', + 'statue_of_liberty': '๐Ÿ—ฝ', + 'house': '๐Ÿ ', + 'house_with_garden': '๐Ÿก', + 'derelict_house': '๐Ÿš', + 'office': '๐Ÿข', + 'department_store': '๐Ÿฌ', + 'post_office': '๐Ÿฃ', + 'european_post_office': '๐Ÿค', + 'hospital': '๐Ÿฅ', + 'bank': '๐Ÿฆ', + 'hotel': '๐Ÿจ', + 'convenience_store': '๐Ÿช', + 'school': '๐Ÿซ', + 'love_hotel': '๐Ÿฉ', + 'wedding': '๐Ÿ’’', + 'classical_building': '๐Ÿ›', + 'church': 'โ›ช', + 'mosque': '๐Ÿ•Œ', + 'synagogue': '๐Ÿ•', + 'kaaba': '๐Ÿ•‹', + 'shinto_shrine': 'โ›ฉ', + 'watch': 'โŒš', + 'iphone': '๐Ÿ“ฑ', + 'calling': '๐Ÿ“ฒ', + 'computer': '๐Ÿ’ป', + 'keyboard': 'โŒจ', + 'desktop_computer': '๐Ÿ–ฅ', + 'printer': '๐Ÿ–จ', + 'computer_mouse': '๐Ÿ–ฑ', + 'trackball': '๐Ÿ–ฒ', + 'joystick': '๐Ÿ•น', + 'clamp': '๐Ÿ—œ', + 'minidisc': '๐Ÿ’ฝ', + 'floppy_disk': '๐Ÿ’พ', + 'cd': '๐Ÿ’ฟ', + 'dvd': '๐Ÿ“€', + 'vhs': '๐Ÿ“ผ', + 'camera': '๐Ÿ“ท', + 'camera_flash': '๐Ÿ“ธ', + 'video_camera': '๐Ÿ“น', + 'movie_camera': '๐ŸŽฅ', + 'film_projector': '๐Ÿ“ฝ', + 'film_strip': '๐ŸŽž', + 'telephone_receiver': '๐Ÿ“ž', + 'phone': 'โ˜Ž๏ธ', + 'pager': '๐Ÿ“Ÿ', + 'fax': '๐Ÿ“ ', + 'tv': '๐Ÿ“บ', + 'radio': '๐Ÿ“ป', + 'studio_microphone': '๐ŸŽ™', + 'level_slider': '๐ŸŽš', + 'control_knobs': '๐ŸŽ›', + 'stopwatch': 'โฑ', + 'timer_clock': 'โฒ', + 'alarm_clock': 'โฐ', + 'mantelpiece_clock': '๐Ÿ•ฐ', + 'hourglass_flowing_sand': 'โณ', + 'hourglass': 'โŒ›', + 'satellite': '๐Ÿ“ก', + 'battery': '๐Ÿ”‹', + 'electric_plug': '๐Ÿ”Œ', + 'bulb': '๐Ÿ’ก', + 'flashlight': '๐Ÿ”ฆ', + 'candle': '๐Ÿ•ฏ', + 'wastebasket': '๐Ÿ—‘', + 'oil_drum': '๐Ÿ›ข', + 'money_with_wings': '๐Ÿ’ธ', + 'dollar': '๐Ÿ’ต', + 'yen': '๐Ÿ’ด', + 'euro': '๐Ÿ’ถ', + 'pound': '๐Ÿ’ท', + 'moneybag': '๐Ÿ’ฐ', + 'credit_card': '๐Ÿ’ณ', + 'gem': '๐Ÿ’Ž', + 'balance_scale': 'โš–', + 'wrench': '๐Ÿ”ง', + 'hammer': '๐Ÿ”จ', + 'hammer_and_pick': 'โš’', + 'hammer_and_wrench': '๐Ÿ› ', + 'pick': 'โ›', + 'nut_and_bolt': '๐Ÿ”ฉ', + 'gear': 'โš™', + 'chains': 'โ›“', + 'gun': '๐Ÿ”ซ', + 'bomb': '๐Ÿ’ฃ', + 'hocho': '๐Ÿ”ช', + 'dagger': '๐Ÿ—ก', + 'crossed_swords': 'โš”', + 'shield': '๐Ÿ›ก', + 'smoking': '๐Ÿšฌ', + 'skull_and_crossbones': 'โ˜ ', + 'coffin': 'โšฐ', + 'funeral_urn': 'โšฑ', + 'amphora': '๐Ÿบ', + 'crystal_ball': '๐Ÿ”ฎ', + 'prayer_beads': '๐Ÿ“ฟ', + 'barber': '๐Ÿ’ˆ', + 'alembic': 'โš—', + 'telescope': '๐Ÿ”ญ', + 'microscope': '๐Ÿ”ฌ', + 'hole': '๐Ÿ•ณ', + 'pill': '๐Ÿ’Š', + 'syringe': '๐Ÿ’‰', + 'thermometer': '๐ŸŒก', + 'label': '๐Ÿท', + 'bookmark': '๐Ÿ”–', + 'toilet': '๐Ÿšฝ', + 'shower': '๐Ÿšฟ', + 'bathtub': '๐Ÿ›', + 'key': '๐Ÿ”‘', + 'old_key': '๐Ÿ—', + 'couch_and_lamp': '๐Ÿ›‹', + 'sleeping_bed': '๐Ÿ›Œ', + 'bed': '๐Ÿ›', + 'door': '๐Ÿšช', + 'bellhop_bell': '๐Ÿ›Ž', + 'framed_picture': '๐Ÿ–ผ', + 'world_map': '๐Ÿ—บ', + 'parasol_on_ground': 'โ›ฑ', + 'moyai': '๐Ÿ—ฟ', + 'shopping': '๐Ÿ›', + 'shopping_cart': '๐Ÿ›’', + 'balloon': '๐ŸŽˆ', + 'flags': '๐ŸŽ', + 'ribbon': '๐ŸŽ€', + 'gift': '๐ŸŽ', + 'confetti_ball': '๐ŸŽŠ', + 'tada': '๐ŸŽ‰', + 'dolls': '๐ŸŽŽ', + 'wind_chime': '๐ŸŽ', + 'crossed_flags': '๐ŸŽŒ', + 'izakaya_lantern': '๐Ÿฎ', + 'email': 'โœ‰๏ธ', + 'envelope_with_arrow': '๐Ÿ“ฉ', + 'incoming_envelope': '๐Ÿ“จ', + 'e-mail': '๐Ÿ“ง', + 'love_letter': '๐Ÿ’Œ', + 'postbox': '๐Ÿ“ฎ', + 'mailbox_closed': '๐Ÿ“ช', + 'mailbox': '๐Ÿ“ซ', + 'mailbox_with_mail': '๐Ÿ“ฌ', + 'mailbox_with_no_mail': '๐Ÿ“ญ', + 'package': '๐Ÿ“ฆ', + 'postal_horn': '๐Ÿ“ฏ', + 'inbox_tray': '๐Ÿ“ฅ', + 'outbox_tray': '๐Ÿ“ค', + 'scroll': '๐Ÿ“œ', + 'page_with_curl': '๐Ÿ“ƒ', + 'bookmark_tabs': '๐Ÿ“‘', + 'bar_chart': '๐Ÿ“Š', + 'chart_with_upwards_trend': '๐Ÿ“ˆ', + 'chart_with_downwards_trend': '๐Ÿ“‰', + 'page_facing_up': '๐Ÿ“„', + 'date': '๐Ÿ“…', + 'calendar': '๐Ÿ“†', + 'spiral_calendar': '๐Ÿ—“', + 'card_index': '๐Ÿ“‡', + 'card_file_box': '๐Ÿ—ƒ', + 'ballot_box': '๐Ÿ—ณ', + 'file_cabinet': '๐Ÿ—„', + 'clipboard': '๐Ÿ“‹', + 'spiral_notepad': '๐Ÿ—’', + 'file_folder': '๐Ÿ“', + 'open_file_folder': '๐Ÿ“‚', + 'card_index_dividers': '๐Ÿ—‚', + 'newspaper_roll': '๐Ÿ—ž', + 'newspaper': '๐Ÿ“ฐ', + 'notebook': '๐Ÿ““', + 'closed_book': '๐Ÿ“•', + 'green_book': '๐Ÿ“—', + 'blue_book': '๐Ÿ“˜', + 'orange_book': '๐Ÿ“™', + 'notebook_with_decorative_cover': '๐Ÿ“”', + 'ledger': '๐Ÿ“’', + 'books': '๐Ÿ“š', + 'open_book': '๐Ÿ“–', + 'link': '๐Ÿ”—', + 'paperclip': '๐Ÿ“Ž', + 'paperclips': '๐Ÿ–‡', + 'scissors': 'โœ‚๏ธ', + 'triangular_ruler': '๐Ÿ“', + 'straight_ruler': '๐Ÿ“', + 'pushpin': '๐Ÿ“Œ', + 'round_pushpin': '๐Ÿ“', + 'triangular_flag_on_post': '๐Ÿšฉ', + 'white_flag': '๐Ÿณ', + 'black_flag': '๐Ÿด', + 'rainbow_flag': '๐Ÿณ๏ธโ€๐ŸŒˆ', + 'closed_lock_with_key': '๐Ÿ”', + 'lock': '๐Ÿ”’', + 'unlock': '๐Ÿ”“', + 'lock_with_ink_pen': '๐Ÿ”', + 'pen': '๐Ÿ–Š', + 'fountain_pen': '๐Ÿ–‹', + 'black_nib': 'โœ’๏ธ', + 'memo': '๐Ÿ“', + 'pencil2': 'โœ๏ธ', + 'crayon': '๐Ÿ–', + 'paintbrush': '๐Ÿ–Œ', + 'mag': '๐Ÿ”', + 'mag_right': '๐Ÿ”Ž', + 'heart': 'โค๏ธ', + 'orange_heart': '๐Ÿงก', + 'yellow_heart': '๐Ÿ’›', + 'green_heart': '๐Ÿ’š', + 'blue_heart': '๐Ÿ’™', + 'purple_heart': '๐Ÿ’œ', + 'black_heart': '๐Ÿ–ค', + 'broken_heart': '๐Ÿ’”', + 'heavy_heart_exclamation': 'โฃ', + 'two_hearts': '๐Ÿ’•', + 'revolving_hearts': '๐Ÿ’ž', + 'heartbeat': '๐Ÿ’“', + 'heartpulse': '๐Ÿ’—', + 'sparkling_heart': '๐Ÿ’–', + 'cupid': '๐Ÿ’˜', + 'gift_heart': '๐Ÿ’', + 'heart_decoration': '๐Ÿ’Ÿ', + 'peace_symbol': 'โ˜ฎ', + 'latin_cross': 'โœ', + 'star_and_crescent': 'โ˜ช', + 'om': '๐Ÿ•‰', + 'wheel_of_dharma': 'โ˜ธ', + 'star_of_david': 'โœก', + 'six_pointed_star': '๐Ÿ”ฏ', + 'menorah': '๐Ÿ•Ž', + 'yin_yang': 'โ˜ฏ', + 'orthodox_cross': 'โ˜ฆ', + 'place_of_worship': '๐Ÿ›', + 'ophiuchus': 'โ›Ž', + 'aries': 'โ™ˆ', + 'taurus': 'โ™‰', + 'gemini': 'โ™Š', + 'cancer': 'โ™‹', + 'leo': 'โ™Œ', + 'virgo': 'โ™', + 'libra': 'โ™Ž', + 'scorpius': 'โ™', + 'sagittarius': 'โ™', + 'capricorn': 'โ™‘', + 'aquarius': 'โ™’', + 'pisces': 'โ™“', + 'id': '๐Ÿ†”', + 'atom_symbol': 'โš›', + 'u7a7a': '๐Ÿˆณ', + 'u5272': '๐Ÿˆน', + 'radioactive': 'โ˜ข', + 'biohazard': 'โ˜ฃ', + 'mobile_phone_off': '๐Ÿ“ด', + 'vibration_mode': '๐Ÿ“ณ', + 'u6709': '๐Ÿˆถ', + 'u7121': '๐Ÿˆš', + 'u7533': '๐Ÿˆธ', + 'u55b6': '๐Ÿˆบ', + 'u6708': '๐Ÿˆท๏ธ', + 'eight_pointed_black_star': 'โœด๏ธ', + 'vs': '๐Ÿ†š', + 'accept': '๐Ÿ‰‘', + 'white_flower': '๐Ÿ’ฎ', + 'ideograph_advantage': '๐Ÿ‰', + 'secret': 'ใŠ™๏ธ', + 'congratulations': 'ใŠ—๏ธ', + 'u5408': '๐Ÿˆด', + 'u6e80': '๐Ÿˆต', + 'u7981': '๐Ÿˆฒ', + 'a': '๐Ÿ…ฐ๏ธ', + 'b': '๐Ÿ…ฑ๏ธ', + 'ab': '๐Ÿ†Ž', + 'cl': '๐Ÿ†‘', + 'o2': '๐Ÿ…พ๏ธ', + 'sos': '๐Ÿ†˜', + 'no_entry': 'โ›”', + 'name_badge': '๐Ÿ“›', + 'no_entry_sign': '๐Ÿšซ', + 'x': 'โŒ', + 'o': 'โญ•', + 'stop_sign': '๐Ÿ›‘', + 'anger': '๐Ÿ’ข', + 'hotsprings': 'โ™จ๏ธ', + 'no_pedestrians': '๐Ÿšท', + 'do_not_litter': '๐Ÿšฏ', + 'no_bicycles': '๐Ÿšณ', + 'non-potable_water': '๐Ÿšฑ', + 'underage': '๐Ÿ”ž', + 'no_mobile_phones': '๐Ÿ“ต', + 'exclamation': 'โ—', + 'grey_exclamation': 'โ•', + 'question': 'โ“', + 'grey_question': 'โ”', + 'bangbang': 'โ€ผ๏ธ', + 'interrobang': 'โ‰๏ธ', + '100': '๐Ÿ’ฏ', + 'low_brightness': '๐Ÿ”…', + 'high_brightness': '๐Ÿ”†', + 'trident': '๐Ÿ”ฑ', + 'fleur_de_lis': 'โšœ', + 'part_alternation_mark': 'ใ€ฝ๏ธ', + 'warning': 'โš ๏ธ', + 'children_crossing': '๐Ÿšธ', + 'beginner': '๐Ÿ”ฐ', + 'recycle': 'โ™ป๏ธ', + 'u6307': '๐Ÿˆฏ', + 'chart': '๐Ÿ’น', + 'sparkle': 'โ‡๏ธ', + 'eight_spoked_asterisk': 'โœณ๏ธ', + 'negative_squared_cross_mark': 'โŽ', + 'white_check_mark': 'โœ…', + 'diamond_shape_with_a_dot_inside': '๐Ÿ’ ', + 'cyclone': '๐ŸŒ€', + 'loop': 'โžฟ', + 'globe_with_meridians': '๐ŸŒ', + 'm': 'โ“‚๏ธ', + 'atm': '๐Ÿง', + 'sa': '๐Ÿˆ‚๏ธ', + 'passport_control': '๐Ÿ›‚', + 'customs': '๐Ÿ›ƒ', + 'baggage_claim': '๐Ÿ›„', + 'left_luggage': '๐Ÿ›…', + 'wheelchair': 'โ™ฟ', + 'no_smoking': '๐Ÿšญ', + 'wc': '๐Ÿšพ', + 'parking': '๐Ÿ…ฟ๏ธ', + 'potable_water': '๐Ÿšฐ', + 'mens': '๐Ÿšน', + 'womens': '๐Ÿšบ', + 'baby_symbol': '๐Ÿšผ', + 'restroom': '๐Ÿšป', + 'put_litter_in_its_place': '๐Ÿšฎ', + 'cinema': '๐ŸŽฆ', + 'signal_strength': '๐Ÿ“ถ', + 'koko': '๐Ÿˆ', + 'ng': '๐Ÿ†–', + 'ok': '๐Ÿ†—', + 'up': '๐Ÿ†™', + 'cool': '๐Ÿ†’', + 'new': '๐Ÿ†•', + 'free': '๐Ÿ†“', + 'zero': '0๏ธโƒฃ', + 'one': '1๏ธโƒฃ', + 'two': '2๏ธโƒฃ', + 'three': '3๏ธโƒฃ', + 'four': '4๏ธโƒฃ', + 'five': '5๏ธโƒฃ', + 'six': '6๏ธโƒฃ', + 'seven': '7๏ธโƒฃ', + 'eight': '8๏ธโƒฃ', + 'nine': '9๏ธโƒฃ', + 'keycap_ten': '๐Ÿ”Ÿ', + 'asterisk': '*โƒฃ', + '1234': '๐Ÿ”ข', + 'eject_button': 'โ๏ธ', + 'arrow_forward': 'โ–ถ๏ธ', + 'pause_button': 'โธ', + 'next_track_button': 'โญ', + 'stop_button': 'โน', + 'record_button': 'โบ', + 'play_or_pause_button': 'โฏ', + 'previous_track_button': 'โฎ', + 'fast_forward': 'โฉ', + 'rewind': 'โช', + 'twisted_rightwards_arrows': '๐Ÿ”€', + 'repeat': '๐Ÿ”', + 'repeat_one': '๐Ÿ”‚', + 'arrow_backward': 'โ—€๏ธ', + 'arrow_up_small': '๐Ÿ”ผ', + 'arrow_down_small': '๐Ÿ”ฝ', + 'arrow_double_up': 'โซ', + 'arrow_double_down': 'โฌ', + 'arrow_right': 'โžก๏ธ', + 'arrow_left': 'โฌ…๏ธ', + 'arrow_up': 'โฌ†๏ธ', + 'arrow_down': 'โฌ‡๏ธ', + 'arrow_upper_right': 'โ†—๏ธ', + 'arrow_lower_right': 'โ†˜๏ธ', + 'arrow_lower_left': 'โ†™๏ธ', + 'arrow_upper_left': 'โ†–๏ธ', + 'arrow_up_down': 'โ†•๏ธ', + 'left_right_arrow': 'โ†”๏ธ', + 'arrows_counterclockwise': '๐Ÿ”„', + 'arrow_right_hook': 'โ†ช๏ธ', + 'leftwards_arrow_with_hook': 'โ†ฉ๏ธ', + 'arrow_heading_up': 'โคด๏ธ', + 'arrow_heading_down': 'โคต๏ธ', + 'hash': '#๏ธโƒฃ', + 'information_source': 'โ„น๏ธ', + 'abc': '๐Ÿ”ค', + 'abcd': '๐Ÿ”ก', + 'capital_abcd': '๐Ÿ” ', + 'symbols': '๐Ÿ”ฃ', + 'musical_note': '๐ŸŽต', + 'notes': '๐ŸŽถ', + 'wavy_dash': 'ใ€ฐ๏ธ', + 'curly_loop': 'โžฐ', + 'heavy_check_mark': 'โœ”๏ธ', + 'arrows_clockwise': '๐Ÿ”ƒ', + 'heavy_plus_sign': 'โž•', + 'heavy_minus_sign': 'โž–', + 'heavy_division_sign': 'โž—', + 'heavy_multiplication_x': 'โœ–๏ธ', + 'heavy_dollar_sign': '๐Ÿ’ฒ', + 'currency_exchange': '๐Ÿ’ฑ', + 'copyright': 'ยฉ๏ธ', + 'registered': 'ยฎ๏ธ', + 'tm': 'โ„ข๏ธ', + 'end': '๐Ÿ”š', + 'back': '๐Ÿ”™', + 'on': '๐Ÿ”›', + 'top': '๐Ÿ”', + 'soon': '๐Ÿ”œ', + 'ballot_box_with_check': 'โ˜‘๏ธ', + 'radio_button': '๐Ÿ”˜', + 'white_circle': 'โšช', + 'black_circle': 'โšซ', + 'red_circle': '๐Ÿ”ด', + 'large_blue_circle': '๐Ÿ”ต', + 'small_orange_diamond': '๐Ÿ”ธ', + 'small_blue_diamond': '๐Ÿ”น', + 'large_orange_diamond': '๐Ÿ”ถ', + 'large_blue_diamond': '๐Ÿ”ท', + 'small_red_triangle': '๐Ÿ”บ', + 'black_small_square': 'โ–ช๏ธ', + 'white_small_square': 'โ–ซ๏ธ', + 'black_large_square': 'โฌ›', + 'white_large_square': 'โฌœ', + 'small_red_triangle_down': '๐Ÿ”ป', + 'black_medium_square': 'โ—ผ๏ธ', + 'white_medium_square': 'โ—ป๏ธ', + 'black_medium_small_square': 'โ—พ', + 'white_medium_small_square': 'โ—ฝ', + 'black_square_button': '๐Ÿ”ฒ', + 'white_square_button': '๐Ÿ”ณ', + 'speaker': '๐Ÿ”ˆ', + 'sound': '๐Ÿ”‰', + 'loud_sound': '๐Ÿ”Š', + 'mute': '๐Ÿ”‡', + 'mega': '๐Ÿ“ฃ', + 'loudspeaker': '๐Ÿ“ข', + 'bell': '๐Ÿ””', + 'no_bell': '๐Ÿ”•', + 'black_joker': '๐Ÿƒ', + 'mahjong': '๐Ÿ€„', + 'spades': 'โ™ ๏ธ', + 'clubs': 'โ™ฃ๏ธ', + 'hearts': 'โ™ฅ๏ธ', + 'diamonds': 'โ™ฆ๏ธ', + 'flower_playing_cards': '๐ŸŽด', + 'thought_balloon': '๐Ÿ’ญ', + 'right_anger_bubble': '๐Ÿ—ฏ', + 'speech_balloon': '๐Ÿ’ฌ', + 'left_speech_bubble': '๐Ÿ—จ', + 'clock1': '๐Ÿ•', + 'clock2': '๐Ÿ•‘', + 'clock3': '๐Ÿ•’', + 'clock4': '๐Ÿ•“', + 'clock5': '๐Ÿ•”', + 'clock6': '๐Ÿ••', + 'clock7': '๐Ÿ•–', + 'clock8': '๐Ÿ•—', + 'clock9': '๐Ÿ•˜', + 'clock10': '๐Ÿ•™', + 'clock11': '๐Ÿ•š', + 'clock12': '๐Ÿ•›', + 'clock130': '๐Ÿ•œ', + 'clock230': '๐Ÿ•', + 'clock330': '๐Ÿ•ž', + 'clock430': '๐Ÿ•Ÿ', + 'clock530': '๐Ÿ• ', + 'clock630': '๐Ÿ•ก', + 'clock730': '๐Ÿ•ข', + 'clock830': '๐Ÿ•ฃ', + 'clock930': '๐Ÿ•ค', + 'clock1030': '๐Ÿ•ฅ', + 'clock1130': '๐Ÿ•ฆ', + 'clock1230': '๐Ÿ•ง', + 'afghanistan': '๐Ÿ‡ฆ๐Ÿ‡ซ', + 'aland_islands': '๐Ÿ‡ฆ๐Ÿ‡ฝ', + 'albania': '๐Ÿ‡ฆ๐Ÿ‡ฑ', + 'algeria': '๐Ÿ‡ฉ๐Ÿ‡ฟ', + 'american_samoa': '๐Ÿ‡ฆ๐Ÿ‡ธ', + 'andorra': '๐Ÿ‡ฆ๐Ÿ‡ฉ', + 'angola': '๐Ÿ‡ฆ๐Ÿ‡ด', + 'anguilla': '๐Ÿ‡ฆ๐Ÿ‡ฎ', + 'antarctica': '๐Ÿ‡ฆ๐Ÿ‡ถ', + 'antigua_barbuda': '๐Ÿ‡ฆ๐Ÿ‡ฌ', + 'argentina': '๐Ÿ‡ฆ๐Ÿ‡ท', + 'armenia': '๐Ÿ‡ฆ๐Ÿ‡ฒ', + 'aruba': '๐Ÿ‡ฆ๐Ÿ‡ผ', + 'australia': '๐Ÿ‡ฆ๐Ÿ‡บ', + 'austria': '๐Ÿ‡ฆ๐Ÿ‡น', + 'azerbaijan': '๐Ÿ‡ฆ๐Ÿ‡ฟ', + 'bahamas': '๐Ÿ‡ง๐Ÿ‡ธ', + 'bahrain': '๐Ÿ‡ง๐Ÿ‡ญ', + 'bangladesh': '๐Ÿ‡ง๐Ÿ‡ฉ', + 'barbados': '๐Ÿ‡ง๐Ÿ‡ง', + 'belarus': '๐Ÿ‡ง๐Ÿ‡พ', + 'belgium': '๐Ÿ‡ง๐Ÿ‡ช', + 'belize': '๐Ÿ‡ง๐Ÿ‡ฟ', + 'benin': '๐Ÿ‡ง๐Ÿ‡ฏ', + 'bermuda': '๐Ÿ‡ง๐Ÿ‡ฒ', + 'bhutan': '๐Ÿ‡ง๐Ÿ‡น', + 'bolivia': '๐Ÿ‡ง๐Ÿ‡ด', + 'caribbean_netherlands': '๐Ÿ‡ง๐Ÿ‡ถ', + 'bosnia_herzegovina': '๐Ÿ‡ง๐Ÿ‡ฆ', + 'botswana': '๐Ÿ‡ง๐Ÿ‡ผ', + 'brazil': '๐Ÿ‡ง๐Ÿ‡ท', + 'british_indian_ocean_territory': '๐Ÿ‡ฎ๐Ÿ‡ด', + 'british_virgin_islands': '๐Ÿ‡ป๐Ÿ‡ฌ', + 'brunei': '๐Ÿ‡ง๐Ÿ‡ณ', + 'bulgaria': '๐Ÿ‡ง๐Ÿ‡ฌ', + 'burkina_faso': '๐Ÿ‡ง๐Ÿ‡ซ', + 'burundi': '๐Ÿ‡ง๐Ÿ‡ฎ', + 'cape_verde': '๐Ÿ‡จ๐Ÿ‡ป', + 'cambodia': '๐Ÿ‡ฐ๐Ÿ‡ญ', + 'cameroon': '๐Ÿ‡จ๐Ÿ‡ฒ', + 'canada': '๐Ÿ‡จ๐Ÿ‡ฆ', + 'canary_islands': '๐Ÿ‡ฎ๐Ÿ‡จ', + 'cayman_islands': '๐Ÿ‡ฐ๐Ÿ‡พ', + 'central_african_republic': '๐Ÿ‡จ๐Ÿ‡ซ', + 'chad': '๐Ÿ‡น๐Ÿ‡ฉ', + 'chile': '๐Ÿ‡จ๐Ÿ‡ฑ', + 'cn': '๐Ÿ‡จ๐Ÿ‡ณ', + 'christmas_island': '๐Ÿ‡จ๐Ÿ‡ฝ', + 'cocos_islands': '๐Ÿ‡จ๐Ÿ‡จ', + 'colombia': '๐Ÿ‡จ๐Ÿ‡ด', + 'comoros': '๐Ÿ‡ฐ๐Ÿ‡ฒ', + 'congo_brazzaville': '๐Ÿ‡จ๐Ÿ‡ฌ', + 'congo_kinshasa': '๐Ÿ‡จ๐Ÿ‡ฉ', + 'cook_islands': '๐Ÿ‡จ๐Ÿ‡ฐ', + 'costa_rica': '๐Ÿ‡จ๐Ÿ‡ท', + 'croatia': '๐Ÿ‡ญ๐Ÿ‡ท', + 'cuba': '๐Ÿ‡จ๐Ÿ‡บ', + 'curacao': '๐Ÿ‡จ๐Ÿ‡ผ', + 'cyprus': '๐Ÿ‡จ๐Ÿ‡พ', + 'czech_republic': '๐Ÿ‡จ๐Ÿ‡ฟ', + 'denmark': '๐Ÿ‡ฉ๐Ÿ‡ฐ', + 'djibouti': '๐Ÿ‡ฉ๐Ÿ‡ฏ', + 'dominica': '๐Ÿ‡ฉ๐Ÿ‡ฒ', + 'dominican_republic': '๐Ÿ‡ฉ๐Ÿ‡ด', + 'ecuador': '๐Ÿ‡ช๐Ÿ‡จ', + 'egypt': '๐Ÿ‡ช๐Ÿ‡ฌ', + 'el_salvador': '๐Ÿ‡ธ๐Ÿ‡ป', + 'equatorial_guinea': '๐Ÿ‡ฌ๐Ÿ‡ถ', + 'eritrea': '๐Ÿ‡ช๐Ÿ‡ท', + 'estonia': '๐Ÿ‡ช๐Ÿ‡ช', + 'ethiopia': '๐Ÿ‡ช๐Ÿ‡น', + 'eu': '๐Ÿ‡ช๐Ÿ‡บ', + 'falkland_islands': '๐Ÿ‡ซ๐Ÿ‡ฐ', + 'faroe_islands': '๐Ÿ‡ซ๐Ÿ‡ด', + 'fiji': '๐Ÿ‡ซ๐Ÿ‡ฏ', + 'finland': '๐Ÿ‡ซ๐Ÿ‡ฎ', + 'fr': '๐Ÿ‡ซ๐Ÿ‡ท', + 'french_guiana': '๐Ÿ‡ฌ๐Ÿ‡ซ', + 'french_polynesia': '๐Ÿ‡ต๐Ÿ‡ซ', + 'french_southern_territories': '๐Ÿ‡น๐Ÿ‡ซ', + 'gabon': '๐Ÿ‡ฌ๐Ÿ‡ฆ', + 'gambia': '๐Ÿ‡ฌ๐Ÿ‡ฒ', + 'georgia': '๐Ÿ‡ฌ๐Ÿ‡ช', + 'de': '๐Ÿ‡ฉ๐Ÿ‡ช', + 'ghana': '๐Ÿ‡ฌ๐Ÿ‡ญ', + 'gibraltar': '๐Ÿ‡ฌ๐Ÿ‡ฎ', + 'greece': '๐Ÿ‡ฌ๐Ÿ‡ท', + 'greenland': '๐Ÿ‡ฌ๐Ÿ‡ฑ', + 'grenada': '๐Ÿ‡ฌ๐Ÿ‡ฉ', + 'guadeloupe': '๐Ÿ‡ฌ๐Ÿ‡ต', + 'guam': '๐Ÿ‡ฌ๐Ÿ‡บ', + 'guatemala': '๐Ÿ‡ฌ๐Ÿ‡น', + 'guernsey': '๐Ÿ‡ฌ๐Ÿ‡ฌ', + 'guinea': '๐Ÿ‡ฌ๐Ÿ‡ณ', + 'guinea_bissau': '๐Ÿ‡ฌ๐Ÿ‡ผ', + 'guyana': '๐Ÿ‡ฌ๐Ÿ‡พ', + 'haiti': '๐Ÿ‡ญ๐Ÿ‡น', + 'honduras': '๐Ÿ‡ญ๐Ÿ‡ณ', + 'hong_kong': '๐Ÿ‡ญ๐Ÿ‡ฐ', + 'hungary': '๐Ÿ‡ญ๐Ÿ‡บ', + 'iceland': '๐Ÿ‡ฎ๐Ÿ‡ธ', + 'india': '๐Ÿ‡ฎ๐Ÿ‡ณ', + 'indonesia': '๐Ÿ‡ฎ๐Ÿ‡ฉ', + 'iran': '๐Ÿ‡ฎ๐Ÿ‡ท', + 'iraq': '๐Ÿ‡ฎ๐Ÿ‡ถ', + 'ireland': '๐Ÿ‡ฎ๐Ÿ‡ช', + 'isle_of_man': '๐Ÿ‡ฎ๐Ÿ‡ฒ', + 'israel': '๐Ÿ‡ฎ๐Ÿ‡ฑ', + 'it': '๐Ÿ‡ฎ๐Ÿ‡น', + 'cote_divoire': '๐Ÿ‡จ๐Ÿ‡ฎ', + 'jamaica': '๐Ÿ‡ฏ๐Ÿ‡ฒ', + 'jp': '๐Ÿ‡ฏ๐Ÿ‡ต', + 'jersey': '๐Ÿ‡ฏ๐Ÿ‡ช', + 'jordan': '๐Ÿ‡ฏ๐Ÿ‡ด', + 'kazakhstan': '๐Ÿ‡ฐ๐Ÿ‡ฟ', + 'kenya': '๐Ÿ‡ฐ๐Ÿ‡ช', + 'kiribati': '๐Ÿ‡ฐ๐Ÿ‡ฎ', + 'kosovo': '๐Ÿ‡ฝ๐Ÿ‡ฐ', + 'kuwait': '๐Ÿ‡ฐ๐Ÿ‡ผ', + 'kyrgyzstan': '๐Ÿ‡ฐ๐Ÿ‡ฌ', + 'laos': '๐Ÿ‡ฑ๐Ÿ‡ฆ', + 'latvia': '๐Ÿ‡ฑ๐Ÿ‡ป', + 'lebanon': '๐Ÿ‡ฑ๐Ÿ‡ง', + 'lesotho': '๐Ÿ‡ฑ๐Ÿ‡ธ', + 'liberia': '๐Ÿ‡ฑ๐Ÿ‡ท', + 'libya': '๐Ÿ‡ฑ๐Ÿ‡พ', + 'liechtenstein': '๐Ÿ‡ฑ๐Ÿ‡ฎ', + 'lithuania': '๐Ÿ‡ฑ๐Ÿ‡น', + 'luxembourg': '๐Ÿ‡ฑ๐Ÿ‡บ', + 'macau': '๐Ÿ‡ฒ๐Ÿ‡ด', + 'macedonia': '๐Ÿ‡ฒ๐Ÿ‡ฐ', + 'madagascar': '๐Ÿ‡ฒ๐Ÿ‡ฌ', + 'malawi': '๐Ÿ‡ฒ๐Ÿ‡ผ', + 'malaysia': '๐Ÿ‡ฒ๐Ÿ‡พ', + 'maldives': '๐Ÿ‡ฒ๐Ÿ‡ป', + 'mali': '๐Ÿ‡ฒ๐Ÿ‡ฑ', + 'malta': '๐Ÿ‡ฒ๐Ÿ‡น', + 'marshall_islands': '๐Ÿ‡ฒ๐Ÿ‡ญ', + 'martinique': '๐Ÿ‡ฒ๐Ÿ‡ถ', + 'mauritania': '๐Ÿ‡ฒ๐Ÿ‡ท', + 'mauritius': '๐Ÿ‡ฒ๐Ÿ‡บ', + 'mayotte': '๐Ÿ‡พ๐Ÿ‡น', + 'mexico': '๐Ÿ‡ฒ๐Ÿ‡ฝ', + 'micronesia': '๐Ÿ‡ซ๐Ÿ‡ฒ', + 'moldova': '๐Ÿ‡ฒ๐Ÿ‡ฉ', + 'monaco': '๐Ÿ‡ฒ๐Ÿ‡จ', + 'mongolia': '๐Ÿ‡ฒ๐Ÿ‡ณ', + 'montenegro': '๐Ÿ‡ฒ๐Ÿ‡ช', + 'montserrat': '๐Ÿ‡ฒ๐Ÿ‡ธ', + 'morocco': '๐Ÿ‡ฒ๐Ÿ‡ฆ', + 'mozambique': '๐Ÿ‡ฒ๐Ÿ‡ฟ', + 'myanmar': '๐Ÿ‡ฒ๐Ÿ‡ฒ', + 'namibia': '๐Ÿ‡ณ๐Ÿ‡ฆ', + 'nauru': '๐Ÿ‡ณ๐Ÿ‡ท', + 'nepal': '๐Ÿ‡ณ๐Ÿ‡ต', + 'netherlands': '๐Ÿ‡ณ๐Ÿ‡ฑ', + 'new_caledonia': '๐Ÿ‡ณ๐Ÿ‡จ', + 'new_zealand': '๐Ÿ‡ณ๐Ÿ‡ฟ', + 'nicaragua': '๐Ÿ‡ณ๐Ÿ‡ฎ', + 'niger': '๐Ÿ‡ณ๐Ÿ‡ช', + 'nigeria': '๐Ÿ‡ณ๐Ÿ‡ฌ', + 'niue': '๐Ÿ‡ณ๐Ÿ‡บ', + 'norfolk_island': '๐Ÿ‡ณ๐Ÿ‡ซ', + 'northern_mariana_islands': '๐Ÿ‡ฒ๐Ÿ‡ต', + 'north_korea': '๐Ÿ‡ฐ๐Ÿ‡ต', + 'norway': '๐Ÿ‡ณ๐Ÿ‡ด', + 'oman': '๐Ÿ‡ด๐Ÿ‡ฒ', + 'pakistan': '๐Ÿ‡ต๐Ÿ‡ฐ', + 'palau': '๐Ÿ‡ต๐Ÿ‡ผ', + 'palestinian_territories': '๐Ÿ‡ต๐Ÿ‡ธ', + 'panama': '๐Ÿ‡ต๐Ÿ‡ฆ', + 'papua_new_guinea': '๐Ÿ‡ต๐Ÿ‡ฌ', + 'paraguay': '๐Ÿ‡ต๐Ÿ‡พ', + 'peru': '๐Ÿ‡ต๐Ÿ‡ช', + 'philippines': '๐Ÿ‡ต๐Ÿ‡ญ', + 'pitcairn_islands': '๐Ÿ‡ต๐Ÿ‡ณ', + 'poland': '๐Ÿ‡ต๐Ÿ‡ฑ', + 'portugal': '๐Ÿ‡ต๐Ÿ‡น', + 'puerto_rico': '๐Ÿ‡ต๐Ÿ‡ท', + 'qatar': '๐Ÿ‡ถ๐Ÿ‡ฆ', + 'reunion': '๐Ÿ‡ท๐Ÿ‡ช', + 'romania': '๐Ÿ‡ท๐Ÿ‡ด', + 'ru': '๐Ÿ‡ท๐Ÿ‡บ', + 'rwanda': '๐Ÿ‡ท๐Ÿ‡ผ', + 'st_barthelemy': '๐Ÿ‡ง๐Ÿ‡ฑ', + 'st_helena': '๐Ÿ‡ธ๐Ÿ‡ญ', + 'st_kitts_nevis': '๐Ÿ‡ฐ๐Ÿ‡ณ', + 'st_lucia': '๐Ÿ‡ฑ๐Ÿ‡จ', + 'st_pierre_miquelon': '๐Ÿ‡ต๐Ÿ‡ฒ', + 'st_vincent_grenadines': '๐Ÿ‡ป๐Ÿ‡จ', + 'samoa': '๐Ÿ‡ผ๐Ÿ‡ธ', + 'san_marino': '๐Ÿ‡ธ๐Ÿ‡ฒ', + 'sao_tome_principe': '๐Ÿ‡ธ๐Ÿ‡น', + 'saudi_arabia': '๐Ÿ‡ธ๐Ÿ‡ฆ', + 'senegal': '๐Ÿ‡ธ๐Ÿ‡ณ', + 'serbia': '๐Ÿ‡ท๐Ÿ‡ธ', + 'seychelles': '๐Ÿ‡ธ๐Ÿ‡จ', + 'sierra_leone': '๐Ÿ‡ธ๐Ÿ‡ฑ', + 'singapore': '๐Ÿ‡ธ๐Ÿ‡ฌ', + 'sint_maarten': '๐Ÿ‡ธ๐Ÿ‡ฝ', + 'slovakia': '๐Ÿ‡ธ๐Ÿ‡ฐ', + 'slovenia': '๐Ÿ‡ธ๐Ÿ‡ฎ', + 'solomon_islands': '๐Ÿ‡ธ๐Ÿ‡ง', + 'somalia': '๐Ÿ‡ธ๐Ÿ‡ด', + 'south_africa': '๐Ÿ‡ฟ๐Ÿ‡ฆ', + 'south_georgia_south_sandwich_islands': '๐Ÿ‡ฌ๐Ÿ‡ธ', + 'kr': '๐Ÿ‡ฐ๐Ÿ‡ท', + 'south_sudan': '๐Ÿ‡ธ๐Ÿ‡ธ', + 'es': '๐Ÿ‡ช๐Ÿ‡ธ', + 'sri_lanka': '๐Ÿ‡ฑ๐Ÿ‡ฐ', + 'sudan': '๐Ÿ‡ธ๐Ÿ‡ฉ', + 'suriname': '๐Ÿ‡ธ๐Ÿ‡ท', + 'swaziland': '๐Ÿ‡ธ๐Ÿ‡ฟ', + 'sweden': '๐Ÿ‡ธ๐Ÿ‡ช', + 'switzerland': '๐Ÿ‡จ๐Ÿ‡ญ', + 'syria': '๐Ÿ‡ธ๐Ÿ‡พ', + 'taiwan': '๐Ÿ‡น๐Ÿ‡ผ', + 'tajikistan': '๐Ÿ‡น๐Ÿ‡ฏ', + 'tanzania': '๐Ÿ‡น๐Ÿ‡ฟ', + 'thailand': '๐Ÿ‡น๐Ÿ‡ญ', + 'timor_leste': '๐Ÿ‡น๐Ÿ‡ฑ', + 'togo': '๐Ÿ‡น๐Ÿ‡ฌ', + 'tokelau': '๐Ÿ‡น๐Ÿ‡ฐ', + 'tonga': '๐Ÿ‡น๐Ÿ‡ด', + 'trinidad_tobago': '๐Ÿ‡น๐Ÿ‡น', + 'tunisia': '๐Ÿ‡น๐Ÿ‡ณ', + 'tr': '๐Ÿ‡น๐Ÿ‡ท', + 'turkmenistan': '๐Ÿ‡น๐Ÿ‡ฒ', + 'turks_caicos_islands': '๐Ÿ‡น๐Ÿ‡จ', + 'tuvalu': '๐Ÿ‡น๐Ÿ‡ป', + 'uganda': '๐Ÿ‡บ๐Ÿ‡ฌ', + 'ukraine': '๐Ÿ‡บ๐Ÿ‡ฆ', + 'united_arab_emirates': '๐Ÿ‡ฆ๐Ÿ‡ช', + 'uk': '๐Ÿ‡ฌ๐Ÿ‡ง', + 'england': '๐Ÿด๓ ง๓ ข๓ ฅ๓ ฎ๓ ง๓ ฟ', + 'scotland': '๐Ÿด๓ ง๓ ข๓ ณ๓ ฃ๓ ด๓ ฟ', + 'wales': '๐Ÿด๓ ง๓ ข๓ ท๓ ฌ๓ ณ๓ ฟ', + 'us': '๐Ÿ‡บ๐Ÿ‡ธ', + 'us_virgin_islands': '๐Ÿ‡ป๐Ÿ‡ฎ', + 'uruguay': '๐Ÿ‡บ๐Ÿ‡พ', + 'uzbekistan': '๐Ÿ‡บ๐Ÿ‡ฟ', + 'vanuatu': '๐Ÿ‡ป๐Ÿ‡บ', + 'vatican_city': '๐Ÿ‡ป๐Ÿ‡ฆ', + 'venezuela': '๐Ÿ‡ป๐Ÿ‡ช', + 'vietnam': '๐Ÿ‡ป๐Ÿ‡ณ', + 'wallis_futuna': '๐Ÿ‡ผ๐Ÿ‡ซ', + 'western_sahara': '๐Ÿ‡ช๐Ÿ‡ญ', + 'yemen': '๐Ÿ‡พ๐Ÿ‡ช', + 'zambia': '๐Ÿ‡ฟ๐Ÿ‡ฒ', + 'zimbabwe': '๐Ÿ‡ฟ๐Ÿ‡ผ', +}; diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/extension_set.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/extension_set.dart new file mode 100644 index 0000000000..eadda7bc05 --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/extension_set.dart @@ -0,0 +1,64 @@ +import 'block_parser.dart'; +import 'inline_parser.dart'; + +/// ExtensionSets provide a simple grouping mechanism for common Markdown +/// flavors. +/// +/// For example, the [gitHubFlavored] set of syntax extensions allows users to +/// output HTML from their Markdown in a similar fashion to GitHub's parsing. +class ExtensionSet { + ExtensionSet(this.blockSyntaxes, this.inlineSyntaxes); + + /// The [ExtensionSet.none] extension set renders Markdown similar to + /// [Markdown.pl]. + /// + /// However, this set does not render _exactly_ the same as Markdown.pl; + /// rather it is more-or-less the CommonMark standard of Markdown, without + /// fenced code blocks, or inline HTML. + /// + /// [Markdown.pl]: http://daringfireball.net/projects/markdown/syntax + static final ExtensionSet none = ExtensionSet([], []); + + /// The [commonMark] extension set is close to compliance with [CommonMark]. + /// + /// [CommonMark]: http://commonmark.org/ + static final ExtensionSet commonMark = + ExtensionSet([const FencedCodeBlockSyntax()], [InlineHtmlSyntax()]); + + /// The [gitHubWeb] extension set renders Markdown similarly to GitHub. + /// + /// This is different from the [gitHubFlavored] extension set in that GitHub + /// actually renders HTML different from straight [GitHub flavored Markdown]. + /// + /// (The only difference currently is that [gitHubWeb] renders headers with + /// linkable IDs.) + /// + /// [GitHub flavored Markdown]: https://github.github.com/gfm/ + static final ExtensionSet gitHubWeb = ExtensionSet([ + const FencedCodeBlockSyntax(), + const HeaderWithIdSyntax(), + const SetextHeaderWithIdSyntax(), + const TableSyntax() + ], [ + InlineHtmlSyntax(), + StrikethroughSyntax(), + EmojiSyntax(), + AutolinkExtensionSyntax(), + ]); + + /// The [gitHubFlavored] extension set is close to compliance with the [GitHub + /// flavored Markdown spec]. + /// + /// [GitHub flavored Markdown]: https://github.github.com/gfm/ + static final ExtensionSet gitHubFlavored = ExtensionSet([ + const FencedCodeBlockSyntax(), + const TableSyntax() + ], [ + InlineHtmlSyntax(), + StrikethroughSyntax(), + AutolinkExtensionSyntax(), + ]); + + final List blockSyntaxes; + final List inlineSyntaxes; +} diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/html_renderer.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/html_renderer.dart new file mode 100644 index 0000000000..d6630e297c --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/html_renderer.dart @@ -0,0 +1,121 @@ +// 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 'ast.dart'; +import 'block_parser.dart'; +import 'document.dart'; +import 'extension_set.dart'; +import 'inline_parser.dart'; + +/// Converts the given string of Markdown to HTML. +String markdownToHtml(String markdown, + {Iterable? blockSyntaxes, + Iterable? inlineSyntaxes, + ExtensionSet? extensionSet, + Resolver? linkResolver, + Resolver? imageLinkResolver, + bool inlineOnly = false}) { + final document = Document( + blockSyntaxes: blockSyntaxes, + inlineSyntaxes: inlineSyntaxes, + extensionSet: extensionSet, + linkResolver: linkResolver, + imageLinkResolver: imageLinkResolver); + + if (inlineOnly) { + return renderToHtml(document.parseInline(markdown)!); + } + + // Replace windows line endings with unix line endings, and split. + final lines = markdown.replaceAll('\r\n', '\n').split('\n'); + + return '${renderToHtml(document.parseLines(lines))}\n'; +} + +/// Renders [nodes] to HTML. +String renderToHtml(List nodes) => HtmlRenderer().render(nodes); + +/// Translates a parsed AST to HTML. +class HtmlRenderer implements NodeVisitor { + HtmlRenderer(); + + static final _blockTags = RegExp('blockquote|h1|h2|h3|h4|h5|h6|hr|p|pre'); + + late StringBuffer buffer; + late Set uniqueIds; + + String render(List nodes) { + buffer = StringBuffer(); + uniqueIds = {}; + + for (final node in nodes) { + node.accept(this); + } + + return buffer.toString(); + } + + @override + void visitText(Text text) { + buffer.write(text.text); + } + + @override + bool visitElementBefore(Element element) { + // Hackish. Separate block-level elements with newlines. + if (buffer.isNotEmpty && _blockTags.firstMatch(element.tag) != null) { + buffer.write('\n'); + } + + buffer.write('<${element.tag}'); + + // Sort the keys so that we generate stable output. + final attributeNames = element.attributes.keys.toList() + ..sort((a, b) => a.compareTo(b)); + + for (final name in attributeNames) { + buffer.write(' $name="${element.attributes[name]}"'); + } + + // attach header anchor ids generated from text + if (element.generatedId != null) { + buffer.write(' id="${uniquifyId(element.generatedId!)}"'); + } + + if (element.isEmpty) { + // Empty element like
. + buffer.write(' />'); + + if (element.tag == 'br') { + buffer.write('\n'); + } + + return false; + } else { + buffer.write('>'); + return true; + } + } + + @override + void visitElementAfter(Element element) { + buffer.write(''); + } + + /// Uniquifies an id generated from text. + String uniquifyId(String id) { + if (!uniqueIds.contains(id)) { + uniqueIds.add(id); + return id; + } + + var suffix = 2; + var suffixedId = '$id-$suffix'; + while (uniqueIds.contains(suffixedId)) { + suffixedId = '$id-${suffix++}'; + } + uniqueIds.add(suffixedId); + return suffixedId; + } +} diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/inline_parser.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/inline_parser.dart new file mode 100644 index 0000000000..0f15a99e4d --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/inline_parser.dart @@ -0,0 +1,1271 @@ +// 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 'package:charcode/charcode.dart'; + +import 'ast.dart'; +import 'document.dart'; +import 'emojis.dart'; +import 'util.dart'; + +/// Maintains the internal state needed to parse inline span elements in +/// Markdown. +class InlineParser { + InlineParser(this.source, this.document) : _stack = [] { + // User specified syntaxes are the first syntaxes to be evaluated. + syntaxes.addAll(document.inlineSyntaxes); + + final documentHasCustomInlineSyntaxes = document.inlineSyntaxes + .any((s) => !document.extensionSet.inlineSyntaxes.contains(s)); + + // This first RegExp matches plain text to accelerate parsing. It's written + // so that it does not match any prefix of any following syntaxes. Most + // Markdown is plain text, so it's faster to match one RegExp per 'word' + // rather than fail to match all the following RegExps at each non-syntax + // character position. + if (documentHasCustomInlineSyntaxes) { + // We should be less aggressive in blowing past "words". + syntaxes.add(TextSyntax(r'[A-Za-z0-9]+(?=\s)')); + } else { + syntaxes.add(TextSyntax(r'[ \tA-Za-z0-9]*[A-Za-z0-9](?=\s)')); + } + + syntaxes + ..addAll(_defaultSyntaxes) + // Custom link resolvers go after the generic text syntax. + ..insertAll(1, [ + LinkSyntax(linkResolver: document.linkResolver), + ImageSyntax(linkResolver: document.imageLinkResolver) + ]); + } + + static final List _defaultSyntaxes = + List.unmodifiable([ + EmailAutolinkSyntax(), + AutolinkSyntax(), + LineBreakSyntax(), + LinkSyntax(), + ImageSyntax(), + // Allow any punctuation to be escaped. + EscapeSyntax(), + // "*" surrounded by spaces is left alone. + TextSyntax(r' \* '), + // "_" surrounded by spaces is left alone. + TextSyntax(r' _ '), + // Parse "**strong**" and "*emphasis*" tags. + TagSyntax(r'\*+', requiresDelimiterRun: true), + // Parse "__strong__" and "_emphasis_" tags. + TagSyntax(r'_+', requiresDelimiterRun: true), + CodeSyntax(), + // We will add the LinkSyntax once we know about the specific link resolver. + ]); + + /// The string of Markdown being parsed. + final String source; + + /// The Markdown document this parser is parsing. + final Document document; + + final List syntaxes = []; + + /// The current read position. + int pos = 0; + + /// Starting position of the last unconsumed text. + int start = 0; + + final List _stack; + + List? parse() { + // Make a fake top tag to hold the results. + _stack.add(TagState(0, 0, null, null)); + + while (!isDone) { + // See if any of the current tags on the stack match. This takes + // priority over other possible matches. + if (_stack.reversed + .any((state) => state.syntax != null && state.tryMatch(this))) { + continue; + } + + // See if the current text matches any defined markdown syntax. + if (syntaxes.any((syntax) => syntax.tryMatch(this))) { + continue; + } + + // If we got here, it's just text. + advanceBy(1); + } + + // Unwind any unmatched tags and get the results. + return _stack[0].close(this, null); + } + + int charAt(int index) => source.codeUnitAt(index); + + void writeText() { + writeTextRange(start, pos); + start = pos; + } + + void writeTextRange(int start, int end) { + if (end <= start) { + return; + } + + final text = source.substring(start, end); + final nodes = _stack.last.children; + + // If the previous node is text too, just append. + if (nodes.isNotEmpty && nodes.last is Text) { + final textNode = nodes.last as Text; + nodes[nodes.length - 1] = Text('${textNode.text}$text'); + } else { + nodes.add(Text(text)); + } + } + + /// Add [node] to the last [TagState] on the stack. + void addNode(Node node) { + _stack.last.children.add(node); + } + + /// Push [state] onto the stack of [TagState]s. + void openTag(TagState state) => _stack.add(state); + + bool get isDone => pos == source.length; + + void advanceBy(int length) { + pos += length; + } + + void consume(int length) { + pos += length; + start = pos; + } +} + +/// Represents one kind of Markdown tag that can be parsed. +abstract class InlineSyntax { + InlineSyntax(String pattern) : pattern = RegExp(pattern, multiLine: true); + + final RegExp pattern; + + /// Tries to match at the parser's current position. + /// + /// The parser's position can be overriden with [startMatchPos]. + /// Returns whether or not the pattern successfully matched. + bool tryMatch(InlineParser parser, [int? startMatchPos]) { + startMatchPos ??= parser.pos; + + final startMatch = pattern.matchAsPrefix(parser.source, startMatchPos); + if (startMatch == null) { + return false; + } + + // Write any existing plain text up to this point. + parser.writeText(); + + if (onMatch(parser, startMatch)) { + parser.consume(startMatch[0]!.length); + } + return true; + } + + /// Processes [match], adding nodes to [parser] and possibly advancing + /// [parser]. + /// + /// Returns whether the caller should advance [parser] by `match[0].length`. + bool onMatch(InlineParser parser, Match match); +} + +/// Represents a hard line break. +class LineBreakSyntax extends InlineSyntax { + LineBreakSyntax() : super(r'(?:\\| +)\n'); + + /// Create a void
element. + @override + bool onMatch(InlineParser parser, Match match) { + parser.addNode(Element.empty('br')); + return true; + } +} + +/// Matches stuff that should just be passed through as straight text. +class TextSyntax extends InlineSyntax { + TextSyntax(String pattern, {String? sub}) + : substitute = sub, + super(pattern); + + final String? substitute; + + @override + bool onMatch(InlineParser parser, Match match) { + if (substitute == null) { + // Just use the original matched text. + parser.advanceBy(match[0]!.length); + return false; + } + + // Insert the substitution. + parser.addNode(Text(substitute!)); + return true; + } +} + +/// Escape punctuation preceded by a backslash. +class EscapeSyntax extends InlineSyntax { + EscapeSyntax() : super(r'''\\[!"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~]'''); + + @override + bool onMatch(InlineParser parser, Match match) { + // Insert the substitution. + parser.addNode(Text(match[0]![1])); + return true; + } +} + +/// Leave inline HTML tags alone, from +/// [CommonMark 0.28](http://spec.commonmark.org/0.28/#raw-html). +/// +/// This is not actually a good definition (nor CommonMark's) of an HTML tag, +/// but it is fast. It will leave text like `]*)?>'); +} + +/// Matches autolinks like ``. +/// +/// See . +class EmailAutolinkSyntax extends InlineSyntax { + EmailAutolinkSyntax() : super('<($_email)>'); + + static const _email = + r'''[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}''' + r'''[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*'''; + + @override + bool onMatch(InlineParser parser, Match match) { + final url = match[1]!; + final anchor = Element.text('a', escapeHtml(url)); + anchor.attributes['href'] = Uri.encodeFull('mailto:$url'); + parser.addNode(anchor); + + return true; + } +} + +/// Matches autolinks like ``. +class AutolinkSyntax extends InlineSyntax { + AutolinkSyntax() : super(r'<(([a-zA-Z][a-zA-Z\-\+\.]+):(?://)?[^\s>]*)>'); + + @override + bool onMatch(InlineParser parser, Match match) { + final url = match[1]!; + final anchor = Element.text('a', escapeHtml(url)); + anchor.attributes['href'] = Uri.encodeFull(url); + parser.addNode(anchor); + + return true; + } +} + +/// Matches autolinks like `http://foo.com`. +class AutolinkExtensionSyntax extends InlineSyntax { + AutolinkExtensionSyntax() : super('$start(($scheme)($domain)($path))'); + + /// Broken up parts of the autolink regex for reusability and readability + + // Autolinks can only come at the beginning of a line, after whitespace, or + // any of the delimiting characters *, _, ~, and (. + static const start = r'(?:^|[\s*_~(>])'; + // An extended url autolink will be recognized when one of the schemes + // http://, https://, or ftp://, followed by a valid domain + static const scheme = r'(?:(?:https?|ftp):\/\/|www\.)'; + // A valid domain consists of alphanumeric characters, underscores (_), + // hyphens (-) and periods (.). There must be at least one period, and no + // underscores may be present in the last two segments of the domain. + static const domainPart = r'\w\-'; + static const domain = '[$domainPart][$domainPart.]+'; + // A valid domain consists of alphanumeric characters, underscores (_), + // hyphens (-) and periods (.). + static const path = r'[^\s<]*'; + // Trailing punctuation (specifically, ?, !, ., ,, :, *, _, and ~) will not + // be considered part of the autolink + static const truncatingPunctuationPositive = r'[?!.,:*_~]'; + + static final regExpTrailingPunc = + RegExp('$truncatingPunctuationPositive*' r'$'); + static final regExpEndsWithColon = RegExp(r'\&[a-zA-Z0-9]+;$'); + static final regExpWhiteSpace = RegExp(r'\s'); + + @override + bool tryMatch(InlineParser parser, [int? startMatchPos]) { + return super.tryMatch(parser, parser.pos > 0 ? parser.pos - 1 : 0); + } + + @override + bool onMatch(InlineParser parser, Match match) { + var url = match[1]!; + var href = url; + var matchLength = url.length; + + if (url[0] == '>' || url.startsWith(regExpWhiteSpace)) { + url = url.substring(1, url.length - 1); + href = href.substring(1, href.length - 1); + parser.pos++; + matchLength--; + } + + // Prevent accidental standard autolink matches + if (url.endsWith('>') && parser.source[parser.pos - 1] == '<') { + return false; + } + + // When an autolink ends in ), we scan the entire autolink for the total + // number of parentheses. If there is a greater number of closing + // parentheses than opening ones, we donโ€™t consider the last character + // part of the autolink, in order to facilitate including an autolink + // inside a parenthesis: + // https://github.github.com/gfm/#example-600 + if (url.endsWith(')')) { + final opening = _countChars(url, '('); + final closing = _countChars(url, ')'); + + if (closing > opening) { + url = url.substring(0, url.length - 1); + href = href.substring(0, href.length - 1); + matchLength--; + } + } + + // Trailing punctuation (specifically, ?, !, ., ,, :, *, _, and ~) will + // not be considered part of the autolink, though they may be included + // in the interior of the link: + // https://github.github.com/gfm/#example-599 + final trailingPunc = regExpTrailingPunc.firstMatch(url); + if (trailingPunc != null) { + url = url.substring(0, url.length - trailingPunc[0]!.length); + href = href.substring(0, href.length - trailingPunc[0]!.length); + matchLength -= trailingPunc[0]!.length; + } + + // If an autolink ends in a semicolon (;), we check to see if it appears + // to resemble an + // [entity reference](https://github.github.com/gfm/#entity-references); + // if the preceding text is & followed by one or more alphanumeric + // characters. If so, it is excluded from the autolink: + // https://github.github.com/gfm/#example-602 + if (url.endsWith(';')) { + final entityRef = regExpEndsWithColon.firstMatch(url); + if (entityRef != null) { + // Strip out HTML entity reference + url = url.substring(0, url.length - entityRef[0]!.length); + href = href.substring(0, href.length - entityRef[0]!.length); + matchLength -= entityRef[0]!.length; + } + } + + // The scheme http will be inserted automatically + if (!href.startsWith('http://') && + !href.startsWith('https://') && + !href.startsWith('ftp://')) { + href = 'http://$href'; + } + + final anchor = Element.text('a', escapeHtml(url)); + anchor.attributes['href'] = Uri.encodeFull(href); + parser + ..addNode(anchor) + ..consume(matchLength); + return false; + } + + int _countChars(String input, String char) { + var count = 0; + + for (var i = 0; i < input.length; i++) { + if (input[i] == char) { + count++; + } + } + + return count; + } +} + +class _DelimiterRun { + _DelimiterRun._( + {this.char, + this.length, + this.isLeftFlanking, + this.isRightFlanking, + this.isPrecededByPunctuation, + this.isFollowedByPunctuation}); + + static const String punctuation = r'''!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~'''; + // TODO(srawlins): Unicode whitespace + static const String whitespace = ' \t\r\n'; + + final int? char; + final int? length; + final bool? isLeftFlanking; + final bool? isRightFlanking; + final bool? isPrecededByPunctuation; + final bool? isFollowedByPunctuation; + + // ignore: prefer_constructors_over_static_methods + static _DelimiterRun? tryParse( + InlineParser parser, int runStart, int runEnd) { + bool leftFlanking, + rightFlanking, + precededByPunctuation, + followedByPunctuation; + String preceding, following; + if (runStart == 0) { + rightFlanking = false; + preceding = '\n'; + } else { + preceding = parser.source.substring(runStart - 1, runStart); + } + precededByPunctuation = punctuation.contains(preceding); + + if (runEnd == parser.source.length - 1) { + leftFlanking = false; + following = '\n'; + } else { + following = parser.source.substring(runEnd + 1, runEnd + 2); + } + followedByPunctuation = punctuation.contains(following); + + // http://spec.commonmark.org/0.28/#left-flanking-delimiter-run + if (whitespace.contains(following)) { + leftFlanking = false; + } else { + leftFlanking = !followedByPunctuation || + whitespace.contains(preceding) || + precededByPunctuation; + } + + // http://spec.commonmark.org/0.28/#right-flanking-delimiter-run + if (whitespace.contains(preceding)) { + rightFlanking = false; + } else { + rightFlanking = !precededByPunctuation || + whitespace.contains(following) || + followedByPunctuation; + } + + if (!leftFlanking && !rightFlanking) { + // Could not parse a delimiter run. + return null; + } + + return _DelimiterRun._( + char: parser.charAt(runStart), + length: runEnd - runStart + 1, + isLeftFlanking: leftFlanking, + isRightFlanking: rightFlanking, + isPrecededByPunctuation: precededByPunctuation, + isFollowedByPunctuation: followedByPunctuation); + } + + @override + String toString() => + ''; + + // Whether a delimiter in this run can open emphasis or strong emphasis. + bool get canOpen => + isLeftFlanking! && + (char == $asterisk || !isRightFlanking! || isPrecededByPunctuation!); + + // Whether a delimiter in this run can close emphasis or strong emphasis. + bool get canClose => + isRightFlanking! && + (char == $asterisk || !isLeftFlanking! || isFollowedByPunctuation!); +} + +/// Matches syntax that has a pair of tags and becomes an element, like `*` for +/// ``. Allows nested tags. +class TagSyntax extends InlineSyntax { + TagSyntax(String pattern, {String? end, this.requiresDelimiterRun = false}) + : endPattern = RegExp((end != null) ? end : pattern, multiLine: true), + super(pattern); + + final RegExp endPattern; + + /// Whether this is parsed according to the same nesting rules as [emphasis + /// delimiters][]. + /// + /// [emphasis delimiters]: http://spec.commonmark.org/0.28/#can-open-emphasis + final bool requiresDelimiterRun; + + @override + bool onMatch(InlineParser parser, Match match) { + final runLength = match.group(0)!.length; + final matchStart = parser.pos; + final matchEnd = parser.pos + runLength - 1; + if (!requiresDelimiterRun) { + parser.openTag(TagState(parser.pos, matchEnd + 1, this, null)); + return true; + } + + final delimiterRun = _DelimiterRun.tryParse(parser, matchStart, matchEnd); + if (delimiterRun != null && delimiterRun.canOpen) { + parser.openTag(TagState(parser.pos, matchEnd + 1, this, delimiterRun)); + return true; + } else { + parser.advanceBy(runLength); + return false; + } + } + + bool onMatchEnd(InlineParser parser, Match match, TagState state) { + final runLength = match.group(0)!.length; + final matchStart = parser.pos; + final matchEnd = parser.pos + runLength - 1; + final openingRunLength = state.endPos - state.startPos; + final delimiterRun = _DelimiterRun.tryParse(parser, matchStart, matchEnd); + + if (openingRunLength == 1 && runLength == 1) { + parser.addNode(Element('em', state.children)); + } else if (openingRunLength == 1 && runLength > 1) { + parser + ..addNode(Element('em', state.children)) + ..pos = parser.pos - (runLength - 1) + ..start = parser.pos; + } else if (openingRunLength > 1 && runLength == 1) { + parser + ..openTag( + TagState(state.startPos, state.endPos - 1, this, delimiterRun)) + ..addNode(Element('em', state.children)); + } else if (openingRunLength == 2 && runLength == 2) { + parser.addNode(Element('strong', state.children)); + } else if (openingRunLength == 2 && runLength > 2) { + parser + ..addNode(Element('strong', state.children)) + ..pos = parser.pos - (runLength - 2) + ..start = parser.pos; + } else if (openingRunLength > 2 && runLength == 2) { + parser + ..openTag( + TagState(state.startPos, state.endPos - 2, this, delimiterRun)) + ..addNode(Element('strong', state.children)); + } else if (openingRunLength > 2 && runLength > 2) { + parser + ..openTag( + TagState(state.startPos, state.endPos - 2, this, delimiterRun)) + ..addNode(Element('strong', state.children)) + ..pos = parser.pos - (runLength - 2) + ..start = parser.pos; + } + + return true; + } +} + +/// Matches strikethrough syntax according to the GFM spec. +class StrikethroughSyntax extends TagSyntax { + StrikethroughSyntax() : super('~+', requiresDelimiterRun: true); + + @override + bool onMatchEnd(InlineParser parser, Match match, TagState state) { + final runLength = match.group(0)!.length; + final matchStart = parser.pos; + final matchEnd = parser.pos + runLength - 1; + final delimiterRun = _DelimiterRun.tryParse(parser, matchStart, matchEnd)!; + if (!delimiterRun.isRightFlanking!) { + return false; + } + + parser.addNode(Element('del', state.children)); + return true; + } +} + +/// Matches links like `[blah][label]` and `[blah](url)`. +class LinkSyntax extends TagSyntax { + LinkSyntax({Resolver? linkResolver, String pattern = r'\['}) + : linkResolver = (linkResolver ?? (_, [__]) => null), + super(pattern, end: r'\]'); + + static final _entirelyWhitespacePattern = RegExp(r'^\s*$'); + + final Resolver linkResolver; + + // The pending [TagState]s, all together, are "active" or "inactive" based on + // whether a link element has just been parsed. + // + // Links cannot be nested, so we must "deactivate" any pending ones. For + // example, take the following text: + // + // Text [link and [more](links)](links). + // + // Once we have parsed `Text [`, there is one (pending) link in the state + // stack. It is, by default, active. Once we parse the next possible link, + // `[more](links)`, as a real link, we must deactive the pending links (just + // the one, in this case). + var _pendingStatesAreActive = true; + + @override + bool onMatch(InlineParser parser, Match match) { + final matched = super.onMatch(parser, match); + if (!matched) { + return false; + } + + _pendingStatesAreActive = true; + + return true; + } + + @override + bool onMatchEnd(InlineParser parser, Match match, TagState state) { + if (!_pendingStatesAreActive) { + return false; + } + + final text = parser.source.substring(state.endPos, parser.pos); + // The current character is the `]` that closed the link text. Examine the + // next character, to determine what type of link we might have (a '(' + // means a possible inline link; otherwise a possible reference link). + if (parser.pos + 1 >= parser.source.length) { + // In this case, the Markdown document may have ended with a shortcut + // reference link. + + return _tryAddReferenceLink(parser, state, text); + } + // Peek at the next character; don't advance, so as to avoid later stepping + // backward. + final char = parser.charAt(parser.pos + 1); + + if (char == $lparen) { + // Maybe an inline link, like `[text](destination)`. + parser.advanceBy(1); + final leftParenIndex = parser.pos; + final inlineLink = _parseInlineLink(parser); + if (inlineLink != null) { + return _tryAddInlineLink(parser, state, inlineLink); + } + + // Reset the parser position. + parser + ..pos = leftParenIndex + + // At this point, we've matched `[...](`, but that `(` did not pan out + // to be an inline link. We must now check if `[...]` is simply a + // shortcut reference link. + ..advanceBy(-1); + return _tryAddReferenceLink(parser, state, text); + } + + if (char == $lbracket) { + parser.advanceBy(1); + // At this point, we've matched `[...][`. Maybe a *full* reference link, + // like `[foo][bar]` or a *collapsed* reference link, like `[foo][]`. + if (parser.pos + 1 < parser.source.length && + parser.charAt(parser.pos + 1) == $rbracket) { + // That opening `[` is not actually part of the link. Maybe a + // *shortcut* reference link (followed by a `[`). + parser.advanceBy(1); + return _tryAddReferenceLink(parser, state, text); + } + final label = _parseReferenceLinkLabel(parser); + if (label != null) { + return _tryAddReferenceLink(parser, state, label); + } + return false; + } + + // The link text (inside `[...]`) was not followed with a opening `(` nor + // an opening `[`. Perhaps just a simple shortcut reference link (`[...]`). + + return _tryAddReferenceLink(parser, state, text); + } + + /// Resolve a possible reference link. + /// + /// Uses [linkReferences], [linkResolver], and [_createNode] to try to + /// resolve [label] and [state] into a [Node]. If [label] is defined in + /// [linkReferences] or can be resolved by [linkResolver], returns a [Node] + /// that links to the resolved URL. + /// + /// Otherwise, returns `null`. + /// + /// [label] does not need to be normalized. + Node? _resolveReferenceLink( + String label, TagState state, Map linkReferences) { + final normalizedLabel = label.toLowerCase(); + final linkReference = linkReferences[normalizedLabel]; + if (linkReference != null) { + return _createNode(state, linkReference.destination, linkReference.title); + } else { + // This link has no reference definition. But we allow users of the + // library to specify a custom resolver function ([linkResolver]) that + // may choose to handle this. Otherwise, it's just treated as plain + // text. + + // Normally, label text does not get parsed as inline Markdown. However, + // for the benefit of the link resolver, we need to at least escape + // brackets, so that, e.g. a link resolver can receive `[\[\]]` as `[]`. + return linkResolver(label + .replaceAll(r'\\', r'\') + .replaceAll(r'\[', '[') + .replaceAll(r'\]', ']')); + } + } + + /// Create the node represented by a Markdown link. + Node _createNode(TagState state, String destination, String? title) { + final element = Element('a', state.children); + element.attributes['href'] = escapeAttribute(destination); + if (title != null && title.isNotEmpty) { + element.attributes['title'] = escapeAttribute(title); + } + return element; + } + + // Add a reference link node to [parser]'s AST. + // + // Returns whether the link was added successfully. + bool _tryAddReferenceLink(InlineParser parser, TagState state, String label) { + final element = + _resolveReferenceLink(label, state, parser.document.linkReferences); + if (element == null) { + return false; + } + parser + ..addNode(element) + ..start = parser.pos; + _pendingStatesAreActive = false; + return true; + } + + // Add an inline link node to [parser]'s AST. + // + // Returns whether the link was added successfully. + bool _tryAddInlineLink(InlineParser parser, TagState state, InlineLink link) { + final element = _createNode(state, link.destination, link.title); + parser + ..addNode(element) + ..start = parser.pos; + _pendingStatesAreActive = false; + return true; + } + + /// Parse a reference link label at the current position. + /// + /// Specifically, [parser.pos] is expected to be pointing at the `[` which + /// opens the link label. + /// + /// Returns the label if it could be parsed, or `null` if not. + String? _parseReferenceLinkLabel(InlineParser parser) { + // Walk past the opening `[`. + parser.advanceBy(1); + if (parser.isDone) { + return null; + } + + final buffer = StringBuffer(); + while (true) { + final char = parser.charAt(parser.pos); + if (char == $backslash) { + parser.advanceBy(1); + final next = parser.charAt(parser.pos); + if (next != $backslash && next != $rbracket) { + buffer.writeCharCode(char); + } + buffer.writeCharCode(next); + } else if (char == $rbracket) { + break; + } else { + buffer.writeCharCode(char); + } + parser.advanceBy(1); + if (parser.isDone) { + return null; + } + // TODO(srawlins): only check 999 characters, for performance reasons? + } + + final label = buffer.toString(); + + // A link label must contain at least one non-whitespace character. + if (_entirelyWhitespacePattern.hasMatch(label)) { + return null; + } + + return label; + } + + /// Parse an inline [InlineLink] at the current position. + /// + /// At this point, we have parsed a link's (or image's) opening `[`, and then + /// a matching closing `]`, and [parser.pos] is pointing at an opening `(`. + /// This method will then attempt to parse a link destination wrapped in `<>`, + /// such as `()`, or a bare link destination, such as + /// `(http://url)`, or a link destination with a title, such as + /// `(http://url "title")`. + /// + /// Returns the [InlineLink] if one was parsed, or `null` if not. + InlineLink? _parseInlineLink(InlineParser parser) { + // Start walking to the character just after the opening `(`. + parser.advanceBy(1); + + _moveThroughWhitespace(parser); + if (parser.isDone) { + return null; // EOF. Not a link. + } + + if (parser.charAt(parser.pos) == $lt) { + // Maybe a `<...>`-enclosed link destination. + return _parseInlineBracketedLink(parser); + } else { + return _parseInlineBareDestinationLink(parser); + } + } + + /// Parse an inline link with a bracketed destination (a destination wrapped + /// in `<...>`). The current position of the parser must be the first + /// character of the destination. + InlineLink? _parseInlineBracketedLink(InlineParser parser) { + parser.advanceBy(1); + + final buffer = StringBuffer(); + while (true) { + final char = parser.charAt(parser.pos); + if (char == $backslash) { + parser.advanceBy(1); + final next = parser.charAt(parser.pos); + if (char == $space || char == $lf || char == $cr || char == $ff) { + // Not a link (no whitespace allowed within `<...>`). + return null; + } + // TODO: Follow the backslash spec better here. + // http://spec.commonmark.org/0.28/#backslash-escapes + if (next != $backslash && next != $gt) { + buffer.writeCharCode(char); + } + buffer.writeCharCode(next); + } else if (char == $space || char == $lf || char == $cr || char == $ff) { + // Not a link (no whitespace allowed within `<...>`). + return null; + } else if (char == $gt) { + break; + } else { + buffer.writeCharCode(char); + } + parser.advanceBy(1); + if (parser.isDone) { + return null; + } + } + final destination = buffer.toString(); + + parser.advanceBy(1); + final char = parser.charAt(parser.pos); + if (char == $space || char == $lf || char == $cr || char == $ff) { + final title = _parseTitle(parser); + if (title == null && parser.charAt(parser.pos) != $rparen) { + // This looked like an inline link, until we found this $space + // followed by mystery characters; no longer a link. + return null; + } + return InlineLink(destination, title: title); + } else if (char == $rparen) { + return InlineLink(destination); + } else { + // We parsed something like `[foo](X`. Not a link. + return null; + } + } + + /// Parse an inline link with a "bare" destination (a destination _not_ + /// wrapped in `<...>`). The current position of the parser must be the first + /// character of the destination. + InlineLink? _parseInlineBareDestinationLink(InlineParser parser) { + // According to + // [CommonMark](http://spec.commonmark.org/0.28/#link-destination): + // + // > A link destination consists of [...] a nonempty sequence of + // > characters [...], and includes parentheses only if (a) they are + // > backslash-escaped or (b) they are part of a balanced pair of + // > unescaped parentheses. + // + // We need to count the open parens. We start with 1 for the paren that + // opened the destination. + var parenCount = 1; + final buffer = StringBuffer(); + + while (true) { + final char = parser.charAt(parser.pos); + switch (char) { + case $backslash: + parser.advanceBy(1); + if (parser.isDone) { + return null; // EOF. Not a link. + } + + final next = parser.charAt(parser.pos); + // Parentheses may be escaped. + // + // http://spec.commonmark.org/0.28/#example-467 + if (next != $backslash && next != $lparen && next != $rparen) { + buffer.writeCharCode(char); + } + buffer.writeCharCode(next); + break; + + case $space: + case $lf: + case $cr: + case $ff: + final destination = buffer.toString(); + final title = _parseTitle(parser); + if (title == null && parser.charAt(parser.pos) != $rparen) { + // This looked like an inline link, until we found this $space + // followed by mystery characters; no longer a link. + return null; + } + // [_parseTitle] made sure the title was follwed by a closing `)` + // (but it's up to the code here to examine the balance of + // parentheses). + parenCount--; + if (parenCount == 0) { + return InlineLink(destination, title: title); + } + break; + + case $lparen: + parenCount++; + buffer.writeCharCode(char); + break; + + case $rparen: + parenCount--; + // ignore: invariant_booleans + if (parenCount == 0) { + final destination = buffer.toString(); + return InlineLink(destination); + } + buffer.writeCharCode(char); + break; + + default: + buffer.writeCharCode(char); + } + parser.advanceBy(1); + if (parser.isDone) { + return null; // EOF. Not a link. + } + } + } + + // Walk the parser forward through any whitespace. + void _moveThroughWhitespace(InlineParser parser) { + while (true) { + final char = parser.charAt(parser.pos); + if (char != $space && + char != $tab && + char != $lf && + char != $vt && + char != $cr && + char != $ff) { + return; + } + parser.advanceBy(1); + if (parser.isDone) { + return; + } + } + } + + // Parse a link title in [parser] at it's current position. The parser's + // current position should be a whitespace character that followed a link + // destination. + String? _parseTitle(InlineParser parser) { + _moveThroughWhitespace(parser); + if (parser.isDone) { + return null; + } + + // The whitespace should be followed by a title delimiter. + final delimiter = parser.charAt(parser.pos); + if (delimiter != $apostrophe && + delimiter != $quote && + delimiter != $lparen) { + return null; + } + + final closeDelimiter = delimiter == $lparen ? $rparen : delimiter; + parser.advanceBy(1); + + // Now we look for an un-escaped closing delimiter. + final buffer = StringBuffer(); + while (true) { + final char = parser.charAt(parser.pos); + if (char == $backslash) { + parser.advanceBy(1); + final next = parser.charAt(parser.pos); + if (next != $backslash && next != closeDelimiter) { + buffer.writeCharCode(char); + } + buffer.writeCharCode(next); + } else if (char == closeDelimiter) { + break; + } else { + buffer.writeCharCode(char); + } + parser.advanceBy(1); + if (parser.isDone) { + return null; + } + } + final title = buffer.toString(); + + // Advance past the closing delimiter. + parser.advanceBy(1); + if (parser.isDone) { + return null; + } + _moveThroughWhitespace(parser); + if (parser.isDone) { + return null; + } + if (parser.charAt(parser.pos) != $rparen) { + return null; + } + return title; + } +} + +/// Matches images like `![alternate text](url "optional title")` and +/// `![alternate text][label]`. +class ImageSyntax extends LinkSyntax { + ImageSyntax({Resolver? linkResolver}) + : super(linkResolver: linkResolver, pattern: r'!\['); + + @override + Node _createNode(TagState state, String destination, String? title) { + final element = Element.empty('img'); + element.attributes['src'] = escapeHtml(destination); + element.attributes['alt'] = state.textContent; + if (title != null && title.isNotEmpty) { + element.attributes['title'] = escapeAttribute(title); + } + return element; + } + + // Add an image node to [parser]'s AST. + // + // If [label] is present, the potential image is treated as a reference image. + // Otherwise, it is treated as an inline image. + // + // Returns whether the image was added successfully. + @override + bool _tryAddReferenceLink(InlineParser parser, TagState state, String label) { + final element = + _resolveReferenceLink(label, state, parser.document.linkReferences); + if (element == null) { + return false; + } + parser + ..addNode(element) + ..start = parser.pos; + return true; + } +} + +/// Matches backtick-enclosed inline code blocks. +class CodeSyntax extends InlineSyntax { + CodeSyntax() : super(_pattern); + + // This pattern matches: + // + // * a string of backticks (not followed by any more), followed by + // * a non-greedy string of anything, including newlines, ending with anything + // except a backtick, followed by + // * a string of backticks the same length as the first, not followed by any + // more. + // + // This conforms to the delimiters of inline code, both in Markdown.pl, and + // CommonMark. + static const String _pattern = r'(`+(?!`))((?:.|\n)*?[^`])\1(?!`)'; + + @override + bool tryMatch(InlineParser parser, [int? startMatchPos]) { + if (parser.pos > 0 && parser.charAt(parser.pos - 1) == $backquote) { + // Not really a match! We can't just sneak past one backtick to try the + // next character. An example of this situation would be: + // + // before ``` and `` after. + // ^--parser.pos + return false; + } + + final match = pattern.matchAsPrefix(parser.source, parser.pos); + if (match == null) { + return false; + } + parser.writeText(); + if (onMatch(parser, match)) { + parser.consume(match[0]!.length); + } + return true; + } + + @override + bool onMatch(InlineParser parser, Match match) { + parser.addNode(Element.text('code', escapeHtml(match[2]!.trim()))); + return true; + } +} + +/// Matches GitHub Markdown emoji syntax like `:smile:`. +/// +/// There is no formal specification of GitHub's support for this colon-based +/// emoji support, so this syntax is based on the results of Markdown-enabled +/// text fields at github.com. +class EmojiSyntax extends InlineSyntax { + // Emoji "aliases" are mostly limited to lower-case letters, numbers, and + // underscores, but GitHub also supports `:+1:` and `:-1:`. + EmojiSyntax() : super(':([a-z0-9_+-]+):'); + + @override + bool onMatch(InlineParser parser, Match match) { + final alias = match[1]; + final emoji = emojis[alias!]; + if (emoji == null) { + parser.advanceBy(1); + return false; + } + parser.addNode(Text(emoji)); + + return true; + } +} + +/// Keeps track of a currently open tag while it is being parsed. +/// +/// The parser maintains a stack of these so it can handle nested tags. +class TagState { + TagState(this.startPos, this.endPos, this.syntax, this.openingDelimiterRun) + : children = []; + + /// The point in the original source where this tag started. + final int startPos; + + /// The point in the original source where open tag ended. + final int endPos; + + /// The syntax that created this node. + final TagSyntax? syntax; + + /// The children of this node. Will be `null` for text nodes. + final List children; + + final _DelimiterRun? openingDelimiterRun; + + /// Attempts to close this tag by matching the current text against its end + /// pattern. + bool tryMatch(InlineParser parser) { + final endMatch = + syntax!.endPattern.matchAsPrefix(parser.source, parser.pos); + if (endMatch == null) { + return false; + } + + if (!syntax!.requiresDelimiterRun) { + // Close the tag. + close(parser, endMatch); + return true; + } + + // TODO: Move this logic into TagSyntax. + final runLength = endMatch.group(0)!.length; + final openingRunLength = endPos - startPos; + final closingMatchStart = parser.pos; + final closingMatchEnd = parser.pos + runLength - 1; + final closingDelimiterRun = + _DelimiterRun.tryParse(parser, closingMatchStart, closingMatchEnd); + if (closingDelimiterRun != null && closingDelimiterRun.canClose) { + // Emphasis rules #9 and #10: + final oneRunOpensAndCloses = + (openingDelimiterRun!.canOpen && openingDelimiterRun!.canClose) || + (closingDelimiterRun.canOpen && closingDelimiterRun.canClose); + if (oneRunOpensAndCloses && + (openingRunLength + closingDelimiterRun.length!) % 3 == 0) { + return false; + } + // Close the tag. + close(parser, endMatch); + return true; + } else { + return false; + } + } + + /// Pops this tag off the stack, completes it, and adds it to the output. + /// + /// Will discard any unmatched tags that happen to be above it on the stack. + /// If this is the last node in the stack, returns its children. + List? close(InlineParser parser, Match? endMatch) { + // If there are unclosed tags on top of this one when it's closed, that + // means they are mismatched. Mismatched tags are treated as plain text in + // markdown. So for each tag above this one, we write its start tag as text + // and then adds its children to this one's children. + final index = parser._stack.indexOf(this); + + // Remove the unmatched children. + final unmatchedTags = parser._stack.sublist(index + 1); + parser._stack.removeRange(index + 1, parser._stack.length); + + // Flatten them out onto this tag. + for (final unmatched in unmatchedTags) { + // Write the start tag as text. + parser.writeTextRange(unmatched.startPos, unmatched.endPos); + + // Bequeath its children unto this tag. + children.addAll(unmatched.children); + } + + // Pop this off the stack. + parser.writeText(); + parser._stack.removeLast(); + + // If the stack is empty now, this is the special "results" node. + if (parser._stack.isEmpty) { + return children; + } + final endMatchIndex = parser.pos; + + // We are still parsing, so add this to its parent's children. + if (syntax!.onMatchEnd(parser, endMatch!, this)) { + parser.consume(endMatch[0]!.length); + } else { + // Didn't close correctly so revert to text. + parser + ..writeTextRange(startPos, endPos) + .._stack.last.children.addAll(children) + ..pos = endMatchIndex + ..advanceBy(endMatch[0]!.length); + } + + return null; + } + + String get textContent => children.map((child) => child.textContent).join(); +} + +class InlineLink { + InlineLink(this.destination, {this.title}); + + final String destination; + final String? title; +} diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/util.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/util.dart new file mode 100644 index 0000000000..aed4c3c3fc --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/util.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; + +import 'package:charcode/charcode.dart'; + +String escapeHtml(String html) => + const HtmlEscape(HtmlEscapeMode.element).convert(html); + +// Escape the contents of [value], so that it may be used as an HTML attribute. + +// Based on http://spec.commonmark.org/0.28/#backslash-escapes. +String escapeAttribute(String value) { + final result = StringBuffer(); + int ch; + for (var i = 0; i < value.codeUnits.length; i++) { + ch = value.codeUnitAt(i); + if (ch == $backslash) { + i++; + if (i == value.codeUnits.length) { + result.writeCharCode(ch); + break; + } + ch = value.codeUnitAt(i); + switch (ch) { + case $quote: + result.write('"'); + break; + case $exclamation: + case $hash: + case $dollar: + case $percent: + case $ampersand: + case $apostrophe: + case $lparen: + case $rparen: + case $asterisk: + case $plus: + case $comma: + case $dash: + case $dot: + case $slash: + case $colon: + case $semicolon: + case $lt: + case $equal: + case $gt: + case $question: + case $at: + case $lbracket: + case $backslash: + case $rbracket: + case $caret: + case $underscore: + case $backquote: + case $lbrace: + case $bar: + case $rbrace: + case $tilde: + result.writeCharCode(ch); + break; + default: + result.write('%5C'); + result.writeCharCode(ch); + } + } else if (ch == $quote) { + result.write('%22'); + } else { + result.writeCharCode(ch); + } + } + return result.toString(); +} diff --git a/app_flowy/lib/workspace/infrastructure/markdown/src/version.dart b/app_flowy/lib/workspace/infrastructure/markdown/src/version.dart new file mode 100644 index 0000000000..19433ffa4a --- /dev/null +++ b/app_flowy/lib/workspace/infrastructure/markdown/src/version.dart @@ -0,0 +1,2 @@ +// Generated code. Do not modify. +const packageVersion = '0.0.2'; diff --git a/app_flowy/lib/workspace/presentation/stack_page/doc/doc_stack_page.dart b/app_flowy/lib/workspace/presentation/stack_page/doc/doc_stack_page.dart index 2372cf2656..2f89a2311e 100644 --- a/app_flowy/lib/workspace/presentation/stack_page/doc/doc_stack_page.dart +++ b/app_flowy/lib/workspace/presentation/stack_page/doc/doc_stack_page.dart @@ -8,6 +8,7 @@ import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flowy_log/flowy_log.dart'; import 'package:flowy_sdk/protobuf/flowy-workspace-infra/export.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-workspace-infra/view_create.pb.dart'; import 'package:flowy_sdk/protobuf/flowy-workspace/errors.pb.dart'; @@ -117,9 +118,7 @@ class DocShareButton extends StatelessWidget { // TODO: Handle this case. break; case ExportType.Markdown: - FlutterClipboard.copy(exportData.data).then( - (value) => print('copied'), - ); + FlutterClipboard.copy(exportData.data).then((value) => Log.info('copied to clipboard')); break; case ExportType.Text: // TODO: Handle this case. diff --git a/app_flowy/pubspec.lock b/app_flowy/pubspec.lock index 817667704a..4a6b8f92e5 100644 --- a/app_flowy/pubspec.lock +++ b/app_flowy/pubspec.lock @@ -253,13 +253,6 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" - file_picker: - dependency: "direct main" - description: - name: file_picker - url: "https://pub.dartlang.org" - source: hosted - version: "4.2.1" fixnum: dependency: transitive description: diff --git a/app_flowy/pubspec.yaml b/app_flowy/pubspec.yaml index 6afd5232bb..bf81a33dfc 100644 --- a/app_flowy/pubspec.yaml +++ b/app_flowy/pubspec.yaml @@ -66,7 +66,6 @@ dependencies: url_launcher: ^6.0.2 # file_picker: ^4.2.1 clipboard: ^0.1.3 - delta_markdown: '>=0.3.0' # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.