LCOV - code coverage report
Current view: top level - lib/src/utils - markdown.dart (source / functions) Hit Total Coverage
Test: merged.info Lines: 89 104 85.6 %
Date: 2024-09-28 12:47:43 Functions: 0 0 -

          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           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          18 :   MentionSyntax(this.getMention) : super(r'(@(?:\[[^\]:]+\]|\w+)(?:#\w+)?)');
     189             : 
     190           2 :   @override
     191             :   bool onMatch(InlineParser parser, Match match) {
     192           6 :     final mention = getMention?.call(match[1]!);
     193           4 :     if ((match.start > 0 &&
     194          12 :             !RegExp(r'[\s.!?:;\(]').hasMatch(match.input[match.start - 1])) ||
     195             :         mention == null) {
     196           6 :       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           9 : String markdown(
     208             :   String text, {
     209             :   Map<String, Map<String, String>> Function()? getEmotePacks,
     210             :   String? Function(String)? getMention,
     211             :   bool convertLinebreaks = true,
     212             : }) {
     213           9 :   var ret = markdownToHtml(
     214             :     text,
     215           9 :     extensionSet: ExtensionSet.commonMark,
     216           9 :     blockSyntaxes: [
     217           9 :       BlockLatexSyntax(),
     218             :     ],
     219           9 :     inlineSyntaxes: [
     220           9 :       StrikethroughSyntax(),
     221           9 :       SpoilerSyntax(),
     222           9 :       EmoteSyntax(getEmotePacks),
     223           9 :       PillSyntax(),
     224           9 :       MentionSyntax(getMention),
     225           9 :       InlineLatexSyntax(),
     226             :     ],
     227             :   );
     228             : 
     229          27 :   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          18 :     for (final tag in otherBlockTags) {
     246             :       // we check for the close tag as the opening one might have attributes
     247          18 :       if (ret.contains('</$tag>')) {
     248             :         stripPTags = false;
     249             :         break;
     250             :       }
     251             :     }
     252             :   }
     253             :   ret = ret
     254           9 :       .trim()
     255             :       // Remove trailing linebreaks
     256          18 :       .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             : }

Generated by: LCOV version 1.14