moxygen
Version:
Doxygen XML to Markdown converter
409 lines • 16.5 kB
JavaScript
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import Handlebars from 'handlebars';
import { getAnchor, cleanId, stripMarkdownLinks } from './helpers.js';
import { log } from './logger.js';
const templates = {};
let activeAnchorMap;
const DEFAULT_RENDER_CONTEXT = {
headingBase: 1,
};
function headingLevel(relativeLevel, context) {
const relative = Number(relativeLevel);
const base = Number.isFinite(context.headingBase) ? context.headingBase : 1;
const level = base + (Number.isFinite(relative) ? relative : 1) - 1;
return Math.min(Math.max(level, 1), 6);
}
/**
* Set the anchor map used by cleanAnchor/cleanId helpers.
* Call before rendering a batch of compounds.
*/
export function setAnchorMap(map) {
activeAnchorMap = map;
}
/**
* Register Handlebars helpers for template rendering.
*/
export function registerHelpers(options) {
const encodePath = (value) => value.split('/').map((part) => encodeURIComponent(part)).join('/');
const applySourceUrlTemplate = (base, fullPath, line, routedPath = fullPath) => {
if (base.includes('{path}') || base.includes('{fullPath}') || base.includes('{line}')) {
const url = base
.replace(/\{path\}/g, encodePath(routedPath))
.replace(/\{fullPath\}/g, encodePath(fullPath))
.replace(/\{line\}/g, line);
return line && !base.includes('{line}') ? `${url}#L${encodeURIComponent(line)}` : url;
}
const separator = base.endsWith('/') ? '' : '/';
const url = `${base}${separator}${encodePath(fullPath)}`;
return line ? `${url}#L${encodeURIComponent(line)}` : url;
};
const matchingSourceRoute = (routes, path) => routes
.filter((route) => path.startsWith(route.prefix))
.sort((a, b) => b.prefix.length - a.prefix.length)[0];
const resolvedSourceHref = (path, line, symbol) => {
const sourceUrl = options.sourceUrl;
if (!sourceUrl)
return '';
if (Array.isArray(sourceUrl)) {
const route = matchingSourceRoute(sourceUrl, path);
if (!route?.url)
return '';
const routedPath = path.slice(route.prefix.length);
return applySourceUrlTemplate(route.url, path, line, routedPath);
}
const base = typeof sourceUrl === 'function'
? sourceUrl({ path, line: line || undefined, symbol })
: sourceUrl;
return base ? applySourceUrlTemplate(base, path, line) : '';
};
const cleanCellText = (value) => {
const lines = (value || '').split(/\n+/);
const kept = [];
for (const line of lines) {
if (/^#{2,6}\s+(Parameters|Template Parameters|Exceptions|Returns?|Return Values)\b/i.test(line.trim())) {
break;
}
kept.push(line);
}
return kept.join(' ').replace(/\s+/g, ' ').trim();
};
const formatTemplateParams = (params) => {
if (!Array.isArray(params) || params.length === 0)
return '';
return `template<${params.map((param) => {
const record = param;
const type = stripMarkdownLinks(String(record.type ?? '')).trim();
const name = stripMarkdownLinks(String(record.name ?? '')).trim();
const defaultValue = stripMarkdownLinks(String(record.defaultValue ?? '')).trim();
return [
name ? `${type} ${name}` : type,
defaultValue ? ` = ${defaultValue}` : '',
].join('');
}).filter(Boolean).join(', ')}>`;
};
const sourceLabel = (record) => {
const location = typeof record.location === 'string' ? record.location : '';
if (!location)
return '';
const line = typeof record.locationLine === 'string' ? record.locationLine : '';
return line ? `${location}:${line}` : location;
};
const sourceHref = (record) => {
const location = typeof record.location === 'string' ? record.location : '';
if (!options.sourceUrl || !location)
return '';
const path = location.replace(/^\.?\//, '');
const line = typeof record.locationLine === 'string' ? record.locationLine : '';
const symbol = typeof record.name === 'string' ? record.name : undefined;
return resolvedSourceHref(path, line, symbol);
};
const synthesizedMemberSummary = (member) => {
const summary = typeof member.summary === 'string' ? cleanCellText(member.summary) : '';
if (summary)
return summary;
const qualifiers = Array.isArray(member.qualifiers)
? member.qualifiers.filter((q) => typeof q === 'string')
: [];
const name = typeof member.name === 'string' ? member.name : '';
const returnType = typeof member.returnType === 'string' ? member.returnType.trim() : '';
const adjective = qualifiers.includes('= delete')
? 'Deleted'
: qualifiers.includes('= default')
? 'Defaulted'
: '';
if (!adjective)
return '';
if (name === 'operator=')
return `${adjective} assignment operator.`;
if (name.startsWith('~'))
return `${adjective} destructor.`;
if (!returnType)
return `${adjective} constructor.`;
return `${adjective} member function.`;
};
// Classic helpers
Handlebars.registerHelper('cell', (code) => cleanCellText(code).replace(/\|/g, '\\|').replace(/\n/g, '<br/>'));
Handlebars.registerHelper('headingMarker', (relativeLevel, helperOptions) => {
const context = (helperOptions.data?.renderContext ?? DEFAULT_RENDER_CONTEXT);
return '#'.repeat(headingLevel(relativeLevel, context));
});
Handlebars.registerHelper('eq', (a, b) => a === b);
Handlebars.registerHelper('or', (a, b) => a || b);
Handlebars.registerHelper('compoundsOfKind', (compounds, ...args) => {
const options = args[args.length - 1];
void options;
const kinds = new Set(args
.slice(0, -1)
.filter((value) => typeof value === 'string'));
if (!Array.isArray(compounds) || !kinds.size) {
return [];
}
return compounds.filter((compound) => compound &&
typeof compound === 'object' &&
'kind' in compound &&
kinds.has(compound.kind));
});
Handlebars.registerHelper('shortname', (fullname) => {
const parts = (fullname || '').split('::');
return parts[parts.length - 1] || fullname;
});
Handlebars.registerHelper('signature', function () {
const member = this;
const kind = member.kind;
if (kind === 'enum') {
return `enum ${member.name}`;
}
if (kind === 'typedef') {
const rt = stripMarkdownLinks(member.returnType).trim();
return rt ? `using ${member.name} = ${rt}` : String(member.definition ?? `using ${member.name}`);
}
if (kind === 'friend' && !String(member.argsstring ?? '').trim()) {
const templatePrefix = formatTemplateParams(member.templateParams);
const rt = stripMarkdownLinks(member.returnType).trim();
return [templatePrefix, 'friend', rt, stripMarkdownLinks(String(member.name ?? '')).trim()]
.filter(Boolean)
.join(' ');
}
if (kind === 'variable') {
const init = member.initializer;
return init
? `${stripMarkdownLinks(member.returnType)} ${member.name} ${init}`
: `${stripMarkdownLinks(member.returnType)} ${member.name}`;
}
if (kind === 'property') {
return `${stripMarkdownLinks(member.returnType)} ${member.name}`;
}
// function/signal/slot
const parts = [];
if (kind === 'friend')
parts.push('friend');
const tparams = member.templateParams;
const templatePrefix = formatTemplateParams(tparams);
if (templatePrefix)
parts.push(templatePrefix);
const prefixQualifiers = member.prefixQualifiers;
if (prefixQualifiers)
parts.push(...prefixQualifiers);
if (member.isVirtual)
parts.push('virtual');
if (member.isStatic)
parts.push('static');
if (member.isInline)
parts.push('inline');
if (member.isExplicit)
parts.push('explicit');
const rt = member.returnType;
if (rt)
parts.push(stripMarkdownLinks(rt));
const params = member.params;
const paramStr = params
? params.map((p) => {
const type = stripMarkdownLinks(p.type);
const defaultValue = stripMarkdownLinks(String(p.defaultValue ?? '')).trim();
return `${p.name ? `${type} ${p.name}` : type}${defaultValue ? ` = ${defaultValue}` : ''}`;
}).join(', ')
: '';
parts.push(`${member.name}(${paramStr})`);
const qualifiers = member.qualifiers;
if (qualifiers) {
for (const q of qualifiers)
parts.push(q);
}
return parts.join(' ');
});
Handlebars.registerHelper('badges', function () {
const member = this;
const badges = [];
const prot = member.prot;
if (prot && prot !== 'public')
badges.push(prot);
if (member.isVirtual)
badges.push('virtual');
if (member.isStatic)
badges.push('static');
if (member.isConst)
badges.push('const');
if (member.isInline)
badges.push('inline');
if (member.isExplicit)
badges.push('explicit');
if (member.isNodiscard)
badges.push('nodiscard');
if (member.isConstexpr)
badges.push('constexpr');
if (member.isConsteval)
badges.push('consteval');
const qualifiers = Array.isArray(member.qualifiers)
? member.qualifiers.filter((q) => typeof q === 'string')
: [];
for (const q of qualifiers) {
if (q === 'const' || q === '= delete' || q === '= default')
continue;
badges.push(q);
}
return badges.map(b => `\`${b}\``).join(' ');
});
Handlebars.registerHelper('hasParams', function () {
const params = this.params;
return params && params.length > 0 && params.some(p => p.name);
});
Handlebars.registerHelper('documentedParams', (params) => {
if (!Array.isArray(params)) {
return [];
}
return params.filter((param) => {
if (!param || typeof param !== 'object') {
return false;
}
const record = param;
const name = typeof record.name === 'string' ? record.name.trim() : '';
const description = typeof record.description === 'string'
? record.description.trim()
: '';
return !!name && !!description;
});
});
Handlebars.registerHelper('hasDocumentedParams', (params) => {
if (!Array.isArray(params)) {
return false;
}
return params.some((param) => {
if (!param || typeof param !== 'object') {
return false;
}
const record = param;
const name = typeof record.name === 'string' ? record.name.trim() : '';
const description = typeof record.description === 'string'
? record.description.trim()
: '';
return !!name && !!description;
});
});
Handlebars.registerHelper('memberSummary', function () {
return synthesizedMemberSummary(this);
});
Handlebars.registerHelper('classSignature', function () {
const templatePrefix = formatTemplateParams(this.templateParams);
const kind = this.kind === 'interface' ? 'class' : String(this.kind ?? 'class');
const name = this.shortname || this.name;
return [templatePrefix, `${kind} ${name}`].filter(Boolean).join('\n');
});
Handlebars.registerHelper('sourceLabel', function () {
return sourceLabel(this);
});
Handlebars.registerHelper('sourceHref', function () {
return sourceHref(this);
});
// Clean anchor: generates a readable anchor, using the anchor map for consistency
Handlebars.registerHelper('cleanAnchor', (refid, name) => {
const id = activeAnchorMap?.get(refid) ?? cleanId(name || refid);
return getAnchor(id, options);
});
// Clean ID: returns the clean id string for href targets, using the anchor map
Handlebars.registerHelper('cleanId', (refid, name) => {
return activeAnchorMap?.get(refid) ?? cleanId(name || refid);
});
// Return type for summary tables: strip markdown links to plain text
Handlebars.registerHelper('returnTypeShort', function () {
const rt = this.returnType || '';
const clean = rt.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1').trim();
return clean ? `\`${clean}\`` : '';
});
// Linked name: renders as markdown link if refid exists
Handlebars.registerHelper('linkedName', (name, refid) => {
const short = (name || '').split('::').pop() || name;
if (refid)
return `[\`${short}\`]({#ref ${refid} #})`;
return `\`${short}\``;
});
Handlebars.registerHelper('groupBreadcrumbs', (compound) => {
const breadcrumbs = [];
let current = compound.parent;
while (current) {
if (current.kind === 'group') {
breadcrumbs.unshift(current);
}
current = current.parent;
}
return breadcrumbs;
});
// Not helper for conditionals
Handlebars.registerHelper('not', (value) => !value);
// Whether a section should show the return/type column
Handlebars.registerHelper('hasReturnColumn', (section) => {
const noReturn = new Set(['enum', 'define', 'public-type', 'friend']);
return !noReturn.has(section);
});
}
/**
* Load all .md templates from the given directory.
*/
export function load(templateDirectory) {
for (const filename of readdirSync(templateDirectory)) {
const fullpath = join(templateDirectory, filename);
const match = filename.match(/(.*)\.md$/);
if (!match)
continue;
const content = readFileSync(fullpath, 'utf8');
templates[match[1]] = Handlebars.compile(content, {
noEscape: true,
strict: true,
});
}
}
/**
* Render a single compound using the appropriate template.
*/
export function render(compound, context = DEFAULT_RENDER_CONTEXT) {
let templateName;
log.verbose(`Rendering ${compound.kind} ${compound.fullname}`);
switch (compound.kind) {
case 'index':
templateName = 'index';
break;
case 'page':
templateName = 'page';
break;
case 'group':
templateName = 'namespace';
break;
case 'namespace': {
const keys = Object.keys(compound.compounds);
if (keys.length === 1 &&
compound.compounds[keys[0]].kind === 'namespace') {
return undefined;
}
templateName = 'namespace';
break;
}
case 'class':
case 'struct':
case 'union':
case 'interface':
case 'enum':
case 'concept':
templateName = 'class';
break;
default:
log.warn(`Cannot render ${compound.kind} ${compound.fullname}`);
return undefined;
}
if (!templates[templateName]) {
throw new Error(`Template "${templateName}" not found in your templates directory.`);
}
return templates[templateName](compound, {
data: {
renderContext: context,
},
}).replace(/(\r\n|\r|\n){3,}/g, '$1\n');
}
/**
* Render an array of compounds.
*/
export function renderArray(compounds, contextFor = (_compound, index) => ({
headingBase: index === 0 ? 1 : 2,
})) {
return compounds.map((compound, index) => render(compound, contextFor(compound, index)));
}
//# sourceMappingURL=templates.js.map