Skip to content

Commit c910105

Browse files
authored
Merge pull request #332 from commonmark/footnotes-extension
Footnotes extension
2 parents 591b452 + e3e38ef commit c910105

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+2521
-211
lines changed

commonmark-ext-footnotes/pom.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
3+
<modelVersion>4.0.0</modelVersion>
4+
<parent>
5+
<groupId>org.commonmark</groupId>
6+
<artifactId>commonmark-parent</artifactId>
7+
<version>0.22.1-SNAPSHOT</version>
8+
</parent>
9+
10+
<artifactId>commonmark-ext-footnotes</artifactId>
11+
<name>commonmark-java extension for footnotes</name>
12+
<description>commonmark-java extension for footnotes using [^1] syntax</description>
13+
14+
<dependencies>
15+
<dependency>
16+
<groupId>org.commonmark</groupId>
17+
<artifactId>commonmark</artifactId>
18+
</dependency>
19+
20+
<dependency>
21+
<groupId>org.commonmark</groupId>
22+
<artifactId>commonmark-test-util</artifactId>
23+
<scope>test</scope>
24+
</dependency>
25+
</dependencies>
26+
27+
</project>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module org.commonmark.ext.footnotes {
2+
exports org.commonmark.ext.footnotes;
3+
4+
requires org.commonmark;
5+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package org.commonmark.ext.footnotes;
2+
3+
import org.commonmark.node.CustomBlock;
4+
5+
/**
6+
* A footnote definition, e.g.:
7+
* <pre><code>
8+
* [^foo]: This is the footnote text
9+
* </code></pre>
10+
* The {@link #getLabel() label} is the text in brackets after {@code ^}, so {@code foo} in the example. The contents
11+
* of the footnote are child nodes of the definition, a {@link org.commonmark.node.Paragraph} in the example.
12+
* <p>
13+
* Footnote definitions are parsed even if there's no corresponding {@link FootnoteReference}.
14+
*/
15+
public class FootnoteDefinition extends CustomBlock {
16+
17+
private String label;
18+
19+
public FootnoteDefinition(String label) {
20+
this.label = label;
21+
}
22+
23+
public String getLabel() {
24+
return label;
25+
}
26+
}
27+
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package org.commonmark.ext.footnotes;
2+
3+
import org.commonmark.node.CustomNode;
4+
5+
/**
6+
* A footnote reference, e.g. <code>[^foo]</code> in <code>Some text with a footnote[^foo]</code>
7+
* <p>
8+
* The {@link #getLabel() label} is the text within brackets after {@code ^}, so {@code foo} in the example. It needs to
9+
* match the label of a corresponding {@link FootnoteDefinition} for the footnote to be parsed.
10+
*/
11+
public class FootnoteReference extends CustomNode {
12+
private String label;
13+
14+
public FootnoteReference(String label) {
15+
this.label = label;
16+
}
17+
18+
public String getLabel() {
19+
return label;
20+
}
21+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package org.commonmark.ext.footnotes;
2+
3+
import org.commonmark.Extension;
4+
import org.commonmark.ext.footnotes.internal.*;
5+
import org.commonmark.parser.Parser;
6+
import org.commonmark.parser.beta.InlineContentParserFactory;
7+
import org.commonmark.renderer.NodeRenderer;
8+
import org.commonmark.renderer.html.HtmlRenderer;
9+
import org.commonmark.renderer.markdown.MarkdownNodeRendererContext;
10+
import org.commonmark.renderer.markdown.MarkdownNodeRendererFactory;
11+
import org.commonmark.renderer.markdown.MarkdownRenderer;
12+
13+
import java.util.Set;
14+
15+
/**
16+
* Extension for footnotes with syntax like GitHub Flavored Markdown:
17+
* <pre><code>
18+
* Some text with a footnote[^1].
19+
*
20+
* [^1]: The text of the footnote.
21+
* </code></pre>
22+
* The <code>[^1]</code> is a {@link FootnoteReference}, with "1" being the label.
23+
* <p>
24+
* The line with <code>[^1]: ...</code> is a {@link FootnoteDefinition}, with the contents as child nodes (can be a
25+
* paragraph like in the example, or other blocks like lists).
26+
* <p>
27+
* All the footnotes (definitions) will be rendered in a list at the end of a document, no matter where they appear in
28+
* the source. The footnotes will be numbered starting from 1, then 2, etc, depending on the order in which they appear
29+
* in the text (and not dependent on the label). The footnote reference is a link to the footnote, and from the footnote
30+
* 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}.
33+
*
34+
* @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>
35+
*/
36+
public class FootnotesExtension implements Parser.ParserExtension,
37+
HtmlRenderer.HtmlRendererExtension,
38+
MarkdownRenderer.MarkdownRendererExtension {
39+
40+
private final boolean inlineFootnotes;
41+
42+
private FootnotesExtension(boolean inlineFootnotes) {
43+
this.inlineFootnotes = inlineFootnotes;
44+
}
45+
46+
/**
47+
* The extension with the default configuration (no support for inline footnotes).
48+
*/
49+
public static Extension create() {
50+
return builder().build();
51+
}
52+
53+
public static Builder builder() {
54+
return new Builder();
55+
}
56+
57+
@Override
58+
public void extend(Parser.Builder parserBuilder) {
59+
parserBuilder
60+
.customBlockParserFactory(new FootnoteBlockParser.Factory())
61+
.linkProcessor(new FootnoteLinkProcessor());
62+
if (inlineFootnotes) {
63+
parserBuilder.linkMarker('^');
64+
}
65+
}
66+
67+
@Override
68+
public void extend(HtmlRenderer.Builder rendererBuilder) {
69+
rendererBuilder.nodeRendererFactory(FootnoteHtmlNodeRenderer::new);
70+
}
71+
72+
@Override
73+
public void extend(MarkdownRenderer.Builder rendererBuilder) {
74+
rendererBuilder.nodeRendererFactory(new MarkdownNodeRendererFactory() {
75+
@Override
76+
public NodeRenderer create(MarkdownNodeRendererContext context) {
77+
return new FootnoteMarkdownNodeRenderer(context);
78+
}
79+
80+
@Override
81+
public Set<Character> getSpecialCharacters() {
82+
return Set.of();
83+
}
84+
});
85+
}
86+
87+
public static class Builder {
88+
89+
private boolean inlineFootnotes = false;
90+
91+
/**
92+
* Enable support for inline footnotes without definitions, e.g.:
93+
* <pre>
94+
* Some text^[this is an inline footnote]
95+
* </pre>
96+
*/
97+
public Builder inlineFootnotes(boolean inlineFootnotes) {
98+
this.inlineFootnotes = inlineFootnotes;
99+
return this;
100+
}
101+
102+
public FootnotesExtension build() {
103+
return new FootnotesExtension(inlineFootnotes);
104+
}
105+
}
106+
}
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+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package org.commonmark.ext.footnotes.internal;
2+
3+
import org.commonmark.ext.footnotes.FootnoteDefinition;
4+
import org.commonmark.node.Block;
5+
import org.commonmark.node.DefinitionMap;
6+
import org.commonmark.parser.block.*;
7+
import org.commonmark.text.Characters;
8+
9+
import java.util.List;
10+
11+
/**
12+
* Parser for a single {@link FootnoteDefinition} block.
13+
*/
14+
public class FootnoteBlockParser extends AbstractBlockParser {
15+
16+
private final FootnoteDefinition block;
17+
18+
public FootnoteBlockParser(String label) {
19+
block = new FootnoteDefinition(label);
20+
}
21+
22+
@Override
23+
public Block getBlock() {
24+
return block;
25+
}
26+
27+
@Override
28+
public boolean isContainer() {
29+
return true;
30+
}
31+
32+
@Override
33+
public boolean canContain(Block childBlock) {
34+
return true;
35+
}
36+
37+
@Override
38+
public BlockContinue tryContinue(ParserState parserState) {
39+
if (parserState.getIndent() >= 4) {
40+
// It looks like content needs to be indented by 4 so that it's part of a footnote (instead of starting a new block).
41+
return BlockContinue.atColumn(4);
42+
} else {
43+
// We're not continuing to give other block parsers a chance to interrupt this definition.
44+
// But if no other block parser applied (including another FootnotesBlockParser), we will
45+
// accept the line via lazy continuation (same as a block quote).
46+
return BlockContinue.none();
47+
}
48+
}
49+
50+
@Override
51+
public List<DefinitionMap<?>> getDefinitions() {
52+
var map = new DefinitionMap<>(FootnoteDefinition.class);
53+
map.putIfAbsent(block.getLabel(), block);
54+
return List.of(map);
55+
}
56+
57+
public static class Factory implements BlockParserFactory {
58+
59+
@Override
60+
public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) {
61+
if (state.getIndent() >= 4) {
62+
return BlockStart.none();
63+
}
64+
var index = state.getNextNonSpaceIndex();
65+
var content = state.getLine().getContent();
66+
if (content.charAt(index) != '[' || index + 1 >= content.length()) {
67+
return BlockStart.none();
68+
}
69+
index++;
70+
if (content.charAt(index) != '^' || index + 1 >= content.length()) {
71+
return BlockStart.none();
72+
}
73+
// Now at first label character (if any)
74+
index++;
75+
var labelStart = index;
76+
77+
for (index = labelStart; index < content.length(); index++) {
78+
var c = content.charAt(index);
79+
switch (c) {
80+
case ']':
81+
if (index > labelStart && index + 1 < content.length() && content.charAt(index + 1) == ':') {
82+
var label = content.subSequence(labelStart, index).toString();
83+
// After the colon, any number of spaces is skipped (not part of the content)
84+
var afterSpaces = Characters.skipSpaceTab(content, index + 2, content.length());
85+
return BlockStart.of(new FootnoteBlockParser(label)).atIndex(afterSpaces);
86+
} else {
87+
return BlockStart.none();
88+
}
89+
case ' ':
90+
case '\r':
91+
case '\n':
92+
case '\0':
93+
case '\t':
94+
return BlockStart.none();
95+
}
96+
}
97+
98+
return BlockStart.none();
99+
}
100+
}
101+
}

0 commit comments

Comments
 (0)