diff --git a/.github/workflows/dependency-submission.yml b/.github/workflows/dependency-submission.yml
index 748dfe67..d02a1dbe 100644
--- a/.github/workflows/dependency-submission.yml
+++ b/.github/workflows/dependency-submission.yml
@@ -16,7 +16,7 @@ jobs:
- name: Checkout sources
uses: actions/checkout@v6
- name: Generate and submit dependency graph
- uses: gradle/actions/dependency-submission@v5
+ uses: gradle/actions/dependency-submission@v6
env:
DEPENDENCY_GRAPH_EXCLUDE_PROJECTS: ':allure-java-commons-test'
DEPENDENCY_GRAPH_INCLUDE_CONFIGURATIONS: 'runtimeClasspath'
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2295deb9..fd0a661c 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -30,7 +30,7 @@ jobs:
- name: "Gradle Publish"
run: |
- ./gradlew publishToSonatype closeSonatypeStagingRepository -Pversion=${GITHUB_REF:10} \
+ ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository -Pversion=${GITHUB_REF:10} \
-Psigning.keyId=${GPG_KEY_ID} \
-Psigning.password=${GPG_PASSPHRASE} \
-Psigning.secretKeyRingFile=${GITHUB_WORKSPACE}/${GPG_KEY_ID}.gpg
diff --git a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java
new file mode 100644
index 00000000..6546dbb4
--- /dev/null
+++ b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionRenderer.java
@@ -0,0 +1,562 @@
+/*
+ * Copyright 2016-2026 Qameta Software Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.qameta.allure.description;
+
+import java.util.Locale;
+
+/**
+ * Renders raw JavaDoc comment text into a safe, markdown-friendly description for Allure.
+ *
+ *
This renderer intentionally implements a small, conservative subset of the JavaDoc comment
+ * specification instead of attempting to preserve the full doclet output. The goal is to keep
+ * JavaDoc-backed descriptions readable in reports while ensuring that untrusted comment content is
+ * never treated as executable HTML.
+ *
+ *
The rendering algorithm is intentionally simple:
+ *
+ *
Take only the main description, stopping at the first block tag such as {@code @param}
+ * or {@code @throws}.
+ *
Render the remaining content with a small parser that recognizes a limited set of inline
+ * JavaDoc tags and structural HTML tags.
+ *
Escape or drop everything else so the output remains plain text or safe markdown.
+ *
+ *
+ *
Currently supported JavaDoc constructs include inline tags such as {@code {@code ...}},
+ * {@code {@literal ...}}, {@code {@link ...}}, and {@code {@linkplain ...}}.
+ *
+ *
The renderer also understands a small set of structural HTML tags: {@code p}, {@code br},
+ * {@code ul}, {@code ol}, {@code li}, and {@code code}. Common entity references such as
+ * {@code <}, {@code >}, {@code &}, {@code @},
+ * {@code {}, and numeric entities are decoded before the output is escaped again.
+ *
+ *
Unsupported tags are degraded to escaped text instead of being interpreted. Unknown HTML tags
+ * are ignored as markup while their text content remains visible. This keeps the JavaDoc
+ * description path suitable for open source projects where comments may evolve over time and where
+ * security is more important than pixel-perfect parity with the standard doclet.
+ */
+final class JavaDocDescriptionRenderer {
+
+ private static final String PARAGRAPH_BREAK = "\n\n";
+ private static final String HTML_LT = "<";
+ private static final String HTML_GT = ">";
+ private static final String HTML_AMP = "&";
+ private static final String INLINE_CODE_MARKER = "`";
+ private static final String ESCAPED_INLINE_CODE_MARKER = "``";
+ private static final String CODE_TAG = "code";
+ private static final String HTML_TAG_END = ">";
+ private static final String CLOSING_TAG_PREFIX = "";
+
+ /**
+ * Converts raw JavaDoc comment text into the description format stored by the annotation
+ * processor.
+ *
+ *
The method extracts the JavaDoc main description, renders the supported inline and HTML
+ * constructs into plain text or markdown, normalizes whitespace, and escapes unsafe content.
+ * The returned value is intended for Allure's plain {@code description} field, not for
+ * {@code descriptionHtml}.
+ *
+ * @param rawDocComment the comment text returned by {@link javax.lang.model.util.Elements#getDocComment}
+ * @return a safe markdown/plain-text description, or an empty string if the comment has no main
+ * description
+ */
+ String render(final String rawDocComment) {
+ final String descriptionBody = extractDescriptionBody(rawDocComment);
+ if (descriptionBody.isEmpty()) {
+ return "";
+ }
+
+ final StringBuilder rendered = new StringBuilder();
+ renderFragment(descriptionBody, rendered);
+ return cleanup(rendered.toString());
+ }
+
+ private String extractDescriptionBody(final String rawDocComment) {
+ final String[] lines = normalize(rawDocComment).split("\n", -1);
+ final StringBuilder body = new StringBuilder();
+ int inlineTagDepth = 0;
+
+ for (String line : lines) {
+ if (inlineTagDepth == 0 && startsBlockTag(line)) {
+ return trimBlankLines(body.toString());
+ }
+ if (body.length() > 0) {
+ body.append('\n');
+ }
+ body.append(trimTrailingWhitespace(line));
+ inlineTagDepth = updateInlineTagDepth(line, inlineTagDepth);
+ }
+
+ return trimBlankLines(body.toString());
+ }
+
+ private boolean startsBlockTag(final String line) {
+ final String trimmed = line.trim();
+ return trimmed.length() > 1 && trimmed.charAt(0) == '@' && Character.isJavaIdentifierStart(trimmed.charAt(1));
+ }
+
+ @SuppressWarnings("checkstyle:CyclomaticComplexity")
+ private void renderFragment(final String fragment, final StringBuilder rendered) {
+ int index = 0;
+ while (index < fragment.length()) {
+ final char current = fragment.charAt(index);
+ if (current == '{' && index + 1 < fragment.length() && fragment.charAt(index + 1) == '@') {
+ final int nextIndex = renderInlineTag(fragment, index, rendered);
+ if (nextIndex > index) {
+ index = nextIndex;
+ continue;
+ }
+ }
+ if (current == '<') {
+ final int nextIndex = renderHtmlTag(fragment, index, rendered);
+ if (nextIndex > index) {
+ index = nextIndex;
+ continue;
+ }
+ rendered.append(HTML_LT);
+ index++;
+ continue;
+ }
+ if (current == '&') {
+ final int nextIndex = renderEntityReference(fragment, index, rendered);
+ if (nextIndex > index) {
+ index = nextIndex;
+ continue;
+ }
+ rendered.append(HTML_AMP);
+ index++;
+ continue;
+ }
+ if (current == '>') {
+ rendered.append(HTML_GT);
+ index++;
+ continue;
+ }
+ rendered.append(current);
+ index++;
+ }
+ }
+
+ @SuppressWarnings("checkstyle:ReturnCount")
+ private int renderInlineTag(final String fragment, final int start, final StringBuilder rendered) {
+ final int end = findInlineTagEnd(fragment, start);
+ if (end < 0) {
+ return start;
+ }
+
+ final String content = fragment.substring(start + 2, end).trim();
+ if (content.isEmpty()) {
+ return end + 1;
+ }
+
+ final int separator = findWhitespace(content);
+ final String tag = separator < 0 ? content : content.substring(0, separator);
+ final String payload = separator < 0 ? "" : content.substring(separator + 1).trim();
+
+ if (CODE_TAG.equals(tag)) {
+ appendCode(rendered, payload);
+ return end + 1;
+ }
+ if ("literal".equals(tag)) {
+ rendered.append(escapeText(payload));
+ return end + 1;
+ }
+ if ("link".equals(tag) || "linkplain".equals(tag)) {
+ appendLink(rendered, payload);
+ return end + 1;
+ }
+
+ rendered.append(escapeText(content));
+ return end + 1;
+ }
+
+ @SuppressWarnings({
+ "checkstyle:CyclomaticComplexity",
+ "checkstyle:NPathComplexity",
+ "checkstyle:ReturnCount"})
+ private int renderHtmlTag(final String fragment, final int start, final StringBuilder rendered) {
+ if (start + 1 >= fragment.length() || Character.isWhitespace(fragment.charAt(start + 1))) {
+ return start;
+ }
+
+ final int end = fragment.indexOf('>', start + 1);
+ if (end < 0) {
+ return start;
+ }
+
+ final String rawTag = fragment.substring(start + 1, end).trim();
+ if (rawTag.isEmpty()) {
+ return start;
+ }
+
+ boolean closing = false;
+ String tag = rawTag;
+ if (tag.charAt(0) == '/') {
+ closing = true;
+ tag = tag.substring(1).trim();
+ }
+
+ if (tag.endsWith("/")) {
+ tag = tag.substring(0, tag.length() - 1).trim();
+ }
+
+ final int separator = findTagNameEnd(tag);
+ if (separator <= 0) {
+ return end + 1;
+ }
+
+ final String name = tag.substring(0, separator).toLowerCase(Locale.ROOT);
+ if ("br".equals(name)) {
+ appendLineBreak(rendered);
+ return end + 1;
+ }
+ if ("p".equals(name) || "ul".equals(name) || "ol".equals(name)) {
+ appendParagraphBreak(rendered);
+ return end + 1;
+ }
+ if ("li".equals(name)) {
+ if (!closing) {
+ startListItem(rendered);
+ }
+ return end + 1;
+ }
+ if (CODE_TAG.equals(name)) {
+ if (closing) {
+ return end + 1;
+ }
+ final int closingStart = findClosingTag(fragment, end + 1, CODE_TAG);
+ if (closingStart <= end) {
+ return end + 1;
+ }
+ appendCode(rendered, fragment.substring(end + 1, closingStart));
+ return closingStart + (CLOSING_TAG_PREFIX + CODE_TAG + HTML_TAG_END).length();
+ }
+
+ return end + 1;
+ }
+
+ private void appendLink(final StringBuilder rendered, final String payload) {
+ if (payload.isEmpty()) {
+ return;
+ }
+
+ final int separator = findWhitespace(payload);
+ final String label = separator < 0 ? "" : payload.substring(separator + 1).trim();
+ if (label.isEmpty()) {
+ final String reference = separator < 0 ? payload : payload.substring(0, separator);
+ rendered.append(escapeText(shortenReference(reference)));
+ return;
+ }
+
+ renderFragment(label, rendered);
+ }
+
+ private String shortenReference(final String reference) {
+ final String trimmed = reference.trim();
+ final int hashIndex = trimmed.lastIndexOf('#');
+ if (hashIndex >= 0 && hashIndex + 1 < trimmed.length()) {
+ return trimmed.substring(hashIndex + 1);
+ }
+
+ final int dotIndex = trimmed.lastIndexOf('.');
+ if (dotIndex >= 0 && dotIndex + 1 < trimmed.length()) {
+ return trimmed.substring(dotIndex + 1);
+ }
+
+ return trimmed;
+ }
+
+ private void appendCode(final StringBuilder rendered, final String payload) {
+ final String escaped = escapeText(payload);
+ final String marker = escaped.contains(INLINE_CODE_MARKER)
+ ? ESCAPED_INLINE_CODE_MARKER
+ : INLINE_CODE_MARKER;
+ rendered.append(marker)
+ .append(escaped)
+ .append(marker);
+ }
+
+ private void startListItem(final StringBuilder rendered) {
+ trimTrailingSpaces(rendered);
+ if (rendered.length() > 0 && rendered.charAt(rendered.length() - 1) != '\n') {
+ rendered.append('\n');
+ }
+ rendered.append("- ");
+ }
+
+ private void appendParagraphBreak(final StringBuilder rendered) {
+ trimTrailingSpaces(rendered);
+ if (rendered.length() == 0 || endsWith(rendered, PARAGRAPH_BREAK)) {
+ return;
+ }
+ if (rendered.charAt(rendered.length() - 1) == '\n') {
+ rendered.append('\n');
+ return;
+ }
+ rendered.append(PARAGRAPH_BREAK);
+ }
+
+ private void appendLineBreak(final StringBuilder rendered) {
+ trimTrailingSpaces(rendered);
+ if (rendered.length() == 0 || rendered.charAt(rendered.length() - 1) == '\n') {
+ return;
+ }
+ rendered.append('\n');
+ }
+
+ private String cleanup(final String rendered) {
+ final String[] lines = normalize(rendered).split("\n", -1);
+ final StringBuilder cleaned = new StringBuilder();
+ boolean blankLinePending = false;
+
+ for (String line : lines) {
+ final String trimmed = line.trim();
+ if (trimmed.isEmpty()) {
+ if (cleaned.length() > 0) {
+ blankLinePending = true;
+ }
+ continue;
+ }
+
+ if (cleaned.length() > 0) {
+ cleaned.append(blankLinePending ? PARAGRAPH_BREAK : "\n");
+ }
+ cleaned.append(trimmed);
+ blankLinePending = false;
+ }
+
+ return cleaned.toString();
+ }
+
+ private String trimBlankLines(final String value) {
+ final String[] lines = normalize(value).split("\n", -1);
+ int start = 0;
+ int end = lines.length;
+
+ while (start < end && isBlank(lines[start])) {
+ start++;
+ }
+ while (end > start && isBlank(lines[end - 1])) {
+ end--;
+ }
+
+ final StringBuilder result = new StringBuilder();
+ for (int index = start; index < end; index++) {
+ if (result.length() > 0) {
+ result.append('\n');
+ }
+ result.append(lines[index]);
+ }
+ return result.toString();
+ }
+
+ private int updateInlineTagDepth(final String line, final int initialDepth) {
+ int depth = initialDepth;
+ int index = 0;
+ while (index < line.length()) {
+ final char current = line.charAt(index);
+ if (depth == 0) {
+ if (current == '{' && index + 1 < line.length() && line.charAt(index + 1) == '@') {
+ depth = 1;
+ index += 2;
+ continue;
+ }
+ } else if (current == '{') {
+ depth++;
+ } else if (current == '}') {
+ depth--;
+ }
+ index++;
+ }
+ return depth;
+ }
+
+ private void trimTrailingSpaces(final StringBuilder builder) {
+ while (builder.length() > 0) {
+ final char current = builder.charAt(builder.length() - 1);
+ if (current != ' ' && current != '\t') {
+ break;
+ }
+ builder.deleteCharAt(builder.length() - 1);
+ }
+ }
+
+ private boolean endsWith(final StringBuilder builder, final String suffix) {
+ return builder.length() >= suffix.length()
+ && builder.substring(builder.length() - suffix.length()).equals(suffix);
+ }
+
+ private int findWhitespace(final String value) {
+ for (int index = 0; index < value.length(); index++) {
+ if (Character.isWhitespace(value.charAt(index))) {
+ return index;
+ }
+ }
+ return -1;
+ }
+
+ private int findTagNameEnd(final String tag) {
+ for (int index = 0; index < tag.length(); index++) {
+ final char current = tag.charAt(index);
+ if (!(Character.isLetterOrDigit(current) || current == '-' || current == '_')) {
+ return index;
+ }
+ }
+ return tag.length();
+ }
+
+ private int findClosingTag(final String fragment, final int fromIndex, final String tagName) {
+ return fragment.toLowerCase(Locale.ROOT).indexOf(CLOSING_TAG_PREFIX + tagName + HTML_TAG_END, fromIndex);
+ }
+
+ private int findInlineTagEnd(final String fragment, final int start) {
+ int depth = 1;
+ for (int index = start + 2; index < fragment.length(); index++) {
+ final char current = fragment.charAt(index);
+ if (current == '{') {
+ depth++;
+ continue;
+ }
+ if (current == '}') {
+ depth--;
+ if (depth == 0) {
+ return index;
+ }
+ }
+ }
+ return -1;
+ }
+
+ private String trimTrailingWhitespace(final String line) {
+ int end = line.length();
+ while (end > 0) {
+ final char current = line.charAt(end - 1);
+ if (current != ' ' && current != '\t') {
+ break;
+ }
+ end--;
+ }
+ return line.substring(0, end);
+ }
+
+ private String normalize(final String value) {
+ return value.replace("\r\n", "\n").replace('\r', '\n');
+ }
+
+ private boolean isBlank(final String value) {
+ for (int index = 0; index < value.length(); index++) {
+ if (!Character.isWhitespace(value.charAt(index))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private int renderEntityReference(final String fragment, final int start, final StringBuilder rendered) {
+ final int end = fragment.indexOf(';', start + 1);
+ if (end < 0) {
+ return start;
+ }
+
+ final String decoded = decodeEntity(fragment.substring(start + 1, end));
+ if (decoded == null) {
+ return start;
+ }
+
+ rendered.append(escapeText(decoded));
+ return end + 1;
+ }
+
+ @SuppressWarnings({
+ "checkstyle:CyclomaticComplexity",
+ "checkstyle:NPathComplexity",
+ "checkstyle:ReturnCount"})
+ private String decodeEntity(final String entity) {
+ if (entity.isEmpty()) {
+ return null;
+ }
+
+ if (entity.charAt(0) == '#') {
+ return decodeNumericEntity(entity);
+ }
+
+ if ("amp".equals(entity)) {
+ return Character.toString('&');
+ }
+ if ("lt".equals(entity)) {
+ return Character.toString('<');
+ }
+ if ("gt".equals(entity)) {
+ return Character.toString('>');
+ }
+ if ("quot".equals(entity)) {
+ return "\"";
+ }
+ if ("apos".equals(entity)) {
+ return "'";
+ }
+ if ("nbsp".equals(entity)) {
+ return " ";
+ }
+ if ("lbrace".equals(entity)) {
+ return "{";
+ }
+ if ("rbrace".equals(entity)) {
+ return "}";
+ }
+ if ("commat".equals(entity)) {
+ return Character.toString('@');
+ }
+ return null;
+ }
+
+ private String decodeNumericEntity(final String entity) {
+ try {
+ final int codePoint;
+ if (entity.startsWith("#x") || entity.startsWith("#X")) {
+ codePoint = Integer.parseInt(entity.substring(2), 16);
+ } else {
+ codePoint = Integer.parseInt(entity.substring(1), 10);
+ }
+ return new String(Character.toChars(codePoint));
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
+ private String escapeText(final String value) {
+ final StringBuilder escaped = new StringBuilder();
+ int index = 0;
+ while (index < value.length()) {
+ final char current = value.charAt(index);
+ if (current == '&') {
+ final int nextIndex = renderEntityReference(value, index, escaped);
+ if (nextIndex > index) {
+ index = nextIndex;
+ continue;
+ }
+ escaped.append(HTML_AMP);
+ } else if (current == '<') {
+ escaped.append(HTML_LT);
+ } else if (current == '>') {
+ escaped.append(HTML_GT);
+ } else {
+ escaped.append(current);
+ }
+ index++;
+ }
+ return escaped.toString();
+ }
+}
diff --git a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java
index f423c7bb..acc1d555 100644
--- a/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java
+++ b/allure-descriptions-javadoc/src/main/java/io/qameta/allure/description/JavaDocDescriptionsProcessor.java
@@ -54,6 +54,7 @@ public class JavaDocDescriptionsProcessor extends AbstractProcessor {
private Filer filer;
private Elements elementUtils;
private Messager messager;
+ private JavaDocDescriptionRenderer renderer;
@Override
@SuppressWarnings("PMD.AvoidSynchronizedAtMethodLevel")
@@ -62,6 +63,7 @@ public synchronized void init(final ProcessingEnvironment env) {
filer = env.getFiler();
elementUtils = env.getElementUtils();
messager = env.getMessager();
+ renderer = new JavaDocDescriptionRenderer();
}
@Override
@@ -76,12 +78,11 @@ public boolean process(final Set extends TypeElement> annotations, final Round
final Set methods = ElementFilter.methodsIn(elements);
methods.forEach(method -> {
final String rawDocs = elementUtils.getDocComment(method);
-
if (rawDocs == null) {
return;
}
- final String docs = rawDocs.trim();
+ final String docs = renderer.render(rawDocs);
if (docs.isEmpty()) {
return;
}
diff --git a/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java
new file mode 100644
index 00000000..180d2b03
--- /dev/null
+++ b/allure-descriptions-javadoc/src/test/java/io/qameta/allure/description/JavaDocDescriptionRendererTest.java
@@ -0,0 +1,328 @@
+/*
+ * Copyright 2016-2026 Qameta Software Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package io.qameta.allure.description;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class JavaDocDescriptionRendererTest {
+
+ private final JavaDocDescriptionRenderer renderer = new JavaDocDescriptionRenderer();
+
+ @Test
+ void shouldRenderPlainTextAndTrimBlankLines() {
+ final String rendered = renderer.render(
+ "\r\n"
+ + " First line \r\n"
+ + "\r\n"
+ + " Second line\t\r\n"
+ + "\r\n"
+ );
+
+ assertThat(rendered)
+ .isEqualTo("First line\n\nSecond line");
+ }
+
+ @Test
+ void shouldReturnEmptyStringWhenBodyContainsOnlyBlockTags() {
+ final String rendered = renderer.render(
+ "@param value description\n"
+ + "@throws Exception description"
+ );
+
+ assertThat(rendered)
+ .isEmpty();
+ }
+
+ @Test
+ void shouldIgnoreBlockTagsAndEverythingAfterThem() {
+ final String rendered = renderer.render(
+ "Summary paragraph.\n"
+ + "\n"
+ + "@param value Description of the value.\n"
+ + "Continuation that should also be ignored."
+ );
+
+ assertThat(rendered)
+ .isEqualTo("Summary paragraph.");
+ }
+
+ @Test
+ void shouldIgnoreStandardBlockTagsAfterMainDescription() {
+ final List blockTags = List.of(
+ "author",
+ "deprecated",
+ "exception",
+ "hidden",
+ "param",
+ "provides",
+ "return",
+ "see",
+ "serial",
+ "serialData",
+ "serialField",
+ "since",
+ "spec",
+ "throws",
+ "uses",
+ "version"
+ );
+
+ for (String blockTag : blockTags) {
+ assertThat(renderer.render("Summary paragraph.\n@" + blockTag + " metadata"))
+ .as(blockTag)
+ .isEqualTo("Summary paragraph.");
+ }
+ }
+
+ @Test
+ void shouldNotTreatAtSignsInsideTextAsBlockTags() {
+ final String rendered = renderer.render(
+ "Email support@example.com\n"
+ + "Use @smoke in prose."
+ );
+
+ assertThat(rendered)
+ .isEqualTo("Email support@example.com\nUse @smoke in prose.");
+ }
+
+ @Test
+ void shouldDecodeEscapedAtEntityBeforeBlockTags() {
+ final String rendered = renderer.render(
+ "@version stays in prose.\n"
+ + "@version 2.4.0"
+ );
+
+ assertThat(rendered)
+ .isEqualTo("@version stays in prose.");
+ }
+
+ @Test
+ void shouldPreserveUnicodeCharactersInDescriptions() {
+ final String rendered = renderer.render("Release notes: cafe, café, Привет, 東京, λ.");
+
+ assertThat(rendered)
+ .isEqualTo("Release notes: cafe, café, Привет, 東京, λ.");
+ }
+
+ @Test
+ void shouldDecodeSupportedNamedAndNumericEntities() {
+ final String rendered = renderer.render(
+ "Use <tag>, &, {x}, @, λ, and λ."
+ );
+
+ assertThat(rendered)
+ .isEqualTo("Use <tag>, &, {x}, @, λ, and λ.");
+ }
+
+ @Test
+ void shouldRenderSupportedInlineTags() {
+ final String rendered = renderer.render(
+ "Use {@code a < b}, {@literal }, "
+ + "{@link java.lang.String}, "
+ + "{@linkplain java.lang.String#valueOf(Object)}, "
+ + "{@link java.util.List list docs}."
+ );
+
+ assertThat(rendered)
+ .isEqualTo("Use `a < b`, <safe>, String, valueOf(Object), list docs.");
+ }
+
+ @Test
+ void shouldSupportBalancedBracesInsideInlineTags() {
+ final String rendered = renderer.render(
+ "Payload {@code {\"outer\": {\"inner\": true}}}."
+ );
+
+ assertThat(rendered)
+ .isEqualTo("Payload `{\"outer\": {\"inner\": true}}`.");
+ }
+
+ @Test
+ void shouldNotTreatAtLinesInsideBalancedInlineTagsAsBlockTags() {
+ final String rendered = renderer.render(
+ "Summary {@literal first line\n"
+ + "@notATag\n"
+ + "last line}\n"
+ + "@param ignored"
+ );
+
+ assertThat(rendered)
+ .isEqualTo("Summary first line\n@notATag\nlast line");
+ }
+
+ @Test
+ void shouldRenderNestedInlineTagsInsideLinkLabels() {
+ final String rendered = renderer.render(
+ "See {@linkplain java.util.List docs with {@code List}}."
+ );
+
+ assertThat(rendered)
+ .isEqualTo("See docs with `List`.");
+ }
+
+ @Test
+ void shouldSafelyDegradeUnsupportedStandardInlineTags() {
+ final String rendered = renderer.render(
+ "Fallbacks: {@docRoot}, {@inheritDoc}, {@index release}, "
+ + "{@summary quick summary}, {@systemProperty user.home}, "
+ + "{@value java.lang.Integer#MAX_VALUE}."
+ );
+
+ assertThat(rendered)
+ .isEqualTo(
+ "Fallbacks: docRoot, inheritDoc, index release, summary quick summary, "
+ + "systemProperty user.home, value java.lang.Integer#MAX_VALUE."
+ );
+ }
+
+ @Test
+ void shouldSafelyDegradeSnippetTags() {
+ final String rendered = renderer.render(
+ "Snippet {@snippet :\n"
+ + "int answer = 42;\n"
+ + "@highlight substring=\"answer\"\n"
+ + "}."
+ );
+
+ assertThat(rendered)
+ .isEqualTo("Snippet snippet :\nint answer = 42;\n@highlight substring=\"answer\".");
+ }
+
+ @Test
+ void shouldEscapeUnknownInlineTags() {
+ final String rendered = renderer.render("Unsupported {@unknown } clause.");
+
+ assertThat(rendered)
+ .isEqualTo("Unsupported unknown <tag> clause.");
+ }
+
+ @Test
+ void shouldPreserveMalformedInlineTagsAsText() {
+ final String rendered = renderer.render("Broken {@code tag");
+
+ assertThat(rendered)
+ .isEqualTo("Broken {@code tag");
+ }
+
+ @Test
+ void shouldRenderSupportedHtmlStructure() {
+ final String rendered = renderer.render(
+ "First