Skip to content

feat(cli): split cli into sub commands #250

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Apr 19, 2025
Merged
Changes from 5 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
73 changes: 52 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
@@ -23,30 +23,61 @@

## Usage

Local invocation:
### `help`

```sh
$ npx api-docs-tooling --help
npx api-docs-tooling help [command]
```

### `generate`

Generate API documentation from Markdown files.

```sh
npx api-docs-tooling generate [options]
```

**Options:**

- `-i, --input <patterns...>` Input file patterns (glob)
- `--ignore [patterns...]` Files to ignore
- `-o, --output <dir>` Output directory
- `-v, --version <semver>` Target Node.js version (default: latest)
- `-c, --changelog <url>` Changelog file or URL
- `--git-ref <url>` Git ref/commit URL
- `-t, --target [modes...]` Generator target(s): `json-simple`, `legacy-html`, etc.
- `--no-lint` Skip linting before generation

### `lint`

Run the linter on API documentation.

```sh
npx api-docs-tooling lint [options]
```

**Options:**

- `-i, --input <patterns...>` Input file patterns (glob)
- `--ignore [patterns...]` Files to ignore
- `--disable-rule [rules...]` Disable specific linting rules
- `--dry-run` Run linter without applying changes
- `-r, --reporter <reporter>` Reporter format: `console`, `github`, etc.

### `interactive`

Launches a fully interactive CLI prompt to guide you through all available options.

```sh
npx api-docs-tooling interactive
```

### `list`

See available modules for each subsystem.

```sh
Usage: api-docs-tooling [options]

CLI tool to generate API documentation of a Node.js project.

Options:
-i, --input [patterns...] Specify input file patterns using glob syntax
--ignore [patterns...] Specify which input files to ignore using glob syntax
-o, --output <path> Specify the relative or absolute output directory
-v, --version <semver> Specify the target version of Node.js, semver compliant (default: "v22.11.0")
-c, --changelog <url> 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", "legacy-json", "legacy-json-all", "addon-verify", "api-links", "orama-db")
--disable-rule [rule...] Disable a specific linter rule (choices: "invalid-change-version",
"missing-change-version", "missing-introduced-in", default: [])
--lint-dry-run Run linter in dry-run mode (default: false)
--git-ref A git ref/commit URL pointing to Node.js
-r, --reporter [reporter] Specify the linter reporter (choices: "console", "github", default: "console")
-h, --help display help for command
npx api-docs-tooling list generators
npx api-docs-tooling list rules
npx api-docs-tooling list reporters
```
189 changes: 45 additions & 144 deletions bin/cli.mjs
Original file line number Diff line number Diff line change
@@ -1,161 +1,62 @@
#!/usr/bin/env node

import { resolve } from 'node:path';
import process from 'node:process';
import { cpus } from 'node:os';
import { Argument, Command, Option } from 'commander';

import { Command, Option } from 'commander';
import interactive from './commands/interactive.mjs';
import list, { types } from './commands/list.mjs';
import commands from './commands/index.mjs';

import { coerce } from 'semver';
import { DOC_NODE_CHANGELOG_URL, DOC_NODE_VERSION } from '../src/constants.mjs';
import createGenerator from '../src/generators.mjs';
import { publicGenerators } from '../src/generators/index.mjs';
import createLinter from '../src/linter/index.mjs';
import reporters from '../src/linter/reporters/index.mjs';
import rules from '../src/linter/rules/index.mjs';
import createMarkdownLoader from '../src/loaders/markdown.mjs';
import createMarkdownParser from '../src/parsers/markdown.mjs';
import createNodeReleases from '../src/releases.mjs';

const availableGenerators = Object.keys(publicGenerators);

const program = new Command();

