From 561aa5c5576e753ce9ec4de9bfea72f2cafc6e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Wed, 8 Apr 2026 17:44:31 +0200 Subject: [PATCH 1/9] Add skeleton MarkdownConverter with SPI registration and core dispatch --- .../asciidoctor/MarkdownConverter.java | 105 ++++++++++++++++++ .../MarkdownConverterRegistry.java | 11 ++ ...ctor.jruby.converter.spi.ConverterRegistry | 1 + 3 files changed, 117 insertions(+) create mode 100644 build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java create mode 100644 build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverterRegistry.java create mode 100644 build-logic/asciidoc-extensions/src/main/resources/META-INF/services/org.asciidoctor.jruby.converter.spi.ConverterRegistry diff --git a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java new file mode 100644 index 0000000000..1cb4fcd584 --- /dev/null +++ b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java @@ -0,0 +1,105 @@ +package org.spockframework.plugins.asciidoctor; + +import org.asciidoctor.ast.*; +import org.asciidoctor.converter.ConverterFor; +import org.asciidoctor.converter.StringConverter; +import org.asciidoctor.log.LogRecord; +import org.asciidoctor.log.Severity; + +import java.util.Map; + +@ConverterFor(value = "markdown", suffix = ".md") +public class MarkdownConverter extends StringConverter { + + public MarkdownConverter(String backend, Map opts) { + super(backend, opts); + } + + @Override + public String convert(ContentNode node, String transform, Map opts) { + String name = transform != null ? transform : node.getNodeName(); + return switch (name) { + case "document" -> convertDocument((Document) node); + case "section" -> convertSection((Section) node); + case "paragraph" -> convertParagraph((StructuralNode) node); + case "preamble" -> convertPreamble((StructuralNode) node); + case "listing" -> convertListing((Block) node); + case "literal" -> convertLiteral((Block) node); + case "ulist" -> convertUnorderedList((List) node); + case "olist" -> convertOrderedList((List) node); + case "dlist" -> convertDescriptionList((DescriptionList) node); + case "table" -> convertTable((Table) node); + case "admonition" -> convertAdmonition((StructuralNode) node); + case "image" -> convertImage((StructuralNode) node); + case "open" -> convertOpen((StructuralNode) node); + case "sidebar" -> convertSidebar((StructuralNode) node); + case "example" -> convertExample((StructuralNode) node); + case "thematic_break" -> "---\n\n"; + case "inline_quoted" -> convertInlineQuoted((PhraseNode) node); + case "inline_anchor" -> convertInlineAnchor((PhraseNode) node); + case "inline_image" -> convertInlineImage((PhraseNode) node); + case "inline_footnote" -> convertInlineFootnote((PhraseNode) node); + default -> { + log(new LogRecord(Severity.WARN, "Unsupported node: " + name)); + yield ""; + } + }; + } + + private String convertDocument(Document node) { + var sb = new StringBuilder(); + String title = node.getDoctitle(); + if (title != null) { + sb.append("# ").append(title).append("\n\n"); + } + Object content = node.getContent(); + if (content != null) { + sb.append(content); + } + return sb.toString(); + } + + private String convertSection(Section node) { + var sb = new StringBuilder(); + // Section level 0 = top-level section = ##, level 1 = ###, etc. + int headingLevel = node.getLevel() + 1; + sb.append("#".repeat(Math.min(headingLevel, 6))) + .append(" ") + .append(node.getTitle()) + .append("\n\n"); + Object content = node.getContent(); + if (content != null) { + sb.append(content); + } + return sb.toString(); + } + + private String convertParagraph(StructuralNode node) { + Object content = node.getContent(); + if (content == null) return ""; + return content.toString() + "\n\n"; + } + + private String convertPreamble(StructuralNode node) { + Object content = node.getContent(); + return content != null ? content.toString() : ""; + } + + // Stubs — implemented in subsequent tasks + + private String convertListing(Block node) { return ""; } + private String convertLiteral(Block node) { return ""; } + private String convertUnorderedList(List node) { return ""; } + private String convertOrderedList(List node) { return ""; } + private String convertDescriptionList(DescriptionList node) { return ""; } + private String convertTable(Table node) { return ""; } + private String convertAdmonition(StructuralNode node) { return ""; } + private String convertImage(StructuralNode node) { return ""; } + private String convertOpen(StructuralNode node) { return ""; } + private String convertSidebar(StructuralNode node) { return ""; } + private String convertExample(StructuralNode node) { return ""; } + private String convertInlineQuoted(PhraseNode node) { return ""; } + private String convertInlineAnchor(PhraseNode node) { return ""; } + private String convertInlineImage(PhraseNode node) { return ""; } + private String convertInlineFootnote(PhraseNode node) { return ""; } +} diff --git a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverterRegistry.java b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverterRegistry.java new file mode 100644 index 0000000000..534bf16080 --- /dev/null +++ b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverterRegistry.java @@ -0,0 +1,11 @@ +package org.spockframework.plugins.asciidoctor; + +import org.asciidoctor.Asciidoctor; +import org.asciidoctor.jruby.converter.spi.ConverterRegistry; + +public class MarkdownConverterRegistry implements ConverterRegistry { + @Override + public void register(Asciidoctor asciidoctor) { + asciidoctor.javaConverterRegistry().register(MarkdownConverter.class); + } +} diff --git a/build-logic/asciidoc-extensions/src/main/resources/META-INF/services/org.asciidoctor.jruby.converter.spi.ConverterRegistry b/build-logic/asciidoc-extensions/src/main/resources/META-INF/services/org.asciidoctor.jruby.converter.spi.ConverterRegistry new file mode 100644 index 0000000000..355adafb06 --- /dev/null +++ b/build-logic/asciidoc-extensions/src/main/resources/META-INF/services/org.asciidoctor.jruby.converter.spi.ConverterRegistry @@ -0,0 +1 @@ +org.spockframework.plugins.asciidoctor.MarkdownConverterRegistry From a3246d2d9851e1f75900f259e1bfeba9207ef4de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Wed, 8 Apr 2026 17:45:50 +0200 Subject: [PATCH 2/9] Implement all MarkdownConverter node type methods Inline: quoted, anchor, image, footnote Code blocks: listing (with diagram support), literal Lists: unordered, ordered, description Table, admonition, image Container blocks: open, sidebar, example --- .../asciidoctor/MarkdownConverter.java | 376 +++++++++++++++++- 1 file changed, 359 insertions(+), 17 deletions(-) diff --git a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java index 1cb4fcd584..28aa6c46e0 100644 --- a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java +++ b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java @@ -85,21 +85,363 @@ private String convertPreamble(StructuralNode node) { return content != null ? content.toString() : ""; } - // Stubs — implemented in subsequent tasks - - private String convertListing(Block node) { return ""; } - private String convertLiteral(Block node) { return ""; } - private String convertUnorderedList(List node) { return ""; } - private String convertOrderedList(List node) { return ""; } - private String convertDescriptionList(DescriptionList node) { return ""; } - private String convertTable(Table node) { return ""; } - private String convertAdmonition(StructuralNode node) { return ""; } - private String convertImage(StructuralNode node) { return ""; } - private String convertOpen(StructuralNode node) { return ""; } - private String convertSidebar(StructuralNode node) { return ""; } - private String convertExample(StructuralNode node) { return ""; } - private String convertInlineQuoted(PhraseNode node) { return ""; } - private String convertInlineAnchor(PhraseNode node) { return ""; } - private String convertInlineImage(PhraseNode node) { return ""; } - private String convertInlineFootnote(PhraseNode node) { return ""; } + // --- Inline converters --- + + private String convertInlineQuoted(PhraseNode node) { + String text = node.getText(); + if (text == null) return ""; + return switch (node.getType()) { + case "strong" -> "**" + text + "**"; + case "emphasis" -> "*" + text + "*"; + case "monospaced" -> "`" + text + "`"; + case "double" -> "\u201c" + text + "\u201d"; + case "single" -> "\u2018" + text + "\u2019"; + case "superscript" -> "" + text + ""; + case "subscript" -> "" + text + ""; + case "mark" -> "" + text + ""; + default -> text; + }; + } + + private String convertInlineAnchor(PhraseNode node) { + String type = node.getType(); + return switch (type) { + case "link" -> { + String text = node.getText(); + String target = node.getTarget(); + if (text == null || text.isEmpty()) { + yield "[" + target + "](" + target + ")"; + } + yield "[" + text + "](" + target + ")"; + } + case "xref" -> { + String text = node.getText(); + String refid = node.getAttribute("refid", "").toString(); + String path = node.getAttribute("path", "").toString(); + String fragment = node.getAttribute("fragment", "").toString(); + var target = new StringBuilder(); + if (!path.isEmpty()) { + target.append(path.replace(".adoc", ".md")); + } + if (!fragment.isEmpty()) { + target.append("#").append(fragment); + } else if (!refid.isEmpty() && path.isEmpty()) { + target.append("#").append(refid); + } + if (text == null || text.isEmpty()) { + text = refid.isEmpty() ? target.toString() : refid; + } + yield "[" + text + "](" + target + ")"; + } + case "bibref" -> { + String text = node.getText(); + yield text != null ? "[" + text + "]" : ""; + } + case "ref" -> ""; + default -> { + String text = node.getText(); + yield text != null ? text : ""; + } + }; + } + + private String convertInlineImage(PhraseNode node) { + String target = node.getTarget(); + String alt = node.getAttribute("alt", "").toString(); + return "![" + alt + "](" + target + ")"; + } + + private String convertInlineFootnote(PhraseNode node) { + String text = node.getText(); + if (text == null || text.isEmpty()) return ""; + return " (Note: " + text + ")"; + } + + // --- Code block converters --- + + private String convertListing(Block node) { + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + sb.append("```"); + String style = node.getStyle(); + if ("source".equals(style)) { + Object lang = node.getAttribute("language"); + if (lang != null) { + sb.append(lang); + } + } else if (style != null && !"listing".equals(style)) { + // Diagram block (plantuml, ditaa, etc.) — use style as language + sb.append(style); + } + sb.append("\n"); + String source = node.getSource(); + if (source != null) { + sb.append(source); + if (!source.endsWith("\n")) { + sb.append("\n"); + } + } + sb.append("```\n\n"); + return sb.toString(); + } + + private String convertLiteral(Block node) { + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + sb.append("```\n"); + String source = node.getSource(); + if (source != null) { + sb.append(source); + if (!source.endsWith("\n")) { + sb.append("\n"); + } + } + sb.append("```\n\n"); + return sb.toString(); + } + + // --- List converters --- + + private String convertUnorderedList(List node) { + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + appendUnorderedItems(sb, node, 0); + sb.append("\n"); + return sb.toString(); + } + + private void appendUnorderedItems(StringBuilder sb, List node, int depth) { + String indent = " ".repeat(depth); + for (StructuralNode item : node.getItems()) { + ListItem listItem = (ListItem) item; + sb.append(indent).append("- "); + if (listItem.hasText()) { + sb.append(listItem.getText()); + } + sb.append("\n"); + for (StructuralNode block : listItem.getBlocks()) { + if (block instanceof List nestedList) { + appendUnorderedItems(sb, nestedList, depth + 1); + } else { + String converted = block.convert(); + if (converted != null && !converted.isEmpty()) { + for (String line : converted.split("\n", -1)) { + sb.append(indent).append(" ").append(line).append("\n"); + } + } + } + } + } + } + + private String convertOrderedList(List node) { + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + appendOrderedItems(sb, node, 0); + sb.append("\n"); + return sb.toString(); + } + + private void appendOrderedItems(StringBuilder sb, List node, int depth) { + String indent = " ".repeat(depth); + int number = 1; + for (StructuralNode item : node.getItems()) { + ListItem listItem = (ListItem) item; + sb.append(indent).append(number).append(". "); + if (listItem.hasText()) { + sb.append(listItem.getText()); + } + sb.append("\n"); + for (StructuralNode block : listItem.getBlocks()) { + if (block instanceof List nestedList) { + if ("olist".equals(nestedList.getContext())) { + appendOrderedItems(sb, nestedList, depth + 1); + } else { + appendUnorderedItems(sb, nestedList, depth + 1); + } + } else { + String converted = block.convert(); + if (converted != null && !converted.isEmpty()) { + String padding = indent + " "; + for (String line : converted.split("\n", -1)) { + sb.append(padding).append(line).append("\n"); + } + } + } + } + number++; + } + } + + private String convertDescriptionList(DescriptionList node) { + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + for (DescriptionListEntry entry : node.getItems()) { + for (ListItem term : entry.getTerms()) { + sb.append("**").append(term.getText()).append("**\n"); + } + ListItem description = entry.getDescription(); + if (description != null) { + if (description.hasText()) { + sb.append(": ").append(description.getText()).append("\n"); + } + for (StructuralNode block : description.getBlocks()) { + String converted = block.convert(); + if (converted != null && !converted.isEmpty()) { + sb.append("\n").append(converted); + } + } + } + sb.append("\n"); + } + return sb.toString(); + } + + // --- Table, admonition, image converters --- + + private String convertTable(Table node) { + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + java.util.List headerRows = node.getHeader(); + java.util.List bodyRows = node.getBody(); + int colCount = node.getColumns().size(); + + if (!headerRows.isEmpty()) { + Row headerRow = headerRows.get(0); + sb.append("|"); + for (Cell cell : headerRow.getCells()) { + sb.append(" ").append(cellText(cell)).append(" |"); + } + sb.append("\n"); + } else if (!bodyRows.isEmpty()) { + sb.append("|"); + for (int i = 0; i < colCount; i++) { + sb.append(" |"); + } + sb.append("\n"); + } + + sb.append("|"); + for (int i = 0; i < colCount; i++) { + sb.append(" --- |"); + } + sb.append("\n"); + + for (Row row : bodyRows) { + sb.append("|"); + for (Cell cell : row.getCells()) { + sb.append(" ").append(cellText(cell)).append(" |"); + } + sb.append("\n"); + } + sb.append("\n"); + return sb.toString(); + } + + private String cellText(Cell cell) { + String style = cell.getStyle(); + if ("asciidoc".equals(style)) { + Document innerDoc = cell.getInnerDocument(); + if (innerDoc != null) { + String content = innerDoc.getContent() != null ? innerDoc.getContent().toString() : ""; + return content.strip().replaceAll("\n+", " "); + } + } + String text = cell.getText(); + if (text == null) return ""; + return text.strip().replaceAll("\n+", " "); + } + + private String convertAdmonition(StructuralNode node) { + String style = node.getStyle(); + String alertType = switch (style != null ? style.toUpperCase() : "") { + case "NOTE" -> "NOTE"; + case "TIP" -> "TIP"; + case "WARNING" -> "WARNING"; + case "IMPORTANT" -> "IMPORTANT"; + case "CAUTION" -> "CAUTION"; + default -> "NOTE"; + }; + var sb = new StringBuilder(); + sb.append("> [!").append(alertType).append("]\n"); + Object content = node.getContent(); + if (content != null) { + for (String line : content.toString().split("\n", -1)) { + sb.append("> ").append(line).append("\n"); + } + } + sb.append("\n"); + return sb.toString(); + } + + private String convertImage(StructuralNode node) { + String target = (String) node.getAttribute("target"); + String alt = node.getAttribute("alt", "").toString(); + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + sb.append("![").append(alt).append("](").append(target).append(")\n\n"); + return sb.toString(); + } + + // --- Container block converters --- + + private String convertOpen(StructuralNode node) { + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + Object content = node.getContent(); + if (content != null) { + sb.append(content); + } + return sb.toString(); + } + + private String convertSidebar(StructuralNode node) { + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + Object content = node.getContent(); + if (content != null) { + sb.append(content); + } + return sb.toString(); + } + + private String convertExample(StructuralNode node) { + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + Object content = node.getContent(); + if (content != null) { + sb.append(content); + } + return sb.toString(); + } } From d1d5cb919826d1ecc55696d42a45c96e613f4aa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Wed, 8 Apr 2026 17:46:18 +0200 Subject: [PATCH 3/9] Skip IncludedSourceLinker table wrapping for markdown backend --- .../plugins/asciidoctor/IncludedSourceLinker.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/IncludedSourceLinker.java b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/IncludedSourceLinker.java index 1e32a85970..a26f687572 100644 --- a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/IncludedSourceLinker.java +++ b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/IncludedSourceLinker.java @@ -604,6 +604,11 @@ private void processBlocks(Document document, boolean listings) { lines.removeIf(line -> line.trim().startsWith(includeSourceMarker) && line.endsWith(includeSourceMarker)); block.setLines(lines); + // For markdown backend, skip the table wrapping — just keep the plain code block + if ("markdown".equals(document.getAttribute("backend"))) { + return; + } + // construct an AsciiDoc table programmatically that wraps // the current block and in AsciiDoc would be looking like // From d4512337626268a3ced4db34c91f77bce8a51fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Wed, 8 Apr 2026 17:46:45 +0200 Subject: [PATCH 4/9] Add markdown backend to asciidoctor task and include in publishDocs --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index f4e6810a1b..617ecd37c5 100644 --- a/build.gradle +++ b/build.gradle @@ -290,6 +290,7 @@ tasks.register("publishDocs", Exec) { rm -rf docs/$variantLessVersion mkdir -p docs/$variantLessVersion cp -r build/docs/asciidoc/* docs/$variantLessVersion + cp -r build/docs/asciidocMarkdown/* docs/$variantLessVersion git add docs git commit -qm "Publish docs/$variantLessVersion" git push "https://\$GITHUB_TOKEN@github.com/spockframework/spock.git" gh-pages 2>&1 | sed "s/\$GITHUB_TOKEN/xxx/g" @@ -355,6 +356,7 @@ tasks.named("asciidoctor") { configurations 'asciidoctorExtensions' sourceDir = "docs" baseDirFollowsSourceDir() + backends 'html5', 'markdown' logDocuments = true attributes "source-highlighter": "coderay", "linkcss": true, "sectanchors": true, "revnumber": variantLessVersion, "commit-ish": System.getenv("GITHUB_SHA") ?: "master" // also treats the included specs as inputs From e1c3577e5847228c7fec992f3a004e5fbd621f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Wed, 8 Apr 2026 18:01:16 +0200 Subject: [PATCH 5/9] Fix HTML entity decoding, double-nested links, and output paths - Decode numeric HTML entities (’ etc.) to Unicode characters - Decode named HTML entities (< > & etc.) to literal chars - Remove zero-width spaces from converted text - Fix double-nested markdown links from spockIssue/spockPull macros by returning plain URL when link text equals target - Fix publishDocs paths for multi-backend output directory structure - Fix asciidoctor task to use outputOptions block for backend config --- .../asciidoctor/MarkdownConverter.java | 61 +++++++++++++++---- build.gradle | 8 ++- 2 files changed, 53 insertions(+), 16 deletions(-) diff --git a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java index 28aa6c46e0..d61e9e6323 100644 --- a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java +++ b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java @@ -7,10 +7,16 @@ import org.asciidoctor.log.Severity; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; @ConverterFor(value = "markdown", suffix = ".md") public class MarkdownConverter extends StringConverter { + private static final Pattern HTML_ENTITY_PATTERN = Pattern.compile("&#(\\d+);"); + private static final Pattern NAMED_ENTITY_PATTERN = Pattern.compile("&(lt|gt|amp|quot|apos);"); + private static final Pattern ZERO_WIDTH_SPACE_PATTERN = Pattern.compile("\u200B"); + public MarkdownConverter(String backend, Map opts) { super(backend, opts); } @@ -65,7 +71,7 @@ private String convertSection(Section node) { int headingLevel = node.getLevel() + 1; sb.append("#".repeat(Math.min(headingLevel, 6))) .append(" ") - .append(node.getTitle()) + .append(decodeEntities(node.getTitle())) .append("\n\n"); Object content = node.getContent(); if (content != null) { @@ -77,7 +83,7 @@ private String convertSection(Section node) { private String convertParagraph(StructuralNode node) { Object content = node.getContent(); if (content == null) return ""; - return content.toString() + "\n\n"; + return decodeEntities(content.toString()) + "\n\n"; } private String convertPreamble(StructuralNode node) { @@ -88,7 +94,7 @@ private String convertPreamble(StructuralNode node) { // --- Inline converters --- private String convertInlineQuoted(PhraseNode node) { - String text = node.getText(); + String text = decodeEntities(node.getText()); if (text == null) return ""; return switch (node.getType()) { case "strong" -> "**" + text + "**"; @@ -107,15 +113,20 @@ private String convertInlineAnchor(PhraseNode node) { String type = node.getType(); return switch (type) { case "link" -> { - String text = node.getText(); + String text = decodeEntities(node.getText()); String target = node.getTarget(); if (text == null || text.isEmpty()) { yield "[" + target + "](" + target + ")"; } + // When text equals target (auto-linked URL), output just the URL + // to avoid double-wrapping when the framework creates a nested link node + if (text.equals(target)) { + yield target; + } yield "[" + text + "](" + target + ")"; } case "xref" -> { - String text = node.getText(); + String text = decodeEntities(node.getText()); String refid = node.getAttribute("refid", "").toString(); String path = node.getAttribute("path", "").toString(); String fragment = node.getAttribute("fragment", "").toString(); @@ -152,7 +163,7 @@ private String convertInlineImage(PhraseNode node) { } private String convertInlineFootnote(PhraseNode node) { - String text = node.getText(); + String text = decodeEntities(node.getText()); if (text == null || text.isEmpty()) return ""; return " (Note: " + text + ")"; } @@ -225,7 +236,7 @@ private void appendUnorderedItems(StringBuilder sb, List node, int depth) { ListItem listItem = (ListItem) item; sb.append(indent).append("- "); if (listItem.hasText()) { - sb.append(listItem.getText()); + sb.append(decodeEntities(listItem.getText())); } sb.append("\n"); for (StructuralNode block : listItem.getBlocks()) { @@ -261,7 +272,7 @@ private void appendOrderedItems(StringBuilder sb, List node, int depth) { ListItem listItem = (ListItem) item; sb.append(indent).append(number).append(". "); if (listItem.hasText()) { - sb.append(listItem.getText()); + sb.append(decodeEntities(listItem.getText())); } sb.append("\n"); for (StructuralNode block : listItem.getBlocks()) { @@ -293,12 +304,12 @@ private String convertDescriptionList(DescriptionList node) { } for (DescriptionListEntry entry : node.getItems()) { for (ListItem term : entry.getTerms()) { - sb.append("**").append(term.getText()).append("**\n"); + sb.append("**").append(decodeEntities(term.getText())).append("**\n"); } ListItem description = entry.getDescription(); if (description != null) { if (description.hasText()) { - sb.append(": ").append(description.getText()).append("\n"); + sb.append(": ").append(decodeEntities(description.getText())).append("\n"); } for (StructuralNode block : description.getBlocks()) { String converted = block.convert(); @@ -362,12 +373,12 @@ private String cellText(Cell cell) { Document innerDoc = cell.getInnerDocument(); if (innerDoc != null) { String content = innerDoc.getContent() != null ? innerDoc.getContent().toString() : ""; - return content.strip().replaceAll("\n+", " "); + return decodeEntities(content.strip().replaceAll("\n+", " ")); } } String text = cell.getText(); if (text == null) return ""; - return text.strip().replaceAll("\n+", " "); + return decodeEntities(text.strip().replaceAll("\n+", " ")); } private String convertAdmonition(StructuralNode node) { @@ -384,7 +395,7 @@ private String convertAdmonition(StructuralNode node) { sb.append("> [!").append(alertType).append("]\n"); Object content = node.getContent(); if (content != null) { - for (String line : content.toString().split("\n", -1)) { + for (String line : decodeEntities(content.toString()).split("\n", -1)) { sb.append("> ").append(line).append("\n"); } } @@ -444,4 +455,28 @@ private String convertExample(StructuralNode node) { } return sb.toString(); } + + // --- Utility methods --- + + private static String decodeEntities(String text) { + if (text == null) return null; + // Decode numeric HTML entities (’ etc.) + text = HTML_ENTITY_PATTERN.matcher(text).replaceAll(mr -> { + int codePoint = Integer.parseInt(mr.group(1)); + return Matcher.quoteReplacement(new String(Character.toChars(codePoint))); + }); + // Decode named HTML entities + text = NAMED_ENTITY_PATTERN.matcher(text).replaceAll(mr -> switch (mr.group(1)) { + case "lt" -> "<"; + case "gt" -> ">"; + case "amp" -> "&"; + case "quot" -> Matcher.quoteReplacement("\""); + case "apos" -> "'"; + default -> mr.group(0); + }); + // Remove zero-width spaces + text = ZERO_WIDTH_SPACE_PATTERN.matcher(text).replaceAll(""); + return text; + } + } diff --git a/build.gradle b/build.gradle index 617ecd37c5..186846a5d7 100644 --- a/build.gradle +++ b/build.gradle @@ -289,8 +289,8 @@ tasks.register("publishDocs", Exec) { git switch gh-pages rm -rf docs/$variantLessVersion mkdir -p docs/$variantLessVersion - cp -r build/docs/asciidoc/* docs/$variantLessVersion - cp -r build/docs/asciidocMarkdown/* docs/$variantLessVersion + cp -r build/docs/asciidoc/html5/* docs/$variantLessVersion + cp -r build/docs/asciidoc/markdown/* docs/$variantLessVersion git add docs git commit -qm "Publish docs/$variantLessVersion" git push "https://\$GITHUB_TOKEN@github.com/spockframework/spock.git" gh-pages 2>&1 | sed "s/\$GITHUB_TOKEN/xxx/g" @@ -356,7 +356,9 @@ tasks.named("asciidoctor") { configurations 'asciidoctorExtensions' sourceDir = "docs" baseDirFollowsSourceDir() - backends 'html5', 'markdown' + outputOptions { + backends 'html5', 'markdown' + } logDocuments = true attributes "source-highlighter": "coderay", "linkcss": true, "sectanchors": true, "revnumber": variantLessVersion, "commit-ish": System.getenv("GITHUB_SHA") ?: "master" // also treats the included specs as inputs From e0e6924aea76002a6a953f70ce6370347e8fe5ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Wed, 8 Apr 2026 18:13:57 +0200 Subject: [PATCH 6/9] Split into separate asciidoctor tasks to skip diagrams for markdown - Remove markdown from main asciidoctor task, restore single html5 backend - Move diagram.use() from project-level asciidoctorj to the HTML task only - Extract shared config into configureAsciidoctor closure - Create asciidoctorMarkdown task without diagram module, preserving raw PlantUML/ditaa source as fenced code blocks - Fix convertLiteral to detect diagram style on literal blocks - Update publishDocs to depend on both tasks - Update docs-pr workflow to also run asciidoctorMarkdown --- .github/workflows/docs-pr.main.kts | 1 + .github/workflows/docs-pr.yaml | 2 +- .../asciidoctor/MarkdownConverter.java | 8 ++- build.gradle | 54 +++++++++++-------- 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/.github/workflows/docs-pr.main.kts b/.github/workflows/docs-pr.main.kts index 5c41500d6a..c8904f1098 100755 --- a/.github/workflows/docs-pr.main.kts +++ b/.github/workflows/docs-pr.main.kts @@ -84,6 +84,7 @@ workflow( "./gradlew", "--stacktrace", "asciidoctor", + "asciidoctorMarkdown", "javadoc", """"-Dvariant=${Matrix.axes.variants.last()}"""", """"-DjavaVersion=${Matrix.axes.javaVersions.last()}"""" diff --git a/.github/workflows/docs-pr.yaml b/.github/workflows/docs-pr.yaml index f3d930a41c..500dd0b7a7 100644 --- a/.github/workflows/docs-pr.yaml +++ b/.github/workflows/docs-pr.yaml @@ -48,7 +48,7 @@ jobs: run: 'sudo apt update && sudo apt install --yes graphviz' - id: 'step-3' name: 'Build Docs' - run: './gradlew --stacktrace asciidoctor javadoc "-Dvariant=5.0" "-DjavaVersion=25"' + run: './gradlew --stacktrace asciidoctor asciidoctorMarkdown javadoc "-Dvariant=5.0" "-DjavaVersion=25"' - id: 'step-4' name: 'Archive and upload docs' uses: 'actions/upload-artifact@v7' diff --git a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java index d61e9e6323..143340036b 100644 --- a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java +++ b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java @@ -205,7 +205,13 @@ private String convertLiteral(Block node) { if (blockTitle != null) { sb.append("**").append(blockTitle).append("**\n\n"); } - sb.append("```\n"); + sb.append("```"); + String style = node.getStyle(); + if (style != null && !"literal".equals(style)) { + // Diagram block (plantuml, ditaa, etc.) — use style as language + sb.append(style); + } + sb.append("\n"); String source = node.getSource(); if (source != null) { sb.append(source); diff --git a/build.gradle b/build.gradle index 186846a5d7..69ce33f506 100644 --- a/build.gradle +++ b/build.gradle @@ -280,7 +280,7 @@ tasks.register("publishJavadoc", Exec) { """ } tasks.register("publishDocs", Exec) { - dependsOn "asciidoctor" + dependsOn "asciidoctor", "asciidoctorMarkdown" commandLine "sh", "-c", """ git config user.email "dev@forum.spockframework.org" @@ -289,8 +289,8 @@ tasks.register("publishDocs", Exec) { git switch gh-pages rm -rf docs/$variantLessVersion mkdir -p docs/$variantLessVersion - cp -r build/docs/asciidoc/html5/* docs/$variantLessVersion - cp -r build/docs/asciidoc/markdown/* docs/$variantLessVersion + cp -r build/docs/asciidoc/* docs/$variantLessVersion + cp -r build/docs/asciidocMarkdown/* docs/$variantLessVersion git add docs git commit -qm "Publish docs/$variantLessVersion" git push "https://\$GITHUB_TOKEN@github.com/spockframework/spock.git" gh-pages 2>&1 | sed "s/\$GITHUB_TOKEN/xxx/g" @@ -345,33 +345,45 @@ dependencies { asciidoctorj { version = libs.versions.asciidoctorj fatalWarnings(missingIncludes()) - modules { - diagram.use() - } } -tasks.named("asciidoctor") { +// shared configuration for both asciidoctor tasks +def configureAsciidoctor = { task -> // work-around for https://github.com/asciidoctor/asciidoctor-gradle-plugin/issues/721 - dependsOn(project.configurations.asciidoctorExtensions) - configurations 'asciidoctorExtensions' - sourceDir = "docs" - baseDirFollowsSourceDir() - outputOptions { - backends 'html5', 'markdown' - } - logDocuments = true - attributes "source-highlighter": "coderay", "linkcss": true, "sectanchors": true, "revnumber": variantLessVersion, "commit-ish": System.getenv("GITHUB_SHA") ?: "master" + task.dependsOn(project.configurations.asciidoctorExtensions) + task.configurations 'asciidoctorExtensions' + task.sourceDir = "docs" + task.baseDirFollowsSourceDir() + task.logDocuments = true + task.attributes "revnumber": variantLessVersion, "commit-ish": System.getenv("GITHUB_SHA") ?: "master" // also treats the included specs as inputs - inputs.dir file("spock-specs/src/test/groovy/org/spockframework/docs") - inputs.dir file("spock-specs/src/test/resources/snapshots/org/spockframework/docs") - inputs.dir file("spock-spring/src/test/groovy/org/spockframework/spring/docs") - inputs.dir file("spock-spring/src/test/resources/org/spockframework/spring/docs") - inputs.dir file("spock-spring/boot2-test/src/test/groovy/org/spockframework/boot2") + task.inputs.dir file("spock-specs/src/test/groovy/org/spockframework/docs") + task.inputs.dir file("spock-specs/src/test/resources/snapshots/org/spockframework/docs") + task.inputs.dir file("spock-spring/src/test/groovy/org/spockframework/spring/docs") + task.inputs.dir file("spock-spring/src/test/resources/org/spockframework/spring/docs") + task.inputs.dir file("spock-spring/boot2-test/src/test/groovy/org/spockframework/boot2") +} + +tasks.named("asciidoctor") { + configureAsciidoctor(it) + asciidoctorj { + modules { + diagram.use() + } + } + attributes "source-highlighter": "coderay", "linkcss": true, "sectanchors": true doFirst { verifyAnchorlessCrossDocumentLinks(sourceFileTree) } doLast { verifyLinksAndAnchors(outputs.files.asFileTree) } } +tasks.register("asciidoctorMarkdown", org.asciidoctor.gradle.jvm.AsciidoctorTask) { + configureAsciidoctor(it) + outputOptions { + backends 'markdown' + } +} + nexusPublishing { packageGroup = 'org.spockframework' repositories { From 1cb9340a554463c5160c157f97b59f3bece92c79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Wed, 8 Apr 2026 18:28:52 +0200 Subject: [PATCH 7/9] Fix lost emphasis text in list items and add missing node types - Add listItemText() helper that recovers leading emphasis content lost by getText() when followed by + line continuation, by extracting it from getSource() and combining with getText() result - Add inline_break node handler (returns newline) - Add colist (callout list) handler (reuses ordered list converter) - Add quote block handler (renders as markdown blockquote with > prefix) --- .../asciidoctor/MarkdownConverter.java | 54 +++++++++++++++++-- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java index 143340036b..2db4fe412e 100644 --- a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java +++ b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java @@ -45,6 +45,9 @@ public String convert(ContentNode node, String transform, Map op case "inline_anchor" -> convertInlineAnchor((PhraseNode) node); case "inline_image" -> convertInlineImage((PhraseNode) node); case "inline_footnote" -> convertInlineFootnote((PhraseNode) node); + case "inline_break" -> "\n"; + case "colist" -> convertOrderedList((List) node); + case "quote" -> convertQuote((StructuralNode) node); default -> { log(new LogRecord(Severity.WARN, "Unsupported node: " + name)); yield ""; @@ -242,7 +245,7 @@ private void appendUnorderedItems(StringBuilder sb, List node, int depth) { ListItem listItem = (ListItem) item; sb.append(indent).append("- "); if (listItem.hasText()) { - sb.append(decodeEntities(listItem.getText())); + sb.append(decodeEntities(listItemText(listItem))); } sb.append("\n"); for (StructuralNode block : listItem.getBlocks()) { @@ -278,7 +281,7 @@ private void appendOrderedItems(StringBuilder sb, List node, int depth) { ListItem listItem = (ListItem) item; sb.append(indent).append(number).append(". "); if (listItem.hasText()) { - sb.append(decodeEntities(listItem.getText())); + sb.append(decodeEntities(listItemText(listItem))); } sb.append("\n"); for (StructuralNode block : listItem.getBlocks()) { @@ -310,12 +313,12 @@ private String convertDescriptionList(DescriptionList node) { } for (DescriptionListEntry entry : node.getItems()) { for (ListItem term : entry.getTerms()) { - sb.append("**").append(decodeEntities(term.getText())).append("**\n"); + sb.append("**").append(decodeEntities(listItemText(term))).append("**\n"); } ListItem description = entry.getDescription(); if (description != null) { if (description.hasText()) { - sb.append(": ").append(decodeEntities(description.getText())).append("\n"); + sb.append(": ").append(decodeEntities(listItemText(description))).append("\n"); } for (StructuralNode block : description.getBlocks()) { String converted = block.convert(); @@ -449,6 +452,27 @@ private String convertSidebar(StructuralNode node) { return sb.toString(); } + private String convertQuote(StructuralNode node) { + var sb = new StringBuilder(); + String blockTitle = node.getTitle(); + if (blockTitle != null) { + sb.append("**").append(blockTitle).append("**\n\n"); + } + Object content = node.getContent(); + if (content != null) { + for (String line : content.toString().split("\n", -1)) { + sb.append("> ").append(line).append("\n"); + } + } + // Attribution + Object attribution = node.getAttribute("attribution"); + if (attribution != null) { + sb.append(">\n> — ").append(attribution).append("\n"); + } + sb.append("\n"); + return sb.toString(); + } + private String convertExample(StructuralNode node) { var sb = new StringBuilder(); String blockTitle = node.getTitle(); @@ -464,6 +488,28 @@ private String convertExample(StructuralNode node) { // --- Utility methods --- + private static final Pattern LINE_CONTINUATION_PATTERN = Pattern.compile("\\s*\\+\\s*$", Pattern.MULTILINE); + + private static String listItemText(ListItem item) { + String text = item.getText(); + // getText() applies inline substitutions which can lose leading emphasis content + // when followed by a + line continuation. Recover the lost content from getSource(). + if (text == null || !text.startsWith("\n")) { + return text; + } + String source = item.getSource(); + if (source == null) return text; + // Extract the part before the first + line continuation from the raw source + Matcher m = LINE_CONTINUATION_PATTERN.matcher(source); + if (m.find()) { + String leadingContent = source.substring(0, m.start()); + // Combine: raw leading content (AsciiDoc emphasis is valid markdown italic) + // + the getText() result which has inline macros properly expanded + return leadingContent + text; + } + return text; + } + private static String decodeEntities(String text) { if (text == null) return null; // Decode numeric HTML entities (’ etc.) From d41533bba7fbd100c1a6d9f2a0ee728c9f8d7cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Wed, 8 Apr 2026 18:41:18 +0200 Subject: [PATCH 8/9] Fix inline_break handling to preserve text before + continuation The HTML5 converter outputs node.text +
for inline_break nodes. Our converter was returning just "\n", losing the text content before the + line continuation. Now properly returns text + newline. Also collapse double newlines in list item text to prevent paragraph breaks within list items from + continuations. --- .../asciidoctor/MarkdownConverter.java | 39 ++++++------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java index 2db4fe412e..4e7b8d3af1 100644 --- a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java +++ b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java @@ -45,7 +45,7 @@ public String convert(ContentNode node, String transform, Map op case "inline_anchor" -> convertInlineAnchor((PhraseNode) node); case "inline_image" -> convertInlineImage((PhraseNode) node); case "inline_footnote" -> convertInlineFootnote((PhraseNode) node); - case "inline_break" -> "\n"; + case "inline_break" -> convertInlineBreak((PhraseNode) node); case "colist" -> convertOrderedList((List) node); case "quote" -> convertQuote((StructuralNode) node); default -> { @@ -165,6 +165,12 @@ private String convertInlineImage(PhraseNode node) { return "![" + alt + "](" + target + ")"; } + private String convertInlineBreak(PhraseNode node) { + String text = decodeEntities(node.getText()); + if (text == null || text.isEmpty()) return "\n"; + return text + "\n"; + } + private String convertInlineFootnote(PhraseNode node) { String text = decodeEntities(node.getText()); if (text == null || text.isEmpty()) return ""; @@ -245,7 +251,8 @@ private void appendUnorderedItems(StringBuilder sb, List node, int depth) { ListItem listItem = (ListItem) item; sb.append(indent).append("- "); if (listItem.hasText()) { - sb.append(decodeEntities(listItemText(listItem))); + // Collapse double newlines from inline_break to keep list item together + sb.append(decodeEntities(listItem.getText()).replace("\n\n", "\n")); } sb.append("\n"); for (StructuralNode block : listItem.getBlocks()) { @@ -281,7 +288,7 @@ private void appendOrderedItems(StringBuilder sb, List node, int depth) { ListItem listItem = (ListItem) item; sb.append(indent).append(number).append(". "); if (listItem.hasText()) { - sb.append(decodeEntities(listItemText(listItem))); + sb.append(decodeEntities(listItem.getText()).replace("\n\n", "\n")); } sb.append("\n"); for (StructuralNode block : listItem.getBlocks()) { @@ -313,12 +320,12 @@ private String convertDescriptionList(DescriptionList node) { } for (DescriptionListEntry entry : node.getItems()) { for (ListItem term : entry.getTerms()) { - sb.append("**").append(decodeEntities(listItemText(term))).append("**\n"); + sb.append("**").append(decodeEntities(term.getText())).append("**\n"); } ListItem description = entry.getDescription(); if (description != null) { if (description.hasText()) { - sb.append(": ").append(decodeEntities(listItemText(description))).append("\n"); + sb.append(": ").append(decodeEntities(description.getText())).append("\n"); } for (StructuralNode block : description.getBlocks()) { String converted = block.convert(); @@ -488,28 +495,6 @@ private String convertExample(StructuralNode node) { // --- Utility methods --- - private static final Pattern LINE_CONTINUATION_PATTERN = Pattern.compile("\\s*\\+\\s*$", Pattern.MULTILINE); - - private static String listItemText(ListItem item) { - String text = item.getText(); - // getText() applies inline substitutions which can lose leading emphasis content - // when followed by a + line continuation. Recover the lost content from getSource(). - if (text == null || !text.startsWith("\n")) { - return text; - } - String source = item.getSource(); - if (source == null) return text; - // Extract the part before the first + line continuation from the raw source - Matcher m = LINE_CONTINUATION_PATTERN.matcher(source); - if (m.find()) { - String leadingContent = source.substring(0, m.start()); - // Combine: raw leading content (AsciiDoc emphasis is valid markdown italic) - // + the getText() result which has inline macros properly expanded - return leadingContent + text; - } - return text; - } - private static String decodeEntities(String text) { if (text == null) return null; // Decode numeric HTML entities (’ etc.) From 4058ea73ac7fed08d262a3ea90b50f6e8379b243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leonard=20Br=C3=BCnings?= Date: Wed, 8 Apr 2026 18:45:01 +0200 Subject: [PATCH 9/9] DRY MarkdownConverter with shared helper methods - appendBlockTitle: extracts repeated block title rendering - appendContent: extracts repeated content appending - appendPrefixedContent: shared by admonition and quote (> prefix) - fencedCodeBlock: shared by convertListing and convertLiteral - appendListItemText: shared by ordered and unordered lists - appendListItemBlocks: shared nested block handling for list items - appendTableRow: extracts repeated table row rendering - convertContentBlock: replaces identical open/sidebar/example methods --- .../asciidoctor/MarkdownConverter.java | 280 +++++++----------- 1 file changed, 100 insertions(+), 180 deletions(-) diff --git a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java index 4e7b8d3af1..efc7be98f9 100644 --- a/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java +++ b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java @@ -37,9 +37,8 @@ public String convert(ContentNode node, String transform, Map op case "table" -> convertTable((Table) node); case "admonition" -> convertAdmonition((StructuralNode) node); case "image" -> convertImage((StructuralNode) node); - case "open" -> convertOpen((StructuralNode) node); - case "sidebar" -> convertSidebar((StructuralNode) node); - case "example" -> convertExample((StructuralNode) node); + case "open", "sidebar", "example" + -> convertContentBlock((StructuralNode) node); case "thematic_break" -> "---\n\n"; case "inline_quoted" -> convertInlineQuoted((PhraseNode) node); case "inline_anchor" -> convertInlineAnchor((PhraseNode) node); @@ -61,25 +60,18 @@ private String convertDocument(Document node) { if (title != null) { sb.append("# ").append(title).append("\n\n"); } - Object content = node.getContent(); - if (content != null) { - sb.append(content); - } + appendContent(sb, node); return sb.toString(); } private String convertSection(Section node) { var sb = new StringBuilder(); - // Section level 0 = top-level section = ##, level 1 = ###, etc. int headingLevel = node.getLevel() + 1; sb.append("#".repeat(Math.min(headingLevel, 6))) .append(" ") .append(decodeEntities(node.getTitle())) .append("\n\n"); - Object content = node.getContent(); - if (content != null) { - sb.append(content); - } + appendContent(sb, node); return sb.toString(); } @@ -113,8 +105,7 @@ private String convertInlineQuoted(PhraseNode node) { } private String convertInlineAnchor(PhraseNode node) { - String type = node.getType(); - return switch (type) { + return switch (node.getType()) { case "link" -> { String text = decodeEntities(node.getText()); String target = node.getTarget(); @@ -160,9 +151,7 @@ private String convertInlineAnchor(PhraseNode node) { } private String convertInlineImage(PhraseNode node) { - String target = node.getTarget(); - String alt = node.getAttribute("alt", "").toString(); - return "![" + alt + "](" + target + ")"; + return "![" + node.getAttribute("alt", "") + "](" + node.getTarget() + ")"; } private String convertInlineBreak(PhraseNode node) { @@ -180,66 +169,28 @@ private String convertInlineFootnote(PhraseNode node) { // --- Code block converters --- private String convertListing(Block node) { - var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); - } - sb.append("```"); String style = node.getStyle(); + String lang = null; if ("source".equals(style)) { - Object lang = node.getAttribute("language"); - if (lang != null) { - sb.append(lang); - } + Object langAttr = node.getAttribute("language"); + if (langAttr != null) lang = langAttr.toString(); } else if (style != null && !"listing".equals(style)) { - // Diagram block (plantuml, ditaa, etc.) — use style as language - sb.append(style); - } - sb.append("\n"); - String source = node.getSource(); - if (source != null) { - sb.append(source); - if (!source.endsWith("\n")) { - sb.append("\n"); - } + lang = style; // diagram block (plantuml, ditaa, etc.) } - sb.append("```\n\n"); - return sb.toString(); + return fencedCodeBlock(node, lang); } private String convertLiteral(Block node) { - var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); - } - sb.append("```"); String style = node.getStyle(); - if (style != null && !"literal".equals(style)) { - // Diagram block (plantuml, ditaa, etc.) — use style as language - sb.append(style); - } - sb.append("\n"); - String source = node.getSource(); - if (source != null) { - sb.append(source); - if (!source.endsWith("\n")) { - sb.append("\n"); - } - } - sb.append("```\n\n"); - return sb.toString(); + String lang = (style != null && !"literal".equals(style)) ? style : null; + return fencedCodeBlock(node, lang); } // --- List converters --- private String convertUnorderedList(List node) { var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); - } + appendBlockTitle(sb, node); appendUnorderedItems(sb, node, 0); sb.append("\n"); return sb.toString(); @@ -250,32 +201,15 @@ private void appendUnorderedItems(StringBuilder sb, List node, int depth) { for (StructuralNode item : node.getItems()) { ListItem listItem = (ListItem) item; sb.append(indent).append("- "); - if (listItem.hasText()) { - // Collapse double newlines from inline_break to keep list item together - sb.append(decodeEntities(listItem.getText()).replace("\n\n", "\n")); - } + appendListItemText(sb, listItem); sb.append("\n"); - for (StructuralNode block : listItem.getBlocks()) { - if (block instanceof List nestedList) { - appendUnorderedItems(sb, nestedList, depth + 1); - } else { - String converted = block.convert(); - if (converted != null && !converted.isEmpty()) { - for (String line : converted.split("\n", -1)) { - sb.append(indent).append(" ").append(line).append("\n"); - } - } - } - } + appendListItemBlocks(sb, listItem, indent + " ", depth); } } private String convertOrderedList(List node) { var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); - } + appendBlockTitle(sb, node); appendOrderedItems(sb, node, 0); sb.append("\n"); return sb.toString(); @@ -287,37 +221,16 @@ private void appendOrderedItems(StringBuilder sb, List node, int depth) { for (StructuralNode item : node.getItems()) { ListItem listItem = (ListItem) item; sb.append(indent).append(number).append(". "); - if (listItem.hasText()) { - sb.append(decodeEntities(listItem.getText()).replace("\n\n", "\n")); - } + appendListItemText(sb, listItem); sb.append("\n"); - for (StructuralNode block : listItem.getBlocks()) { - if (block instanceof List nestedList) { - if ("olist".equals(nestedList.getContext())) { - appendOrderedItems(sb, nestedList, depth + 1); - } else { - appendUnorderedItems(sb, nestedList, depth + 1); - } - } else { - String converted = block.convert(); - if (converted != null && !converted.isEmpty()) { - String padding = indent + " "; - for (String line : converted.split("\n", -1)) { - sb.append(padding).append(line).append("\n"); - } - } - } - } + appendListItemBlocks(sb, listItem, indent + " ", depth); number++; } } private String convertDescriptionList(DescriptionList node) { var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); - } + appendBlockTitle(sb, node); for (DescriptionListEntry entry : node.getItems()) { for (ListItem term : entry.getTerms()) { sb.append("**").append(decodeEntities(term.getText())).append("**\n"); @@ -343,21 +256,13 @@ private String convertDescriptionList(DescriptionList node) { private String convertTable(Table node) { var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); - } + appendBlockTitle(sb, node); java.util.List headerRows = node.getHeader(); java.util.List bodyRows = node.getBody(); int colCount = node.getColumns().size(); if (!headerRows.isEmpty()) { - Row headerRow = headerRows.get(0); - sb.append("|"); - for (Cell cell : headerRow.getCells()) { - sb.append(" ").append(cellText(cell)).append(" |"); - } - sb.append("\n"); + appendTableRow(sb, headerRows.get(0)); } else if (!bodyRows.isEmpty()) { sb.append("|"); for (int i = 0; i < colCount; i++) { @@ -373,27 +278,21 @@ private String convertTable(Table node) { sb.append("\n"); for (Row row : bodyRows) { - sb.append("|"); - for (Cell cell : row.getCells()) { - sb.append(" ").append(cellText(cell)).append(" |"); - } - sb.append("\n"); + appendTableRow(sb, row); } sb.append("\n"); return sb.toString(); } private String cellText(Cell cell) { - String style = cell.getStyle(); - if ("asciidoc".equals(style)) { + String text; + if ("asciidoc".equals(cell.getStyle())) { Document innerDoc = cell.getInnerDocument(); - if (innerDoc != null) { - String content = innerDoc.getContent() != null ? innerDoc.getContent().toString() : ""; - return decodeEntities(content.strip().replaceAll("\n+", " ")); - } + text = (innerDoc != null && innerDoc.getContent() != null) ? innerDoc.getContent().toString() : ""; + } else { + text = cell.getText(); + if (text == null) return ""; } - String text = cell.getText(); - if (text == null) return ""; return decodeEntities(text.strip().replaceAll("\n+", " ")); } @@ -409,91 +308,112 @@ private String convertAdmonition(StructuralNode node) { }; var sb = new StringBuilder(); sb.append("> [!").append(alertType).append("]\n"); - Object content = node.getContent(); - if (content != null) { - for (String line : decodeEntities(content.toString()).split("\n", -1)) { - sb.append("> ").append(line).append("\n"); - } - } + appendPrefixedContent(sb, node, "> "); sb.append("\n"); return sb.toString(); } private String convertImage(StructuralNode node) { - String target = (String) node.getAttribute("target"); - String alt = node.getAttribute("alt", "").toString(); var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); - } - sb.append("![").append(alt).append("](").append(target).append(")\n\n"); + appendBlockTitle(sb, node); + sb.append("![").append(node.getAttribute("alt", "")).append("](") + .append(node.getAttribute("target")).append(")\n\n"); return sb.toString(); } // --- Container block converters --- - private String convertOpen(StructuralNode node) { + private String convertContentBlock(StructuralNode node) { var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); - } - Object content = node.getContent(); - if (content != null) { - sb.append(content); - } + appendBlockTitle(sb, node); + appendContent(sb, node); return sb.toString(); } - private String convertSidebar(StructuralNode node) { + private String convertQuote(StructuralNode node) { var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); + appendBlockTitle(sb, node); + appendPrefixedContent(sb, node, "> "); + Object attribution = node.getAttribute("attribution"); + if (attribution != null) { + sb.append(">\n> — ").append(attribution).append("\n"); + } + sb.append("\n"); + return sb.toString(); + } + + // --- Helpers --- + + private static void appendBlockTitle(StringBuilder sb, StructuralNode node) { + String title = node.getTitle(); + if (title != null) { + sb.append("**").append(title).append("**\n\n"); } + } + + private static void appendContent(StringBuilder sb, StructuralNode node) { Object content = node.getContent(); if (content != null) { sb.append(content); } - return sb.toString(); } - private String convertQuote(StructuralNode node) { - var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); - } + private void appendPrefixedContent(StringBuilder sb, StructuralNode node, String prefix) { Object content = node.getContent(); if (content != null) { - for (String line : content.toString().split("\n", -1)) { - sb.append("> ").append(line).append("\n"); + for (String line : decodeEntities(content.toString()).split("\n", -1)) { + sb.append(prefix).append(line).append("\n"); } } - // Attribution - Object attribution = node.getAttribute("attribution"); - if (attribution != null) { - sb.append(">\n> — ").append(attribution).append("\n"); - } + } + + private String fencedCodeBlock(Block node, String lang) { + var sb = new StringBuilder(); + appendBlockTitle(sb, node); + sb.append("```"); + if (lang != null) sb.append(lang); sb.append("\n"); + String source = node.getSource(); + if (source != null) { + sb.append(source); + if (!source.endsWith("\n")) sb.append("\n"); + } + sb.append("```\n\n"); return sb.toString(); } - private String convertExample(StructuralNode node) { - var sb = new StringBuilder(); - String blockTitle = node.getTitle(); - if (blockTitle != null) { - sb.append("**").append(blockTitle).append("**\n\n"); + private void appendListItemText(StringBuilder sb, ListItem item) { + if (item.hasText()) { + sb.append(decodeEntities(item.getText()).replace("\n\n", "\n")); } - Object content = node.getContent(); - if (content != null) { - sb.append(content); + } + + private void appendListItemBlocks(StringBuilder sb, ListItem item, String padding, int depth) { + for (StructuralNode block : item.getBlocks()) { + if (block instanceof List nestedList) { + if ("olist".equals(nestedList.getContext())) { + appendOrderedItems(sb, nestedList, depth + 1); + } else { + appendUnorderedItems(sb, nestedList, depth + 1); + } + } else { + String converted = block.convert(); + if (converted != null && !converted.isEmpty()) { + for (String line : converted.split("\n", -1)) { + sb.append(padding).append(line).append("\n"); + } + } + } } - return sb.toString(); } - // --- Utility methods --- + private void appendTableRow(StringBuilder sb, Row row) { + sb.append("|"); + for (Cell cell : row.getCells()) { + sb.append(" ").append(cellText(cell)).append(" |"); + } + sb.append("\n"); + } private static String decodeEntities(String text) { if (text == null) return null;