Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,5 @@ build/
.claude/
# Internal planning, audit, and roadmap notes that should not ship.
docs/private/
# Scratch sandbox project used to try the library out by hand; not part of the repo.
/GP-Test/
47 changes: 38 additions & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,49 @@ and the project follows [Semantic Versioning](https://semver.org/).
commit's detached-module build; a release-prep commit can no longer tag with the drift.

### Fixed
- **Tables are pinned to GitHub-Flavored-Markdown parity — no more silent content loss.** The
Flexmark tables extension ran with its permissive defaults (column spans, multiple header rows,
trailing `[caption]` lines), all of which the mapper dropped without a trace: a `[caption]` after
a table vanished, and a header-less table mapped to nothing — its whole body disappeared, invisible
even to strict mode. Tables now parse as GFM (`COLUMN_SPANS=false`, one header row,
`WITH_CAPTION=false`, append/discard ragged columns), and a header-less table surfaces its source
as unsupported content (rejected in strict mode, shown verbatim otherwise) rather than being swallowed.
- **List-item continuation blocks keep the item's indent.** A second paragraph, code block or quote
inside a list item bypassed the marker paragraph's margin and rendered flush against the page margin
— left of its own bullet. Continuation blocks now carry the item's left indent (nested lists, which
compute their own indent, are unchanged).
- **Undefined footnote references render as their literal source.** A `[^label]` with no matching
definition was still assigned a number, leaving a dangling superscript and a gap in the footnote run.
It now renders as literal text (the CommonMark behaviour), mirroring how undefined link references are
already handled; defined footnotes number and back-link exactly as before.
- **Heading anchors are always document-unique.** The GitHub-style de-duplicator could hand out the
same `-N` slug twice when a later heading's own text slugified to an already-generated anchor (e.g.
headings `Foo`, `Foo`, `Foo 1`); it now bumps past any taken slug so every heading keeps a unique
anchor that `[TOC]` and `[text](#slug)` links resolve to.
- **Built-in dark themes ship a dark-tuned alert palette.** `DefaultMarkdownTheme.dark()` and
`GitHubTheme.dark()` inherited the light amber/blue alert accents, which fell below a readable
contrast on the near-black surface. A new `AlertColors.defaultDark()` (mirroring
`SyntaxColors.defaultDark()`) gives GitHub-dark-mode accents; both dark themes now use it.
- **Locale-independent lowercasing.** Custom-block type dispatch, `:::` callout variants, image-URL
scheme checks and inline-HTML detection used `String.toLowerCase()` without a locale, so under a
Turkish locale `:::INFO` lowercased to `ınfo` and missed its handler. All now use `Locale.ROOT`.
- **CLI `--version` tracks the module version.** It hard-coded `0.2.0-SNAPSHOT` and had already drifted
stale at the v0.2.0 tag; it now reads the Maven-filtered module version, so it can never diverge from
the pom the version-lockstep guard already enforces.
- **Footnotes cited from table cells are bidirectional too.** Cell paragraphs bypass the
paragraph renderer, so a footnote first cited inside a table cell had a forward link but a
dead back-link; the `fnref-N` back-anchor is now placed on the citing cell as well (list items
were covered in 0.2.0). A footnote cited only inside a *heading* still keeps just its forward
link — a heading's single anchor slot already carries its navigation slug.

