diff --git a/packages/notus/lib/src/convert/markdown.dart b/packages/notus/lib/src/convert/markdown.dart index 6e8f00a19..a1d3a99e8 100644 --- a/packages/notus/lib/src/convert/markdown.dart +++ b/packages/notus/lib/src/convert/markdown.dart @@ -4,20 +4,296 @@ import 'dart:convert'; -import 'package:notus/notus.dart'; import 'package:quill_delta/quill_delta.dart'; +import 'package:notus/notus.dart'; class NotusMarkdownCodec extends Codec<Delta, String> { const NotusMarkdownCodec(); @override - Converter<String, Delta> get decoder => - throw UnimplementedError('Decoding is not implemented yet.'); + Converter<String, Delta> get decoder => _NotusMarkdownDecoder(); @override Converter<Delta, String> get encoder => _NotusMarkdownEncoder(); } +class _NotusMarkdownDecoder extends Converter<String, Delta> { + final List<Map<String, dynamic>> _attributesByStyleLength = [ + null, + {'i': true}, // _ + {'b': true}, // ** + {'i': true, 'b': true} // **_ + ]; + final RegExp _headingRegExp = RegExp(r'(#+) *(.+)'); + final RegExp _styleRegExp = RegExp(r'((?:\*|_){1,3})(.*?[^\1 ])\1'); + final RegExp _linkRegExp = RegExp(r'\[([^\]]+)\]\(([^\)]+)\)'); + final RegExp _ulRegExp = RegExp(r'^( *)\* +(.*)'); + final RegExp _olRegExp = RegExp(r'^( *)\d+[\.)] +(.*)'); + final RegExp _bqRegExp = RegExp(r'^> *(.*)'); + final RegExp _codeRegExp = RegExp(r'^( *)```'); // TODO: inline code + bool _inBlockStack = false; +// final List<String> _blockStack = []; +// int _olDepth = 0; + + @override + Delta convert(String input) { + final lines = input.split('\n'); + final delta = Delta(); + + if(_allLinesEmpty(lines)) { + Map<String, dynamic> style; + _handleSpan(lines[0], delta, true, style); + } else { + for (var line in lines) { + _handleLine(line, delta); + } + } + + return delta; + } + + bool _allLinesEmpty(List<String> lines) { + for (var line in lines) { + if (line != '') { + return false; + } + } + + return true; + } + + void _handleLine(String line, Delta delta, [Map<String, dynamic> attributes, bool isBlock]) { + if (_handleBlockQuote(line, delta, attributes)) { + return; + } + if (_handleBlock(line, delta, attributes)) { + return; + } + if (_handleHeading(line, delta, attributes)) { + return; + } + + if (line.isNotEmpty) { + _handleSpan(line, delta, true, attributes, isBlock); + } + } + + /// Markdown supports headings and blocks within blocks (except for within code) + /// but not blocks within headers, or ul within + bool _handleBlock(String line, Delta delta, + [Map<String, dynamic> attributes]) { + var match; + + match = _codeRegExp.matchAsPrefix(line); + if (match != null) { + _inBlockStack = !_inBlockStack; + return true; + } + if (_inBlockStack) { + delta.insert( + line + '\n', + NotusAttribute.code + .toJson()); // TODO: replace with?: {'quote': true}) + // Don't bother testing for code blocks within block stacks + return true; + } + + if (_handleOrderedList(line, delta, attributes) || + _handleUnorderedList(line, delta, attributes)) { + return true; + } + + return false; + } + + /// all blocks are supported within bq + bool _handleBlockQuote(String line, Delta delta, + [Map<String, dynamic> attributes]) { + var match = _bqRegExp.matchAsPrefix(line); + if (match != null) { + var span = match.group(1); + var newAttributes = NotusAttribute.bq.toJson(); // NotusAttribute.bq.toJson(); + if (attributes != null) { + newAttributes.addAll(attributes); + } + // all blocks are supported within bq + _handleLine(span, delta, newAttributes, true); + return true; + } + return false; + } + + /// ol is supported within ol and bq, but not supported within ul + bool _handleOrderedList(String line, Delta delta, + [Map<String, dynamic> attributes]) { + var match = _olRegExp.matchAsPrefix(line); + if (match != null) { +// TODO: support nesting +// var depth = match.group(1).length / 3; + var span = match.group(2); + var newAttributes = NotusAttribute.ol.toJson(); + if (attributes != null) { + newAttributes.addAll(attributes); + } + // There's probably no reason why you would have other block types on the same line + _handleSpan(span, delta, true, newAttributes, true); + return true; + } + return false; + } + + bool _handleUnorderedList(String line, Delta delta, + [Map<String, dynamic> attributes]) { + var match = _ulRegExp.matchAsPrefix(line); + if (match != null) { +// var depth = match.group(1).length / 3; + var span = match.group(2); + var newAttributes = NotusAttribute.ul.toJson(); + if (attributes != null) { + newAttributes.addAll(attributes); + } + // There's probably no reason why you would have other block types on the same line + _handleSpan(span, delta, true, newAttributes, true); + return true; + } + return false; + } + + bool _handleHeading(String line, Delta delta, [Map<String, dynamic> attributes]) { + var match = _headingRegExp.matchAsPrefix(line); + if (match != null) { + var level = match.group(1).length; + var newAttributes = <String, dynamic>{ + 'heading': level + }; // NotusAttribute.heading.withValue(level).toJson(); + if (attributes != null) { + newAttributes.addAll(attributes); + } + + var span = match.group(2); + // TODO: true or false? + _handleSpan(span, delta, true, newAttributes, true); +// delta.insert('\n', attribute.toJson()); + return true; + } + + return false; + } + + void _handleSpan(String span, Delta delta, bool addNewLine, + Map<String, dynamic> outerStyle, [bool isBlock]) { + var start = _handleStyles(span, delta, outerStyle); + span = span.substring(start); + + if (span.isNotEmpty) { + start = _handleLinks(span, delta, outerStyle); + span = span.substring(start); + } + + if (span.isNotEmpty) { + if (addNewLine) { + if(isBlock != null && isBlock){ + delta.insert(span); + delta.insert('\n', outerStyle); + } else { + delta.insert('$span\n', outerStyle); + } + } else { + delta.insert(span, outerStyle); + } + } else if (addNewLine) { + delta.insert('\n', outerStyle); + } + } + + int _handleStyles(String span, Delta delta, Map<String, dynamic> outerStyle) { + var start = 0; + + var matches = _styleRegExp.allMatches(span); + matches.forEach((match) { + if (match.start > start) { + var validInlineStyles = _getValidInlineStyles(outerStyle); + if (span.substring(match.start - 1, match.start) == '[') { + var text = span.substring(start, match.start - 1); + validInlineStyles != null ? delta.insert(text, validInlineStyles) : delta.insert(text); + start = match.start - + 1 + + _handleLinks(span.substring(match.start - 1), delta, validInlineStyles); + return; + } else { + var text = span.substring(start, match.start); + + validInlineStyles != null ? delta.insert(text, validInlineStyles) : delta.insert(text); + } + } + + var text = match.group(2); + var newStyle = Map<String, dynamic>.from( + _attributesByStyleLength[match.group(1).length]); + + var validInlineStyles = _getValidInlineStyles(outerStyle); + if (validInlineStyles != null) { + newStyle.addAll(validInlineStyles); + } + + _handleSpan(text, delta, false, newStyle); + start = match.end; + }); + + return start; + } + + Map<String, dynamic> _getValidInlineStyles(Map<String, dynamic> outerStyle) { + Map<String, dynamic> leafStyles; + + if(outerStyle == null) { + return null; + } + + if(outerStyle.containsKey(NotusAttribute.bold.key)){ + leafStyles = {'b': true}; + } + + if(outerStyle.containsKey(NotusAttribute.italic.key)){ + leafStyles = {'i': true}; + } + + if(outerStyle.containsKey(NotusAttribute.link.key)){ + leafStyles = {NotusAttribute.link.key: outerStyle[NotusAttribute.link.key]}; + } + + return leafStyles; + } + + int _handleLinks(String span, Delta delta, Map<String, dynamic> outerStyle) { + var start = 0; + + var matches = _linkRegExp.allMatches(span); + matches.forEach((match) { + if (match.start > start) { + var text = span.substring(start, match.start); + delta.insert(text); //, outerStyle); + } + + var text = match.group(1); + var href = match.group(2); + var newAttributes = <String, dynamic>{ + 'a': href + }; // NotusAttribute.link.fromString(href).toJson(); + + var validInlineStyles = _getValidInlineStyles(outerStyle); + if (validInlineStyles != null) { + newAttributes.addAll(validInlineStyles); + } + + _handleSpan(text, delta, false, newAttributes); + start = match.end; + }); + + return start; + } +} + class _NotusMarkdownEncoder extends Converter<Delta, String> { static const kBold = '**'; static const kItalic = '_'; @@ -34,13 +310,27 @@ class _NotusMarkdownEncoder extends Converter<Delta, String> { final lineBuffer = StringBuffer(); NotusAttribute<String> currentBlockStyle; var currentInlineStyle = NotusStyle(); - var currentBlockLines = []; + var currentBlockLines = <String>[]; + + bool _allLinesEmpty(List<String> lines) { + for (var line in lines) { + if (line != '') { + return false; + } + } + + return true; + } void _handleBlock(NotusAttribute<String> blockStyle) { if (currentBlockLines.isEmpty) { return; // Empty block } + if(_allLinesEmpty(currentBlockLines)){ + return; + } + if (blockStyle == null) { buffer.write(currentBlockLines.join('\n\n')); buffer.writeln(); @@ -142,7 +432,7 @@ class _NotusMarkdownEncoder extends Converter<Delta, String> { if (padding.isNotEmpty) buffer.write(padding); } // Now open any new styles. - for (var value in style.values) { + for (var value in style.values.toList().reversed) { if (value.scope == NotusAttributeScope.line) continue; if (currentStyle.containsSame(value)) continue; final originalText = text; @@ -210,4 +500,4 @@ class _NotusMarkdownEncoder extends Converter<Delta, String> { buffer.write(tag); } } -} +} \ No newline at end of file diff --git a/packages/notus/test/convert/markdown_test.dart b/packages/notus/test/convert/markdown_test.dart index 9c02a62d3..9db191185 100644 --- a/packages/notus/test/convert/markdown_test.dart +++ b/packages/notus/test/convert/markdown_test.dart @@ -1,23 +1,385 @@ + // Copyright (c) 2018, the Zefyr project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. -import 'dart:convert'; -import 'package:notus/convert.dart'; -import 'package:notus/notus.dart'; -import 'package:quill_delta/quill_delta.dart'; +import 'dart:convert'; import 'package:test/test.dart'; +import 'package:quill_delta/quill_delta.dart'; +import 'package:notus/notus.dart'; +import 'package:notus/convert.dart'; void main() { - group('$NotusMarkdownCodec.encode', () { - test('unimplemented', () { - expect(() { - notusMarkdown.decode('test'); - }, throwsUnimplementedError); + group('$NotusMarkdownCodec.decode', () { + test('should convert empty markdown to valid empty notus document', () { + final markdown = ''; + final newNotusDoc = NotusDocument(); + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, '\n'); + expect(delta, newNotusDoc.toDelta()); + }); + + test('should convert invalid markdown with only line breaks to valid empty notus document', () { + final markdown = '\n\n\n'; + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, '\n'); + final newNotusDoc = NotusDocument(); + expect(delta, newNotusDoc.toDelta()); + }); + + test('paragraphs', () { + final markdown = 'First line\n\nSecond line\n\n'; + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'First line\nSecond line\n'); + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('italics', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'italics'); + expect(delta.elementAt(0).attributes['i'], true); + expect(delta.elementAt(0).attributes['b'], null); + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor('_italics_\n\n', true); + runFor('*italics*\n\n', false); + }); + + test('multi-word italics', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'Okay, '); + expect(delta.elementAt(0).attributes, null); + + expect(delta.elementAt(1).data, 'this is in italics'); + expect(delta.elementAt(1).attributes['i'], true); + expect(delta.elementAt(1).attributes['b'], null); + + expect(delta.elementAt(3).data, 'so is all of _ this'); + expect(delta.elementAt(3).attributes['i'], true); + + expect(delta.elementAt(4).data, ' but this is not\n'); + expect(delta.elementAt(4).attributes, null); + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor( + 'Okay, _this is in italics_ and _so is all of _ this_ but this is not\n\n', + true); + runFor( + 'Okay, *this is in italics* and *so is all of _ this* but this is not\n\n', + false); + }); + + test('bold', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'bold'); + expect(delta.elementAt(0).attributes['b'], true); + expect(delta.elementAt(0).attributes['i'], null); + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor('**bold**\n\n', true); + runFor('__bold__\n\n', false); + }); + + test('multi-word bold', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'Okay, '); + expect(delta.elementAt(0).attributes, null); + + expect(delta.elementAt(1).data, 'this is bold'); + expect(delta.elementAt(1).attributes['b'], true); + expect(delta.elementAt(1).attributes['i'], null); + + expect(delta.elementAt(3).data, 'so is all of __ this'); + expect(delta.elementAt(3).attributes['b'], true); + + expect(delta.elementAt(4).data, ' but this is not\n'); + expect(delta.elementAt(4).attributes, null); + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor( + 'Okay, **this is bold** and **so is all of __ this** but this is not\n\n', + true); + runFor( + 'Okay, __this is bold__ and __so is all of __ this__ but this is not\n\n', + false); + }); + + test('intersecting inline styles', () { + var markdown = 'This **house _is a_ circus**\n\n'; + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(1).data, 'house '); + expect(delta.elementAt(1).attributes['b'], true); + expect(delta.elementAt(1).attributes['i'], null); + + expect(delta.elementAt(2).data, 'is a'); + expect(delta.elementAt(2).attributes['b'], true); + expect(delta.elementAt(2).attributes['i'], true); + + expect(delta.elementAt(3).data, ' circus'); + expect(delta.elementAt(3).attributes['b'], true); + expect(delta.elementAt(3).attributes['i'], null); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('bold and italics', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'this is bold and italic'); + expect(delta.elementAt(0).attributes['b'], true); + expect(delta.elementAt(0).attributes['i'], true); + + expect(delta.elementAt(1).data, '\n'); + expect(delta.length, 2); + + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor('**_this is bold and italic_**\n\n', true); + runFor('_**this is bold and italic**_\n\n', true); + runFor('***this is bold and italic***\n\n', false); + runFor('___this is bold and italic___\n\n', false); + }); + + test('bold and italics combinations', () { + void runFor(String markdown, bool testEncode) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'this is bold'); + expect(delta.elementAt(0).attributes['b'], true); + expect(delta.elementAt(0).attributes['i'], null); + + expect(delta.elementAt(2).data, 'this is in italics'); + expect(delta.elementAt(2).attributes['b'], null); + expect(delta.elementAt(2).attributes['i'], true); + + expect(delta.elementAt(4).data, 'this is both'); + expect(delta.elementAt(4).attributes['b'], true); + expect(delta.elementAt(4).attributes['i'], true); + + if (testEncode) { + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + } + + runFor('**this is bold** _this is in italics_ and **_this is both_**\n\n', + true); + runFor('**this is bold** *this is in italics* and ***this is both***\n\n', + false); + runFor('__this is bold__ _this is in italics_ and ___this is both___\n\n', + false); + }); + + test('link', () { + var markdown = 'This **house** is a [circus](https://github.com)\n\n'; + final delta = notusMarkdown.decode(markdown); + + expect(delta.elementAt(1).data, 'house'); + expect(delta.elementAt(1).attributes['b'], true); + expect(delta.elementAt(1).attributes['a'], null); + + expect(delta.elementAt(3).data, 'circus'); + expect(delta.elementAt(3).attributes['b'], null); + expect(delta.elementAt(3).attributes['a'], 'https://github.com'); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('style around link', () { + var markdown = 'This **house** is a **[circus](https://github.com)**\n\n'; + final delta = notusMarkdown.decode(markdown); + + expect(delta.elementAt(1).data, 'house'); + expect(delta.elementAt(1).attributes['b'], true); + expect(delta.elementAt(1).attributes['a'], null); + + expect(delta.elementAt(3).data, 'circus'); + expect(delta.elementAt(3).attributes['b'], true); + expect(delta.elementAt(3).attributes['a'], 'https://github.com'); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('style within link', () { + var markdown = 'This **house** is a [**circus**](https://github.com)\n\n'; + final delta = notusMarkdown.decode(markdown); + + expect(delta.elementAt(1).data, 'house'); + expect(delta.elementAt(1).attributes['b'], true); + expect(delta.elementAt(1).attributes['a'], null); + + expect(delta.elementAt(2).data, ' is a '); + expect(delta.elementAt(2).attributes, null); + + expect(delta.elementAt(3).data, 'circus'); + expect(delta.elementAt(3).attributes['b'], true); + expect(delta.elementAt(3).attributes['a'], 'https://github.com'); + + expect(delta.elementAt(4).data, '\n'); + expect(delta.length, 5); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('heading styles', () { + void runFor(String markdown, int level) { + final delta = notusMarkdown.decode(markdown); + expect(delta.elementAt(0).data, 'This is an H$level'); + + expect(delta.elementAt(1).data, '\n'); + expect(delta.elementAt(1).attributes['heading'], level); + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + } + + runFor('# This is an H1\n\n', 1); + runFor('## This is an H2\n\n', 2); + runFor('### This is an H3\n\n', 3); + }); + + test('ul', () { + var markdown = '* a bullet point\n* another bullet point\n\n'; + final delta = notusMarkdown.decode(markdown); + print(delta); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('ol', () { + var markdown = '1. 1st point\n1. 2nd point\n\n'; + final delta = notusMarkdown.decode(markdown); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + + test('simple bq', () { +// var markdown = '> quote\n> > nested\n>#Heading\n>**bold**\n>_italics_\n>* bullet\n>1. 1st point\n>1. 2nd point\n\n'; + var markdown = + '> quote\n> # Heading in Quote\n> # **Styled** heading in _block quote_\n> **bold text**\n> _text in italics_\n\n'; + final delta = notusMarkdown.decode(markdown); + + expect(delta.elementAt(0).data, 'quote'); + expect(delta.elementAt(0).attributes, null); + + expect(delta.elementAt(1).data, '\n'); + expect(delta.elementAt(1).attributes['block'], 'quote'); + expect(delta.elementAt(1).attributes.length, 1); + + expect(delta.elementAt(2).data, 'Heading in Quote'); + expect(delta.elementAt(2).attributes, null); + + expect(delta.elementAt(3).data, '\n'); + expect(delta.elementAt(3).attributes['block'], 'quote'); + expect(delta.elementAt(3).attributes['heading'], 1); + expect(delta.elementAt(3).attributes.length, 2); + + expect(delta.elementAt(4).data, 'Styled'); + expect(delta.elementAt(4).attributes['b'], true); + expect(delta.elementAt(4).attributes.length, 1); + + expect(delta.elementAt(5).data, ' heading in '); + expect(delta.elementAt(5).attributes, null); + + expect(delta.elementAt(6).data, 'block quote'); + expect(delta.elementAt(6).attributes['i'], true); + expect(delta.elementAt(6).attributes.length, 1); + + expect(delta.elementAt(7).data, '\n'); + expect(delta.elementAt(7).attributes['block'], 'quote'); + expect(delta.elementAt(7).attributes['heading'], 1); + expect(delta.elementAt(7).attributes.length, 2); + + expect(delta.elementAt(8).data, 'bold text'); + expect(delta.elementAt(8).attributes['b'], true); + expect(delta.elementAt(8).attributes.length, 1); + + expect(delta.elementAt(9).data, '\n'); + expect(delta.elementAt(9).attributes['block'], 'quote'); + expect(delta.elementAt(9).attributes.length, 1); + + expect(delta.elementAt(10).data, 'text in italics'); + expect(delta.elementAt(10).attributes['i'], true); + expect(delta.elementAt(10).attributes.length, 1); + + expect(delta.elementAt(11).data, '\n'); + expect(delta.elementAt(11).attributes['block'], 'quote'); + expect(delta.elementAt(11).attributes.length, 1); + + final andBack = notusMarkdown.encode(delta); + expect(andBack, markdown); + }); + +// test('nested bq', () { +// var markdown = '> > nested\n>* bullet\n>1. 1st point\n>1. 2nd point\n\n'; +// final delta = notusMarkdown.decode(markdown); +// final andBack = notusMarkdown.encode(delta); +// expect(andBack, markdown); +// }); + + +// test('code in bq', () { +// var markdown = '> ```\n> print("Hello world!")\n> ```\n\n'; +// final delta = notusMarkdown.decode(markdown); +// final andBack = notusMarkdown.encode(delta); +// expect(andBack, markdown); +// }); + + test('multiple styles', () { + final delta = notusMarkdown.decode(expectedMarkdown); +// expect(delta, doc); + final andBack = notusMarkdown.encode(delta); + expect(andBack, expectedMarkdown); }); }); group('$NotusMarkdownCodec.encode', () { + test('should convert empty valid notus document to empty markdown', () { + final delta = NotusDocument().toDelta(); + final result = notusMarkdown.encode(delta); + expect(result, ''); + }); + + test('should convert delta with only line breaks to empty markdown', () { + final delta = Delta() + ..insert('\n') + ..insert('\n') + ..insert('\n') + ..insert('\n'); + + final result = notusMarkdown.encode(delta); + expect(result, ''); + }); + test('split adjacent paragraphs', () { final delta = Delta()..insert('First line\nSecond line\n'); final result = notusMarkdown.encode(delta); @@ -87,8 +449,7 @@ void main() { }); test('heading styles', () { - void runFor( - NotusAttribute<int> attribute, String source, String expected) { + void runFor(NotusAttribute<int> attribute, String source, String expected) { final delta = Delta()..insert(source)..insert('\n', attribute.toJson()); final result = notusMarkdown.encode(delta); expect(result, expected); @@ -100,8 +461,7 @@ void main() { }); test('block styles', () { - void runFor( - NotusAttribute<String> attribute, String source, String expected) { + void runFor(NotusAttribute<String> attribute, String source, String expected) { final delta = Delta()..insert(source)..insert('\n', attribute.toJson()); final result = notusMarkdown.encode(delta); expect(result, expected); @@ -114,8 +474,7 @@ void main() { }); test('multiline blocks', () { - void runFor( - NotusAttribute<String> attribute, String source, String expected) { + void runFor(NotusAttribute<String> attribute, String source, String expected) { final delta = Delta() ..insert(source) ..insert('\n', attribute.toJson())