From e535c13c1843eca8e784148a6f67d1f6d09d9e1f Mon Sep 17 00:00:00 2001 From: RedYetiDev Date: Mon, 14 Oct 2024 12:39:44 -0400 Subject: [PATCH 1/2] feat: add mandoc generator --- .github/workflows/codespell.yml | 2 +- README.md | 5 +- src/generators/index.mjs | 2 + src/generators/man-page/index.mjs | 68 ++++++++ src/generators/man-page/template.1 | 74 ++++++++ src/generators/man-page/utils/converter.mjs | 132 ++++++++++++++ src/test/man-page.test.mjs | 182 ++++++++++++++++++++ 7 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 src/generators/man-page/index.mjs create mode 100644 src/generators/man-page/template.1 create mode 100644 src/generators/man-page/utils/converter.mjs create mode 100644 src/test/man-page.test.mjs diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index d6188e3..409d729 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -18,4 +18,4 @@ jobs: with: ignore_words_list: crate,raison exclude_file: .gitignore - skip: package-lock.json + skip: package-lock.json, ./src/generators/mandoc/template.1 diff --git a/README.md b/README.md index 7b45655..60d9ce7 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,9 @@ CLI tool to generate API documentation of a Node.js project. Options: -i, --input [patterns...] Specify input file patterns using glob syntax -o, --output Specify the relative or absolute output directory - -v, --version Specify the target version of Node.js, semver compliant (default: "v22.6.0") + -v, --version Specify the target version of Node.js, semver compliant (default: "v22.9.0") -c, --changelog Specify the path (file: or https://) to the CHANGELOG.md file (default: "https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md") - -t, --target [mode...] Set the processing target modes (choices: "json-simple", "legacy-html", - "legacy-html-all") + -t, --target [mode...] Set the processing target modes (choices: "json-simple", "legacy-html", "legacy-html-all", "mandoc") -h, --help display help for command ``` diff --git a/src/generators/index.mjs b/src/generators/index.mjs index 5b916d8..6c8c835 100644 --- a/src/generators/index.mjs +++ b/src/generators/index.mjs @@ -3,9 +3,11 @@ import jsonSimple from './json-simple/index.mjs'; import legacyHtml from './legacy-html/index.mjs'; import legacyHtmlAll from './legacy-html-all/index.mjs'; +import manPage from './man-page/index.mjs'; export default { 'json-simple': jsonSimple, 'legacy-html': legacyHtml, 'legacy-html-all': legacyHtmlAll, + 'man-page': manPage, }; diff --git a/src/generators/man-page/index.mjs b/src/generators/man-page/index.mjs new file mode 100644 index 0000000..03d7b95 --- /dev/null +++ b/src/generators/man-page/index.mjs @@ -0,0 +1,68 @@ +'use strict'; + +import { writeFile, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { + convertOptionToMandoc, + convertEnvVarToMandoc, +} from './utils/converter.mjs'; + +/** + * This generator generates a man page version of the CLI.md file. + * See https://man.openbsd.org/mdoc.7 for the formatting. + * + * @typedef {Array} Input + * + * @type {import('../types.d.ts').GeneratorMetadata} + */ +export default { + name: 'man-page', + + version: '1.0.0', + + description: 'Generates the Node.js man-page.', + + dependsOn: 'ast', + + async generate(input, options) { + // Find the appropriate headers + const optionsStart = input.findIndex(({ slug }) => slug === 'options'); + const environmentStart = input.findIndex( + ({ slug }) => slug === 'environment-variables-1' + ); + + if (optionsStart + environmentStart <= 0) { + throw new Error('Could not find headers'); + } + + // Generate the option mandoc + let optionsOutput = ''; + for (let i = optionsStart + 1; i < environmentStart; i++) { + const el = input[i]; + if (el.heading.depth === 3) { + optionsOutput += convertOptionToMandoc(el); + } + } + + // Generate the environment mandoc + let envOutput = ''; + for (let i = environmentStart + 1; i < input.length; i++) { + const el = input[i]; + if (el.heading.depth === 3) { + envOutput += convertEnvVarToMandoc(el); + } + if (el.heading.depth < 3) break; + } + + const apiTemplate = await readFile( + join(import.meta.dirname, 'template.1'), + 'utf-8' + ); + const template = apiTemplate + .replace('__OPTIONS__', optionsOutput) + .replace('__ENVIRONMENT__', envOutput); + + await writeFile(options.output, template); + }, +}; diff --git a/src/generators/man-page/template.1 b/src/generators/man-page/template.1 new file mode 100644 index 0000000..4e82775 --- /dev/null +++ b/src/generators/man-page/template.1 @@ -0,0 +1,74 @@ +.\" +.\" This file is automatically generated by api-docs-tooling. +.\" Do not edit this file directly. Please make changes to CLI.md +.\" and then regenerate this file. +.\" +.\" For generation instructions using api-docs-tooling, see: +.\" https://github.com/nodejs/api-docs-tooling +.\" +.\"====================================================================== +.Dd $Mdocdate$ +.Dt NODE 1 +. +.Sh NAME +.Nm node +.Nd server-side JavaScript runtime +. +.Sh SYNOPSIS +.Nm node +.Op Ar options +.Op Ar v8 options +.Op Ar | Fl e Ar string | Fl - +.Op Ar arguments ... +. +.Nm node +.Cm inspect, +.Op Ar | Fl e Ar string | Ar : +.Ar ... +. +.Nm node +.Op Fl -v8-options +. +.Sh DESCRIPTION +Node.js is a set of libraries for JavaScript which allows it to be used outside of the browser. +It is primarily focused on creating simple, easy-to-build network clients and servers. +.Pp +Execute +.Nm +without arguments to start a REPL. +. +.Sh OPTIONS +.Bl -tag -width 6n +__OPTIONS__ +.El +. +.Sh ENVIRONMENT +.Bl -tag -width 6n +__ENVIRONMENT__ +.El +. +.Sh BUGS +Bugs are tracked in GitHub Issues: +.Sy https://github.com/nodejs/node/issues +. +.Sh COPYRIGHT +Copyright Node.js contributors. +Node.js is available under the MIT license. +. +.Pp +Node.js also includes external libraries that are available under a variety of licenses. +See +.Sy https://github.com/nodejs/node/blob/HEAD/LICENSE +for the full license text. +. +.Sh SEE ALSO +Website: +.Sy https://nodejs.org/ +. +.Pp +Documentation: +.Sy https://nodejs.org/api/ +. +.Pp +GitHub repository and issue tracker: +.Sy https://github.com/nodejs/node \ No newline at end of file diff --git a/src/generators/man-page/utils/converter.mjs b/src/generators/man-page/utils/converter.mjs new file mode 100644 index 0000000..60d1b6c --- /dev/null +++ b/src/generators/man-page/utils/converter.mjs @@ -0,0 +1,132 @@ +/** + * Converts an Abstract Syntax Tree (AST) node to the Mandoc format for Unix manual pages. + * This function processes the node recursively, converting each supported node type + * to its corresponding Mandoc markup representation. Unsupported node types will be ignored. + * + * @param {import("mdast").Node} node - The AST node to be converted to Mandoc format. + * @param {boolean} [isListItem=false] - Indicates if the current node is a list item. + * This parameter is used to correctly format list elements in Mandoc. + * @returns {string} The Mandoc formatted string representing the given node and its children. + */ +export function convertNodeToMandoc(node, isListItem = false) { + const convertChildren = (sep = '', ili = false) => + node.children.map(child => convertNodeToMandoc(child, ili)).join(sep); + const escapeText = () => node.value.replace(/\\/g, '\\\\'); + + switch (node.type) { + case 'root': + // Process the root node by converting all children and separating them with new lines. + return convertChildren('\n'); + + case 'heading': + // Convert to a Mandoc heading section (.Sh). + return `.Sh ${convertChildren()}`; + + case 'link': + case 'paragraph': + case 'listItem': + // Convert to Mandoc paragraph or list item. + // .It denotes a list item in Mandoc, added only if the node is a list item. + return `${isListItem && node.type === 'listItem' ? '.It\n' : ''}${convertChildren()}`; + + case 'text': + // Escape any special characters in plain text content. + return escapeText(); + + case 'inlineCode': + // Format inline code using Mandoc's bold markup (\\fB ... \\fR). + return `\\fB${escapeText()}\\fR`; + + case 'strong': + // Format inline code + strong using Mandoc's bold markup (\\fB ... \\fR). + return `\\fB${convertChildren()}\\fR`; + + case 'code': + // Format code blocks as literal text using .Bd -literal and .Ed for start and end. + return `.Bd -literal\n${escapeText()}\n.Ed`; + + case 'list': + // Convert to a bullet list in Mandoc, starting with .Bl -bullet and ending with .El. + return `.Bl -bullet\n${convertChildren('\n', true)}\n.El`; + + case 'emphasis': + // Format emphasized text in Mandoc using italic markup (\\fI ... \\fR). + return `\\fI${convertChildren()}\\fR`; + + default: + // Ignore `html`, `blockquote`, etc. + return ''; + } +} + +/** + * Converts a command-line flag to its Mandoc representation. + * This function splits the flag into its name and optional value (if present), + * formatting them appropriately for Mandoc manual pages. + * + * @param {string} flag - The command-line flag to be formatted. It may include a value + * specified with either an equals sign (=) or a space. + * @returns {string} The Mandoc formatted representation of the flag and its value. + */ +export function flagValueToMandoc(flag) { + // The seperator is '=' or ' '. + const sep = flag.match(/[= ]/)?.[0]; + if (sep == null) return ''; + // Split the flag into the name and value based on = or space delimiter. + const value = flag.split(sep)[1]; + // Format the value using Ns and Ar macros for Mandoc, if present. + // If the seperator is ' ', it'll become ''. + return value + ? `${sep === ' ' ? '' : ' Ns = Ns'} Ar ${value.replace(/\]$/, '')}` + : ''; +} + +/** + * Converts an API option metadata entry into the Mandoc format. + * This function formats command-line options, including flags and descriptions, + * for display in Unix manual pages using Mandoc. + * + * @param {ApiDocMetadataEntry} element - The metadata entry containing details about the API option. + * @returns {string} The Mandoc formatted string representing the API option, including flags and content. + */ +export function convertOptionToMandoc(element) { + // Format the option flags by splitting them, removing backticks, and converting each flag. + const formattedFlags = element.heading.data.text + .replace(/`/g, '') + .split(', ') + .map( + // 'Fl' denotes a flag + flag => `Fl ${flag.split(/[= ]/)[0].slice(1)}${flagValueToMandoc(flag)}` + ) + .join(' , '); + + // Remove the header itself. + element.content.children.shift(); + + // Return the formatted flags and content, separated by Mandoc markers. + return `.It ${formattedFlags.trim()}\n${convertNodeToMandoc(element.content)}\n.\n`; +} + +/** + * Converts an API environment variable metadata entry into the Mandoc format. + * This function formats environment variables for Unix manual pages, converting + * the variable name and value, along with any associated descriptions, into Mandoc. + * + * @param {ApiDocMetadataEntry} element - The metadata entry containing details about the environment variable. + * @returns {string} The Mandoc formatted representation of the environment variable and its content. + */ +export function convertEnvVarToMandoc(element) { + // Split the environment variable into name and optional value. + const [varName, varValue] = element.heading.data.text + .replace(/`/g, '') + .split('='); + + // Format the variable value if present. + const formattedValue = varValue ? ` Ar ${varValue}` : ''; + + // Remove the header itself. + element.content.children.shift(); + + // Return the formatted environment variable and content, using Mandoc's .It (List item) and .Ev (Env Var) macros. + return `.It Ev ${varName}${formattedValue}\n${convertNodeToMandoc(element.content)}\n.\n`; +} diff --git a/src/test/man-page.test.mjs b/src/test/man-page.test.mjs new file mode 100644 index 0000000..0e358d1 --- /dev/null +++ b/src/test/man-page.test.mjs @@ -0,0 +1,182 @@ +import { strictEqual } from 'node:assert'; +import { describe, it } from 'node:test'; +import { + convertNodeToMandoc, + flagValueToMandoc, + convertOptionToMandoc, + convertEnvVarToMandoc, +} from '../generators/man-page/utils/converter.mjs'; +import { u } from 'unist-builder'; + +const textNode = text => u('text', text); + +const createMockElement = (headingText, description) => ({ + heading: { data: { text: headingText } }, + // eslint-disable-next-line no-sparse-arrays + content: u('root', [, u('paragraph', [textNode(description)])]), +}); + +const runTests = (cases, conversionFunc) => { + cases.forEach(({ input, expected }) => { + strictEqual(conversionFunc(input), expected); + }); +}; + +describe('Mandoc Conversion', () => { + describe('Node Conversion', () => { + it('should convert a root node with heading and paragraph', () => { + const node = u('root', [ + u('heading', { depth: 1 }, [textNode('Main Title')]), + u('paragraph', [textNode('Introductory text.')]), + ]); + strictEqual( + convertNodeToMandoc(node), + '.Sh Main Title\nIntroductory text.' + ); + }); + + it('should convert various nodes to Mandoc', () => { + const cases = [ + { + input: u('heading', { depth: 2 }, [textNode('Heading')]), + expected: '.Sh Heading', + }, + { input: textNode('Some text'), expected: 'Some text' }, + { + input: textNode('Text with a backslash: \\\\'), + expected: 'Text with a backslash: \\\\\\\\', + }, + { + input: u('list', [ + u('listItem', [textNode('Item 1')]), + u('listItem', [textNode('Item 2')]), + ]), + expected: '.Bl -bullet\n.It\nItem 1\n.It\nItem 2\n.El', + }, + { + input: u('code', 'const a = 1;'), + expected: '.Bd -literal\nconst a = 1;\n.Ed', + }, + { + input: u('inlineCode', 'inline code'), + expected: '\\fBinline code\\fR', + }, + { + input: u('emphasis', [textNode('emphasized text')]), + expected: '\\fIemphasized text\\fR', + }, + { + input: u('blockquote', [ + u('paragraph', [textNode('This is a quote.')]), + ]), + expected: '', + }, + { input: u('paragraph', []), expected: '' }, + { input: u('list', []), expected: '.Bl -bullet\n\n.El' }, + { + input: u('strong', [textNode('strongly emphasized text')]), + expected: '\\fBstrongly emphasized text\\fR', + }, + ]; + runTests(cases, convertNodeToMandoc); + }); + }); + + describe('Flag Value Formatting', () => { + it('should format flag values correctly', () => { + const cases = [ + { input: '-o value', expected: ' Ar value' }, + { input: '--flag=value', expected: ' Ns = Ns Ar value' }, + { input: '-n', expected: '' }, + { input: '-f value1,value2', expected: ' Ar value1,value2' }, + { + input: '--multi-flag=value1,value2,value3', + expected: ' Ns = Ns Ar value1,value2,value3', + }, + { input: '-x', expected: '' }, + ]; + runTests(cases, flagValueToMandoc); + }); + }); + + describe('Option Conversion', () => { + it('should convert options to Mandoc format', () => { + const mockElement = createMockElement( + '`-a`, `-b=value`', + 'Description of the options.' + ); + strictEqual( + convertOptionToMandoc(mockElement), + `.It Fl a , Fl b Ns = Ns Ar value\nDescription of the options.\n.\n` + ); + }); + + it('should handle options without values', () => { + const mockElement = createMockElement( + '`-a`', + 'Description of the option without a value.' + ); + strictEqual( + convertOptionToMandoc(mockElement), + `.It Fl a\nDescription of the option without a value.\n.\n` + ); + }); + + it('should handle multiple options in a single heading', () => { + const mockElement = createMockElement( + '`-x`, `-y`, `-z=value`', + 'Description of multiple options.' + ); + strictEqual( + convertOptionToMandoc(mockElement), + `.It Fl x , Fl y , Fl z Ns = Ns Ar value\nDescription of multiple options.\n.\n` + ); + }); + + it('should handle options with special characters', () => { + const mockElement = createMockElement( + '`-d`, `--option=value with spaces`', + 'Description of special options.' + ); + strictEqual( + convertOptionToMandoc(mockElement), + `.It Fl d , Fl -option Ns = Ns Ar value with spaces\nDescription of special options.\n.\n` + ); + }); + }); + + describe('Environment Variable Conversion', () => { + it('should convert environment variables to Mandoc format', () => { + const mockElement = createMockElement( + '`MY_VAR=some_value`', + 'Description of the environment variable.' + ); + strictEqual( + convertEnvVarToMandoc(mockElement), + `.It Ev MY_VAR Ar some_value\nDescription of the environment variable.\n.\n` + ); + }); + + it('should handle environment variables without values', () => { + const mockElement = createMockElement( + '`MY_VAR=`', + 'Description of the environment variable without a value.' + ); + strictEqual( + convertEnvVarToMandoc(mockElement), + `.It Ev MY_VAR\nDescription of the environment variable without a value.\n.\n` + ); + }); + + it('should handle environment variables with special characters', () => { + const mockElement = createMockElement( + '`SPECIAL_VAR=special value!`', + 'Description of special environment variable.' + ); + strictEqual( + convertEnvVarToMandoc(mockElement), + `.It Ev SPECIAL_VAR Ar special value!\nDescription of special environment variable.\n.\n` + ); + }); + }); +}); From 5e1e0ceb22230916a00d0d10a92cbecd3c5054ae Mon Sep 17 00:00:00 2001 From: RedYetiDev <38299977+RedYetiDev@users.noreply.github.com> Date: Wed, 16 Oct 2024 19:06:58 -0400 Subject: [PATCH 2/2] fixup! Resolve suggestions + Fixup trimming --- README.md | 7 +- src/generators/man-page/index.mjs | 71 ++++++++++++--------- src/generators/man-page/utils/converter.mjs | 43 ++++++++----- 3 files changed, 73 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 60d9ce7..3548d07 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,8 @@ CLI tool to generate API documentation of a Node.js project. Options: -i, --input [patterns...] Specify input file patterns using glob syntax -o, --output Specify the relative or absolute output directory - -v, --version Specify the target version of Node.js, semver compliant (default: "v22.9.0") - -c, --changelog Specify the path (file: or https://) to the CHANGELOG.md file (default: - "https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md") - -t, --target [mode...] Set the processing target modes (choices: "json-simple", "legacy-html", "legacy-html-all", "mandoc") + -v, --version Specify the target version of Node.js, semver compliant (default: "v22.6.0") + -c, --changelog Specify the path (file: or https://) to the CHANGELOG.md file (default: "https://raw.githubusercontent.com/nodejs/node/HEAD/CHANGELOG.md") + -t, --target [mode...] Set the processing target modes (choices: "json-simple", "legacy-html", "legacy-html-all", "man-page") -h, --help display help for command ``` diff --git a/src/generators/man-page/index.mjs b/src/generators/man-page/index.mjs index 03d7b95..94de839 100644 --- a/src/generators/man-page/index.mjs +++ b/src/generators/man-page/index.mjs @@ -26,43 +26,56 @@ export default { dependsOn: 'ast', async generate(input, options) { + // Filter to only 'cli'. + const components = input.filter(({ api }) => api === 'cli'); + if (!components.length) { + throw new Error('CLI.md not found'); + } + // Find the appropriate headers - const optionsStart = input.findIndex(({ slug }) => slug === 'options'); - const environmentStart = input.findIndex( + const optionsStart = components.findIndex(({ slug }) => slug === 'options'); + const environmentStart = components.findIndex( ({ slug }) => slug === 'environment-variables-1' ); + // The first header that is <3 in depth after environmentStart + const environmentEnd = components.findIndex( + ({ heading }, index) => heading.depth < 3 && index > environmentStart + ); - if (optionsStart + environmentStart <= 0) { - throw new Error('Could not find headers'); - } - - // Generate the option mandoc - let optionsOutput = ''; - for (let i = optionsStart + 1; i < environmentStart; i++) { - const el = input[i]; - if (el.heading.depth === 3) { - optionsOutput += convertOptionToMandoc(el); - } - } - - // Generate the environment mandoc - let envOutput = ''; - for (let i = environmentStart + 1; i < input.length; i++) { - const el = input[i]; - if (el.heading.depth === 3) { - envOutput += convertEnvVarToMandoc(el); - } - if (el.heading.depth < 3) break; - } + const output = { + // Extract the CLI options. + options: extractMandoc( + components, + optionsStart + 1, + environmentStart, + convertOptionToMandoc + ), + // Extract the environment variables. + env: extractMandoc( + components, + environmentStart + 1, + environmentEnd, + convertEnvVarToMandoc + ), + }; - const apiTemplate = await readFile( + const template = await readFile( join(import.meta.dirname, 'template.1'), 'utf-8' ); - const template = apiTemplate - .replace('__OPTIONS__', optionsOutput) - .replace('__ENVIRONMENT__', envOutput); - await writeFile(options.output, template); + const filledTemplate = template + .replace('__OPTIONS__', output.options) + .replace('__ENVIRONMENT__', output.env); + + await writeFile(options.output, filledTemplate); }, }; + +function extractMandoc(components, start, end, convert) { + return components + .slice(start, end) + .filter(({ heading }) => heading.depth === 3) + .map(convert) + .join(''); +} diff --git a/src/generators/man-page/utils/converter.mjs b/src/generators/man-page/utils/converter.mjs index 60d1b6c..6352e6d 100644 --- a/src/generators/man-page/utils/converter.mjs +++ b/src/generators/man-page/utils/converter.mjs @@ -69,18 +69,33 @@ export function convertNodeToMandoc(node, isListItem = false) { * @returns {string} The Mandoc formatted representation of the flag and its value. */ export function flagValueToMandoc(flag) { - // The seperator is '=' or ' '. - const sep = flag.match(/[= ]/)?.[0]; - if (sep == null) return ''; - // Split the flag into the name and value based on = or space delimiter. + // The separator is '=' or ' '. + let sep = flag.match(/[= ]/)?.[0]; + + if (sep == null) { + // This flag does not have a default value. + return ''; + } + + // Split the flag into the name and value based on the separator ('=' or space). const value = flag.split(sep)[1]; - // Format the value using Ns and Ar macros for Mandoc, if present. - // If the seperator is ' ', it'll become ''. - return value - ? `${sep === ' ' ? '' : ' Ns = Ns'} Ar ${value.replace(/\]$/, '')}` - : ''; + + // If there is no value, return an empty string. + if (!value) { + return ''; + } + + // Determine the prefix based on the separator type. + const prefix = sep === ' ' ? '' : ' Ns = Ns'; + + // Combine prefix and formatted value. + return `${prefix} Ar ${value.replace(/\]$/, '')}`; } +const formatFlag = flag => + // 'Fl' denotes a flag, followed by an optional 'Ar' (argument). + `Fl ${flag.split(/[= ]/)[0].slice(1)}${flagValueToMandoc(flag)}`; + /** * Converts an API option metadata entry into the Mandoc format. * This function formats command-line options, including flags and descriptions, @@ -94,17 +109,15 @@ export function convertOptionToMandoc(element) { const formattedFlags = element.heading.data.text .replace(/`/g, '') .split(', ') - .map( - // 'Fl' denotes a flag - flag => `Fl ${flag.split(/[= ]/)[0].slice(1)}${flagValueToMandoc(flag)}` - ) - .join(' , '); + .map(formatFlag) + .join(' , ') + .trim(); // Remove the header itself. element.content.children.shift(); // Return the formatted flags and content, separated by Mandoc markers. - return `.It ${formattedFlags.trim()}\n${convertNodeToMandoc(element.content)}\n.\n`; + return `.It ${formattedFlags}\n${convertNodeToMandoc(element.content).trim()}\n.\n`; } /**