Groovy has an optional groovy-markdown module which provides support for
parsing CommonMark Markdown. The classes are found
in the groovy.markdown package.
1. MarkdownSlurper
MarkdownSlurper parses Markdown text into a MarkdownDocument backed by
nested lists and maps. Each node is a Map with a type key plus
type-specific fields:
def doc = new MarkdownSlurper().parseText('# Hello World')
def h = doc.headings[0]
assert h.level == 1
assert h.text == 'Hello World'
The raw structure supports all standard Groovy list/map operations. The
document also exposes convenience properties — headings, codeBlocks,
links, tables — that recursively walk the tree.
1.1. Extracting code blocks
A common pattern when consuming LLM output is to pull fenced code blocks by language:
def md = '''
Some intro text.
```groovy
println 'hi'
```
```sql
SELECT 1
```
'''.stripIndent()
def doc = new MarkdownSlurper().parseText(md)
def groovySnippets = doc.codeBlocks.findAll { it.lang == 'groovy' }*.text
assert groovySnippets == ["println 'hi'\n"]
codeBlocks walks the entire tree, so blocks nested inside block quotes
or list items are included.
1.2. Links
def doc = new MarkdownSlurper().parseText('See [docs](https://example.com/docs).')
def link = doc.links[0]
assert link.href == 'https://example.com/docs'
assert link.text == 'docs'
1.3. Sections
section(headingText) returns the nodes between the given heading and the
next heading of equal or higher level — useful for parsing structured
agent replies:
def md = '''
# Title
## Summary
The work is on track.
## Next Steps
- Item one
- Item two
'''.stripIndent()
def doc = new MarkdownSlurper().parseText(md)
def summary = doc.section('Summary')
assert summary[0].type == 'paragraph'
assert summary[0].children[0].value == 'The work is on track.'
def next = doc.section('Next Steps')
assert next[0].type == 'list'
assert next[0].items*.text == ['Item one', 'Item two']
1.4. Tables (optional)
GFM-style tables are supported when the
org.commonmark:commonmark-ext-gfm-tables jar is on the classpath. Call
enableTables(true) on the slurper:
def md = '''
| name | age |
|-------|-----|
| Alice | 30 |
| Bob | 25 |
'''.stripIndent()
def doc = new MarkdownSlurper().enableTables(true).parseText(md)
def rows = doc.tables[0].rows
assert rows[0].name == 'Alice'
assert rows[0].age == '30'
assert rows[1].name == 'Bob'
Each row is returned as a Map keyed by header.
1.5. Node types
| type | fields |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(no fields) |
|
(no fields) |
|
|
1.6. Untrusted input
Prefer parsing Markdown from trusted sources. Like the sibling JsonSlurper
and XmlSlurper, MarkdownSlurper is a convenience parser, not a security
boundary, so the safest choice is not to hand it attacker-controlled input. If
you must, bound the input by size yourself before parsing and treat the parsed
result defensively.
As a backstop, MarkdownSlurper caps how deeply a document may nest, so that a
small but pathologically nested input — thousands of nested block quotes, or a
long run of emphasis markers — is reported as a MarkdownRuntimeException
rather than being allowed to exhaust the stack with a StackOverflowError. The
limit defaults to 1000 and can be set per instance or globally via the
groovy.markdown.maxNestingDepth system property; a value of 0 or less
disables the check.
def slurper = new MarkdownSlurper()
slurper.maxNestingDepth = 100 // cap block nesting (default 1000; 0 disables the check)
// a hostile, deeply nested document is rejected, not allowed to crash the parser
def rejected = false
try {
slurper.parseText(('>' * 5000) + ' text')
} catch (MarkdownRuntimeException ignored) {
rejected = true
}
assert rejected
This cap is a robustness backstop, not a licence to treat the parser as hardened.