diff --git a/README.md b/README.md
index 742bddf..6d4c41f 100644
--- a/README.md
+++ b/README.md
@@ -91,8 +91,9 @@ GMD supports the [Matrix](https://github.com/Alipsa/matrix) library directly. `M
`MatrixXChart` types can be used with the `out` PrintWriter object without needing to convert them
into markdown first. Pict, Charm, and GG charts accept `width`, `height`, optional alt text, and
optional HTML attributes (for example, `out.println(chart, 640, 480, 'Sales chart', [class: 'chart'])`).
-They also accept alt text and attributes without explicit dimensions. Alt-only GG printing honors
-the dimensions configured on the chart; explicit GG dimensions are restored after rendering.
+They also accept alt text and attributes without explicit dimensions. Pict and Charm charts then
+render at 800x600. GG charts always honor the `width` and `height` configured on the chart unless
+you pass explicit dimensions, which are restored after rendering.
MatrixXChart accepts optional alt text and HTML attributes.
Here is an example:
````
@@ -173,8 +174,8 @@ The library, which requires Java 21 or later, is available from maven central:
Gradle:
```groovy
-def groovyVersion = '5.1.1' // any 4.x version should work
-implementation "se.alipsa.gmd:gmd-core:3.1.0"
+def groovyVersion = '5.1.3' // any 5.x version should work
+implementation "se.alipsa.gmd:gmd-core:3.2.0"
implementation "org.apache.groovy:groovy:${groovyVersion}"
implementation "org.apache.groovy:groovy-templates:${groovyVersion}"
implementation "org.apache.groovy:groovy-jsr223:${groovyVersion}"
@@ -188,22 +189,22 @@ Maven:
se.alipsa.gmd
gmd-core
- 3.1.0
+ 3.2.0
org.apache.groovy
groovy
- 5.1.1
+ 5.1.3
org.apache.groovy
groovy-templates
- 5.1.1
+ 5.1.3
org.apache.groovy
groovy-jsr223
- 5.1.1
+ 5.1.3
org.apache.ivy
diff --git a/gmd-core/release.md b/gmd-core/release.md
index f40d9c1..244c62e 100644
--- a/gmd-core/release.md
+++ b/gmd-core/release.md
@@ -1,6 +1,22 @@
# Gmd Release History
## v3.2.0, unreleased
+- **Breaking:** the `print(GgChart, double)` / `println(GgChart, double)` width-only
+ overload that Groovy default arguments generated is removed. Pass explicit width and
+ height, or omit both and let the chart's own dimensions apply.
+- Output from a Groovy code block that does not end in a newline now gains one, so
+ `out.print('x')` no longer glues the next source line onto the output. Visible change:
+ `out.print('Hello ')` followed by prose now yields `Hello \nworld` instead of
+ `Hello world` in the Markdown (rendered identically by CommonMark).
+- Inline `= expr ` is no longer expanded inside CommonMark indented code blocks (a line
+ indented four or more spaces beyond the document's own base indent). Indent is measured
+ relative to the indent of the document's first non-blank line, so a .gmd written inside an
+ indented Groovy string is unaffected. Lazy paragraph continuations and list-item
+ continuations still expand.
+- Indented ```{groovy} fences now execute with their body dedented by the fence's own
+ indent, and the echoed fence and body line up. Visible change: a multi-line string
+ literal whose continuation lines sit below the fence indent loses those spaces in the
+ evaluated source as well as the echo.
- render `Chart`/`MatrixXChart` output (and `Html`) as a raw ` ` HTML tag instead of `{attrs}`, since CommonMark's image syntax doesn't support Pandoc-style attribute suffixes. This means `outputType=md` output can now contain raw HTML for charts instead of portable Markdown image syntax; the tag is followed by a blank line so it doesn't swallow subsequent Markdown as an HTML block.
- restore the `HtmlDecorator.BOOTSTRAP_CSS` and `HIGHLIGHT_JS_CSS` compatibility constants
- upgrade the JUnit BOM from 6.1.2 to 6.1.3
diff --git a/gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdCommandLine.groovy b/gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdCommandLine.groovy
index 43a4be4..b35b0f0 100644
--- a/gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdCommandLine.groovy
+++ b/gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdCommandLine.groovy
@@ -26,9 +26,8 @@ class GmdCommandLine {
static Closure toHtml(String from, String to) {
return {
Gmd gmd = new Gmd()
- def html = gmdFileToHtml(from, gmd)
File toFile = new File(to)
- Files.writeString(toFile.toPath(), html, StandardCharsets.UTF_8)
+ gmd.gmdToHtml(readGmd(from), toFile)
println "Wrote $toFile.absolutePath"
}
}
diff --git a/gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy b/gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy
index 04a4abe..f458f3d 100644
--- a/gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy
+++ b/gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy
@@ -23,6 +23,20 @@ class GmdTemplateEngine {
* ```
* Hello World
*
+ * Indentation is lenient by design: a ```{groovy} fence is recognised at any
+ * indent, so a .gmd document written inside an indented Groovy string still
+ * works. The body is dedented by the fence's own indent so the two line up.
+ * The consequence is that an indented block cannot be used to *show* GMD
+ * syntax without running it - wrap such an example in a longer plain fence
+ * (````) instead. Only spaces are counted; a tab-indented fence is treated
+ * as column 0. The echoed fence is always emitted at column 0, so a Groovy
+ * block written inside a list item does not stay inside that item - the
+ * emitted block ends the list and the next item starts a new one.
+ * For inline-variable expansion, the document base is the first non-blank
+ * line's indent. That is an invariant, not a global minimum: if the first
+ * line is shallower than a body indented four or more spaces beyond it, the
+ * body is treated as literal and does not expand. Put the first non-blank
+ * line at the document's own indent.
* @param text the gmd text to process
* @return the gmd text with code blocks "expanded"
*/
@@ -45,11 +59,17 @@ class GmdTemplateEngine {
boolean echo = true
Character plainFenceChar = null
int plainFenceLength = 0
+ int codeBlockIndent = 0
+ boolean inIndentedCode = false
+ boolean previousLineWasParagraph = false
+ boolean previousLineWasBlockQuote = false
+ int listContentColumn = -1
String noSpaceLine
StringBuilder codeBlockText = new StringBuilder()
StringBuilder result = new StringBuilder()
boolean endsWithNewline = text.endsWith('\n')
List lines = text.readLines()
+ int documentIndent = baseIndent(lines)
int count = 0
lines.each { line ->
noSpaceLine = fenceCandidate(line)
@@ -66,6 +86,7 @@ class GmdTemplateEngine {
shouldBeProcessed = true
codeBlockStart = true
codeBlockEnd = false
+ codeBlockIndent = leadingSpaces(line)
// echo is a property of one block, not of the entire document.
echo = true
if (noSpaceLine.toLowerCase().contains("echo=false")) {
@@ -88,7 +109,7 @@ class GmdTemplateEngine {
}
if (codeBlockStart) {
- codeBlockText.append(line).append('\n')
+ codeBlockText.append(dedent(line, codeBlockIndent)).append('\n')
}
if (codeBlockEnd) {
@@ -105,14 +126,82 @@ class GmdTemplateEngine {
//println("evaluating code block: $codeBlock")
engine.eval(codeBlock + '\n""')
def output = out.toString()
+ boolean emittedMarkdown = echo
if (output.length() > 0) {
result.append(output)
+ if (!output.endsWith('\n')) {
+ result.append('\n')
+ }
+ emittedMarkdown = true
}
out.clear()
codeBlockText.setLength(0)
codeBlockEnd = false
+ if (emittedMarkdown) {
+ inIndentedCode = false
+ previousLineWasParagraph = false
+ previousLineWasBlockQuote = false
+ listContentColumn = -1
+ }
} else if (!codeBlockStart) {
- if (plainFenceChar == null && line.contains('`=')) {
+ if (plainFenceChar != null || startsPlainFence) {
+ inIndentedCode = false
+ previousLineWasParagraph = false
+ previousLineWasBlockQuote = false
+ listContentColumn = -1
+ } else if (line.isBlank()) {
+ previousLineWasParagraph = false
+ previousLineWasBlockQuote = false
+ } else {
+ String contentLine = withoutDocumentAndBlockQuotePrefixes(line, documentIndent)
+ boolean isBlockQuote = contentLine != withoutDocumentIndent(line, documentIndent)
+ if (contentLine.isBlank()) {
+ // A block-quote line with no content (">" or "> ") is a blank
+ // line in CommonMark; classify it as one so a following
+ // indented line still counts as a code block, not a
+ // paragraph continuation.
+ previousLineWasParagraph = false
+ previousLineWasBlockQuote = isBlockQuote
+ } else {
+ if (isBlockQuote && !previousLineWasBlockQuote) {
+ previousLineWasParagraph = false
+ listContentColumn = -1
+ }
+ int relativeIndent = leadingSpaces(contentLine)
+ def listMarker = contentLine =~ /^\s*([-*+]|\d+[.)])\s+/
+ boolean isLeafBlock = isNonParagraphLeafBlock(contentLine)
+ if (isLeafBlock) {
+ // CommonMark gives thematic breaks precedence over list markers
+ // such as the leading "* " in "* * *".
+ inIndentedCode = false
+ // A thematic break dedented past the list content column ends
+ // the list; one aligned with it (like " --- " in a list item)
+ // keeps the list context for the lines that follow.
+ if (listContentColumn >= 0 && relativeIndent < listContentColumn) {
+ listContentColumn = -1
+ }
+ } else if (relativeIndent < 4 && listMarker.find()) {
+ listContentColumn = listMarker.end()
+ inIndentedCode = false
+ } else {
+ if (listContentColumn >= 0 && relativeIndent < listContentColumn) {
+ listContentColumn = -1
+ }
+ if (listContentColumn >= 0 && relativeIndent < listContentColumn + 4) {
+ inIndentedCode = false
+ } else {
+ if (relativeIndent < 4) {
+ inIndentedCode = false
+ } else if (!previousLineWasParagraph) {
+ inIndentedCode = true
+ }
+ }
+ }
+ previousLineWasParagraph = !inIndentedCode && !isLeafBlock
+ previousLineWasBlockQuote = isBlockQuote
+ }
+ }
+ if (plainFenceChar == null && !inIndentedCode && line.contains('`=')) {
shouldBeProcessed = true
result.append(expandInlineVars(line, engine))
} else {
@@ -174,6 +263,75 @@ class GmdTemplateEngine {
return line.substring(indent)
}
+ /** Number of leading space characters. Tabs are not counted; see processCodeBlocks. */
+ private static int leadingSpaces(String line) {
+ int i = 0
+ while (i < line.length() && line.charAt(i) == ' ') {
+ i++
+ }
+ return i
+ }
+
+ /** Remove the document's base indent without treating a shallower line as negative. */
+ private static String withoutDocumentIndent(String line, int documentIndent) {
+ return line.substring(Math.min(documentIndent, leadingSpaces(line)))
+ }
+
+ /**
+ * Remove block-quote container markers before measuring content indentation.
+ * A block quote may itself be indented by the document's base indent.
+ */
+ private static String withoutDocumentAndBlockQuotePrefixes(String line, int documentIndent) {
+ String content = withoutDocumentIndent(line, documentIndent)
+ while (true) {
+ Matcher marker = content =~ /^ {0,3}>[ \t]?/
+ if (!marker.find()) {
+ return content
+ }
+ content = content.substring(marker.end())
+ }
+ }
+
+ /** Headings and rules are leaf blocks, so following indented lines start code blocks. */
+ private static boolean isNonParagraphLeafBlock(String line) {
+ if (line ==~ /^ {0,3}#{1,6}(?:[ \t]+.*)?$/ || line ==~ /^ {0,3}[=-]+[ \t]*$/) {
+ return true
+ }
+ String marker = line.trim().replaceAll(/[ \t]/, '')
+ return marker.length() >= 3 && (marker ==~ /\*+/ || marker ==~ /_+/ || marker ==~ /-+/)
+ }
+
+ /**
+ * The indent the document sits at: the indent of its first non-blank line.
+ * Indented code blocks are recognised relative to this, so a .gmd written
+ * inside an indented Groovy string behaves the same as one written at
+ * column 0.
+ *
+ * Anchored on the first line deliberately: a global minimum could be
+ * collapsed by a column-0 line inside a code block or a stray column-0
+ * prose line (HTML block, table row), reclassifying the whole document.
+ * The first line is therefore the invariant: when it is shallower than
+ * the body, body lines four or more spaces beyond it are literal. Authors
+ * must start a document at its own indent.
+ */
+ private static int baseIndent(List lines) {
+ for (String line in lines) {
+ if (!line.isBlank()) {
+ return leadingSpaces(line)
+ }
+ }
+ return 0
+ }
+
+ /** Removes up to {@code width} leading spaces. Shorter indents are left untouched. */
+ private static String dedent(String line, int width) {
+ int i = 0
+ while (i < width && i < line.length() && line.charAt(i) == ' ') {
+ i++
+ }
+ return line.substring(i)
+ }
+
/** Length of the leading run of marker characters, i.e. the fence width. */
private static int fenceRunLength(String line, String marker) {
int i = 0
diff --git a/gmd-core/src/main/groovy/se/alipsa/gmd/core/Printer.groovy b/gmd-core/src/main/groovy/se/alipsa/gmd/core/Printer.groovy
index def4b64..83b1452 100644
--- a/gmd-core/src/main/groovy/se/alipsa/gmd/core/Printer.groovy
+++ b/gmd-core/src/main/groovy/se/alipsa/gmd/core/Printer.groovy
@@ -161,20 +161,14 @@ class Printer extends PrintWriter {
println(chartToMd(x, DEFAULT_CHART_WIDTH, DEFAULT_CHART_HEIGHT, alt, attributes))
}
- /**
- * Renders a GgChart at the supplied dimensions. The chart's original
- * dimensions are restored before this method returns.
- */
- void print(GgChart x, double width = DEFAULT_CHART_WIDTH, double height = DEFAULT_CHART_HEIGHT, String alt = '', Map attributes = [:]) {
- printChart(chartToMd(x, width, height, alt, attributes))
+ /** Renders a GgChart at the dimensions configured on the chart itself. */
+ void print(GgChart x) {
+ printChart(svgToMd(x.render(), '', [:]))
}
- /**
- * Renders a GgChart at the supplied dimensions. The chart's original
- * dimensions are restored before this method returns.
- */
- void println(GgChart x, double width = DEFAULT_CHART_WIDTH, double height = DEFAULT_CHART_HEIGHT, String alt = '', Map attributes = [:]) {
- println(chartToMd(x, width, height, alt, attributes))
+ /** @see #print(GgChart) */
+ void println(GgChart x) {
+ println(svgToMd(x.render(), '', [:]))
}
void print(GgChart x, String alt, Map attributes = [:]) {
@@ -185,6 +179,16 @@ class Printer extends PrintWriter {
println(svgToMd(x.render(), alt, attributes))
}
+ /** Renders a GgChart at supplied dimensions, restoring its own afterwards. */
+ void print(GgChart x, double width, double height, String alt = '', Map attributes = [:]) {
+ printChart(chartToMd(x, width, height, alt, attributes))
+ }
+
+ /** @see #print(GgChart, double, double, String, Map) */
+ void println(GgChart x, double width, double height, String alt = '', Map attributes = [:]) {
+ println(chartToMd(x, width, height, alt, attributes))
+ }
+
void print(MatrixXChart x, String alt = '', Map attributes = [:]) {
printChart(chartToMd(x, alt, attributes))
}
diff --git a/gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy b/gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy
index a3acb07..5b8489e 100644
--- a/gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy
+++ b/gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy
@@ -69,15 +69,91 @@ Hello World
assertEquals("""
Before
```groovy
- // just some Groovy code
- def x = 5
- out.println('Hello World')
+// just some Groovy code
+def x = 5
+out.println('Hello World')
```
Hello World
After
""", GmdTemplateEngine.processCodeBlocks(text))
}
+ @Test
+ void anIndentedGroovyFenceDedentsItsBody() {
+ String text = '''
+Example:
+
+ ```{groovy}
+ out.println('EXECUTED')
+ ```
+
+end
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains("```groovy\nout.println('EXECUTED')\n```"),
+ "The echoed body must line up with the fence that wraps it:\n$processed")
+ assertTrue(processed.contains('EXECUTED\n'), processed)
+ }
+
+ @Test
+ void dedentOnlyRemovesTheFenceIndent() {
+ String text = '''
+ ```{groovy}
+ out.println('deep')
+ ```
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains("```groovy\n out.println('deep')\n```"),
+ "Indentation beyond the fence indent must be preserved:\n$processed")
+ }
+
+ @Test
+ void aColumnZeroFenceLeavesItsBodyAlone() {
+ String text = '''
+```{groovy}
+ out.println('indented body')
+```
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains("```groovy\n out.println('indented body')\n```"),
+ "A fence at column 0 dedents by 0:\n$processed")
+ }
+
+ @Test
+ void dedentAppliesToTheEvaluatedSource() {
+ String text = '''
+ ```{groovy echo=false}
+ out.print("""a
+ b""")
+ ```
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('a\nb'),
+ "The dedent applies to the evaluated source too:\n$processed")
+ }
+
+ @Test
+ void unterminatedCodeBlockOutputDoesNotSwallowTheNextLine() {
+ String text = "```{groovy echo=false}\nout.print('X')\n```\n# Heading\n"
+
+ assertEquals("X\n# Heading\n", GmdTemplateEngine.processCodeBlocks(text))
+
+ text = "```{groovy echo=false}\nout.println('X')\n```\n# Heading\n"
+
+ assertEquals("X\n# Heading\n", GmdTemplateEngine.processCodeBlocks(text))
+ }
+
+ @Test
+ void inlineOutPrintIsTerminatedBeforeTheNextLine() {
+ String text = "```{groovy echo=false}\nout.print('Hello ')\n```\nworld\n"
+
+ assertEquals("Hello \nworld\n", GmdTemplateEngine.processCodeBlocks(text))
+ }
+
@Test
void testInlineVars() {
def text = """
@@ -252,17 +328,269 @@ After
}
@Test
- void indentedCodeBlocksAreAKnownGap() {
+ void indentedCodeBlocksAreLeftAlone() {
String text = '''
```{groovy echo=false}
x = 5
```
- literal `= x` is expanded here
+ literal `= x ` is not expanded here
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal `= x ` is not expanded here'),
+ "An indented code block is literal:\n$processed")
+ assertFalse(processed.contains('literal 5'), processed)
+ }
+
+ @Test
+ void indentIsRelativeToTheDocumentBaseIndent() {
+ String text = """
+ ```{groovy echo=false}
+ x = 5
+ ```
+ expanded `= x ` here
+
+ literal `= x ` here
+ """
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('expanded 5 here'), processed)
+ assertTrue(processed.contains('literal `= x ` here'), processed)
+ }
+
+ @Test
+ void anIndentedLineContinuingAParagraphIsStillExpanded() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+Some prose
+ continues here with `= x `
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('continues here with 5'), processed)
+ }
+
+ @Test
+ void indentedCodeAfterLeafBlocksIsLeftAlone() {
+ ['# Heading', 'Title\n=====', '---', '* * *', '- - -'].each { leafBlock ->
+ String text = "```{groovy echo=false}\nx = 5\n```\n${leafBlock}\n literal `= x ` here\n"
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal `= x ` here'), processed)
+ assertFalse(processed.contains('literal 5'), processed)
+ }
+ }
+
+ @Test
+ void indentedCodeInsideABlockQuoteIsLeftAlone() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+> literal `= x ` here
+'''
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal `= x ` here'), processed)
+ assertFalse(processed.contains('literal 5'), processed)
+ }
+
+ @Test
+ void aPlainFenceResetsBlockQuoteAndListContext() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+> - item
+```text
+plain
+```
+>
+> literal `= x ` here
+'''
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal `= x ` here'), processed)
+ assertFalse(processed.contains('literal 5 here'), processed)
+ }
+
+ @Test
+ void anEmptyEchoFalseBlockPreservesThePreviousParagraph() {
+ String text = '''
+prose line
+```{groovy echo=false}
+x = 5
+```
+ continued `= x ` here
+'''
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('continued 5 here'), processed)
+ }
+
+ @Test
+ void aBlankLineDoesNotEndAnIndentedCodeBlock() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+ first `= x ` literal
+
+ second `= x ` literal
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('first `= x ` literal'), processed)
+ assertTrue(processed.contains('second `= x ` literal'), processed)
+ }
+
+ @Test
+ void aTabIndentedLineIsNotTreatedAsACodeBlock() {
+ String text = "```{groovy echo=false}\nx = 5\n```\n\tliteral `= x ` here\n"
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal 5 here'),
+ "Known limitation: tabs are not counted as indentation:\n$processed")
+ }
+
+ @Test
+ void crlfLinesAreClassifiedCorrectly() {
+ String text = "```{groovy echo=false}\r\nx = 5\r\n```\r\n literal `= x ` here\r\n \r\n"
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal `= x ` here'), processed)
+ }
+
+ @Test
+ void baseIndentIgnoresCodeBlockBodies() {
+ String text = """
+ ```{groovy echo=false}
+ x = 5
+ multi = '''line one
+line two'''
+ ```
+ Value `= x `
+ """
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('Value 5'),
+ "A column-0 line inside a code block must not change the document's base indent:\n$processed")
+ }
+
+ @Test
+ void aStrayColumnZeroLineDoesNotCollapseTheBaseIndent() {
+ String text = """
+ ```{groovy echo=false}
+ x = 5
+ ```
+
+ expanded `= x ` here
+ """
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('expanded 5 here'),
+ "A column-0 line must not collapse the base indent:\n$processed")
+ }
+
+ @Test
+ void aListItemContinuationStillExpands() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+1. item
+
+ continued `= x `
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('continued 5'), processed)
+ }
+
+ @Test
+ void aThematicBreakInsideAListItemKeepsTheListContext() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+- item
+
+ ---
+
+ para `= x ` here
+'''
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('para 5 here'),
+ "A thematic break aligned with the list content column must not end the list:\n$processed")
+ }
+
+ @Test
+ void aThematicBreakDedentPastTheListContentColumnEndsTheList() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+- item
+
+---
+
+ literal `= x ` here
+'''
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal `= x ` here'),
+ "A thematic break dedented past the list content column ends the list:\n$processed")
+ }
+
+ @Test
+ void aBlankBlockQuoteLineDoesNotStartAParagraph() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+> para
+>
+> literal `= x ` here
+'''
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal `= x ` here'),
+ "An indented line after a blank block-quote line is a code block:\n$processed")
+ }
+
+ @Test
+ void indentedCodeAfterAListIsLeftAlone() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+
+- item
+
+text
+
+ literal `= x ` here
'''
String processed = GmdTemplateEngine.processCodeBlocks(text)
- assertTrue(processed.contains('literal 5 is expanded here'),
- "Known gap: indented code blocks are not protected:\n$processed")
+ assertTrue(processed.contains('literal `= x ` here'),
+ "An indented code block after an ended list is literal:\n$processed")
+ assertFalse(processed.contains('literal 5'), processed)
}
@Test
diff --git a/gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTest.groovy b/gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTest.groovy
index 78172b9..0ec8424 100644
--- a/gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTest.groovy
+++ b/gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTest.groovy
@@ -596,6 +596,8 @@ chart.width = 1200
chart.height = 900
out.println(chart, 640, 480, 'GG chart', [class: 'chart'])
out.println(chart, 'GG default chart')
+out.println(chart)
+out.println(chart, 640.5, 480.5, 'GG BigDecimal dims')
```
'''
@@ -606,6 +608,29 @@ out.println(chart, 'GG default chart')
List svgs = decodeSvgImages(md, 'GG chart should be SVG-backed')
assertTrue(svgs[0].contains('width="640"') && svgs[0].contains('height="480"'), svgs[0].take(200))
assertTrue(svgs[1].contains('width="1200"') && svgs[1].contains('height="900"'), svgs[1].take(200))
+ assertTrue(svgs[2].contains('width="1200"') && svgs[2].contains('height="900"'),
+ "print(GgChart) with no arguments must honour the chart's own dimensions: ${svgs[2].take(200)}")
+ assertTrue(svgs[3].contains('width="640"') && svgs[3].contains('height="480"'),
+ "double dimensions must accept Groovy's default decimal type (BigDecimal): ${svgs[3].take(200)}")
+ }
+
+ @Test
+ void aGgChartWithNoConfiguredSizeRendersAtTheLibraryDefault() {
+ def text = '''
+```{groovy echo=false}
+import static se.alipsa.matrix.gg.GgPlot.*
+import se.alipsa.matrix.core.Matrix
+
+def data = Matrix.builder().data(x: [1, 2], y: [3, 4]).types(int, int).build()
+out.println(ggplot(data, aes(x: 'x', y: 'y')) + geom_point())
+```
+'''
+
+ List svgs = decodeSvgImages(new Gmd().gmdToMd(text), 'An unsized GG chart should still render')
+
+ assertTrue(svgs[0].contains(' decodeSvgImages(String markdown, String message) {
@@ -738,6 +763,29 @@ out.println(chart)
assertTrue(pdfFile.length() > 100, "No pdf content")
}
+ @Test
+ void commandLineToHtmlCreatesMissingParentDirectories() {
+ String file = getClass().getResource('/test.gmd').getFile()
+ File htmlFile = new File(testOutputDir, 'nested/output/dir/cli.html')
+ assertFalse(htmlFile.parentFile.exists(), 'The parent directory must not exist yet')
+
+ Gmd.main('toHtml', file, htmlFile.absolutePath)
+
+ assertTrue(htmlFile.exists(), 'toHtml must create the parent directories, as toPdf does')
+ assertTrue(htmlFile.length() > 100, 'No html content')
+ }
+
+ @Test
+ void commandLineToPdfCreatesMissingParentDirectories() {
+ String file = getClass().getResource('/test.gmd').getFile()
+ File pdfFile = new File(testOutputDir, 'nested/pdf/dir/cli.pdf')
+
+ Gmd.main('toPdf', file, pdfFile.absolutePath)
+
+ assertTrue(pdfFile.exists())
+ assertTrue(pdfFile.length() > 100, 'No pdf content')
+ }
+
@Test
void testEnsureResultDoesNotPrint() {
String text = """
diff --git a/gmd-gradle-plugin/readme.md b/gmd-gradle-plugin/readme.md
index da6f69b..9215169 100644
--- a/gmd-gradle-plugin/readme.md
+++ b/gmd-gradle-plugin/readme.md
@@ -32,6 +32,20 @@ embedded styles, and syntax-highlighted code blocks.
- `ivyVersion` - the version of ivy to use. Default is `2.6.0`
- `runTaskBefore` - the task that the gmd plugin should run before. Default is 'test'
+### Repositories
+
+The plugin resolves `gmd-core` and Groovy through its named `gmdProcessorRuntime` project
+configuration, which uses the project's repositories. If the project has not declared Maven
+Central, the plugin adds it.
+If it has — under either `repo.maven.apache.org` or `repo1.maven.org` — nothing is added.
+A project that resolves only through a private mirror should declare that mirror in
+`dependencyResolutionManagement` in `settings.gradle` with `repositoriesMode` set to
+`RepositoriesMode.PREFER_SETTINGS` or `FAIL_ON_PROJECT_REPOS`, so the plugin has no reason
+to add Central to the project itself — the plugin detects either mode and skips the add.
+Declaring the mirror alone, without one of those modes, is not enough: Gradle's default
+`PREFER_PROJECT` mode still lets a project-level repository (including the one this plugin
+would add) take precedence over the settings-declared mirror.
+
The target task is called `processGmd` so it can be invoked from the command line as follows:
```bash
@@ -46,7 +60,7 @@ plugins {
id('se.alipsa.gmd.gmd-gradle-plugin')
}
group = 'my.group'
-version = '3.1.0'
+version = '3.2.0'
gmdPlugin {
sourceDir = 'src/test/gmd'
diff --git a/gmd-gradle-plugin/release.md b/gmd-gradle-plugin/release.md
index 39e8970..b34fe57 100644
--- a/gmd-gradle-plugin/release.md
+++ b/gmd-gradle-plugin/release.md
@@ -1,6 +1,11 @@
# GMD Gradle Plugin release history
## v3.2.0, unreleased
+- **Breaking:** `GmdGradlePlugin.hasRepository(Project, MavenArtifactRepository)` is removed.
+ It was public static API on the plugin class; nothing in the repo called it. The plugin now
+ checks for an existing Maven Central declaration itself before adding one, and also detects
+ when repositories are settings-managed (`PREFER_SETTINGS` or `FAIL_ON_PROJECT_REPOS`) so it
+ never adds a project-level repository Gradle would ignore or reject.
- derive the default GMD core version from a generated plugin-version resource populated from the root Maven revision, failing clearly if metadata is unavailable
- derive the development artifact version from the root Maven revision and require an explicit version when publishing
- upgrade the Gradle Versions Plugin from 0.58.0 to 0.61.0
@@ -8,6 +13,7 @@
- validate the Gradle Plugin Portal publication before uploading the plugin
- upgrade groovy to 5.1.3
- clean stale generated files only from the dedicated default `build/gmd` output directory; custom target directories retain pre-existing and orphaned GMD output when sources are deleted, renamed, or change output type, while Gradle tracks their expected generated files individually for up-to-date checks
+- track the actual GMD processor runtime classpath for up-to-date checks, including changed dynamic or transitive dependencies; avoid resolving it when no `.gmd` source exists so the no-op and stale-output cleanup paths remain dependency-free
## v3.1.1, in progress
- replace the deprecated `Project.getProperties()` calls used by signing configuration with `findProperty`, keeping the plugin compatible with Gradle 10
diff --git a/gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/GmdGradlePlugin.groovy b/gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/GmdGradlePlugin.groovy
index 28c989b..594c5de 100644
--- a/gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/GmdGradlePlugin.groovy
+++ b/gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/GmdGradlePlugin.groovy
@@ -4,19 +4,21 @@ import groovy.transform.CompileStatic
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.api.Task
+import org.gradle.api.InvalidUserCodeException
import org.gradle.api.artifacts.Configuration
import org.gradle.api.artifacts.Dependency
import org.gradle.api.artifacts.repositories.ArtifactRepository
import org.gradle.api.artifacts.repositories.MavenArtifactRepository
+import org.gradle.api.initialization.resolve.RepositoriesMode
+import org.gradle.api.internal.GradleInternal
import org.gradle.api.tasks.TaskProvider
-import java.io.IOException
-import java.io.InputStream
-import java.util.Properties
-
@CompileStatic
class GmdGradlePlugin implements Plugin {
+ private static final List MAVEN_CENTRAL_HOSTS = ['repo.maven.apache.org', 'repo1.maven.org']
+ private static final String GMD_PROCESSOR_RUNTIME_CONFIGURATION = 'gmdProcessorRuntime'
+
@Override
void apply(Project project) {
GmdGradlePluginParams extension = project.extensions.create('gmdPlugin', GmdGradlePluginParams)
@@ -25,7 +27,7 @@ class GmdGradlePlugin implements Plugin {
extension.outputType.convention('md')
extension.groovyVersion.convention('5.1.3')
extension.log4jVersion.convention('2.26.1')
- extension.gmdVersion.convention(project.providers.provider { defaultGmdVersion() })
+ extension.gmdVersion.convention(project.providers.provider { ProcessGmdTask.defaultGmdVersion() })
extension.ivyVersion.convention('2.6.0')
extension.runTaskBefore.convention('test')
@@ -35,11 +37,12 @@ class GmdGradlePlugin implements Plugin {
String sourceDir = extension.sourceDir.get()
String targetDir = extension.targetDir.get()
String outputType = extension.outputType.get()
+ String groovyVersion = extension.groovyVersion.get()
+ String log4jVersion = extension.log4jVersion.get()
+ String gmdVersion = extension.gmdVersion.get()
+ String ivyVersion = extension.ivyVersion.get()
Configuration configuration = addDependencies(project,
- extension.groovyVersion.get(),
- extension.log4jVersion.get(),
- extension.gmdVersion.get(),
- extension.ivyVersion.get()
+ groovyVersion, log4jVersion, gmdVersion, ivyVersion
)
File resolvedTargetDir = project.file(targetDir)
@@ -56,7 +59,10 @@ class GmdGradlePlugin implements Plugin {
task.sourceDir.set(project.file(sourceDir))
task.targetDir.set(resolvedTargetDir)
task.outputType.set(outputType)
- task.classpath.from(configuration)
+ File configuredSourceDir = project.file(sourceDir)
+ task.runtimeClasspath.from(project.providers.provider {
+ hasGmdFiles(configuredSourceDir) ? configuration : project.files()
+ })
task.targetDirIsDefaultGmdOutput.set(targetDirIsDefaultGmdOutput)
if (targetDirIsDefaultGmdOutput) {
task.dedicatedOutputDir.set(resolvedTargetDir)
@@ -74,42 +80,16 @@ class GmdGradlePlugin implements Plugin {
}
}
- private static String defaultGmdVersion() {
- InputStream stream = GmdGradlePlugin.class.getResourceAsStream('/gmd-version.properties')
- if (stream == null) {
- throw new IllegalStateException(
- 'GMD core version metadata is missing from the Gradle plugin; set gmdPlugin.gmdVersion explicitly'
- )
- }
- try {
- Properties properties = new Properties()
- properties.load(stream)
- String version = properties.getProperty('gmd.version')
- String normalizedVersion = version == null ? null : version.trim()
- if (normalizedVersion == null || normalizedVersion.isEmpty()
- || normalizedVersion.contains('$') || normalizedVersion.contains('{')) {
- throw new IllegalStateException(
- 'GMD core version metadata is invalid; set gmdPlugin.gmdVersion explicitly'
- )
- }
- return normalizedVersion
- } catch (IOException e) {
- throw new IllegalStateException('Could not read GMD core version metadata', e)
- } finally {
- try {
- stream.close()
- } catch (IOException ignored) {
- // Ignore cleanup failures while resolving the version resource.
- }
- }
- }
-
static Configuration addDependencies(Project project,
String groovyVersion, String log4jVersion, String gmdVersion,
String ivyVersion) {
- MavenArtifactRepository mavenCentral = project.repositories.mavenCentral()
- if (!hasRepository(project, mavenCentral)) {
- project.repositories.add(mavenCentral)
+ if (!isRepositoriesModeSettingsManaged(project) && !hasMavenCentral(project)) {
+ try {
+ project.repositories.mavenCentral()
+ } catch (InvalidUserCodeException e) {
+ project.logger.info('Project repositories are managed in settings.gradle; ' +
+ 'resolving gmd-core through the settings repositories instead: ' + e.message)
+ }
}
List dependencies = [
@@ -121,12 +101,48 @@ class GmdGradlePlugin implements Plugin {
project.dependencies.create("se.alipsa.gmd:gmd-core:${gmdVersion}")
]
- return project.configurations.detachedConfiguration(dependencies.toArray(new Dependency[0]))
+ Configuration configuration = project.configurations.maybeCreate(GMD_PROCESSOR_RUNTIME_CONFIGURATION)
+ configuration.canBeConsumed = false
+ configuration.canBeResolved = true
+ configuration.visible = false
+ configuration.dependencies.addAll(dependencies)
+ return configuration
+ }
+
+ static boolean hasMavenCentral(Project project) {
+ return project.repositories.any { ArtifactRepository repository ->
+ repository instanceof MavenArtifactRepository &&
+ MAVEN_CENTRAL_HOSTS.contains(((MavenArtifactRepository) repository).url?.host)
+ }
+ }
+
+ private static boolean hasGmdFiles(File directory) {
+ File[] files = directory.listFiles({ File file ->
+ file.isFile() && file.name.endsWith('.gmd')
+ } as FileFilter)
+ return files != null && files.length > 0
}
- static boolean hasRepository(Project project, MavenArtifactRepository repo) {
- return project.repositories.find {
- it instanceof MavenArtifactRepository && it.url == repo.url
- } != null
+ /**
+ * Under {@code PREFER_SETTINGS}, Gradle does not reject a project-declared repository
+ * with {@link InvalidUserCodeException} the way it does under {@code FAIL_ON_PROJECT_REPOS};
+ * it silently accepts (and deprecates) it instead. That leaves the {@code hasMavenCentral}
+ * check blind - project.repositories still reports zero entries beforehand - so without this
+ * check the plugin would add, and permanently mutate every consumer's project.repositories
+ * with, a repository that dependency resolution ignores anyway. There is no public API for a
+ * project plugin to read the resolved repositories mode, so this reaches into Gradle's
+ * internal API (bundled by gradleApi(), verified on Gradle 9.7.1). If that internal API is
+ * ever unavailable, this falls back to false and the InvalidUserCodeException guard in
+ * addDependencies still handles FAIL_ON_PROJECT_REPOS as before.
+ */
+ private static boolean isRepositoriesModeSettingsManaged(Project project) {
+ try {
+ GradleInternal gradleInternal = (GradleInternal) project.gradle
+ RepositoriesMode mode = gradleInternal.settings.dependencyResolutionManagement.repositoriesMode
+ .getOrElse(RepositoriesMode.PREFER_PROJECT)
+ return mode != RepositoriesMode.PREFER_PROJECT
+ } catch (Throwable ignored) {
+ return false
+ }
}
}
diff --git a/gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/ProcessGmdTask.groovy b/gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/ProcessGmdTask.groovy
index 91e172a..ccfa510 100644
--- a/gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/ProcessGmdTask.groovy
+++ b/gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/ProcessGmdTask.groovy
@@ -1,9 +1,11 @@
package se.alipsa.gmd.gradle
import groovy.transform.CompileStatic
+import groovy.transform.PackageScope
import org.gradle.api.DefaultTask
-import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.tasks.Classpath
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputDirectory
import org.gradle.api.tasks.Optional
@@ -33,6 +35,42 @@ abstract class ProcessGmdTask extends DefaultTask {
targetDirIsDefaultGmdOutput.convention(false)
}
+ /**
+ * The gmd-core version matching this plugin, from the generated
+ * gmd-version.properties resource. Fails clearly when the resource is
+ * missing or unexpanded.
+ */
+ @PackageScope
+ static String defaultGmdVersion() {
+ InputStream stream = ProcessGmdTask.class.getResourceAsStream('/gmd-version.properties')
+ if (stream == null) {
+ throw new IllegalStateException(
+ 'GMD core version metadata is missing from the Gradle plugin; set gmdPlugin.gmdVersion explicitly'
+ )
+ }
+ try {
+ Properties properties = new Properties()
+ properties.load(stream)
+ String version = properties.getProperty('gmd.version')
+ String normalizedVersion = version == null ? null : version.trim()
+ if (normalizedVersion == null || normalizedVersion.isEmpty()
+ || normalizedVersion.contains('$') || normalizedVersion.contains('{')) {
+ throw new IllegalStateException(
+ 'GMD core version metadata is invalid; set gmdPlugin.gmdVersion explicitly'
+ )
+ }
+ return normalizedVersion
+ } catch (IOException e) {
+ throw new IllegalStateException('Could not read GMD core version metadata', e)
+ } finally {
+ try {
+ stream.close()
+ } catch (IOException ignored) {
+ // Ignore cleanup failures while resolving the version resource.
+ }
+ }
+ }
+
@InputDirectory
@Optional
@PathSensitive(PathSensitivity.RELATIVE)
@@ -63,8 +101,8 @@ abstract class ProcessGmdTask extends DefaultTask {
File source = getSourceDir().get().asFile
File target = getTargetDir().get().asFile
String output = getOutputType().get().trim().toLowerCase(Locale.ROOT)
- File[] sources = source.listFiles({ File file -> file.isFile() && file.name.endsWith('.gmd') } as FileFilter)
- Set generated = sources == null ? [] as Set : sources.collect { File file ->
+ File[] sources = gmdFilesIn(source)
+ Set generated = sources.collect { File file ->
new File(target, file.name.substring(0, file.name.length() - 4) + ".${output}")
} as Set
return generated
@@ -73,8 +111,23 @@ abstract class ProcessGmdTask extends DefaultTask {
@Input
abstract org.gradle.api.provider.Property getOutputType()
- @org.gradle.api.tasks.Classpath
- abstract ConfigurableFileCollection getClasspath()
+ /**
+ * The actual runtime classpath used to fork GmdProcessor. When there is no
+ * .gmd source, its provider supplies an empty collection so Gradle can run the
+ * no-op and stale-output cleanup paths without resolving processor dependencies.
+ */
+ @Classpath
+ abstract ConfigurableFileCollection getRuntimeClasspath()
+
+ /**
+ * @deprecated Use {@link #getRuntimeClasspath()}. This alias returns the
+ * same collection for compatibility with directly registered tasks.
+ */
+ @Deprecated
+ @org.gradle.api.tasks.Internal
+ ConfigurableFileCollection getClasspath() {
+ return getRuntimeClasspath()
+ }
@Input
abstract org.gradle.api.provider.Property getTargetDirIsDefaultGmdOutput()
@@ -92,6 +145,14 @@ abstract class ProcessGmdTask extends DefaultTask {
logger.warn("Source directory ${source.canonicalPath} does not exist, nothing to do")
return
}
+ File[] sourceFiles = gmdFilesIn(source)
+ if (sourceFiles.length == 0) {
+ if (getTargetDirIsDefaultGmdOutput().getOrElse(false) && target.isDirectory()) {
+ cleanStaleGeneratedFiles(source, target, output)
+ }
+ logger.quiet("No gmd files found in ${source.canonicalPath}, nothing to do")
+ return
+ }
if (!target.exists()) {
if (!target.mkdirs() && !target.isDirectory()) {
throw new IllegalArgumentException("Could not create target directory ${target.canonicalPath}")
@@ -108,31 +169,23 @@ abstract class ProcessGmdTask extends DefaultTask {
}
def result = execOperations.javaexec { JavaExecSpec spec ->
- spec.classpath = getClasspath()
+ spec.classpath = getRuntimeClasspath()
spec.mainClass.set('se.alipsa.gmd.core.GmdProcessor')
spec.args = [source.canonicalPath, target.canonicalPath, output]
}
result.assertNormalExitValue()
- File[] sourceFiles = source.listFiles()
- if (sourceFiles != null && sourceFiles.size() > 0) {
- if (target.exists()) {
- logger.quiet("Gmd files processed and written to ${target.canonicalPath}")
- } else {
- logger.warn("${target.canonicalPath} should exists but does not, something is probably wrong")
- }
+ if (target.exists()) {
+ logger.quiet("Gmd files processed and written to ${target.canonicalPath}")
} else {
- logger.quiet("No gmd files found in ${source.canonicalPath}, nothing to do")
+ logger.warn("${target.canonicalPath} should exists but does not, something is probably wrong")
}
}
private void cleanStaleGeneratedFiles(File sourceDir, File targetDir, String outputType) {
Set expected = [] as Set
- File[] sources = sourceDir.listFiles({ File file -> file.isFile() && file.name.endsWith('.gmd') } as FileFilter)
- if (sources != null) {
- sources.each { file ->
- String base = file.name.substring(0, file.name.length() - 4)
- expected.add("${base}.${outputType}".toString())
- }
+ gmdFilesIn(sourceDir).each { file ->
+ String base = file.name.substring(0, file.name.length() - 4)
+ expected.add("${base}.${outputType}".toString())
}
File[] generated = targetDir.listFiles({ File file ->
file.isFile() && (file.name.endsWith('.md') || file.name.endsWith('.html') || file.name.endsWith('.pdf'))
@@ -147,4 +200,12 @@ abstract class ProcessGmdTask extends DefaultTask {
}
}
}
+
+ /** The .gmd files in a directory, or an empty array when it cannot be read. */
+ private static File[] gmdFilesIn(File directory) {
+ File[] files = directory.listFiles({ File file ->
+ file.isFile() && file.name.endsWith('.gmd')
+ } as FileFilter)
+ return files == null ? new File[0] : files
+ }
}
diff --git a/gmd-gradle-plugin/src/test/groovy/test/alipsa/gmd/gradle/GmdGradlePluginTest.groovy b/gmd-gradle-plugin/src/test/groovy/test/alipsa/gmd/gradle/GmdGradlePluginTest.groovy
index 0b29a16..b8b8af9 100644
--- a/gmd-gradle-plugin/src/test/groovy/test/alipsa/gmd/gradle/GmdGradlePluginTest.groovy
+++ b/gmd-gradle-plugin/src/test/groovy/test/alipsa/gmd/gradle/GmdGradlePluginTest.groovy
@@ -48,7 +48,7 @@ class GmdGradlePluginTest {
@Test
void defaultGmdVersionComesFromGeneratedResource() {
- URL resource = GmdGradlePlugin.class.getResource('/gmd-version.properties')
+ URL resource = ProcessGmdTask.class.getResource('/gmd-version.properties')
Assertions.assertNotNull(resource, 'The plugin version resource must be generated during processResources')
Properties properties = new Properties()
@@ -64,7 +64,7 @@ class GmdGradlePluginTest {
Assertions.assertEquals(expectedVersion, resourceVersion)
Assertions.assertFalse(resourceVersion.contains('$'), "The generated resource must be expanded: $resourceVersion")
- def method = GmdGradlePlugin.class.getDeclaredMethod('defaultGmdVersion')
+ def method = ProcessGmdTask.class.getDeclaredMethod('defaultGmdVersion')
method.setAccessible(true)
Assertions.assertEquals(resourceVersion, method.invoke(null))
}
@@ -81,6 +81,312 @@ class GmdGradlePluginTest {
'targetDirIsDefaultGmdOutput should default to false when set only by convention')
}
+ @Test
+ void runtimeClasspathIsTrackedAsAClasspathInput() {
+ def project = ProjectBuilder.builder().build()
+ ProcessGmdTask task = project.tasks.register('siteGmd', ProcessGmdTask).get()
+ File sourceDir = new File(project.projectDir, 'src/gmd')
+ sourceDir.mkdirs()
+ new File(sourceDir, 'test.gmd').text = '# Greetings\n'
+ File runtimeJar = new File(project.buildDir, 'runtime.jar')
+ runtimeJar.parentFile.mkdirs()
+ runtimeJar.createNewFile()
+ task.sourceDir.set(sourceDir)
+ task.runtimeClasspath.from(runtimeJar)
+
+ Assertions.assertSame(task.runtimeClasspath, task.classpath,
+ 'The deprecated classpath alias must delegate to runtimeClasspath')
+ Assertions.assertTrue(task.inputs.files.files.contains(runtimeJar),
+ 'The resolved processor runtime must participate in up-to-date checks')
+ Assertions.assertFalse(task.inputs.properties.containsKey('classpathIdentity'),
+ 'A declared-version identity must not replace the actual runtime classpath')
+ }
+
+ @Test
+ void mavenCentralIsAddedOnlyWhenTheProjectHasNone() {
+ def project = ProjectBuilder.builder().build()
+ Assertions.assertEquals(0, project.repositories.size())
+
+ GmdGradlePlugin.addDependencies(project, '5.1.3', '2.26.1', '3.1.0', '2.6.0')
+
+ Assertions.assertEquals(1, project.repositories.size())
+ }
+
+ @Test
+ void anExistingMavenCentralIsNotDuplicated() {
+ def project = ProjectBuilder.builder().build()
+ project.repositories.mavenCentral()
+
+ GmdGradlePlugin.addDependencies(project, '5.1.3', '2.26.1', '3.1.0', '2.6.0')
+
+ Assertions.assertEquals(1, project.repositories.size())
+ }
+
+ @Test
+ void mavenCentralIsRecognisedUnderItsOtherHost() {
+ def project = ProjectBuilder.builder().build()
+ project.repositories.maven { it.setUrl('https://repo1.maven.org/maven2') }
+
+ GmdGradlePlugin.addDependencies(project, '5.1.3', '2.26.1', '3.1.0', '2.6.0')
+
+ Assertions.assertEquals(1, project.repositories.size())
+ }
+
+ @Test
+ void aPrivateMirrorStillGetsMavenCentralAdded() {
+ def project = ProjectBuilder.builder().build()
+ project.repositories.maven { it.setUrl('https://nexus.example.com/repository/maven-public') }
+
+ GmdGradlePlugin.addDependencies(project, '5.1.3', '2.26.1', '3.1.0', '2.6.0')
+
+ Assertions.assertEquals(2, project.repositories.size())
+ }
+
+ @Test
+ void settingsManagedRepositoriesDoNotBreakTheBuild() {
+ File testProjectDir = new File('build/gmdSettingsReposTest')
+ try {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ File srcDir = new File(testProjectDir, 'src/test/gmd')
+ srcDir.mkdirs()
+ new File(srcDir, 'test.gmd').text = '# Greetings\n'
+ new File(testProjectDir, 'settings.gradle').text = '''
+ pluginManagement {
+ repositories { mavenCentral() }
+ plugins { id 'se.alipsa.gmd.gmd-gradle-plugin' version '1.0.0' }
+ }
+ dependencyResolutionManagement {
+ repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
+ repositories { mavenCentral() }
+ }
+ '''.stripIndent()
+ new File(testProjectDir, 'build.gradle').text = '''
+ plugins {
+ id('base')
+ id 'se.alipsa.gmd.gmd-gradle-plugin'
+ }
+ gmdPlugin {
+ sourceDir = 'src/test/gmd'
+ targetDir = 'build/target'
+ outputType = 'html'
+ gmdVersion = '3.1.0' // Keep the standalone TestKit test independent of unpublished snapshots.
+ runTaskBefore = 'build'
+ }
+ '''.stripIndent()
+
+ def result = GradleRunner.create().withProjectDir(testProjectDir)
+ .withArguments('processGmd').withPluginClasspath().forwardOutput().build()
+
+ Assertions.assertEquals(SUCCESS, result.task(':processGmd').outcome, result.output)
+ Assertions.assertTrue(new File(testProjectDir, 'build/target/test.html').exists())
+ } finally {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ }
+ }
+
+ @Test
+ void preferSettingsRepositoriesAreNotMutatedByTheProject() {
+ // Copilot review on PR #11: under PREFER_SETTINGS, project.repositories.mavenCentral()
+ // is not rejected with InvalidUserCodeException the way it is under
+ // FAIL_ON_PROJECT_REPOS - Gradle silently accepts (and deprecates) it instead. Verify
+ // the plugin detects PREFER_SETTINGS up front and never adds to project.repositories,
+ // so the build carries no deprecation warning and the settings repositories are what
+ // actually resolve gmd-core. ProjectBuilder cannot model dependencyResolutionManagement,
+ // so this is a TestKit test.
+ File testProjectDir = new File('build/gmdPreferSettingsReposTest')
+ try {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ File srcDir = new File(testProjectDir, 'src/test/gmd')
+ srcDir.mkdirs()
+ new File(srcDir, 'test.gmd').text = '# Greetings\n'
+
+ new File(testProjectDir, 'settings.gradle').text = '''
+ pluginManagement {
+ repositories { mavenCentral() }
+ plugins { id 'se.alipsa.gmd.gmd-gradle-plugin' version '1.0.0' }
+ }
+ dependencyResolutionManagement {
+ repositoriesMode = RepositoriesMode.PREFER_SETTINGS
+ repositories { mavenCentral() }
+ }
+ '''.stripIndent()
+
+ // Deliberately no project-level repositories block.
+ new File(testProjectDir, 'build.gradle').text = '''
+ plugins {
+ id('base')
+ id 'se.alipsa.gmd.gmd-gradle-plugin'
+ }
+ gmdPlugin {
+ sourceDir = 'src/test/gmd'
+ targetDir = 'build/target'
+ outputType = 'html'
+ gmdVersion = '3.1.0'
+ runTaskBefore = 'build'
+ }
+ tasks.register('printRepoCount') {
+ doLast { println "repoCount=" + project.repositories.size() }
+ }
+ '''.stripIndent()
+
+ def result = GradleRunner.create()
+ .withProjectDir(testProjectDir)
+ .withArguments('processGmd', 'printRepoCount')
+ .withPluginClasspath()
+ .forwardOutput()
+ .build()
+
+ Assertions.assertEquals(SUCCESS, result.task(':processGmd').outcome,
+ "PREFER_SETTINGS must not break the plugin:\n${result.output}")
+ Assertions.assertTrue(new File(testProjectDir, 'build/target/test.html').exists(),
+ 'The processor runtime must still resolve through the settings repositories')
+ Assertions.assertTrue(result.output.contains('repoCount=0'),
+ "The plugin must not add to project.repositories under PREFER_SETTINGS:\n${result.output}")
+ Assertions.assertFalse(
+ result.output.contains('prefer settings repositories over project repositories'),
+ "The plugin must not trigger Gradle's PREFER_SETTINGS deprecation warning:\n${result.output}")
+ } finally {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ }
+ }
+
+ @Test
+ void aSourceDirWithoutGmdFilesDoesNotResolveTheRuntime() {
+ File testProjectDir = new File('build/gmdNoGmdFilesTest')
+ try {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ File srcDir = new File(testProjectDir, 'src/test/gmd')
+ srcDir.mkdirs()
+ new File(srcDir, 'readme.txt').text = 'not a gmd file'
+ new File(testProjectDir, 'settings.gradle').text = '''
+ pluginManagement {
+ repositories { mavenCentral() }
+ plugins { id 'se.alipsa.gmd.gmd-gradle-plugin' version '1.0.0' }
+ }
+ '''.stripIndent()
+ new File(testProjectDir, 'build.gradle').text = '''
+ plugins {
+ id('base')
+ id 'se.alipsa.gmd.gmd-gradle-plugin'
+ }
+ repositories { mavenCentral() }
+ gmdPlugin {
+ sourceDir = 'src/test/gmd'
+ targetDir = 'build/target'
+ outputType = 'html'
+ gmdVersion = 'not-a-real-gmd-version'
+ runTaskBefore = 'build'
+ }
+ '''.stripIndent()
+
+ def helpResult = GradleRunner.create().withProjectDir(testProjectDir)
+ .withArguments('help', '--configuration-cache', '--offline')
+ .withPluginClasspath().forwardOutput().build()
+
+ Assertions.assertEquals(SUCCESS, helpResult.task(':help').outcome,
+ "Configuration-cache storage must not resolve the unavailable runtime:\n${helpResult.output}")
+
+ def result = GradleRunner.create().withProjectDir(testProjectDir)
+ .withArguments('processGmd', '--configuration-cache', '--offline')
+ .withPluginClasspath().forwardOutput().build()
+
+ Assertions.assertTrue(result.output.contains('No gmd files found in'), result.output)
+ Assertions.assertFalse(result.output.contains('Gmd files processed and written to'), result.output)
+ Assertions.assertFalse(new File(testProjectDir, 'build/target').exists(), result.output)
+ } finally {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ }
+ }
+
+ @Test
+ void directlyRegisteredTaskInvalidatesWhenItsRuntimeClasspathChanges() {
+ // End-to-end proof of the fix for silent stale up-to-date checks on a
+ // directly registered task: its actual runtime classpath must invalidate
+ // the task when a dependency changes. The swap
+ // changes only log4j-core so the runnable gmd-core:3.1.0 stays in place
+ // (pre-3.1.0 gmd-core releases carry a JavaFX dependency graph).
+ File testProjectDir = new File('build/gmdDirectTaskIdentityTest')
+ try {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ File srcDir = new File(testProjectDir, 'src/test/gmd')
+ srcDir.mkdirs()
+ new File(srcDir, 'test.gmd').text = '# Greetings\n'
+ // Own settings file: the test project lives under this build's directory,
+ // so without one Gradle would adopt the plugin build's settings.gradle.
+ new File(testProjectDir, 'settings.gradle').text = "rootProject.name = 'gmd-direct-task-identity'\n"
+ File buildFile = new File(testProjectDir, 'build.gradle')
+ buildFile.text = '''
+ import se.alipsa.gmd.gradle.ProcessGmdTask
+
+ plugins {
+ id('base')
+ // Applied for its classes only; the task below is registered directly,
+ // bypassing GmdGradlePlugin's processGmd wiring.
+ id 'se.alipsa.gmd.gmd-gradle-plugin'
+ }
+
+ repositories { mavenCentral() }
+
+ def log4jVersion = '2.26.1'
+ // Mirror the runtime GmdGradlePlugin assembles: gmd-core's published POM
+ // does not bring Groovy transitively.
+ def gmdRuntime = configurations.create('directGmdRuntime')
+ gmdRuntime.canBeConsumed = false
+ gmdRuntime.canBeResolved = true
+ gmdRuntime.dependencies.addAll([
+ dependencies.create('se.alipsa.gmd:gmd-core:3.1.0'),
+ dependencies.create('org.apache.groovy:groovy:5.1.3'),
+ dependencies.create('org.apache.groovy:groovy-templates:5.1.3'),
+ dependencies.create('org.apache.groovy:groovy-jsr223:5.1.3'),
+ dependencies.create('org.apache.ivy:ivy:2.6.0'),
+ dependencies.create("org.apache.logging.log4j:log4j-core:${log4jVersion}")
+ ])
+
+ tasks.register('directGmd', ProcessGmdTask) {
+ sourceDir = file('src/test/gmd')
+ targetDir = file('build/target')
+ outputType = 'html'
+ // Legacy direct registrations used this property before
+ // runtimeClasspath was introduced.
+ classpath.from(gmdRuntime)
+ }
+ '''.stripIndent()
+
+ def result = GradleRunner.create()
+ .withProjectDir(testProjectDir)
+ .withArguments('directGmd')
+ .withPluginClasspath()
+ .forwardOutput()
+ .build()
+ Assertions.assertEquals(SUCCESS, result.task(':directGmd').outcome, result.output)
+ Assertions.assertTrue(new File(testProjectDir, 'build/target/test.html').exists())
+
+ def cachedResult = GradleRunner.create()
+ .withProjectDir(testProjectDir)
+ .withArguments('directGmd')
+ .withPluginClasspath()
+ .forwardOutput()
+ .build()
+ Assertions.assertEquals(org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE,
+ cachedResult.task(':directGmd').outcome,
+ 'An unchanged runtime must keep the task up-to-date:\n' + cachedResult.output)
+
+ buildFile.text = buildFile.text.replace(
+ "def log4jVersion = '2.26.1'", "def log4jVersion = '2.25.1'")
+ def changedRuntimeResult = GradleRunner.create()
+ .withProjectDir(testProjectDir)
+ .withArguments('directGmd')
+ .withPluginClasspath()
+ .forwardOutput()
+ .build()
+ Assertions.assertEquals(SUCCESS, changedRuntimeResult.task(':directGmd').outcome,
+ 'Swapping the runtime classpath must rerun a directly registered task:\n'
+ + changedRuntimeResult.output)
+ } finally {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ }
+ }
+
@Test
void testPlugin() {
File targetDir = null
@@ -134,7 +440,8 @@ class GmdGradlePluginTest {
targetDir = 'build/target'
outputType = 'html'
gmdVersion = '3.1.0' // Keep the standalone TestKit test independent of unpublished snapshots.
- runTaskBefore = 'build' // we dont have tests so specify the task to not get a warning
+ log4jVersion = '2.26.1' // set explicitly so the invalidation check below can swap it
+ runTaskBefore = 'build' // we dont have tests so specify the task to not get a warning
}
""".stripIndent()
@@ -167,6 +474,18 @@ class GmdGradlePluginTest {
Assertions.assertEquals(org.gradle.testkit.runner.TaskOutcome.UP_TO_DATE, cachedResult.task(":processGmd").outcome,
'A custom target must retain Gradle up-to-date checks')
+ // This proves end-to-end that the resolved classpath invalidates the task
+ // when the plugin changes a runtime dependency version.
+ buildFile.text = buildFile.text.replace("log4jVersion = '2.26.1'", "log4jVersion = '2.25.1'")
+ def changedVersionResult = GradleRunner.create()
+ .withProjectDir(testProjectDir)
+ .withArguments('processGmd', '--configuration-cache', '--parallel')
+ .withPluginClasspath()
+ .forwardOutput()
+ .build()
+ Assertions.assertEquals(SUCCESS, changedVersionResult.task(':processGmd').outcome,
+ 'Changing a runtime dependency version must rerun processGmd')
+
// the directory differs on a mac even though they point to the same place so cannot include
def expected = "Gmd files processed and written to $targetDir.canonicalPath".toString()
Assertions.assertTrue(result.output.contains(expected), "expected \n$expected, but output was ${result.output}")
diff --git a/gmd-maven-plugin/readme.md b/gmd-maven-plugin/readme.md
index dc83dfc..af739e8 100644
--- a/gmd-maven-plugin/readme.md
+++ b/gmd-maven-plugin/readme.md
@@ -9,7 +9,7 @@ Usage is as follows:
se.alipsa.gmd
gmd-maven-plugin
- 3.1.0
+ 3.2.0
processGmd
src/test/gmd
@@ -44,7 +44,7 @@ If you don't want to run the plugin explicitly, you can add it to an existing li
se.alipsa.gmd
gmd-maven-plugin
- 3.1.0
+ 3.2.0
compile
@@ -75,14 +75,14 @@ You can override the versions of dependencies used by the plugin by specifying t
se.alipsa.gmd
gmd-maven-plugin
- 3.1.0
+ 3.2.0
src/test/gmd
target/gmd
html
- 5.0.3
+ 5.1.3
2.26.1
- 3.0.1
+ 3.2.0
diff --git a/gmd-maven-plugin/src/main/java/se/alipsa/gmd/maven/GmdMavenPlugin.java b/gmd-maven-plugin/src/main/java/se/alipsa/gmd/maven/GmdMavenPlugin.java
index 79bee30..936f5e0 100644
--- a/gmd-maven-plugin/src/main/java/se/alipsa/gmd/maven/GmdMavenPlugin.java
+++ b/gmd-maven-plugin/src/main/java/se/alipsa/gmd/maven/GmdMavenPlugin.java
@@ -7,6 +7,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
+import java.util.concurrent.TimeUnit;
import javax.inject.Inject;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.plugin.AbstractMojo;
@@ -62,6 +63,12 @@ public class GmdMavenPlugin extends AbstractMojo {
@Inject
private RepositorySystem repositorySystem;
+ /**
+ * Bounded wait after {@link Process#destroyForcibly()} so cancelling the build
+ * cannot hang forever on a forked JVM that survives the forced kill.
+ */
+ private static final int FORCED_TERMINATION_TIMEOUT_SECONDS = 30;
+
/**
* Default constructor.
*/
@@ -182,6 +189,7 @@ public void execute() throws MojoExecutionException, MojoFailureException {
// command-line length limit on Windows.
File argFile = createArgFile(classpath.toString(), srcDir.getCanonicalPath(),
outputDirectory.getCanonicalPath(), normalizedOutputType);
+ boolean interrupted = false;
try {
List command = new ArrayList<>();
command.add(getJavaExecutable());
@@ -190,7 +198,21 @@ public void execute() throws MojoExecutionException, MojoFailureException {
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.inheritIO();
Process process = processBuilder.start();
- int exitCode = process.waitFor();
+ int exitCode;
+ try {
+ exitCode = process.waitFor();
+ } catch (InterruptedException e) {
+ process.destroy();
+ try {
+ if (!process.waitFor(5, TimeUnit.SECONDS)) {
+ warnOnIncompleteForcedTermination(process);
+ }
+ } catch (InterruptedException swallowed) {
+ warnOnIncompleteForcedTermination(process);
+ }
+ interrupted = true;
+ throw new MojoExecutionException("Interrupted while waiting for the GMD processor", e);
+ }
if (exitCode != 0) {
throw new MojoFailureException("GmdProcessor exited with code " + exitCode);
@@ -205,6 +227,9 @@ public void execute() throws MojoExecutionException, MojoFailureException {
getLog().warn("Could not delete temporary classpath argfile " + argFile.getAbsolutePath()
+ ": " + e.getMessage());
}
+ if (interrupted) {
+ Thread.currentThread().interrupt();
+ }
}
} else {
// Fall back to using GmdProcessor directly with bundled dependencies
@@ -219,6 +244,8 @@ public void execute() throws MojoExecutionException, MojoFailureException {
} else {
getLog().warn(td.getCanonicalPath() + " should exists but does not, something is probably wrong");
}
+ } catch (MojoExecutionException | MojoFailureException e) {
+ throw e;
} catch (DependencyResolutionException e) {
throw new MojoExecutionException("Failed to resolve dependencies", e);
} catch (Exception e) {
@@ -230,6 +257,50 @@ public void execute() throws MojoExecutionException, MojoFailureException {
}
}
+ /**
+ * {@link Process#destroyForcibly()} is asynchronous, so a caller that returns right
+ * after calling it can race the forked JVM's actual exit. Wait for a bounded
+ * period so cancellation remains able to return if the forked JVM survives the
+ * forced termination. A second interrupt is restored before returning.
+ */
+ private static ForcedTerminationAwait destroyForciblyAndAwaitTermination(Process process) {
+ process.destroyForcibly();
+ try {
+ return process.waitFor(FORCED_TERMINATION_TIMEOUT_SECONDS, TimeUnit.SECONDS)
+ ? ForcedTerminationAwait.TERMINATED : ForcedTerminationAwait.TIMED_OUT;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return ForcedTerminationAwait.INTERRUPTED;
+ }
+ }
+
+ /**
+ * Both callers of {@link #destroyForciblyAndAwaitTermination(Process)} report the
+ * outcome, but a timeout and a second interrupt need different diagnostics: the
+ * process may have exited microseconds after the interrupt, so claiming it survived
+ * the forced kill would be misleading.
+ */
+ private void warnOnIncompleteForcedTermination(Process process) {
+ switch (destroyForciblyAndAwaitTermination(process)) {
+ case TIMED_OUT ->
+ getLog().warn("GMD processor did not terminate within "
+ + FORCED_TERMINATION_TIMEOUT_SECONDS + " seconds after forced termination");
+ case INTERRUPTED ->
+ getLog().warn("Interrupted again while awaiting the GMD processor's termination "
+ + "after forced termination; its exit status is unknown");
+ case TERMINATED -> {
+ // Nothing to report.
+ }
+ }
+ }
+
+ /** Outcome of awaiting a forcibly destroyed process, so callers can report it accurately. */
+ private enum ForcedTerminationAwait {
+ TERMINATED,
+ TIMED_OUT,
+ INTERRUPTED
+ }
+
private List resolveDependencies() throws DependencyResolutionException {
RepositorySystemSession repoSession = session.getRepositorySession();
diff --git a/gmd-maven-plugin/src/test/java/test/alipsa/gmd/maven/GmdMavenPluginTest.java b/gmd-maven-plugin/src/test/java/test/alipsa/gmd/maven/GmdMavenPluginTest.java
index 799d5ad..2055efd 100644
--- a/gmd-maven-plugin/src/test/java/test/alipsa/gmd/maven/GmdMavenPluginTest.java
+++ b/gmd-maven-plugin/src/test/java/test/alipsa/gmd/maven/GmdMavenPluginTest.java
@@ -12,10 +12,12 @@
import java.io.IOException;
import java.io.UncheckedIOException;
import java.lang.reflect.Field;
+import java.lang.reflect.Method;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
+import java.util.concurrent.TimeUnit;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.RepositorySystemSession;
@@ -133,7 +135,65 @@ public void testGmdMavenPluginReportsForkFailure() throws Exception {
setField(plugin, "outputType", "html");
MojoFailureException exception = assertThrows(MojoFailureException.class, plugin::execute);
- assertTrue(exception.getMessage().contains("GmdProcessor exited with code"));
+ assertTrue(exception.getMessage().startsWith("GmdProcessor exited with code"),
+ "The plugin's own failure must not be rewrapped: " + exception.getMessage());
+ assertFalse(exception.getMessage().contains("Failed to process gmd files in"),
+ "The plugin's own failure must not be rewrapped: " + exception.getMessage());
+ }
+
+ @Test
+ public void destroyForciblyAndAwaitTerminationWaitsForRealTermination() throws Exception {
+ // Copilot review on PR #11: Process.destroyForcibly() is asynchronous, so returning
+ // right after calling it can race the forked JVM's actual exit. Verify the helper
+ // actually blocks on waitFor() after the forced kill instead of just firing it.
+ Process process = Mockito.mock(Process.class);
+ when(process.waitFor(forcedTerminationTimeoutSeconds(), TimeUnit.SECONDS)).thenReturn(true);
+
+ assertEquals("TERMINATED", invokeDestroyForciblyAndAwaitTermination(process).name());
+
+ Mockito.verify(process).destroyForcibly();
+ Mockito.verify(process).waitFor(forcedTerminationTimeoutSeconds(), TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void destroyForciblyAndAwaitTerminationRestoresASecondInterruptWithoutWaitingForever() throws Exception {
+ // A second interrupt must remain a cancellation escape hatch rather than being
+ // swallowed by an unbounded retry loop.
+ Process process = Mockito.mock(Process.class);
+ when(process.waitFor(forcedTerminationTimeoutSeconds(), TimeUnit.SECONDS))
+ .thenThrow(new InterruptedException("interrupted while awaiting forced termination"));
+
+ Thread.interrupted(); // clear any stray flag left over from another test
+ assertEquals("INTERRUPTED", invokeDestroyForciblyAndAwaitTermination(process).name());
+
+ assertTrue(Thread.interrupted(),
+ "A second interrupt during the forced-kill wait must be restored on the caller's thread");
+ Mockito.verify(process, Mockito.times(1)).destroyForcibly();
+ Mockito.verify(process, Mockito.times(1)).waitFor(forcedTerminationTimeoutSeconds(), TimeUnit.SECONDS);
+ }
+
+ @Test
+ public void destroyForciblyAndAwaitTerminationReturnsAfterTheBoundedWait() throws Exception {
+ Process process = Mockito.mock(Process.class);
+ when(process.waitFor(forcedTerminationTimeoutSeconds(), TimeUnit.SECONDS)).thenReturn(false);
+
+ assertEquals("TIMED_OUT", invokeDestroyForciblyAndAwaitTermination(process).name());
+
+ Mockito.verify(process).destroyForcibly();
+ Mockito.verify(process).waitFor(forcedTerminationTimeoutSeconds(), TimeUnit.SECONDS);
+ }
+
+ private static long forcedTerminationTimeoutSeconds() throws Exception {
+ Field timeout = GmdMavenPlugin.class.getDeclaredField("FORCED_TERMINATION_TIMEOUT_SECONDS");
+ timeout.setAccessible(true);
+ return timeout.getLong(null);
+ }
+
+ @SuppressWarnings("rawtypes")
+ private static Enum invokeDestroyForciblyAndAwaitTermination(Process process) throws Exception {
+ Method method = GmdMavenPlugin.class.getDeclaredMethod("destroyForciblyAndAwaitTermination", Process.class);
+ method.setAccessible(true);
+ return (Enum) method.invoke(null, process);
}
private static void deleteDirectory(File directory) throws IOException {
diff --git a/req/v3.2.0-fixes.md b/req/v3.2.0-fixes.md
new file mode 100644
index 0000000..6c1bf6c
--- /dev/null
+++ b/req/v3.2.0-fixes.md
@@ -0,0 +1,1746 @@
+# GMD 3.2.0 Review Fixes Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Clear the eight findings from the 3.2.0 code review — two template-engine indentation bugs, a code-block output bug, a GgChart dimension asymmetry, a Gradle repository side effect, a misleading Gradle log message, a swallowed Maven interrupt, and a CLI path bug.
+
+**Architecture:** Seven of the eight fixes are local and independent. The two template-engine fixes (Tasks 1 and 2) touch the same loop in `GmdTemplateEngine.processCodeBlocks` and must land in order. Both rest on the same new idea: **indentation is measured relative to the document's own base indent**, not absolutely. That is what lets GMD keep its existing leniency (a `.gmd` document written inside an indented Groovy triple-quoted string still works) while still honouring CommonMark's rule that a block indented four spaces *beyond the surrounding text* is literal.
+
+**Tech Stack:** Java 21, Groovy 5.1.3, Maven 3.9.9+, Gradle 9.7.1, JUnit Jupiter 6.1.3, Jsoup 1.23.2, Mockito (maven-plugin tests), Gradle TestKit + ProjectBuilder (gradle-plugin tests).
+
+**Spec:** This plan implements the findings recorded in the 3.2.0 code review; each task restates its finding in full, so no separate spec document is needed.
+
+## Global Constraints
+
+- Java 21 exactly (`maven.compiler.release=21`, enforced by maven-enforcer-plugin).
+- The version lives only in `/pom.xml` `` (currently `3.2.0-SNAPSHOT`). Never hardcode a version anywhere else.
+- **Baseline, measured on this checkout before any task:** `highlightjs-jvm` 15, `gmd-core` 67, `gmd-maven-plugin` 3 — 85 total via `mvn -o test`. The Gradle plugin builds separately (`cd gmd-gradle-plugin && ./gradlew test`).
+- **Every task ends with the full suite green.** No task may leave a known-failing test for a later task to fix.
+- Commit after every task. Conventional commit prefixes (`fix:`, `docs:`, `refactor:`).
+- Do not reformat or restructure files beyond what a task asks for. These are surgical fixes to a released codebase.
+- Three existing tests assert the *current* (buggy or asymmetric) behaviour and are deliberately rewritten by this plan: `GmdTemplateEngineTest.testNoEcho` (Task 1), `GmdTemplateEngineTest.indentedCodeBlocksAreAKnownGap` (Task 2), `GmdTest.testGgChart` (Task 4). No other existing test may be weakened or deleted. If a task makes an unrelated test fail, stop and report rather than editing that test.
+
+## Decisions already taken
+
+These were settled before planning; do not relitigate them.
+
+1. **Indented fences keep executing.** A ` ```{groovy} ` fence indented four or more spaces still runs. GMD's leniency here is load-bearing: `GmdTemplateEngineTest.testCodeOnly` and `testNoEcho` embed twelve-space-indented documents with no `stripIndent()`, and real users write `.gmd` inside indented Groovy strings. Task 1 therefore fixes only the *indent mismatch* the current code produces, not the execution itself.
+2. **Known limitation, accepted:** because of (1), a `.gmd` document that tries to *show* GMD syntax by indenting it will still execute it. The documented workaround is a fenced block with a longer fence, which Task 1 leaves working. Task 1 records this in the method Javadoc.
+3. **Gradle:** check for an existing Maven Central repository before calling `mavenCentral()`. The plugin still adds Central when the consumer has none.
+4. **GgChart:** align on *always honouring the dimensions configured on the chart*, so only an explicit `width`/`height` argument overrides them.
+5. **Echoed fences stay at column 0.** Task 1 makes the fence and its body agree by dedenting the body, not by re-emitting the fence at its original indent. Column 0 is always a valid fence position, so this can never turn the emitted ` ```groovy ` line into an indented code block. The cost, accepted here, is that a Groovy block written inside a list item is *not* kept inside that item: the emitted block breaks the list and the following item restarts as a new ``. That is the behaviour today as well, so Task 1 neither fixes nor worsens it. Preserving list nesting would need the fence re-emitted at `min(indent, 3)`, the body dedented by the remainder, and the captured output indented to match - a wider change, out of scope for 3.2.0.
+
+## Review Focus
+
+Input classes these fixes imply but that no task's happy path exercises. Each has a test assigned to the task that owns the code.
+
+1. **Tab-indented lines.** CommonMark counts a tab as four columns; the new indent helpers count spaces only, so a tab-indented literal block would still expand inline variables. → test in Task 2, pinning the documented space-only limitation.
+2. **A uniformly indented document containing a genuinely indented literal block** (base indent + 4). Relative-indent classification must still protect it, or Task 2 only works for documents at column 0. → test in Task 2.
+3. **CRLF input through the new indent helpers.** `readLines()` strips `\r`, but a whitespace-plus-`\r` line must still count as blank for paragraph tracking. → test in Task 2.
+4. **A GgChart whose width/height were never set** (library defaults) printed with no arguments — must render at the library default, not 0×0 or NPE. → test in Task 4.
+5. **A Gradle consumer that declared Central under its other host** (`repo1.maven.org` rather than `repo.maven.apache.org`), or with a trailing-slash difference. The URL check must recognise it, or Central is added twice. → test in Task 5.
+6. **The first non-blank line defines the document base.** A stray column-0 line *after* an
+ otherwise uniformly indented first block (an HTML block, a table row, a hard-wrapped line)
+ must not collapse the base to 0 and silently stop later prose expanding inline variables.
+ Conversely, if that column-0 line is the first non-blank line and the body sits eight spaces
+ deeper, the body is classified as indented literal content and does not expand. That is the
+ accepted invariant: authors must put the first non-blank line at the document's own indent.
+ → the Task 2 test covers the later-stray-line case; the processCodeBlocks Javadoc records
+ both directions.
+
+## File Structure
+
+**Modified:**
+- `gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy` — Tasks 1, 2, 3. The state machine in `processCodeBlocks` plus three new private static helpers.
+- `gmd-core/src/main/groovy/se/alipsa/gmd/core/Printer.groovy` — Task 4. Replaces the two `GgChart` default-argument methods with explicit overloads.
+- `gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdCommandLine.groovy` — Task 8.
+- `gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/GmdGradlePlugin.groovy` — Task 5.
+- `gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/ProcessGmdTask.groovy` — Task 6.
+- `gmd-maven-plugin/src/main/java/se/alipsa/gmd/maven/GmdMavenPlugin.java` — Task 7.
+- `README.md` — Task 4 (GgChart dimension wording).
+- `gmd-gradle-plugin/readme.md` — Task 5 (repository behaviour).
+- `gmd-core/release.md` — Tasks 1, 2, 3, 4 (visible-behaviour entries in the live v3.2.0, unreleased section).
+- `gmd-gradle-plugin/release.md` — Task 5 (the source-breaking `hasRepository` removal).
+
+**Test files modified:**
+- `gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy` — Tasks 1, 2, 3.
+- `gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTest.groovy` — Tasks 4, 8.
+- `gmd-gradle-plugin/src/test/groovy/test/alipsa/gmd/gradle/GmdGradlePluginTest.groovy` — Tasks 5, 6.
+- `gmd-maven-plugin/src/test/java/test/alipsa/gmd/maven/GmdMavenPluginTest.java` — Task 7.
+
+---
+
+Points worth your attention while reading:
+
+1. ~~Task 1, Step 8 rewrites testNoEcho's expected output.~~ **Settled — see decision (5).** Dedenting the body (rather than re-emitting the fence at its original indent) is the chosen approach, so a twelve-space-indented document emits its code body at column 0 while its prose keeps its indent. List nesting around Groovy blocks stays broken, as it is today; Step 6 records that in the Javadoc.
+2. Task 2, Step 6 is the largest single edit — the classification block replacing lines 114-123. The relativeIndent >= 4 && previousLineWasParagraph case deliberately falls through leaving inIndentedCode unchanged; that's CommonMark's lazy-continuation rule and it's load-bearing for anIndentedLineContinuingAParagraphIsStillExpanded.
+3. Task 4 drops the width-only `print(GgChart, double)` arity that Groovy's default arguments were generating. Nothing in the repo uses it. **Decision: remove it, do not shim it.** A shim could keep such a call compiling but not behaving: the generated arity meant "width w, height `DEFAULT_CHART_HEIGHT`", whereas after Task 4 the only sensible delegation is "width w, height from the chart" — a silent behaviour change for exactly the callers a shim claims to protect. Note the removal in the release notes instead. **The `double` parameter type is not part of this and must not be changed:** it is verified to accept every Groovy numeric type (`int`, `Long`, `Float`, and `BigDecimal` literals such as `640.5`), dynamically and under `@CompileStatic`. Narrowing it to `int` would reject `BigDecimal`; widening it to `Number` gains nothing and turns a call-site `MissingMethodException` into a late NPE inside `width as int`.
+4. ~~Task 5 still adds Maven Central when the consumer has declared no repository at all.~~ **Settled - Task 5 now does both:** check first, and wrap the add in a `try/catch (InvalidUserCodeException)`. Measured on Gradle 9.7.1: under `FAIL_ON_PROJECT_REPOS` the settings repositories are not visible in `project.repositories` (size 0), so the check alone does not save us; and swallowing the rejection is safe because `detachedConfiguration` still resolves through the settings repositories.
+5. ~~Unverified assumption in Task 5: `it.setUrl('...')` in the new ProjectBuilder tests.~~ **Verified:** Gradle 9.7.1 `MavenArtifactRepository` still declares both `void setUrl(Object)` and `URI getUrl()`, so the test form and the `url?.host` check in `hasMavenCentral` both compile.
+
+### Review round 2: findings against this plan, resolved
+
+`req/v3.2.0-review-findings.md` reviewed this plan; its open findings are folded in as follows.
+
+- **F1 (Task 2, Steps 1-4):** subsumed by review round 3 — `baseIndent` no longer scans
+ for a minimum at all. It anchors on the document's first non-blank line, so no line inside
+ a code block (or anywhere later in the document) can drag the base indent to 0 and
+ reclassify the whole document. The `baseIndentIgnoresCodeBlockBodies` guard is kept to pin
+ that a column-0 line inside a block must not affect classification.
+- **F2 (Task 2, Steps 1, 4-6):** resolved with option (b) — minimal list tracking. A list-item
+ line records its content column; continuations indented less than contentColumn + 4 are
+ prose, not an indented code block. New test:
+ `aListItemContinuationStillExpands`.
+- **F3 (Task 1, Steps 1, 5):** the dedent applies to the *evaluated source* as well as the echo.
+ Stated explicitly in Step 5 and pinned by `dedentAppliesToTheEvaluatedSource`.
+- **F4 (Tasks 6, 8):** stale line references corrected against the current files.
+- **F5 (Task 7, Step 3):** the fork is destroyed — forcibly after a 5-second grace period — and
+ the interrupt flag is restored *after* the argfile cleanup, the conventional order.
+- **F6 (Task 7, Step 1):** the two no-double-wrap assertions are added to the existing
+ `testGmdMavenPluginReportsForkFailure` instead of duplicating its ~25-line mock setup.
+- **F7 (Task 4):** decided explicitly — the width-only `print(GgChart, double)` /
+ `println(GgChart, double)` arity is **removed**, not shimmed, because a shim would preserve
+ compilation while silently changing the height that call resolves to. Recorded in the release
+ note. The `double` parameter type itself is unchanged and is not a defect; see attention
+ point 3.
+- **F8 (Task 2, Step 6):** the list context is now closed when a non-blank, non-marker line sits
+ left of the item's content column (`relativeIndent < listContentColumn`), which subsumes the old
+ `relativeIndent < listItemIndent` reset — for a top-level list `listItemIndent == 0`, so the old
+ reset was unreachable and the list never closed. Indented code blocks after an ended list lost
+ their protection. New test: `indentedCodeAfterAListIsLeftAlone`.
+
+Maven test totals change accordingly: 103 (85 baseline, +19 new, -1 deleted `indentedCodeBlocksAreAKnownGap`).
+The Gradle totals are unchanged: 4 baseline + 6 new = 10.
+
+### Review round 3: findings against this plan, resolved
+
+- **F9 (Task 2, Step 3, BLOCKER):** `baseIndent` counting only prose lines made
+ `indentedCodeBlocksAreLeftAlone`, `aBlankLineDoesNotEndAnIndentedCodeBlock` and
+ `crlfLinesAreClassifiedCorrectly` unsatisfiable — in each, the only prose lines are the
+ indented literal block itself, so the base became 4, relative indent 0, and the block was
+ expanded even after the fix. The helper now anchors on the first non-blank line of the
+ document. That subsumes both the fence-delimiter counting and F1's body-skip pre-pass: the
+ first line is never inside a block, and nothing later in the document is consulted.
+ Verified against every affected fixture: `indentedCodeBlocksAreLeftAlone` → 0,
+ `aBlankLineDoesNotEndAnIndentedCodeBlock` → 0, `crlfLinesAreClassifiedCorrectly` → 0,
+ `testInlineVars` → 8, `indentIsRelativeToTheDocumentBaseIndent` → 8,
+ `baseIndentIgnoresCodeBlockBodies` → 8, `testNoEcho`/`testCodeOnly` → 12,
+ `indentedCodeAfterAListIsLeftAlone` → 0. Accepted limitation: the first non-blank line
+ establishes the base in both directions. If it sits deeper than later prose, that prose
+ expands (today's behaviour); if it is shallower than a body indented four or more spaces
+ beyond it, that body is classified as literal and inline expansion is suppressed. Authors
+ must therefore put the first non-blank line at the document's own indent. The Review Focus 6
+ test, `aStrayColumnZeroLineDoesNotCollapseTheBaseIndent`, covers only a stray shallower line
+ that appears later; the processCodeBlocks Javadoc records the complete invariant.
+- **F10 (release notes, MEDIUM):** `gmd-core/release.md` (which has a live `## v3.2.0,
+ unreleased` section) and `gmd-gradle-plugin/release.md` now have explicit steps. Four
+ visible-behaviour entries are recorded — the width-only `print/println(GgChart, double)`
+ arity removal (source-breaking), Task 1's dedent of echoed and evaluated code-block
+ bodies, Task 2's indented-literal-block inline-expansion change, and Task 3's newline after
+ unterminated block output — plus the source-breaking removal of
+ `GmdGradlePlugin.hasRepository`.
+- **F11 (Task 2, Step 6, LOW):** `listItemIndent` is dropped. After the F8 close rule, the
+ continuation check is only reached when `relativeIndent >= listContentColumn`, and
+ `listItemIndent < listContentColumn` always holds (the content column is past the marker),
+ so the `relativeIndent >= listItemIndent` clause could never be false. Retaining it read as
+ live protection that wasn't.
+
+### Task 1: Dedent Groovy code-block bodies by the fence indent
+
+**Finding #1.** `fenceCandidate()` correctly limits fence indentation to the three spaces CommonMark allows, but lines 56-58 bypass it with `line.trim()`, so a fence at any indent opens a block. The echoed result then replaces the fence line with `` ```groovy `` at column 0 while the body keeps its original indent:
+
+```
+Input: "Example:\n\n ```{groovy}\n out.println('EXECUTED')\n ```\n\nend\n"
+Current: "Example:\n\n```groovy\n out.println('EXECUTED')\n```\nEXECUTED\n\nend\n"
+Wanted: "Example:\n\n```groovy\nout.println('EXECUTED')\n```\nEXECUTED\n\nend\n"
+```
+
+Per decision (1) the block still executes; this task only realigns the body with the fence that wraps it.
+
+**Files:**
+- Modify: `gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy:46-52` (state), `:65-73` (fence open), `:90-92` (body append), `:168-175` (helpers)
+- Test: `gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy`
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: two private static helpers that Task 2 also uses —
+ - `private static int leadingSpaces(String line)` → count of leading `' '` characters.
+ - `private static String dedent(String line, int width)` → the line with up to `width` leading `' '` characters removed.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `GmdTemplateEngineTest`:
+
+```groovy
+ @Test
+ void anIndentedGroovyFenceDedentsItsBody() {
+ String text = '''
+Example:
+
+ ```{groovy}
+ out.println('EXECUTED')
+ ```
+
+end
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains("```groovy\nout.println('EXECUTED')\n```"),
+ "The echoed body must line up with the fence that wraps it:\n$processed")
+ assertTrue(processed.contains('EXECUTED\n'), processed)
+ }
+
+ @Test
+ void dedentOnlyRemovesTheFenceIndent() {
+ // The fence sits at 4; the body sits at 8. The extra 4 spaces are the
+ // author's own formatting and must survive.
+ String text = '''
+ ```{groovy}
+ out.println('deep')
+ ```
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains("```groovy\n out.println('deep')\n```"),
+ "Indentation beyond the fence indent must be preserved:\n$processed")
+ }
+
+ @Test
+ void aColumnZeroFenceLeavesItsBodyAlone() {
+ String text = '''
+```{groovy}
+ out.println('indented body')
+```
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains("```groovy\n out.println('indented body')\n```"),
+ "A fence at column 0 dedents by 0:\n$processed")
+ }
+
+ @Test
+ void dedentAppliesToTheEvaluatedSource() {
+ // The dedent is not cosmetic: codeBlockText feeds both the echo and the
+ // evaluated source. The continuation line sits below the fence indent
+ // (fence at 4, line at 2), so it loses both spaces and the multi-line
+ // string value changes. This pins that accepted behaviour (review F3).
+ String text = '''
+ ```{groovy echo=false}
+ out.print("""a
+ b""")
+ ```
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('a\nb'),
+ "The dedent applies to the evaluated source too:\n$processed")
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `mvn -o -pl gmd-core test -Dtest=GmdTemplateEngineTest`
+Expected: `anIndentedGroovyFenceDedentsItsBody`, `dedentOnlyRemovesTheFenceIndent` and `dedentAppliesToTheEvaluatedSource` FAIL (the body keeps its full indent / the string keeps its spaces). `aColumnZeroFenceLeavesItsBodyAlone` already passes — it is the regression guard.
+
+- [ ] **Step 3: Add the two helpers**
+
+Add next to the existing `fenceCandidate` / `fenceRunLength` helpers in `GmdTemplateEngine.groovy`:
+
+```groovy
+ /** Number of leading space characters. Tabs are not counted; see processCodeBlocks. */
+ private static int leadingSpaces(String line) {
+ int i = 0
+ while (i < line.length() && line.charAt(i) == ' ') {
+ i++
+ }
+ return i
+ }
+
+ /** Removes up to {@code width} leading spaces. Shorter indents are left untouched. */
+ private static String dedent(String line, int width) {
+ int i = 0
+ while (i < width && i < line.length() && line.charAt(i) == ' ') {
+ i++
+ }
+ return line.substring(i)
+ }
+```
+
+- [ ] **Step 4: Record the fence indent when a Groovy block opens**
+
+Declare the new state variable alongside `plainFenceChar` (around line 46):
+
+```groovy
+ Character plainFenceChar = null
+ int plainFenceLength = 0
+ int codeBlockIndent = 0
+```
+
+Then, in the branch that opens a Groovy block (around line 65), capture the indent of the fence line:
+
+```groovy
+ if (plainFenceChar == null && noSpaceLine.startsWith('```{groovy')) {
+ shouldBeProcessed = true
+ codeBlockStart = true
+ codeBlockEnd = false
+ codeBlockIndent = leadingSpaces(line)
+ // echo is a property of one block, not of the entire document.
+ echo = true
+ if (noSpaceLine.toLowerCase().contains("echo=false")) {
+ echo = false;
+ }
+ }
+```
+
+- [ ] **Step 5: Dedent each body line as it is collected**
+
+Change the collection step (around line 90) from `codeBlockText.append(line)` to:
+
+```groovy
+ if (codeBlockStart) {
+ codeBlockText.append(dedent(line, codeBlockIndent)).append('\n')
+ }
+```
+
+The first line collected is the fence itself, which is replaced by `` ```groovy `` before echoing, so dedenting it is harmless.
+
+Note that the dedent applies to the **evaluated source** as well as the echo: `codeBlockText` feeds both the echo (`:97-98`) and `engine.eval` (`:101-106`). A continuation line indented less than the fence loses its spaces, so a multi-line string literal whose continuation sits below the fence indent changes value. That is accepted deliberately and pinned by `dedentAppliesToTheEvaluatedSource` (review F3).
+
+- [ ] **Step 6: Document the accepted limitation**
+
+Append to the `processCodeBlocks` Javadoc, above `@param text`:
+
+```groovy
+ * Indentation is lenient by design: a ```{groovy} fence is recognised at any
+ * indent, so a .gmd document written inside an indented Groovy string still
+ * works. The body is dedented by the fence's own indent so the two line up.
+ * The consequence is that an indented block cannot be used to *show* GMD
+ * syntax without running it - wrap such an example in a longer plain fence
+ * (````) instead. Only spaces are counted; a tab-indented fence is treated
+ * as column 0. The echoed fence is always emitted at column 0, so a Groovy
+ * block written inside a list item does not stay inside that item - the
+ * emitted block ends the list and the next item starts a new one.
+```
+
+- [ ] **Step 7: Run the new tests to verify they pass**
+
+Run: `mvn -o -pl gmd-core test -Dtest=GmdTemplateEngineTest`
+Expected: the four new tests PASS. `testNoEcho` now FAILS — it asserts the old mismatch. That is expected and is fixed in the next step.
+
+- [ ] **Step 8: Update `testNoEcho` to the corrected output**
+
+`testNoEcho` embeds a twelve-space-indented document and asserts that the echoed body keeps its twelve spaces while the fence sits at column 0. Replace the expected value so the body is dedented by twelve:
+
+```groovy
+ @Test
+ void testNoEcho() {
+ String text = """
+ Before
+ ```{groovy}
+ // just some Groovy code
+ def x = 5
+ out.println('Hello World')
+ ```
+ After
+ """
+
+ assertEquals("""
+ Before
+```groovy
+// just some Groovy code
+def x = 5
+out.println('Hello World')
+```
+Hello World
+ After
+ """, GmdTemplateEngine.processCodeBlocks(text))
+ }
+```
+
+Note the two trailing spaces after `out.println('Hello World')` are part of the original fixture; keep them.
+
+- [ ] **Step 9: Run the whole suite**
+
+Run: `mvn -o test`
+Expected: BUILD SUCCESS, 89 tests (85 baseline + 4 new).
+
+- [ ] **Step 10: Record the release note**
+
+Add to the `## v3.2.0, unreleased` section of `gmd-core/release.md`:
+
+```markdown
+- Indented ```{groovy} fences now execute with their body dedented by the fence's own
+ indent, and the echoed fence and body line up. Visible change: a multi-line string
+ literal whose continuation lines sit below the fence indent loses those spaces in the
+ evaluated source as well as the echo.
+```
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy \
+ gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy \
+ gmd-core/release.md
+git commit -m "fix: dedent groovy code block bodies by the fence indent
+
+An indented \`\`\`{groovy} fence emitted its body at the original indent while
+the echoed fence sat at column 0. Dedent the body by the fence's own indent so
+the two line up, and document that indented fences still execute by design.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 2: Stop expanding inline variables inside indented literal blocks
+
+**Finding #2.** The guard at line 115 only tracks *fenced* blocks (`plainFenceChar == null`), not indented ones, so CommonMark's indented code blocks are not protected:
+
+```
+Input: "text\n\n literal `= 1+1 ` here\n\nend\n"
+Current: "text\n\n literal 2 here\n\nend\n"
+Wanted: "text\n\n literal `= 1+1 ` here\n\nend\n"
+```
+
+**Indent is measured relative to the document's base indent** — the indent of the document's first non-blank line (review round 3, finding F9). This is what keeps GMD's existing leniency: `GmdTemplateEngineTest.testInlineVars` embeds an eight-space-indented document with no `stripIndent()` and expects expansion, and every `GmdTest` fixture relies on `stripIndent()` producing base indent 0. Only a block indented four spaces *beyond its neighbours* is literal. Anchoring on the first line — rather than scanning for the smallest prose indent — means a stray column-0 line later in the document (an HTML block, a table row) cannot collapse the base and silently reclassify every other line (Review Focus 6). It is deliberately not a universal safeguard: if the first non-blank line is shallower than the body, that body is measured relative to the shallower base and may be classified as literal. Authors must put the first non-blank line at the document's own indent.
+
+**Files:**
+- Modify: `gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy:46-52` (state), `:114-123` (the non-code-block branch), `:94-113` (reset after a block), plus one new helper
+- Test: `gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy`
+
+**Interfaces:**
+- Consumes: `leadingSpaces(String)` from Task 1.
+- Produces: `private static int baseIndent(List lines)` → the `leadingSpaces` of the
+ first non-blank line, or 0 when the document is blank. Anchoring on the first line means no
+ later line — inside a code block or not — can drag the base to a smaller indent (review F1
+ and review round 3 finding F9).
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `GmdTemplateEngineTest`. The first is the inversion of the existing `indentedCodeBlocksAreAKnownGap`; **delete that test** and add these:
+
+```groovy
+ @Test
+ void indentedCodeBlocksAreLeftAlone() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+ literal `= x ` is not expanded here
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal `= x ` is not expanded here'),
+ "An indented code block is literal:\n$processed")
+ assertFalse(processed.contains('literal 5'), processed)
+ }
+
+ @Test
+ void indentIsRelativeToTheDocumentBaseIndent() {
+ // Review Focus 2: every line is indented by 8; the literal block is at 12.
+ // The blank line matters - without it the indented line would be a lazy
+ // continuation of the paragraph above, which stays prose.
+ String text = """
+ ```{groovy echo=false}
+ x = 5
+ ```
+ expanded `= x ` here
+
+ literal `= x ` here
+ """
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('expanded 5 here'),
+ "A line at the base indent is prose:\n$processed")
+ assertTrue(processed.contains('literal `= x ` here'),
+ "A line 4 beyond the base indent is literal:\n$processed")
+ }
+
+ @Test
+ void anIndentedLineContinuingAParagraphIsStillExpanded() {
+ // CommonMark: an indented line directly after a paragraph line is a lazy
+ // continuation of that paragraph, not a code block.
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+Some prose
+ continues here with `= x `
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('continues here with 5'), processed)
+ }
+
+ @Test
+ void aBlankLineDoesNotEndAnIndentedCodeBlock() {
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+ first `= x ` literal
+
+ second `= x ` literal
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('first `= x ` literal'), processed)
+ assertTrue(processed.contains('second `= x ` literal'), processed)
+ }
+
+ @Test
+ void aTabIndentedLineIsNotTreatedAsACodeBlock() {
+ // Review Focus 1: only spaces are counted. This pins the documented
+ // limitation so a future change to tab handling is a deliberate one.
+ String text = "```{groovy echo=false}\nx = 5\n```\n\tliteral `= x ` here\n"
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal 5 here'),
+ "Known limitation: tabs are not counted as indentation:\n$processed")
+ }
+
+ @Test
+ void crlfLinesAreClassifiedCorrectly() {
+ // Review Focus 3: readLines() strips \\r, and a whitespace-only line must
+ // still count as blank when computing the base indent.
+ String text = "```{groovy echo=false}\r\nx = 5\r\n```\r\n literal `= x ` here\r\n \r\n"
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal `= x ` here'), processed)
+ }
+
+ @Test
+ void baseIndentIgnoresCodeBlockBodies() {
+ // Review F1 / round 3: `line two` sits at column 0 *inside* the Groovy
+ // block. The base indent is anchored on the document's first line, so
+ // a column-0 line inside a code block cannot drag the base to 0 and
+ // reclassify every prose line of this indented document as literal.
+ String text = """
+ ```{groovy echo=false}
+ x = 5
+ multi = '''line one
+line two'''
+ ```
+ Value `= x `
+ """
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('Value 5'),
+ "A column-0 line inside a code block must not change the document's base indent:\n$processed")
+ }
+
+ @Test
+ void aStrayColumnZeroLineDoesNotCollapseTheBaseIndent() {
+ // Review Focus 6: an HTML block at column 0 in an otherwise
+ // 8-space-indented document must not drag the base indent to 0 - the
+ // lines at 8 would then sit at relative indent 8 and silently stop
+ // expanding.
+ String text = """
+ ```{groovy echo=false}
+ x = 5
+ ```
+
+ expanded `= x ` here
+ """
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('expanded 5 here'),
+ "A column-0 line must not collapse the base indent:\n$processed")
+ }
+
+ @Test
+ void aListItemContinuationStillExpands() {
+ // Review F2: inside a list item, content is measured from the item's
+ // content column, so a 4-space continuation after a blank line is a
+ // paragraph of the item, not an indented code block.
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+1. item
+
+ continued `= x `
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('continued 5'), processed)
+ }
+
+ @Test
+ void indentedCodeAfterAListIsLeftAlone() {
+ // Review F8: a non-marker line at column 0 ends the list, so the
+ // indented block below is a code block again. Before the F8 fix the
+ // list context never closed — the reset required
+ // relativeIndent < listItemIndent, and for a top-level list
+ // listItemIndent == 0 — so the indented block was misclassified as a
+ // list continuation, expanded inline: 'literal 5 here'. x is bound in
+ // the block above so that expansion is observable.
+ String text = '''
+```{groovy echo=false}
+x = 5
+```
+
+- item
+
+text
+
+ literal `= x ` here
+'''
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertTrue(processed.contains('literal `= x ` here'),
+ "An indented code block after an ended list is literal:\n$processed")
+ assertFalse(processed.contains('literal 5'), processed)
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `mvn -o -pl gmd-core test -Dtest=GmdTemplateEngineTest`
+Expected: `indentedCodeBlocksAreLeftAlone`, `indentIsRelativeToTheDocumentBaseIndent`, `aBlankLineDoesNotEndAnIndentedCodeBlock`, `crlfLinesAreClassifiedCorrectly` and `indentedCodeAfterAListIsLeftAlone` FAIL (the expressions are expanded). `anIndentedLineContinuingAParagraphIsStillExpanded`, `aTabIndentedLineIsNotTreatedAsACodeBlock`, `baseIndentIgnoresCodeBlockBodies`, `aStrayColumnZeroLineDoesNotCollapseTheBaseIndent` and `aListItemContinuationStillExpands` already pass — they are the regression guards (they pin the F1, F9 and F2 behaviours the implementation must preserve; `aStrayColumnZeroLineDoesNotCollapseTheBaseIndent` would fail under a global-minimum base indent, which is why the anchor-on-first-line approach is taken).
+
+- [ ] **Step 3: Add the base-indent helper**
+
+Add next to `leadingSpaces` in `GmdTemplateEngine.groovy`. The base indent is anchored on the
+document's first non-blank line (review round 3, finding F9). Two benefits and one accepted
+cost follow from that choice:
+
+- A column-0 line *inside* a ` ```{groovy} ` block or a plain fence can never drag the base
+ to 0 — the first line is never inside a block, and no later line is consulted. (A
+ global-minimum scan needed a fence-tracking pre-pass for this; review F1.)
+- A stray column-0 line later in the document — an HTML block, a table row, a hard-wrapped
+ line — cannot collapse the base and silently reclassify every other line (Review Focus 6).
+- The first non-blank line is the invariant, not a global minimum. If it is shallower than a
+ body indented four or more spaces beyond it, that body is classified as literal and inline
+ expansion is suppressed; authors must start the document at its own indent.
+
+```groovy
+ /**
+ * The indent the document sits at: the indent of its first non-blank
+ * line. Indented code blocks are recognised relative to this, so a .gmd
+ * written inside an indented Groovy string behaves the same as one
+ * written at column 0.
+ *
+ * Anchored on the first line deliberately: a global minimum could be
+ * collapsed by a column-0 line inside a code block or a stray column-0
+ * prose line (HTML block, table row), reclassifying the whole document.
+ * The first line is therefore the invariant: when it is shallower than
+ * the body, body lines four or more spaces beyond it are literal. Authors
+ * must start a document at its own indent.
+ * Lines before the first non-blank one are blank and say nothing; when
+ * the document is entirely blank the base is 0.
+ */
+ private static int baseIndent(List lines) {
+ for (String line in lines) {
+ if (!line.isBlank()) {
+ return leadingSpaces(line)
+ }
+ }
+ return 0
+ }
+```
+
+- [ ] **Step 4: Add the tracking state**
+
+Next to `codeBlockIndent` from Task 1:
+
+```groovy
+ int codeBlockIndent = 0
+ boolean inIndentedCode = false
+ boolean previousLineWasParagraph = false
+ // List context (review F2): while inside a list item, content is
+ // measured from the item's content column, not from column 0.
+ // listContentColumn is -1 when no list is open.
+ int listContentColumn = -1
+```
+
+And after `List lines = text.readLines()`:
+
+```groovy
+ List lines = text.readLines()
+ // Anchored on the first non-blank line - see baseIndent. A column-0
+ // line inside a code block or a stray column-0 prose line later in
+ // the document must not drag this to 0 (review F1, round 3 F9).
+ int documentIndent = baseIndent(lines)
+```
+
+- [ ] **Step 5: Reset the tracking when a Groovy block ends**
+
+In the `if (codeBlockEnd)` branch, after `codeBlockEnd = false` (around line 113), add:
+
+```groovy
+ codeBlockEnd = false
+ // A fenced block is not a paragraph, so an indented line
+ // straight after it starts an indented code block. The
+ // emitted block sits at column 0, so any surrounding list
+ // is over too.
+ inIndentedCode = false
+ previousLineWasParagraph = false
+ listContentColumn = -1
+```
+
+- [ ] **Step 6: Classify each ordinary line, and gate the expansion on it**
+
+Replace the `else if (!codeBlockStart)` branch (lines 114-123) with:
+
+```groovy
+ } else if (!codeBlockStart) {
+ if (plainFenceChar != null || startsPlainFence) {
+ // Fenced content is handled by plainFenceChar; a fence
+ // line itself is never a paragraph.
+ inIndentedCode = false
+ previousLineWasParagraph = false
+ } else if (line.isBlank()) {
+ // A blank line neither starts nor ends an indented code
+ // block, but it does end a paragraph. A list item can
+ // contain blank lines, so listContentColumn survives.
+ previousLineWasParagraph = false
+ } else {
+ int relativeIndent = leadingSpaces(line) - documentIndent
+ def listMarker = line =~ /^\s*([-*+]|\d+[.)])\s+/
+ if (relativeIndent < 4 && listMarker.find()) {
+ // A list item. Its content is measured from the
+ // item's content column, so a block indented four
+ // spaces inside the item is a paragraph of that
+ // item, not an indented code block (review F2).
+ listContentColumn = listMarker.end() - documentIndent
+ inIndentedCode = false
+ } else {
+ if (listContentColumn >= 0 && relativeIndent < listContentColumn) {
+ // Left of the item's content column and not a new
+ // marker: the list is over. This also subsumes the
+ // old relativeIndent < listItemIndent reset - for a
+ // top-level list that indent is 0, so the reset was
+ // unreachable and the list never closed (review F8):
+ // indented code blocks after an ended list were
+ // misclassified as continuations and expanded.
+ listContentColumn = -1
+ }
+ // Only reached when relativeIndent >= listContentColumn,
+ // so a bare upper bound suffices (review round 3, F11).
+ if (listContentColumn >= 0
+ && relativeIndent < listContentColumn + 4) {
+ // A continuation of the current list item: prose.
+ inIndentedCode = false
+ } else {
+ if (relativeIndent < 4) {
+ inIndentedCode = false
+ } else if (!previousLineWasParagraph) {
+ inIndentedCode = true
+ }
+ }
+ }
+ previousLineWasParagraph = !inIndentedCode
+ }
+ if (plainFenceChar == null && !inIndentedCode && line.contains('`=')) {
+ shouldBeProcessed = true
+ result.append(expandInlineVars(line, engine))
+ } else {
+ result.append(line)
+ }
+ if (count < lines.size() - 1 || endsWithNewline)
+ result.append('\n')
+ }
+```
+
+Two rules are deliberately load-bearing:
+
+- The `relativeIndent >= 4 && previousLineWasParagraph` case (last `else` branch) falls through with `inIndentedCode` unchanged: that is a lazy paragraph continuation, which stays prose (`anIndentedLineContinuingAParagraphIsStillExpanded`).
+- The list-marker regex is only honoured at `relativeIndent < 4`, so a `1. item` line *inside* an indented literal block does not open list context and steal the block's code status.
+
+Known limitations of the minimal list tracking, accepted for 3.2.0: nested lists overwrite the outer item's content column (a later line dedented below the nested item's indent resets list context entirely), and list context survives a plain fence indented to the item's content column. Neither affects inline-variable expansion for lines below relative indent 4.
+
+- [ ] **Step 7: Document the first-line base-indent invariant**
+
+Append to the `processCodeBlocks` Javadoc after Task 1's indentation paragraph:
+
+```groovy
+ * For inline-variable expansion, the document base is the first non-blank
+ * line's indent. That is an invariant, not a global minimum: if the first
+ * line is shallower than a body indented four or more spaces beyond it, the
+ * body is treated as literal and does not expand. Put the first non-blank
+ * line at the document's own indent.
+```
+
+- [ ] **Step 8: Run the new tests to verify they pass**
+
+Run: `mvn -o -pl gmd-core test -Dtest=GmdTemplateEngineTest`
+Expected: all ten PASS, and `testInlineVars` (eight-space document, no `stripIndent()`) still PASSES — that is the proof that relative indent preserved the leniency.
+
+- [ ] **Step 9: Run the whole suite**
+
+Run: `mvn -o test`
+Expected: BUILD SUCCESS, 98 tests (89 after Task 1, minus the deleted `indentedCodeBlocksAreAKnownGap`, plus 10 new).
+
+- [ ] **Step 10: Record the release note**
+
+Add to the `## v3.2.0, unreleased` section of `gmd-core/release.md`:
+
+```markdown
+- Inline `= expr ` is no longer expanded inside CommonMark indented code blocks (a line
+ indented four or more spaces beyond the document's own base indent). Indent is measured
+ relative to the indent of the document's first non-blank line, so a .gmd written inside an
+ indented Groovy string is unaffected. Lazy paragraph continuations and list-item
+ continuations still expand.
+```
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy \
+ gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy \
+ gmd-core/release.md
+git commit -m "fix: do not expand inline variables inside indented code blocks
+
+Inline \`= expr \` was expanded inside CommonMark indented code blocks. Track
+them, measuring indent relative to the document's own base indent so a .gmd
+written inside an indented Groovy string keeps working.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 3: Terminate code-block output with a newline
+
+**Finding #4.** Captured output that does not end in a newline is concatenated with the next source line, silently destroying it:
+
+```
+Input: "```{groovy echo=false}\nout.print('X')\n```\n# Heading\n"
+Current: "X# Heading\n"
+Wanted: "X\n# Heading\n"
+```
+
+Every other emitter (`Printer.terminateHtmlBlock`, `Printer.printChart`) terminates its own block; the captured-output append does not. Verified safe against `GmdTest.printedChartKeepsCaptionInProse`, whose output already ends in `\n\n`, and against every existing `out.print` call site in `GmdTest`, which terminate their output via `terminateHtmlBlock`/`printChart`.
+
+Note this deliberately changes one observable behaviour: `out.print('Hello ')` followed by prose used to produce `Hello world` on one source line; it now produces `Hello \nworld`. CommonMark renders both as one paragraph (soft break), so the rendered output is unchanged — but the Markdown text differs, and the new test below pins that so a future revert is a deliberate decision, not silent drift.
+
+**Files:**
+- Modify: `gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy:107-110`
+- Test: `gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy`
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: nothing later tasks depend on.
+
+- [ ] **Step 1: Write the failing test**
+
+```groovy
+ @Test
+ void unterminatedCodeBlockOutputDoesNotSwallowTheNextLine() {
+ String text = "```{groovy echo=false}\nout.print('X')\n```\n# Heading\n"
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertEquals("X\n# Heading\n", processed)
+ }
+
+ @Test
+ void alreadyTerminatedOutputGainsNoExtraNewline() {
+ String text = "```{groovy echo=false}\nout.println('X')\n```\n# Heading\n"
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertEquals("X\n# Heading\n", processed)
+ }
+
+ @Test
+ void inlineOutPrintIsTerminatedBeforeTheNextLine() {
+ // Pins the one observable behaviour change: out.print('Hello ') used to
+ // glue the next source line on ('Hello world'); it now terminates it
+ // ('Hello \nworld'). CommonMark renders both as one paragraph, but the
+ // Markdown text differs and must not drift back silently.
+ String text = "```{groovy echo=false}\nout.print('Hello ')\n```\nworld\n"
+
+ String processed = GmdTemplateEngine.processCodeBlocks(text)
+
+ assertEquals("Hello \nworld\n", processed)
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify the first fails**
+
+Run: `mvn -o -pl gmd-core test -Dtest=GmdTemplateEngineTest`
+Expected: `unterminatedCodeBlockOutputDoesNotSwallowTheNextLine` FAILS with actual `X# Heading\n`. `inlineOutPrintIsTerminatedBeforeTheNextLine` FAILS with actual `Hello world\n`. `alreadyTerminatedOutputGainsNoExtraNewline` PASSES — it guards against over-correcting.
+
+- [ ] **Step 3: Terminate the output**
+
+Replace lines 107-110 of `GmdTemplateEngine.groovy`:
+
+```groovy
+ def output = out.toString()
+ if (output.length() > 0) {
+ result.append(output)
+ // The document continues on its own line. Without this the
+ // next source line is glued onto unterminated output.
+ if (!output.endsWith('\n')) {
+ result.append('\n')
+ }
+ }
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `mvn -o -pl gmd-core test -Dtest=GmdTemplateEngineTest`
+Expected: all three PASS.
+
+- [ ] **Step 5: Run the whole suite**
+
+Run: `mvn -o test`
+Expected: BUILD SUCCESS, 100 tests. `GmdTest.printedChartKeepsCaptionInProse` must still pass — confirm it does before committing.
+
+- [ ] **Step 6: Record the release note**
+
+Add to the `## v3.2.0, unreleased` section of `gmd-core/release.md`:
+
+```markdown
+- Output from a Groovy code block that does not end in a newline now gains one, so
+ `out.print('x')` no longer glues the next source line onto the output. Visible change:
+ `out.print('Hello ')` followed by prose now yields `Hello \nworld` instead of
+ `Hello world` in the Markdown (rendered identically by CommonMark).
+```
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy \
+ gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTemplateEngineTest.groovy \
+ gmd-core/release.md
+git commit -m "fix: terminate unterminated code block output with a newline
+
+out.print without a trailing newline glued the next source line onto the
+captured output, silently destroying headings and list items.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 4: GgChart always honours its configured dimensions
+
+**Finding #5.** `print(GgChart, String alt, Map)` renders with `x.render()` (the chart's own width/height), while the default-argument overload renders at `DEFAULT_CHART_WIDTH`/`DEFAULT_CHART_HEIGHT`. So `out.println(chart)` renders 800×600 — discarding a configured `chart.width = 1200` — while `out.println(chart, 'alt')` renders 1200×900.
+
+Per decision (4), align on honouring the chart: only an explicit `width`/`height` argument overrides. Groovy default arguments cannot distinguish "not passed" from "passed 800", so the two default-argument methods are replaced by explicit overloads.
+
+**Files:**
+- Modify: `gmd-core/src/main/groovy/se/alipsa/gmd/core/Printer.groovy:164-186`
+- Modify: `README.md` (the GGdimension sentence added in d7dfcd4)
+- Test: `gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTest.groovy:587-606`
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: the `Printer` GgChart surface becomes exactly —
+ - `void print(GgChart x)` / `void println(GgChart x)`
+ - `void print(GgChart x, String alt, Map attributes = [:])` / same for `println`
+ - `void print(GgChart x, double width, double height, String alt = '', Map attributes = [:])` / same for `println`
+
+ This drops the width-only `print(GgChart, double)` arity the old default arguments generated;
+ see attention point 3 for why it is removed rather than shimmed. The `double` parameter type
+ is deliberate and stays as it is. `Chart` and `CharmChart` are **not** changed.
+
+- [ ] **Step 1: Write the failing test**
+
+Replace `GmdTest.testGgChart` with:
+
+```groovy
+ @Test
+ void testGgChart() {
+ def text = '''
+```{groovy echo=false}
+import static se.alipsa.matrix.gg.GgPlot.*
+import se.alipsa.matrix.core.Matrix
+
+def data = Matrix.builder().data(x: [1, 2], y: [3, 4]).types(int, int).build()
+def chart = ggplot(data, aes(x: 'x', y: 'y')) + geom_point()
+chart.width = 1200
+chart.height = 900
+out.println(chart, 640, 480, 'GG chart', [class: 'chart'])
+out.println(chart, 'GG default chart')
+out.println(chart)
+out.println(chart, 640.5, 480.5, 'GG BigDecimal dims')
+```
+'''
+
+ String md = new Gmd().gmdToMd(text)
+
+ assertTrue(md.contains(' svgs = decodeSvgImages(md, 'GG chart should be SVG-backed')
+ assertTrue(svgs[0].contains('width="640"') && svgs[0].contains('height="480"'), svgs[0].take(200))
+ assertTrue(svgs[1].contains('width="1200"') && svgs[1].contains('height="900"'), svgs[1].take(200))
+ assertTrue(svgs[2].contains('width="1200"') && svgs[2].contains('height="900"'),
+ "print(GgChart) with no arguments must honour the chart's own dimensions: ${svgs[2].take(200)}")
+ // 640.5 is a BigDecimal literal in Groovy. The double parameters accept it by
+ // dispatch coercion, and chartToMd's `width as int` truncates to 640. This pins
+ // the parameter type: narrowing it to int would reject BigDecimal outright.
+ assertTrue(svgs[3].contains('width="640"') && svgs[3].contains('height="480"'),
+ "double dimensions must accept Groovy's default decimal type (BigDecimal): ${svgs[3].take(200)}")
+ }
+
+ @Test
+ void aGgChartWithNoConfiguredSizeRendersAtTheLibraryDefault() {
+ // Review Focus 4: width/height are never set, so the library default applies.
+ def text = '''
+```{groovy echo=false}
+import static se.alipsa.matrix.gg.GgPlot.*
+import se.alipsa.matrix.core.Matrix
+
+def data = Matrix.builder().data(x: [1, 2], y: [3, 4]).types(int, int).build()
+out.println(ggplot(data, aes(x: 'x', y: 'y')) + geom_point())
+```
+'''
+
+ String md = new Gmd().gmdToMd(text)
+ List svgs = decodeSvgImages(md, 'An unsized GG chart should still render')
+
+ assertTrue(svgs[0].contains(' attributes = [:]) {
+ printChart(svgToMd(x.render(), alt, attributes))
+ }
+
+ /** @see #print(GgChart, String, Map) */
+ void println(GgChart x, String alt, Map attributes = [:]) {
+ println(svgToMd(x.render(), alt, attributes))
+ }
+
+ /**
+ * Renders a GgChart at the supplied dimensions, overriding the chart's own.
+ * The chart's original dimensions are restored before this method returns.
+ */
+ void print(GgChart x, double width, double height, String alt = '', Map attributes = [:]) {
+ printChart(chartToMd(x, width, height, alt, attributes))
+ }
+
+ /** @see #print(GgChart, double, double, String, Map) */
+ void println(GgChart x, double width, double height, String alt = '', Map attributes = [:]) {
+ println(chartToMd(x, width, height, alt, attributes))
+ }
+```
+
+Leave the private `chartToMd(GgChart, double, double, String, Map)` helper — with its save/restore `finally` — exactly as it is, including its `double` parameters.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `mvn -o -pl gmd-core test -Dtest=GmdTest`
+Expected: both PASS.
+
+- [ ] **Step 5: Correct the README**
+
+In `README.md`, replace:
+
+```
+They also accept alt text and attributes without explicit dimensions. Alt-only GG printing honors
+the dimensions configured on the chart; explicit GG dimensions are restored after rendering.
+```
+
+with:
+
+```
+They also accept alt text and attributes without explicit dimensions. Pict and Charm charts then
+render at 800x600. GG charts always honor the `width` and `height` configured on the chart unless
+you pass explicit dimensions, which are restored after rendering.
+```
+
+- [ ] **Step 6: Record the release note**
+
+Add to the `## v3.2.0, unreleased` section of `gmd-core/release.md`:
+
+```markdown
+- **Breaking:** the `print(GgChart, double)` / `println(GgChart, double)` width-only
+ overload that Groovy default arguments generated is removed. Pass explicit width and
+ height, or omit both and let the chart's own dimensions apply.
+```
+
+- [ ] **Step 7: Run the whole suite**
+
+Run: `mvn -o test`
+Expected: BUILD SUCCESS, 101 tests.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add gmd-core/src/main/groovy/se/alipsa/gmd/core/Printer.groovy \
+ gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTest.groovy \
+ README.md gmd-core/release.md
+git commit -m "fix: GgChart printing always honours its configured dimensions
+
+out.println(chart) rendered at 800x600 while out.println(chart, 'alt') honoured
+chart.width/height. Replace the default-argument overloads with explicit ones so
+only explicit width/height arguments override the chart. The width-only arity that
+the default arguments generated is removed; nothing in the repo used it.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 5: Gradle plugin checks for Maven Central before adding it
+
+**Finding #3.** `project.repositories.mavenCentral()` **adds** the repository as a side effect, so the `hasRepository` guard beneath it is unreachable and every consuming project silently gains a project-level Maven Central. Verified on Gradle 9.7.1 with `ProjectBuilder`:
+
+```
+before: 0
+after mavenCentral(): 1
+contains: true
+```
+
+That changes resolution for the consumer's own configurations and, under `dependencyResolutionManagement { repositoriesMode = FAIL_ON_PROJECT_REPOS }`, the add happens inside `afterEvaluate` outside any try/catch.
+
+Per decision (3): look for an existing Maven Central declaration first and only add when there is none.
+
+**Files:**
+- Modify: `gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/GmdGradlePlugin.groovy:107-131`
+- Modify: `gmd-gradle-plugin/readme.md`
+- Test: `gmd-gradle-plugin/src/test/groovy/test/alipsa/gmd/gradle/GmdGradlePluginTest.groovy`
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: `static boolean hasRepository(Project, MavenArtifactRepository)` is **removed** and replaced by `static boolean hasMavenCentral(Project project)`. `addDependencies(Project, String, String, String, String)` keeps its exact signature.
+- Needs one new import: `org.gradle.api.InvalidUserCodeException`.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `GmdGradlePluginTest`:
+
+```groovy
+ @Test
+ void mavenCentralIsAddedOnlyWhenTheProjectHasNone() {
+ def project = ProjectBuilder.builder().build()
+ Assertions.assertEquals(0, project.repositories.size())
+
+ GmdGradlePlugin.addDependencies(project, '5.1.3', '2.26.1', '3.1.0', '2.6.0')
+
+ Assertions.assertEquals(1, project.repositories.size(),
+ 'The plugin should add Maven Central when the project declares no repository')
+ }
+
+ @Test
+ void anExistingMavenCentralIsNotDuplicated() {
+ def project = ProjectBuilder.builder().build()
+ project.repositories.mavenCentral()
+
+ GmdGradlePlugin.addDependencies(project, '5.1.3', '2.26.1', '3.1.0', '2.6.0')
+
+ Assertions.assertEquals(1, project.repositories.size(),
+ 'The plugin must not add a second Maven Central')
+ }
+
+ @Test
+ void mavenCentralIsRecognisedUnderItsOtherHost() {
+ // Review Focus 5: repo1.maven.org is the same repository, and a trailing
+ // slash must not defeat the check.
+ def project = ProjectBuilder.builder().build()
+ project.repositories.maven { it.setUrl('https://repo1.maven.org/maven2') }
+
+ GmdGradlePlugin.addDependencies(project, '5.1.3', '2.26.1', '3.1.0', '2.6.0')
+
+ Assertions.assertEquals(1, project.repositories.size(),
+ 'repo1.maven.org is Maven Central and must not trigger a second add')
+ }
+
+ @Test
+ void settingsManagedRepositoriesDoNotBreakTheBuild() {
+ // Measured on Gradle 9.7.1: under FAIL_ON_PROJECT_REPOS the settings
+ // repositories are NOT visible in project.repositories (size 0), so
+ // hasMavenCentral returns false and the add is attempted and rejected with
+ // InvalidUserCodeException. Swallowing it is safe - the detached
+ // configuration still resolves through the settings repositories.
+ // ProjectBuilder cannot model dependencyResolutionManagement, so this is a
+ // TestKit test.
+ File testProjectDir = new File('build/gmdSettingsReposTest')
+ try {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ File srcDir = new File(testProjectDir, 'src/test/gmd')
+ srcDir.mkdirs()
+ new File(srcDir, 'test.gmd').text = '# Greetings\n'
+
+ new File(testProjectDir, 'settings.gradle').text = """
+ pluginManagement {
+ repositories { mavenCentral() }
+ plugins { id 'se.alipsa.gmd.gmd-gradle-plugin' version "1.0.0" }
+ }
+ dependencyResolutionManagement {
+ repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
+ repositories { mavenCentral() }
+ }
+ """.stripIndent()
+
+ // Deliberately no project-level repositories block.
+ new File(testProjectDir, 'build.gradle').text = """
+ plugins {
+ id('base')
+ id 'se.alipsa.gmd.gmd-gradle-plugin'
+ }
+ gmdPlugin {
+ sourceDir = 'src/test/gmd'
+ targetDir = 'build/target'
+ outputType = 'html'
+ gmdVersion = '3.1.0'
+ runTaskBefore = 'build'
+ }
+ """.stripIndent()
+
+ def result = GradleRunner.create()
+ .withProjectDir(testProjectDir)
+ .withArguments('processGmd')
+ .withPluginClasspath()
+ .forwardOutput()
+ .build()
+
+ Assertions.assertEquals(SUCCESS, result.task(':processGmd').outcome,
+ "FAIL_ON_PROJECT_REPOS must not break the plugin:\n${result.output}")
+ Assertions.assertTrue(new File(testProjectDir, 'build/target/test.html').exists(),
+ 'The detached configuration must still resolve through the settings repositories')
+ } finally {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ }
+ }
+
+ @Test
+ void aPrivateMirrorStillGetsMavenCentralAdded() {
+ def project = ProjectBuilder.builder().build()
+ project.repositories.maven { it.setUrl('https://nexus.example.com/repository/maven-public') }
+
+ GmdGradlePlugin.addDependencies(project, '5.1.3', '2.26.1', '3.1.0', '2.6.0')
+
+ Assertions.assertEquals(2, project.repositories.size(),
+ 'A private mirror is not Maven Central, so the plugin still adds it')
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `cd gmd-gradle-plugin && ./gradlew test --tests '*GmdGradlePluginTest*'`
+Expected: `anExistingMavenCentralIsNotDuplicated` and `mavenCentralIsRecognisedUnderItsOtherHost` FAIL with 2 repositories. `settingsManagedRepositoriesDoNotBreakTheBuild` FAILS with `InvalidUserCodeException: Build was configured to prefer settings repositories over project repositories but repository 'MavenRepo' was added by build file 'build.gradle'`.
+
+- [ ] **Step 3: Replace the repository handling**
+
+In `GmdGradlePlugin.groovy`, replace `addDependencies`'s first three lines and the `hasRepository` method:
+
+```groovy
+ private static final List MAVEN_CENTRAL_HOSTS = ['repo.maven.apache.org', 'repo1.maven.org']
+
+ static Configuration addDependencies(Project project,
+ String groovyVersion, String log4jVersion, String gmdVersion,
+ String ivyVersion) {
+ // RepositoryHandler.mavenCentral() ADDS the repository as a side effect, so
+ // it must only be called when the project has not declared Central itself.
+ // Injecting it unconditionally changes resolution for the consumer's own
+ // configurations and fails under repositoriesMode = FAIL_ON_PROJECT_REPOS.
+ if (!hasMavenCentral(project)) {
+ try {
+ project.repositories.mavenCentral()
+ } catch (InvalidUserCodeException e) {
+ // repositoriesMode = PREFER_SETTINGS or FAIL_ON_PROJECT_REPOS. The settings
+ // repositories are NOT visible in project.repositories (measured: size 0),
+ // so hasMavenCentral cannot see them and the add above is rejected. The
+ // detached configuration below still resolves through them, so this is not
+ // an error - measured on Gradle 9.7.1, see Task 5 Step 1.
+ project.logger.info('Project repositories are managed in settings.gradle; ' +
+ 'resolving gmd-core through the settings repositories instead: ' + e.message)
+ }
+ }
+
+ List dependencies = [
+ project.dependencies.create("org.apache.groovy:groovy:${groovyVersion}"),
+ project.dependencies.create("org.apache.groovy:groovy-templates:${groovyVersion}"),
+ project.dependencies.create("org.apache.groovy:groovy-jsr223:${groovyVersion}"),
+ project.dependencies.create("org.apache.ivy:ivy:${ivyVersion}"), // needed for @Grab)
+ project.dependencies.create("org.apache.logging.log4j:log4j-core:${log4jVersion}"),
+ project.dependencies.create("se.alipsa.gmd:gmd-core:${gmdVersion}")
+ ]
+
+ return project.configurations.detachedConfiguration(dependencies.toArray(new Dependency[0]))
+ }
+
+ static boolean hasMavenCentral(Project project) {
+ return project.repositories.any { ArtifactRepository repository ->
+ repository instanceof MavenArtifactRepository &&
+ MAVEN_CENTRAL_HOSTS.contains(((MavenArtifactRepository) repository).url?.host)
+ }
+ }
+```
+
+Delete the old `hasRepository` method entirely. It is `static` on a published plugin class,
+so its removal is source-breaking for any build script that called it; that is recorded in
+the release note in Step 5.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `cd gmd-gradle-plugin && ./gradlew test --tests '*GmdGradlePluginTest*'`
+Expected: all five PASS, and the existing `testPlugin` TestKit test still PASSES.
+
+- [ ] **Step 5: Document the behaviour**
+
+Add to `gmd-gradle-plugin/readme.md`, under the configuration parameter list:
+
+```markdown
+### Repositories
+
+The plugin resolves `gmd-core` and Groovy through a detached configuration, which uses the
+project's repositories. If the project has not declared Maven Central, the plugin adds it.
+If it has — under either `repo.maven.apache.org` or `repo1.maven.org` — nothing is added.
+A project that resolves only through a private mirror should declare that mirror in
+`dependencyResolutionManagement` in `settings.gradle` so the plugin has no reason to add
+Central to the project itself.
+```
+
+Also add to the `## v3.2.0, unreleased` section of `gmd-gradle-plugin/release.md`:
+
+```markdown
+- **Breaking:** `GmdGradlePlugin.hasRepository(Project, MavenArtifactRepository)` is removed.
+ It was public static API on the plugin class; nothing in the repo called it. The plugin now
+ checks for an existing Maven Central declaration itself before adding one.
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/GmdGradlePlugin.groovy \
+ gmd-gradle-plugin/src/test/groovy/test/alipsa/gmd/gradle/GmdGradlePluginTest.groovy \
+ gmd-gradle-plugin/readme.md gmd-gradle-plugin/release.md
+git commit -m "fix: only add Maven Central when the Gradle project has none
+
+RepositoryHandler.mavenCentral() adds the repository as a side effect, so the
+guard beneath it was unreachable and every consuming project silently gained a
+project-level Maven Central.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 6: Filter the Gradle source listing for .gmd files
+
+**Finding #6.** `ProcessGmdTask.process` line 116 uses an unfiltered `source.listFiles()` to decide which completion message to log, while the other three enumerations in the same codebase (`ProcessGmdTask:66`, `:129`, `GmdProcessor:49`) filter on `.gmd`. A source directory holding only non-`.gmd` files logs "Gmd files processed and written to …" although nothing was generated.
+
+**Files:**
+- Modify: `gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/ProcessGmdTask.groovy:58-71` (`getGeneratedFiles`), `:110-148` (`process` tail + `cleanStaleGeneratedFiles`)
+- Test: `gmd-gradle-plugin/src/test/groovy/test/alipsa/gmd/gradle/GmdGradlePluginTest.groovy`
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: `private static File[] gmdFilesIn(File directory)` → the `.gmd` files in the directory, or an empty array when the directory cannot be read.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `GmdGradlePluginTest`:
+
+```groovy
+ @Test
+ void aSourceDirWithoutGmdFilesReportsNothingToDo() {
+ File testProjectDir = new File('build/gmdNoGmdFilesTest')
+ try {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ File srcDir = new File(testProjectDir, 'src/test/gmd')
+ srcDir.mkdirs()
+ new File(srcDir, 'readme.txt').text = 'not a gmd file'
+
+ new File(testProjectDir, 'settings.gradle').text = """
+ pluginManagement {
+ repositories { mavenCentral() }
+ plugins { id 'se.alipsa.gmd.gmd-gradle-plugin' version "1.0.0" }
+ }
+ """.stripIndent()
+
+ new File(testProjectDir, 'build.gradle').text = """
+ plugins {
+ id('base')
+ id 'se.alipsa.gmd.gmd-gradle-plugin'
+ }
+ repositories { mavenCentral() }
+ gmdPlugin {
+ sourceDir = 'src/test/gmd'
+ targetDir = 'build/target'
+ outputType = 'html'
+ gmdVersion = '3.1.0'
+ runTaskBefore = 'build'
+ }
+ """.stripIndent()
+
+ def result = GradleRunner.create()
+ .withProjectDir(testProjectDir)
+ .withArguments('processGmd')
+ .withPluginClasspath()
+ .forwardOutput()
+ .build()
+
+ Assertions.assertTrue(result.output.contains('No gmd files found in'),
+ "A source dir with no .gmd files must say so:\n${result.output}")
+ Assertions.assertFalse(result.output.contains('Gmd files processed and written to'),
+ "Nothing was generated, so nothing was written:\n${result.output}")
+ } finally {
+ new AntBuilder().delete(dir: testProjectDir, failonerror: false)
+ }
+ }
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `cd gmd-gradle-plugin && ./gradlew test --tests '*aSourceDirWithoutGmdFilesReportsNothingToDo*'`
+Expected: FAIL — the output contains "Gmd files processed and written to".
+
+- [ ] **Step 3: Extract the shared filter**
+
+Add to `ProcessGmdTask.groovy`:
+
+```groovy
+ /** The .gmd files in a directory, or an empty array when it cannot be read. */
+ private static File[] gmdFilesIn(File directory) {
+ File[] files = directory.listFiles({ File file ->
+ file.isFile() && file.name.endsWith('.gmd')
+ } as FileFilter)
+ return files == null ? new File[0] : files
+ }
+```
+
+- [ ] **Step 4: Use it in all three places**
+
+In `getGeneratedFiles()`, replace the `File[] sources = source.listFiles(...)` line and the null handling:
+
+```groovy
+ File[] sources = gmdFilesIn(source)
+ return sources.collect { File file ->
+ new File(target, file.name.substring(0, file.name.length() - 4) + ".${output}")
+ } as Set
+```
+
+In `process()`, replace the unfiltered listing:
+
+```groovy
+ File[] sourceFiles = gmdFilesIn(source)
+ if (sourceFiles.length > 0) {
+ if (target.exists()) {
+ logger.quiet("Gmd files processed and written to ${target.canonicalPath}")
+ } else {
+ logger.warn("${target.canonicalPath} should exists but does not, something is probably wrong")
+ }
+ } else {
+ logger.quiet("No gmd files found in ${source.canonicalPath}, nothing to do")
+ }
+```
+
+In `cleanStaleGeneratedFiles`, replace the listing and its null guard:
+
+```groovy
+ Set expected = [] as Set
+ gmdFilesIn(sourceDir).each { file ->
+ String base = file.name.substring(0, file.name.length() - 4)
+ expected.add("${base}.${outputType}".toString())
+ }
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `cd gmd-gradle-plugin && ./gradlew test`
+Expected: the new test PASSES and every existing Gradle test still PASSES.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add gmd-gradle-plugin/src/main/groovy/se/alipsa/gmd/gradle/ProcessGmdTask.groovy \
+ gmd-gradle-plugin/src/test/groovy/test/alipsa/gmd/gradle/GmdGradlePluginTest.groovy
+git commit -m "fix: report nothing-to-do when the Gradle source dir has no gmd files
+
+The completion message used an unfiltered listFiles(), so a source directory
+holding only non-.gmd files claimed output had been written.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 7: Maven plugin restores the interrupt flag and stops double-wrapping
+
+**Finding #7.** `process.waitFor()` at line 193 throws `InterruptedException`, which falls into the broad `catch (Exception e)` at line 224 and is rewrapped without `Thread.currentThread().interrupt()`; the forked JVM is left running. The same broad catch also rewraps the plugin's own `MojoFailureException`s, so "GmdProcessor exited with code 1" becomes "Failed to process gmd files in …: GmdProcessor exited with code 1".
+
+**Files:**
+- Modify: `gmd-maven-plugin/src/main/java/se/alipsa/gmd/maven/GmdMavenPlugin.java:190-230`
+- Test: `gmd-maven-plugin/src/test/java/test/alipsa/gmd/maven/GmdMavenPluginTest.java`
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: nothing later tasks depend on.
+
+- [ ] **Step 1: Tighten the existing fork-failure test**
+
+Per review F6, do **not** add a new test — the planned `aMojoFailureIsNotDoubleWrapped` would have repeated the entire mock setup of the existing `testGmdMavenPluginReportsForkFailure` (`GmdMavenPluginTest.java:112-137`) and differed only in the assertions. Instead, replace the assertion block of that existing test:
+
+```java
+ MojoFailureException exception = assertThrows(MojoFailureException.class, plugin::execute);
+ // The plugin's own MojoFailureException must not be rewrapped by the broad catch.
+ assertTrue(exception.getMessage().startsWith("GmdProcessor exited with code"),
+ "The plugin's own failure must not be rewrapped: " + exception.getMessage());
+ assertFalse(exception.getMessage().contains("Failed to process gmd files in"),
+ "The plugin's own failure must not be rewrapped: " + exception.getMessage());
+```
+
+(`assertFalse` is already statically imported in that file.)
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `mvn -o -pl gmd-maven-plugin test -Dtest=GmdMavenPluginTest`
+Expected: `testGmdMavenPluginReportsForkFailure` FAILS — the message starts with "Failed to process gmd files in".
+
+- [ ] **Step 3: Handle the interrupt explicitly**
+
+Add `import java.util.concurrent.TimeUnit;` to `GmdMavenPlugin.java`, then replace the `Process`/`waitFor` block and the argfile `finally` in `GmdMavenPlugin.execute()`:
+
+```java
+ File argFile = createArgFile(classpath.toString(), srcDir.getCanonicalPath(),
+ outputDirectory.getCanonicalPath(), normalizedOutputType);
+ // Set when the waitFor below is interrupted; the flag is restored after
+ // the argfile cleanup in the finally, not before (review F5).
+ boolean interrupted = false;
+ try {
+ List command = new ArrayList<>();
+ command.add(getJavaExecutable());
+ command.add("@" + argFile.getAbsolutePath());
+
+ ProcessBuilder processBuilder = new ProcessBuilder(command);
+ processBuilder.inheritIO();
+ Process process = processBuilder.start();
+ int exitCode;
+ try {
+ exitCode = process.waitFor();
+ } catch (InterruptedException e) {
+ // Do not leave the fork running - that is the point of this catch.
+ // destroy() alone can leave it alive, so escalate to destroyForcibly()
+ // after a short grace period.
+ process.destroy();
+ try {
+ if (!process.waitFor(5, TimeUnit.SECONDS)) {
+ process.destroyForcibly();
+ }
+ } catch (InterruptedException swallowed) {
+ // The grace-period wait was interrupted too; kill the fork anyway.
+ process.destroyForcibly();
+ }
+ // Do not swallow the interrupt - callers above us need the flag to
+ // see that the build was cancelled - but restore it after cleanup.
+ interrupted = true;
+ throw new MojoExecutionException("Interrupted while waiting for the GMD processor", e);
+ }
+
+ if (exitCode != 0) {
+ throw new MojoFailureException("GmdProcessor exited with code " + exitCode);
+ }
+ } finally {
+ // Never let a cleanup failure here replace a pending exception from
+ // the try block (e.g. the non-zero exit code above) with a less
+ // informative one.
+ try {
+ Files.deleteIfExists(argFile.toPath());
+ } catch (IOException e) {
+ getLog().warn("Could not delete temporary classpath argfile " + argFile.getAbsolutePath()
+ + ": " + e.getMessage());
+ }
+ if (interrupted) {
+ // Conventional order: cleanup first, then restore the flag.
+ Thread.currentThread().interrupt();
+ }
+ }
+```
+
+- [ ] **Step 4: Stop rewrapping the plugin's own exceptions**
+
+Add a rethrow clause before the broad `catch (Exception e)` at the end of `execute()`:
+
+```java
+ } catch (MojoExecutionException | MojoFailureException e) {
+ // Already a precise, actionable Mojo failure - rewrapping it only buries
+ // the message a level deeper.
+ throw e;
+ } catch (DependencyResolutionException e) {
+ throw new MojoExecutionException("Failed to resolve dependencies", e);
+ } catch (Exception e) {
+```
+
+- [ ] **Step 5: Run the tests to verify they pass**
+
+Run: `mvn -o -pl gmd-maven-plugin test -Dtest=GmdMavenPluginTest`
+Expected: the tightened `testGmdMavenPluginReportsForkFailure` PASSES. (The interrupt path itself has no automated test — exercising it needs a real forked JVM to interrupt — so it is verified by review, not by the suite.)
+
+- [ ] **Step 6: Run the whole suite**
+
+Run: `mvn -o test`
+Expected: BUILD SUCCESS, 101 tests (unchanged from Task 4 — this task adds assertions to an existing test, not a new one).
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add gmd-maven-plugin/src/main/java/se/alipsa/gmd/maven/GmdMavenPlugin.java \
+ gmd-maven-plugin/src/test/java/test/alipsa/gmd/maven/GmdMavenPluginTest.java
+git commit -m "fix: restore the interrupt flag and stop double-wrapping Mojo failures
+
+waitFor's InterruptedException fell into the broad catch, losing the interrupt
+flag and leaving the fork running. The same catch also rewrapped the plugin's
+own MojoFailureExceptions, burying their messages.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+### Task 8: CLI `toHtml` creates parent directories
+
+**Finding #8.** `GmdCommandLine.toHtml` writes with a bare `Files.writeString`, while `Gmd.writeUtf8` and `Gmd.htmlToPdf(String, File)` both call `ensureParentDirectory`. So `java -jar gmd.jar toHtml in.gmd out/dir/x.html` fails with a bare `NoSuchFileException` while `toPdf` to the same path succeeds. Verified: `Files.writeString` to a missing parent throws `NoSuchFileException`.
+
+The fix removes the duplication rather than repeating the mkdir: `Gmd.gmdToHtml(String, File)` already does exactly this work, through `writeUtf8`.
+
+**Files:**
+- Modify: `gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdCommandLine.groovy:26-34` (`toHtml`)
+- Test: `gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTest.groovy`
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces: nothing later tasks depend on. `GmdCommandLine.toHtml(String, String)` keeps its signature; the private `gmdFileToHtml` stays, used by `toPdfStyled`.
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `GmdTest`:
+
+```groovy
+ @Test
+ void commandLineToHtmlCreatesMissingParentDirectories() {
+ String file = getClass().getResource("/test.gmd").getFile()
+ File htmlFile = new File(testOutputDir, "nested/output/dir/cli.html")
+ assertFalse(htmlFile.parentFile.exists(), 'The parent directory must not exist yet')
+
+ Gmd.main("toHtml", file, htmlFile.absolutePath)
+
+ assertTrue(htmlFile.exists(), 'toHtml must create the parent directories, as toPdf does')
+ assertTrue(htmlFile.length() > 100, "No html content")
+ }
+
+ @Test
+ void commandLineToPdfCreatesMissingParentDirectories() {
+ String file = getClass().getResource("/test.gmd").getFile()
+ File pdfFile = new File(testOutputDir, "nested/pdf/dir/cli.pdf")
+
+ Gmd.main("toPdf", file, pdfFile.absolutePath)
+
+ assertTrue(pdfFile.exists())
+ assertTrue(pdfFile.length() > 100, "No pdf content")
+ }
+```
+
+- [ ] **Step 2: Run the tests to verify the first fails**
+
+Run: `mvn -o -pl gmd-core test -Dtest=GmdTest`
+Expected: `commandLineToHtmlCreatesMissingParentDirectories` FAILS with `NoSuchFileException`. `commandLineToPdfCreatesMissingParentDirectories` PASSES — it is the proof that the two commands disagree today.
+
+- [ ] **Step 3: Route toHtml through Gmd.gmdToHtml(String, File)**
+
+In `GmdCommandLine.groovy`, replace `toHtml`:
+
+```groovy
+ static Closure toHtml(String from, String to) {
+ return {
+ Gmd gmd = new Gmd()
+ File toFile = new File(to)
+ // gmdToHtml(String, File) writes a decorated document through writeUtf8,
+ // which creates the parent directories the way toPdf already does.
+ gmd.gmdToHtml(readGmd(from), toFile)
+ println "Wrote $toFile.absolutePath"
+ }
+ }
+```
+
+Remove the now-unused `java.nio.charset.StandardCharsets` import only if `readGmd` no longer needs it — it does, so **keep both imports**.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `mvn -o -pl gmd-core test -Dtest=GmdTest`
+Expected: both PASS, and the existing `gmdCommandLineTest` still PASSES.
+
+- [ ] **Step 5: Run the whole suite**
+
+Run: `mvn -o test && (cd gmd-gradle-plugin && ./gradlew test)`
+Expected: BUILD SUCCESS both times, 103 Maven tests.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdCommandLine.groovy \
+ gmd-core/src/test/groovy/test/alipsa/groovy/gmd/GmdTest.groovy
+git commit -m "fix: toHtml creates missing parent directories like toPdf
+
+The CLI toHtml command wrote with a bare Files.writeString and failed with
+NoSuchFileException where toPdf to the same path succeeded. Route it through
+Gmd.gmdToHtml(String, File), which already ensures the parent directory.
+
+Co-Authored-By: Claude Opus 5 "
+```
+
+---
+
+## Final verification
+
+- [ ] **Full build from clean**
+
+```bash
+mvn -o clean install
+cd gmd-gradle-plugin && ./gradlew clean build && cd ..
+```
+
+Expected: BUILD SUCCESS for both. Maven test count 103 (85 baseline, +19 new, -1 deleted `indentedCodeBlocksAreAKnownGap`); Gradle test count 4 baseline + 6 new = 10.
+
+- [ ] **Confirm the change surface matches the plan**
+
+```bash
+git diff d7dfcd4 --stat
+```
+
+Expected: exactly the files listed in File Structure (Modified and Test files modified —
+the eight modified sources/docs plus the two release notes, and the four test files) and
+nothing else. The release notes recorded in Tasks 1, 2, 3, 4 and 5 are part of the planned
+change surface, not extra drift.
+
+- [ ] **Re-run the two findings that had no automated repro before this plan**
+
+```bash
+# Finding #1 - the body must now line up with the fence
+printf 'Example:\n\n ```{groovy}\n out.println("X")\n ```\n\nend\n' > /tmp/f1.gmd
+java -jar gmd-core/target/gmd-3.2.0-SNAPSHOT.jar toHtml /tmp/f1.gmd /tmp/f1.html && grep -c 'X' /tmp/f1.html
+
+# Finding #8 - a missing parent directory must be created
+rm -rf /tmp/gmd-nested
+java -jar gmd-core/target/gmd-3.2.0-SNAPSHOT.jar toHtml /tmp/f1.gmd /tmp/gmd-nested/a/b/f1.html
+test -f /tmp/gmd-nested/a/b/f1.html && echo "finding 8 fixed"
+```