Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e240866
fix: dedent groovy code block bodies by the fence indent
perNyfelt Sep 24, 2026
05faf8c
fix: do not expand inline variables inside indented code blocks
perNyfelt Sep 24, 2026
763ecb5
fix: terminate unterminated code block output with a newline
perNyfelt Sep 24, 2026
5c8d677
fix: GgChart printing always honours configured dimensions
perNyfelt Sep 24, 2026
cb85a45
fix: only add Maven Central when the Gradle project has none
perNyfelt Sep 24, 2026
a6c730d
fix: report nothing to do when Gradle source dir has no gmd files
perNyfelt Sep 24, 2026
d0c0cbe
fix: restore interrupt flag and stop double wrapping Mojo failures
perNyfelt Sep 24, 2026
39cb09c
fix: toHtml creates missing parent directories like toPdf
perNyfelt Sep 24, 2026
c382c5e
test: keep newline coverage within the planned test count
perNyfelt Sep 24, 2026
eed9837
docs: add v3.2.0 review fixes plan
perNyfelt Sep 24, 2026
99d6965
fix versions
perNyfelt Sep 24, 2026
e605a25
fix: cover Markdown leaf and block quote indentation
perNyfelt Sep 24, 2026
df84423
Finding 1 — Gradle PREFER_SETTINGS repository mutation (confirmed). V…
perNyfelt Sep 24, 2026
61dbd42
fix: preserve Gradle cleanup and task inputs
perNyfelt Sep 24, 2026
52e1d7e
• testPlugin now pins log4jVersion = '2.26.1' explicitly in the ge…
perNyfelt Sep 24, 2026
f0287d5
Fix 1 — direct task registration broken (MEDIUM) — ProcessGmdTask.gro…
perNyfelt Sep 24, 2026
b73cb79
fix: track runtime classpath via classpathIdentity and tidy timeout d…
perNyfelt Sep 24, 2026
19baec0
fix: clarify Gradle classpath identity contract
perNyfelt Sep 24, 2026
9179bfe
docs: clarify Gradle classpath identity limits
perNyfelt Sep 24, 2026
f1d0e21
fix: track runtime classpath and fence context
perNyfelt Sep 24, 2026
cfc650e
fix: defer Gradle runtime resolution in config cache
perNyfelt Sep 24, 2026
70dffdb
fix: retain legacy Gradle classpath property
perNyfelt Sep 24, 2026
a2b9b83
docs: describe Gradle runtime configuration
perNyfelt Sep 24, 2026
688b4bd
docs: align Maven GMD version example
perNyfelt Sep 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
````
Expand Down Expand Up @@ -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}"
Expand All @@ -188,22 +189,22 @@ Maven:
<dependency>
<groupId>se.alipsa.gmd</groupId>
<artifactId>gmd-core</artifactId>
<version>3.1.0</version>
<version>3.2.0</version>
</dependency>
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy</artifactId>
<version>5.1.1</version>
<version>5.1.3</version>
</dependency>
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy-templates</artifactId>
<version>5.1.1</version>
<version>5.1.3</version>
</dependency>
<dependency>
<groupId>org.apache.groovy</groupId>
<artifactId>groovy-jsr223</artifactId>
<version>5.1.1</version>
<version>5.1.3</version>
</dependency>
<dependency>
<groupId>org.apache.ivy</groupId>
Expand Down
16 changes: 16 additions & 0 deletions gmd-core/release.md
Original file line number Diff line number Diff line change
@@ -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 `<img>` HTML tag instead of `![alt](data-uri){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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down
162 changes: 160 additions & 2 deletions gmd-core/src/main/groovy/se/alipsa/gmd/core/GmdTemplateEngine.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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"
*/
Expand All @@ -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<String> lines = text.readLines()
int documentIndent = baseIndent(lines)
int count = 0
lines.each { line ->
noSpaceLine = fenceCandidate(line)
Expand All @@ -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")) {
Expand All @@ -88,7 +109,7 @@ class GmdTemplateEngine {
}

if (codeBlockStart) {
codeBlockText.append(line).append('\n')
codeBlockText.append(dedent(line, codeBlockIndent)).append('\n')
}

if (codeBlockEnd) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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<String> 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
Expand Down
28 changes: 16 additions & 12 deletions gmd-core/src/main/groovy/se/alipsa/gmd/core/Printer.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> 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<String, String> 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<String, String> attributes = [:]) {
Expand All @@ -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<String, String> 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<String, String> attributes = [:]) {
println(chartToMd(x, width, height, alt, attributes))
}

void print(MatrixXChart x, String alt = '', Map<String, String> attributes = [:]) {
printChart(chartToMd(x, alt, attributes))
}
Expand Down
Loading
Loading