Explorar o código

[flutter]: add markdown parser

appflowy %!s(int64=3) %!d(string=hai) anos
pai
achega
0d362a4781

+ 8 - 1
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<DocShareEvent, DocShareState> {
         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<DocShareEvent, DocShareState> {
       );
     });
   }
+
+  ExportData _convertDeltaToMarkdown(ExportData value) {
+    final result = deltaToMarkdown(value.data);
+    value.data = result;
+    return value;
+  }
 }
 
 @freezed

+ 30 - 0
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<String, String> {
+  const DeltaMarkdownCodec();
+
+  @override
+  Converter<String, String> get decoder => DeltaMarkdownDecoder();
+
+  @override
+  Converter<String, String> get encoder => DeltaMarkdownEncoder();
+}

+ 113 - 0
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 = <String, String>{};
+
+  /// 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<Node>? children;
+  final Map<String, String> 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);
+}

+ 1096 - 0
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<String> 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<BlockSyntax> 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<BlockSyntax> standardBlockSyntaxes = [
+    const EmptyBlockSyntax(),
+    const BlockTagBlockHtmlSyntax(),
+    LongBlockHtmlSyntax(r'^ {0,3}<pre(?:\s|>|$)', '</pre>'),
+    LongBlockHtmlSyntax(r'^ {0,3}<script(?:\s|>|$)', '</script>'),
+    LongBlockHtmlSyntax(r'^ {0,3}<style(?:\s|>|$)', '</style>'),
+    LongBlockHtmlSyntax('^ {0,3}<!--', '-->'),
+    LongBlockHtmlSyntax('^ {0,3}<\\?', '\\?>'),
+    LongBlockHtmlSyntax('^ {0,3}<![A-Z]', '>'),
+    LongBlockHtmlSyntax('^ {0,3}<!\\[CDATA\\[', '\\]\\]>'),
+    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<Node> parseLines() {
+    final blocks = <Node>[];
+    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<String?> parseChildLines(BlockParser parser) {
+    // Grab all of the lines that form the block element.
+    final childLines = <String?>[];
+
+    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 = <String>[];
+    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<String> parseChildLines(BlockParser parser) {
+    // Grab all of the lines that form the blockquote, stripping off the ">".
+    final childLines = <String>[];
+
+    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<String?> parseChildLines(BlockParser parser) {
+    final childLines = <String?>[];
+
+    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<String> parseChildLines(BlockParser parser, [String? endBlock]) {
+    endBlock ??= '';
+
+    final childLines = <String>[];
+    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}</?(?:address|article|aside|base|basefont|blockquote|body|'
+      r'caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|'
+      r'figcaption|figure|footer|form|frame|frameset|h1|head|header|hr|html|'
+      r'iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|'
+      r'option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|'
+      'title|tr|track|ul)'
+      r'(?:\s|>|/>|$)');
+
+  @override
+  RegExp get pattern => _pattern;
+
+  @override
+  Node parse(BlockParser parser) {
+    final childLines = <String>[];
+
+    // 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 "</")
+  // * some word characters
+  // * either:
+  //   * a close bracket, or
+  //   * whitespace followed by not-brackets followed by a close bracket
+  // * possible whitespace and the end of the line.
+  @override
+  RegExp get pattern => RegExp(r'^ {0,3}</?\w+(?:>|\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 = <String>[];
+    // 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<String> 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 = <ListItem>[];
+    var childLines = <String>[];
+
+    void endItem() {
+      if (childLines.isNotEmpty) {
+        items.add(ListItem(childLines));
+        childLines = <String>[];
+      }
+    }
+
+    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 = <Element>[];
+
+    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<ListItem> items) {
+    var anyEmpty = false;
+    for (var i = 0; i < items.length; i++) {
+      if (items[i].lines.length == 1) {
+        continue;
+      }
+      while (items[i].lines.isNotEmpty &&
+          _emptyPattern.hasMatch(items[i].lines.last)) {
+        if (i < items.length - 1) {
+          anyEmpty = true;
+        }
+        items[i].lines.removeLast();
+      }
+    }
+    return anyEmpty;
+  }
+
+  static int _expandedTabLength(String input) {
+    var length = 0;
+    for (final char in input.codeUnits) {
+      length += char == 0x9 ? 4 - (length % 4) : 1;
+    }
+    return length;
+  }
+}
+
+/// 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 (`<th>` cells)
+  /// * a divider of hyphens and pipes (not rendered)
+  /// * many body rows of body cells (`<td>` 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 = <Element>[];
+    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<String?> 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<String?> alignments, String cellType) {
+    final line = parser.current
+        .replaceFirst(_openingPipe, '')
+        .replaceFirst(_closingPipe, '');
+    final cells = line.split(_pipePattern);
+    parser.advance();
+    final row = <Element>[];
+    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 = <String>[];
+
+    // 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<String>? _extractReflinkDefinitions(
+      BlockParser parser, List<String> 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;
+  }
+}

+ 255 - 0
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<String, String> {
+  @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<Attribute> activeInlineAttributes;
+  Attribute? activeBlockAttribute;
+  late Set<String> uniqueIds;
+
+  ast.Element? previousElement;
+  late ast.Element previousToplevelElement;
+
+  Delta convert(List<ast.Node> nodes) {
+    delta = Delta();
+    activeInlineAttributes = Queue<Attribute>();
+    uniqueIds = <String>{};
+
+    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<String, dynamic>();
+    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 = <String, dynamic>{};
+    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 <hr/>.
+      //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
+    //
+    // <ul>
+    //   <li>...</li>
+    //   <li>...</li>
+    // </ul>
+    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<String?> {
+  ImageAttribute(String? val) : super('image', AttributeScope.EMBEDS, val);
+}
+
+class DividerAttribute extends Attribute<String?> {
+  DividerAttribute() : super('divider', AttributeScope.EMBEDS, 'hr');
+}

+ 272 - 0
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<String, String> {
+  static const _lineFeedAsciiCode = 0x0A;
+
+  late StringBuffer markdownBuffer;
+  late StringBuffer lineBuffer;
+
+  Attribute? currentBlockStyle;
+  late Style currentInlineStyle;
+
+  late List<String> currentBlockLines;
+
+  /// Converts the [input] delta to Markdown.
+  @override
+  String convert(String input) {
+    markdownBuffer = StringBuffer();
+    lineBuffer = StringBuffer();
+    currentInlineStyle = Style();
+    currentBlockLines = <String>[];
+
+    final inputJson = jsonDecode(input) as List<dynamic>?;
+    if (inputJson is! List<dynamic>) {
+      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<String, dynamic>) {
+        _handleEmbed(operation.data as Map<String, dynamic>);
+      } 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<String, dynamic>? attributes,
+  ) {
+    final style = Style.fromJson(attributes);
+
+    // First close any current styles if needed
+    final markedForRemoval = <Attribute>[];
+    // 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<String, dynamic>? 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<String, dynamic> 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');
+    }
+  }
+}

+ 88 - 0
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<BlockSyntax>? blockSyntaxes,
+    Iterable<InlineSyntax>? 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<String, LinkReference> linkReferences = <String, LinkReference>{};
+  final ExtensionSet extensionSet;
+  final Resolver? linkResolver;
+  final Resolver? imageLinkResolver;
+  final _blockSyntaxes = <BlockSyntax>{};
+  final _inlineSyntaxes = <InlineSyntax>{};
+
+  Iterable<BlockSyntax> get blockSyntaxes => _blockSyntaxes;
+  Iterable<InlineSyntax> get inlineSyntaxes => _inlineSyntaxes;
+
+  /// Parses the given [lines] of Markdown to a series of AST nodes.
+  List<Node> parseLines(List<String> 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<Node>? parseInline(String text) => InlineParser(text, this).parse();
+
+  void _parseInlineContent(List<Node> 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;
+}

+ 1510 - 0
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 = <String, String>{
+  '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': '🇿🇼',
+};

+ 64 - 0
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<BlockSyntax> blockSyntaxes;
+  final List<InlineSyntax> inlineSyntaxes;
+}

+ 121 - 0
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<BlockSyntax>? blockSyntaxes,
+    Iterable<InlineSyntax>? 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<Node> 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<String> uniqueIds;
+
+  String render(List<Node> nodes) {
+    buffer = StringBuffer();
+    uniqueIds = <String>{};
+
+    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 <hr/>.
+      buffer.write(' />');
+
+      if (element.tag == 'br') {
+        buffer.write('\n');
+      }
+
+      return false;
+    } else {
+      buffer.write('>');
+      return true;
+    }
+  }
+
+  @override
+  void visitElementAfter(Element element) {
+    buffer.write('</${element.tag}>');
+  }
+
+  /// 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;
+  }
+}

+ 1271 - 0
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 = <TagState>[] {
+    // 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<InlineSyntax> _defaultSyntaxes =
+      List<InlineSyntax>.unmodifiable(<InlineSyntax>[
+    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<InlineSyntax> syntaxes = <InlineSyntax>[];
+
+  /// The current read position.
+  int pos = 0;
+
+  /// Starting position of the last unconsumed text.
+  int start = 0;
+
+  final List<TagState> _stack;
+
+  List<Node>? 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 <br> 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 `<a href='hi">` alone, which is
+/// incorrect.
+///
+/// TODO(srawlins): improve accuracy while ensuring performance, once
+/// Markdown benchmarking is more mature.
+class InlineHtmlSyntax extends TextSyntax {
+  InlineHtmlSyntax() : super(r'<[/!?]?[A-Za-z][A-Za-z0-9-]*(?:\s[^>]*)?>');
+}
+
+/// Matches autolinks like `<[email protected]>`.
+///
+/// See <http://spec.commonmark.org/0.28/#email-address>.
+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 `<http://foo.com>`.
+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() =>
+      '<char: $char, length: $length, isLeftFlanking: $isLeftFlanking, '
+      'isRightFlanking: $isRightFlanking>';
+
+  // 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
+/// `<em>`. 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<String, LinkReference> 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 `(<http://url>)`, 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](<url>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 = <Node>[];
+
+  /// 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<Node> 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<Node>? 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;
+}

+ 71 - 0
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('&quot;');
+          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();
+}

+ 2 - 0
app_flowy/lib/workspace/infrastructure/markdown/src/version.dart

@@ -0,0 +1,2 @@
+// Generated code. Do not modify.
+const packageVersion = '0.0.2';

+ 2 - 3
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.

+ 0 - 7
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:

+ 0 - 1
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.