moxygen
Version:
Doxygen XML to Markdown converter
1,071 lines • 41.2 kB
JavaScript
import { existsSync, readFileSync } from 'node:fs';
import { join, dirname, isAbsolute, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { toArray, toFilteredArray, filterChildren, filterCollection, filterNoise, groupMembersBySection } from './compound.js';
import { writeCompound, renderCompound, compoundPath, writeFile, buildCleanAnchorMap, safePathSegment, stripMarkdownLinks } from './helpers.js';
import { log } from './logger.js';
import { loadIndex } from './parser.js';
import * as templates from './templates.js';
import { setAnchorMap } from './templates.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
export const defaultFilters = {
members: [
'define',
'enum',
'typedef',
'func',
'friend',
'var',
'property',
'public-attrib',
'public-func',
'public-static-attrib',
'public-static-func',
'protected-attrib',
'protected-func',
'signal',
'public-slot',
'protected-slot',
'public-type',
'private-attrib',
'private-func',
'private-slot',
'private-static-attrib',
'private-static-func',
],
compounds: [
'namespace',
'class',
'struct',
'union',
'typedef',
'interface',
'enum',
'concept',
],
};
export const defaultOptions = {
directory: '',
output: 'api.md',
groups: false,
classes: false,
pages: false,
noindex: false,
anchors: true,
htmlAnchors: false,
language: 'cpp',
templates: 'templates',
titleSeparator: ' - ',
quiet: false,
frontmatter: false,
filters: defaultFilters,
inlineGroups: false,
};
// ---------------------------------------------------------------------------
// Shared setup
// ---------------------------------------------------------------------------
function resolveOptions(options) {
const opts = { ...defaultOptions, ...options };
if (!opts.filters) {
opts.filters = defaultFilters;
}
if (!options.output) {
opts.output = opts.classes || opts.groups
? 'api_%s.md'
: 'api.md';
}
if ((opts.classes || opts.groups) && !opts.output.includes('%s')) {
throw new Error("The `output` file parameter must contain '%s' for group or class name " +
'substitution when `groups` or `classes` are enabled.');
}
if (!options.templates) {
opts.templates = join(__dirname, '..', 'templates', opts.language);
}
return opts;
}
async function loadAndPrepare(opts) {
log.init(opts);
templates.registerHelpers(opts);
templates.load(opts.templates);
return loadIndex(opts);
}
function extractRawDescription(compound) {
return compound.briefdescription || firstSentence(compound.detaileddescription) || '';
}
function extractMeta(compound, description = extractRawDescription(compound)) {
const ns = findNamespace(compound);
const group = findGroup(compound);
const title = compound.kind === 'index' && !compound.name
? 'API Reference'
: qualifiedTitle(compound);
return {
slug: slugify(compound.name),
title,
kind: compound.kind,
module: group?.name,
namespace: ns?.fullname || compound.namespace || undefined,
header: compound.includes,
description,
};
}
function extractResolvedMeta(compound, references, options, anchorMap, slugMap, pagePathMap) {
const description = renderCompound(compound, [extractRawDescription(compound)], references, options, anchorMap, slugMap, pagePathMap);
return extractMeta(compound, description);
}
/**
* Generate YAML frontmatter string from metadata.
*/
export function generateFrontmatter(meta) {
const lines = ['---'];
lines.push(`title: "${meta.title}"`);
if (meta.description)
lines.push(`description: "${meta.description.replace(/"/g, '\\"')}"`);
if (meta.kind)
lines.push(`kind: ${meta.kind}`);
if (meta.module)
lines.push(`module: ${meta.module}`);
if (meta.namespace)
lines.push(`namespace: ${meta.namespace}`);
if (meta.header)
lines.push(`header: "${meta.header}"`);
lines.push('---', '');
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// generate() — library API returning structured pages (no disk I/O)
// ---------------------------------------------------------------------------
/**
* Apply noise filtering and section grouping to a compound after filterChildren.
*/
function prepareCompound(compound) {
compound.filtered.members = filterNoise(compound.filtered.members);
compound.filtered.sections = groupMembersBySection(compound);
}
function memberOwnerName(compound) {
return compound.fullname || compound.name || qualifiedTitle(compound);
}
function collectOwnAllMemberEntries(compound) {
const owner = memberOwnerName(compound);
const members = compound.filtered?.members?.length ? compound.filtered.members : compound.members;
return members.map((member) => ({
name: member.name,
kind: member.kind,
refid: member.refid,
owner,
ownerRefid: compound.refid,
inherited: false,
}));
}
function collectInheritedMemberGroups(compound, references) {
const seenBaseRefids = new Set();
const groups = [];
const visitBase = (baseRefid) => {
if (!baseRefid || seenBaseRefids.has(baseRefid))
return;
seenBaseRefids.add(baseRefid);
const base = references[baseRefid];
if (!base || !('members' in base))
return;
const members = base.filtered?.members?.length ? base.filtered.members : base.members;
if (members.length) {
groups.push({
name: memberOwnerName(base),
refid: base.refid,
members,
});
}
for (const parent of base.basecompoundref ?? []) {
visitBase(parent.refid);
}
};
for (const base of compound.basecompoundref ?? []) {
visitBase(base.refid);
}
return groups;
}
function attachRelationshipSummaries(compounds, references) {
for (const compound of compounds) {
compound.inheritedMemberGroups = collectInheritedMemberGroups(compound, references);
const inheritedEntries = compound.inheritedMemberGroups.flatMap((group) => group.members.map((member) => ({
name: member.name,
kind: member.kind,
refid: member.refid,
owner: group.name,
ownerRefid: group.refid,
inherited: true,
})));
compound.allMembers = [...collectOwnAllMemberEntries(compound), ...inheritedEntries]
.filter((entry) => entry.name && entry.refid);
}
}
const DOXYGEN_COMMENT_RE = /\/\*\*[\s\S]*?\*\/|\/\*![\s\S]*?\*\/|(?:^[ \t]*(?:\/\/\/|\/\/!).*(?:\r?\n|$))+/gm;
const ADDTOGROUP_MARKER_RE = /(?:@|\\)addtogroup\s+([A-Za-z_][\w:-]*)/g;
const INGROUP_MARKER_RE = /(?:@|\\)ingroup\s+([A-Za-z_][\w:-]*)/g;
const FILE_MARKER_RE = /(?:@|\\)file(?:\s|$)/;
function addGroupMarkerTags(source, marker, tags) {
marker.lastIndex = 0;
let match;
while ((match = marker.exec(source)) !== null) {
tags.add(match[1]);
}
}
function collectSourceGroupTags(source) {
const tags = new Set();
DOXYGEN_COMMENT_RE.lastIndex = 0;
let comment;
while ((comment = DOXYGEN_COMMENT_RE.exec(source)) !== null) {
const text = comment[0];
addGroupMarkerTags(text, ADDTOGROUP_MARKER_RE, tags);
if (FILE_MARKER_RE.test(text)) {
addGroupMarkerTags(text, INGROUP_MARKER_RE, tags);
}
}
return [...tags];
}
function resolveSourcePath(location, options) {
const candidates = [];
const seen = new Set();
const pushCandidate = (candidate) => {
const resolved = resolve(candidate);
if (seen.has(resolved))
return;
seen.add(resolved);
candidates.push(resolved);
};
if (isAbsolute(location)) {
pushCandidate(location);
}
else {
if (options.sourceRoot) {
pushCandidate(join(options.sourceRoot, location));
}
pushCandidate(location);
let current = resolve(options.directory);
while (true) {
pushCandidate(join(current, location));
const parent = dirname(current);
if (parent === current)
break;
current = parent;
}
}
return candidates.find((candidate) => existsSync(candidate));
}
function readFileGroupTags(file, options) {
if (Array.isArray(file.fileGroupTags)) {
return file.fileGroupTags;
}
const location = typeof file.location === 'string' ? file.location : '';
if (!location) {
file.fileGroupTags = [];
return file.fileGroupTags;
}
const sourcePath = resolveSourcePath(location, options);
if (!sourcePath) {
file.fileGroupTags = [];
return file.fileGroupTags;
}
const source = readFileSync(sourcePath, 'utf8');
file.fileGroupTags = collectSourceGroupTags(source);
return file.fileGroupTags;
}
function markCompoundGroup(group, compound) {
if (compound.groupid && compound.groupid !== group.id)
return;
compound.groupid = group.id;
compound.groupname = group.name;
for (const member of compound.members) {
if (member.groupid && member.groupid !== group.id)
continue;
member.groupid = group.id;
member.groupname = group.name;
}
for (const child of Object.values(compound.compounds)) {
markCompoundGroup(group, child);
}
}
function attachCompoundToGroup(group, compound) {
if (compound.groupid && compound.groupid !== group.id)
return;
group.compounds[compound.id] = compound;
markCompoundGroup(group, compound);
}
function attachMemberToGroup(group, member) {
if (member.groupid && member.groupid !== group.id)
return;
member.groupid = group.id;
member.groupname = group.name;
if (!group.members.some((existing) => existing.refid === member.refid)) {
group.members.push(member);
}
}
function attachFileScopedNamespaceMembersToGroup(group, namespaceCompound, fileLocation) {
let attached = false;
for (const member of namespaceCompound.members) {
if (member.location === fileLocation) {
attachMemberToGroup(group, member);
attached = true;
}
}
for (const child of Object.values(namespaceCompound.compounds)) {
if (child.kind === 'namespace') {
if (attachFileScopedNamespaceMembersToGroup(group, child, fileLocation)) {
attached = true;
}
}
}
if (attached) {
const refs = group.fileScopedNamespaceRefs ?? [];
if (!refs.includes(namespaceCompound.refid)) {
refs.push(namespaceCompound.refid);
}
group.fileScopedNamespaceRefs = refs;
}
return attached;
}
function isNestedCompound(compound) {
const parent = compound.parent;
return !!parent && ['class', 'struct', 'union', 'interface'].includes(parent.kind);
}
function hasAncestorRefid(compound, refids) {
let current = compound.parent;
while (current) {
if (refids.has(current.refid)) {
return true;
}
current = current.parent;
}
return false;
}
function pruneTopLevelGroupCompounds(group) {
const topLevelRefids = new Set(Object.values(group.compounds).map((compound) => compound.refid));
for (const [id, compound] of Object.entries(group.compounds)) {
let current = compound.parent;
while (current) {
if (topLevelRefids.has(current.refid) && current.groupid === group.id) {
delete group.compounds[id];
break;
}
current = current.parent;
}
}
}
function collectSharedNamespaceRefs(files, options) {
const namespaceGroups = new Map();
for (const file of files) {
const tags = readFileGroupTags(file, options);
if (!tags.length)
continue;
for (const refid of (file.fileNamespaceRefs ?? [])) {
let groups = namespaceGroups.get(refid);
if (!groups) {
groups = new Set();
namespaceGroups.set(refid, groups);
}
for (const tag of tags) {
groups.add(tag);
}
}
}
const shared = new Set();
for (const [refid, groups] of namespaceGroups) {
if (groups.size > 1) {
shared.add(refid);
}
}
return shared;
}
function augmentGroupsFromFiles(root, groups, options) {
const files = toArray(root, 'compounds', 'file');
if (!files.length)
return;
const sharedNamespaceRefs = collectSharedNamespaceRefs(files, options);
const compoundsByRefid = new Map();
for (const compound of toArray(root, 'compounds')) {
compoundsByRefid.set(compound.refid, compound);
}
const groupsByName = new Map();
for (const group of groups) {
groupsByName.set(group.name, group);
}
for (const file of files) {
const tags = readFileGroupTags(file, options);
if (!tags.length)
continue;
for (const tag of tags) {
const group = groupsByName.get(tag);
if (!group)
continue;
let namespaceRefs = (file.fileNamespaceRefs ?? []).filter((refid) => !sharedNamespaceRefs.has(refid));
if (!namespaceRefs.length && !(file.fileCompoundRefs?.length || file.members.length)) {
namespaceRefs = [...(file.fileNamespaceRefs ?? [])];
}
const attachedNamespaceRefs = new Set();
for (const refid of namespaceRefs) {
const candidate = compoundsByRefid.get(refid);
if (!candidate || isJunkCompound(candidate))
continue;
attachCompoundToGroup(group, candidate);
attachedNamespaceRefs.add(refid);
}
const fileLocation = typeof file.location === 'string' ? file.location : '';
if (fileLocation) {
for (const refid of (file.fileNamespaceRefs ?? [])) {
if (!sharedNamespaceRefs.has(refid))
continue;
const candidate = compoundsByRefid.get(refid);
if (!candidate || candidate.kind !== 'namespace')
continue;
attachFileScopedNamespaceMembersToGroup(group, candidate, fileLocation);
}
}
for (const refid of (file.fileCompoundRefs ?? [])) {
const candidate = compoundsByRefid.get(refid);
if (!candidate)
continue;
if (isJunkCompound(candidate) || isNestedCompound(candidate))
continue;
if (hasAncestorRefid(candidate, attachedNamespaceRefs))
continue;
attachCompoundToGroup(group, candidate);
}
for (const member of file.members) {
attachMemberToGroup(group, member);
}
}
}
}
function finalizeGroups(groups, sharedNamespaceRefs) {
for (const group of groups) {
const groupedTopLevelCompounds = Object.values(group.compounds)
.filter((compound) => compound.groupid === group.id);
if (groupedTopLevelCompounds.length > 1) {
for (const compound of groupedTopLevelCompounds) {
if (sharedNamespaceRefs.has(compound.refid)) {
delete group.compounds[compound.id];
}
}
}
pruneTopLevelGroupCompounds(group);
}
}
/**
* Parse Doxygen XML and return structured pages with rendered markdown.
* Markdown is the body only; metadata is in the structured fields.
* Uses Doxygen groups (@defgroup/@addtogroup) as the primary module
* organization when available, falling back to namespaces.
*/
export async function generate(options) {
const opts = resolveOptions({ ...options, classes: true, anchors: false, htmlAnchors: true });
const { root, references } = await loadAndPrepare(opts);
const pages = [];
// Check if Doxygen groups exist (from @defgroup/@addtogroup)
const groups = toArray(root, 'compounds', 'group')
.filter((g) => !isJunkCompound(g));
const useGroups = groups.length > 0;
// First pass: filter and prepare all compounds
const allCompounds = [];
if (useGroups) {
const seenPrep = new Set();
augmentGroupsFromFiles(root, groups, opts);
finalizeGroups(groups, collectSharedNamespaceRefs(toArray(root, 'compounds', 'file'), opts));
const filters = groupAwareFilters(opts.filters);
// Group-based: each @defgroup becomes a module
for (const group of groups) {
filterChildren(group, filters, group.id);
prepareCompound(group);
allCompounds.push(group);
seenPrep.add(group.refid);
for (const child of toFilteredArray(group, 'compounds')) {
if (isJunkCompound(child))
continue;
if (child.kind === 'group') {
filterChildren(child, filters, child.id);
}
else {
filterChildren(child, opts.filters);
}
prepareCompound(child);
allCompounds.push(child);
seenPrep.add(child.refid);
}
}
// Also collect orphaned classes from namespaces not in any group
const allNamespaces = toArray(root, 'compounds', 'namespace')
.filter((c) => !isJunkCompound(c));
for (const ns of allNamespaces) {
filterChildren(ns, opts.filters);
for (const child of toFilteredArray(ns)) {
if (seenPrep.has(child.refid) || isJunkCompound(child))
continue;
filterChildren(child, opts.filters);
prepareCompound(child);
allCompounds.push(child);
seenPrep.add(child.refid);
}
}
}
else {
// Namespace-based fallback
const rootCompounds = toArray(root, 'compounds', 'namespace')
.filter((c) => !isJunkCompound(c));
for (const comp of rootCompounds) {
filterChildren(comp, opts.filters);
prepareCompound(comp);
allCompounds.push(comp);
for (const child of toFilteredArray(comp)) {
if (isJunkCompound(child))
continue;
filterChildren(child, opts.filters);
prepareCompound(child);
allCompounds.push(child);
}
}
}
// Build clean anchor map and slug map across all compounds
const anchorMap = buildCleanAnchorMap(allCompounds);
setAnchorMap(anchorMap);
const slugMap = new Map();
for (const c of allCompounds) {
slugMap.set(c.refid, slugify(c.name));
}
const pagePathMap = useGroups ? buildGroupedPagePathMap(groups) : undefined;
attachRelationshipSummaries(allCompounds, references);
// Second pass: render (dedup by refid)
const seen = new Set();
function emitPage(compound, moduleName) {
if (seen.has(compound.refid))
return;
seen.add(compound.refid);
if (isJunkCompound(compound))
return;
const md = templates.render(compound);
if (!md)
return;
const meta = extractResolvedMeta(compound, references, opts, anchorMap, slugMap, pagePathMap);
if (moduleName)
meta.module = moduleName;
if (compound.kind === 'group') {
pages.push({
...meta,
title: compound.shortname || compound.name,
module: compound.name,
searchEntries: collectSearchEntries(compound, opts.titleSeparator, anchorMap),
markdown: renderCompound(compound, [md], references, opts, anchorMap, slugMap, pagePathMap),
});
}
else {
pages.push({
...meta,
searchEntries: collectSearchEntries(compound, opts.titleSeparator, anchorMap),
markdown: renderCompound(compound, [md], references, opts, anchorMap, slugMap, pagePathMap),
});
}
}
if (useGroups) {
for (const group of groups) {
emitPage(group);
for (const child of toFilteredArray(group, 'compounds')) {
emitPage(child, group.name);
}
}
// Emit orphaned classes (already prepared in first pass)
const allNamespaces = toArray(root, 'compounds', 'namespace')
.filter((c) => !isJunkCompound(c));
for (const ns of allNamespaces) {
for (const child of toFilteredArray(ns)) {
if (seen.has(child.refid) || isJunkCompound(child))
continue;
const nsParts = ns.fullname.split('::');
const inferredModule = nsParts.length > 1 ? nsParts[1] : nsParts[0];
emitPage(child, inferredModule);
}
}
}
else {
const rootCompounds = toArray(root, 'compounds', 'namespace')
.filter((c) => !isJunkCompound(c));
for (const comp of rootCompounds) {
emitPage(comp);
for (const child of toFilteredArray(comp)) {
emitPage(child);
}
}
}
// Doxygen @page entries (skip auto-generated ones from source READMEs)
for (const page of toArray(root, 'compounds', 'page')) {
if (isJunkPage(page))
continue;
const pageCompounds = toFilteredArray(page, 'compounds');
pageCompounds.unshift(page);
const content = templates.renderArray(pageCompounds);
const markdown = renderCompound(page, content, references, opts, anchorMap);
const meta = extractResolvedMeta(page, references, opts, anchorMap);
if (markdown) {
pages.push({
slug: page.name,
title: shortname(page.name),
kind: 'page',
description: meta.description,
markdown,
});
}
}
disambiguateDuplicatePageTitles(pages);
return pages;
}
function disambiguateDuplicatePageTitles(pages) {
const byModuleTitle = new Map();
for (const page of pages) {
const key = `${page.module ?? ''}|${page.title}`;
const matches = byModuleTitle.get(key) ?? [];
matches.push(page);
byModuleTitle.set(key, matches);
}
for (const matches of byModuleTitle.values()) {
if (matches.length <= 1)
continue;
const labels = minimallyQualifiedTitles(matches);
for (let i = 0; i < matches.length; i += 1) {
matches[i].title = labels[i];
}
}
}
function minimallyQualifiedTitles(pages) {
const namespaces = pages.map((page) => namespaceParts(page.namespace));
const commonPrefix = commonNamespacePrefixLength(namespaces);
const suffixes = namespaces.map((parts) => parts.slice(commonPrefix));
const maxDepth = Math.max(0, ...suffixes.map((parts) => parts.length));
for (let depth = 1; depth <= maxDepth; depth += 1) {
const labels = pages.map((page, index) => qualifyWithNamespaceSuffix(page.title, suffixes[index], depth));
if (new Set(labels).size === labels.length)
return labels;
}
return pages.map((page, index) => qualifyWithNamespaceSuffix(page.title, suffixes[index], maxDepth));
}
function namespaceParts(namespace) {
return (namespace ?? '').split('::').filter(Boolean);
}
function commonNamespacePrefixLength(namespaces) {
if (!namespaces.length || namespaces.some((parts) => parts.length === 0))
return 0;
let prefixLength = 0;
while (namespaces.every((parts) => parts[prefixLength] && parts[prefixLength] === namespaces[0][prefixLength])) {
prefixLength += 1;
}
return prefixLength;
}
function qualifyWithNamespaceSuffix(title, namespace, depth) {
const qualifier = namespace.slice(Math.max(0, namespace.length - depth)).join('::');
return qualifier ? `${qualifier}::${title}` : title;
}
function lastSegment(ns) {
const parts = ns.split('::');
return parts[parts.length - 1] || ns;
}
function buildGroupedPagePathMap(groups) {
const map = new Map();
for (const group of groups) {
const groupPath = `${slugify(group.name)}.html`;
map.set(group.refid, groupPath);
const namespaces = toArray(group, 'compounds', 'namespace');
for (const namespace of namespaces) {
if (isJunkCompound(namespace))
continue;
map.set(namespace.refid, groupPath);
}
for (const refid of group.fileScopedNamespaceRefs ?? []) {
if (!map.has(refid)) {
map.set(refid, groupPath);
}
}
}
return map;
}
function collectSearchEntries(compound, separator, anchorMap) {
const entries = [];
const members = compound.filtered?.members?.length ? compound.filtered.members : compound.members;
const ownerTitle = qualifiedTitle(compound);
const owner = compound.fullname || compound.name || ownerTitle;
const namespace = compound.namespace || findNamespace(compound)?.fullname;
for (const member of members) {
if (!member.refid || !member.name)
continue;
const qualifiedName = owner ? `${owner}::${member.name}` : member.name;
const signature = typeof member.definition === 'string' && member.definition
? stripMarkdownLinks(member.definition)
: stripMarkdownLinks(member.proto || member.name);
const summary = stripMarkdownLinks(member.summary || member.briefdescription || '').replace(/\s+/g, ' ').trim();
entries.push({
title: `${member.name}`,
content: [qualifiedName, ownerTitle, signature, summary].filter(Boolean).join(separator),
anchor: anchorMap?.get(member.refid),
category: searchCategoryForKind(member.kind),
symbolKind: member.kind,
owner,
ownerKind: compound.kind,
namespace,
qualifiedName,
});
if (member.enumvalue?.length) {
for (const value of member.enumvalue) {
const enumQualifiedName = `${qualifiedName}::${value.name}`;
entries.push({
title: value.name,
content: [enumQualifiedName, ownerTitle, member.name, stripMarkdownLinks(value.summary || value.briefdescription || '')]
.filter(Boolean)
.join(separator),
anchor: anchorMap?.get(member.refid),
category: 'Enum Values',
symbolKind: 'enumvalue',
owner: qualifiedName,
ownerKind: 'enum',
namespace,
qualifiedName: enumQualifiedName,
});
}
}
}
return entries;
}
function searchCategoryForKind(kind) {
switch (kind) {
case 'function':
case 'signal':
case 'slot':
return 'Functions';
case 'typedef':
return 'Types';
case 'enum':
return 'Enums';
case 'variable':
return 'Variables';
case 'friend':
return 'Friends';
case 'property':
return 'Properties';
case 'define':
return 'Macros';
default:
return 'Members';
}
}
// ---------------------------------------------------------------------------
// run() — CLI API writing to disk
// ---------------------------------------------------------------------------
const CLASS_OUTPUT_KINDS = new Set(['namespace', 'class', 'struct', 'union', 'interface', 'enum', 'concept']);
function groupAwareFilters(filters) {
return {
...filters,
compounds: [...new Set(['group', ...filters.compounds])],
};
}
function isClassOutputCompound(compound) {
return CLASS_OUTPUT_KINDS.has(compound.kind) && !isJunkCompound(compound);
}
function uniqueCompounds(compounds) {
const seen = new Set();
const result = [];
for (const compound of compounds) {
if (seen.has(compound.refid))
continue;
seen.add(compound.refid);
result.push(compound);
}
return result;
}
function collectClassOutputCompounds(root) {
return uniqueCompounds(toArray(root, 'compounds').filter(isClassOutputCompound));
}
function collectRootIndexCompounds(root, groups) {
const topLevelGroups = groups.filter((group) => group.parent?.kind !== 'group');
const rootCompounds = Object.values(root.compounds)
.filter((compound) => isClassOutputCompound(compound) && !compound.groupid);
return uniqueCompounds([...topLevelGroups, ...rootCompounds]);
}
function prepareRootIndex(root, groups, opts) {
root.filtered.members = filterNoise(filterCollection(root.members, 'section', opts.filters.members));
root.filtered.compounds = collectRootIndexCompounds(root, groups);
root.filtered.sections = groupMembersBySection(root);
}
function ensurePathMap(pagePathMap) {
return pagePathMap ?? new Map();
}
function addCompoundPath(pagePathMap, compound, opts) {
if (!pagePathMap.has(compound.refid)) {
pagePathMap.set(compound.refid, compoundPath(compound, opts));
}
}
const TYPE_DEFINITION_KINDS = new Set(['class', 'struct', 'union', 'interface', 'enum', 'concept']);
function definitionSectionTitle(compounds) {
return compounds.every((compound) => TYPE_DEFINITION_KINDS.has(compound.kind))
? 'Class Definitions'
: 'Definitions';
}
function renderCompoundPage(primary, definitions = []) {
const contents = [
templates.render(primary, { headingBase: 1 }),
];
if (definitions.length) {
contents.push(`\n## ${definitionSectionTitle(definitions)}\n\n`);
contents.push(...definitions.map((compound) => templates.render(compound, { headingBase: 3 })));
}
return contents;
}
/**
* Parse Doxygen XML and render Markdown output to disk.
*/
export async function run(options) {
const opts = resolveOptions(options);
const { root, references } = await loadAndPrepare(opts);
let pagePathMap;
const splitOutput = opts.groups || opts.classes;
// --- Pass 1: filter + prepare all compounds ---
const allCompounds = [];
let writeRootIndex = false;
let rootBodyCompounds = [];
let groups = [];
let classOutputCompounds = [];
if (opts.groups) {
groups = toArray(root, 'compounds', 'group')
.filter((group) => !isJunkCompound(group));
if (!groups.length) {
throw new Error('You have enabled `groups` output, but no groups were located in your doxygen XML files.');
}
augmentGroupsFromFiles(root, groups, opts);
finalizeGroups(groups, collectSharedNamespaceRefs(toArray(root, 'compounds', 'file'), opts));
}
if (opts.classes) {
classOutputCompounds = collectClassOutputCompounds(root);
if (!classOutputCompounds.length) {
throw new Error('You have enabled `classes` output, but no classes were located in your doxygen XML files.');
}
}
if (opts.groups) {
const filters = groupAwareFilters(opts.filters);
for (const group of groups) {
filterChildren(group, filters, group.id);
prepareCompound(group);
const children = toFilteredArray(group, 'compounds');
for (const c of children)
prepareCompound(c);
pagePathMap = ensurePathMap(pagePathMap);
addCompoundPath(pagePathMap, group, opts);
for (const child of children) {
if (child.kind === 'group') {
addCompoundPath(pagePathMap, child, opts);
}
else if (!opts.classes) {
pagePathMap.set(child.refid, compoundPath(group, opts));
}
}
for (const refid of group.fileScopedNamespaceRefs ?? []) {
if (!pagePathMap.has(refid)) {
pagePathMap.set(refid, compoundPath(group, opts));
}
}
allCompounds.push(group, ...children);
}
}
if (opts.classes) {
for (const comp of classOutputCompounds) {
filterChildren(comp, opts.filters);
prepareCompound(comp);
allCompounds.push(comp);
pagePathMap = ensurePathMap(pagePathMap);
addCompoundPath(pagePathMap, comp, opts);
}
}
if (splitOutput) {
prepareRootIndex(root, groups, opts);
rootBodyCompounds = opts.classes
? []
: root.filtered.compounds.filter((compound) => compound.kind !== 'group');
writeRootIndex = !opts.noindex && (root.filtered.members.length > 0 ||
root.filtered.compounds.length > 0);
if (writeRootIndex) {
pagePathMap = ensurePathMap(pagePathMap);
const pagePath = compoundPath(root, opts);
pagePathMap.set(root.refid, pagePath);
for (const member of root.filtered.members) {
pagePathMap.set(member.refid, pagePath);
}
if (!opts.classes) {
for (const compound of rootBodyCompounds) {
pagePathMap.set(compound.refid, pagePath);
}
}
allCompounds.push(root);
}
}
else {
filterChildren(root, opts.filters);
prepareCompound(root);
const children = toFilteredArray(root, 'compounds');
for (const c of children)
prepareCompound(c);
allCompounds.push(root, ...children);
}
// --- Build anchor map once ---
const anchorMap = buildCleanAnchorMap(allCompounds);
setAnchorMap(anchorMap);
attachRelationshipSummaries(allCompounds, references);
// --- Pass 2: render + write ---
if (splitOutput) {
if (writeRootIndex) {
const contents = renderCompoundPage(root, rootBodyCompounds);
contents.push('Generated by [Moxygen](https://0state.com/moxygen)');
writeWithOptionalFrontmatter(root, contents, references, opts, anchorMap, pagePathMap);
}
if (opts.groups) {
for (const group of groups) {
const compounds = toFilteredArray(group, 'compounds')
.filter((compound) => compound.kind !== 'group' && !opts.classes && compound.groupid === group.id);
writeWithOptionalFrontmatter(group, renderCompoundPage(group, compounds), references, opts, anchorMap, pagePathMap);
}
}
if (opts.classes) {
for (const comp of classOutputCompounds) {
writeWithOptionalFrontmatter(comp, [templates.render(comp, { headingBase: 1 })], references, opts, anchorMap, pagePathMap);
}
}
}
else {
const compounds = toFilteredArray(root, 'compounds');
if (opts.inlineGroups) {
const groups = toArray(root, 'compounds', 'group')
.filter((g) => !isJunkCompound(g));
if (groups.length) {
augmentGroupsFromFiles(root, groups, opts);
finalizeGroups(groups, collectSharedNamespaceRefs(toArray(root, 'compounds', 'file'), opts));
const filters = groupAwareFilters(opts.filters);
for (const group of groups) {
filterChildren(group, filters, group.id);
prepareCompound(group);
const childCompounds = toFilteredArray(group, 'compounds')
.filter((compound) => compound.kind !== 'group' && compound.groupid === group.id);
for (const c of childCompounds)
prepareCompound(c);
compounds.push(group, ...childCompounds);
}
}
}
if (!opts.noindex) {
compounds.unshift(root);
}
const contents = templates.renderArray(compounds);
contents.push('Generated by [Moxygen](https://0state.com/moxygen)');
writeWithOptionalFrontmatter(root, contents, references, opts, anchorMap);
}
if (opts.pages) {
const doxyPages = toArray(root, 'compounds', 'page');
if (!doxyPages.length) {
throw new Error('You have enabled `pages` output, but no pages were located in your doxygen XML files.');
}
for (const page of doxyPages) {
const compounds = toFilteredArray(page, 'compounds');
compounds.unshift(page);
writeWithOptionalFrontmatter(page, templates.renderArray(compounds), references, opts, anchorMap, pagePathMap);
}
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Write compound to disk, optionally prepending YAML frontmatter.
* Works for all output modes (groups, classes, pages, single).
*/
function writeWithOptionalFrontmatter(compound, contents, references, options, anchorMap, pagePathMap) {
if (options.frontmatter) {
const filepath = compoundPath(compound, options);
const body = renderCompound(compound, contents, references, options, anchorMap, undefined, pagePathMap);
const fm = generateFrontmatter(extractResolvedMeta(compound, references, options, anchorMap, undefined, pagePathMap));
writeFile(filepath, [fm, body]);
}
else {
writeCompound(compound, contents, references, options, anchorMap, pagePathMap);
}
}
const JUNK_NAMESPACES = new Set(['std', 'detail', 'nlohmann']);
const JUNK_NAME_RE = /^@\d+$/;
/**
* Skip compounds that produce junk documentation:
* std namespace, anonymous groups (@123), deprecated pseudo-pages.
*/
function isJunkCompound(compound) {
const name = compound.name;
if (JUNK_NAMESPACES.has(name))
return true;
if (JUNK_NAME_RE.test(name))
return true;
if (name === 'deprecated')
return true;
return false;
}
/**
* Skip Doxygen pages auto-generated from source tree markdown files.
*/
function isJunkPage(page) {
return page.name.startsWith('md_') || page.name === 'deprecated';
}
function firstSentence(text) {
if (!text)
return '';
const clean = text.replace(/\n/g, ' ').trim();
const match = clean.match(/^(.+?[.!?])\s/);
return match ? match[1] : clean.slice(0, 120);
}
function findNamespace(compound) {
let current = compound.parent;
while (current) {
if (current.kind === 'namespace')
return current;
current = current.parent;
}
return undefined;
}
function findGroup(compound) {
let current = compound.parent;
while (current) {
if (current.kind === 'group')
return current;
current = current.parent;
}
return undefined;
}
function shortname(name) {
const parts = (name || '').split('::');
return parts[parts.length - 1] || name;
}
/**
* Build a readable title for a compound.
* Inner classes: "Device::AudioCapability"
* Deep sub-namespace classes: "ws::ConnectionAdapter"
* Top-level module classes: "Server" (icy::http::Server stays as Server)
*/
function qualifiedTitle(compound) {
const name = shortname(compound.name);
// Inner class/struct: qualify with parent class name
if (compound.parent && ['class', 'struct'].includes(compound.parent.kind)) {
return `${shortname(compound.parent.name)}::${name}`;
}
// Sub-namespace: 4+ segments means it's nested deeper than the module level
// e.g. icy::http::ws::ConnectionAdapter -> ws::ConnectionAdapter
// but icy::http::Server (3 segments) stays as Server
const parts = compound.fullname.split('::');
if (parts.length >= 4) {
return `${parts[parts.length - 2]}::${name}`;
}
return name;
}
function slugify(name) {
return safePathSegment(name);
}
// ---------------------------------------------------------------------------
// Public exports
// ---------------------------------------------------------------------------
export { loadIndex } from './parser.js';
export { filterChildren, toArray, toFilteredArray } from './compound.js';
export { renderCompound, resolveRefs, compoundPath } from './helpers.js';
//# sourceMappingURL=index.js.map