Skip to content

Commit

Permalink
Merge pull request #332 from commonmark/footnotes-extension
Browse files Browse the repository at this point in the history
Footnotes extension
  • Loading branch information
robinst authored Sep 12, 2024
2 parents 591b452 + e3e38ef commit c910105
Show file tree
Hide file tree
Showing 45 changed files with 2,521 additions and 211 deletions.
27 changes: 27 additions & 0 deletions commonmark-ext-footnotes/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<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">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-parent</artifactId>
<version>0.22.1-SNAPSHOT</version>
</parent>

<artifactId>commonmark-ext-footnotes</artifactId>
<name>commonmark-java extension for footnotes</name>
<description>commonmark-java extension for footnotes using [^1] syntax</description>

<dependencies>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
</dependency>

<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark-test-util</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

</project>
5 changes: 5 additions & 0 deletions commonmark-ext-footnotes/src/main/java/module-info.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module org.commonmark.ext.footnotes {
exports org.commonmark.ext.footnotes;

requires org.commonmark;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package org.commonmark.ext.footnotes;

import org.commonmark.node.CustomBlock;

/**
* A footnote definition, e.g.:
* <pre><code>
* [^foo]: This is the footnote text
* </code></pre>
* The {@link #getLabel() label} is the text in brackets after {@code ^}, so {@code foo} in the example. The contents
* of the footnote are child nodes of the definition, a {@link org.commonmark.node.Paragraph} in the example.
* <p>
* Footnote definitions are parsed even if there's no corresponding {@link FootnoteReference}.
*/
public class FootnoteDefinition extends CustomBlock {

private String label;

public FootnoteDefinition(String label) {
this.label = label;
}

public String getLabel() {
return label;
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.commonmark.ext.footnotes;

import org.commonmark.node.CustomNode;

/**
* A footnote reference, e.g. <code>[^foo]</code> in <code>Some text with a footnote[^foo]</code>
* <p>
* The {@link #getLabel() label} is the text within brackets after {@code ^}, so {@code foo} in the example. It needs to
* match the label of a corresponding {@link FootnoteDefinition} for the footnote to be parsed.
*/
public class FootnoteReference extends CustomNode {
private String label;

public FootnoteReference(String label) {
this.label = label;
}

public String getLabel() {
return label;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package org.commonmark.ext.footnotes;

import org.commonmark.Extension;
import org.commonmark.ext.footnotes.internal.*;
import org.commonmark.parser.Parser;
import org.commonmark.parser.beta.InlineContentParserFactory;
import org.commonmark.renderer.NodeRenderer;
import org.commonmark.renderer.html.HtmlRenderer;
import org.commonmark.renderer.markdown.MarkdownNodeRendererContext;
import org.commonmark.renderer.markdown.MarkdownNodeRendererFactory;
import org.commonmark.renderer.markdown.MarkdownRenderer;

import java.util.Set;

/**
* Extension for footnotes with syntax like GitHub Flavored Markdown:
* <pre><code>
* Some text with a footnote[^1].
*
* [^1]: The text of the footnote.
* </code></pre>
* The <code>[^1]</code> is a {@link FootnoteReference}, with "1" being the label.
* <p>
* The line with <code>[^1]: ...</code> is a {@link FootnoteDefinition}, with the contents as child nodes (can be a
* paragraph like in the example, or other blocks like lists).
* <p>
* All the footnotes (definitions) will be rendered in a list at the end of a document, no matter where they appear in
* the source. The footnotes will be numbered starting from 1, then 2, etc, depending on the order in which they appear
* in the text (and not dependent on the label). The footnote reference is a link to the footnote, and from the footnote
* there is a link back to the reference (or multiple).
* <p>
* There is also optional support for inline footnotes, use {@link #builder()} and then set {@link Builder#inlineFootnotes}.
*
* @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>
*/
public class FootnotesExtension implements Parser.ParserExtension,
HtmlRenderer.HtmlRendererExtension,
MarkdownRenderer.MarkdownRendererExtension {

private final boolean inlineFootnotes;

private FootnotesExtension(boolean inlineFootnotes) {
this.inlineFootnotes = inlineFootnotes;
}

/**
* The extension with the default configuration (no support for inline footnotes).
*/
public static Extension create() {
return builder().build();
}

public static Builder builder() {
return new Builder();
}

@Override
public void extend(Parser.Builder parserBuilder) {
parserBuilder
.customBlockParserFactory(new FootnoteBlockParser.Factory())
.linkProcessor(new FootnoteLinkProcessor());
if (inlineFootnotes) {
parserBuilder.linkMarker('^');
}
}

@Override
public void extend(HtmlRenderer.Builder rendererBuilder) {
rendererBuilder.nodeRendererFactory(FootnoteHtmlNodeRenderer::new);
}

@Override
public void extend(MarkdownRenderer.Builder rendererBuilder) {
rendererBuilder.nodeRendererFactory(new MarkdownNodeRendererFactory() {
@Override
public NodeRenderer create(MarkdownNodeRendererContext context) {
return new FootnoteMarkdownNodeRenderer(context);
}

@Override
public Set<Character> getSpecialCharacters() {
return Set.of();
}
});
}

public static class Builder {

private boolean inlineFootnotes = false;

/**
* Enable support for inline footnotes without definitions, e.g.:
* <pre>
* Some text^[this is an inline footnote]
* </pre>
*/
public Builder inlineFootnotes(boolean inlineFootnotes) {
this.inlineFootnotes = inlineFootnotes;
return this;
}

public FootnotesExtension build() {
return new FootnotesExtension(inlineFootnotes);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package org.commonmark.ext.footnotes;

import org.commonmark.node.CustomNode;

public class InlineFootnote extends CustomNode {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package org.commonmark.ext.footnotes.internal;

import org.commonmark.ext.footnotes.FootnoteDefinition;
import org.commonmark.node.Block;
import org.commonmark.node.DefinitionMap;
import org.commonmark.parser.block.*;
import org.commonmark.text.Characters;

import java.util.List;

/**
* Parser for a single {@link FootnoteDefinition} block.
*/
public class FootnoteBlockParser extends AbstractBlockParser {

private final FootnoteDefinition block;

public FootnoteBlockParser(String label) {
block = new FootnoteDefinition(label);
}

@Override
public Block getBlock() {
return block;
}

@Override
public boolean isContainer() {
return true;
}

@Override
public boolean canContain(Block childBlock) {
return true;
}

@Override
public BlockContinue tryContinue(ParserState parserState) {
if (parserState.getIndent() >= 4) {
// It looks like content needs to be indented by 4 so that it's part of a footnote (instead of starting a new block).
return BlockContinue.atColumn(4);
} else {
// We're not continuing to give other block parsers a chance to interrupt this definition.
// But if no other block parser applied (including another FootnotesBlockParser), we will
// accept the line via lazy continuation (same as a block quote).
return BlockContinue.none();
}
}

@Override
public List<DefinitionMap<?>> getDefinitions() {
var map = new DefinitionMap<>(FootnoteDefinition.class);
map.putIfAbsent(block.getLabel(), block);
return List.of(map);
}

public static class Factory implements BlockParserFactory {

@Override
public BlockStart tryStart(ParserState state, MatchedBlockParser matchedBlockParser) {
if (state.getIndent() >= 4) {
return BlockStart.none();
}
var index = state.getNextNonSpaceIndex();
var content = state.getLine().getContent();
if (content.charAt(index) != '[' || index + 1 >= content.length()) {
return BlockStart.none();
}
index++;
if (content.charAt(index) != '^' || index + 1 >= content.length()) {
return BlockStart.none();
}
// Now at first label character (if any)
index++;
var labelStart = index;

for (index = labelStart; index < content.length(); index++) {
var c = content.charAt(index);
switch (c) {
case ']':
if (index > labelStart && index + 1 < content.length() && content.charAt(index + 1) == ':') {
var label = content.subSequence(labelStart, index).toString();
// After the colon, any number of spaces is skipped (not part of the content)
var afterSpaces = Characters.skipSpaceTab(content, index + 2, content.length());
return BlockStart.of(new FootnoteBlockParser(label)).atIndex(afterSpaces);
} else {
return BlockStart.none();
}
case ' ':
case '\r':
case '\n':
case '\0':
case '\t':
return BlockStart.none();
}
}

return BlockStart.none();
}
}
}
Loading

0 comments on commit c910105

Please sign in to comment.