program
const program = new Command()
.name('api-docs-tooling')
.description('CLI tool to generate API documentation of a Node.js project.')
.addOption(
new Option(
'-i, --input [patterns...]',
'Specify input file patterns using glob syntax'
).makeOptionMandatory()
)
.addOption(
new Option(
'--ignore [patterns...]',
'Specify which input files to ignore using glob syntax'
)
)
.addOption(
new Option(
'-o, --output <path>',
'Specify the relative or absolute output directory'
)
)
.addOption(
new Option(
'-v, --version <semver>',
'Specify the target version of Node.js, semver compliant'
).default(DOC_NODE_VERSION)
)
.addOption(
new Option(
'-c, --changelog <url>',
'Specify the path (file: or https://) to the CHANGELOG.md file'
).default(DOC_NODE_CHANGELOG_URL)
)
.addOption(
new Option(
'-t, --target [mode...]',
'Set the processing target modes'
).choices(availableGenerators)
)
.addOption(
new Option('--disable-rule [rule...]', 'Disable a specific linter rule')
.choices(Object.keys(rules))
.default([])
)
.addOption(
new Option('--lint-dry-run', 'Run linter in dry-run mode').default(false)
)
.addOption(
new Option('--git-ref', 'A git ref/commit URL pointing to Node.js').default(
'https://github.com/nodejs/node/tree/HEAD'
)
)
.addOption(
new Option('-r, --reporter [reporter]', 'Specify the linter reporter')
.choices(Object.keys(reporters))
.default('console')
)
.addOption(
new Option(
'-p, --threads <number>',
'The maximum number of threads to use. Set to 1 to disable parallelism'
).default(Math.max(1, cpus().length - 1))
)
.parse(process.argv);
.description('CLI tool to generate and lint Node.js API documentation');

/**
* @typedef {keyof publicGenerators} Target A list of the available generator names.
*
* @typedef {Object} Options
* @property {Array<string>|string} input Specifies the glob/path for input files.
* @property {string} output Specifies the directory where output files will be saved.
* @property {Target[]} target Specifies the generator target mode.
* @property {string} version Specifies the target Node.js version.
* @property {string} changelog Specifies the path to the Node.js CHANGELOG.md file.
* @property {string[]} disableRule Specifies the linter rules to disable.
* @property {boolean} lintDryRun Specifies whether the linter should run in dry-run mode.
* @property {boolean} useGit Specifies whether the parser should execute optional git commands. (Should only be used within a git repo)
* @property {keyof reporters} reporter Specifies the linter reporter.
*
* @name ProgramOptions
* @type {Options}
* @description The return type for values sent to the program from the CLI.
*/
const {
input,
ignore,
output,
target = [],
version,
changelog,
disableRule,
lintDryRun,
gitRef,
reporter,
threads,
} = program.opts();
// Registering generate and lint commands
commands.forEach(({ name, description, options, action }) => {
const cmd = program.command(name).description(description);

const linter = createLinter(lintDryRun, disableRule);
// Add options to the command
Object.values(options).forEach(({ flags, desc, prompt }) => {
const option = new Option(flags.join(', '), desc).default(
prompt.initialValue
);

const { loadFiles } = createMarkdownLoader();
const { parseApiDocs } = createMarkdownParser();
if (prompt.required) {
option.makeOptionMandatory();
}

const apiDocFiles = await loadFiles(input, ignore);
if (prompt.type === 'multiselect') {
option.choices(prompt.options.map(({ value }) => value));
}

const parsedApiDocs = await parseApiDocs(apiDocFiles);
cmd.addOption(option);
});

const { runGenerators } = createGenerator(parsedApiDocs);
// Set the action for the command
cmd.action(action);
});

// Retrieves Node.js release metadata from a given Node.js version and CHANGELOG.md file
const { getAllMajors } = createNodeReleases(changelog);
// Register the interactive command
program
.command('interactive')
.description('Launch guided CLI wizard')
.action(interactive);

// Runs the Linter on the parsed API docs
linter.lintAll(parsedApiDocs);
// Register the list command
program
.command('list')
.addArgument(new Argument('<types>', 'The type to list').choices(types))
.description('List the given type')
.action(list);

