matrix-0.8.13/lib/src/utils/markdown.dart

257 lines
7.6 KiB
Dart
Raw Permalink Normal View History

2022-04-18 08:57:08 +00:00
/*
* Famedly Matrix SDK
* Copyright (C) 2020, 2021 Famedly GmbH
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 'package:markdown/markdown.dart';
import 'dart:convert';
const htmlAttrEscape = HtmlEscape(HtmlEscapeMode.attribute);
class LinebreakSyntax extends InlineSyntax {
LinebreakSyntax() : super(r'\n');
@override
bool onMatch(InlineParser parser, Match match) {
parser.addNode(Element.empty('br'));
return true;
}
}
class SpoilerSyntax extends TagSyntax {
SpoilerSyntax() : super(r'\|\|', requiresDelimiterRun: true);
@override
Node close(InlineParser parser, Delimiter opener, Delimiter closer,
{required List<Node> Function() getChildren}) {
final children = getChildren();
final newChildren = <Node>[];
var searchingForReason = true;
var reason = '';
for (final child in children) {
// If we already found a reason, let's just use our child nodes as-is
if (!searchingForReason) {
newChildren.add(child);
continue;
}
if (child is Text) {
final ix = child.text.indexOf('|');
if (ix > 0) {
reason += child.text.substring(0, ix);
newChildren.add(Text(child.text.substring(ix + 1)));
searchingForReason = false;
} else {
reason += child.text;
}
} else {
// if we don't have a text node as reason we just want to cancel this whole thing
break;
}
}
// if we were still searching for a reason that means there was none - use the original children!
final element =
Element('span', searchingForReason ? children : newChildren);
element.attributes['data-mx-spoiler'] =
searchingForReason ? '' : htmlAttrEscape.convert(reason);
return element;
}
}
class EmoteSyntax extends InlineSyntax {
final Map<String, Map<String, String>> Function()? getEmotePacks;
Map<String, Map<String, String>>? emotePacks;
EmoteSyntax(this.getEmotePacks) : super(r':(?:([-\w]+)~)?([-\w]+):');
@override
bool onMatch(InlineParser parser, Match match) {
final emotePacks = this.emotePacks ??= getEmotePacks?.call() ?? {};
final pack = match[1] ?? '';
final emote = match[2];
String? mxc;
if (pack.isEmpty) {
// search all packs
for (final emotePack in emotePacks.values) {
mxc = emotePack[emote];
if (mxc != null) {
break;
}
}
} else {
mxc = emotePacks[pack]?[emote];
}
if (mxc == null) {
// emote not found. Insert the whole thing as plain text
parser.addNode(Text(match[0]!));
return true;
}
final element = Element.empty('img');
element.attributes['data-mx-emoticon'] = '';
element.attributes['src'] = htmlAttrEscape.convert(mxc);
element.attributes['alt'] = htmlAttrEscape.convert(':$emote:');
element.attributes['title'] = htmlAttrEscape.convert(':$emote:');
element.attributes['height'] = '32';
element.attributes['vertical-align'] = 'middle';
parser.addNode(element);
return true;
}
}
class InlineLatexSyntax extends TagSyntax {
InlineLatexSyntax() : super(r'\$([^\s$]([^\$]*[^\s$])?)\$');
@override
bool onMatch(InlineParser parser, Match match) {
final element =
Element('span', [Element.text('code', htmlEscape.convert(match[1]!))]);
element.attributes['data-mx-maths'] = htmlAttrEscape.convert(match[1]!);
parser.addNode(element);
return true;
}
}
// We also want to allow single-lines of like "$$latex$$"
class BlockLatexSyntax extends BlockSyntax {
@override
RegExp get pattern => RegExp(r'^[ ]{0,3}\$\$(.*)$');
final endPattern = RegExp(r'^(.*)\$\$\s*$');
@override
List<String> parseChildLines(BlockParser parser) {
final childLines = <String>[];
var first = true;
while (!parser.isDone) {
final match = endPattern.firstMatch(parser.current);
if (match == null || (first && match[1]!.trim().isEmpty)) {
childLines.add(parser.current);
parser.advance();
} else {
childLines.add(match[1]!);
parser.advance();
break;
}
first = false;
}
return childLines;
}
@override
Node parse(BlockParser parser) {
final childLines = parseChildLines(parser);
// we use .substring(2) as childLines will *always* contain the first two '$$'
final latex = childLines.join('\n').trim().substring(2).trim();
final element = Element('div', [
Element('pre', [Element.text('code', htmlEscape.convert(latex))])
]);
element.attributes['data-mx-maths'] = htmlAttrEscape.convert(latex);
return element;
}
}
class PillSyntax extends InlineSyntax {
PillSyntax()
: super(
r'([@#!][^\s:]*:(?:[^\s]+\.\w+|[\d\.]+|\[[a-fA-F0-9:]+\])(?::\d+)?)');
@override
bool onMatch(InlineParser parser, Match match) {
if (match.start > 0 &&
!RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) {
parser.addNode(Text(match[0]!));
return true;
}
final identifier = match[1]!;
final element = Element.text('a', htmlEscape.convert(identifier));
element.attributes['href'] =
htmlAttrEscape.convert('https://matrix.to/#/$identifier');
parser.addNode(element);
return true;
}
}
class MentionSyntax extends InlineSyntax {
final String? Function(String)? getMention;
MentionSyntax(this.getMention) : super(r'(@(?:\[[^\]:]+\]|\w+)(?:#\w+)?)');
@override
bool onMatch(InlineParser parser, Match match) {
final mention = getMention?.call(match[1]!);
if ((match.start > 0 &&
!RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) ||
mention == null) {
parser.addNode(Text(match[0]!));
return true;
}
final element = Element.text('a', htmlEscape.convert(match[1]!));
element.attributes['href'] =
htmlAttrEscape.convert('https://matrix.to/#/$mention');
parser.addNode(element);
return true;
}
}
String markdown(
String text, {
Map<String, Map<String, String>> Function()? getEmotePacks,
String? Function(String)? getMention,
}) {
var ret = markdownToHtml(
text,
extensionSet: ExtensionSet.commonMark,
blockSyntaxes: [
BlockLatexSyntax(),
],
inlineSyntaxes: [
StrikethroughSyntax(),
LinebreakSyntax(),
SpoilerSyntax(),
EmoteSyntax(getEmotePacks),
PillSyntax(),
MentionSyntax(getMention),
InlineLatexSyntax(),
],
);
var stripPTags = '<p>'.allMatches(ret).length <= 1;
if (stripPTags) {
const otherBlockTags = {
'table',
'pre',
'ol',
'ul',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'div',
};
for (final tag in otherBlockTags) {
// we check for the close tag as the opening one might have attributes
if (ret.contains('</$tag>')) {
stripPTags = false;
break;
}
}
}
if (stripPTags) {
ret = ret.replaceAll('<p>', '').replaceAll('</p>', '');
}
return ret.trim().replaceAll(RegExp(r'(<br />)+$'), '');
}