Skip to content

Commit 72d6fa7

Browse files
committed
Add support for inline footnotes
See e.g. https://pandoc.org/MANUAL.html#extension-inline_notes
1 parent e170d31 commit 72d6fa7

10 files changed

Lines changed: 201 additions & 12 deletions

File tree

commonmark-ext-footnotes/src/main/java/org/commonmark/ext/footnotes/FootnotesExtension.java

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
package org.commonmark.ext.footnotes;
22

33
import org.commonmark.Extension;
4-
import org.commonmark.ext.footnotes.internal.FootnoteBlockParser;
5-
import org.commonmark.ext.footnotes.internal.FootnoteLinkProcessor;
6-
import org.commonmark.ext.footnotes.internal.FootnoteHtmlNodeRenderer;
7-
import org.commonmark.ext.footnotes.internal.FootnoteMarkdownNodeRenderer;
4+
import org.commonmark.ext.footnotes.internal.*;
85
import org.commonmark.parser.Parser;
6+
import org.commonmark.parser.beta.InlineContentParserFactory;
97
import org.commonmark.renderer.NodeRenderer;
108
import org.commonmark.renderer.html.HtmlRenderer;
119
import org.commonmark.renderer.markdown.MarkdownNodeRendererContext;
@@ -30,25 +28,41 @@
3028
* the source. The footnotes will be numbered starting from 1, then 2, etc, depending on the order in which they appear
3129
* in the text (and not dependent on the label). The footnote reference is a link to the footnote, and from the footnote
3230
* there is a link back to the reference (or multiple).
31+
* <p>
32+
* There is also optional support for inline footnotes, use {@link #builder()} and then set {@link Builder#inlineFootnotes}.
3333
*
3434
* @see <a href="https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#footnotes">GitHub docs for footnotes</a>
3535
*/
3636
public class FootnotesExtension implements Parser.ParserExtension,
3737
HtmlRenderer.HtmlRendererExtension,
3838
MarkdownRenderer.MarkdownRendererExtension {
3939

40-
private FootnotesExtension() {
40+
private final boolean inlineFootnotes;
41+
42+
private FootnotesExtension(boolean inlineFootnotes) {
43+
this.inlineFootnotes = inlineFootnotes;
4144
}
4245

46+
/**
47+
* The extension with the default configuration (no support for inline footnotes).
48+
*/
4349
public static Extension create() {
44-
return new FootnotesExtension();
50+
return builder().build();
51+
}
52+
53+
public static Builder builder() {
54+
return new Builder();
4555
}
4656

4757
@Override
4858
public void extend(Parser.Builder parserBuilder) {
4959
parserBuilder
5060
.customBlockParserFactory(new FootnoteBlockParser.Factory())
5161
.linkProcessor(new FootnoteLinkProcessor());
62+
if (inlineFootnotes) {
63+
parserBuilder.customInlineContentParserFactory(new InlineFootnoteMarkerParser.Factory())
64+
.postProcessor(new InlineFootnoteMarkerRemover());
65+
}
5266
}
5367

5468
@Override
@@ -70,4 +84,24 @@ public Set<Character> getSpecialCharacters() {
7084
}
7185
});
7286
}
87+
88+
public static class Builder {
89+
90+
private boolean inlineFootnotes = false;
91+
92+
/**
93+
* Enable support for inline footnotes without definitions, e.g.:
94+
* <pre>
95+
* Some text^[this is an inline footnote]
96+
* </pre>
97+
*/
98+
public Builder inlineFootnotes(boolean inlineFootnotes) {
99+
this.inlineFootnotes = inlineFootnotes;
100+
return this;
101+
}
102+
103+
public FootnotesExtension build() {
104+
return new FootnotesExtension(inlineFootnotes);
105+
}
106+
}
73107
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package org.commonmark.ext.footnotes;
2+
3+
import org.commonmark.node.CustomNode;
4+
5+
public class InlineFootnote extends CustomNode {
6+
}

commonmark-ext-footnotes/src/main/java/org/commonmark/ext/footnotes/internal/FootnoteLinkProcessor.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,29 @@
22

33
import org.commonmark.ext.footnotes.FootnoteDefinition;
44
import org.commonmark.ext.footnotes.FootnoteReference;
5+
import org.commonmark.ext.footnotes.InlineFootnote;
56
import org.commonmark.node.LinkReferenceDefinition;
7+
import org.commonmark.node.Text;
68
import org.commonmark.parser.InlineParserContext;
79
import org.commonmark.parser.beta.LinkInfo;
810
import org.commonmark.parser.beta.LinkProcessor;
911
import org.commonmark.parser.beta.LinkResult;
1012
import org.commonmark.parser.beta.Scanner;
1113

