Skip to content
This repository was archived by the owner on Feb 24, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/markdown.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export 'src/block_syntaxes/dummy_block_syntax.dart';
export 'src/block_syntaxes/empty_block_syntax.dart';
export 'src/block_syntaxes/fenced_blockquote_syntax.dart';
export 'src/block_syntaxes/fenced_code_block_syntax.dart';
export 'src/block_syntaxes/footnote_def_syntax.dart';
export 'src/block_syntaxes/header_syntax.dart';
export 'src/block_syntaxes/header_with_id_syntax.dart';
export 'src/block_syntaxes/horizontal_rule_syntax.dart';
Expand Down
1 change: 1 addition & 0 deletions lib/src/ast.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class Element implements Node {
final List<Node>? children;
final Map<String, String> attributes;
String? generatedId;
String? footnoteLabel;

/// Instantiates a [tag] Element with [children].
Element(this.tag, this.children) : attributes = {};
Expand Down
81 changes: 81 additions & 0 deletions lib/src/block_syntaxes/footnote_def_syntax.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import '../ast.dart' show Element, Node;
import '../block_parser.dart' show BlockParser;
import '../line.dart';
import '../patterns.dart' show dummyPattern, emptyPattern, footnotePattern;
import 'block_syntax.dart' show BlockSyntax;

/// The spec of GFM about footnotes is [missing](https://github.com/github/cmark-gfm/issues/283#issuecomment-1378868725).
/// For online source code of cmark-gfm, see [master@c32ef78](https://github.com/github/cmark-gfm/blob/c32ef78/src/blocks.c#L1212).
/// A Rust implementation is also [available](https://github.com/wooorm/markdown-rs/blob/2498e31eecead798efc649502bbf5f86feaa94be/src/construct/gfm_footnote_definition.rs).
/// Footnote definition could contain multiple line-children and children could
/// be separated by one empty line.
/// Its first child-line would be the remaining part of the first line after
/// taking definition leading, combining with other child lines parsed by
/// [parseChildLines], is fed into [BlockParser].
class FootnoteDefSyntax extends BlockSyntax {
const FootnoteDefSyntax();

@override
RegExp get pattern => footnotePattern;

@override
Node? parse(BlockParser parser) {
final current = parser.current.content;
final match = pattern.firstMatch(current)!;
final label = match[2]!;
final refs = parser.document.footnoteReferences;
refs[label] = 0;

final id = Uri.encodeComponent(label);
parser.advance();
final lines = [
Line(current.substring(match[0]!.length)),
...parseChildLines(parser),
];
final children = BlockParser(lines, parser.document).parseLines();
return Element('li', children)
..attributes['id'] = 'fn-$id'
..footnoteLabel = label;
}

@override
List<Line> parseChildLines(BlockParser parser) {
final children = <String>[];
// As one empty line should not split footnote definition, use this flag.
var shouldBeBlock = false;
late final syntaxList = parser.blockSyntaxes
.where((s) => !_excludingPattern.contains(s.pattern));

// Every line is footnote's children util two blank lines or a block.
while (!parser.isDone) {
final line = parser.current.content;
if (line.trim().isEmpty) {
children.add(line);
parser.advance();
shouldBeBlock = true;
continue;
} else if (line.startsWith(' ')) {
children.add(line.substring(4));
parser.advance();
shouldBeBlock = false;
} else if (shouldBeBlock || _isBlock(syntaxList, line)) {
break;
} else {
children.add(line);
parser.advance();
}
}
return children.map(Line.new).toList(growable: false);
}

/// Patterns that would be used to decide if one line is a block.
static final _excludingPattern = {
emptyPattern,
dummyPattern,
};

/// Whether this line is one kind of block, if true footnotes block should end.
static bool _isBlock(Iterable<BlockSyntax> syntaxList, String line) {
return syntaxList.any((s) => s.pattern.hasMatch(line));
}
}
93 changes: 92 additions & 1 deletion lib/src/document.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import 'util.dart';
/// Maintains the context needed to parse a Markdown document.
class Document {
final Map<String, LinkReference> linkReferences = {};

/// Footnote ref count, keys are case-sensitive and added by define syntax.
final footnoteReferences = <String, int>{};

/// Footnotes labels by appearing order, are case-insensitive and added by ref syntax.
final footnoteLabels = <String>[];
final Resolver? linkResolver;
final Resolver? imageLinkResolver;
final bool encodeHtml;
Expand Down Expand Up @@ -78,7 +84,8 @@ class Document {
List<Node> parseLineList(List<Line> lines) {
final nodes = BlockParser(lines, this).parseLines();
_parseInlineContent(nodes);
return nodes;
// Do filter after parsing inline as we need ref count.
return _filterFootnotes(nodes);
}

/// Parses the given inline Markdown [text] to a series of AST nodes.
Expand All @@ -97,6 +104,90 @@ class Document {
}
}
}

/// Footnotes could be defined in arbitrary positions of a document, we need
/// to distinguish them and put them behind; and every footnote definition
/// may have multiple backrefs, we need to append backrefs for it.
List<Node> _filterFootnotes(List<Node> nodes) {
final footnotes = <Element>[];
final blocks = <Node>[];
for (final node in nodes) {
if (node is Element &&
node.tag == 'li' &&
footnoteReferences.containsKey(node.footnoteLabel)) {
final label = node.footnoteLabel;
var count = 0;
if (label != null && (count = footnoteReferences[label] ?? 0) > 0) {
footnotes.add(node);
final children = node.children;
if (children != null) {
_appendBackref(children, Uri.encodeComponent(label), count);
}
}
} else {
blocks.add(node);
}
}

if (footnotes.isNotEmpty) {
// Sort footnotes by appearing order.
final ordinal = {
for (var i = 0; i < footnoteLabels.length; i++)
'fn-${footnoteLabels[i]}': i,
};
footnotes.sort((l, r) {
final idl = l.attributes['id']?.toLowerCase() ?? '';
final idr = r.attributes['id']?.toLowerCase() ?? '';
return (ordinal[idl] ?? 0) - (ordinal[idr] ?? 0);
});
final list = Element('ol', footnotes);

// Ignore GFM attribute: <data-footnotes>.
final section = Element('section', [list])
..attributes['class'] = 'footnotes';
blocks.add(section);
}
return blocks;
}

/// Generate backref nodes, append them to footnote definition's last child.
void _appendBackref(List<Node> children, String ref, int count) {
final refs = [
for (var i = 0; i < count; i++) ...[
Text(' '),
_ElementExt.footnoteAnchor(ref, i)
]
];
if (children.isEmpty) {
children.addAll(refs);
} else {
final last = children.last;
if (last is Element) {
last.children?.addAll(refs);
} else {
children.last = Element('p', [last, ...refs]);
}
}
}
}

