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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

Notable changes per release. Older history is in the git log.

## 2.1.19 - 2026-09-01

### Added

- `--flavour github` renders admonitions as GitHub alerts, so `@note` becomes `> [!NOTE]` rather than the Pandoc `:::note` container that GitHub shows as literal text. `@warning` maps to `[!WARNING]`, `@attention` to `[!IMPORTANT]`, `@remark` to `[!NOTE]`. The default stays `pandoc` with unchanged output, and `--flavor` is accepted too. Requested by @gkodinov in #126.

### Fixed

- Nested lists keep their nesting, and list items no longer have a blank line between them. Indentation is measured against the parent item's marker, so a list under `1. ` indents three spaces and one under `* ` indents two, which is what Markdown requires to nest. Reported by @gkodinov in #115.
- Definition lists no longer put a blank line between terms.
- An admonition inside a list item sits at the item's content column instead of ending the list.

## 2.1.18 - 2026-09-01

### Changed
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ Options:
-a, --anchors add anchors to internal links
-H, --html-anchors add HTML anchors to internal links
-l, --language <lang> programming language (default: cpp)
-F, --flavour <name> markdown dialect for admonitions: pandoc or github (default: pandoc)
-t, --templates <dir> custom templates directory
-s, --source-root <dir>
source root used to resolve file-level group membership
Expand Down
1 change: 1 addition & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ moxygen [options] <doxygen XML directory>
| `-a, --anchors` | Add anchors to internal links |
| `-H, --html-anchors` | Add HTML anchors to internal links |
| `-l, --language <lang>` | Programming language (default: `cpp`) |
| `-F, --flavour <name>` | Markdown dialect for admonitions: `pandoc` or `github` (default: `pandoc`) |
| `-t, --templates <dir>` | Custom templates directory |
| `-s, --source-root <dir>` | Source root for file-level group resolution |
| `-f, --frontmatter` | Prepend YAML frontmatter to output files |
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "moxygen",
"version": "2.1.18",
"version": "2.1.19",
"description": "Doxygen XML to Markdown converter",
"type": "module",
"main": "dist/index.js",
Expand Down
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ program
.option('-a, --anchors', 'add anchors to internal links', false)
.option('-H, --html-anchors', 'add HTML anchors to internal links', false)
.option('-l, --language <lang>', 'programming language', 'cpp')
.option('-F, --flavour <name>', 'markdown dialect for admonitions: pandoc or github', 'pandoc')
.option('--flavor <name>', 'alias for --flavour')
.option('-t, --templates <dir>', 'custom templates directory')
.option('-s, --source-root <dir>', 'source root used to resolve file-level group membership')
.option('-f, --frontmatter', 'prepend YAML frontmatter to output files', false)
Expand All @@ -41,6 +43,7 @@ program
anchors: opts.anchors as boolean,
htmlAnchors: opts.htmlAnchors as boolean,
language: opts.language as string,
flavour: (opts.flavor ?? opts.flavour) as string,
templates: opts.templates as string | undefined,
sourceRoot: opts.sourceRoot as string | undefined,
frontmatter: opts.frontmatter as boolean,
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export const defaultOptions: MoxygenOptions = {
anchors: true,
htmlAnchors: false,
language: 'cpp',
flavour: 'pandoc',
templates: 'templates',
titleSeparator: ' - ',
quiet: false,
Expand Down
120 changes: 87 additions & 33 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,72 @@ function currentContextName(context: XmlElement[]): string | undefined {
return context[context.length - 1]?.['#name'];
}

const LIST_ELEMENTS = new Set(['orderedlist', 'itemizedlist', 'variablelist']);

/** Doxygen sections that read as a callout rather than as prose. */
const ADMONITIONS: Record<string, { container: string; alert: string }> = {
note: { container: 'note', alert: 'NOTE' },
remark: { container: 'note', alert: 'NOTE' },
warning: { container: 'warning', alert: 'WARNING' },
attention: { container: 'warning', alert: 'IMPORTANT' },
deprecated: { container: 'warning', alert: 'CAUTION' },
};

/**
* Render a callout in the configured dialect. Pandoc-style containers are the
* default; GitHub renders those as literal text and wants blockquote alerts,
* where every line of the body carries the quote marker.
*/
function renderAdmonition(kind: string, body: string, flavour: string, indent: string): string {
const admonition = ADMONITIONS[kind];
const prefixed = kind === 'deprecated' ? `**Deprecated.** ${body}` : body;

const lines = flavour === 'github'
? [`> [!${admonition.alert}]`, ...prefixed.trim().split('\n').map((line) => (line.trim() ? `> ${line}` : '>'))]
: [`:::${admonition.container}`, ...prefixed.split('\n'), ':::'];

// Inside a list item the whole block has to sit at the item's content
// column, or it ends the list instead of belonging to it, and it closes on a
// single newline so the next item follows on.
const block = lines.map((line) => (line ? indent + line : line)).join('\n');
return `\n${block}\n${indent ? '' : '\n'}`;
}

function listMarker(name: string | undefined): string {
return name === 'orderedlist' ? '1. ' : name === 'variablelist' ? ': ' : '* ';
}

function enclosingLists(context: XmlElement[]): XmlElement[] {
return context.filter((el) => LIST_ELEMENTS.has(el['#name'] ?? ''));
}

/** Column at which the content of the current list item continues. */
function listContentIndent(context: XmlElement[]): string {
return enclosingLists(context)
.map((el) => ' '.repeat(listMarker(el['#name']).length))
.join('');
}

/**
* Indent for a list item, which Markdown measures against the marker of the
* item it nests under, so an ordered parent indents further than a bullet.
*/
function listIndent(context: XmlElement[]): string {
return enclosingLists(context)
.slice(0, -1)
.map((el) => ' '.repeat(listMarker(el['#name']).length))
.join('');
}

function insideList(context: XmlElement[]): boolean {
return LIST_ELEMENTS.has(currentContextName(context) ?? '');
}

/** Markdown needs exactly one newline between list items, never a blank one. */
function endLine(text: string): string {
return text.endsWith('\n') ? text : `${text}\n`;
}

function headingLevel(context: XmlElement[]): number {
const parent = context[context.length - 1];
const match = parent?.['#name']?.match(/^sect([1-6])$/);
Expand Down Expand Up @@ -112,29 +178,23 @@ function toMarkdown(element: unknown, context: XmlElement[] = [], options: Markd
s = '\n```\n';
break;
case 'orderedlist':
context.push(el);
s = '\n\n';
break;
case 'itemizedlist':
context.push(el);
s = '\n\n';
break;
case 'variablelist':
// A nested list continues the item above it rather than starting a
// fresh block, so it opens on the next line, not after a blank one.
s = enclosingLists(context).length > 0 ? '\n' : '\n\n';
context.push(el);
s = '\n\n';
break;
case 'varlistentry':
s = '\n* ';
// The entry before this one already ended its line, so starting with a
// newline here would put a blank line between every term.
s = listIndent(context) + '* ';
break;
case 'term':
s = '**';
break;
case 'listitem':
s = currentContextName(context) === 'orderedlist'
? '1. '
: currentContextName(context) === 'variablelist'
? ': '
: '* ';
s = listIndent(context) + listMarker(currentContextName(context));
break;
case 'sp':
s = ' ';
Expand All @@ -148,12 +208,10 @@ function toMarkdown(element: unknown, context: XmlElement[] = [], options: Markd
case 'simplesect': {
const kind = el.$?.kind;
context.push(el);
if (kind === 'attention' || kind === 'warning') {
s = '\n:::warning\n';
} else if (kind === 'note' || kind === 'remark') {
s = '\n:::note\n';
} else if (kind === 'deprecated') {
s = '\n:::warning\n**Deprecated.** ';
if (kind && ADMONITIONS[kind]) {
// The body is wrapped when the section closes, once its content is
// known: GitHub alerts have to prefix every line of it.
s = '';
} else if (kind === 'return') {
s = '\n#### Returns\n';
} else if (kind === 'see') {
Expand Down Expand Up @@ -251,18 +309,20 @@ function toMarkdown(element: unknown, context: XmlElement[] = [], options: Markd
switch (el['#name']) {
case 'simplesect': {
const closeKind = el.$?.kind;
if (closeKind === 'attention' || closeKind === 'warning' || closeKind === 'note' || closeKind === 'remark' || closeKind === 'deprecated') {
s += '\n:::\n\n';
} else {
s += '\n\n';
}
s = closeKind && ADMONITIONS[closeKind]
? renderAdmonition(closeKind, s, parserOptions?.flavour ?? 'pandoc', listContentIndent(context))
: `${s}\n\n`;
context.pop();
break;
}
case 'parameterlist':
case 'para':
s += '\n\n';
break;
case 'para':
// A paragraph is a block on its own, except inside a list item, where a
// blank line would break the item away from its own list.
s = insideList(context) ? endLine(s) : `${s}\n\n`;
break;
case 'emphasis':
s += '*';
break;
Expand Down Expand Up @@ -296,19 +356,13 @@ function toMarkdown(element: unknown, context: XmlElement[] = [], options: Markd
s = md.safeLink(s, el.$?.url ?? '');
break;
case 'orderedlist':
context.pop();
s += '\n';
break;
case 'itemizedlist':
context.pop();
s += '\n';
break;
case 'variablelist':
context.pop();
s += '\n';
s = endLine(s);
break;
case 'listitem':
s += '\n';
s = endLine(s);
break;
case 'term':
s += '**';
Expand Down
8 changes: 3 additions & 5 deletions src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ const DEFAULT_RENDER_CONTEXT: RenderContext = {
headingBase: 1,
};

const MARKDOWN_LINK = /\[([^\]]+)\]\(([^)]+)\)/g;
const MARKDOWN_LINK = /\[([^\]]+)\]\(([^)]+)\)/;
const MARKDOWN_LINKS = /\[([^\]]+)\]\(([^)]+)\)/g;

/**
* Render a type for a table cell.
Expand All @@ -34,11 +35,8 @@ function typeCell(type: string): string {
const trimmed = (type || '').trim();
if (!trimmed) return '';

MARKDOWN_LINK.lastIndex = 0;
if (!MARKDOWN_LINK.test(trimmed)) return `\`${trimmed}\``;

MARKDOWN_LINK.lastIndex = 0;
return trimmed.replace(MARKDOWN_LINK, '[`$1`]($2)');
return trimmed.replace(MARKDOWN_LINKS, '[`$1`]($2)');
}

function headingLevel(relativeLevel: unknown, context: RenderContext): number {
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface MoxygenOptions {
anchors: boolean;
htmlAnchors: boolean;
language: string;
/** Markdown dialect for admonitions: 'pandoc' containers or 'github' alerts. */
flavour: string;
templates: string;
/** Separator joining qualified name, owner, signature and summary in search snippets. */
titleSeparator: string;
Expand Down
45 changes: 45 additions & 0 deletions test/fixtures/macros/src/macros.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,51 @@
*/
void reset(int);

/**
Flush pending work.

@note Flushing is best effort.
@remark Callers usually prefer drain().
@warning Blocks until the queue is empty.
@attention Never call this from a signal handler.
@deprecated Use drain() instead.
*/
void flush(void);

/**
Drain the queue.

Ordering rules:

- first
- first.a
- first.b
- second
- second.a
- third

Steps:

1. open
1. acquire
2. verify
2. close

Terms:

<dl>
<dt>pending</dt><dd>queued but not started</dd>
<dt>settled</dt><dd>finished either way</dd>
</dl>

Caveats:

- the queue may grow while draining
@note callers should not rely on the count
- draining twice is safe
*/
void drain(void);

/**
Registry of handler hooks.
*/
Expand Down
18 changes: 18 additions & 0 deletions test/fixtures/macros/xml-out/xml/deprecated.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version='1.0' encoding='UTF-8' standalone='no'?>
<doxygen xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="compound.xsd" version="1.17.0" xml:lang="en-US">
<compounddef id="deprecated" kind="page">
<compoundname>deprecated</compoundname>
<title>Deprecated List</title>
<briefdescription>
</briefdescription>
<detaileddescription>
<para><variablelist>
<varlistentry><term>Member <ref refid="macros_8h_1a0a9e9396972b76c5947592479860020d" kindref="member">flush</ref> (void)</term></varlistentry>
<listitem><para><anchor id="deprecated_1_deprecated000001"/>Use <ref refid="macros_8h_1a8168e46e61860b969b9ee6c101bbc1c9" kindref="member">drain()</ref> instead. </para>
</listitem>
</variablelist>
</para>
</detaileddescription>
<location file="deprecated"/>
</compounddef>
</doxygen>
4 changes: 4 additions & 0 deletions test/fixtures/macros/xml-out/xml/index.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,12 @@
<member refid="macros_8h_1a1ee636c8ea8ef76556c00290eb5a289b" kind="define"><name>RETRY_HOOK</name></member>
<member refid="macros_8h_1a37285142ae15ca3cdc36f3a03faeb0c9" kind="variable"><name>registry</name></member>
<member refid="macros_8h_1aa77e5763e57ffef3a8c0f08af40a73f8" kind="function"><name>reset</name></member>
<member refid="macros_8h_1a0a9e9396972b76c5947592479860020d" kind="function"><name>flush</name></member>
<member refid="macros_8h_1a8168e46e61860b969b9ee6c101bbc1c9" kind="function"><name>drain</name></member>
<member refid="macros_8h_1af6f5804d6181b428b0abd148e8005ff6" kind="function"><name>configure</name></member>
</compound>
<compound refid="deprecated" kind="page"><name>deprecated</name>
</compound>
<compound refid="dir_68267d1309a1af8e8297ef4c3efbcdba" kind="dir"><name>src</name>
</compound>
</doxygenindex>
Loading