diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 8345185..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,./src/generators/mandoc/template.1 + skip: package-lock.json, ./src/generators/mandoc/template.1 diff --git a/src/generators/index.mjs b/src/generators/index.mjs index c6131e3..6c8c835 100644 --- a/src/generators/index.mjs +++ b/src/generators/index.mjs @@ -3,11 +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 mandoc from './mandoc/index.mjs'; +import manPage from './man-page/index.mjs'; export default { 'json-simple': jsonSimple, 'legacy-html': legacyHtml, 'legacy-html-all': legacyHtmlAll, - mandoc: mandoc, + 'man-page': manPage, }; diff --git a/src/generators/mandoc/index.mjs b/src/generators/man-page/index.mjs similarity index 67% rename from src/generators/mandoc/index.mjs rename to src/generators/man-page/index.mjs index 981cc3b..95e6008 100644 --- a/src/generators/mandoc/index.mjs +++ b/src/generators/man-page/index.mjs @@ -1,22 +1,27 @@ 'use strict'; -import { optionToMandoc, envToMandoc } from './utils/converter.mjs'; import { writeFile, readFile } from 'node:fs/promises'; import { join } from 'node:path'; +import { + convertOptionToMandoc, + convertEnvVarToMandoc, +} from './utils/converter.mjs'; + /** - * This generator generates a mandoc version of the API docs + * 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: 'mandoc', + name: 'man-page', version: '1.0.0', - description: 'Generates the `node.1` file.', + description: 'Generates the Node.js man-page.', dependsOn: 'ast', @@ -31,8 +36,8 @@ export default { let optionsOutput = ''; for (let i = optionsStart + 1; i < environmentStart; i++) { const el = input[i]; - if (el.heading?.depth === 3) { - optionsOutput += optionToMandoc(el); + if (el.heading.depth === 3) { + optionsOutput += convertOptionToMandoc(el); } } @@ -40,10 +45,10 @@ export default { let envOutput = ''; for (let i = environmentStart + 1; i < input.length; i++) { const el = input[i]; - if (el.heading?.depth === 3) { - envOutput += envToMandoc(el); + if (el.heading.depth === 3) { + envOutput += convertEnvVarToMandoc(el); } - if (el.heading?.depth < 3) break; + if (el.heading.depth < 3) break; } const apiTemplate = await readFile( @@ -54,6 +59,6 @@ export default { .replace('__OPTIONS__', optionsOutput) .replace('__ENVIRONMENT__', envOutput); - return await writeFile(options.output, template); + await writeFile(options.output, template); }, }; diff --git a/src/generators/mandoc/template.1 b/src/generators/man-page/template.1 similarity index 100% rename from src/generators/mandoc/template.1 rename to src/generators/man-page/template.1 diff --git a/src/generators/man-page/utils/converter.mjs b/src/generators/man-page/utils/converter.mjs new file mode 100644 index 0000000..1061882 --- /dev/null +++ b/src/generators/man-page/utils/converter.mjs @@ -0,0 +1,129 @@ +/** + * 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 '', hence the .trim(). + 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/generators/mandoc/utils/converter.mjs b/src/generators/mandoc/utils/converter.mjs deleted file mode 100644 index 97beeed..0000000 --- a/src/generators/mandoc/utils/converter.mjs +++ /dev/null @@ -1,63 +0,0 @@ -function toMandoc(node, opts) { - const mapChildren = () => node.children.map(toMandoc).join(''); - const value = () => node.value.replace(/\\/g, '\\\\'); - - switch (node.type) { - case 'root': - return node.children.map(toMandoc).join('\n'); - case 'heading': - return `.Sh ${mapChildren()}`; - case 'link': - case 'paragraph': - case 'listItem': - return `${opts.fromListParent ? '.It\n' : ''}${mapChildren()}`; - case 'text': - return value(); - case 'inlineCode': - return `\\fB${value()}\\fR`; // Bold formatting - case 'blockquote': - return ''; // Ignore stability - case 'code': - return `.Bd -literal\n${value()}\n.Ed`; - case 'list': - return `.Bl -bullet\n${node.children.map(child => toMandoc(child, { fromListParent: true })).join('\n')}\n.El\n.\n`; - case 'emphasis': - return `\\fI${mapChildren()}\\fR`; // Italic formatting - default: - return ''; - } -} - -function flagToMandoc(flag) { - const [, value] = flag.split(/[= ]/); - const separator = flag.match(/[= ]/)?.[0]; - return separator === ' ' || !value - ? '' - : ` Ns ${separator} Ns Ar ${value.replace(/\]$/, '')}`; -} - -export function optionToMandoc(element) { - const formatFlag = flag => { - const name = flag.split(/\[?[= ]/)[0].slice(1); - return `Fl ${name}${flagToMandoc(flag)}`; - }; - - const formattedFlags = element.heading.data.text - .replace(/`/g, '') - .split(', ') - .map(formatFlag) - .join(' , '); - - element.content.children.shift(); // Remove the first child - return `.It ${formattedFlags.trim()}\n${toMandoc(element.content)}\n.\n`; -} - -export function envToMandoc(element) { - const [varName, varValue] = element.heading.data.text - .replace(/`/g, '') - .split('='); - const value = varValue ? ` Ar ${varValue}` : ''; - - element.content.children.shift(); // Remove the first child - return `.It Ev ${varName}${value}\n${toMandoc(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` + ); + }); + }); +});