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 9 : SpoilerSyntax()
27 9 : : super(
28 : r'\|\|',
29 : requiresDelimiterRun: true,
30 18 : 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 18 : 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 18 : 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 9 : @override
129 9 : 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 9 : PillSyntax()
167 9 : : super(
168 : r'([@#!][^\s:]*:(?:[^\s]+\.\w+|[\d\.]+|\[[a-fA-F0-9:]+\])(?::\d+)?)',
169 : );
170 :
171 2 : @override
172 : bool onMatch(InlineParser parser, Match match) {
173 4 : if (match.start > 0 &&
174 12 : !RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) {
175 6 : parser.addNode(Text(match[0]!));
176 : return true;
177 : }
178 2 : final identifier = match[1]!;
179 4 : final element = Element.text('a', htmlEscape.convert(identifier));
180 4 : element.attributes['href'] =
181 4 : htmlAttrEscape.convert('https://matrix.to/#/$identifier');
182 2 : parser.addNode(element);
183 : return true;
184 : }
185 : }
186 :
187 : class MentionSyntax extends InlineSyntax {
188 : final String? Function(String)? getMention;
189 18 : MentionSyntax(this.getMention) : super(r'(@(?:\[[^\]:]+\]|\w+)(?:#\w+)?)');
190 :
191 2 : @override
192 : bool onMatch(InlineParser parser, Match match) {
193 6 : final mention = getMention?.call(match[1]!);
194 4 : if ((match.start > 0 &&
195 12 : !RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) ||
196 : mention == null) {
197 6 : parser.addNode(Text(match[0]!));
198 : return true;
199 : }
200 6 : final element = Element.text('a', htmlEscape.convert(match[1]!));
201 4 : element.attributes['href'] =
202 4 : htmlAttrEscape.convert('https://matrix.to/#/$mention');
203 2 : parser.addNode(element);
204 : return true;
205 : }
206 : }
207 :
208 9 : String markdown(
209 : String text, {
210 : Map<String, Map<String, String>> Function()? getEmotePacks,
211 : String? Function(String)? getMention,
212 : bool convertLinebreaks = true,
213 : }) {
214 9 : var ret = markdownToHtml(
215 : text,
216 9 : extensionSet: ExtensionSet.commonMark,
217 9 : blockSyntaxes: [
218 9 : BlockLatexSyntax(),
219 : ],
220 9 : inlineSyntaxes: [
221 9 : StrikethroughSyntax(),
222 9 : SpoilerSyntax(),
223 9 : EmoteSyntax(getEmotePacks),
224 9 : PillSyntax(),
225 9 : MentionSyntax(getMention),
226 9 : InlineLatexSyntax(),
227 : ],
228 : );
229 :
230 27 : var stripPTags = '<p>'.allMatches(ret).length <= 1;
231 : if (stripPTags) {
232 : const otherBlockTags = {
233 : 'table',
234 : 'pre',
235 : 'ol',
236 : 'ul',
237 : 'h1',
238 : 'h2',
239 : 'h3',
240 : 'h4',
241 : 'h5',
242 : 'h6',
243 : 'blockquote',
244 : 'div',
245 : };
246 18 : for (final tag in otherBlockTags) {
247 : // we check for the close tag as the opening one might have attributes
248 18 : if (ret.contains('</$tag>')) {
249 : stripPTags = false;
250 : break;
251 : }
252 : }
253 : }
254 : ret = ret
255 9 : .trim()
256 : // Remove trailing linebreaks
257 18 : .replaceAll(RegExp(r'(<br />)+$'), '');
258 : if (convertLinebreaks) {
259 : // Only convert linebreaks which are not in <pre> blocks
260 7 : ret = ret.convertLinebreaksToBr('p');
261 : // Delete other linebreaks except for pre blocks:
262 7 : ret = ret.convertLinebreaksToBr('pre', exclude: true, replaceWith: '');
263 : }
264 :
265 : if (stripPTags) {
266 14 : ret = ret.replaceAll('<p>', '').replaceAll('</p>', '');
267 : }
268 :
269 : return ret;
270 : }
271 :
272 : extension on String {
273 7 : String convertLinebreaksToBr(
274 : String tagName, {
275 : bool exclude = false,
276 : String replaceWith = '<br/>',
277 : }) {
278 14 : final parts = split('$tagName>');
279 : var convertLinebreaks = exclude;
280 21 : for (var i = 0; i < parts.length; i++) {
281 21 : if (convertLinebreaks) parts[i] = parts[i].replaceAll('\n', replaceWith);
282 : convertLinebreaks = !convertLinebreaks;
283 : }
284 14 : return parts.join('$tagName>');
285 : }
286 : }
|