LCOV - code coverage report
Current view: top level - lib/src/utils - markdown.dart (source / functions) Coverage Total Hit
Test: merged.info Lines: 85.6 % 104 89
Test Date: 2025-01-14 13:39:53 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              : 
     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              : }
        

Generated by: LCOV version 2.0-1