### Changed
- **The custom-renderer seam is now truly open for headings and the table of contents.** The docs
advertise swapping the renderer for *any* node type, but a third-party `NodeRenderer<HeadingNode>`
or `NodeRenderer<TocNode>` outside the render package had no way to reach the planned heading slugs:
it was forced to call `RenderContext.headingAnchor(title)`, which allocates a *fresh* slug and
silently desynced the anchor from what `[TOC]` and `[text](#slug)` link to. `RenderContext.headingSlug(HeadingNode)`
and `RenderContext.tocEntries()` are now `public`, and `TocEntry` is a public record, so a custom
heading/TOC renderer can declare the same anchors the built-ins do and stay navigable.
- **The HTML-entity decoder now handles curly-quote and guillemet entities (all modes).** A
literal `&rsquo;` / `&ldquo;` / `&laquo;` … in the Markdown source now decodes to its character
(`’` `“` `«`) instead of rendering as the raw entity text. This applies regardless of the
Expand Down Expand Up @@ -56,14 +92,6 @@ and the project follows [Semantic Versioning](https://semver.org/).
manual two-pass) and every label a clickable jump. Heading nesting indents the label; an
optional title row; empty-heading and no-heading documents degrade exactly like the default
renderer. Runnable `BookTocExample` (paired with the page-number footer).
- **Book-style page-numbered table of contents — `BookTocRenderer`.** An opt-in alternative to
the default `[TOC]` link list: swap the `TocNode` renderer
(`MarkdownTheme.builder(base).renderer(TocNode.class, new BookTocRenderer("Contents"))`) and
the marker renders as dot-leader contents rows — "Introduction ….. 3" — with **page numbers
resolved automatically from the laid-out document** (the engine's `addTableOfContents`; no
manual two-pass) and every label a clickable jump. Heading nesting indents the label; an
optional title row; empty-heading and no-heading documents degrade exactly like the default
renderer. Runnable `BookTocExample` (paired with the page-number footer).
- **Vector colour emoji out of the box — optional `graph-compose-emoji` support.** With the
companion artifact on the classpath (declared `optional`, mirroring `graph-compose-fonts`),
`:shortcode:` emoji render as crisp Noto vector glyphs at any size — no user-supplied images.
Expand Down Expand Up @@ -373,5 +401,6 @@ are grouped by area.
theme-composition tests; and `EngineRobustnessTest` (code-fence `:::`,
unsupported-content handling, strict mode, theme immutability).

[Unreleased]: https://github.com/DemchaAV/graphcompose-markdown/compare/v0.1.0...HEAD
[Unreleased]: https://github.com/DemchaAV/graphcompose-markdown/compare/v0.2.0...HEAD
[v0.2.0]: https://github.com/DemchaAV/graphcompose-markdown/releases/tag/v0.2.0
[v0.1.0]: https://github.com/DemchaAV/graphcompose-markdown/releases/tag/v0.1.0
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Markdown ──Flexmark──▶ Flexmark AST ──mapper──▶ Semantic mod
types downstream)
```

> Status: `0.1.0` released; `0.2.0` in development. The API may still change before `1.0.0`.
> Status: `0.2.0` released; `0.3.0` in development. The API may still change before `1.0.0`.

## Showcase

Expand Down
Binary file modified assets/readme/manual-dark.pdf
Binary file not shown.
4 changes: 4 additions & 0 deletions assets/readme/manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ The library is a single Maven dependency; the GraphCompose engine comes in trans
</dependency>
```

> [!NOTE]
> Not yet on Maven Central (first release pending). Until then, build from source
> (`./mvnw install`) and depend on the snapshot, or consume a tagged version via JitPack.

Two **optional** companion artifacts unlock extras; add them only if you want them:

| Artifact | What it adds |
Expand Down
Binary file modified assets/readme/manual.pdf
Binary file not shown.
20 changes: 19 additions & 1 deletion cli/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<gcmd.version>0.3.0-SNAPSHOT</gcmd.version>
<picocli.version>4.7.6</picocli.version>
<junit.bom.version>6.1.0</junit.bom.version>
<junit.bom.version>6.1.1</junit.bom.version>
<assertj.version>3.27.7</assertj.version>
<surefire.plugin.version>3.5.6</surefire.plugin.version>
<!-- The CLI is never published as a Maven artifact. -->
Expand Down Expand Up @@ -92,6 +92,24 @@

<build>
<finalName>graph-compose-markdown-cli</finalName>
<resources>
<!-- Filter gcmd-cli.properties so ${project.version} is baked in at build time;
the CLI's version flag reads it and thus tracks the module version automatically. -->
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
<includes>
<include>gcmd-cli.properties</include>
</includes>
</resource>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
<excludes>
<exclude>gcmd-cli.properties</exclude>
</excludes>
</resource>
</resources>
<plugins>
<!-- Run the CLI tests during `package` (pinned so a bare `-f cli/pom.xml package`
in CI does not depend on the ambient default Surefire version). -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import java.nio.file.Path;
import java.util.Locale;
import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.Callable;

