-
Notifications
You must be signed in to change notification settings - Fork 286
/
Copy pathcontent.dart
1331 lines (1112 loc) · 44.1 KB
/
content.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart';
import 'code_block.dart';
/// A node in a parse tree for Zulip message-style content.
///
/// See [ZulipContent].
///
/// When implementing subclasses:
/// * Override [==] and [hashCode] when they are cheap, i.e. when there is
/// an O(1) quantity of data under the node. These are for testing
/// and debugging.
/// * Don't override [==] or [hashCode] when the data includes a list.
/// This avoids accidentally doing a lot of work in an operation that
/// looks like it should be cheap.
/// * Don't override [toString].
/// * Override [debugDescribeChildren] and/or [debugFillProperties]
/// to report all the data attached to the node, for debugging.
/// See docs: https://api.flutter.dev/flutter/foundation/Diagnosticable/debugFillProperties.html
/// We also rely on these for comparing actual to expected in tests.
///
/// When modifying subclasses, always check the following places
/// to see if they need a matching update:
/// * [==] and [hashCode], if overridden.
/// * [debugFillProperties] and/or [debugDescribeChildren].
///
/// In particular, a newly-added field typically must be added in
/// [debugFillProperties]. Otherwise tests will not examine the new field,
/// and will not spot discrepancies there.
@immutable
sealed class ContentNode extends DiagnosticableTree {
const ContentNode({this.debugHtmlNode});
final dom.Node? debugHtmlNode;
String get debugHtmlText {
final node = debugHtmlNode;
if (node == null) return "(elided)";
if (node is dom.Element) return node.outerHtml;
if (node is dom.Text) return "(text «${node.text}»)";
return "(node of type ${node.nodeType})";
}
@override
String toStringShort() => objectRuntimeType(this, 'ContentNode');
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
String? result;
assert(() {
result = toStringDeep(minLevel: minLevel);
return true;
}());
return result ?? toStringShort();
}
}
/// A node corresponding to HTML that this client doesn't know how to parse.
mixin UnimplementedNode on ContentNode {
dom.Node get htmlNode;
@override
dom.Node get debugHtmlNode => htmlNode;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('html', debugHtmlText));
}
}
/// A complete parse tree for a Zulip message's content,
/// or other complete piece of Zulip HTML content.
///
/// This is a parsed representation for an entire value of [Message.content],
/// [Stream.renderedDescription], or other text from a Zulip server that comes
/// in the same Zulip HTML format.
class ZulipContent extends ContentNode {
const ZulipContent({super.debugHtmlNode, required this.nodes});
final List<BlockContentNode> nodes;
@override
List<DiagnosticsNode> debugDescribeChildren() {
return nodes.map((node) => node.toDiagnosticsNode()).toList();
}
}
/// A content node that expects a block layout context from its parent.
///
/// When rendered as Flutter widgets, these become children of a [Column]
/// created by the parent node's widget.
///
/// Generally these correspond to HTML elements which in the Zulip web client
/// are laid out as block-level boxes, in a block formatting context:
/// <https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flow_layout/Block_and_inline_layout_in_normal_flow>
///
/// Almost all nodes are either a [BlockContentNode] or an [InlineContentNode].
abstract class BlockContentNode extends ContentNode {
const BlockContentNode({super.debugHtmlNode});
}
/// A block node corresponding to HTML that this client doesn't know how to parse.
class UnimplementedBlockContentNode extends BlockContentNode
with UnimplementedNode {
const UnimplementedBlockContentNode({required this.htmlNode});
@override
final dom.Node htmlNode;
// No ==/hashCode, because htmlNode is a whole subtree.
}
class _BlockContentListNode extends DiagnosticableTree {
const _BlockContentListNode(this.nodes);
final List<BlockContentNode> nodes;
@override
String toStringShort() => 'BlockContentNode list';
@override
List<DiagnosticsNode> debugDescribeChildren() {
return nodes.map((node) => node.toDiagnosticsNode()).toList();
}
}
/// A block content node whose children are inline content nodes.
///
/// A node of this type expects a block layout context from its parent,
/// but provides an inline layout context for its children.
///
/// See also [InlineContainerNode].
class BlockInlineContainerNode extends BlockContentNode {
const BlockInlineContainerNode({
super.debugHtmlNode,
required this.links,
required this.nodes,
});
/// A list of all [LinkNode] descendants.
///
/// An empty list is represented as null.
///
/// Because this lists all descendants that are [LinkNode]s,
/// it carries no information that couldn't be computed from [nodes].
/// It exists as an optimization, to allow a widget interpreting this node
/// to obtain that list during build without having to walk the [nodes] tree.
//
// We leave [links] out of [debugFillProperties], because it should carry
// no information that's not already in [nodes].
// Our tests validate that invariant systematically
// (see `_checkLinks` in `test/model/content_checks.dart`),
// and give a specialized error message if it fails.
final List<LinkNode>? links; // TODO perhaps use `const []` instead of null
final List<InlineContentNode> nodes;
@override
List<DiagnosticsNode> debugDescribeChildren() {
return nodes.map((node) => node.toDiagnosticsNode()).toList();
}
}
// A `br` element.
class LineBreakNode extends BlockContentNode {
const LineBreakNode({super.debugHtmlNode});
@override
bool operator ==(Object other) {
return other is LineBreakNode;
}
@override
int get hashCode => 'LineBreakNode'.hashCode;
}
/// A `hr` element
class ThematicBreakNode extends BlockContentNode {
const ThematicBreakNode({super.debugHtmlNode});
@override
bool operator ==(Object other) {
return other is ThematicBreakNode;
}
@override
int get hashCode => 'ThematicBreakNode'.hashCode;
}
/// A `p` element, or a place where the DOM tree logically wanted one.
///
/// We synthesize these in the absence of an actual `p` element in cases where
/// there's inline content (like [dom.Text] nodes, links, or spans) in a context
/// where block content can also appear (like inside a `li`.) These are marked
/// with [wasImplicit].
///
/// See also [parseImplicitParagraphBlockContentList].
class ParagraphNode extends BlockInlineContainerNode {
const ParagraphNode({
super.debugHtmlNode,
this.wasImplicit = false,
required super.links,
required super.nodes,
});
/// True when there was no corresponding `p` element in the original HTML.
final bool wasImplicit;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(FlagProperty('wasImplicit', value: wasImplicit, ifTrue: 'was implicit'));
}
}
enum HeadingLevel { h1, h2, h3, h4, h5, h6 }
class HeadingNode extends BlockInlineContainerNode {
const HeadingNode({
super.debugHtmlNode,
required super.links,
required super.nodes,
required this.level,
});
final HeadingLevel level;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(EnumProperty('level', level));
}
}
enum ListStyle { ordered, unordered }
class ListNode extends BlockContentNode {
const ListNode(this.style, this.items, {super.debugHtmlNode});
final ListStyle style;
final List<List<BlockContentNode>> items;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(FlagProperty('ordered', value: style == ListStyle.ordered,
ifTrue: 'ordered', ifFalse: 'unordered'));
}
@override
List<DiagnosticsNode> debugDescribeChildren() {
return items
.mapIndexed((i, nodes) =>
_BlockContentListNode(nodes).toDiagnosticsNode(name: 'item $i'))
.toList();
}
}
class QuotationNode extends BlockContentNode {
const QuotationNode(this.nodes, {super.debugHtmlNode});
final List<BlockContentNode> nodes;
@override
List<DiagnosticsNode> debugDescribeChildren() {
return nodes.map((node) => node.toDiagnosticsNode()).toList();
}
}
class SpoilerNode extends BlockContentNode {
const SpoilerNode({super.debugHtmlNode, required this.header, required this.content});
final List<BlockContentNode> header;
final List<BlockContentNode> content;
@override
List<DiagnosticsNode> debugDescribeChildren() {
return [
_BlockContentListNode(header).toDiagnosticsNode(name: 'header'),
_BlockContentListNode(content).toDiagnosticsNode(name: 'content'),
];
}
}
class CodeBlockNode extends BlockContentNode {
const CodeBlockNode(this.spans, {super.debugHtmlNode});
final List<CodeBlockSpanNode> spans;
@override
List<DiagnosticsNode> debugDescribeChildren() {
return spans.map((node) => node.toDiagnosticsNode()).toList();
}
}
class CodeBlockSpanNode extends InlineContentNode {
const CodeBlockSpanNode({super.debugHtmlNode, required this.text, required this.type});
final String text;
final CodeBlockSpanType type;
@override
bool operator ==(Object other) {
return other is CodeBlockSpanNode && other.text == text && other.type == type;
}
@override
int get hashCode => Object.hash('CodeBlockSpanNode', text, type);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('text', text));
properties.add(EnumProperty('type', type));
}
}
class MathBlockNode extends BlockContentNode {
const MathBlockNode({super.debugHtmlNode, required this.texSource});
final String texSource;
@override
bool operator ==(Object other) {
return other is MathBlockNode && other.texSource == texSource;
}
@override
int get hashCode => Object.hash('MathBlockNode', texSource);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('texSource', texSource));
}
}
class ImageNodeList extends BlockContentNode {
const ImageNodeList(this.images, {super.debugHtmlNode});
final List<ImageNode> images;
@override
List<DiagnosticsNode> debugDescribeChildren() {
return images.map((node) => node.toDiagnosticsNode()).toList();
}
}
class ImageNode extends BlockContentNode {
const ImageNode({super.debugHtmlNode, required this.srcUrl});
/// The unmodified `src` attribute for the image.
///
/// This may be a relative URL string. It also may not work without adding
/// authentication credentials to the request.
final String srcUrl;
@override
bool operator ==(Object other) {
return other is ImageNode && other.srcUrl == srcUrl;
}
@override
int get hashCode => Object.hash('ImageNode', srcUrl);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('srcUrl', srcUrl));
}
}
class InlineVideoNode extends BlockContentNode {
const InlineVideoNode({
super.debugHtmlNode,
required this.srcUrl,
});
/// A URL string for the video resource, on the Zulip server.
///
/// This may be a relative URL string. It also may not work without adding
/// authentication credentials to the request.
///
/// Unlike [EmbedVideoNode.hrefUrl], this should always be a URL served by
/// either the Zulip server itself or a service it trusts. It's therefore
/// fine from a privacy perspective to eagerly request data from this resource
/// when the user passively scrolls the video into view.
final String srcUrl;
@override
bool operator ==(Object other) {
return other is InlineVideoNode
&& other.srcUrl == srcUrl;
}
@override
int get hashCode => Object.hash('InlineVideoNode', srcUrl);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('srcUrl', srcUrl));
}
}
class EmbedVideoNode extends BlockContentNode {
const EmbedVideoNode({
super.debugHtmlNode,
required this.hrefUrl,
required this.previewImageSrcUrl,
});
/// A URL string for the video, typically on an external service.
///
/// For example, this URL may be on youtube.com or vimeo.com.
///
/// Unlike with [previewImageSrcUrl] or [InlineVideoNode.srcUrl],
/// no requests should be made to this URL unless the user explicitly chooses
/// to interact with the video, in order to protect the user's privacy.
final String hrefUrl;
/// A URL string for a thumbnail image for the video, on the Zulip server.
///
/// This may be a relative URL string. It also may not work without adding
/// authentication credentials to the request.
///
/// Like [InlineVideoNode.srcUrl] and unlike [hrefUrl], this is suitable
/// from a privacy perspective for eagerly fetching data when the user
/// passively scrolls the video into view.
final String previewImageSrcUrl;
@override
bool operator ==(Object other) {
return other is EmbedVideoNode
&& other.hrefUrl == hrefUrl
&& other.previewImageSrcUrl == previewImageSrcUrl;
}
@override
int get hashCode => Object.hash('EmbedVideoNode', hrefUrl, previewImageSrcUrl);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('hrefUrl', hrefUrl));
properties.add(StringProperty('previewImageSrcUrl', previewImageSrcUrl));
}
}
/// A content node that expects an inline layout context from its parent.
///
/// When rendered into a Flutter widget tree, an inline content node
/// becomes an [InlineSpan], not a widget. It therefore participates
/// in paragraph layout, as a portion of the paragraph's text.
///
/// Generally these correspond to HTML elements which in the Zulip web client
/// are laid out as inline boxes, in an inline formatting context:
/// https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_flow_layout/Block_and_inline_layout_in_normal_flow#elements_participating_in_an_inline_formatting_context
///
/// Almost all nodes are either an [InlineContentNode] or a [BlockContentNode].
abstract class InlineContentNode extends ContentNode {
const InlineContentNode({super.debugHtmlNode});
}
/// An inline node corresponding to HTML that this client doesn't know how to parse.
class UnimplementedInlineContentNode extends InlineContentNode
with UnimplementedNode {
const UnimplementedInlineContentNode({required this.htmlNode});
@override
final dom.Node htmlNode;
}
/// A node consisting of pure text, with no markup of its own.
///
/// This node type is how plain text is represented. This is also the type
/// of the leaf nodes that ultimately provide the actual text in the
/// parse tree for any piece of content that contains text in a link, italics,
/// bold, a list, a blockquote, or many other constructs.
class TextNode extends InlineContentNode {
const TextNode(this.text, {super.debugHtmlNode});
final String text;
@override
bool operator ==(Object other) {
return other is TextNode
&& other.text == text;
}
@override
int get hashCode => Object.hash('TextNode', text);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('text', text, showName: false));
}
}
class LineBreakInlineNode extends InlineContentNode {
const LineBreakInlineNode({super.debugHtmlNode});
@override
bool operator ==(Object other) => other is LineBreakInlineNode;
@override
int get hashCode => 'LineBreakInlineNode'.hashCode;
}
/// An inline content node which contains other inline content nodes.
///
/// A node of this type expects an inline layout context from its parent,
/// and provides an inline layout context for its children.
///
/// Typically this is realized by building a [TextSpan] whose children are
/// the [InlineSpan]s built from this node's children. In that case,
/// the children participate in the same paragraph layout as this node
/// itself does.
///
/// See also [BlockInlineContainerNode].
abstract class InlineContainerNode extends InlineContentNode {
const InlineContainerNode({super.debugHtmlNode, required this.nodes});
final List<InlineContentNode> nodes;
@override
List<DiagnosticsNode> debugDescribeChildren() {
return nodes.map((node) => node.toDiagnosticsNode()).toList();
}
// No ==/hashCode, because contains nodes.
}
class StrongNode extends InlineContainerNode {
const StrongNode({super.debugHtmlNode, required super.nodes});
}
class DeletedNode extends InlineContainerNode {
const DeletedNode({super.debugHtmlNode, required super.nodes});
}
class EmphasisNode extends InlineContainerNode {
const EmphasisNode({super.debugHtmlNode, required super.nodes});
}
class InlineCodeNode extends InlineContainerNode {
const InlineCodeNode({super.debugHtmlNode, required super.nodes});
}
class LinkNode extends InlineContainerNode {
const LinkNode({super.debugHtmlNode, required super.nodes, required this.url});
final String url; // Left as a string, to defer parsing until link actually followed.
// Unlike other [ContentNode]s, the identity is useful to show in debugging
// because the identical [LinkNode]s are expected in the enclosing
// [BlockInlineContainerNode.links].
@override
String toStringShort() => "${objectRuntimeType(this, 'LinkNode')}#${shortHash(this)}";
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('url', url));
}
}
enum UserMentionType { user, userGroup }
class UserMentionNode extends InlineContainerNode {
const UserMentionNode({
super.debugHtmlNode,
required super.nodes,
// required this.mentionType,
// required this.isSilent,
});
// We don't currently seem to need this information in code. Instead,
// the inner text already shows how to communicate it to the user
// (e.g., silent mentions' text lacks a leading "@"),
// and we show that text in the same style for all types of @-mention.
// If we need this information in the future, go ahead and add it here.
// final UserMentionType mentionType;
// final bool isSilent;
}
abstract class EmojiNode extends InlineContentNode {
const EmojiNode({super.debugHtmlNode});
}
class UnicodeEmojiNode extends EmojiNode {
const UnicodeEmojiNode({super.debugHtmlNode, required this.emojiUnicode});
final String emojiUnicode;
@override
bool operator ==(Object other) {
return other is UnicodeEmojiNode && other.emojiUnicode == emojiUnicode;
}
@override
int get hashCode => Object.hash('UnicodeEmojiNode', emojiUnicode);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('emojiUnicode', emojiUnicode));
}
}
class ImageEmojiNode extends EmojiNode {
const ImageEmojiNode({super.debugHtmlNode, required this.src, required this.alt });
final String src;
final String alt;
@override
bool operator ==(Object other) {
return other is ImageEmojiNode && other.src == src && other.alt == alt;
}
@override
int get hashCode => Object.hash('ImageEmojiNode', src, alt);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('alt', alt));
properties.add(StringProperty('src', src));
}
}
class MathInlineNode extends InlineContentNode {
const MathInlineNode({super.debugHtmlNode, required this.texSource});
final String texSource;
@override
bool operator ==(Object other) {
return other is MathInlineNode && other.texSource == texSource;
}
@override
int get hashCode => Object.hash('MathInlineNode', texSource);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(StringProperty('texSource', texSource));
}
}
class GlobalTimeNode extends InlineContentNode {
const GlobalTimeNode({super.debugHtmlNode, required this.datetime});
/// Always in UTC, enforced in [_ZulipContentParser.parseInlineContent].
final DateTime datetime;
@override
bool operator ==(Object other) {
return other is GlobalTimeNode && other.datetime == datetime;
}
@override
int get hashCode => Object.hash('GlobalTimeNode', datetime);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties.add(DiagnosticsProperty<DateTime>('datetime', datetime));
}
}
////////////////////////////////////////////////////////////////
// Ported from https://github.com/zulip/zulip-mobile/blob/c979530d6804db33310ed7d14a4ac62017432944/src/emoji/data.js#L108-L112
//
// Which was in turn ported from https://github.com/zulip/zulip/blob/63c9296d5339517450f79f176dc02d77b08020c8/zerver/models.py#L3235-L3242
// and that describes the encoding as follows:
//
// > * For Unicode emoji, [emoji_code is] a dash-separated hex encoding of
// > the sequence of Unicode codepoints that define this emoji in the
// > Unicode specification. For examples, see "non_qualified" or
// > "unified" in the following data, with "non_qualified" taking
// > precedence when both present:
// > https://raw.githubusercontent.com/iamcal/emoji-data/master/emoji_pretty.json
String? tryParseEmojiCodeToUnicode(String code) {
try {
return String.fromCharCodes(code.split('-').map((hex) => int.parse(hex, radix: 16)));
} on FormatException { // thrown by `int.parse`
return null;
} on ArgumentError { // thrown by `String.fromCharCodes`
return null;
}
}
/// What sort of nodes a [_ZulipContentParser] is currently expecting to find.
enum _ParserContext {
/// The parser is currently looking for block nodes.
block,
/// The parser is currently looking for inline nodes.
inline,
}
class _ZulipContentParser {
/// The current state of what sort of nodes the parser is looking for.
///
/// This exists for the sake of debug-mode checks,
/// and should be read or updated only inside an assertion.
_ParserContext _debugParserContext = _ParserContext.block;
String? parseMath(dom.Element element, {required bool block}) {
assert(block == (_debugParserContext == _ParserContext.block));
final dom.Element katexElement;
if (!block) {
assert(element.localName == 'span' && element.className == 'katex');
katexElement = element;
} else {
assert(element.localName == 'span' && element.className == 'katex-display');
if (element.nodes.length != 1) return null;
final child = element.nodes.single;
if (child is! dom.Element) return null;
if (child.localName != 'span') return null;
if (child.className != 'katex') return null;
katexElement = child;
}
// Expect two children span.katex-mathml, span.katex-html .
// For now we only care about the .katex-mathml .
if (katexElement.nodes.isEmpty) return null;
final child = katexElement.nodes.first;
if (child is! dom.Element) return null;
if (child.localName != 'span') return null;
if (child.className != 'katex-mathml') return null;
if (child.nodes.length != 1) return null;
final grandchild = child.nodes.single;
if (grandchild is! dom.Element) return null;
if (grandchild.localName != 'math') return null;
if (grandchild.attributes['display'] != (block ? 'block' : null)) return null;
if (grandchild.namespaceUri != 'http://www.w3.org/1998/Math/MathML') return null;
if (grandchild.nodes.length != 1) return null;
final greatgrand = grandchild.nodes.single;
if (greatgrand is! dom.Element) return null;
if (greatgrand.localName != 'semantics') return null;
if (greatgrand.nodes.isEmpty) return null;
final descendant4 = greatgrand.nodes.last;
if (descendant4 is! dom.Element) return null;
if (descendant4.localName != 'annotation') return null;
if (descendant4.attributes['encoding'] != 'application/x-tex') return null;
return descendant4.text.trim();
}
/// The links found so far in the current block inline container.
///
/// Empty is represented as null.
/// This is also null when not within a block inline container.
List<LinkNode>? _linkNodes;
List<LinkNode>? _takeLinkNodes() {
final result = _linkNodes;
_linkNodes = null;
return result;
}
static final _userMentionClassNameRegexp = () {
// This matches a class `user-mention` or `user-group-mention`,
// plus an optional class `silent`, appearing in either order.
const mentionClass = r"user(?:-group)?-mention";
return RegExp("^(?:$mentionClass(?: silent)?|silent $mentionClass)\$");
}();
static final _emojiClassNameRegexp = () {
const specificEmoji = r"emoji(?:-[0-9a-f]+)+";
return RegExp("^(?:emoji $specificEmoji|$specificEmoji emoji)\$");
}();
static final _emojiCodeFromClassNameRegexp = RegExp(r"emoji-([^ ]+)");
InlineContentNode parseInlineContent(dom.Node node) {
assert(_debugParserContext == _ParserContext.inline);
final debugHtmlNode = kDebugMode ? node : null;
InlineContentNode unimplemented() => UnimplementedInlineContentNode(htmlNode: node);
if (node is dom.Text) {
return TextNode(node.text, debugHtmlNode: debugHtmlNode);
}
if (node is! dom.Element) {
return unimplemented();
}
final element = node;
final localName = element.localName;
final className = element.className;
List<InlineContentNode> nodes() => parseInlineContentList(element.nodes);
if (localName == 'br' && className.isEmpty) {
return LineBreakInlineNode(debugHtmlNode: debugHtmlNode);
}
if (localName == 'strong' && className.isEmpty) {
return StrongNode(nodes: nodes(), debugHtmlNode: debugHtmlNode);
}
if (localName == 'del' && className.isEmpty) {
return DeletedNode(nodes: nodes(), debugHtmlNode: debugHtmlNode);
}
if (localName == 'em' && className.isEmpty) {
return EmphasisNode(nodes: nodes(), debugHtmlNode: debugHtmlNode);
}
if (localName == 'code' && className.isEmpty) {
return InlineCodeNode(nodes: nodes(), debugHtmlNode: debugHtmlNode);
}
if (localName == 'a'
&& (className.isEmpty
|| (className == 'stream-topic' || className == 'stream'))) {
final href = element.attributes['href'];
if (href == null) return unimplemented();
final link = LinkNode(nodes: nodes(), url: href, debugHtmlNode: debugHtmlNode);
(_linkNodes ??= []).add(link);
return link;
}
if (localName == 'span'
&& _userMentionClassNameRegexp.hasMatch(className)) {
// TODO assert UserMentionNode can't contain LinkNode;
// either a debug-mode check, or perhaps we can make expectations much
// tighter on a UserMentionNode's contents overall.
return UserMentionNode(nodes: nodes(), debugHtmlNode: debugHtmlNode);
}
if (localName == 'span'
&& _emojiClassNameRegexp.hasMatch(className)) {
final emojiCode = _emojiCodeFromClassNameRegexp.firstMatch(className)!
.group(1)!;
final unicode = tryParseEmojiCodeToUnicode(emojiCode);
if (unicode == null) return unimplemented();
return UnicodeEmojiNode(emojiUnicode: unicode, debugHtmlNode: debugHtmlNode);
}
if (localName == 'img' && className == 'emoji') {
final alt = element.attributes['alt'];
if (alt == null) return unimplemented();
final src = element.attributes['src'];
if (src == null) return unimplemented();
return ImageEmojiNode(src: src, alt: alt, debugHtmlNode: debugHtmlNode);
}
if (localName == 'time' && className.isEmpty) {
final dateTimeAttr = element.attributes['datetime'];
if (dateTimeAttr == null) return unimplemented();
// This attribute is always in ISO 8601 format with a Z suffix;
// see `Timestamp` in zulip:zerver/lib/markdown/__init__.py .
final datetime = DateTime.tryParse(dateTimeAttr);
if (datetime == null) return unimplemented();
if (!datetime.isUtc) return unimplemented();
return GlobalTimeNode(datetime: datetime, debugHtmlNode: debugHtmlNode);
}
if (localName == 'span' && className == 'katex') {
final texSource = parseMath(element, block: false);
if (texSource == null) return unimplemented();
return MathInlineNode(texSource: texSource, debugHtmlNode: debugHtmlNode);
}
// TODO more types of node
return unimplemented();
}
List<InlineContentNode> parseInlineContentList(List<dom.Node> nodes) {
assert(_debugParserContext == _ParserContext.inline);
return nodes.map(parseInlineContent).toList(growable: false);
}
({List<InlineContentNode> nodes, List<LinkNode>? links}) parseBlockInline(List<dom.Node> nodes) {
assert(_debugParserContext == _ParserContext.block);
assert(() {
_debugParserContext = _ParserContext.inline;
return true;
}());
final resultNodes = parseInlineContentList(nodes);
assert(() {
_debugParserContext = _ParserContext.block;
return true;
}());
return (nodes: resultNodes, links: _takeLinkNodes());
}
BlockContentNode parseListNode(dom.Element element) {
assert(_debugParserContext == _ParserContext.block);
ListStyle? listStyle;
switch (element.localName) {
case 'ol': listStyle = ListStyle.ordered; break;
case 'ul': listStyle = ListStyle.unordered; break;
}
assert(listStyle != null);
assert(element.className.isEmpty);
final debugHtmlNode = kDebugMode ? element : null;
final List<List<BlockContentNode>> items = [];
for (final item in element.nodes) {
if (item is dom.Text && item.text == '\n') continue;
if (item is! dom.Element || item.localName != 'li' || item.className.isNotEmpty) {
items.add([UnimplementedBlockContentNode(htmlNode: item)]);
}
items.add(parseImplicitParagraphBlockContentList(item.nodes));
}
return ListNode(listStyle!, items, debugHtmlNode: debugHtmlNode);
}
BlockContentNode parseSpoilerNode(dom.Element divElement) {
assert(_debugParserContext == _ParserContext.block);
assert(divElement.localName == 'div'
&& divElement.className == 'spoiler-block');
if (divElement.nodes case [
dom.Element(
localName: 'div', className: 'spoiler-header', nodes: var headerNodes),
dom.Element(
localName: 'div', className: 'spoiler-content', nodes: var contentNodes),
]) {
return SpoilerNode(
header: parseBlockContentList(headerNodes),
content: parseBlockContentList(contentNodes),
);
} else {
return UnimplementedBlockContentNode(htmlNode: divElement);
}
}
BlockContentNode parseCodeBlock(dom.Element divElement) {
assert(_debugParserContext == _ParserContext.block);
final mainElement = () {
assert(divElement.localName == 'div'
&& divElement.className == "codehilite");
if (divElement.nodes.length != 1) return null;
final child = divElement.nodes[0];
if (child is! dom.Element) return null;
if (child.localName != 'pre') return null;
if (child.nodes.length > 2) return null;
if (child.nodes.length == 2) {
final first = child.nodes[0];
if (first is! dom.Element
|| first.localName != 'span'
|| first.nodes.isNotEmpty) return null;
}
final grandchild = child.nodes[child.nodes.length - 1];
if (grandchild is! dom.Element) return null;
if (grandchild.localName != 'code') return null;
return grandchild;
}();
final debugHtmlNode = kDebugMode ? divElement : null;
if (mainElement == null) {
return UnimplementedBlockContentNode(htmlNode: divElement);
}
final spans = <CodeBlockSpanNode>[];
for (int i = 0; i < mainElement.nodes.length; i++) {
final child = mainElement.nodes[i];
final CodeBlockSpanNode span;
switch (child) {
case dom.Text(:var text):
if (i == mainElement.nodes.length - 1) {
// The HTML tends to have a final newline here. If included in the
// [Text] widget, that would make a trailing blank line. So cut it out.
text = text.replaceFirst(RegExp(r'\n$'), '');
}
if (text.isEmpty) {
continue;
}
span = CodeBlockSpanNode(text: text, type: CodeBlockSpanType.text);
case dom.Element(localName: 'span', :final text, :final className):
final CodeBlockSpanType type = codeBlockSpanTypeFromClassName(className);
switch (type) {
case CodeBlockSpanType.unknown:
// TODO(#194): Show these as un-syntax-highlighted code, in production.
return UnimplementedBlockContentNode(htmlNode: divElement);
case CodeBlockSpanType.highlightedLines:
// TODO: Implement nesting in CodeBlockSpanNode to support hierarchically
// inherited styles for `span.hll` nodes.
return UnimplementedBlockContentNode(htmlNode: divElement);