From 4eeea5bfa34cf2d5c69273be21b5d5dc4650881e Mon Sep 17 00:00:00 2001 From: avivkeller Date: Sun, 22 Jun 2025 15:48:29 -0400 Subject: [PATCH] feat(index): support passing an index file --- bin/commands/generate.mjs | 16 ++++-- package.json | 8 +-- src/generators/legacy-html/index.mjs | 11 ++++- src/generators/types.d.ts | 3 ++ src/parsers/__tests__/markdown.test.mjs | 52 ++++++++++++++++++++ src/parsers/markdown.mjs | 43 ++++++++++++++++ src/releases.mjs | 65 ------------------------- src/utils/__tests__/parser.test.mjs | 30 ++++++++++++ src/utils/parser.mjs | 21 ++++++++ 9 files changed, 174 insertions(+), 75 deletions(-) create mode 100644 src/parsers/__tests__/markdown.test.mjs delete mode 100644 src/releases.mjs create mode 100644 src/utils/__tests__/parser.test.mjs create mode 100644 src/utils/parser.mjs diff --git a/bin/commands/generate.mjs b/bin/commands/generate.mjs index 9d7095d7..f13fb150 100644 --- a/bin/commands/generate.mjs +++ b/bin/commands/generate.mjs @@ -11,7 +11,7 @@ import { publicGenerators } from '../../src/generators/index.mjs'; import createGenerator from '../../src/generators.mjs'; import createLinter from '../../src/linter/index.mjs'; import { getEnabledRules } from '../../src/linter/utils/rules.mjs'; -import createNodeReleases from '../../src/releases.mjs'; +import { parseChangelog, parseIndex } from '../../src/parsers/markdown.mjs'; import { loadAndParse } from '../utils.mjs'; const availableGenerators = Object.keys(publicGenerators); @@ -107,6 +107,14 @@ export default { })), }, }, + index: { + flags: ['--index '], + desc: 'The index document, for getting the titles of various API docs', + prompt: { + message: 'Path to doc/api/index.md', + type: 'text', + }, + }, skipLint: { flags: ['--skip-lint'], desc: 'Skip lint before generate', @@ -135,9 +143,8 @@ export default { process.exit(1); } - const { getAllMajors } = createNodeReleases(opts.changelog); - - const releases = await getAllMajors(); + const releases = await parseChangelog(opts.changelog); + const index = opts.index && (await parseIndex(opts.index)); const { runGenerators } = createGenerator(docs); @@ -149,6 +156,7 @@ export default { releases, gitRef: opts.gitRef, threads: parseInt(opts.threads, 10), + index, }); }, }; diff --git a/package.json b/package.json index 62bd9e7f..5523c6e6 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,11 @@ "format": "prettier .", "format:write": "prettier --write .", "format:check": "prettier --check .", - "test": "node --test", + "test": "node --test --experimental-test-module-mocks", "test:coverage": "c8 npm test", - "test:ci": "c8 --reporter=lcov node --test --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml --test-reporter=spec --test-reporter-destination=stdout", - "test:update-snapshots": "node --test --test-update-snapshots", - "test:watch": "node --test --watch", + "test:ci": "c8 --reporter=lcov node --test --experimental-test-module-mocks --test-reporter=@reporters/github --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=junit.xml --test-reporter=spec --test-reporter-destination=stdout", + "test:update-snapshots": "node --test --experimental-test-module-mocks --test-update-snapshots", + "test:watch": "node --test --experimental-test-module-mocks --watch", "prepare": "husky", "run": "node bin/cli.mjs", "watch": "node --watch bin/cli.mjs" diff --git a/src/generators/legacy-html/index.mjs b/src/generators/legacy-html/index.mjs index 475ae924..94bb3fc5 100644 --- a/src/generators/legacy-html/index.mjs +++ b/src/generators/legacy-html/index.mjs @@ -47,7 +47,7 @@ export default { * @param {Input} input * @param {Partial} options */ - async generate(input, { releases, version, output }) { + async generate(input, { index, releases, version, output }) { // This array holds all the generated values for each module const generatedValues = []; @@ -68,9 +68,16 @@ export default { .filter(node => node.heading.depth === 1) .sort((a, b) => a.heading.data.name.localeCompare(b.heading.data.name)); + const indexOfFiles = index + ? index.map(entry => ({ + api: entry.api, + heading: { data: { depth: 1, name: entry.section } }, + })) + : headNodes; + // Generates the global Table of Contents (Sidebar Navigation) const parsedSideNav = remarkRehypeProcessor.processSync( - tableOfContents(headNodes, { + tableOfContents(indexOfFiles, { maxDepth: 1, parser: tableOfContents.parseNavigationNode, }) diff --git a/src/generators/types.d.ts b/src/generators/types.d.ts index 91d98ebe..189fb624 100644 --- a/src/generators/types.d.ts +++ b/src/generators/types.d.ts @@ -31,6 +31,9 @@ declare global { // A list of all Node.js major versions and their respective release information releases: Array; + // A list of all the titles of all the documentation files + index: Array<{ section: string; api: string }>; + // 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 // i.e. https://github.com/nodejs/node/tree/2cb1d07e0f6d9456438016bab7db4688ab354fd2 diff --git a/src/parsers/__tests__/markdown.test.mjs b/src/parsers/__tests__/markdown.test.mjs new file mode 100644 index 00000000..dda42123 --- /dev/null +++ b/src/parsers/__tests__/markdown.test.mjs @@ -0,0 +1,52 @@ +'use strict'; + +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; + +import dedent from 'dedent'; + +let content; +mock.module('../../utils/parser.mjs', { + namedExports: { + loadFromURL: async () => content, + }, +}); + +const { parseChangelog, parseIndex } = await import('../markdown.mjs'); + +describe('parseChangelog', () => { + it('should parse Node.js versions and their LTS status', async () => { + content = dedent` + * [Node.js 24](doc/changelogs/CHANGELOG_V24.md) **Current** + * [Node.js 22](doc/changelogs/CHANGELOG_V22.md) **Long Term Support**\n + `; + + const results = await parseChangelog('...'); + + assert.partialDeepStrictEqual(results, [ + { version: { raw: '24.0.0' }, isLts: false }, + { version: { raw: '22.0.0' }, isLts: true }, + ]); + }); +}); + +describe('parseIndex', () => { + it('should retrieve document titles for sidebar generation', async () => { + content = dedent` + # API Documentation + + * [Assert](assert.md) + * [Buffer](buffer.md) + * [Child Process](child_process.md) + * [Something](not-a-markdown-file) + `; + + const results = await parseIndex('...'); + + assert.deepStrictEqual(results, [ + { section: 'Assert', api: 'assert' }, + { section: 'Buffer', api: 'buffer' }, + { section: 'Child Process', api: 'child_process' }, + ]); + }); +}); diff --git a/src/parsers/markdown.mjs b/src/parsers/markdown.mjs index 51b89035..608c783e 100644 --- a/src/parsers/markdown.mjs +++ b/src/parsers/markdown.mjs @@ -1,8 +1,20 @@ 'use strict'; +import { coerce } from 'semver'; + +import { loadFromURL } from '../utils/parser.mjs'; import createQueries from '../utils/queries/index.mjs'; import { getRemark } from '../utils/remark.mjs'; +// A ReGeX for retrieving Node.js version headers from the CHANGELOG.md +const NODE_VERSIONS_REGEX = /\* \[Node\.js ([0-9.]+)\]\S+ (.*)\r?\n/g; + +// A ReGeX for retrieving the list items in the index document +const LIST_ITEM_REGEX = /\* \[(.*?)\]\((.*?)\.md\)/g; + +// A ReGeX for checking if a Node.js version is an LTS release +const NODE_LTS_VERSION_REGEX = /Long Term Support/i; + /** * Creates an API doc parser for a given Markdown API doc file * @@ -58,4 +70,35 @@ const createParser = linter => { return { parseApiDocs, parseApiDoc }; }; +/** + * Retrieves all Node.js major versions from the provided CHANGELOG.md file + * and returns an array of objects containing the version and LTS status. + * @param {string|URL} path Path to changelog + * @returns {Promise>} + */ +export const parseChangelog = async path => { + const changelog = await loadFromURL(path); + + const nodeMajors = Array.from(changelog.matchAll(NODE_VERSIONS_REGEX)); + + return nodeMajors.map(match => ({ + version: coerce(match[1]), + isLts: NODE_LTS_VERSION_REGEX.test(match[2]), + })); +}; + +/** + * Retrieves all the document titles for sidebar generation. + * + * @param {string|URL} path Path to changelog + * @returns {Promise>} + */ +export const parseIndex = async path => { + const index = await loadFromURL(path); + + const items = Array.from(index.matchAll(LIST_ITEM_REGEX)); + + return items.map(([, section, api]) => ({ section, api })); +}; + export default createParser; diff --git a/src/releases.mjs b/src/releases.mjs deleted file mode 100644 index bb795dae..00000000 --- a/src/releases.mjs +++ /dev/null @@ -1,65 +0,0 @@ -'use strict'; - -import { readFile } from 'node:fs/promises'; - -import { coerce } from 'semver'; - -// A ReGeX for retrieving Node.js version headers from the CHANGELOG.md -const NODE_VERSIONS_REGEX = /\* \[Node\.js ([0-9.]+)\]\S+ (.*)\r?\n/g; - -// A ReGeX for checking if a Node.js version is an LTS release -const NODE_LTS_VERSION_REGEX = /Long Term Support/i; - -/** - * Retrieves the Node.js CHANGELOG.md via a Network Request, - * used when a non-file protocol is provided - * - * @param {URL} changelogUrl The URL to the CHANGELOG.md raw content - */ -const getChangelogFromNetwork = async changelogUrl => - fetch(changelogUrl).then(response => response.text()); - -/** - * Retrieves the Node.js CHANGELOG.md via the File System, - * used when a file protocol is provided - * - * @param {URL} changelogUrl The File Path to the CHANGELOG.md file - */ -const getChangelogFromFileSystem = async changelogUrl => - readFile(changelogUrl, 'utf-8'); - -/** - * This creates an utility to retrieve the Node.js major release metadata - * purely out from the Node.js CHANGELOG.md file - * - * @param {string} changelogPath The given URL to the Node.js CHANGELOG.md file - */ -const createNodeReleases = changelogPath => { - const changelogUrl = URL.parse(changelogPath); - - const changelogStrategy = - !changelogUrl || changelogUrl.protocol === 'file:' - ? getChangelogFromFileSystem(changelogUrl ?? changelogPath) - : getChangelogFromNetwork(changelogUrl); - - /** - * Retrieves all Node.js major versions from the provided CHANGELOG.md file - * and returns an array of objects containing the version and LTS status. - * - * @returns {Promise>} - */ - const getAllMajors = async () => { - const changelog = await changelogStrategy; - - const nodeMajors = Array.from(changelog.matchAll(NODE_VERSIONS_REGEX)); - - return nodeMajors.map(match => ({ - version: coerce(match[1]), - isLts: NODE_LTS_VERSION_REGEX.test(match[2]), - })); - }; - - return { getAllMajors }; -}; - -export default createNodeReleases; diff --git a/src/utils/__tests__/parser.test.mjs b/src/utils/__tests__/parser.test.mjs new file mode 100644 index 00000000..ff906a1f --- /dev/null +++ b/src/utils/__tests__/parser.test.mjs @@ -0,0 +1,30 @@ +'use strict'; + +import assert from 'node:assert/strict'; +import { describe, it, mock } from 'node:test'; + +mock.module('node:fs/promises', { + namedExports: { + readFile: async () => 'file content', + }, +}); + +global.fetch = mock.fn(() => + Promise.resolve({ + text: () => Promise.resolve('fetched content'), + }) +); + +const { loadFromURL } = await import('../parser.mjs'); + +describe('loadFromURL', () => { + it('should load content from a file path', async () => { + const result = await loadFromURL('path/to/file.txt'); + assert.equal(result, 'file content'); + }); + + it('should load content from a URL', async () => { + const result = await loadFromURL('https://example.com/data'); + assert.equal(result, 'fetched content'); + }); +}); diff --git a/src/utils/parser.mjs b/src/utils/parser.mjs new file mode 100644 index 00000000..a53c6fe5 --- /dev/null +++ b/src/utils/parser.mjs @@ -0,0 +1,21 @@ +'use strict'; + +import { readFile } from 'node:fs/promises'; + +/** + * Loads content from a URL or file path + * @param {string|URL} url The URL or file path to load + * @returns {Promise} The content as a string + */ +export const loadFromURL = async url => { + const parsedUrl = url instanceof URL ? url : URL.parse(url); + + if (!parsedUrl || parsedUrl.protocol === 'file:') { + // Load from file system + return readFile(url, 'utf-8'); + } else { + // Load from network + const response = await fetch(parsedUrl); + return response.text(); + } +};