extension _ElementExt on Element {
static Element footnoteAnchor(String ref, int i) {
final num = '${i + 1}';
final suffix = i > 0 ? '-$num' : '';
final e = Element.empty('tag');
e.match;
return Element('a', [
Text('\u21a9'),
if (i > 0)
Element('sup', [Text(num)])..attributes['class'] = 'footnote-ref',
])
// Ignore GFM's attributes: <data-footnote-backref aria-label="Back to content">.
..attributes['href'] = '#fnref-$ref$suffix'
..attributes['class'] = 'footnote-backref';
}

String get match => tag;
}

/// A [link reference
Expand Down
3 changes: 3 additions & 0 deletions lib/src/extension_set.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'block_syntaxes/block_syntax.dart';
import 'block_syntaxes/fenced_code_block_syntax.dart';
import 'block_syntaxes/footnote_def_syntax.dart';
import 'block_syntaxes/header_with_id_syntax.dart';
import 'block_syntaxes/ordered_list_with_checkbox_syntax.dart';
import 'block_syntaxes/setext_header_with_id_syntax.dart';
Expand Down Expand Up @@ -58,6 +59,7 @@ class ExtensionSet {
const TableSyntax(),
const UnorderedListWithCheckboxSyntax(),
const OrderedListWithCheckboxSyntax(),
const FootnoteDefSyntax(),
],
),
List<InlineSyntax>.unmodifiable(
Expand All @@ -80,6 +82,7 @@ class ExtensionSet {
const TableSyntax(),
const UnorderedListWithCheckboxSyntax(),
const OrderedListWithCheckboxSyntax(),
const FootnoteDefSyntax(),
],
),
List<InlineSyntax>.unmodifiable(
Expand Down
69 changes: 69 additions & 0 deletions lib/src/inline_syntaxes/footnote_ref_syntax.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import '../ast.dart' show Element, Node, Text;
import '../charcode.dart';
import 'link_syntax.dart' show LinkContext;

/// The spec of GFM about footnotes is [missing](https://github.com/github/cmark-gfm/issues/283#issuecomment-1378868725).
/// For source code of cmark-gfm, See [noMatch] label of [handle_close_bracket] function in [master@c32ef78](https://github.com/github/cmark-gfm/blob/c32ef78/src/inlines.c#L1236).
/// A Rust implementation is also [available](https://github.com/wooorm/markdown-rs/blob/2498e31eecead798efc649502bbf5f86feaa94be/src/construct/gfm_label_start_footnote.rs).
/// Footnote shares the same syntax with [LinkSyntax], but goes a different branch of handling close bracket.
class FootnoteRefSyntax {
static String? _footnoteLabel(String key) {
if (key.isEmpty || key.codeUnitAt(0) != $caret) {
return null;
}
key = key.substring(1).trim().toLowerCase();
if (key.isEmpty) {
return null;
}
return key;
}

static Iterable<Node>? tryCreateFootnoteLink(
LinkContext context,
String text, {
bool? secondary,
}) {
secondary ??= false;
final parser = context.parser;
final key = _footnoteLabel(text);
final refs = parser.document.footnoteReferences;
// `label` is what footnoteReferences stored, it is case sensitive.
final label =
refs.keys.firstWhere((k) => k.toLowerCase() == key, orElse: () => '');
// `count != null` means footnote was valid.
var count = refs[label];
// And then check if footnote was matched.
if (key == null || count == null) {
return null;
}
final result = <Node>[];
// There are 4 cases here: ![^...], [^...], ![...][^...], [...][^...]
if (context.opener.char == $exclamation) {
result.add(Text('!'));
}
refs[label] = ++count;
final labels = parser.document.footnoteLabels;
var pos = labels.indexOf(key);
if (pos < 0) {
pos = labels.length;
labels.add(key);
}

// `children` are text segments after '[^' before ']'.
final children = context.getChildren();
if (secondary) {
result.add(Text('['));
result.addAll(children);
result.add(Text(']'));
}
final id = Uri.encodeComponent(label);
final suffix = count > 1 ? '-$count' : '';
final link = Element('a', [Text('${pos + 1}')])
// Ignore GitHub's attribute: <data-footnote-ref>.
..attributes['href'] = '#fn-$id'
..attributes['id'] = 'fnref-$id$suffix';
final sup = Element('sup', [link])..attributes['class'] = 'footnote-ref';
result.add(sup);
return result;
}
}
23 changes: 14 additions & 9 deletions lib/src/inline_syntaxes/link_syntax.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ import '../document.dart';
import '../inline_parser.dart';
import '../util.dart';
import 'delimiter_syntax.dart';
import 'footnote_ref_syntax.dart';

/// A helper class holds params of link context.
/// Footnote creation needs other info in [_tryCreateReferenceLink].
class _LinkContext {
class LinkContext {
final InlineParser parser;
final SimpleDelimiter opener;
final List<Node> Function() getChildren;

const _LinkContext(this.parser, this.opener, this.getChildren);
const LinkContext(this.parser, this.opener, this.getChildren);
}

/// Matches links like `[blah][label]` and `[blah](url)`.
Expand All @@ -40,7 +41,7 @@ class LinkSyntax extends DelimiterSyntax {
String? tag,
required List<Node> Function() getChildren,
}) {
final context = _LinkContext(parser, opener, getChildren);
final context = LinkContext(parser, opener, getChildren);
final text = parser.source.substring(opener.endPos, parser.pos);
// The current character is the `]` that closed the link text. Examine the
// next character, to determine what type of link we might have (a '('
Expand Down Expand Up @@ -92,7 +93,7 @@ class LinkSyntax extends DelimiterSyntax {
}
final label = _parseReferenceLinkLabel(parser);
if (label != null) {
return _tryCreateReferenceLink(context, label);
return _tryCreateReferenceLink(context, label, secondary: true);
}
return null;
}
Expand Down Expand Up @@ -167,9 +168,10 @@ class LinkSyntax extends DelimiterSyntax {
///
/// Returns the nodes if it was successfully created, `null` otherwise.
Iterable<Node>? _tryCreateReferenceLink(
_LinkContext context,
String label,
) {
LinkContext context,
String label, {
bool? secondary,
}) {
final parser = context.parser;
final getChildren = context.getChildren;
final link = _resolveReferenceLink(
Expand All @@ -180,8 +182,11 @@ class LinkSyntax extends DelimiterSyntax {
if (link != null) {
return [link];
}
// TODO: add footnote creation here
return null;
return FootnoteRefSyntax.tryCreateFootnoteLink(
context,
label,
secondary: secondary,
);
}

// Tries to create an inline link node.
Expand Down
4 changes: 4 additions & 0 deletions lib/src/patterns.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ final listPattern =
final tablePattern = RegExp(
r'^[ ]{0,3}\|?([ \t]*:?\-+:?[ \t]*\|)+([ \t]|[ \t]*:?\-+:?[ \t]*)?$');

/// A line starting with `[^` and contains with `]:`, but without special chars
/// (`\] \r\n\x00\t`) between. Same as [GFM](cmark-gfm/src/scanners.re:318).
final footnotePattern = RegExp(r'(^[ ]{0,3})\[\^([^\] \r\n\x00\t]+)\]:[ \t]*');

/// A pattern which should never be used. It just satisfies non-nullability of
/// pattern fields.
final dummyPattern = RegExp('');
Expand Down
Loading