Line data Source code
1 : /*
2 : * Famedly Matrix SDK
3 : * Copyright (C) 2020, 2021 Famedly GmbH
4 : *
5 : * This program is free software: you can redistribute it and/or modify
6 : * it under the terms of the GNU Affero General Public License as
7 : * published by the Free Software Foundation, either version 3 of the
8 : * License, or (at your option) any later version.
9 : *
10 : * This program is distributed in the hope that it will be useful,
11 : * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 : * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 : * GNU Affero General Public License for more details.
14 : *
15 : * You should have received a copy of the GNU Affero General Public License
16 : * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 : */
18 :
19 : import 'dart:convert';
20 :
21 : import 'package:markdown/markdown.dart';
22 :
23 : const htmlAttrEscape = HtmlEscape(HtmlEscapeMode.attribute);
24 :
25 : class SpoilerSyntax extends DelimiterSyntax {
26 8 : SpoilerSyntax()
27 8 : : super(
28 : r'\|\|',
29 : requiresDelimiterRun: true,
30 16 : tags: [DelimiterTag('span', 2)],
31 : );
32 :
33 2 : @override
34 : Iterable<Node>? close(
35 : InlineParser parser,
36 : Delimiter opener,
37 : Delimiter closer, {
38 : required String tag,
39 : required List<Node> Function() getChildren,
40 : }) {
41 2 : final children = getChildren();
42 2 : final newChildren = <Node>[];
43 : var searchingForReason = true;
44 : var reason = '';
45 4 : for (final child in children) {
46 : // If we already found a reason, let's just use our child nodes as-is
47 : if (!searchingForReason) {
48 2 : newChildren.add(child);
49 : continue;
50 : }
51 2 : if (child is Text) {
52 4 : final ix = child.text.indexOf('|');
53 2 : if (ix > 0) {
54 6 : reason += child.text.substring(0, ix);
55 10 : newChildren.add(Text(child.text.substring(ix + 1)));
56 : searchingForReason = false;
57 : } else {
58 4 : reason += child.text;
59 : }
60 : } else {
61 : // if we don't have a text node as reason we just want to cancel this whole thing
62 : break;
63 : }
64 : }
65 : // if we were still searching for a reason that means there was none - use the original children!
66 : final element =
67 2 : Element('span', searchingForReason ? children : newChildren);
68 4 : element.attributes['data-mx-spoiler'] =
69 2 : searchingForReason ? '' : htmlAttrEscape.convert(reason);
70 2 : return <Node>[element];
71 : }
72 : }
73 :
74 : class EmoteSyntax extends InlineSyntax {
75 : final Map<String, Map<String, String>> Function()? getEmotePacks;
76 : Map<String, Map<String, String>>? emotePacks;
77 16 : EmoteSyntax(this.getEmotePacks) : super(r':(?:([-\w]+)~)?([-\w]+):');
78 :
79 2 : @override
80 : bool onMatch(InlineParser parser, Match match) {
81 6 : final emotePacks = this.emotePacks ??= getEmotePacks?.call() ?? {};
82 2 : final pack = match[1] ?? '';
83 2 : final emote = match[2];
84 : String? mxc;
85 2 : if (pack.isEmpty) {
86 : // search all packs
87 4 : for (final emotePack in emotePacks.values) {
88 2 : mxc = emotePack[emote];
89 : if (mxc != null) {
90 : break;
91 : }
92 : }
93 : } else {
94 4 : mxc = emotePacks[pack]?[emote];
95 : }
96 : if (mxc == null) {
97 : // emote not found. Insert the whole thing as plain text
98 6 : parser.addNode(Text(match[0]!));
99 : return true;
100 : }
101 2 : final element = Element.empty('img');
102 4 : element.attributes['data-mx-emoticon'] = '';
103 6 : element.attributes['src'] = htmlAttrEscape.convert(mxc);
104 8 : element.attributes['alt'] = htmlAttrEscape.convert(':$emote:');
105 8 : element.attributes['title'] = htmlAttrEscape.convert(':$emote:');
106 4 : element.attributes['height'] = '32';
107 4 : element.attributes['vertical-align'] = 'middle';
108 2 : parser.addNode(element);
109 : return true;
110 : }
111 : }
112 :
113 : class InlineLatexSyntax extends DelimiterSyntax {
114 16 : InlineLatexSyntax() : super(r'\$([^\s$]([^\$]*[^\s$])?)\$');
115 :
116 2 : @override
117 : bool onMatch(InlineParser parser, Match match) {
118 : final element =
119 10 : Element('span', [Element.text('code', htmlEscape.convert(match[1]!))]);
120 8 : element.attributes['data-mx-maths'] = htmlAttrEscape.convert(match[1]!);
121 2 : parser.addNode(element);
122 : return true;
123 : }
124 : }
125 :
126 : // We also want to allow single-lines of like "$$latex$$"
127 : class BlockLatexSyntax extends BlockSyntax {
128 8 : @override
129 8 : RegExp get pattern => RegExp(r'^[ ]{0,3}\$\$(.*)$');
130 :
131 : final endPattern = RegExp(r'^(.*)\$\$\s*$');
132 :
133 0 : @override
134 : List<Line?> parseChildLines(BlockParser parser) {
135 0 : final childLines = <Line>[];
136 : var first = true;
137 0 : while (!parser.isDone) {
138 0 : final match = endPattern.firstMatch(parser.current.content);
139 0 : if (match == null || (first && match[1]!.trim().isEmpty)) {
140 0 : childLines.add(parser.current);
141 0 : parser.advance();
142 : } else {
143 0 : childLines.add(Line(match[1]!));
144 0 : parser.advance();
145 : break;
146 : }
147 : first = false;
148 : }
149 : return childLines;
150 : }
151 :
152 0 : @override
153 : Node parse(BlockParser parser) {
154 0 : final childLines = parseChildLines(parser);
155 : // we use .substring(2) as childLines will *always* contain the first two '$$'
156 0 : final latex = childLines.join('\n').trim().substring(2).trim();
157 0 : final element = Element('div', [
158 0 : Element('pre', [Element.text('code', htmlEscape.convert(latex))])
159 : ]);
160 0 : element.attributes['data-mx-maths'] = htmlAttrEscape.convert(latex);
161 : return element;
162 : }
163 : }
164 :
165 : class PillSyntax extends InlineSyntax {
166 8 : PillSyntax()
167 8 : : super(
168 : r'([@#!][^\s:]*:(?:[^\s]+\.\w+|[\d\.]+|\[[a-fA-F0-9:]+\])(?::\d+)?)');
169 :
170 2 : @override
171 : bool onMatch(InlineParser parser, Match match) {
172 4 : if (match.start > 0 &&
173 12 : !RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) {
174 6 : parser.addNode(Text(match[0]!));
175 : return true;
176 : }
177 2 : final identifier = match[1]!;
178 4 : final element = Element.text('a', htmlEscape.convert(identifier));
179 4 : element.attributes['href'] =
180 4 : htmlAttrEscape.convert('https://matrix.to/#/$identifier');
181 2 : parser.addNode(element);
182 : return true;
183 : }
184 : }
185 :
186 : class MentionSyntax extends InlineSyntax {
187 : final String? Function(String)? getMention;
188 16 : MentionSyntax(this.getMention) : super(r'(@(?:\[[^\]:]+\]|\w+)(?:#\w+)?)');
189 :
190 4 : @override
191 : bool onMatch(InlineParser parser, Match match) {
192 12 : final mention = getMention?.call(match[1]!);
193 8 : if ((match.start > 0 &&
194 24 : !RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) ||
195 : mention == null) {
196 12 : parser.addNode(Text(match[0]!));
197 : return true;
198 : }
199 6 : final element = Element.text('a', htmlEscape.convert(match[1]!));
200 4 : element.attributes['href'] =
201 4 : htmlAttrEscape.convert('https://matrix.to/#/$mention');
202 2 : parser.addNode(element);
203 : return true;
204 : }
205 : }
206 :
207 8 : String markdown(
208 : String text, {
209 : Map<String, Map<String, String>> Function()? getEmotePacks,
210 : String? Function(String)? getMention,
211 : bool convertLinebreaks = true,
212 : }) {
213 8 : var ret = markdownToHtml(
214 : text,
215 8 : extensionSet: ExtensionSet.commonMark,
216 8 : blockSyntaxes: [
217 8 : BlockLatexSyntax(),
218 : ],
219 8 : inlineSyntaxes: [
220 8 : StrikethroughSyntax(),
221 8 : SpoilerSyntax(),
222 8 : EmoteSyntax(getEmotePacks),
223 8 : PillSyntax(),
224 8 : MentionSyntax(getMention),
225 8 : InlineLatexSyntax(),
226 : ],
227 : );
228 :
229 24 : var stripPTags = '<p>'.allMatches(ret).length <= 1;
230 : if (stripPTags) {
231 : const otherBlockTags = {
232 : 'table',
233 : 'pre',
234 : 'ol',
235 : 'ul',
236 : 'h1',
237 : 'h2',
238 : 'h3',
239 : 'h4',
240 : 'h5',
241 : 'h6',
242 : 'blockquote',
243 : 'div',
244 : };
245 16 : for (final tag in otherBlockTags) {
246 : // we check for the close tag as the opening one might have attributes
247 16 : if (ret.contains('</$tag>')) {
248 : stripPTags = false;
249 : break;
250 : }
251 : }
252 : }
253 : ret = ret
254 8 : .trim()
255 : // Remove trailing linebreaks
256 16 : .replaceAll(RegExp(r'(<br />)+$'), '');
257 : if (convertLinebreaks) {
258 : // Only convert linebreaks which are not in <pre> blocks
259 7 : ret = ret.convertLinebreaksToBr('p');
260 : // Delete other linebreaks except for pre blocks:
261 7 : ret = ret.convertLinebreaksToBr('pre', exclude: true, replaceWith: '');
262 : }
263 :
264 : if (stripPTags) {
265 14 : ret = ret.replaceAll('<p>', '').replaceAll('</p>', '');
266 : }
267 :
268 : return ret;
269 : }
270 :
271 : extension on String {
272 7 : String convertLinebreaksToBr(String tagName,
273 : {bool exclude = false, String replaceWith = '<br/>'}) {
274 14 : final parts = split('$tagName>');
275 : var convertLinebreaks = exclude;
276 21 : for (var i = 0; i < parts.length; i++) {
277 21 : if (convertLinebreaks) parts[i] = parts[i].replaceAll('\n', replaceWith);
278 : convertLinebreaks = !convertLinebreaks;
279 : }
280 14 : return parts.join('$tagName>');
281 : }
282 : }
|