diff --git a/build.gradle b/build.gradle index 6f677d639..afc3ada1e 100644 --- a/build.gradle +++ b/build.gradle @@ -98,6 +98,7 @@ ext { huaweiObsVersion = "3.19.7" templateInheritanceVersion = "0.4.RELEASE" jsoupVersion = "1.13.1" + byteBuddyAgentVersion = "1.10.22" } dependencies { @@ -141,12 +142,15 @@ dependencies { implementation "com.vladsch.flexmark:flexmark-ext-superscript:$flexmarkVersion" implementation "com.vladsch.flexmark:flexmark-ext-yaml-front-matter:$flexmarkVersion" implementation "com.vladsch.flexmark:flexmark-ext-gitlab:$flexmarkVersion" + implementation "com.vladsch.flexmark:flexmark-ext-footnotes:$flexmarkVersion" + implementation "kr.pe.kwonnam.freemarker:freemarker-template-inheritance:$templateInheritanceVersion" implementation "net.coobird:thumbnailator:$thumbnailatorVersion" implementation "net.sf.image4j:image4j:$image4jVersion" implementation "org.flywaydb:flyway-core:$flywayVersion" implementation "com.google.zxing:core:$zxingVersion" + implementation "net.bytebuddy:byte-buddy-agent:$byteBuddyAgentVersion" implementation "org.iq80.leveldb:leveldb:$levelDbVersion" runtimeOnly "com.h2database:h2:$h2Version" diff --git a/src/main/java/run/halo/app/utils/FootnoteNodeRendererInterceptor.java b/src/main/java/run/halo/app/utils/FootnoteNodeRendererInterceptor.java new file mode 100644 index 000000000..695447052 --- /dev/null +++ b/src/main/java/run/halo/app/utils/FootnoteNodeRendererInterceptor.java @@ -0,0 +1,219 @@ +package run.halo.app.utils; + +import com.vladsch.flexmark.ast.Link; +import com.vladsch.flexmark.ast.LinkNodeBase; +import com.vladsch.flexmark.ext.footnotes.Footnote; +import com.vladsch.flexmark.ext.footnotes.FootnoteBlock; +import com.vladsch.flexmark.ext.footnotes.internal.FootnoteNodeRenderer; +import com.vladsch.flexmark.ext.footnotes.internal.FootnoteOptions; +import com.vladsch.flexmark.ext.footnotes.internal.FootnoteRepository; +import com.vladsch.flexmark.html.HtmlWriter; +import com.vladsch.flexmark.html.renderer.NodeRendererContext; +import com.vladsch.flexmark.html.renderer.RenderingPhase; +import com.vladsch.flexmark.util.ast.Document; +import com.vladsch.flexmark.util.ast.NodeVisitor; +import com.vladsch.flexmark.util.ast.VisitHandler; +import com.vladsch.flexmark.util.sequence.BasedSequence; +import java.lang.reflect.Field; +import java.util.Locale; +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.agent.ByteBuddyAgent; +import net.bytebuddy.dynamic.loading.ClassReloadingStrategy; +import net.bytebuddy.implementation.MethodDelegation; +import net.bytebuddy.implementation.bind.annotation.Argument; +import net.bytebuddy.implementation.bind.annotation.FieldValue; +import net.bytebuddy.matcher.ElementMatchers; +import org.apache.commons.lang3.StringUtils; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.yaml.snakeyaml.nodes.SequenceNode; + +/** + * Flexmark footnote node render interceptor. + * Delegate the render method to intercept the FootNoteNodeRender by ByteBuddy runtime. + * + * @author guqing + * @date 2021-06-26 + */ +public class FootnoteNodeRendererInterceptor { + + /** + * Delegate the render method to intercept the FootNoteNodeRender by ByteBuddy runtime. + */ + public static void doDelegationMethod() { + ByteBuddyAgent.install(); + new ByteBuddy() + .redefine(FootnoteNodeRenderer.class) + + .method(ElementMatchers.named("render").and(ElementMatchers.takesArguments( + Footnote.class, NodeRendererContext.class, HtmlWriter.class))) + .intercept(MethodDelegation.to(FootnoteNodeRendererInterceptor.class)) + + .method(ElementMatchers.named("renderDocument")) + .intercept(MethodDelegation.to(FootnoteNodeRendererInterceptor.class)) + + .make() + .load(Thread.currentThread().getContextClassLoader(), + ClassReloadingStrategy.fromInstalledAgent()); + } + + /** + * footnote render see {@link FootnoteNodeRenderer#renderDocument}. + * + * @param node footnote node + * @param context node renderer context + * @param html html writer + */ + public static void render(Footnote node, NodeRendererContext context, HtmlWriter html) { + FootnoteBlock footnoteBlock = node.getFootnoteBlock(); + if (footnoteBlock == null) { + //just text + html.raw("[^"); + context.renderChildren(node); + html.raw("]"); + } else { + int footnoteOrdinal = footnoteBlock.getFootnoteOrdinal(); + int i = node.getReferenceOrdinal(); + + html.attr("class", "footnote-ref"); + html.srcPos(node.getChars()).withAttr() + .tag("sup", false, false, () -> { + // if (!options.footnoteLinkRefClass.isEmpty()) html.attr("class", options + // .footnoteLinkRefClass); + String ordinal = footnoteOrdinal + (i == 0 ? "" : String.format(Locale.US, + ":%d", i)); + html.attr("id", "fnref" + + ordinal); + html.attr("href", "#fn" + footnoteOrdinal); + html.withAttr().tag("a"); + html.raw("[" + ordinal + "]"); + html.tag("/a"); + }); + } + } + + /** + * render document. + * + * @param footnoteRepository footnoteRepository field of FootNoteRenderer class + * @param options options field of FootNoteRenderer class + * @param recheckUndefinedReferences recheckUndefinedReferences field of FootNoteRenderer class + * @param context node render context + * @param html html writer + * @param document document + * @param phase rendering phase + */ + public static void renderDocument(@FieldValue("footnoteRepository") + FootnoteRepository footnoteRepository, + @FieldValue("options") FootnoteOptions options, + @FieldValue("recheckUndefinedReferences") + boolean recheckUndefinedReferences, + @Argument(0) NodeRendererContext context, + @Argument(1) HtmlWriter html, @Argument(2) Document document, + @Argument(3) + RenderingPhase phase) { + final String footnoteBackLinkRefClass = + (String) getFootnoteOptionsFieldValue("footnoteBackLinkRefClass", options); + final String footnoteBackRefString = ObjectUtils + .getDisplayString(getFootnoteOptionsFieldValue("footnoteBackRefString", options)); + + if (phase == RenderingPhase.BODY_TOP) { + if (recheckUndefinedReferences) { + // need to see if have undefined footnotes that were defined after parsing + boolean[] hadNewFootnotes = {false}; + NodeVisitor visitor = new NodeVisitor( + new VisitHandler<>(Footnote.class, node -> { + if (!node.isDefined()) { + FootnoteBlock footonoteBlock = + node.getFootnoteBlock(footnoteRepository); + + if (footonoteBlock != null) { + footnoteRepository.addFootnoteReference(footonoteBlock, node); + node.setFootnoteBlock(footonoteBlock); + hadNewFootnotes[0] = true; + } + } + }) + ); + + visitor.visit(document); + if (hadNewFootnotes[0]) { + footnoteRepository.resolveFootnoteOrdinals(); + } + } + } + + if (phase == RenderingPhase.BODY_BOTTOM) { + // here we dump the footnote blocks that were referenced in the document body, ie. + // ones with footnoteOrdinal > 0 + if (footnoteRepository.getReferencedFootnoteBlocks().size() > 0) { + html.attr("class", "footnotes-sep").withAttr().tagVoid("hr"); + html.attr("class", "footnotes").withAttr().tagIndent("section", () -> { + html.attr("class", "footnotes-list").withAttr().tagIndent("ol", () -> { + for (FootnoteBlock footnoteBlock : footnoteRepository + .getReferencedFootnoteBlocks()) { + int footnoteOrdinal = footnoteBlock.getFootnoteOrdinal(); + html.attr("id", "fn" + footnoteOrdinal) + .attr("class", "footnote-item"); + html.withAttr().tagIndent("li", () -> { + context.renderChildren(footnoteBlock); + int lineIndex = html.getLineCount() - 1; + BasedSequence line = html.getLine(lineIndex); + if (line.lastIndexOf("

") > -1) { + int iMax = footnoteBlock.getFootnoteReferences(); + for (int i = 0; i < iMax; i++) { + StringBuilder sb = new StringBuilder(); + sb.append(" ").append(footnoteBackRefString).append(""); + html.setLine(html.getLineCount() - 1, "", + line.insert(line.lastIndexOf("\n"); + .set(HtmlRenderer.SOFT_BREAK, "
\n") + .set(FootnoteExtension.FOOTNOTE_BACK_REF_STRING, "↩︎"); private static final Parser PARSER = Parser.builder(OPTIONS).build(); @@ -108,6 +111,8 @@ public class MarkdownUtils { markdown = markdown .replaceAll(HaloConst.YOUTUBE_VIDEO_REG_PATTERN, HaloConst.YOUTUBE_VIDEO_IFRAME); } + // footnote render method delegation. + FootnoteNodeRendererInterceptor.doDelegationMethod(); Node document = PARSER.parse(markdown); diff --git a/src/test/java/run/halo/app/utils/FootnoteNodeRendererInterceptorTest.java b/src/test/java/run/halo/app/utils/FootnoteNodeRendererInterceptorTest.java new file mode 100644 index 000000000..b67b565cb --- /dev/null +++ b/src/test/java/run/halo/app/utils/FootnoteNodeRendererInterceptorTest.java @@ -0,0 +1,358 @@ +package run.halo.app.utils; + +import cn.hutool.core.lang.Assert; +import com.vladsch.flexmark.ext.attributes.AttributesExtension; +import com.vladsch.flexmark.ext.autolink.AutolinkExtension; +import com.vladsch.flexmark.ext.emoji.EmojiExtension; +import com.vladsch.flexmark.ext.emoji.EmojiImageType; +import com.vladsch.flexmark.ext.emoji.EmojiShortcutType; +import com.vladsch.flexmark.ext.footnotes.FootnoteExtension; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.parser.Parser; +import com.vladsch.flexmark.util.ast.Node; +import com.vladsch.flexmark.util.data.DataHolder; +import com.vladsch.flexmark.util.data.MutableDataSet; +import java.util.Arrays; +import org.apache.commons.lang3.StringUtils; +import org.junit.jupiter.api.Test; + +/** + * Compare the rendering result of FootnoteNodeRendererInterceptor + * and markdown-it-footnote. + * You can view markdown-it-footnote's rendering HTML results on this + * link markdown-it-footnote example page. + * + * @author guqing + * @date 2021-06-26 + */ +public class FootnoteNodeRendererInterceptorTest { + private static final DataHolder OPTIONS = + new MutableDataSet().set(Parser.EXTENSIONS, Arrays.asList(EmojiExtension.create(), + FootnoteExtension.create())) + .set(HtmlRenderer.SOFT_BREAK, "
\n") + .set(FootnoteExtension.FOOTNOTE_BACK_REF_STRING, "↩︎") + .set(EmojiExtension.USE_SHORTCUT_TYPE, EmojiShortcutType.EMOJI_CHEAT_SHEET) + .set(EmojiExtension.USE_IMAGE_TYPE, EmojiImageType.UNICODE_ONLY); + private static final Parser PARSER = Parser.builder(OPTIONS).build(); + + private static final HtmlRenderer RENDERER = HtmlRenderer.builder(OPTIONS).build(); + + private String renderHtml(String markdown) { + FootnoteNodeRendererInterceptor.doDelegationMethod(); + + Node document = PARSER.parse(markdown); + + return RENDERER.render(document); + } + + @Test + public void duplicatedTest() { + // duplicated + String markdown = "text [^footnote] embedded.\n" + + "\n" + + "[^footnote]: footnote text\n" + + "with continuation\n" + + "\n" + + "[^footnote]: duplicated footnote text\n" + + "with continuation"; + String s = renderHtml(markdown); + System.out.println(s); + Assert.isTrue(StringUtils.equals(s, "

text [1] embedded.

\n" + + "
\n" + + "
\n" + + "
    \n" + + "
  1. \n" + + "

    footnote text
    \n" + + "with continuation ↩︎

    \n" + + "
  2. \n" + + "
\n" + + "
\n")); + } + + @Test + public void nestedTest() { + // nested + String markdown = "text [^footnote] embedded.\n" + + "\n" + + "[^footnote]: footnote text with [^another] embedded footnote\n" + + "with continuation\n" + + "\n" + + "[^another]: footnote text\n" + + "with continuation"; + String s = renderHtml(markdown); + Assert.isTrue(StringUtils.equals(s, "

text [1] embedded.

\n" + + "
\n" + + "
\n" + + "
    \n" + + "
  1. \n" + + "

    footnote text with [2] embedded footnote
    \n" + + "with continuation ↩︎

    \n" + + "
  2. \n" + + "
  3. \n" + + "

    footnote text
    \n" + + "with continuation ↩︎

    \n" + + "
  4. \n" + + "
\n" + + "
\n")); + } + + @Test + public void circularTest() { + // circular + String markdown = "text [^footnote] embedded.\n" + + "\n" + + "[^footnote]: footnote text with [^another] embedded footnote\n" + + "with continuation\n" + + "\n" + + "[^another]: footnote text with [^another] embedded footnote\n" + + "with continuation"; + String s = renderHtml(markdown); + Assert.isTrue(StringUtils.equals(s, "

text [1] embedded.

\n" + + "
\n" + + "
\n" + + "
    \n" + + "
  1. \n" + + "

    footnote text with [2] embedded footnote
    \n" + + "with continuation ↩︎

    \n" + + "
  2. \n" + + "
  3. \n" + + "

    footnote text with [2:1] embedded footnote
    \n" + + "with continuation ↩︎

    \n" + + "
  4. \n" + + "
\n" + + "
\n")); + } + + @Test + public void compoundTest() { + // compound + String markdown = "This paragraph has a footnote[^footnote].\n" + + "\n" + + "[^footnote]: This is the body of the footnote.\n" + + "with continuation text. Inline _italic_ and \n" + + "**bold**.\n" + + "\n" + + " Multiple paragraphs are supported as other \n" + + " markdown elements such as lists.\n" + + " \n" + + " - item 1\n" + + " - item 2\n" + + "."; + String s = renderHtml(markdown); + Assert.isTrue(StringUtils.equals(s, "

This paragraph has a footnote[1].

\n" + + "
\n" + + "
\n" + + "
    \n" + + "
  1. \n" + + "

    This is the body of the footnote.
    \n" + + "with continuation text. Inline italic and
    \n" + + "bold.

    \n" + + "

    Multiple paragraphs are supported as other
    \n" + + "markdown elements such as lists.

    \n" + + "
      \n" + + "
    • item 1
    • \n" + + "
    • item 2
      \n" + + ".
    • \n" + + "
    \n" + + "↩︎\n" + + "
  2. \n" + + "
\n" + + "
\n")); + } + + @Test + public void notFootnoteTest() { + // Not a footnote nor a footnote definition if space between [ and ^. + String markdown = "This paragraph has no footnote[ ^footnote].\n" + + "\n" + + "[ ^footnote]: This is the body of the footnote.\n" + + "with continuation text. Inline _italic_ and \n" + + "**bold**.\n" + + "\n" + + " Multiple paragraphs are supported as other \n" + + " markdown elements such as lists.\n" + + " \n" + + " - item 1\n" + + " - item 2\n" + + "."; + String s = renderHtml(markdown); + Assert.isTrue(StringUtils.equals(s, "

This paragraph has no footnote[ ^footnote].

\n" + + "

[ ^footnote]: This is the body of the footnote.
\n" + + "with continuation text. Inline italic and
\n" + + "bold.

\n" + + "
Multiple paragraphs are supported as other \n"
+            + "markdown elements such as lists.\n"
+            + "\n"
+            + "- item 1\n"
+            + "- item 2\n"
+            + "
\n" + + "

.

\n")); + } + + @Test + public void unusedFootnotesTest() { + // Unused footnotes are not used and do not show up on the page. + String markdown = "This paragraph has a footnote[^2].\n" + + "\n" + + "[^1]: This is the body of the unused footnote.\n" + + "with continuation text. Inline _italic_ and \n" + + "**bold**.\n" + + "\n" + + "[^2]: This is the body of the footnote.\n" + + "with continuation text. Inline _italic_ and \n" + + "**bold**.\n" + + "\n" + + " Multiple paragraphs are supported as other \n" + + " markdown elements such as lists.\n" + + " \n" + + " - item 1\n" + + " - item 2\n" + + "."; + String s = renderHtml(markdown); + Assert.isTrue(StringUtils.equals(s, "

This paragraph has a footnote[1].

\n" + + "
\n" + + "
\n" + + "
    \n" + + "
  1. \n" + + "

    This is the body of the footnote.
    \n" + + "with continuation text. Inline italic and
    \n" + + "bold.

    \n" + + "

    Multiple paragraphs are supported as other
    \n" + + "markdown elements such as lists.

    \n" + + "
      \n" + + "
    • item 1
    • \n" + + "
    • item 2
      \n" + + ".
    • \n" + + "
    \n" + + "↩︎\n" + + "
  2. \n" + + "
\n" + + "
\n")); + } + + @Test + public void undefinedFootnotesTest() { + // Undefined footnotes are rendered as if they were text, with emphasis left as is. + String markdown = "This paragraph has a footnote[^**footnote**].\n" + + "\n" + + "[^footnote]: This is the body of the footnote.\n" + + "with continuation text. Inline _italic_ and \n" + + "**bold**.\n" + + "\n" + + " Multiple paragraphs are supported as other \n" + + " markdown elements such as lists.\n" + + " \n" + + " - item 1\n" + + " - item 2\n" + + "."; + String s = renderHtml(markdown); + Assert.isTrue(StringUtils + .equals(s, "

This paragraph has a footnote[^footnote].

\n")); + } + + @Test + public void footnoteNumbersOrderTest() { + // Footnote numbers are assigned in order of their reference in the document. + String markdown = "This paragraph has a footnote[^2]. Followed by another[^1]. \n" + + "\n" + + "[^1]: This is the body of the unused footnote.\n" + + "with continuation text. Inline _italic_ and \n" + + "**bold**.\n" + + "\n" + + "[^2]: This is the body of the footnote.\n" + + "with continuation text. Inline _italic_ and \n" + + "**bold**.\n" + + "\n" + + " Multiple paragraphs are supported as other \n" + + " markdown elements such as lists.\n" + + " \n" + + " - item 1\n" + + " - item 2\n" + + "."; + String s = renderHtml(markdown); + Assert.isTrue(StringUtils.equals(s, "

This paragraph has a footnote[1]. Followed by " + + "another[2]" + + ".

\n" + + "
\n" + + "
\n" + + "
    \n" + + "
  1. \n" + + "

    This is the body of the footnote.
    \n" + + "with continuation text. Inline italic and
    \n" + + "bold.

    \n" + + "

    Multiple paragraphs are supported as other
    \n" + + "markdown elements such as lists.

    \n" + + "
      \n" + + "
    • item 1
    • \n" + + "
    • item 2
      \n" + + ".
    • \n" + + "
    \n" + + "↩︎\n" + + "
  2. \n" + + "
  3. \n" + + "

    This is the body of the unused footnote.
    \n" + + "with continuation text. Inline italic and
    \n" + + "bold. ↩︎" + + "

    \n" + + "
  4. \n" + + "
\n" + + "
\n")); + } + + @Test + public void containOtherReferencesTest() { + // Footnotes can contain references to other footnotes. + String markdown = "This paragraph has a footnote[^2]. \n" + + "\n" + + "[^2]: This is the body of the footnote.\n" + + "with continuation text. Inline _italic_ and \n" + + "**bold**.\n" + + "\n" + + " Multiple paragraphs are supported as other \n" + + " markdown elements such as lists and footnotes[^1].\n" + + " \n" + + " - item 1\n" + + " - item 2\n" + + " \n" + + " [^1]: This is the body of a nested footnote.\n" + + " with continuation text. Inline _italic_ and \n" + + " **bold**.\n" + + "."; + String s = renderHtml(markdown); + Assert.isTrue(StringUtils.equals(s, "

This paragraph has a footnote[1].

\n" + + "
\n" + + "
\n" + + "
    \n" + + "
  1. \n" + + "

    This is the body of the footnote.
    \n" + + "with continuation text. Inline italic and
    \n" + + "bold.

    \n" + + "

    Multiple paragraphs are supported as other
    \n" + + "markdown elements such as lists and footnotes[2].

    \n" + + "
      \n" + + "
    • item 1
    • \n" + + "
    • item 2
    • \n" + + "
    \n" + + "↩︎\n" + + "
  2. \n" + + "
  3. \n" + + "

    This is the body of a nested footnote.
    \n" + + "with continuation text. Inline italic and
    \n" + + "bold.
    \n" + + ". ↩︎

    \n" + + "
  4. \n" + + "
\n" + + "
\n")); + } +} diff --git a/src/test/java/run/halo/app/utils/MarkdownUtilsTest.java b/src/test/java/run/halo/app/utils/MarkdownUtilsTest.java index 7c5caa087..2be52130d 100644 --- a/src/test/java/run/halo/app/utils/MarkdownUtilsTest.java +++ b/src/test/java/run/halo/app/utils/MarkdownUtilsTest.java @@ -2,6 +2,7 @@ package run.halo.app.utils; import cn.hutool.core.lang.Assert; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.junit.jupiter.api.Test; /** @@ -30,4 +31,40 @@ class MarkdownUtilsTest { + "test---"; Assert.isTrue("test---".equals(MarkdownUtils.removeFrontMatter(markdown))); } + + @Test + void footNotesTest() { + String markdown1 = "驿外[^1]断桥边,寂寞开无主。已是黄昏独自愁,更着风和雨\n" + + "[^1]: 驿(yì)外:指荒僻、冷清之地。驿,驿站。"; + String s1 = MarkdownUtils.renderHtml(markdown1); + Assert.isTrue(StringUtils.isNotBlank(s1)); + String s1Expected = "

驿外[1]断桥边,寂寞开无主。已是黄昏独自愁,更着风和雨

\n" + + "
\n" + + "
\n" + + "
    \n" + + "
  1. \n" + + "

    驿(yì)外:指荒僻、冷清之地。驿,驿站。 ↩︎" + + "

    \n" + + "
  2. \n" + + "
\n" + + "
\n"; + Assert.isTrue(StringUtils.equals(s1Expected, s1)); + + String markdown2 = "Paragraph with a footnote reference[^1]\n" + + "[^1]: Footnote text added at the bottom of the document"; + String s2 = MarkdownUtils.renderHtml(markdown2); + String s2Expected = "

Paragraph with a footnote reference[1]

\n" + + "
\n" + + "
\n" + + "
    \n" + + "
  1. \n" + + "

    Footnote text added at the bottom of the document ↩︎

    \n" + + "
  2. \n" + + "
\n" + + "
\n"; + Assert.isTrue(StringUtils.equals(s2Expected, s2)); + } } \ No newline at end of file