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/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 // 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..efc7be98f9 --- /dev/null +++ b/build-logic/asciidoc-extensions/src/main/java/org/spockframework/plugins/asciidoctor/MarkdownConverter.java @@ -0,0 +1,439 @@ +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; +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); + } + + @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", "sidebar", "example" + -> convertContentBlock((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); + case "inline_break" -> convertInlineBreak((PhraseNode) node); + case "colist" -> convertOrderedList((List) node); + case "quote" -> convertQuote((StructuralNode) 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"); + } + appendContent(sb, node); + return sb.toString(); + } + + private String convertSection(Section node) { + var sb = new StringBuilder(); + int headingLevel = node.getLevel() + 1; + sb.append("#".repeat(Math.min(headingLevel, 6))) + .append(" ") + .append(decodeEntities(node.getTitle())) + .append("\n\n"); + appendContent(sb, node); + return sb.toString(); + } + + private String convertParagraph(StructuralNode node) { + Object content = node.getContent(); + if (content == null) return ""; + return decodeEntities(content.toString()) + "\n\n"; + } + + private String convertPreamble(StructuralNode node) { + Object content = node.getContent(); + return content != null ? content.toString() : ""; + } + + // --- Inline converters --- + + private String convertInlineQuoted(PhraseNode node) { + String text = decodeEntities(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) { + return switch (node.getType()) { + case "link" -> { + 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 = decodeEntities(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) { + return "![" + node.getAttribute("alt", "") + "](" + node.getTarget() + ")"; + } + + 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 ""; + return " (Note: " + text + ")"; + } + + // --- Code block converters --- + + private String convertListing(Block node) { + String style = node.getStyle(); + String lang = null; + if ("source".equals(style)) { + Object langAttr = node.getAttribute("language"); + if (langAttr != null) lang = langAttr.toString(); + } else if (style != null && !"listing".equals(style)) { + lang = style; // diagram block (plantuml, ditaa, etc.) + } + return fencedCodeBlock(node, lang); + } + + private String convertLiteral(Block node) { + String style = node.getStyle(); + String lang = (style != null && !"literal".equals(style)) ? style : null; + return fencedCodeBlock(node, lang); + } + + // --- List converters --- + + private String convertUnorderedList(List node) { + var sb = new StringBuilder(); + appendBlockTitle(sb, node); + 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("- "); + appendListItemText(sb, listItem); + sb.append("\n"); + appendListItemBlocks(sb, listItem, indent + " ", depth); + } + } + + private String convertOrderedList(List node) { + var sb = new StringBuilder(); + appendBlockTitle(sb, node); + 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(". "); + appendListItemText(sb, listItem); + sb.append("\n"); + appendListItemBlocks(sb, listItem, indent + " ", depth); + number++; + } + } + + private String convertDescriptionList(DescriptionList node) { + var sb = new StringBuilder(); + appendBlockTitle(sb, node); + for (DescriptionListEntry entry : node.getItems()) { + for (ListItem term : entry.getTerms()) { + sb.append("**").append(decodeEntities(term.getText())).append("**\n"); + } + ListItem description = entry.getDescription(); + if (description != null) { + if (description.hasText()) { + sb.append(": ").append(decodeEntities(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(); + appendBlockTitle(sb, node); + java.util.List headerRows = node.getHeader(); + java.util.List bodyRows = node.getBody(); + int colCount = node.getColumns().size(); + + if (!headerRows.isEmpty()) { + appendTableRow(sb, headerRows.get(0)); + } 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) { + appendTableRow(sb, row); + } + sb.append("\n"); + return sb.toString(); + } + + private String cellText(Cell cell) { + String text; + if ("asciidoc".equals(cell.getStyle())) { + Document innerDoc = cell.getInnerDocument(); + text = (innerDoc != null && innerDoc.getContent() != null) ? innerDoc.getContent().toString() : ""; + } else { + text = cell.getText(); + if (text == null) return ""; + } + return decodeEntities(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"); + appendPrefixedContent(sb, node, "> "); + sb.append("\n"); + return sb.toString(); + } + + private String convertImage(StructuralNode node) { + var sb = new StringBuilder(); + 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 convertContentBlock(StructuralNode node) { + var sb = new StringBuilder(); + appendBlockTitle(sb, node); + appendContent(sb, node); + return sb.toString(); + } + + private String convertQuote(StructuralNode node) { + var sb = new StringBuilder(); + 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); + } + } + + private void appendPrefixedContent(StringBuilder sb, StructuralNode node, String prefix) { + Object content = node.getContent(); + if (content != null) { + for (String line : decodeEntities(content.toString()).split("\n", -1)) { + sb.append(prefix).append(line).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 void appendListItemText(StringBuilder sb, ListItem item) { + if (item.hasText()) { + sb.append(decodeEntities(item.getText()).replace("\n\n", "\n")); + } + } + + 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"); + } + } + } + } + } + + 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; + // 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-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 diff --git a/build.gradle b/build.gradle index f4e6810a1b..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" @@ -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" @@ -344,30 +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() - 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 {