parent
3448d7abd9
commit
dd30dc59ee
15 changed files with 4892 additions and 4 deletions
@ -1 +0,0 @@ |
|||||||
Subproject commit 22e49a1abe72894f6666baedcc00fb89f13d2c4a |
|
@ -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); |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,27 @@ |
|||||||
|
library delta_markdown; |
||||||
|
|
||||||
|
import 'dart:convert'; |
||||||
|
|
||||||
|
import 'delta_markdown_decoder.dart'; |
||||||
|
import 'delta_markdown_encoder.dart'; |
||||||
|
|
||||||
|
/// 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(); |
||||||
|
} |
@ -0,0 +1,256 @@ |
|||||||
|
import 'dart:collection'; |
||||||
|
import 'dart:convert'; |
||||||
|
|
||||||
|
import 'package:flutter_quill/flutter_quill.dart' |
||||||
|
show Attribute, AttributeScope, Delta, LinkAttribute; |
||||||
|
|
||||||
|
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 const DividerAttribute(); |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class ImageAttribute extends Attribute<String?> { |
||||||
|
const ImageAttribute(String? val) |
||||||
|
: super('image', AttributeScope.embeds, val); |
||||||
|
} |
||||||
|
|
||||||
|
class DividerAttribute extends Attribute<String?> { |
||||||
|
const DividerAttribute() : super('divider', AttributeScope.embeds, 'hr'); |
||||||
|
} |
@ -0,0 +1,270 @@ |
|||||||
|
import 'dart:convert'; |
||||||
|
|
||||||
|
import 'package:collection/collection.dart' show IterableExtension; |
||||||
|
import 'package:flutter_quill/flutter_quill.dart' |
||||||
|
show Attribute, AttributeScope, BlockEmbed, Delta, DeltaIterator, Style; |
||||||
|
|
||||||
|
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 = const 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'); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -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; |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -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; |
||||||
|
} |
@ -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; |
||||||
|
} |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,71 @@ |
|||||||
|
import 'dart:convert'; |
||||||
|
|
||||||
|
import 'package:charcode/charcode.dart'; |
||||||
|
|
||||||
|
String escapeHtml(String html) => |
||||||
|
const HtmlEscape(HtmlEscapeMode.element).convert(html); |
||||||
|
|
||||||
|
// Escape the contents of [value], so that it may be used as an HTML attribute. |
||||||
|
|
||||||
|
// Based on http://spec.commonmark.org/0.28/#backslash-escapes. |
||||||
|
String escapeAttribute(String value) { |
||||||
|
final result = StringBuffer(); |
||||||
|
int ch; |
||||||
|
for (var i = 0; i < value.codeUnits.length; i++) { |
||||||
|
ch = value.codeUnitAt(i); |
||||||
|
if (ch == $backslash) { |
||||||
|
i++; |
||||||
|
if (i == value.codeUnits.length) { |
||||||
|
result.writeCharCode(ch); |
||||||
|
break; |
||||||
|
} |
||||||
|
ch = value.codeUnitAt(i); |
||||||
|
switch (ch) { |
||||||
|
case $quote: |
||||||
|
result.write('"'); |
||||||
|
break; |
||||||
|
case $exclamation: |
||||||
|
case $hash: |
||||||
|
case $dollar: |
||||||
|
case $percent: |
||||||
|
case $ampersand: |
||||||
|
case $apostrophe: |
||||||
|
case $lparen: |
||||||
|
case $rparen: |
||||||
|
case $asterisk: |
||||||
|
case $plus: |
||||||
|
case $comma: |
||||||
|
case $dash: |
||||||
|
case $dot: |
||||||
|
case $slash: |
||||||
|
case $colon: |
||||||
|
case $semicolon: |
||||||
|
case $lt: |
||||||
|
case $equal: |
||||||
|
case $gt: |
||||||
|
case $question: |
||||||
|
case $at: |
||||||
|
case $lbracket: |
||||||
|
case $backslash: |
||||||
|
case $rbracket: |
||||||
|
case $caret: |
||||||
|
case $underscore: |
||||||
|
case $backquote: |
||||||
|
case $lbrace: |
||||||
|
case $bar: |
||||||
|
case $rbrace: |
||||||
|
case $tilde: |
||||||
|
result.writeCharCode(ch); |
||||||
|
break; |
||||||
|
default: |
||||||
|
result.write('%5C'); |
||||||
|
result.writeCharCode(ch); |
||||||
|
} |
||||||
|
} else if (ch == $quote) { |
||||||
|
result.write('%22'); |
||||||
|
} else { |
||||||
|
result.writeCharCode(ch); |
||||||
|
} |
||||||
|
} |
||||||
|
return result.toString(); |
||||||
|
} |
@ -0,0 +1,2 @@ |
|||||||
|
// Generated code. Do not modify. |
||||||
|
const packageVersion = '0.0.2'; |
Loading…
Reference in new issue