1214
/**
13-
* For turning e.g. <code>[^foo]</code> into a {@link FootnoteReference}.
15+
* For turning e.g. <code>[^foo]</code> into a {@link FootnoteReference},
16+
* and <code>^[foo]</code> into an {@link InlineFootnote}.
1417
*/
1518
public class FootnoteLinkProcessor implements LinkProcessor {
1619
@Override
1720
public LinkResult process(LinkInfo linkInfo, Scanner scanner, InlineParserContext context) {
21+
22+
var beforeBracket = linkInfo.openingBracket().getPrevious();
23+
if (beforeBracket instanceof InlineFootnoteMarker) {
24+
beforeBracket.unlink();
25+
return LinkResult.wrapTextIn(new InlineFootnote(), linkInfo.afterTextBracket());
26+
}
27+
1828
if (linkInfo.destination() != null) {
1929
// If it's an inline link, it can't be a footnote reference
2030
return LinkResult.none();
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package org.commonmark.ext.footnotes.internal;
2+
3+
import org.commonmark.node.CustomNode;
4+
5+
public class InlineFootnoteMarker extends CustomNode {
6+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package org.commonmark.ext.footnotes.internal;
2+
3+
import org.commonmark.parser.beta.InlineContentParser;
4+
import org.commonmark.parser.beta.InlineContentParserFactory;
5+
import org.commonmark.parser.beta.InlineParserState;
6+
import org.commonmark.parser.beta.ParsedInline;
7+
8+
import java.util.Set;
9+
10+
/**
11+
* Parses any potential inline footnote markers (any `^` before `[`). Later we'll either use it in
12+
* {@link FootnoteLinkProcessor}, or remove any unused ones in {@link InlineFootnoteMarkerRemover}.
13+
*/
14+
public class InlineFootnoteMarkerParser implements InlineContentParser {
15+
@Override
16+
public ParsedInline tryParse(InlineParserState inlineParserState) {
17+
var scanner = inlineParserState.scanner();
18+
// Skip ^
19+
scanner.next();
20+
21+
if (scanner.peek() == '[') {
22+
return ParsedInline.of(new InlineFootnoteMarker(), scanner.position());
23+
} else {
24+
return ParsedInline.none();
25+
}
26+
}
27+
28+
public static class Factory implements InlineContentParserFactory {
29+
30+
@Override
31+
public Set<Character> getTriggerCharacters() {
32+
return Set.of('^');
33+
}
34+
35+
@Override
36+
public InlineContentParser create() {
37+
return new InlineFootnoteMarkerParser();
38+
}
39+
}
40+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package org.commonmark.ext.footnotes.internal;
2+
3+
import org.commonmark.node.AbstractVisitor;
4+
import org.commonmark.node.CustomNode;
5+
import org.commonmark.node.Node;
6+
import org.commonmark.node.Text;
7+
import org.commonmark.parser.PostProcessor;
8+
9+
/**
10+
* Remove any markers that have been parsed by {@link InlineFootnoteMarkerParser} but haven't been used in
11+
* {@link FootnoteLinkProcessor}.
12+
*/
13+
public class InlineFootnoteMarkerRemover extends AbstractVisitor implements PostProcessor {
14+
@Override
15+
public Node process(Node node) {
16+
node.accept(this);
17+
return node;
18+
}
19+
20+
@Override
21+
public void visit(CustomNode customNode) {
22+
if (customNode instanceof InlineFootnoteMarker) {
23+
var text = new Text("^");
24+
text.setSourceSpans(customNode.getSourceSpans());
25+
customNode.insertAfter(text);
26+
customNode.unlink();
27+
}
28+
}
29+
}

commonmark-ext-footnotes/src/test/java/org/commonmark/ext/footnotes/FootnotesTest.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package org.commonmark.ext.footnotes;
22

33
import org.commonmark.Extension;
4+
import org.commonmark.ext.footnotes.internal.InlineFootnoteMarker;
45
import org.commonmark.node.*;
56
import org.commonmark.parser.IncludeSourceSpans;
67
import org.commonmark.parser.Parser;
@@ -235,6 +236,49 @@ public void testReferenceLinkWithoutDefinition() {
235236
assertText("[foo]", paragraph.getLastChild());
236237
}
237238

239+
@Test
240+
public void testInlineFootnote() {
241+
var extension = FootnotesExtension.builder().inlineFootnotes(true).build();
242+
var parser = Parser.builder().extensions(Set.of(extension)).build();
243+
244+
{
245+
var doc = parser.parse("Test ^[inline footnote]");
246+
assertText("Test ", doc.getFirstChild().getFirstChild());
247+
var fn = find(doc, InlineFootnote.class);
248+
assertText("inline footnote", fn.getFirstChild());
249+
assertNull(tryFind(doc, InlineFootnoteMarker.class));
250+
}
251+
252+
{
253+
var doc = parser.parse("Test \\^[not inline footnote]");
254+
assertNull(tryFind(doc, InlineFootnote.class));
255+
}
256+
257+
{
258+
var doc = parser.parse("Test ^[not inline footnote");
259+
assertNull(tryFind(doc, InlineFootnote.class));
260+
assertNull(tryFind(doc, InlineFootnoteMarker.class));
261+
var t = doc.getFirstChild().getFirstChild();
262+
// This is not ideal; text nodes aren't merged after post-processing
263+
assertText("Test ", t);
264+
assertText("^", t.getNext());
265+
assertText("[not inline footnote", t.getNext().getNext());
266+
}
267+
268+
{
269+
// This is a tricky one because the code span in the link text
270+
// includes the `]` (and doesn't need to be escaped). Therefore
271+
// inline footnote parsing has to do full link text parsing/inline parsing.
272+
// https://spec.commonmark.org/0.31.2/#link-text
273+
274+
var doc = parser.parse("^[test `bla]`]");
275+
var fn = find(doc, InlineFootnote.class);
276+
assertText("test ", fn.getFirstChild());
277+
var code = fn.getFirstChild().getNext();
278+
assertEquals("bla]", ((Code) code).getLiteral());
279+
}
280+
}
281+
238282
@Test
239283
public void testSourcePositions() {
240284
var parser = Parser.builder().extensions(EXTENSIONS).includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES).build();

commonmark/src/main/java/org/commonmark/internal/InlineParserImpl.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ private LinkInfo parseLinkInfo(Bracket opener, Position beforeClose) {
372372
// Maybe an inline link/image
373373
var destinationTitle = parseInlineDestinationTitle(scanner);
374374
if (destinationTitle != null) {
375-
return new LinkInfoImpl(openerType, text, null, destinationTitle.destination, destinationTitle.title, afterClose);
375+
return new LinkInfoImpl(openerType, opener.bracketNode, text, null, destinationTitle.destination, destinationTitle.title, afterClose);
376376
}
377377
// Not an inline link/image, rewind back to after `]`.
378378
scanner.setPosition(afterClose);
@@ -394,7 +394,7 @@ private LinkInfo parseLinkInfo(Bracket opener, Position beforeClose) {
394394
return null;
395395
}
396396

397-
return new LinkInfoImpl(openerType, text, label, null, null, afterClose);
397+
return new LinkInfoImpl(openerType, opener.bracketNode, text, label, null, null, afterClose);
398398
}
399399

400400
private Node wrapBracket(Bracket opener, Node wrapperNode, boolean startFromBracket) {
@@ -879,15 +879,17 @@ public DestinationTitle(String destination, String title) {
879879
private static class LinkInfoImpl implements LinkInfo {
880880

881881
private final OpenerType openerType;
882+
private final Text openingBracket;
882883
private final String text;
883884
private final String label;
884885
private final String destination;
885886
private final String title;
886887
private final Position afterTextBracket;
887888

888-
private LinkInfoImpl(OpenerType openerType, String text, String label,
889+
private LinkInfoImpl(OpenerType openerType, Text openingBracket, String text, String label,
889890
String destination, String title, Position afterTextBracket) {
890891
this.openerType = openerType;
892+
this.openingBracket = openingBracket;
891893
this.text = text;
892894
this.label = label;
893895
this.destination = destination;
@@ -900,6 +902,11 @@ public OpenerType openerType() {
900902
return openerType;
901903
}
902904

905+
@Override
906+
public Text openingBracket() {
907+
return openingBracket;
908+
}
909+
903910
@Override
904911
public String text() {
905912
return text;

commonmark/src/main/java/org/commonmark/node/Node.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ public void unlink() {
8686
this.prev = null;
8787
}
8888

89+
/**
90+
* Inserts the {@code sibling} node after {@code this} node.
91+
*/
8992
public void insertAfter(Node sibling) {
9093
sibling.unlink();
9194
sibling.next = this.next;
@@ -100,6 +103,9 @@ public void insertAfter(Node sibling) {
100103
}
101104
}
102105

106+
/**
107+
* Inserts the {@code sibling} node before {@code this} node.
108+
*/
103109
public void insertBefore(Node sibling) {
104110
sibling.unlink();
105111
sibling.prev = this.prev;
@@ -114,7 +120,6 @@ public void insertBefore(Node sibling) {
114120
}
115121
}
116122

117-
118123
/**
119124
* @return the source spans of this node if included by the parser, an empty list otherwise
120125
* @since 0.16.0

commonmark/src/main/java/org/commonmark/parser/beta/LinkInfo.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
package org.commonmark.parser.beta;
22

3+
import org.commonmark.node.Node;
4+
import org.commonmark.node.Text;
5+
36
/**
47
* A parsed link or image. There are different types of links.
58
* <p>
@@ -44,6 +47,11 @@ enum OpenerType {
4447
*/
4548
OpenerType openerType();
4649

50+
/**
51+
* The text node of the opening bracket {@code [}.
52+
*/
53+
Text openingBracket();
54+
4755
/**
4856
* The text between the first brackets, e.g. `foo` in `[foo][bar]`.
4957
*/
@@ -65,7 +73,7 @@ enum OpenerType {
6573
String title();
6674

6775
/**
68-
* The position after the text bracket, e.g.:
76+
* The position after the closing text bracket, e.g.:
6977
* <pre>
7078
* [foo][bar]
7179
* ^

0 commit comments

Comments
 (0)