From e978f427fcea9e4c26948d5828ffc9d656fe76cc Mon Sep 17 00:00:00 2001 From: noeppi_noeppi Date: Sat, 12 Jul 2025 17:47:57 +0200 Subject: [PATCH] Add support for markdown doc-comments as described in JEP 467. --- build.gradle | 6 +- gradle/wrapper/gradle-wrapper.properties | 2 +- .../java_doclet_meta/record/DocData.java | 8 +- .../java_doclet_meta/record/ParamData.java | 2 +- .../record/block/DocBlockData.java | 27 ++-- .../util/DocTreePreprocessor.java | 126 ++++++++++++++++++ .../java_doclet_meta/util/HtmlConverter.java | 113 +++++++++------- 7 files changed, 212 insertions(+), 72 deletions(-) create mode 100644 src/main/java/org/moddingx/java_doclet_meta/util/DocTreePreprocessor.java diff --git a/build.gradle b/build.gradle index 7221c9b..bd8c230 100644 --- a/build.gradle +++ b/build.gradle @@ -4,7 +4,7 @@ plugins { } group = 'org.moddingx' -java.toolchain.languageVersion = JavaLanguageVersion.of(21) +java.toolchain.languageVersion = JavaLanguageVersion.of(24) repositories { mavenCentral() @@ -14,7 +14,9 @@ dependencies { implementation 'jakarta.annotation:jakarta.annotation-api:3.0.0' implementation 'org.apache.commons:commons-text:1.12.0' implementation 'com.google.code.gson:gson:2.11.0' - implementation 'org.jsoup:jsoup:1.17.2' + implementation 'org.jsoup:jsoup:1.17.2' + implementation 'org.commonmark:commonmark:0.24.0' + implementation 'org.commonmark:commonmark-ext-gfm-tables:0.24.0' } task fatjar(type: Jar) { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a441313..ca025c8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/org/moddingx/java_doclet_meta/record/DocData.java b/src/main/java/org/moddingx/java_doclet_meta/record/DocData.java index 168b7d2..9cd79cf 100644 --- a/src/main/java/org/moddingx/java_doclet_meta/record/DocData.java +++ b/src/main/java/org/moddingx/java_doclet_meta/record/DocData.java @@ -34,12 +34,12 @@ public static Optional from(DocEnv env, Element element) { DocCommentTree tree = env.docs().getDocCommentTree(elemPath); if (tree == null) return Optional.empty(); DocTreePath basePath = DocTreePath.getPath(elemPath, tree, tree); - String summary = HtmlConverter.asDocHtml(env, basePath, tree.getFirstSentence()); - String text = HtmlConverter.asDocHtml(env, basePath, tree.getFullBody()); + String summary = HtmlConverter.asDocHtml(env, element, basePath, tree.getFirstSentence()); + String text = HtmlConverter.asDocHtml(env, element, basePath, tree.getFullBody()); List properties = tree.getBlockTags().stream() - .flatMap(tag -> DocBlockData.from(env, DocTreePath.getPath(basePath, tag), tag).stream()) + .flatMap(tag -> DocBlockData.from(env, element, DocTreePath.getPath(basePath, tag), tag).stream()) .toList(); - List inlineProperties = DocBlockData.fromInline(env, basePath, properties, tree.getFullBody()); + List inlineProperties = DocBlockData.fromInline(env, element, basePath, properties, tree.getFullBody()); return Optional.of(new DocData(summary, text, Stream.concat(properties.stream(), inlineProperties.stream()).toList())); } } diff --git a/src/main/java/org/moddingx/java_doclet_meta/record/ParamData.java b/src/main/java/org/moddingx/java_doclet_meta/record/ParamData.java index 4bcb9a9..5b3677a 100644 --- a/src/main/java/org/moddingx/java_doclet_meta/record/ParamData.java +++ b/src/main/java/org/moddingx/java_doclet_meta/record/ParamData.java @@ -43,7 +43,7 @@ private static Optional getParamDoc(DocEnv env, String name, Element ele for (DocTree block : tree.getBlockTags()) { if (block.getKind() == DocTree.Kind.PARAM && block instanceof ParamTree pt) { if (!pt.isTypeParameter() && name.equals(pt.getName().getName().toString())) { - return Optional.of(HtmlConverter.asDocHtml(env, DocTreePath.getPath(basePath, pt), pt.getDescription())); + return Optional.of(HtmlConverter.asDocHtml(env, element, DocTreePath.getPath(basePath, pt), pt.getDescription())); } } } diff --git a/src/main/java/org/moddingx/java_doclet_meta/record/block/DocBlockData.java b/src/main/java/org/moddingx/java_doclet_meta/record/block/DocBlockData.java index 02be4c0..3e3476f 100644 --- a/src/main/java/org/moddingx/java_doclet_meta/record/block/DocBlockData.java +++ b/src/main/java/org/moddingx/java_doclet_meta/record/block/DocBlockData.java @@ -7,6 +7,7 @@ import org.moddingx.java_doclet_meta.DocEnv; import org.moddingx.java_doclet_meta.util.HtmlConverter; +import javax.lang.model.element.Element; import java.util.*; import java.util.stream.Collectors; @@ -23,7 +24,7 @@ default JsonObject json() { void addProperties(JsonObject json); - static List fromInline(DocEnv env, DocTreePath basePath, List blocks, List inline) { + static List fromInline(DocEnv env, Element context, DocTreePath basePath, List blocks, List inline) { // Inline return tags in the main description should act as separate block tags. Set knownTypes = new HashSet<>(blocks.stream().map(DocBlockData::type).collect(Collectors.toUnmodifiableSet())); List inlineBlocks = new ArrayList<>(); @@ -32,7 +33,7 @@ static List fromInline(DocEnv env, DocTreePath basePath, List from(DocEnv env, DocTreePath path, DocTree tree) { + static Optional from(DocEnv env, Element context, DocTreePath path, DocTree tree) { // Ignore parameters, they are merged with ParamData return Optional.ofNullable(switch (tree.getKind()) { - case AUTHOR -> new TextBlock(Type.AUTHOR, HtmlConverter.asDocHtml(env, path, ((AuthorTree) tree).getName())); - case DEPRECATED -> new TextBlock(Type.DEPRECATED, HtmlConverter.asDocHtml(env, path, ((DeprecatedTree) tree).getBody())); + case AUTHOR -> new TextBlock(Type.AUTHOR, HtmlConverter.asDocHtml(env, context, path, ((AuthorTree) tree).getName())); + case DEPRECATED -> new TextBlock(Type.DEPRECATED, HtmlConverter.asDocHtml(env, context, path, ((DeprecatedTree) tree).getBody())); case EXCEPTION -> { ThrowsTree ex = (ThrowsTree) tree; - yield ClassTextBlock.from(env, Type.EXCEPTION, path, ex.getExceptionName(), HtmlConverter.asDocHtml(env, path, ex.getDescription())); + yield ClassTextBlock.from(env, Type.EXCEPTION, path, ex.getExceptionName(), HtmlConverter.asDocHtml(env, context, path, ex.getDescription())); } case THROWS -> { ThrowsTree ex = (ThrowsTree) tree; - yield ClassTextBlock.from(env, Type.THROWS, path, ex.getExceptionName(), HtmlConverter.asDocHtml(env, path, ex.getDescription())); + yield ClassTextBlock.from(env, Type.THROWS, path, ex.getExceptionName(), HtmlConverter.asDocHtml(env, context, path, ex.getDescription())); } case PROVIDES -> { ProvidesTree provides = (ProvidesTree) tree; - yield ClassTextBlock.from(env, Type.PROVIDES, path, provides.getServiceType(), HtmlConverter.asDocHtml(env, path, provides.getDescription())); + yield ClassTextBlock.from(env, Type.PROVIDES, path, provides.getServiceType(), HtmlConverter.asDocHtml(env, context, path, provides.getDescription())); } case USES -> { UsesTree provides = (UsesTree) tree; - yield ClassTextBlock.from(env, Type.USES, path, provides.getServiceType(), HtmlConverter.asDocHtml(env, path, provides.getDescription())); + yield ClassTextBlock.from(env, Type.USES, path, provides.getServiceType(), HtmlConverter.asDocHtml(env, context, path, provides.getDescription())); } - case RETURN -> new TextBlock(Type.RETURN, HtmlConverter.asDocHtml(env, path, ((ReturnTree) tree).getDescription())); - case SERIAL -> new TextBlock(Type.SERIAL, HtmlConverter.asDocHtml(env, path, ((SerialTree) tree).getDescription())); - case SINCE -> new TextBlock(Type.SINCE, HtmlConverter.asDocHtml(env, path, ((SinceTree) tree).getBody())); - case UNKNOWN_BLOCK_TAG -> new TextBlock(Type.UNKNOWN, HtmlConverter.asDocHtml(env, path, ((UnknownBlockTagTree) tree).getContent())); + case RETURN -> new TextBlock(Type.RETURN, HtmlConverter.asDocHtml(env, context, path, ((ReturnTree) tree).getDescription())); + case SERIAL -> new TextBlock(Type.SERIAL, HtmlConverter.asDocHtml(env, context, path, ((SerialTree) tree).getDescription())); + case SINCE -> new TextBlock(Type.SINCE, HtmlConverter.asDocHtml(env, context, path, ((SinceTree) tree).getBody())); + case UNKNOWN_BLOCK_TAG -> new TextBlock(Type.UNKNOWN, HtmlConverter.asDocHtml(env, context, path, ((UnknownBlockTagTree) tree).getContent())); default -> null; }); } diff --git a/src/main/java/org/moddingx/java_doclet_meta/util/DocTreePreprocessor.java b/src/main/java/org/moddingx/java_doclet_meta/util/DocTreePreprocessor.java new file mode 100644 index 0000000..66ba819 --- /dev/null +++ b/src/main/java/org/moddingx/java_doclet_meta/util/DocTreePreprocessor.java @@ -0,0 +1,126 @@ +package org.moddingx.java_doclet_meta.util; + +import com.sun.source.doctree.DocTree; +import com.sun.source.doctree.RawTextTree; +import org.commonmark.ext.gfm.tables.TablesExtension; +import org.commonmark.node.AbstractVisitor; +import org.commonmark.node.Heading; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Entities; + +import javax.lang.model.element.Element; +import javax.lang.model.element.ElementKind; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class DocTreePreprocessor { + + // See jdk.javadoc.internal.doclets.formats.html.HtmlDocletWriter$MarkdownHandler + private static final Parser PARSER = Parser.builder().extensions(List.of(TablesExtension.create())).build(); + private static final HtmlRenderer RENDERER = HtmlRenderer.builder().omitSingleParagraphP(true).extensions(List.of(TablesExtension.create())).build(); + private static final Pattern REPLACEMENT_PATTERN = Pattern.compile(""); + + public static List process(Element context, List textElems) { + if (textElems.stream().noneMatch(tree -> tree.getKind() == DocTree.Kind.MARKDOWN)) { + return textElems.stream().map(WrappedDocTree::new).toList(); + } + StringBuilder markdown = new StringBuilder(); + List replacements = new ArrayList<>(); + for (DocTree tree : textElems) { + if (tree.getKind() == DocTree.Kind.MARKDOWN) { + String markdownContent = ((RawTextTree) tree).getContent(); + Matcher m = REPLACEMENT_PATTERN.matcher(markdownContent); + int start = 0; + while (m.find()) { + markdown.append(markdownContent, start, m.start()); + int replacementIdx = replacements.size(); + replacements.add(new RawHtml(m.group())); + markdown.append(""); + start = m.end(); + } + markdown.append(markdownContent.substring(start)); + } else { + int replacementIdx = replacements.size(); + replacements.add(new WrappedDocTree(tree)); + markdown.append(""); + } + } + + Node node = PARSER.parse(markdown.toString()); + adjustHeadings(context, node); + String htmlText = minifyHtml(RENDERER.render(node)); + return replaceElements(htmlText, Collections.unmodifiableList(replacements)); + } + + private static void adjustHeadings(Element context, Node markdown) { + // See jdk.javadoc.internal.doclets.formats.html.HtmlDocletWriter$MarkdownHandler$HeadingNodeRenderer + ElementKind kind = context.getKind(); + int headingInset = kind.isField() || kind.isExecutable() ? 3 : kind != ElementKind.OTHER ? 1 : 0; + + markdown.accept(new AbstractVisitor() { + + @Override + public void visit(Heading heading) { + heading.setLevel(Math.min(heading.getLevel() + headingInset, 6)); + super.visit(heading); + } + }); + } + + private static String minifyHtml(String html) { + Document document = Jsoup.parseBodyFragment(html); + document.outputSettings(new Document.OutputSettings() + .syntax(Document.OutputSettings.Syntax.html) + .escapeMode(Entities.EscapeMode.base) + .charset(StandardCharsets.UTF_8) + .prettyPrint(true) + .indentAmount(0) + .maxPaddingWidth(-1) + .outline(false) + ); + return document.body().html().strip(); + } + + private static List replaceElements(String htmlText, List replacements) { + List replacedText = new ArrayList<>(2 * replacements.size() + 1); + Matcher m = REPLACEMENT_PATTERN.matcher(htmlText); + int start = 0; + while (m.find()) { + replacedText.add(new RawHtml(htmlText.substring(start, m.start()))); + int replacementIdx = -1; + try { + replacementIdx = Integer.parseInt(m.group(1)); + } catch (NumberFormatException e) { + // + } + if (replacementIdx >= 0 && replacementIdx < replacements.size()) { + replacedText.add(replacements.get(replacementIdx)); + } + start = m.end(); + } + replacedText.add(new RawHtml(htmlText.substring(start))); + + List result = new ArrayList<>(replacedText.size()); + for (ProcessedDocTree tree : replacedText) { + if (tree instanceof RawHtml(String html) && html.isEmpty()) continue; + if (tree instanceof RawHtml(String html2) && !result.isEmpty() && result.getLast() instanceof RawHtml(String html1)) { + result.set(result.size() - 1, new RawHtml(html1 + html2)); + } else { + result.add(tree); + } + } + return List.copyOf(result); + } + + public sealed interface ProcessedDocTree permits RawHtml, WrappedDocTree {} + public record RawHtml(String html) implements ProcessedDocTree {} + public record WrappedDocTree(DocTree tree) implements ProcessedDocTree {} +} diff --git a/src/main/java/org/moddingx/java_doclet_meta/util/HtmlConverter.java b/src/main/java/org/moddingx/java_doclet_meta/util/HtmlConverter.java index 7a98cdf..0797192 100644 --- a/src/main/java/org/moddingx/java_doclet_meta/util/HtmlConverter.java +++ b/src/main/java/org/moddingx/java_doclet_meta/util/HtmlConverter.java @@ -10,62 +10,73 @@ public class HtmlConverter { - public static String asDocHtml(DocEnv env, @Nullable DocTreePath basePath, List textElems) { + public static String asDocHtml(DocEnv env, Element context, @Nullable DocTreePath basePath, List textElems) { StringBuilder sb = new StringBuilder(); - for (DocTree tree : textElems) { - DocTreePath path = basePath == null ? null : DocTreePath.getPath(basePath, tree); - switch (tree.getKind()) { - case ENTITY -> sb.append(((EntityTree) tree).getName()); - case START_ELEMENT -> { - StartElementTree elem = (StartElementTree) tree; - sb.append("<").append(elem.getName()); - for (DocTree attrTree : elem.getAttributes()) { - if (attrTree.getKind() == DocTree.Kind.ATTRIBUTE && attrTree instanceof AttributeTree attr) { - sb.append(" ").append(attr.getName().toString()); - if (attr.getValueKind() != AttributeTree.ValueKind.EMPTY) { - sb.append("=\""); - // Only supports plain text - for (DocTree attrContentTree : attr.getValue()) { - if (attrContentTree.getKind() == DocTree.Kind.TEXT) { - sb.append(((TextTree) attrContentTree).getBody() - .replace("\\", "\\\\") - .replace("\"", "\\\"") - ); - } + List processedElems = DocTreePreprocessor.process(context, textElems); + for (DocTreePreprocessor.ProcessedDocTree processedTree : processedElems) { + switch (processedTree) { + case DocTreePreprocessor.RawHtml(String html) -> sb.append(html); + case DocTreePreprocessor.WrappedDocTree(DocTree tree) -> { + DocTreePath path = basePath == null ? null : DocTreePath.getPath(basePath, tree); + processDocTree(sb, env, context, path, tree); + } + } + } + return sb.toString(); + } + + private static void processDocTree(StringBuilder sb, DocEnv env, Element context, @Nullable DocTreePath path, DocTree tree) { + switch (tree.getKind()) { + case ENTITY -> sb.append(((EntityTree) tree).getName()); + case START_ELEMENT -> { + StartElementTree elem = (StartElementTree) tree; + sb.append("<").append(elem.getName()); + for (DocTree attrTree : elem.getAttributes()) { + if (attrTree.getKind() == DocTree.Kind.ATTRIBUTE && attrTree instanceof AttributeTree attr) { + sb.append(" ").append(attr.getName().toString()); + if (attr.getValueKind() != AttributeTree.ValueKind.EMPTY) { + sb.append("=\""); + // Only supports plain text + for (DocTree attrContentTree : attr.getValue()) { + if (attrContentTree.getKind() == DocTree.Kind.TEXT) { + sb.append(((TextTree) attrContentTree).getBody() + .replace("\\", "\\\\") + .replace("\"", "\\\"") + ); } - sb.append("\""); } + sb.append("\""); } } - sb.append(">"); - } - case END_ELEMENT -> { - EndElementTree elem = (EndElementTree) tree; - sb.append(""); } - case CODE -> sb.append("").append(HtmlQuote.quote(((LiteralTree) tree).getBody().getBody())).append(""); - case LITERAL -> sb.append("").append(HtmlQuote.quote(((LiteralTree) tree).getBody().getBody())).append(""); - case ERRONEOUS -> sb.append(HtmlQuote.quote(((ErroneousTree) tree).getBody())); - case LINK -> sb.append(linkTag(env, "ref", path, ((LinkTree) tree).getReference(), ((LinkTree) tree).getLabel())); - case LINK_PLAIN -> sb.append(linkTag(env, "refp", path, ((LinkTree) tree).getReference(), ((LinkTree) tree).getLabel())); - case VALUE -> sb.append(inlineValue(env, path, ((ValueTree) tree).getReference())); - case SYSTEM_PROPERTY -> sb.append("").append(HtmlQuote.quote(((SystemPropertyTree) tree).getPropertyName().toString())).append(""); - case IDENTIFIER -> sb.append(HtmlQuote.quote(((IdentifierTree) tree).getName().toString())); - case RETURN -> { - if (((ReturnTree) tree).isInline()) { - sb.append(HtmlQuote.quote("Returns ")).append(asDocHtml(env, basePath, ((ReturnTree) tree).getDescription())).append(HtmlQuote.quote(".")); - } - } - case TEXT -> sb.append(HtmlQuote.quote(((TextTree) tree).getBody())); - case UNKNOWN_INLINE_TAG -> { - UnknownInlineTagTree tag = (UnknownInlineTagTree) tree; - sb.append("<").append(tag.getTagName()).append(">"); - sb.append(asDocHtml(env, path, tag.getContent())); - sb.append(""); + sb.append(">"); + } + case END_ELEMENT -> { + EndElementTree elem = (EndElementTree) tree; + sb.append(""); + } + case CODE -> sb.append("").append(HtmlQuote.quote(((LiteralTree) tree).getBody().getBody())).append(""); + case LITERAL -> sb.append("").append(HtmlQuote.quote(((LiteralTree) tree).getBody().getBody())).append(""); + case ERRONEOUS -> sb.append(HtmlQuote.quote(((ErroneousTree) tree).getBody())); + case LINK -> sb.append(linkTag(env, "ref", context, path, ((LinkTree) tree).getReference(), ((LinkTree) tree).getLabel())); + case LINK_PLAIN -> sb.append(linkTag(env, "refp", context, path, ((LinkTree) tree).getReference(), ((LinkTree) tree).getLabel())); + case VALUE -> sb.append(inlineValue(env, path, ((ValueTree) tree).getReference())); + case SYSTEM_PROPERTY -> sb.append("").append(HtmlQuote.quote(((SystemPropertyTree) tree).getPropertyName().toString())).append(""); + case IDENTIFIER -> sb.append(HtmlQuote.quote(((IdentifierTree) tree).getName().toString())); + case RETURN -> { + if (((ReturnTree) tree).isInline()) { + sb.append(HtmlQuote.quote("Returns ")).append(asDocHtml(env, context, path, ((ReturnTree) tree).getDescription())).append(HtmlQuote.quote(".")); } } + case TEXT -> sb.append(HtmlQuote.quote(((TextTree) tree).getBody())); + case MARKDOWN -> throw new IllegalStateException("Markdown text in preprocessed DocTree."); + case UNKNOWN_INLINE_TAG -> { + UnknownInlineTagTree tag = (UnknownInlineTagTree) tree; + sb.append("<").append(tag.getTagName()).append(">"); + sb.append(asDocHtml(env, context, path, tag.getContent())); + sb.append(""); + } } - return sb.toString(); } private static String inlineValue(DocEnv env, @Nullable DocTreePath basePath, ReferenceTree reference) { @@ -78,14 +89,14 @@ private static String inlineValue(DocEnv env, @Nullable DocTreePath basePath, Re } } // If no field was found or no constant expression exists, just link the member - return linkTag(env, "ref", basePath, reference, (String) null); + return linkTag(env, "ref", basePath, reference, null); } - private static String linkTag(DocEnv env, String tagName, @Nullable DocTreePath basePath, ReferenceTree reference, @Nullable List textContent) { + private static String linkTag(DocEnv env, String tagName, Element context, @Nullable DocTreePath basePath, ReferenceTree reference, @Nullable List textContent) { if (textContent == null || textContent.isEmpty()) { - return linkTag(env, tagName, basePath, reference, (String) null); + return linkTag(env, tagName, basePath, reference, null); } else { - return linkTag(env, tagName, basePath, reference, asDocHtml(env, basePath, textContent)); + return linkTag(env, tagName, basePath, reference, asDocHtml(env, context, basePath, textContent)); } }