if (target) {
await runGenerators({
// A list of target modes for the API docs parser
generators: target,
// Resolved `input` to be used
input: input,
// Resolved `output` path to be used
output: output && resolve(output),
// Resolved SemVer of current Node.js version
version: coerce(version),
// A list of all Node.js major versions with LTS status
releases: await getAllMajors(),
// An URL containing a git ref URL pointing to the commit or ref that was used
// to generate the API docs. This is used to link to the source code of the
gitRef,
// How many threads should be used
threads,
// Register the help command
program
.command('help [cmd]')
.description('Show help for a command')
.action(cmdName => {
const target = program.commands.find(c => c.name() === cmdName) ?? program;
target.help();
});
}

// Reports Lint Content
linter.report(reporter);

process.exitCode = Number(linter.hasError());
// Parse and execute command-line arguments
program.parse(process.argv);
146 changes: 146 additions & 0 deletions bin/commands/generate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { cpus } from 'node:os';
import { resolve } from 'node:path';
import process from 'node:process';

import { coerce } from 'semver';

import {
DOC_NODE_CHANGELOG_URL,
DOC_NODE_VERSION,
} from '../../src/constants.mjs';
import createGenerator from '../../src/generators.mjs';
import { publicGenerators } from '../../src/generators/index.mjs';
import createNodeReleases from '../../src/releases.mjs';
import { loadAndParse } from '../utils.mjs';
import { runLint } from './lint.mjs';

const availableGenerators = Object.keys(publicGenerators);

/**
* @typedef {Object} Options
* @property {Array<string>|string} input - Specifies the glob/path for input files.
* @property {Array<string>|string} [ignore] - Specifies the glob/path for ignoring files.
* @property {Array<keyof publicGenerators>} target - Specifies the generator target mode.
* @property {string} version - Specifies the target Node.js version.
* @property {string} changelog - Specifies the path to the Node.js CHANGELOG.md file.
* @property {string} [gitRef] - Git ref/commit URL.
* @property {number} [threads] - Number of threads to allow.
* @property {boolean} [skipLint] - Skip lint before generate.
*/

/**
* @type {import('../utils.mjs').Command}
*/
export default {
description: 'Generate API docs',
name: 'generate',
options: {
input: {
flags: ['-i', '--input <patterns...>'],
desc: 'Input file patterns (glob)',
prompt: {
type: 'text',
message: 'Enter input glob patterns',
variadic: true,
required: true,
},
},
ignore: {
flags: ['--ignore [patterns...]'],
desc: 'Ignore patterns (comma-separated)',
prompt: {
type: 'text',
message: 'Enter ignore patterns',
variadic: true,
},
},
output: {
flags: ['-o', '--output <dir>'],
desc: 'Output directory',
prompt: { type: 'text', message: 'Enter output directory' },
},
threads: {
flags: ['-p', '--threads <number>'],
prompt: {
type: 'text',
message: 'How many threads to allow',
initialValue: String(Math.max(cpus().length, 1)),
},
},
version: {
flags: ['-v', '--version <semver>'],
desc: 'Target Node.js version',
prompt: {
type: 'text',
message: 'Enter Node.js version',
initialValue: DOC_NODE_VERSION,
},
},
changelog: {
flags: ['-c', '--changelog <url>'],
desc: 'Changelog URL or path',
prompt: {
type: 'text',
message: 'Enter changelog URL',
initialValue: DOC_NODE_CHANGELOG_URL,
},
},
gitRef: {
flags: ['--git-ref <url>'],
desc: 'Git ref/commit URL',
prompt: {
type: 'text',
message: 'Enter Git ref URL',
initialValue: 'https://github.com/nodejs/node/tree/HEAD',
},
},
target: {
flags: ['-t', '--target [modes...]'],
desc: 'Target generator modes',
prompt: {
required: true,
type: 'multiselect',
message: 'Choose target generators',
options: availableGenerators.map(g => ({
value: g,
label: `${publicGenerators[g].name || g} (v${publicGenerators[g].version}) - ${publicGenerators[g].description}`,
})),
},
},
skipLint: {
flags: ['--no-lint'],
desc: 'Skip lint before generate',
prompt: {
type: 'confirm',
message: 'Skip lint before generate?',
initialValue: false,
},
},
},
/**
* Handles the action for generating API docs
* @param {Options} opts - The options to generate API docs.
* @returns {Promise<void>}
*/
async action(opts) {
const docs = await loadAndParse(opts.input, opts.ignore);

if (!opts.skipLint && !runLint(docs)) {
console.error('Lint failed; aborting generation.');
process.exit(1);
}

const { runGenerators } = createGenerator(docs);
const { getAllMajors } = createNodeReleases(opts.changelog);

await runGenerators({
generators: opts.target,
input: opts.input,
output: opts.output && resolve(opts.output),
version: coerce(opts.version),
releases: await getAllMajors(),
gitRef: opts.gitRef,
threads: parseInt(opts.threads, 10),
});
},
};
4 changes: 4 additions & 0 deletions bin/commands/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import generate from './generate.mjs';
import lint from './lint.mjs';

export default [generate, lint];
178 changes: 178 additions & 0 deletions bin/commands/interactive.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { spawnSync } from 'node:child_process';
import process from 'node:process';

import {
intro,
outro,
select,
multiselect,
text,
confirm,
isCancel,
cancel,
} from '@clack/prompts';

import commands from './index.mjs';

/**
* Validates that a string is not empty.
* @param {string} value The input string to validate.
* @returns {string|undefined} A validation message or undefined if valid.
*/
function requireValue(value) {
if (value.length === 0) {
return 'Value is required!';
}
}

/**
* Retrieves the prompt message based on whether the field is required or has an initial value.
* @param {Object} prompt The prompt definition.
* @param {string} prompt.message The message to display.
* @param {boolean} prompt.required Whether the input is required.
* @param {string} [prompt.initialValue] The initial value of the input field.
* @returns {string} The message to display in the prompt.
*/
function getMessage({ message, required, initialValue }) {
return required || initialValue ? message : `${message} (Optional)`;
}

/**
* Escapes shell argument to ensure it's safe for inclusion in shell commands.
* @param {string} arg The argument to escape.
* @returns {string} The escaped argument.
*/
function escapeShellArg(arg) {
// Return the argument as is if it's alphanumeric or contains safe characters
if (/^[a-zA-Z0-9_/-]+$/.test(arg)) {
return arg;
}
// Escape single quotes in the argument
return `'${arg.replace(/'/g, `'\\''`)}'`;
}

/**
* Main interactive function for the API Docs Tooling command line interface.
* Guides the user through a series of prompts, validates inputs, and generates a command to run.
* @returns {Promise<void>} Resolves once the command is generated and executed.
*/
export default async function interactive() {
// Step 1: Introduction to the tool
intro('Welcome to API Docs Tooling');

// Step 2: Choose the action based on available command definitions
const actionOptions = commands.map(({ description }, i) => ({
label: description,
value: i,
}));

const selectedAction = await select({
message: 'What would you like to do?',
options: actionOptions,
});

if (isCancel(selectedAction)) {
cancel('Cancelled.');
process.exit(0);
}

// Retrieve the options for the selected action
const { options, name } = commands[selectedAction];
const answers = {}; // Store answers from user prompts

// Step 3: Collect input for each option
for (const [key, { prompt }] of Object.entries(options)) {
let response;
const promptMessage = getMessage(prompt);

switch (prompt.type) {
case 'text':
response = await text({
message: promptMessage,
initialValue: prompt.initialValue || '',
validate: prompt.required ? requireValue : undefined,
});
if (response) {
// Store response; split into an array if variadic
answers[key] = prompt.variadic
? response.split(',').map(s => s.trim())
: response;
}
break;

case 'confirm':
response = await confirm({
message: promptMessage,
initialValue: prompt.initialValue,
});
answers[key] = response;
break;

case 'multiselect':
response = await multiselect({
message: promptMessage,
options: prompt.options,
required: !!prompt.required,
});
answers[key] = response;
break;

case 'select':
response = await select({
message: promptMessage,
options: prompt.options,
});
answers[key] = response;
break;
}

// Handle cancellation
if (isCancel(response)) {
cancel('Cancelled.');
process.exit(0);
}
}

// Step 4: Build the final command by escaping values
const cmdParts = ['npx', 'api-docs-tooling', name];
const executionArgs = [name];

for (const [key, { flags }] of Object.entries(options)) {
const value = answers[key];
// Skip empty values
if (value == null || (Array.isArray(value) && value.length === 0)) {
continue;
}

const flag = flags[0].split(/[\s,]+/)[0]; // Use the first flag

// Handle different value types (boolean, array, string)
if (typeof value === 'boolean') {
if (value) {
cmdParts.push(flag);
executionArgs.push(flag);
}
} else if (Array.isArray(value)) {
for (const item of value) {
cmdParts.push(flag, escapeShellArg(item));
executionArgs.push(flag, item);
}
} else {
cmdParts.push(flag, escapeShellArg(value));
executionArgs.push(flag, value);
}
}

const finalCommand = cmdParts.join(' ');

console.log(`\nGenerated command:\n${finalCommand}\n`);

// Step 5: Confirm and execute the generated command
if (await confirm({ message: 'Run now?', initialValue: true })) {
spawnSync(process.execPath, [process.argv[1], ...executionArgs], {
stdio: 'inherit',
});
}

outro('Done!');
}
106 changes: 106 additions & 0 deletions bin/commands/lint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import process from 'node:process';

import createLinter from '../../src/linter/index.mjs';
import reporters from '../../src/linter/reporters/index.mjs';
import rules from '../../src/linter/rules/index.mjs';
import { loadAndParse } from '../utils.mjs';

const availableRules = Object.keys(rules);
const availableReporters = Object.keys(reporters);

/**
* @typedef {Object} LinterOptions
* @property {Array<string>|string} input - Glob/path for input files.
* @property {Array<string>|string} [ignore] - Glob/path for ignoring files.
* @property {string[]} [disableRule] - Linter rules to disable.
* @property {boolean} [dryRun] - Dry-run mode.
* @property {keyof reporters} reporter - Reporter for linter output.
*/

/**
* Run the linter on parsed documentation.
* @param {ApiDocMetadataEntry[]} docs - Parsed documentation objects.
* @param {LinterOptions} options - Linter configuration options.
* @returns {boolean} - True if no errors, false otherwise.
*/
export function runLint(
docs,
{ disableRule = [], dryRun = false, reporter = 'console' } = {}
) {
const linter = createLinter(dryRun, disableRule);
linter.lintAll(docs);
linter.report(reporter);
return !linter.hasError();
}

/**
* @type {import('../utils.mjs').Command}
*/
export default {
name: 'lint',
description: 'Run linter independently',
options: {
input: {
flags: ['-i', '--input <patterns...>'],
desc: 'Input file patterns (glob)',
prompt: {
type: 'text',
message: 'Enter input glob patterns',
variadic: true,
required: true,
},
},
ignore: {
flags: ['--ignore [patterns...]'],
desc: 'Ignore patterns (comma-separated)',
prompt: {
type: 'text',
message: 'Enter ignore patterns',
variadic: true,
},
},
disableRule: {
flags: ['--disable-rule [rules...]'],
desc: 'Disable linter rules',
prompt: {
type: 'multiselect',
message: 'Choose rules to disable',
options: availableRules.map(r => ({ label: r, value: r })),
},
},
dryRun: {
flags: ['--dry-run'],
desc: 'Dry run mode',
prompt: {
type: 'confirm',
message: 'Enable dry run mode?',
initialValue: false,
},
},
reporter: {
flags: ['-r', '--reporter <reporter>'],
desc: 'Linter reporter to use',
prompt: {
type: 'select',
message: 'Choose a reporter',
options: availableReporters.map(r => ({ label: r, value: r })),
},
},
},

/**
* Action for running the linter
* @param {LinterOptions} opts - Linter options.
* @returns {Promise<void>}
*/
async action(opts) {
try {
const docs = await loadAndParse(opts.input, opts.ignore);
const success = runLint(docs, opts);
process.exitCode = success ? 0 : 1;
} catch (error) {
console.error('Error running the linter:', error);
process.exitCode = 1;
}
},
};
27 changes: 27 additions & 0 deletions bin/commands/list.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { publicGenerators } from '../../src/generators/index.mjs';
import reporters from '../../src/linter/reporters/index.mjs';
import rules from '../../src/linter/rules/index.mjs';

const availableRules = Object.keys(rules);
const availableReporters = Object.keys(reporters);

export const types = ['generators', 'rules', 'reporters'];

/**
* Lists available generators, rules, or reporters based on the given type.
*
* @param {'generators' | 'rules' | 'reporters'} type - The type of items to list.
*/
export default function list(type) {
const list =
type === 'generators'
? Object.entries(publicGenerators).map(
([key, generator]) =>
`${generator.name || key} (v${generator.version}) - ${generator.description}`
)
: type === 'rules'
? availableRules
: availableReporters;

console.log(list.join('\n'));
}
40 changes: 40 additions & 0 deletions bin/utils.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import createMarkdownLoader from '../src/loaders/markdown.mjs';
import createMarkdownParser from '../src/parsers/markdown.mjs';

// Instantiate loader and parser once to reuse
const loader = createMarkdownLoader();
const parser = createMarkdownParser();

/**
* Load and parse markdown API docs.
* @param {string[]} input - Glob patterns for input files.
* @param {string[]} [ignore] - Glob patterns to ignore.
* @returns {Promise<ApiDocMetadataEntry[]>} - Parsed documentation objects.
*/
export async function loadAndParse(input, ignore) {
const files = await loader.loadFiles(input, ignore);
return parser.parseApiDocs(files);
}

/**
* Represents a command-line option for the linter CLI.
* @typedef {Object} Option
* @property {string[]} flags - Command-line flags, e.g., ['-i, --input <patterns...>'].
* @property {string} desc - Description of the option.
* @property {Object} [prompt] - Optional prompt configuration.
* @property {'text'|'confirm'|'select'|'multiselect'} prompt.type - Type of the prompt.
* @property {string} prompt.message - Message displayed in the prompt.
* @property {boolean} [prompt.variadic] - Indicates if the prompt accepts multiple values.
* @property {boolean} [prompt.required] - Whether the prompt is required.
* @property {boolean} [prompt.initialValue] - Default value for confirm prompts.
* @property {{label: string, value: string}[]} [prompt.options] - Options for select/multiselect prompts.
*/

/**
* Represents a command-line subcommand
* @typedef {Object} Command
* @property {{ [key: string]: Option }} options
* @property {string} name
* @property {string} description
* @property {Function} action
*/
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -6,7 +6,7 @@ import globals from 'globals';
export default [
// @see https://eslint.org/docs/latest/use/configure/configuration-files#specifying-files-and-ignores
{
files: ['src/**/*.mjs'],
files: ['src/**/*.mjs', 'bin/**/*.mjs'],
plugins: {
jsdoc: jsdoc,
},
34 changes: 34 additions & 0 deletions package-lock.json
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -35,6 +35,7 @@
},
"dependencies": {
"@actions/core": "^1.11.1",
"@clack/prompts": "^0.10.1",
"@orama/orama": "^3.1.3",
"@orama/plugin-data-persistence": "^3.1.3",
"acorn": "^8.14.1",
10 changes: 2 additions & 8 deletions src/generators/legacy-html/utils/buildContent.mjs
Original file line number Diff line number Diff line change
@@ -109,10 +109,7 @@ const buildMetadataElement = node => {
: node.added_in;

// Creates the added in element with the added in version
const addedinElement = createElement('span', [
'Added in: ',
addedIn,
]);
const addedinElement = createElement('span', ['Added in: ', addedIn]);

// Appends the added in element to the metadata element
metadataElement.children.push(addedinElement);
@@ -141,10 +138,7 @@ const buildMetadataElement = node => {
: node.removed_in;

// Creates the removed in element with the removed in version
const removedInElement = createElement('span', [
'Removed in: ',
removedIn,
]);
const removedInElement = createElement('span', ['Removed in: ', removedIn]);

// Appends the removed in element to the metadata element
metadataElement.children.push(removedInElement);