/**
Expand All @@ -31,7 +32,7 @@
@Command(
name = "gcmd",
mixinStandardHelpOptions = true,
version = "graph-compose-markdown CLI 0.2.0-SNAPSHOT",
versionProvider = MarkdownCli.ModuleVersionProvider.class,
sortOptions = false,
description = "Render a Markdown file to a themed PDF (powered by graph-compose-markdown).")
public final class MarkdownCli implements Callable<Integer> {
Expand Down Expand Up @@ -177,6 +178,24 @@ private static MarkdownTheme resolveTheme(String name) {
}
}

/**
* Supplies {@code --version} from the Maven-filtered {@code gcmd-cli.properties} resource so
* the reported version tracks the module {@code <version>} automatically. Replaces a hard-coded
* string that silently drifted to a stale {@code 0.2.0-SNAPSHOT} at the v0.2.0 tag.
*/
static final class ModuleVersionProvider implements CommandLine.IVersionProvider {
@Override
public String[] getVersion() throws IOException {
Properties props = new Properties();
try (var in = MarkdownCli.class.getResourceAsStream("/gcmd-cli.properties")) {
if (in != null) {
props.load(in);
}
}
return new String[] {"graph-compose-markdown CLI " + props.getProperty("version", "unknown")};
}
}

/** Resolves {@code :shortcode:} to {@code <dir>/<shortcode>.png} on the filesystem. */
private static EmojiResolver fileEmojiResolver(Path dir) {
return shortcode -> {
Expand Down
4 changes: 4 additions & 0 deletions cli/src/main/resources/gcmd-cli.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Maven-filtered at build time so the CLI's --version tracks the module version
# automatically and can never drift from the pom (a hard-coded string read a stale
# "0.2.0-SNAPSHOT" at the v0.2.0 tag).
version=${project.version}
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,25 @@ void unknownThemeExitsWithCodeTwo() throws IOException {
}

@Test
void versionFlagExitsZero() {
CliResult result = run("--version");
void versionFlagPrintsTheModuleVersion() {
// --version prints to stdout; capture it and assert it carries a real version number
// rather than a stale hard-coded literal (the string used to drift from the pom).
PrintStream originalOut = System.out;
ByteArrayOutputStream stdout = new ByteArrayOutputStream();
System.setOut(new PrintStream(stdout, true, StandardCharsets.UTF_8));
int exitCode;
try {
exitCode = MarkdownCli.newCommandLine().execute("--version");
} finally {
System.setOut(originalOut);
}

assertThat(result.exitCode()).isZero();
assertThat(exitCode).isZero();
String out = stdout.toString(StandardCharsets.UTF_8);
assertThat(out).contains("graph-compose-markdown CLI");
// A concrete version (filtered from the module <version>), never the literal placeholder.
assertThat(out).containsPattern("CLI \\d+\\.\\d+");
assertThat(out).doesNotContain("${project.version}");
}

@Test
Expand Down
8 changes: 8 additions & 0 deletions docs/custom-renderers.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,11 @@ MarkdownTheme book = MarkdownTheme.builder(DefaultMarkdownTheme.light())

The same seam accepts your own renderer for any node type — tables, code blocks,
headings — while every other component is reused.

A heading or TOC renderer needs the anchors the built-ins plan up front (so its links stay
navigable). Those are on `RenderContext`: `ctx.headingSlug(headingNode)` returns the planned,
document-unique slug for a heading — use it rather than `ctx.headingAnchor(title)`, which
allocates a *fresh* slug and would desync your anchor from what `[TOC]` and `[text](#slug)`
jump to — and `ctx.tocEntries()` returns the document's headings (level, text, slug) as public
`TocEntry` records for a custom `NodeRenderer<TocNode>` to lay out. `BookTocRenderer` is built on
exactly these.
2 changes: 1 addition & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ From the repository root:
./mvnw -B -ntp -DskipTests install
```

This installs `io.github.demchaav:graph-compose-markdown:0.1.0-SNAPSHOT` into your
This installs `io.github.demchaav:graph-compose-markdown:0.3.0-SNAPSHOT` into your
local Maven repository, which these examples depend on.

## 2. Run an example
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ private List<MarkdownNode> parseBlocks(String text, Map<String, Integer> footnot
Matcher open = OPEN.matcher(line.strip());
if (isOpen(line) && open.matches()) {
flushNormal(normal, result, footnoteNumbers);
String type = open.group(1).toLowerCase();
String type = open.group(1).toLowerCase(java.util.Locale.ROOT);
String variant = open.group(2);

int depth = 1;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ public Optional<byte[]> resolve(String source) {
if (source == null || source.isBlank()) {
return Optional.empty();
}
String lower = source.toLowerCase();
String lower = source.toLowerCase(java.util.Locale.ROOT);
if (lower.startsWith("http://") || lower.startsWith("https://")) {
return Optional.empty(); // remote loading is opt-in via a custom resolver
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,9 @@ public List<FootnoteDefinitionNode> footnoteDefinitions(com.vladsch.flexmark.uti

private void assignFootnoteNumbers(Node node, Map<String, Integer> numbers, int[] next) {
for (Node child = node.getFirstChild(); child != null; child = child.getNext()) {
if (child instanceof Footnote footnote) {
if (child instanceof Footnote footnote && footnote.getFootnoteBlock() != null) {
// Only a DEFINED [^label] gets a number; an undefined one renders as literal text
// (below), so numbering it would leave a dangling superscript and a gap in the run.
String label = footnoteLabel(footnote.getText());
if (!label.isEmpty() && !numbers.containsKey(label)) {
numbers.put(label, next[0]++);
Expand Down Expand Up @@ -343,8 +345,13 @@ private MarkdownNode mapTable(TableBlock table) {
}
}

// A GFM table is only valid with a header (which defines the columns).
return alignments.isEmpty() ? null : new TableNode(alignments, header, bodyRows);
// A GFM table is only valid with a header (which defines the columns). Rather than
// dropping a header-less table to null — which would silently swallow its whole body,
// invisible even to strict mode — surface its source like any other unmodelled block.
if (alignments.isEmpty()) {
return new UnsupportedBlockNode(table.getNodeName(), table.getChars().toString());
}
return new TableNode(alignments, header, bodyRows);
}

private List<TableCellNode> mapRow(TableRow row) {
Expand Down Expand Up @@ -452,7 +459,9 @@ private InlineNode mapInline(Node node) {
}
if (node instanceof Footnote footnote) {
Integer number = footnoteNumbers.get().get(footnoteLabel(footnote.getText()));
return number != null ? new FootnoteRefRun(number) : null;
// An undefined [^label] was never numbered; render its literal source (CommonMark keeps
// the brackets) as plain text rather than a dangling superscript with no note to reach.
return number != null ? new FootnoteRefRun(number) : new TextRun(footnote.getChars().toString());
}
if (node instanceof Emoji emoji) {
return new EmojiRun(emoji.getText().toString());
Expand Down Expand Up @@ -489,7 +498,7 @@ private InlineNode mapInline(Node node) {
}
if (node instanceof HtmlInline inline) {
String raw = inline.getChars().toString();
String html = raw.trim().toLowerCase();
String html = raw.trim().toLowerCase(java.util.Locale.ROOT);
if (html.equals("<br>") || html.equals("<br/>") || html.equals("<br />")) {
return new LineBreakRun(true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ public FlexmarkMarkdownParser(boolean smartPunctuation) {
extensions.add(TypographicExtension.create());
}
options.set(Parser.EXTENSIONS, extensions);
// Pin the tables extension to GitHub-Flavored-Markdown semantics. Flexmark's defaults
// otherwise accept column spans (`| a || b |`), multiple header rows and trailing
// `[caption]` lines — none of which are GFM — and the mapper silently drops that extra
// structure, so a document could lose cells or a whole caption without warning.
options.set(TablesExtension.COLUMN_SPANS, false)
.set(TablesExtension.MIN_HEADER_ROWS, 1)
.set(TablesExtension.MAX_HEADER_ROWS, 1)
.set(TablesExtension.APPEND_MISSING_COLUMNS, true)
.set(TablesExtension.DISCARD_EXTRA_COLUMNS, true)
.set(TablesExtension.WITH_CAPTION, false)
.set(TablesExtension.HEADER_SEPARATOR_COLUMN_MATCH, true);
this.parser = Parser.builder(options).build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ private static DocumentColor accentFor(String variant, AlertColors colors, Docum
if (variant == null) {
return fallback;
}
switch (variant.toLowerCase()) {
switch (variant.toLowerCase(java.util.Locale.ROOT)) {
case "warning":
case "caution":
return colors.calloutWarning();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,17 @@ private static void renderItem(ListItemNode item, SectionBuilder listSection, Re
for (int i = start; i < content.size(); i++) {
MarkdownNode child = content.get(i);
if (child instanceof ListNode nested) {
// A nested list computes its own absolute indent from depth, so it is added
// straight into the item section (wrapping it would double-indent it).
renderList(nested, itemSec, ctx, depth + 1);
} else {
ctx.renderBlock(child, itemSec);
// A continuation block (second paragraph, code block, quote, …) bypasses the
// marker paragraph's margin, so wrap it in a section carrying the item's left
// indent — otherwise it renders flush against the page margin, left of the marker.
itemSec.addSection(contSec -> {
contSec.margin(new DocumentInsets(0, 0, 0, indent));
ctx.renderBlock(child, contSec);
});
}
}
});
Expand Down
Loading