Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Footnotes extension #332

Merged
merged 39 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
3d5c730
WIP footnotes: Block parsing
robinst Apr 6, 2024
1f6e729
Move code to new ext-footnotes module with extension
robinst Apr 28, 2024
0c17ae8
Refactor link/image parsing into more manageable pieces
robinst May 4, 2024
92ef1d0
Make bracket processing extensible via a BracketProcessor
robinst May 18, 2024
7500905
Allow adding BracketProcessors, use for footnotes extension
robinst May 18, 2024
f30c787
Actually parse child nodes of footnote definition
robinst May 18, 2024
04ba63f
Extract DefinitionMap as a public class
robinst May 19, 2024
4c2f729
Allow BlockParsers to return definitions (for lookup during inline pa…
robinst May 25, 2024
016eea8
Fix replace mode
robinst May 26, 2024
0606f05
Implement startFromBracket
robinst Jun 1, 2024
aa90ab0
More test cases
robinst Jun 1, 2024
947b1e5
Let full reference links override footnotes
robinst Jun 1, 2024
05621db
Footnotes: HTML and Markdown rendering
robinst Jun 14, 2024
4616d50
Use a single map
robinst Jun 15, 2024
b10bd57
Address TODO for paragraph rendering
robinst Jun 15, 2024
7fe1a9e
Add exclude list for label characters
robinst Jun 20, 2024
47e622e
Check for indentation
robinst Jun 20, 2024
194067b
Address TODO in bracket processor
robinst Jun 20, 2024
cbe5925
Also use processor for inline links
robinst Jun 22, 2024
982e6a5
Set source span, rename methods
robinst Jun 22, 2024
407d0e0
Address TODO in markdown renderer
robinst Jun 29, 2024
e619eac
Rename BracketProcessor to LinkProcessor
robinst Jun 29, 2024
a2258fa
Remove unused ReferenceType, add docs
robinst Jun 29, 2024
f028716
Skip spaces after colon in definition
robinst Jun 30, 2024
3388891
Support nested footnotes
robinst Jul 3, 2024
5425f63
Change footnote visiting, move docs to class
robinst Jul 5, 2024
958048d
Adjust Markdown renderer to changed parsing
robinst Jul 6, 2024
0a8c993
Add docs for footnotes extension
robinst Jul 6, 2024
214c195
Add docs for LinkProcessor
robinst Jul 6, 2024
c68809d
Documentation tweaks
robinst Jul 6, 2024
ee7b710
Fix Javadoc
robinst Jul 7, 2024
e170d31
Merge remote-tracking branch 'origin/main' into footnotes-extension
robinst Sep 6, 2024
72d6fa7
Add support for inline footnotes
robinst Jul 13, 2024
be9a27f
Generalize link markers and use for inline footnotes
robinst Jul 13, 2024
257e4a4
Inline footnotes rendering (first part)
robinst Sep 7, 2024
c6b4275
Inline footnotes rendering finished
robinst Sep 11, 2024
0dfa888
Rename param to allow implementations to use the normal node word
robinst Sep 11, 2024
de53b03
Remove old TODO
robinst Sep 12, 2024
e3e38ef
Javadoc
robinst Sep 12, 2024
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
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