diff --git a/.c8rc.json b/.c8rc.json index 3c63a3ff..238f1959 100644 --- a/.c8rc.json +++ b/.c8rc.json @@ -5,6 +5,7 @@ "**/fixtures", "src/generators/legacy-html/assets", "src/generators/web/ui", - "**/*.d.ts" + "**/*.d.ts", + "scripts/" ] } diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index dfb2a116..e83d93ac 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -106,6 +106,9 @@ jobs: input: './node/doc/api/*.md' compare: file-size + - target: json + input: './node/doc/api/*.md' + - target: llms-txt input: './node/doc/api/*.md' compare: file-size diff --git a/eslint.config.mjs b/eslint.config.mjs index 6076d288..d9b3411b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -63,6 +63,14 @@ export default defineConfig([ globals: { ...globals.nodeBuiltin }, }, rules: { + 'no-unused-vars': [ + 'error', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + ignoreRestSiblings: true, + }, + ], 'jsdoc/check-alignment': 'error', 'jsdoc/check-indentation': 'error', 'jsdoc/require-jsdoc': [ diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 6e7bf03a..1f4858b9 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -24,6 +24,7 @@ "globals": "^16.5.0", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", + "jsonc-parser": "^3.3.1", "lightningcss": "^1.30.2", "mdast-util-slice-markdown": "^2.0.1", "piscina": "^5.1.4", @@ -67,6 +68,8 @@ "eslint-plugin-jsdoc": "^61.5.0", "eslint-plugin-react-x": "^2.4.0", "husky": "^9.1.7", + "json-schema-to-typescript": "^15.0.4", + "jsonschema": "^1.5.0", "lint-staged": "^16.2.7", "prettier": "3.7.4" } @@ -118,6 +121,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", @@ -667,6 +688,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/nice": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", @@ -989,6 +1017,7 @@ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -1088,6 +1117,7 @@ "resolved": "https://registry.npmjs.org/@orama/cuid2/-/cuid2-2.2.3.tgz", "integrity": "sha512-Lcak3chblMejdlSHgYU2lS2cdOhDpU6vkfIJH4m+YKvqQyLqs1bB8+w6NT1MG5bO12NUK2GFc34Mn2xshMIQ1g==", "license": "MIT", + "peer": true, "dependencies": { "@noble/hashes": "^1.1.5" } @@ -1105,7 +1135,8 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/@orama/oramacore-events-parser/-/oramacore-events-parser-0.0.5.tgz", "integrity": "sha512-yAuSwog+HQBAXgZ60TNKEwu04y81/09mpbYBCmz1RCxnr4ObNY2JnPZI7HmALbjAhLJ8t5p+wc2JHRK93ubO4w==", - "license": "AGPL-3.0" + "license": "AGPL-3.0", + "peer": true }, "node_modules/@orama/stopwords": { "version": "3.1.16", @@ -3036,6 +3067,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", @@ -3233,7 +3271,6 @@ "integrity": "sha512-lCLp8H1T9T7gPbEuJSnHwnSuO9mDf8mfK/Nion5mZmiEaQD9sWf9W4dfeFqRyqRjF06/kBuTmAqcs9sewM2NbQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.50.1", @@ -3623,7 +3660,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -4172,7 +4208,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/debug": { "version": "4.4.3", @@ -4375,7 +4412,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5508,6 +5544,30 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5522,6 +5582,22 @@ "dev": true, "license": "MIT" }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/jsonschema": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.5.0.tgz", + "integrity": "sha512-K+A9hhqbn0f3pJX17Q/7H6yQfD/5OXgdrR5UE12gMXCiN9D5Xq2o5mddV2QEcX/bjla99ASsAAQUyMCCRWAEhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5910,6 +5986,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6928,6 +7011,16 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -7262,7 +7355,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -7312,7 +7404,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0-beta.0.tgz", "integrity": "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -7815,7 +7906,8 @@ "version": "0.26.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/semver": { "version": "7.7.3", @@ -8409,7 +8501,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -8767,7 +8858,6 @@ "integrity": "sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==", "dev": true, "hasInstallScript": true, - "peer": true, "dependencies": { "napi-postinstall": "^0.2.4" }, @@ -9198,6 +9288,7 @@ "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", "license": "ISC", + "peer": true, "peerDependencies": { "zod": "^3.24.1" } diff --git a/package.json b/package.json index eb132af2..e1b09421 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "eslint-plugin-jsdoc": "^61.5.0", "eslint-plugin-react-x": "^2.4.0", "husky": "^9.1.7", + "json-schema-to-typescript": "^15.0.4", + "jsonschema": "^1.5.0", "lint-staged": "^16.2.7", "prettier": "3.7.4" }, @@ -57,6 +59,7 @@ "globals": "^16.5.0", "hast-util-to-string": "^3.0.1", "hastscript": "^9.0.1", + "jsonc-parser": "^3.3.1", "lightningcss": "^1.30.2", "mdast-util-slice-markdown": "^2.0.1", "piscina": "^5.1.4", diff --git a/scripts/generate-json-types.mjs b/scripts/generate-json-types.mjs new file mode 100755 index 00000000..429970b5 --- /dev/null +++ b/scripts/generate-json-types.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +/** + * Generates the typedefs for the JSON generator from the JSON schema + */ + +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { compile } from 'json-schema-to-typescript'; +import { parse } from 'jsonc-parser'; + +const GENERATOR_DIR = join( + import.meta.dirname, + '..', + 'src', + 'generators', + 'json' +); + +const SCHEMA_PATH = join(GENERATOR_DIR, 'schema.jsonc'); +const TYPES_PATH = join(GENERATOR_DIR, 'generated', 'generated.d.ts'); + +// Read the contents of the JSON schema +const schemaString = await readFile(SCHEMA_PATH, 'utf8'); + +// Parse the JSON schema into an object +const schema = await parse(schemaString); + +// Compile the JSON schema into TypeScript typedefs +const typeDefs = await compile(schema, 'ApiDocSchema'); + +// Write the types to the expected output path +await writeFile(TYPES_PATH, typeDefs); diff --git a/src/generators/index.mjs b/src/generators/index.mjs index 24294f92..63375a48 100644 --- a/src/generators/index.mjs +++ b/src/generators/index.mjs @@ -4,6 +4,8 @@ import addonVerify from './addon-verify/index.mjs'; import apiLinks from './api-links/index.mjs'; import ast from './ast/index.mjs'; import astJs from './ast-js/index.mjs'; +import json from './json/index.mjs'; +import jsonAll from './json-all/index.mjs'; import jsonSimple from './json-simple/index.mjs'; import jsxAst from './jsx-ast/index.mjs'; import legacyHtml from './legacy-html/index.mjs'; @@ -30,6 +32,8 @@ export const publicGenerators = { 'llms-txt': llmsTxt, sitemap, web, + json, + 'json-all': jsonAll, }; // These ones are special since they don't produce standard output, diff --git a/src/generators/json-all/__tests__/index.test.mjs b/src/generators/json-all/__tests__/index.test.mjs new file mode 100644 index 00000000..0d989dbe --- /dev/null +++ b/src/generators/json-all/__tests__/index.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert'; +import { join } from 'node:path'; +import { test } from 'node:test'; + +import { Validator } from 'jsonschema'; +import { SemVer } from 'semver'; + +import { BASE_URL } from '../../../constants.mjs'; +import createGenerator from '../../../generators.mjs'; +import { SCHEMA_FILENAME } from '../constants.mjs'; +import jsonAll from '../index.mjs'; +import { generateJsonSchema } from '../util/generateJsonSchema.mjs'; + +const FIXTURES_DIR = join( + import.meta.dirname, + '..', + 'json', + '__tests__', + 'fixtures' +); + +test('generator output complies with json schema', async () => { + const validator = new Validator(); + + const version = 'v1.2.3'; + + /** + * @type {object} + */ + const schema = generateJsonSchema(version); + + const input = [ + join(FIXTURES_DIR, 'text-doc.md'), + join(FIXTURES_DIR, 'module.md'), + ]; + + const { runGenerators } = createGenerator(); + + const result = await runGenerators({ + generators: ['json-all'], + input, + output: undefined, + version: new SemVer('v1.2.3'), + releases: [], + gitRef: 'a'.repeat(40), + threads: 1, + typeMap: {}, + }); + + assert.ok(validator.validate(result, schema).valid); +}); + +test('combines json correctly', async () => { + const version = 'v1.2.3'; + + /** + * @type {Array} + */ + const jsonOutput = [ + { + $schema: `https://nodejs.org/docs/${version}/api/node-doc-schema.json`, + source: 'doc/api/some-module.md', + type: 'module', + '@name': 'Some Module', + '@module': 'node:module', + classes: [], + }, + { + $schema: `https://nodejs.org/docs/${version}/api/node-doc-schema.json`, + source: 'doc/api/some-module.md', + type: 'text', + '@name': 'Some Module', + description: 'asdf', + text: [ + { + type: 'text', + '@name': 'asdf', + description: 'bla bla bla', + }, + ], + }, + ]; + + const result = await jsonAll.generate(jsonOutput, { + version: new SemVer(version), + }); + + assert.deepStrictEqual(result, { + $schema: `${BASE_URL}docs/${version}/api/${SCHEMA_FILENAME}`, + modules: [ + { + type: 'module', + '@name': 'Some Module', + '@module': 'node:module', + classes: [], + }, + ], + text: [ + { + type: 'text', + '@name': 'Some Module', + description: 'asdf', + text: [ + { + type: 'text', + '@name': 'asdf', + description: 'bla bla bla', + }, + ], + }, + ], + }); +}); diff --git a/src/generators/json-all/constants.mjs b/src/generators/json-all/constants.mjs new file mode 100644 index 00000000..76f388db --- /dev/null +++ b/src/generators/json-all/constants.mjs @@ -0,0 +1 @@ +export const SCHEMA_FILENAME = 'node-doc-all-schema.json'; diff --git a/src/generators/json-all/index.mjs b/src/generators/json-all/index.mjs new file mode 100644 index 00000000..ff01c60a --- /dev/null +++ b/src/generators/json-all/index.mjs @@ -0,0 +1,66 @@ +'use strict'; + +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { SCHEMA_FILENAME } from './constants.mjs'; +import { BASE_URL } from '../../constants.mjs'; +import { generateJsonSchema } from './util/generateJsonSchema.mjs'; + +/** + * This generator is responsible for collecting the JSON output generated by + * the `json` generator into a single JSON file. + * + * @typedef {Array} Input + * + * @type {GeneratorMetadata} + */ +export default { + name: 'json-all', + + // This should be kept in sync with the JSON schema version for this + // generator AND the `json` generator + version: '2.0.0', + + description: + 'This generator is responsible for collecting the JSON output generated by the `json` generator into a single JSON file.', + + dependsOn: 'json', + + /** + * Generates a JSON file. + * + * @param {Input} input + * @param {Partial} param1 + * @returns {Promise} + */ + async generate(input, { version, output }) { + const generatedValue = { + $schema: `${BASE_URL}docs/${version.raw}/api/${SCHEMA_FILENAME}`, + modules: [], + text: [], + }; + + input.forEach(({ $schema: _, source: _2, ...section }) => { + switch (section.type) { + case 'module': + generatedValue.modules.push(section); + break; + case 'text': + generatedValue.text.push(section); + break; + default: + throw new TypeError(`unsupported root section type ${section.type}`); + } + }); + + if (output) { + const schema = generateJsonSchema(version.raw); + + // Write the parsed JSON schema to the output directory + await writeFile(join(output, SCHEMA_FILENAME), JSON.stringify(schema)); + } + + return generatedValue; + }, +}; diff --git a/src/generators/json-all/util/generateJsonSchema.mjs b/src/generators/json-all/util/generateJsonSchema.mjs new file mode 100644 index 00000000..ed17ebb5 --- /dev/null +++ b/src/generators/json-all/util/generateJsonSchema.mjs @@ -0,0 +1,30 @@ +'use strict'; + +import { BASE_URL } from '../../../constants.mjs'; +import { SCHEMA_FILENAME } from '../constants.mjs'; +import jsonAll from '../index.mjs'; + +/** + * @param {string} version + */ +export function generateJsonSchema(version) { + const jsonSchemaUrl = `${BASE_URL}/docs/${version}/api/${SCHEMA_FILENAME}`; + + return { + $schema: 'http://json-schema.org/draft-07/schema#', + $id: `nodejs-api-doc-all@v${jsonAll.version}`, + title: 'Node.js API Documentation Schema (All)', + readOnly: true, + + properties: { + modules: { + type: 'array', + items: { $ref: `${jsonSchemaUrl}/#/definitions/Module` }, + }, + text: { + type: 'array', + items: { $ref: `${jsonSchemaUrl}/#/definitions/Text` }, + }, + }, + }; +} diff --git a/src/generators/json/__tests__/fixtures/module.md b/src/generators/json/__tests__/fixtures/module.md new file mode 100644 index 00000000..9d7c22c3 --- /dev/null +++ b/src/generators/json/__tests__/fixtures/module.md @@ -0,0 +1,73 @@ +# Module + + + + +> Stability: 2 - Stable + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquet est sit amet nisi iaculis ornare. Donec eu orci condimentum, semper quam ut, luctus nisl. Aliquam vel erat efficitur, egestas justo a, accumsan mauris. Quisque scelerisque enim rhoncus ornare iaculis. Sed commodo, sem ac accumsan posuere, sapien magna eleifend massa, sit amet rhoncus sem diam quis nulla. Donec feugiat elit a tellus hendrerit congue quis bibendum urna. Pellentesque mollis tincidunt tortor, in lobortis ex fringilla a. + +Nulla sagittis pulvinar lacus et venenatis. Morbi eget varius sem, a rutrum velit. Vestibulum placerat vehicula felis. Proin et porttitor augue. Sed feugiat urna sit amet euismod posuere. Phasellus quis pretium velit. Integer porta ut velit ac consectetur. Pellentesque venenatis leo et volutpat dapibus. Nam consectetur quam venenatis feugiat tempus. Curabitur semper fringilla felis. + +Donec sed est at nulla lacinia suscipit id id neque. Cras quis risus a risus mattis consectetur ut ut ex. Cras non metus a orci facilisis laoreet. Sed eu nisi sit amet mauris iaculis placerat. Nullam interdum, lectus sed convallis tristique, nibh nibh ullamcorper leo, non vehicula metus elit a nisl. Donec eros felis, ornare eget tempor nec, egestas mattis elit. Integer semper purus at risus pretium, in tristique ipsum tristique. + +```mjs +import { something } from 'node:something'; +``` + +## text section + + + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquet est sit amet nisi iaculis ornare. Donec eu orci condimentum, semper quam ut, luctus nisl. Aliquam vel erat efficitur, egestas justo a, accumsan mauris. Quisque scelerisque enim rhoncus ornare iaculis. Sed commodo, sem ac accumsan posuere, sapien magna eleifend massa, sit amet rhoncus sem diam quis nulla. Donec feugiat elit a tellus hendrerit congue quis bibendum urna. Pellentesque mollis tincidunt tortor, in lobortis ex fringilla a. + +Nulla sagittis pulvinar lacus et venenatis. Morbi eget varius sem, a rutrum velit. Vestibulum placerat vehicula felis. Proin et porttitor augue. Sed feugiat urna sit amet euismod posuere. Phasellus quis pretium velit. Integer porta ut velit ac consectetur. Pellentesque venenatis leo et volutpat dapibus. Nam consectetur quam venenatis feugiat tempus. Curabitur semper fringilla felis. + +Donec sed est at nulla lacinia suscipit id id neque. Cras quis risus a risus mattis consectetur ut ut ex. Cras non metus a orci facilisis laoreet. Sed eu nisi sit amet mauris iaculis placerat. Nullam interdum, lectus sed convallis tristique, nibh nibh ullamcorper leo, non vehicula metus elit a nisl. Donec eros felis, ornare eget tempor nec, egestas mattis elit. Integer semper purus at risus pretium, in tristique ipsum tristique. + +## Class: `Something` + + + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquet est sit amet nisi iaculis ornare. Donec eu orci condimentum, semper quam ut, luctus nisl. Aliquam vel erat efficitur, egestas justo a, accumsan mauris. Quisque scelerisque enim rhoncus ornare iaculis. Sed commodo, sem ac accumsan posuere, sapien magna eleifend massa, sit amet rhoncus sem diam quis nulla. Donec feugiat elit a tellus hendrerit congue quis bibendum urna. Pellentesque mollis tincidunt tortor, in lobortis ex fringilla a. + +### `new something.Something([sources[, options]])` + +- `sources` {string\[]|object} something something bla bla bla +- `options` {object} something something bla bla bla + - `optionA` {boolean} asdf + - `bla` qwerty + +### `something.doThing()` + +- Returns: {Promise} + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquet est sit amet nisi iaculis ornare. Donec eu orci condimentum, semper quam ut, luctus nisl. Aliquam vel erat efficitur, egestas justo a, accumsan mauris. Quisque scelerisque enim rhoncus ornare iaculis. Sed commodo, sem ac accumsan posuere, sapien magna eleifend massa, sit amet rhoncus sem diam quis nulla. Donec feugiat elit a tellus hendrerit congue quis bibendum urna. Pellentesque mollis tincidunt tortor, in lobortis ex fringilla a. + +### Static method: `something.doStaticThing()` + +- Returns: {Promise} + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquet est sit amet nisi iaculis ornare. Donec eu orci condimentum, semper quam ut, luctus nisl. Aliquam vel erat efficitur, egestas justo a, accumsan mauris. Quisque scelerisque enim rhoncus ornare iaculis. Sed commodo, sem ac accumsan posuere, sapien magna eleifend massa, sit amet rhoncus sem diam quis nulla. Donec feugiat elit a tellus hendrerit congue quis bibendum urna. Pellentesque mollis tincidunt tortor, in lobortis ex fringilla a. + +### Event: `somethingHappened` + +- `thing` {string} thing that happened +- `how` {string|object} bla + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquet est sit amet nisi iaculis ornare. Donec eu orci condimentum, semper quam ut, luctus nisl. Aliquam vel erat efficitur, egestas justo a, accumsan mauris. Quisque scelerisque enim rhoncus ornare iaculis. Sed commodo, sem ac accumsan posuere, sapien magna eleifend massa, sit amet rhoncus sem diam quis nulla. Donec feugiat elit a tellus hendrerit congue quis bibendum urna. Pellentesque mollis tincidunt tortor, in lobortis ex fringilla a. diff --git a/src/generators/json/__tests__/fixtures/text-doc.md b/src/generators/json/__tests__/fixtures/text-doc.md new file mode 100644 index 00000000..f6495604 --- /dev/null +++ b/src/generators/json/__tests__/fixtures/text-doc.md @@ -0,0 +1,43 @@ +# Hello world + + + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquet est sit amet nisi iaculis ornare. Donec eu orci condimentum, semper quam ut, luctus nisl. Aliquam vel erat efficitur, egestas justo a, accumsan mauris. Quisque scelerisque enim rhoncus ornare iaculis. Sed commodo, sem ac accumsan posuere, sapien magna eleifend massa, sit amet rhoncus sem diam quis nulla. Donec feugiat elit a tellus hendrerit congue quis bibendum urna. Pellentesque mollis tincidunt tortor, in lobortis ex fringilla a. + +## abc123 + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquet est sit amet nisi iaculis ornare. Donec eu orci condimentum, semper quam ut, luctus nisl. Aliquam vel erat efficitur, egestas justo a, accumsan mauris. Quisque scelerisque enim rhoncus ornare iaculis. Sed commodo, sem ac accumsan posuere, sapien magna eleifend massa, sit amet rhoncus sem diam quis nulla. Donec feugiat elit a tellus hendrerit congue quis bibendum urna. Pellentesque mollis tincidunt tortor, in lobortis ex fringilla a. + +Nulla sagittis pulvinar lacus et venenatis. Morbi eget varius sem, a rutrum velit. Vestibulum placerat vehicula felis. Proin et porttitor augue. Sed feugiat urna sit amet euismod posuere. Phasellus quis pretium velit. Integer porta ut velit ac consectetur. Pellentesque venenatis leo et volutpat dapibus. Nam consectetur quam venenatis feugiat tempus. Curabitur semper fringilla felis. + +Donec sed est at nulla lacinia suscipit id id neque. Cras quis risus a risus mattis consectetur ut ut ex. Cras non metus a orci facilisis laoreet. Sed eu nisi sit amet mauris iaculis placerat. Nullam interdum, lectus sed convallis tristique, nibh nibh ullamcorper leo, non vehicula metus elit a nisl. Donec eros felis, ornare eget tempor nec, egestas mattis elit. Integer semper purus at risus pretium, in tristique ipsum tristique. + +Nulla ultrices venenatis ex, vitae ullamcorper dui pulvinar non. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vivamus fermentum, metus in blandit hendrerit, lorem urna interdum lacus, nec pretium turpis justo eget est. Cras tincidunt urna vel massa ultrices ultrices. Vivamus ullamcorper leo at nulla semper, ac vehicula nisl fringilla. Curabitur viverra vulputate tristique. Donec lorem erat, ornare sit amet faucibus id, mattis vel diam. Ut posuere sem laoreet augue tristique, eget volutpat leo malesuada. Nullam non velit mattis, pulvinar sem eu, vehicula purus. Proin placerat accumsan porttitor. Duis pharetra facilisis aliquet. Suspendisse blandit a nulla eget gravida. Etiam molestie porttitor sodales. Donec lobortis pretium sollicitudin. [asd](https://github.com/nodejs/node) + +### asd + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquet est sit amet nisi iaculis ornare. Donec eu orci condimentum, semper quam ut, luctus nisl. Aliquam vel erat efficitur, egestas justo a, accumsan mauris. Quisque scelerisque enim rhoncus ornare iaculis. Sed commodo, sem ac accumsan posuere, sapien magna eleifend massa, sit amet rhoncus sem diam quis nulla. Donec feugiat elit a tellus hendrerit congue quis bibendum urna. Pellentesque mollis tincidunt tortor, in lobortis ex fringilla a. + +Nulla sagittis pulvinar lacus et venenatis. Morbi eget varius sem, a rutrum velit. Vestibulum placerat vehicula felis. Proin et porttitor augue. Sed feugiat urna sit amet euismod posuere. Phasellus quis pretium velit. Integer porta ut velit ac consectetur. Pellentesque venenatis leo et volutpat dapibus. Nam consectetur quam venenatis feugiat tempus. Curabitur semper fringilla felis. + +- lorem +- ipsum + +```c++ +int main() { + std::cout << "blahaj\n"; + return 0; +} +``` + +## lorem + +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla aliquet est sit amet nisi iaculis ornare. Donec eu orci condimentum, semper quam ut, luctus nisl. Aliquam vel erat efficitur, egestas justo a, accumsan mauris. Quisque scelerisque enim rhoncus ornare iaculis. Sed commodo, sem ac accumsan posuere, sapien magna eleifend massa, sit amet rhoncus sem diam quis nulla. Donec feugiat elit a tellus hendrerit congue quis bibendum urna. Pellentesque mollis tincidunt tortor, in lobortis ex fringilla a. + +Nulla sagittis pulvinar lacus et venenatis. Morbi eget varius sem, a rutrum velit. Vestibulum placerat vehicula felis. Proin et porttitor augue. Sed feugiat urna sit amet euismod posuere. Phasellus quis pretium velit. Integer porta ut velit ac consectetur. Pellentesque venenatis leo et volutpat dapibus. Nam consectetur quam venenatis feugiat tempus. Curabitur semper fringilla felis. + +Donec sed est at nulla lacinia suscipit id id neque. Cras quis risus a risus mattis consectetur ut ut ex. Cras non metus a orci facilisis laoreet. Sed eu nisi sit amet mauris iaculis placerat. Nullam interdum, lectus sed convallis tristique, nibh nibh ullamcorper leo, non vehicula metus elit a nisl. Donec eros felis, ornare eget tempor nec, egestas mattis elit. Integer semper purus at risus pretium, in tristique ipsum tristique. + +Fusce pretium tincidunt dolor sit amet dignissim. Aliquam egestas purus sed lorem feugiat faucibus. Aenean eget augue non velit maximus tempor cursus eget augue. Suspendisse id laoreet felis. Integer consectetur scelerisque turpis, eu dapibus diam. Etiam ut tempus massa, vel euismod ipsum. Suspendisse quis nisl sapien. Etiam sed euismod dui. Nulla fermentum fringilla dignissim. In mauris ex, iaculis at arcu et, gravida sodales erat. + +Nulla ultrices venenatis ex, vitae ullamcorper dui pulvinar non. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Vivamus fermentum, metus in blandit hendrerit, lorem urna interdum lacus, nec pretium turpis justo eget est. Cras tincidunt urna vel massa ultrices ultrices. Vivamus ullamcorper leo at nulla semper, ac vehicula nisl fringilla. Curabitur viverra vulputate tristique. Donec lorem erat, ornare sit amet faucibus id, mattis vel diam. Ut posuere sem laoreet augue tristique, eget volutpat leo malesuada. Nullam non velit mattis, pulvinar sem eu, vehicula purus. Proin placerat accumsan porttitor. Duis pharetra facilisis aliquet. Suspendisse blandit a nulla eget gravida. Etiam molestie porttitor sodales. Donec lobortis pretium sollicitudin. diff --git a/src/generators/json/__tests__/index.test.mjs b/src/generators/json/__tests__/index.test.mjs new file mode 100644 index 00000000..b657d808 --- /dev/null +++ b/src/generators/json/__tests__/index.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, test, before } from 'node:test'; + +import { parse as jsoncParse } from 'jsonc-parser'; +import { Validator } from 'jsonschema'; +import { SemVer } from 'semver'; + +import createGenerator from '../../../generators.mjs'; +import json from '../index.mjs'; +import { parseSchema } from '../utils/parseSchema.mjs'; + +const FIXTURES_DIR = join(import.meta.dirname, 'fixtures'); + +describe('generator output complies with json schema', () => { + const validator = new Validator(); + + /** + * @type {object} + */ + let schema; + + before(async () => { + schema = await parseSchema(); + }); + + for (const fixture of ['text-doc', 'module']) { + const input = join(FIXTURES_DIR, `${fixture}.md`); + + test(`${fixture}.md`, async () => { + const { runGenerators } = createGenerator(); + + const result = await runGenerators({ + generators: ['json'], + input, + output: undefined, + version: new SemVer('v1.2.3'), + releases: [], + gitRef: 'a'.repeat(40), + threads: 1, + chunkSize: 20, + typeMap: {}, + }); + + const validatorResult = validator.validate(result[0][0], schema); + if (!validatorResult.valid) { + console.log(validatorResult.errors); + assert.fail('does not conform to json schema'); + } + }); + } +}); + +test('schema version matches generator version', async () => { + const schemaString = await readFile( + join(import.meta.dirname, '..', 'schema.jsonc'), + 'utf8' + ); + const schema = await jsoncParse(schemaString); + + assert.strictEqual(schema.$id, `nodejs-api-doc@v${json.version}`); +}); diff --git a/src/generators/json/constants.mjs b/src/generators/json/constants.mjs new file mode 100644 index 00000000..0c78d7b0 --- /dev/null +++ b/src/generators/json/constants.mjs @@ -0,0 +1,52 @@ +'use strict'; + +export const SCHEMA_FILENAME = 'node-doc-schema.json'; + +// Grabs the default value if present +export const DEFAULT_EXPRESSION = /^(?:D|d)efault(?:s|):$/; + +// Grabs the type and description of one of the formats for event types +export const EVENT_TYPE_DESCRIPTION_EXTRACTOR = /{(.*)}(.*)/; + +// Grabs type and optional description for a method's parameter signature +export const METHOD_TYPE_EXTRACTOR = /^{(.*)}( .*)?$/; + +// Grabs return type and optional description for a method signature +// Accepts the following: +// Returns: {string} +// Returns {string} +// Returns: {string} bla bla bla +export const METHOD_RETURN_TYPE_EXTRACTOR = /^Returns(:?) {(.*)}( .*)?$/i; + +// Grabs the parameters from a method's signature +// ex/ 'new buffer.Blob([sources[, options]])'.match(PARAM_EXPRESSION) === ['([sources[, options]])', '[sources[, options]]'] +export const METHOD_PARAM_EXPRESSION = /\((.+)\);?$/; + +/** + * Mapping of {@link HeadingMetadataEntry['type']} to types defined in the + * JSON schema. + */ +export const ENTRY_TO_SECTION_TYPE = /** @type {const} */ ({ + var: 'property', + global: 'property', + module: 'module', + class: 'class', + ctor: 'method', + method: 'method', + classMethod: 'method', + property: 'property', + event: 'event', + misc: 'text', + text: 'text', + example: 'text', +}); + +/** + * Some types in the docs have different capitalization than what exists in JS + * @type {Record} + */ +export const DOC_TYPE_TO_CORRECT_JS_TYPE_MAP = { + integer: 'number', + bigint: 'BigInt', + symbol: 'Symbol', +}; diff --git a/src/generators/json/generated/generated.d.ts b/src/generators/json/generated/generated.d.ts new file mode 100644 index 00000000..3008bd26 --- /dev/null +++ b/src/generators/json/generated/generated.d.ts @@ -0,0 +1,185 @@ +/* eslint-disable */ +/** + * This file was automatically generated by json-schema-to-typescript. + * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, + * and run json-schema-to-typescript to regenerate this file. + */ + +export type NodeJsAPIDocumentationSchema = DocumentRoot & (Module | Text); +/** + * A JavaScript module. + */ +export type Module = SectionBase & { + type: 'module'; + /** + * https://jsdoc.app/tags-module + */ + '@module': string; + /** + * Classes exported from this module. + */ + classes?: Class[]; + /** + * Methods exported from this module. + */ + methods?: Method[]; + /** + * APIs that are available globally. + */ + globals?: (Class | Method)[]; + properties?: Property[]; + events?: Event[]; +}; +export type Text = SectionBase; +/** + * Node.js version number + */ +export type NodeCoreVersion = string; +export type Class = SectionBase & { + type: 'class'; + '@constructor'?: MethodSignature[]; + methods?: Method[]; + staticMethods?: Method[]; + properties?: Property[]; + events?: Event[]; +}; +/** + * A JavaScript function. + */ +export type Method = SectionBase & { + type: 'method'; + signatures: MethodSignature[]; +}; +/** + * A property on a JavaScript object or class. + */ +export type Property = SectionBase & { + type: 'property'; + /** + * JavaScript type of the property. + */ + '@type'?: string | [string, ...string[]]; + /** + * Is this property modifiable by user code? + */ + mutable?: boolean; +}; +/** + * An event that can be emitted by the parent object or class. + */ +export type Event = SectionBase & { + type: 'event'; + parameters: MethodParameter[]; +}; + +/** + * Common properties found at the root of each document. + */ +export interface DocumentRoot { + /** + * The path to the Markdown source used to generate this document. It is relative to the Node.js repository root. + */ + source: string; + [k: string]: unknown; +} +/** + * Common properties found in each section of a document. + */ +export interface SectionBase { + /** + * Type of the section + */ + type: 'module' | 'class' | 'method' | 'property' | 'event' | 'text'; + /** + * https://jsdoc.app/tags-name + */ + '@name': string; + /** + * Description of the section. + */ + description?: string; + /** + * https://jsdoc.app/tags-see + */ + '@see'?: string; + /** + * Sections that just hold further text on this section. + */ + text?: Text[]; + /** + * https://jsdoc.app/tags-example + */ + '@example'?: string | string[]; + /** + * https://jsdoc.app/tags-deprecated + */ + '@deprecated'?: NodeCoreVersion[]; + stability?: Stability; + /** + * The changes this API has underwent. + */ + changes?: Change[]; + /** + * https://jsdoc.app/tags-since + */ + '@since'?: NodeCoreVersion[]; + napiVersion?: number[]; + /** + * Versions that this was removed in. + */ + removedIn?: NodeCoreVersion[]; + [k: string]: unknown; +} +/** + * Describes the stability of an object. + */ +export interface Stability { + /** + * The stability value. + */ + index: number; + /** + * Textual representation of the stability. + */ + description: string; +} +export interface Change { + version: NodeCoreVersion[]; + /** + * URL to the PR that introduced this change. + */ + prUrl?: string; + /** + * Description of the change. + */ + description: string; +} +export interface MethodSignature { + parameters: MethodParameter[]; + '@returns': MethodReturnType; +} +export interface MethodParameter { + /** + * Name of the parameter. + */ + '@name': string; + /** + * Type of the parameter + */ + '@type': string | [string, ...string[]]; + description?: string; + /** + * The parameter's default value + */ + '@default'?: string; +} +/** + * A method signature's return type. + */ +export interface MethodReturnType { + description?: string; + /** + * The method signature's return type. + */ + '@type': string | [string, ...string[]]; +} diff --git a/src/generators/json/index.mjs b/src/generators/json/index.mjs new file mode 100644 index 00000000..ee2c3344 --- /dev/null +++ b/src/generators/json/index.mjs @@ -0,0 +1,104 @@ +'use strict'; + +import { writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { SCHEMA_FILENAME } from './constants.mjs'; +import { parseSchema } from './utils/parseSchema.mjs'; +import { createSection } from './utils/sections/index.mjs'; +import { groupNodesByModule } from '../../utils/generators.mjs'; + +/** + * This generator is responsible for generating the JSON representation of the + * docs. + * + * This is a top-level generator, intaking the raw AST tree of the api docs. + * It generates JSON files to the specified output directory given by the + * config. + * + * @typedef {Array} Input + * @typedef {Array} Output + * + * @type {GeneratorMetadata} + */ +export default { + name: 'json', + + // This should be kept in sync with the JSON schema version for this + // generator AND the `json-all` generator + version: '2.0.0', + + description: + 'This generator is responsible for generating the JSON representation of the docs.', + + dependsOn: 'metadata', + + /** + * Process a chunk of items in a worker thread. + * Builds JSON sections - FS operations happen in generate(). + * + * Each item is pre-grouped {head, nodes} - no need to + * recompute groupNodesByModule for every chunk. + * + * @param slicedInput + * @param itemIndices + * @param root0 + * @param root0.version + * @returns {Promise} JSON sections for each processed module + */ + async processChunk(slicedInput, itemIndices, { version }) { + /** + * @type {Output} + */ + const results = new Array(itemIndices.length); + + for (let i = 0; i < itemIndices.length; i++) { + const { head, nodes } = slicedInput[itemIndices[i]]; + + results[i] = createSection(head, nodes, version.raw); + } + + return results; + }, + + /** + * Generates a JSON file + * + * @param {Input} input + * @param {Partial} param1 + */ + async *generate(input, { output, version, worker }) { + const groupedModules = groupNodesByModule(input); + + const headNodes = input.filter(node => node.heading.depth === 1); + + // Create sliced input: each item contains head + its module's entries + const entries = headNodes.map(head => ({ + head, + nodes: groupedModules.get(head.api), + })); + + for await (const chunkResult of worker.stream(entries, entries, { + version, + })) { + if (output) { + for (const section of chunkResult) { + await writeFile( + join(output, `${section.api}.json`), + JSON.stringify(section) + ); + } + } + + yield chunkResult; + } + + if (output) { + // Parse the JSON schema into an object + const schema = await parseSchema(); + + // Write the parsed JSON schema to the output directory + await writeFile(join(output, SCHEMA_FILENAME), JSON.stringify(schema)); + } + }, +}; diff --git a/src/generators/json/schema.jsonc b/src/generators/json/schema.jsonc new file mode 100644 index 00000000..ca6230b9 --- /dev/null +++ b/src/generators/json/schema.jsonc @@ -0,0 +1,419 @@ +{ + /** + * NOTE: if you modify this, please: + * - Bump the version in the $id property + * - Bump the version of the `json` and `json-all` generator. + * - Run `scripts/generate-json-types.mjs` and ensure there aren't type errors + */ + + "$schema": "https://json-schema.org/draft-07/schema#", + "$id": "nodejs-api-doc@v2.0.0", // This should be kept in sync with the generator version + "title": "Node.js API Documentation Schema", + "readOnly": true, + + "allOf": [ + { "$ref": "#/definitions/DocumentRoot" }, + { + "oneOf": [ + // Top of a document can either be a module (for api declarations) or + // text (for general docs on things) + { "$ref": "#/definitions/Module" }, + { "$ref": "#/definitions/Text" }, + ], + }, + ], + + "definitions": { + "DocumentRoot": { + "type": "object", + "description": "Common properties found at the root of each document.", + "properties": { + "source": { + "type": "string", + "description": "The path to the Markdown source used to generate this document. It is relative to the Node.js repository root.", + "examples": ["doc/api/net.md"], + }, + }, + "required": ["source"], + }, + + "SectionBase": { + "type": "object", + "description": "Common properties found in each section of a document.", + "properties": { + "type": { + "type": "string", + "enum": ["module", "class", "method", "property", "event", "text"], + "description": "Type of the section", + }, + "@name": { + "type": "string", + "description": "https://jsdoc.app/tags-name", + "examples": ["Buffer", "Addons"], + }, + "description": { + "type": "string", + "description": "Description of the section.", + }, + "@see": { + "type": "string", + "description": "https://jsdoc.app/tags-see", + }, + "text": { + "type": "array", + "description": "Sections that just hold further text on this section.", + "items": { "$ref": "#/definitions/Text" }, + }, + "@example": { + "description": "https://jsdoc.app/tags-example", + "oneOf": [ + { "type": "string" }, + { + "type": "array", + "items": { + "type": "string", + }, + }, + ], + }, + "@deprecated": { + "type": "array", + "description": "https://jsdoc.app/tags-deprecated", + "items": { "$ref": "#/definitions/NodeCoreVersion" }, + }, + "stability": { "$ref": "#/definitions/Stability" }, + "changes": { + "type": "array", + "items": { + "$ref": "#/definitions/Change", + }, + "description": "The changes this API has underwent.", + }, + "@since": { + "type": "array", + "description": "https://jsdoc.app/tags-since", + "items": { "$ref": "#/definitions/NodeCoreVersion" }, + }, + "napiVersion": { + "type": "array", + "items": { "type": "number" }, + }, + "removedIn": { + "type": "array", + "description": "Versions that this was removed in.", + "items": { "$ref": "#/definitions/NodeCoreVersion" }, + }, + }, + "required": ["type", "@name"], + }, + + "Module": { + "description": "A JavaScript module.", + "allOf": [ + { "$ref": "#/definitions/SectionBase" }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["module"], + }, + "@module": { + "type": "string", + "description": "https://jsdoc.app/tags-module", + "examples": ["node:buffer"], + }, + "classes": { + "type": "array", + "description": "Classes exported from this module.", + "items": { "$ref": "#/definitions/Class" }, + }, + "methods": { + "type": "array", + "description": "Methods exported from this module.", + "items": { "$ref": "#/definitions/Method" }, + }, + "globals": { + "type": "array", + "description": "APIs that are available globally.", + "items": { + "oneOf": [ + { "$ref": "#/definitions/Class" }, + { "$ref": "#/definitions/Method" }, + ], + }, + }, + "properties": { + "type": "array", + "items": { "$ref": "#/definitions/Property" }, + }, + "events": { + "type": "array", + "items": { "$ref": "#/definitions/Event" }, + }, + }, + "required": ["type", "@module", "@see"], + "additionalProperties": false, + }, + ], + }, + + "Class": { + "allOf": [ + { "$ref": "#/definitions/SectionBase" }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["class"], + }, + "@constructor": { + "type": "array", + "items": { "$ref": "#/definitions/MethodSignature" }, + }, + "methods": { + "type": "array", + "items": { "$ref": "#/definitions/Method" }, + }, + "staticMethods": { + "type": "array", + "items": { "$ref": "#/definitions/Method" }, + }, + "properties": { + "type": "array", + "items": { "$ref": "#/definitions/Property" }, + }, + "events": { + "type": "array", + "items": { "$ref": "#/definitions/Event" }, + }, + }, + "required": ["type"], + "additionalProperties": false, + }, + ], + }, + + "Method": { + "description": "A JavaScript function.", + "allOf": [ + { "$ref": "#/definitions/SectionBase" }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["method"], + }, + "signatures": { + "type": "array", + "items": { "$ref": "#/definitions/MethodSignature" }, + }, + }, + "required": ["type", "signatures"], + "additionalProperties": false, + }, + ], + }, + + "MethodSignature": { + "type": "object", + "properties": { + "parameters": { + "type": "array", + "items": { "$ref": "#/definitions/MethodParameter" }, + }, + "@returns": { + "$ref": "#/definitions/MethodReturnType", + }, + }, + "required": ["@returns"], + "additionalProperties": false, + }, + + "MethodParameter": { + "type": "object", + "properties": { + "@name": { + "type": "string", + "description": "Name of the parameter.", + }, + "@type": { + "description": "Type of the parameter", + "oneOf": [ + { + "type": "string", + "examples": ["string"], + }, + { + "type": "array", + "items": { + "type": "string", + }, + "minItems": 1, + }, + ], + }, + "description": { + "type": "string", + }, + "@default": { + "type": "string", + "description": "The parameter's default value", + "examples": ["foo"], + }, + }, + "required": ["@name", "@type"], + "additionalProperties": false, + }, + + "MethodReturnType": { + "type": "object", + "description": "A method signature's return type.", + "properties": { + "description": { + "type": "string", + }, + "@type": { + "description": "The method signature's return type.", + "oneOf": [ + { + "type": "string", + "examples": ["string"], + }, + { + "type": "array", + "items": { "type": "string" }, + "examples": [["string", "Promise"]], + "minItems": 1, + }, + ], + }, + }, + "required": ["@type"], + "additionalProperties": false, + }, + + "Global": { + "type": "object", + "properties": {}, + "required": [], + }, + + "Property": { + "description": "A property on a JavaScript object or class.", + "allOf": [ + { "$ref": "#/definitions/SectionBase" }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["property"], + }, + "@type": { + "description": "JavaScript type of the property.", + "oneOf": [ + { + "type": "string", + "examples": ["string"], + }, + { + "type": "array", + "items": { + "type": "string", + }, + "minItems": 1, + }, + ], + }, + "mutable": { + "type": "boolean", + "description": "Is this property modifiable by user code?", + "default": false, + }, + }, + "required": ["type"], + "additionalProperties": false, + }, + ], + }, + + "Event": { + "description": "An event that can be emitted by the parent object or class.", + "allOf": [ + { "$ref": "#/definitions/SectionBase" }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["event"], + }, + "parameters": { + "type": "array", + "items": { "$ref": "#/definitions/MethodParameter" }, + }, + }, + "required": ["type", "parameters"], + "additionalProperties": false, + }, + ], + }, + + "Text": { + "allOf": [{ "$ref": "#/definitions/SectionBase" }], + }, + + "NodeCoreVersion": { + "type": "string", + "description": "Node.js version number", + // Taken from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string + // and slightly modified to support the `v` in front + "pattern": "^v(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$", + "examples": ["v24.0.0"], + }, + + "Change": { + "type": "object", + "properties": { + "version": { + "type": "array", + "items": { + "$ref": "#/definitions/NodeCoreVersion", + }, + }, + "prUrl": { + "type": "string", + "description": "URL to the PR that introduced this change.", + }, + "description": { + "type": "string", + "description": "Description of the change.", + }, + }, + "required": ["version", "description"], + "additionalProperties": false, + }, + + "Stability": { + "type": "object", + "description": "Describes the stability of an object.", + "properties": { + "index": { + "type": "number", + "description": "The stability value.", + "minimum": 0, + "maximum": 3, + }, + "description": { + "type": "string", + "description": "Textual representation of the stability.", + }, + }, + "required": ["index", "description"], + "additionalProperties": false, + }, + }, +} diff --git a/src/generators/json/types.d.ts b/src/generators/json/types.d.ts new file mode 100644 index 00000000..ef48ff43 --- /dev/null +++ b/src/generators/json/types.d.ts @@ -0,0 +1,20 @@ +import type { + Class, + Method, + Module, + Property, + SectionBase, + Text, +} from './generated/generated.d.ts'; + +export type Section = SectionBase & + (Module | Class | Method | Property | Text) & + GeneratorMetadata; + +/** + * This is metadata that's only relevant to the generator and should be removed + * before the file is output. + */ +export type GeneratorMetadata = { + parent?: Section; +}; diff --git a/src/generators/json/utils/__tests__/createParameterGroupings.test.mjs b/src/generators/json/utils/__tests__/createParameterGroupings.test.mjs new file mode 100644 index 00000000..b24cca72 --- /dev/null +++ b/src/generators/json/utils/__tests__/createParameterGroupings.test.mjs @@ -0,0 +1,156 @@ +'use strict'; + +import assert from 'node:assert'; +import { describe, test } from 'node:test'; + +import { createParameterGroupings } from '../createParameterGroupings.mjs'; + +describe('createParameterGroupings', () => { + test('param1, param2', () => { + const groupings = createParameterGroupings('param1, param2'.split(',')); + + assert.deepStrictEqual(groupings, [['param1', 'param2']]); + }); + + test('[param1]', () => { + const groupings = createParameterGroupings('[param1]'.split(',')); + + assert.deepStrictEqual(groupings, [[], ['param1']]); + }); + + test('[param1, param2]', () => { + const groupings = createParameterGroupings('[param1, param2]'.split(',')); + + assert.deepStrictEqual(groupings, [[], ['param1', 'param2']]); + }); + + test('param1[, param2]', () => { + const groupings = createParameterGroupings('param1[, param2]'.split(',')); + + assert.deepStrictEqual(groupings, [['param1'], ['param1', 'param2']]); + }); + + test('param1[, param2, param3]', () => { + const groupings = createParameterGroupings( + 'param1[, param2, param3]'.split(',') + ); + + assert.deepStrictEqual(groupings, [ + ['param1'], + ['param1', 'param2', 'param3'], + ]); + }); + + test('param1[, param2], param3', () => { + const groupings = createParameterGroupings( + 'param1[, param2], param3'.split(',') + ); + + assert.deepStrictEqual(groupings, [ + ['param1', 'param3'], + ['param1', 'param2', 'param3'], + ]); + }); + + test('param1[, param2[, param3]]', () => { + const groupings = createParameterGroupings( + 'param1[, param2[, param3]]'.split(',') + ); + + assert.deepStrictEqual(groupings, [ + ['param1'], + ['param1', 'param2'], + ['param1', 'param2', 'param3'], + ]); + }); + + test('[param1][, param2[, param3]]', () => { + const groupings = createParameterGroupings( + '[param1][, param2[, param3]]'.split(',') + ); + + assert.deepStrictEqual(groupings, [ + [], + ['param1'], + ['param2'], + ['param2', 'param3'], + ]); + }); + + test('[param1][, param2]', () => { + const groupings = createParameterGroupings('[param1][, param2]'.split(',')); + + assert.deepStrictEqual(groupings, [[], ['param1'], ['param2']]); + }); + + test('[param1][, param2][, param3][, param4][, param5, param6]', () => { + const groupings = createParameterGroupings( + '[param1][, param2][, param3][, param4][, param5, param6]'.split(',') + ); + + assert.deepStrictEqual(groupings, [ + [], + ['param1'], + ['param2'], + ['param3'], + ['param4'], + ['param5', 'param6'], + ]); + }); + + test('[param1][, param2][, param3][, param4][, param5, param6[, param7[, param8]]]', () => { + const groupings = createParameterGroupings( + '[param1][, param2][, param3][, param4][, param5, param6[, param7[, param8]]]'.split( + ',' + ) + ); + + assert.deepStrictEqual(groupings, [ + [], + ['param1'], + ['param2'], + ['param3'], + ['param4'], + ['param5', 'param6'], + ['param5', 'param6', 'param7'], + ['param5', 'param6', 'param7', 'param8'], + ]); + }); + + test('value[, offset[, end]][, encoding]', () => { + const groupings = createParameterGroupings( + 'value[, offset[, end]][, encoding]'.split(',') + ); + + assert.deepStrictEqual(groupings, [ + ['value'], + ['value', 'offset'], + ['value', 'offset', 'end'], + ['value', 'encoding'], + ]); + }); + + test('[min, ]max[, callback]', () => { + const groupings = createParameterGroupings( + '[min, ]max[, callback]'.split(',') + ); + + assert.deepStrictEqual(groupings, [ + ['max'], + ['min', 'max'], + ['max', 'callback'], + ['min', 'max', 'callback'], + ]); + }); + + test('onsession,[options]', () => { + const groupings = createParameterGroupings( + 'onsession,[options]'.split(',') + ); + + assert.deepStrictEqual(groupings, [ + ['onsession'], + ['onsession', 'options'], + ]); + }); +}); diff --git a/src/generators/json/utils/__tests__/parseTypeList.test.mjs b/src/generators/json/utils/__tests__/parseTypeList.test.mjs new file mode 100644 index 00000000..f6eb3256 --- /dev/null +++ b/src/generators/json/utils/__tests__/parseTypeList.test.mjs @@ -0,0 +1,75 @@ +'use strict'; + +import assert from 'node:assert'; +import { test } from 'node:test'; + +import { parseTypeList } from '../parseTypeList.mjs'; + +test('`bla {[integer](https://mdn-link)} Description start bla bla bla [asd](https://random-link)', () => { + /** + * @type {Array} + */ + const nodes = [ + { + type: 'text', + value: 'this should be ignored', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [{ type: 'text', value: '' }], + }, + { + type: 'text', + value: ' Description start bla bla bla', + }, + { + type: 'link', + url: 'https://node-link', + children: [{ type: 'text', value: 'ignored since in description' }], + }, + ]; + + const result = parseTypeList(nodes, 1); + assert.deepStrictEqual(result.types, ['integer']); + assert.equal(result.endingIndex, 1); +}); + +test('`bla {[integer](https://mdn-link) | [string](https://mdn-link)} Description start bla bla bla [asd](https://random-link)', () => { + /** + * @type {Array} + */ + const nodes = [ + { + type: 'text', + value: 'this should be ignored', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [{ type: 'text', value: '' }], + }, + { + type: 'text', + value: ' | ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [{ type: 'text', value: '' }], + }, + { + type: 'text', + value: ' Description start bla bla bla', + }, + { + type: 'link', + url: 'https://random-link', + children: [{ type: 'text', value: 'asd' }], + }, + ]; + + const result = parseTypeList(nodes, 1); + assert.deepStrictEqual(result.types, ['integer', 'string']); + assert.equal(result.endingIndex, 3); +}); diff --git a/src/generators/json/utils/createParameterGroupings.mjs b/src/generators/json/utils/createParameterGroupings.mjs new file mode 100644 index 00000000..b54459df --- /dev/null +++ b/src/generators/json/utils/createParameterGroupings.mjs @@ -0,0 +1,102 @@ +'use strict'; + +import { ParameterTree } from './parameterTree.mjs'; + +/** + * Parameters are declared in a section's header. This looks something like + * `something([sources[, options, flag[, abc]]])`. + * + * We wanna be able to extract the parameter names from that declaration in + * order to create parameter "groupings", or just the various signatures of the + * parameters that can be used when invoking the function. + * + * For instance, using the above signature, we want to create four method + * signatures for it: + * 1. For `something()` + * 2. For `something(sources)` + * 3. For `something(sources, options, flag)` + * 4. For `something(sources, options, flag, abc)` + * + * This method does just that given an array of the parameter names split by + * commas. + * + * @param {Array} parameterNames + * @returns {Array>} + */ +export function createParameterGroupings(parameterNames) { + const [tree, includeFirstChildren] = createParameterTree(parameterNames); + + return tree.coalesceNames(includeFirstChildren); +} + +/** + * @param {Array} parameterNames + * @returns {[ParameterTree, boolean]} + */ +export function createParameterTree(parameterNames) { + let tree = new ParameterTree(); + let includeFirstChildren = false; + + for (let i = 0; i < parameterNames.length; i++) { + /** + * @example 'length]]' -> add `length` to tree's parameters, close out child tree(s) + * @example 'arrayBuffer[' -> add `arrayBuffer` to tree's parameters, start child tree + * @example '[sources[' -> start child tree with parameter `sources`, then start another child tree + * @example '[hello]' -> start child tree, add `hello` to it, then end it + * @example '[hello' -> start child tree, add `hello` to it + * @example '[hello][' -> start child tree, end child tree, start another child tree + * @example ']max[' -> add `max` to parent tree, start another child tree, set includeFirstChildren to true + * @example 'end' -> add to tree's parameters + */ + const parameter = parameterNames[i].trim(); + + let nameStartIndex = 0; + if (parameter.startsWith('[')) { + tree = tree.createSubTree(); + nameStartIndex++; + } else if (parameter.startsWith(']')) { + tree = tree.parent; + if (!tree) { + throw new TypeError('parent undefined'); + } + + nameStartIndex++; + } + + if (!includeFirstChildren) { + // Something like `]max[`, where we want to have a grouping that contains + // all of the child parameters + includeFirstChildren = + parameter.startsWith(']') && parameter.endsWith('['); + } + + let depthChange = 0; + let nameEndIndex = parameter.endsWith('[') + ? parameter.length - 1 + : parameter.length; + while (nameEndIndex > 0 && parameter[nameEndIndex - 1] === ']') { + nameEndIndex--; + depthChange++; + } + + const name = parameter.substring(nameStartIndex, nameEndIndex); + tree.addParameter(name); + + for (let i = 0; i < depthChange; i++) { + tree = tree.parent; + if (!tree) { + throw new TypeError('parent undefined'); + } + } + + if (parameter.endsWith('[')) { + tree = tree.createSubTree(); + } + } + + if (tree.parent) { + throw new TypeError('returning non-root tree'); + } + + return [tree, includeFirstChildren]; +} diff --git a/src/generators/json/utils/findParentSection.mjs b/src/generators/json/utils/findParentSection.mjs new file mode 100644 index 00000000..bb2e717d --- /dev/null +++ b/src/generators/json/utils/findParentSection.mjs @@ -0,0 +1,23 @@ +'use strict'; + +import { enforceArray } from '../../../utils/array.mjs'; + +/** + * Finds the closest parent section with the specified type(s). + * @param {import('../types.d.ts').Section} section + * @param {import('../generated/generated.d.ts').SectionBase['type'] | Array} type + * @returns {import('../types.d.ts').Section | undefined} + */ +export function findParentSection({ parent }, type) { + type = enforceArray(type); + + while (parent) { + if (type.includes(parent.type)) { + return parent; + } + + parent = parent.parent; + } + + return undefined; +} diff --git a/src/generators/json/utils/parameterTree.mjs b/src/generators/json/utils/parameterTree.mjs new file mode 100644 index 00000000..c8769c27 --- /dev/null +++ b/src/generators/json/utils/parameterTree.mjs @@ -0,0 +1,137 @@ +'use strict'; + +/** + * @typedef {{ + * name: string, + * createdAt: number + * }} Parameter + * + * @typedef {{ + * count: number + * }} Counter + */ +export class ParameterTree { + /** + * @type {Array} + */ + #parameters = []; + + /** + * @type {Array} + */ + #children = []; + + /** + * @type {Counter} + */ + #counter; + + /** + * @type {ParameterTree | undefined} + */ + #parent; + + /** + * @param {Counter} counter + * @param {ParameterTree} [parent=undefined] + */ + constructor(counter = { count: 0 }, parent = undefined) { + this.#counter = counter; + this.#parent = parent; + } + + /** + * + */ + get parent() { + return this.#parent; + } + + /** + * @param {string} name + */ + addParameter(name) { + this.#parameters.push({ + name, + createdAt: this.#counter.count++, + }); + } + + /** + * @returns {ParameterTree} + */ + createSubTree() { + const tree = new ParameterTree(this.#counter, this); + this.#children.push(tree); + + return tree; + } + + /** + * @param {boolean} [includeFirstChildren=false] + * @returns {Array>} + */ + coalesce(includeFirstChildren = false) { + const children = this.#children + .map(child => + child.coalesce(false).map(array => [...this.#parameters, ...array]) + ) + .flat(); + + const coalescedParameters = [this.#parameters, ...children]; + + if (includeFirstChildren) { + const firstChildren = new Set(this.#parameters); + this.#children.forEach(child => { + child.#parameters.forEach(parameter => firstChildren.add(parameter)); + }); + + coalescedParameters.push(Array.from(firstChildren)); + } + + return coalescedParameters; + } + + /** + * @param {boolean} [includeFirstChildren=false] + * @returns {Array>} + */ + coalesceNames(includeFirstChildren = false) { + return makeArrayUnique( + this.coalesce(includeFirstChildren).map(array => + array + // Sort parameter names by the order they were processed + .sort((a, b) => (a.createdAt > b.createdAt ? 1 : -1)) + // Consolidate it to just be the parameter names + .map(param => param.name) + ) + ); + } +} + +/** + * Make an array unique without needing to copy its contents or do things + * more inefficiently + * + * @template {Array} T + * + * @param {T} array + * @returns {T} + */ +function makeArrayUnique(array) { + /** + * @type {Record} + */ + const seen = {}; + + return array.filter(value => { + // eslint-disable-next-line no-prototype-builtins + if (seen.hasOwnProperty(value)) { + return false; + } + + seen[value] = true; + + return true; + }); +} diff --git a/src/generators/json/utils/parseSchema.mjs b/src/generators/json/utils/parseSchema.mjs new file mode 100644 index 00000000..42021c0b --- /dev/null +++ b/src/generators/json/utils/parseSchema.mjs @@ -0,0 +1,22 @@ +'use strict'; + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { parse as jsoncParse } from 'jsonc-parser'; + +/** + * @returns {Promise} + */ +export async function parseSchema() { + // Read the contents of the JSON schema + const schemaString = await readFile( + join(import.meta.dirname, '..', 'schema.jsonc'), + 'utf8' + ); + + // Parse the JSON schema into an object + const schema = await jsoncParse(schemaString); + + return schema; +} diff --git a/src/generators/json/utils/parseTypeList.mjs b/src/generators/json/utils/parseTypeList.mjs new file mode 100644 index 00000000..90efa9ec --- /dev/null +++ b/src/generators/json/utils/parseTypeList.mjs @@ -0,0 +1,48 @@ +'use strict'; + +import { assertAstType } from '../../../utils/assertAstType.mjs'; + +/** + * Types for properties and parameters can be multiple things. In the Markdown + * source, this looks like `{string | integer | ...}`. + * + * JavaScript primitives are converted into links here, ultimately breaking up + * what would otherwise be just a `text` node in the AST. So, in the AST, this + * instead looks like [`link` node, `text` node, `link` node, ...]. + * + * @param {Array} children + * @param {number} [startingIndex=0] + * @returns {{ types: Array, endingIndex: number }} + */ +export function parseTypeList(children, startingIndex = 0) { + /** + * @type {Array} + */ + const types = []; + let endingIndex = startingIndex; + + for (let i = startingIndex; i < children.length; i += 2) { + const child = children[i]; + + if (child.type !== 'link') { + // Not a type + break; + } + + const typeName = assertAstType(child.children[0], ['text', 'inlineCode']); + types.push(typeName.value.replaceAll('<', '').replaceAll('>', '')); + + const nextChild = children[i + 1]; + if ( + !nextChild || + nextChild.type !== 'text' || + nextChild.value.trim() !== '|' + ) { + // No more types to parse, quit early + endingIndex = i; + break; + } + } + + return { types, endingIndex }; +} diff --git a/src/generators/json/utils/sections/__tests__/base.test.mjs b/src/generators/json/utils/sections/__tests__/base.test.mjs new file mode 100644 index 00000000..da22c317 --- /dev/null +++ b/src/generators/json/utils/sections/__tests__/base.test.mjs @@ -0,0 +1,497 @@ +'use strict'; + +import assert from 'node:assert'; +import test, { describe } from 'node:test'; + +import { ENTRY_TO_SECTION_TYPE } from '../../../constants.mjs'; +import { + addDescriptionAndExamples, + addStabilityStatus, + addVersionProperties, + createSectionBase, +} from '../base.mjs'; + +describe('determines the correct type for a section', () => { + describe('type fallbacks', () => { + test('fallbacks to `module` if heading depth is 1 and heading type is undefined', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + hierarchyChildren: [], + api: 'bla', + slug: 'asd', + api_doc_source: 'doc/api/something.md', + changes: [], + heading: { + type: 'heading', + depth: 1, + children: [], + data: { + text: 'Some title', + name: 'Some title', + depth: 1, + slug: 'some-title', + type: undefined, + }, + }, + stability: { + type: 'root', + children: [], + }, + content: { + type: 'root', + children: [], + }, + tags: [], + yaml_position: {}, + }; + + assert.deepStrictEqual(createSectionBase(entry), { + type: 'module', + '@name': 'Some title', + }); + }); + + test('fallbacks to `text` if heading depth is > 1 and heading type is undefined', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + hierarchyChildren: [], + api: 'bla', + slug: 'asd', + api_doc_source: 'doc/api/something.md', + changes: [], + heading: { + type: 'heading', + depth: 2, + children: [], + data: { + text: 'Some title', + name: 'Some title', + depth: 2, + slug: 'some-title', + type: undefined, + }, + }, + stability: { + type: 'root', + children: [], + }, + content: { + type: 'root', + children: [], + }, + tags: [], + yaml_position: {}, + }; + + assert.deepStrictEqual(createSectionBase(entry), { + type: 'text', + '@name': 'Some title', + }); + }); + + test('doc/api/process.md determined as module and not a global', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + hierarchyChildren: [], + api: 'process', + slug: 'asd', + api_doc_source: 'doc/api/something.md', + changes: [], + heading: { + type: 'heading', + depth: 1, + children: [], + data: { + text: 'Process', + name: 'Process', + depth: 1, + slug: 'process', + type: 'global', + }, + }, + stability: { + type: 'root', + children: [], + }, + content: { + type: 'root', + children: [], + }, + tags: [], + yaml_position: {}, + }; + + assert.deepStrictEqual(createSectionBase(entry), { + type: 'module', + '@name': 'Process', + }); + }); + }); + + for (const entryType in ENTRY_TO_SECTION_TYPE) { + const sectionType = ENTRY_TO_SECTION_TYPE[entryType]; + + test(`\`${entryType}\` -> \`${sectionType}\``, () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + hierarchyChildren: [], + api: 'bla', + slug: 'asd', + api_doc_source: 'doc/api/something.md', + changes: [], + heading: { + type: 'heading', + depth: 2, + children: [], + data: { + text: 'Some title', + name: 'Some title', + depth: 2, + slug: 'some-title', + type: entryType, + }, + }, + stability: { + type: 'root', + children: [], + }, + content: { + type: 'root', + children: [], + }, + tags: [], + yaml_position: {}, + }; + + assert.deepStrictEqual(createSectionBase(entry), { + type: sectionType, + '@name': 'Some title', + }); + }); + } +}); + +describe('addDescriptionAndExamples', () => { + test('description with `text`', () => { + /** + * @type {import('../../../generated/generated.d.ts')} + */ + const base = {}; + + addDescriptionAndExamples(base, [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'hello', + }, + ], + }, + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'world', + }, + ], + }, + ]); + + assert.deepStrictEqual(base.description, 'hello\nworld'); + }); + + test('description with `inlineCode`', () => { + /** + * @type {import('../../../generated/generated.d.ts')} + */ + const base = {}; + + addDescriptionAndExamples(base, [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'hello ', + }, + { + type: 'inlineCode', + value: 'world', + }, + ], + }, + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'asd', + }, + ], + }, + ]); + + assert.strictEqual(base.description, 'hello `world`\nasd'); + }); + + test('description with `link`', () => { + /** + * @type {import('../../../generated/generated.d.ts')} + */ + const base = {}; + + addDescriptionAndExamples(base, [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'hello', + }, + ], + }, + { + type: 'link', + url: 'https://nodejs.org', + children: [ + { + type: 'text', + value: 'world', + }, + ], + }, + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'asd', + }, + ], + }, + ]); + + assert.strictEqual( + base.description, + 'hello\n[world](https://nodejs.org) asd' + ); + }); + + test('description with `emphasis`', () => { + /** + * @type {import('../../../generated/generated.d.ts')} + */ + const base = {}; + + addDescriptionAndExamples(base, [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'hello', + }, + ], + }, + { + type: 'emphasis', + children: [ + { + type: 'text', + value: 'world', + }, + ], + }, + ]); + + assert.strictEqual(base.description, 'hello\n_world_'); + }); + + test('extracts single code example', () => { + /** + * @type {import('../../../generated/generated.d.ts')} + */ + const base = {}; + + addDescriptionAndExamples(base, [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'hello', + }, + ], + }, + { + type: 'code', + value: 'some code here', + }, + ]); + + assert.strictEqual(base.description, 'hello'); + assert.strictEqual(base['@example'], 'some code here'); + }); + + test('extracts multiple code examples', () => { + /** + * @type {import('../../../generated/generated.d.ts')} + */ + const base = {}; + + addDescriptionAndExamples(base, [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'hello', + }, + ], + }, + { + type: 'code', + value: 'some code here', + }, + { + type: 'code', + value: 'more code', + }, + { + type: 'code', + value: 'asd', + }, + ]); + + assert.strictEqual(base.description, 'hello'); + assert.deepStrictEqual(base['@example'], [ + 'some code here', + 'more code', + 'asd', + ]); + }); +}); + +describe('addStabilityStatus', () => { + test('defined if provided', () => { + /** + * @type {import('../../../generated/generated.d.ts')} + */ + const base = {}; + + addStabilityStatus(base, { + stability: { + type: 'root', + children: [ + { + data: { + index: 0, + description: 'description', + }, + }, + ], + }, + }); + + assert.deepStrictEqual(base.stability, { + index: 0, + description: 'description', + }); + }); + + test('undefined if not provided', () => { + /** + * @type {import('../../../generated/generated.d.ts')} + */ + const base = {}; + + addStabilityStatus(base, { + stability: { + type: 'root', + children: [ + { + data: { + index: 0, + description: 'description', + }, + }, + ], + }, + }); + + assert.deepStrictEqual(base.stability, { + index: 0, + description: 'description', + }); + }); +}); + +describe('addVersionProperties', () => { + test('defined in provided', () => { + /** + * @type {import('../../../generated/generated.d.ts')} + */ + const base = {}; + + addVersionProperties(base, { + changes: [ + { + description: 'bla', + 'pr-url': 'https://github.com/nodejs/node', + version: ['v10.0.0'], + }, + { + description: 'asd', + 'pr-url': 'https://github.com/nodejs/node', + version: 'v10.2.0', + }, + ], + added_in: ['v5.0.0'], + n_api_version: ['v5.0.0'], + removed_in: ['v20.0.0'], + deprecated_in: ['v20.0.0'], + }); + + assert.deepStrictEqual(base.changes, [ + { + description: 'bla', + prUrl: 'https://github.com/nodejs/node', + version: ['v10.0.0'], + }, + { + description: 'asd', + prUrl: 'https://github.com/nodejs/node', + version: ['v10.2.0'], + }, + ]); + assert.deepStrictEqual(base['@since'], ['v5.0.0']); + assert.deepStrictEqual(base.napiVersion, ['v5.0.0']); + assert.deepStrictEqual(base.removedIn, ['v20.0.0']); + assert.deepStrictEqual(base['@deprecated'], ['v20.0.0']); + }); + + test('undefined if not provided', () => { + /** + * @type {import('../../../generated/generated.d.ts')} + */ + const base = {}; + + addVersionProperties(base, { changes: [] }); + + assert.equal(base.changes, undefined); + assert.equal(base['@since'], undefined); + assert.equal(base.napiVersion, undefined); + assert.equal(base.removedIn, undefined); + assert.equal(base['@deprecated'], undefined); + }); +}); diff --git a/src/generators/json/utils/sections/__tests__/event.test.mjs b/src/generators/json/utils/sections/__tests__/event.test.mjs new file mode 100644 index 00000000..1197e168 --- /dev/null +++ b/src/generators/json/utils/sections/__tests__/event.test.mjs @@ -0,0 +1,753 @@ +'use strict'; + +import assert from 'node:assert'; +import { describe, test } from 'node:test'; + +import { parseParameters } from '../event.mjs'; + +describe('parseParameters', () => { + test('does nothing if the first child is not a list', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'text', + value: 'asd', + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = new Proxy( + {}, + { + set(_, key) { + throw new Error(`property '${String(key)}' modified`); + }, + } + ); + + parseParameters(entry, section); + }); + + describe('`paramName` [description]', () => { + test('`paramName`', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'paramName', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'paramName', + '@type': 'any', + }, + ]); + }); + + test('`paramName` [](https://mdn-link)', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'paramName', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'paramName', + '@type': 'boolean', + }, + ]); + }); + + test('`paramName` [](https://mdn-link) | [](https://mdn-link)', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'paramName', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' | ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'paramName', + '@type': ['boolean', 'string'], + }, + ]); + }); + + test('`paramName` [](https://mdn-link) description bla bla bla', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'paramName', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' description bla bla bla', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'paramName', + '@type': 'boolean', + description: 'description bla bla bla', + }, + ]); + }); + + test('`paramName` {boolean}', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'paramName', + }, + { + type: 'text', + value: ' {boolean}', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'paramName', + '@type': 'boolean', + }, + ]); + }); + + test('`paramName` {boolean} description bla bla bla', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'paramName', + }, + { + type: 'text', + value: ' {boolean} description bla bla bla', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'paramName', + '@type': 'boolean', + description: 'description bla bla bla', + }, + ]); + }); + }); + + describe('Type: [description]', () => { + test('Type: [](https://mdn-link)', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Type: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'value', + '@type': 'boolean', + }, + ]); + }); + + test('Type: [](https://mdn-link) | [](https://mdn-link)', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Type: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' | ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'value', + '@type': ['boolean', 'string'], + }, + ]); + }); + + test('Type: [](https://mdn-link) description bla bla bla', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Type: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' description bla bla bla', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'value', + '@type': 'boolean', + description: 'description bla bla bla', + }, + ]); + }); + }); + + describe(' [description]', () => { + test('[](https://mdn-link)', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'value', + '@type': 'boolean', + }, + ]); + }); + + test('[](https://mdn-link) | [](https://mdn-link)', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' | ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'value', + '@type': ['boolean', 'string'], + }, + ]); + }); + + test('[](https://mdn-link) description bla bla bla', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + content: { + children: [ + undefined, // Should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' description bla bla bla', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Event} + */ + const section = {}; + + parseParameters(entry, section); + + assert.deepStrictEqual(section.parameters, [ + { + '@name': 'value', + '@type': 'boolean', + description: 'description bla bla bla', + }, + ]); + }); + }); +}); diff --git a/src/generators/json/utils/sections/__tests__/index.test.mjs b/src/generators/json/utils/sections/__tests__/index.test.mjs new file mode 100644 index 00000000..26b4ea1b --- /dev/null +++ b/src/generators/json/utils/sections/__tests__/index.test.mjs @@ -0,0 +1,96 @@ +'use strict'; + +import assert from 'node:assert'; +import { test } from 'node:test'; + +import { BASE_URL, NODE_VERSION } from '../../../../../constants.mjs'; +import { SCHEMA_FILENAME } from '../../../constants.mjs'; +import { createSection } from '../index.mjs'; + +test('empty `module` section', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + hierarchyChildren: [], + api: 'bla', + slug: 'asd', + api_doc_source: 'doc/api/something.md', + changes: [], + heading: { + type: 'heading', + depth: 1, + children: [], + data: { + text: 'Some title', + name: 'Some title', + depth: 1, + slug: 'some-title', + type: undefined, + }, + }, + stability: { + type: 'root', + children: [], + }, + content: { + type: 'root', + children: [], + }, + tags: [], + yaml_position: {}, + }; + + assert.deepStrictEqual(createSection(entry, [entry], NODE_VERSION), { + $schema: `${BASE_URL}docs/${NODE_VERSION}/api/${SCHEMA_FILENAME}`, + source: 'doc/api/something.md', + '@module': 'node:bla', + '@see': `${BASE_URL}docs/${NODE_VERSION}/api/bla.html`, + type: 'module', + '@name': 'Some title', + parent: undefined, + }); +}); + +test('empty `text` section', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + hierarchyChildren: [], + api: 'bla', + slug: 'asd', + api_doc_source: 'doc/api/something.md', + changes: [], + heading: { + type: 'heading', + depth: 1, + children: [], + data: { + text: 'Some title', + name: 'Some title', + depth: 1, + slug: 'some-title', + type: 'misc', + }, + }, + stability: { + type: 'root', + children: [], + }, + content: { + type: 'root', + children: [], + }, + tags: [], + yaml_position: {}, + }; + + assert.deepStrictEqual(createSection(entry, [entry], NODE_VERSION), { + $schema: `${BASE_URL}docs/${NODE_VERSION}/api/${SCHEMA_FILENAME}`, + source: 'doc/api/something.md', + type: 'text', + '@name': 'Some title', + parent: undefined, + }); +}); diff --git a/src/generators/json/utils/sections/__tests__/method.test.mjs b/src/generators/json/utils/sections/__tests__/method.test.mjs new file mode 100644 index 00000000..948ae6fd --- /dev/null +++ b/src/generators/json/utils/sections/__tests__/method.test.mjs @@ -0,0 +1,685 @@ +// @ts-check +'use strict'; + +import assert from 'node:assert'; +import { describe, test } from 'node:test'; + +import { createMethodSection, parseSignatures } from '../method.mjs'; + +describe('parseSignatures', () => { + test('`something.doThing()`', () => { + /** + * ### `something.doThing()` + */ + + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'classMethod', + text: '`something.doThing()`', + }, + }, + content: { + type: 'root', + children: [ + undefined, // this should be ignore + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = {}; + + parseSignatures(entry, section); + + assert.deepStrictEqual(section, { + signatures: [ + { + '@returns': { '@type': 'any' }, + }, + ], + }); + }); + + test('`something.doThingWithReturnType()`', () => { + /** + * ### `something.doThingWithReturnType()` + * + * * Returns: + */ + + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'classMethod', + text: '`something.doThingWithReturnType()`', + }, + }, + content: { + type: 'root', + children: [ + undefined, // this should be ignore + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Returns: ', + }, + { + type: 'link', + url: '#some-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' A description bla bla bla', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = {}; + + parseSignatures(entry, section); + + assert.deepStrictEqual(section, { + signatures: [ + { + '@returns': { + '@type': 'string', + description: 'A description bla bla bla', + }, + parameters: undefined, + }, + ], + }); + }); + + test('`something.regexExtractedReturnType()`', () => { + /** + * ### `something.regexExtractedReturnType()` + * + * * this line should be ignored + * * Returns: {integer} something something something + */ + + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'classMethod', + text: '`something.regexExtractedReturnType()`', + }, + }, + content: { + type: 'root', + children: [ + undefined, // this should be ignore + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'this line should be ignored', + }, + ], + }, + ], + }, + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: + 'Returns: {integer} something something something', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = {}; + + parseSignatures(entry, section); + + assert.deepStrictEqual(section, { + signatures: [ + { + '@returns': { + '@type': 'integer', + description: 'something something something', + }, + parameters: undefined, + }, + ], + }); + }); + + test('`something.doThingWithUndefinedReturnType()`', () => { + /** + * ### `something.doThingWithReturnType()` + * + * * Returns: `undefined` + */ + + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'classMethod', + text: '`something.doThingWithUndefinedReturnType()`', + }, + }, + content: { + type: 'root', + children: [ + undefined, // this should be ignore + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Returns: ', + }, + { + type: 'inlineCode', + value: 'undefined', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = {}; + + parseSignatures(entry, section); + + assert.deepStrictEqual(section, { + signatures: [ + { + '@returns': { + '@type': 'undefined', + }, + parameters: undefined, + }, + ], + }); + }); + + test('`something.doThingWithoutParameterList(parameter)`', () => { + /** + * ### `something.doThingWithoutParameterList()` + */ + + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'classMethod', + text: '`something.doThingWithoutParameterList(parameter)`', + }, + }, + content: { + type: 'root', + children: [ + undefined, // this should be ignore + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = {}; + + parseSignatures(entry, section); + + assert.deepStrictEqual(section, { + signatures: [ + { + '@returns': { '@type': 'any' }, + parameters: [ + { + '@name': 'parameter', + '@type': 'any', + '@default': undefined, + }, + ], + }, + ], + }); + }); + + test('`something.doThing(parameter=true, otherParameter=false)`', () => { + /** + * ### `something.doThing(parameter=true, otherParameter=false)` + * + * * `parameter` ` A description for it + * * `otherParameter` ` A description for it + */ + + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'classMethod', + text: '`something.doThing(parameter=true, otherParameter=false)`', + }, + }, + content: { + type: 'root', + children: [ + undefined, // this should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'parameter', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: '#some-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' A description for it', + }, + ], + }, + ], + }, + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'otherParameter', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: '#some-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' A description', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = {}; + + parseSignatures(entry, section); + + assert.deepStrictEqual(section, { + signatures: [ + { + '@returns': { '@type': 'any' }, + parameters: [ + { + '@name': 'parameter', + '@type': 'boolean', + description: 'A description for it', + '@default': 'true', + }, + { + '@name': 'otherParameter', + '@type': 'boolean', + description: 'A description', + '@default': 'false', + }, + ], + }, + ], + }); + }); + + test('Static method: `Something.doThing(parameter)`', () => { + /** + * ### Static method: `Something.doThing(parameter)` + * + * * `parameter` A description for it + */ + + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'classMethod', + text: 'Static method: `Something.doThing(parameter)`', + }, + }, + content: { + type: 'root', + children: [ + undefined, // this should be ignored + { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'parameter', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: '#some-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' A description for it', + }, + ], + }, + ], + }, + ], + }, + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = {}; + + parseSignatures(entry, section); + + assert.deepStrictEqual(section, { + signatures: [ + { + '@returns': { '@type': 'any' }, + parameters: [ + { + '@name': 'parameter', + '@type': 'ParameterType', + description: 'A description for it', + }, + ], + }, + ], + }); + }); + + test('`new Something()`', () => { + /** + * ### Static method: `Something.doThing(parameter)` + * + * * `parameter` A description for it + */ + + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'classMethod', + text: '`new Something()`', + }, + }, + content: { + type: 'root', + children: [ + undefined, // this should be ignored + ], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = {}; + + parseSignatures(entry, section); + + assert.deepStrictEqual(section, { + signatures: [ + { + '@returns': { '@type': 'any' }, + }, + ], + }); + }); +}); + +describe('createMethodSection', () => { + describe('pushes method section to correct property on parent section', () => { + test('@constructor', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'ctor', + text: 'new Something()', + }, + }, + content: { + children: [], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Class} + */ + const parent = { + type: 'class', + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = { + parent, + }; + + createMethodSection(entry, section); + + assert.deepStrictEqual(parent['@constructor'], [section]); + assert.strictEqual(parent.staticMethods, undefined); + assert.strictEqual(parent.methods, undefined); + }); + + test('staticMethods', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'classMethod', + text: 'Static method: something.doSomething()', + }, + }, + content: { + children: [], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Class} + */ + const parent = { + type: 'class', + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = { + parent, + }; + + createMethodSection(entry, section); + + assert.strictEqual(parent['@constructor'], undefined); + assert.deepStrictEqual(parent.staticMethods, [section]); + assert.strictEqual(parent.methods, undefined); + }); + + test('methods', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { + heading: { + data: { + type: 'classMethod', + text: 'something.doSomething()', + }, + }, + content: { + children: [], + }, + }; + + /** + * @type {import('../../../generated/generated.d.ts').Class} + */ + const parent = { + type: 'class', + }; + + /** + * @type {import('../../../generated/generated.d.ts').Method} + */ + const section = { + parent, + }; + + createMethodSection(entry, section); + + assert.strictEqual(parent['@constructor'], undefined); + assert.strictEqual(parent.staticMethods, undefined); + assert.deepStrictEqual(parent.methods, [section]); + }); + }); +}); diff --git a/src/generators/json/utils/sections/__tests__/module.test.mjs b/src/generators/json/utils/sections/__tests__/module.test.mjs new file mode 100644 index 00000000..fc5663e3 --- /dev/null +++ b/src/generators/json/utils/sections/__tests__/module.test.mjs @@ -0,0 +1,26 @@ +'use strict'; + +import assert from 'node:assert'; +import { test } from 'node:test'; + +import { BASE_URL, NODE_VERSION } from '../../../../../constants.mjs'; +import { createModuleSection } from '../module.mjs'; + +test('adds expected properties', () => { + /** + * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} + */ + const entry = { api: 'something' }; + + /** + * @type {import('../../../generated/generated.d.ts').Module} + */ + const section = {}; + createModuleSection(entry, section, NODE_VERSION); + + assert.deepStrictEqual( + section['@see'], + `${BASE_URL}docs/${NODE_VERSION}/api/${entry.api}.html` + ); + assert.deepStrictEqual(section['@module'], `node:${entry.api}`); +}); diff --git a/src/generators/json/utils/sections/__tests__/property.test.mjs b/src/generators/json/utils/sections/__tests__/property.test.mjs new file mode 100644 index 00000000..018b8e19 --- /dev/null +++ b/src/generators/json/utils/sections/__tests__/property.test.mjs @@ -0,0 +1,871 @@ +'use strict'; + +import assert from 'node:assert'; +import { describe, test } from 'node:test'; + +import { DOC_TYPE_TO_CORRECT_JS_TYPE_MAP } from '../../../constants.mjs'; + +// TODO tests + +// describe('parseType', () => { +// test('defaults to `any` if first child is not a list', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'text', +// value: 'asd', +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': 'any', +// }); +// }); + +// describe(' [description] [**Default:** `value`]', () => { +// for (const originalType of Object.keys(DOC_TYPE_TO_CORRECT_JS_TYPE_MAP)) { +// const expectedType = DOC_TYPE_TO_CORRECT_JS_TYPE_MAP[originalType]; + +// test(`'${originalType}' -> '${expectedType}'`, () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: `<${originalType}>`, +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': expectedType, +// }); +// }); +// } + +// test('{number}', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': 'number', +// }); +// }); + +// test('{number[]}', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': 'number[]', +// }); +// }); + +// test('{number|boolean}', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// { +// type: 'text', +// value: ' | ', +// }, +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': ['number', 'boolean'], +// }); +// }); + +// test('{number|boolean[]}', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// { +// type: 'text', +// value: ' | ', +// }, +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': ['number', 'boolean[]'], +// }); +// }); + +// test('{number} **Default:** 8192', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// { +// type: 'text', +// value: ' ', +// }, +// { +// type: 'strong', +// children: [ +// { +// type: 'text', +// value: 'Default:', +// }, +// ], +// }, +// { +// type: 'text', +// value: ' ', +// }, +// { +// type: 'inlineCode', +// value: '8192', +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': 'number', +// }); +// }); +// }); + +// describe('Type: [description] [**Default:** `value`]', () => { +// for (const originalType of Object.keys(DOC_TYPE_TO_CORRECT_JS_TYPE_MAP)) { +// const expectedType = DOC_TYPE_TO_CORRECT_JS_TYPE_MAP[originalType]; + +// test(`'${originalType}' -> '${expectedType}'`, () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'text', +// value: 'Type: ', +// }, +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: `<${originalType}>`, +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': expectedType, +// }); +// }); +// } + +// test('Type: {number}', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'text', +// value: 'Type: ', +// }, +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': 'number', +// }); +// }); + +// test('Type: {number[]}', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'text', +// value: 'Type: ', +// }, +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': 'number[]', +// }); +// }); + +// test('Type: {number|boolean}', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'text', +// value: 'Type: ', +// }, +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// { +// type: 'text', +// value: ' | ', +// }, +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': ['number', 'boolean'], +// }); +// }); + +// test('Type: {number|boolean[]}', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'text', +// value: 'Type: ', +// }, +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// { +// type: 'text', +// value: ' | ', +// }, +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': ['number', 'boolean[]'], +// }); +// }); + +// test('Type: {number} **Default:** 8192', () => { +// /** +// * @type {import('../../../../../utils/buildHierarchy.mjs').HierarchizedEntry} +// */ +// const entry = { +// content: { +// children: [ +// undefined, // Should be ignored +// { +// type: 'list', +// children: [ +// { +// type: 'listItem', +// children: [ +// { +// type: 'paragraph', +// children: [ +// { +// type: 'text', +// value: 'Type: ', +// }, +// { +// type: 'link', +// url: 'https://mdn-link', +// children: [ +// { +// type: 'inlineCode', +// value: '', +// }, +// ], +// }, +// { +// type: 'text', +// value: ' ', +// }, +// { +// type: 'strong', +// children: [ +// { +// type: 'text', +// value: 'Default:', +// }, +// ], +// }, +// { +// type: 'text', +// value: ' ', +// }, +// { +// type: 'inlineCode', +// value: '8192', +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// ], +// }, +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseType(entry, section); + +// assert.deepStrictEqual(section, { +// '@type': 'number', +// }); +// }); +// }); +// }); + +// describe('parseDescription', () => { +// test('does nothing when given an empty array', () => { +// /** +// * @type {import('mdast').Paragraph} +// */ +// const element = { children: [] }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = new Proxy( +// {}, +// { +// set(_, key) { +// throw new Error(`property '${String(key)}' modified`); +// }, +// } +// ); + +// parseDescription(element, section); +// }); + +// test('parses descriptions', () => { +// /** +// * @type {import('mdast').Paragraph} +// */ +// const element = { +// children: [ +// { +// type: 'text', +// value: 'asd1234', +// }, +// { +// type: 'link', +// url: 'https://some-link', +// children: [ +// { +// type: 'text', +// value: 'asd', +// }, +// ], +// }, +// ], +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseDescription(element, section); + +// assert.deepStrictEqual(section, { +// description: 'asd1234 [asd](https://some-link)', +// }); +// }); + +// test('determines mutability', () => { +// /** +// * @type {import('mdast').Paragraph} +// */ +// const element = { +// children: [ +// { +// type: 'text', +// value: 'asd1234', +// }, +// { +// type: 'link', +// url: 'https://some-link', +// children: [ +// { +// type: 'text', +// value: 'asd', +// }, +// ], +// }, +// { +// type: 'strong', +// children: [ +// { +// type: 'text', +// value: 'Default:', +// }, +// ], +// }, +// { +// type: 'text', +// value: ' ', +// }, +// { +// type: 'inlineCode', +// value: '8192', +// }, +// ], +// }; + +// /** +// * @type {import('../../../generated/generated.d.ts').Property} +// */ +// const section = {}; + +// parseDescription(element, section); + +// assert.deepStrictEqual(section, { +// description: 'asd1234 [asd](https://some-link) **Default:** `8192`', +// mutable: true, +// }); +// }); +// }); diff --git a/src/generators/json/utils/sections/base.mjs b/src/generators/json/utils/sections/base.mjs new file mode 100644 index 00000000..b01ffe90 --- /dev/null +++ b/src/generators/json/utils/sections/base.mjs @@ -0,0 +1,136 @@ +'use strict'; + +import { enforceArray } from '../../../../utils/array.mjs'; +import { transformNodeToString } from '../../../../utils/unist.mjs'; +import { ENTRY_TO_SECTION_TYPE } from '../../constants.mjs'; + +/** + * @param {import('mdast').Heading} header + * @param {number} depth + * @returns {typeof ENTRY_TO_SECTION_TYPE[string]} + */ +function determineType(header) { + const fallback = header.depth === 1 ? 'module' : 'text'; + + // doc/api/process.md's parent section shouldn't have a defined type, but + // it is defined as `global` for whatever reason + if ( + header?.data.slug === 'process' && + header?.data.type === 'global' && + header?.data.depth === 1 + ) { + return 'module'; + } + + return ENTRY_TO_SECTION_TYPE[header?.data.type ?? fallback]; +} + +/** + * Adds a description to the section base. + * @param {import('../../generated/generated.d.ts').SectionBase} section + * @param {Array} nodes + */ +export function addDescriptionAndExamples(section, nodes) { + nodes.forEach(node => { + if (node.type === 'code') { + if (section['@example']) { + section['@example'] = [ + ...enforceArray(section['@example']), + node.value, + ]; + } else { + section['@example'] = node.value; + } + + return; + } + + // Not code, let's stringify it and add it to the description. + section.description ??= ''; + section.description += `${transformNodeToString(node)}${node.type === 'paragraph' ? '\n' : ' '}`; + }); + + section.description &&= section.description.trim(); +} + +/** + * Adds the stability property to the section. + * @param {import('../../generated/generated.d.ts').SectionBase} section + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry + */ +export function addStabilityStatus(section, entry) { + const stability = entry.stability.children.map(node => node.data)?.[0]; + + if (!stability) { + return; + } + + let { index, description } = stability; + + if (typeof index !== 'number') { + index = Number(index); + } + + section.stability = { + index, + description, + }; +} + +/** + * Adds the properties relating to versioning to the section. + * @param {import('../../generated/generated.d.ts').SectionBase} section + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry + */ +export function addVersionProperties(section, entry) { + if (entry.changes.length > 0) { + section.changes = entry.changes.map(change => ({ + description: change.description, + prUrl: change['pr-url'], + version: enforceArray(change.version), + })); + } + + if (entry.added_in) { + section['@since'] = enforceArray(entry.added_in); + } + + if (entry.n_api_version) { + section.napiVersion = enforceArray(entry.n_api_version); + } + + if (entry.removed_in) { + section.removedIn = enforceArray(entry.removed_in); + } + + if (entry.deprecated_in) { + section['@deprecated'] = enforceArray(entry.deprecated_in); + } +} + +/** + * Returns an object containing the properties that can be found in every + * section type that we have. + * + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry The AST entry + * @returns {import('../../generated/generated.d.ts').SectionBase} + */ +export function createSectionBase(entry) { + const [, ...nodes] = entry.content.children; + + const type = determineType(entry.heading); + + /** + * @type {import('../../generated/generated.d.ts').SectionBase} + */ + const base = { + type, + '@name': entry.heading.data.name, + }; + + addDescriptionAndExamples(base, nodes); + addStabilityStatus(base, entry); + addVersionProperties(base, entry); + + return base; +} diff --git a/src/generators/json/utils/sections/class.mjs b/src/generators/json/utils/sections/class.mjs new file mode 100644 index 00000000..a59af564 --- /dev/null +++ b/src/generators/json/utils/sections/class.mjs @@ -0,0 +1,16 @@ +'use strict'; + +import { findParentSection } from '../findParentSection.mjs'; + +/** + * Adds the properties expected in a class section to an object. + * @param {import('../../generated/generated.d.ts').Class} section The class section + */ +export function createClassSection(section) { + const parent = findParentSection(section, 'module'); + + if (parent) { + parent.classes ??= []; + parent.classes.push(section); + } +} diff --git a/src/generators/json/utils/sections/event.mjs b/src/generators/json/utils/sections/event.mjs new file mode 100644 index 00000000..6d7d8d8a --- /dev/null +++ b/src/generators/json/utils/sections/event.mjs @@ -0,0 +1,46 @@ +'use strict'; + +import { parseParameterList } from '../../../../utils/parseParameterList.mjs'; +import { findParentSection } from '../findParentSection.mjs'; + +/** + * Parse the parameters for the event's callback method + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry The AST entry + * @param {import('../../generated/generated.d.ts').Event} section The event section + */ +export function parseParameters(entry, section) { + const [, ...nodes] = entry.content.children; + const listNode = nodes[0]; + + // If an event has type info it should be the first thing in its child + // elements + if (!listNode || listNode.type !== 'list') { + // No parameters + return; + } + + const parameters = parseParameterList(listNode); + + section.parameters = parameters.map(({ name, type, description }) => ({ + '@name': name, + '@type': type.length === 1 ? type[0] : type, + description, + })); +} + +/** + * Adds the properties expected in an event section to an object. + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry The AST entry + * @param {import('../../generated/generated.d.ts').Event} section The event section + */ +export function createEventSection(entry, section) { + parseParameters(entry, section); + + const parent = findParentSection(section, ['class', 'module']); + + // Add this section to the parent if it exists + if (parent) { + parent.events ??= []; + parent.events.push(section); + } +} diff --git a/src/generators/json/utils/sections/index.mjs b/src/generators/json/utils/sections/index.mjs new file mode 100644 index 00000000..d8c5a8ba --- /dev/null +++ b/src/generators/json/utils/sections/index.mjs @@ -0,0 +1,113 @@ +'use strict'; + +import { createSectionBase } from './base.mjs'; +import { createClassSection } from './class.mjs'; +import { createEventSection } from './event.mjs'; +import { createMethodSection } from './method.mjs'; +import { createModuleSection } from './module.mjs'; +import { createPropertySection } from './property.mjs'; +import { BASE_URL } from '../../../../constants.mjs'; +import { buildHierarchy } from '../../../../utils/buildHierarchy.mjs'; +import { SCHEMA_FILENAME } from '../../constants.mjs'; + +/** + * Processes children of a given entry and updates the section. + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry - The current entry. + * @param {import('../../types.d.ts').Section | undefined} parent + */ +const handleChildren = ({ hierarchyChildren }, parent) => + hierarchyChildren?.forEach(child => createSectionProperties(child, parent)); + +/** + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry + * @param {import('../../types.d.ts').Section | undefined} parent + * @param {string} version + * @returns {import('../../types.d.ts').Section} + */ +function createSectionProperties(entry, parent, version) { + /** + * @type {import('../../types.d.ts').Section} + */ + let section; + try { + section = createSectionBase(entry); + + // Temporarily add the parent section to the section so we have access to + // it and can easily traverse through them when we need to + section.parent = parent; + + switch (section.type) { + case 'module': + createModuleSection(entry, section, version); + break; + case 'class': + createClassSection(section); + break; + case 'method': + createMethodSection(entry, section); + break; + case 'property': + createPropertySection(entry, section); + break; + case 'event': + createEventSection(entry, section); + break; + case 'text': + if (parent) { + parent.text ??= []; + parent.text.push(section); + } + + break; + default: + throw new TypeError(`unhandled section type ${section.type}`); + } + } catch (err) { + if (err instanceof TypeError) { + err.entry ??= entry; + } + + throw err; + } + + handleChildren(entry, section); + + // Remove the parent property we added to the section earlier + section.parent = undefined; + + return section; +} + +/** + * Builds the module section from head metadata and entries. + * @param {ApiDocMetadataEntry} head The head metadata entry + * @param {Array} entries The list of metadata entries + * @param {string} version The version of Node.js we're targetting + * @returns {import('../../generated/generated.d.ts').NodeJsAPIDocumentationSchema} + */ +export function createSection(head, entries, version) { + const entryHierarchy = buildHierarchy(entries); + + if (entryHierarchy.length === 0) { + throw new TypeError(`${head.api_doc_source} has no root elements`); + } + + const section = createSectionProperties( + entryHierarchy[0], + undefined, + version + ); + + if (section.type !== 'module' && section.type !== 'text') { + throw new TypeError( + `expected root section to be a module or text, got ${section.type}`, + { entry: head } + ); + } + + return { + $schema: `${BASE_URL}docs/${version}/api/${SCHEMA_FILENAME}`, + source: head.api_doc_source, + ...section, + }; +} diff --git a/src/generators/json/utils/sections/method.mjs b/src/generators/json/utils/sections/method.mjs new file mode 100644 index 00000000..eaeff861 --- /dev/null +++ b/src/generators/json/utils/sections/method.mjs @@ -0,0 +1,215 @@ +'use strict'; + +import { parseParameterList } from '../../../../utils/parseParameterList.mjs'; +import { METHOD_PARAM_EXPRESSION } from '../../constants.mjs'; +import { createParameterGroupings } from '../createParameterGroupings.mjs'; +import { findParentSection } from '../findParentSection.mjs'; + +/** + * Parses the parameters that the method accepts + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry The AST entry + * + * @returns {{ + * parameters?: Record, + * returns?: import('../../generated/generated.d.ts').MethodReturnType + * } | undefined} + */ +export function parseParameters(entry) { + // Ignore header + const [, ...nodes] = entry.content.children; + + // The first child node should be the method's parameter list. If there + // isn't one, there is no parameter list. + const listNode = nodes[0]; + if (!listNode || listNode.type !== 'list') { + // Method doesn't take in any parameters + return undefined; + } + + /** + * @type {Record} + */ + const parameters = {}; + /** + * @type {import('../../generated/generated.d.ts').MethodReturnType} + */ + let returns = { '@type': 'any' }; + + parseParameterList(listNode).forEach( + ({ name, type, description, isReturnType }) => { + if (!name) { + // Parameter doesn't have a name so we can't use it + return; + } + + /** + * @type {import('../../generated/generated.d.ts').MethodParameter} + */ + const parameter = { + '@name': name, + '@type': type.length === 1 ? type[0] : type, + description, + }; + + if (isReturnType) { + returns = parameter; + } else { + parameters[name] = parameter; + } + } + ); + + return { + parameters, + returns, + }; +} + +/** + * Given a list of parameter names in the order that they should appear and + * a map of parameter names to their type info, let's create the signature + * objects necessary and add it to the section. + * @param {import('../../generated/generated.d.ts').Method} section + * @param {import('../../generated/generated.d.ts').MethodSignature} baseSignature Signature to base the others on + * @param {Array} parameterNames + * @param {Record} parameters + */ +export function createSignatures( + section, + baseSignature, + parameterNames, + parameters +) { + const parameterGroupings = createParameterGroupings(parameterNames); + + let signatureIndex = section.signatures?.length ?? 0; + section.signatures.length += parameterGroupings.length; + + for (const grouping of parameterGroupings) { + /** + * @type {Array} + */ + const signatureParameters = new Array(grouping.length); + + for (let i = 0; i < signatureParameters.length; i++) { + let parameterName = grouping[i]; + let defaultValue; + + // Check for default value here + const equalSignPos = parameterName.indexOf('='); + if (equalSignPos !== -1) { + defaultValue = parameterName.substring(equalSignPos + 1).trim(); + + parameterName = parameterName.substring(0, equalSignPos); + } + + let parameter = + parameterName in parameters ? parameters[parameterName] : undefined; + + if (!parameter) { + parameter = { + '@name': parameterName, + '@type': 'any', + '@default': defaultValue, + }; + } else if (defaultValue) { + parameter = { + ...parameter, + '@default': defaultValue, + }; + } + + signatureParameters[i] = parameter; + } + + section.signatures[signatureIndex] = { + '@returns': baseSignature['@returns'], + parameters: signatureParameters.length ? signatureParameters : undefined, + }; + + signatureIndex++; + } +} + +/** + * Parses the signatures that the method may have and adds them to the + * section. + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry The AST entry + * @param {import('../../generated/generated.d.ts').Method} section The method section + */ +export function parseSignatures(entry, section) { + section.signatures = []; + + /** + * @type {import('../../generated/generated.d.ts').MethodSignature} + */ + const baseSignature = { + '@returns': { '@type': 'any' }, + }; + + // Parse the parameters defined in the parameter list and grab the return + // type (if the list actually exists and those are present in it) + const parameterList = parseParameters(entry) ?? {}; + + if (parameterList.returns) { + // Return type was defined in the parameter list, let's update the base + // signature + baseSignature['@returns'] = parameterList.returns; + } + + /** + * Extract the parameter names mentioned in the entry's header. This gives + * us 1) the order in which they appear and 2) whether or not they're + * optional + * @example `[sources[, options]]` + */ + let [, parametersNames] = + entry.heading.data.text + .substring(1, entry.heading.data.text.length - 1) + .match(METHOD_PARAM_EXPRESSION) || []; + if (!parametersNames && !parameterList.parameters) { + // Method doesn't have any parameters, return early + section.signatures.push(baseSignature); + return; + } + + /** + * @example ['[sources[', 'options]]']` + */ + parametersNames = parametersNames?.split(','); + + createSignatures( + section, + baseSignature, + parametersNames ?? [], + parameterList.parameters ?? [] + ); +} + +/** + * Adds the properties expected in a method section to an object. + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry The AST entry + * @param {import('../../generated/generated.d.ts').Method} section The method section + */ +export function createMethodSection(entry, section) { + parseSignatures(entry, section); + + const parent = findParentSection(section, ['class', 'module']); + + // Add this section to the parent if it exists + if (parent) { + let property; + if (parent.type === 'class' && entry.heading.data.type === 'ctor') { + property = '@constructor'; + } else { + // Put static methods in `staticMethods` property and non-static methods + // in the `methods` property + property = entry.heading.data.text.startsWith('Static method:') + ? 'staticMethods' + : 'methods'; + } + + parent[property] ??= []; + parent[property].push(section); + } +} diff --git a/src/generators/json/utils/sections/module.mjs b/src/generators/json/utils/sections/module.mjs new file mode 100644 index 00000000..2223f0e4 --- /dev/null +++ b/src/generators/json/utils/sections/module.mjs @@ -0,0 +1,15 @@ +'use strict'; + +import { BASE_URL } from '../../../../constants.mjs'; + +/** + * Adds the properties expected in a module section to an object. + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry The AST entry + * @param {import('../../generated/generated.d.ts').Module} section The module section + * @param {string} version + */ +export function createModuleSection(entry, section, version) { + section['@see'] = `${BASE_URL}docs/${version}/api/${entry.api}.html`; + + section['@module'] = `node:${entry.api}`; +} diff --git a/src/generators/json/utils/sections/property.mjs b/src/generators/json/utils/sections/property.mjs new file mode 100644 index 00000000..da9e3b6d --- /dev/null +++ b/src/generators/json/utils/sections/property.mjs @@ -0,0 +1,43 @@ +'use strict'; + +import { parseParameterList } from '../../../../utils/parseParameterList.mjs'; +import { findParentSection } from '../findParentSection.mjs'; + +/** + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry The AST entry + * @param {import('../../generated/generated.d.ts').Property} section The method section + */ +function parseTypeAndDescription(entry, section) { + const [typeInfo] = parseParameterList(entry); + + if (!typeInfo) { + // No type info + section['@type'] = 'any'; + return; + } + + section['@type'] = + typeInfo.type.length === 1 ? typeInfo.type[0] : typeInfo.type; + + if (typeInfo.description) { + // TODO probably need spaces here + section.description += typeInfo.description; + } +} + +/** + * Adds the properties expected in a method section to an object. + * @param {import('../../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry The AST entry + * @param {import('../../generated/generated.d.ts').Property} section The method section + */ +export function createPropertySection(entry, section) { + parseTypeAndDescription(entry, section); + + const parent = findParentSection(section, ['class', 'module']); + + // Add this section to the parent if it exists + if (parent) { + parent.properties ??= []; + parent.properties.push(section); + } +} diff --git a/src/generators/legacy-json/types.d.ts b/src/generators/legacy-json/types.d.ts index 9b6f1d47..9f82b20c 100644 --- a/src/generators/legacy-json/types.d.ts +++ b/src/generators/legacy-json/types.d.ts @@ -1,16 +1,5 @@ import { ListItem } from '@types/mdast'; -/** - * Represents an entry in a hierarchical structure, extending from ApiDocMetadataEntry. - * It includes children entries organized in a hierarchy. - */ -export interface HierarchizedEntry extends ApiDocMetadataEntry { - /** - * List of child entries that are part of this entry's hierarchy. - */ - hierarchyChildren: ApiDocMetadataEntry[]; -} - /** * Contains metadata related to changes, additions, removals, and deprecated statuses of an entry. */ diff --git a/src/generators/legacy-json/utils/buildSection.mjs b/src/generators/legacy-json/utils/buildSection.mjs index d3f710ba..baca0569 100644 --- a/src/generators/legacy-json/utils/buildSection.mjs +++ b/src/generators/legacy-json/utils/buildSection.mjs @@ -1,6 +1,6 @@ -import { buildHierarchy } from './buildHierarchy.mjs'; import { parseList } from './parseList.mjs'; import { enforceArray } from '../../../utils/array.mjs'; +import { buildHierarchy } from '../../../utils/buildHierarchy.mjs'; import { getRemarkRehype } from '../../../utils/remark.mjs'; import { transformNodesToString } from '../../../utils/unist.mjs'; import { SECTION_TYPE_PLURALS, UNPROMOTED_KEYS } from '../constants.mjs'; @@ -35,7 +35,7 @@ export const createSectionBuilder = () => { /** * Creates metadata from a hierarchized entry. - * @param {import('../types.d.ts').HierarchizedEntry} entry - The entry to create metadata from. + * @param {import('../../../utils/buildHierarchy.mjs').HierarchizedEntry} entry - The entry to create metadata from. * @returns {import('../types.d.ts').Meta | undefined} The created metadata, or undefined if all fields are empty. */ const createMeta = ({ diff --git a/src/generators/legacy-json/utils/__tests__/buildHierarchy.test.mjs b/src/utils/__tests__/buildHierarchy.test.mjs similarity index 100% rename from src/generators/legacy-json/utils/__tests__/buildHierarchy.test.mjs rename to src/utils/__tests__/buildHierarchy.test.mjs diff --git a/src/utils/__tests__/parseParameterList.test.mjs b/src/utils/__tests__/parseParameterList.test.mjs new file mode 100644 index 00000000..17969a3e --- /dev/null +++ b/src/utils/__tests__/parseParameterList.test.mjs @@ -0,0 +1,1063 @@ +// @ts-check +import assert from 'node:assert'; +import { describe, test } from 'node:test'; + +import { parseParameterList } from '../parseParameterList.mjs'; + +describe('parseParameterList', () => { + describe(' [description] [**Default:** `value`]', () => { + test('{number}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [{ type: ['number'] }]); + }); + + test('{number[]}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [{ type: ['number[]'] }]); + }); + + test('{number[]|boolean}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' | ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [{ type: ['number[]', 'boolean'] }]); + }); + + test('{object} some decription bla bla bla', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' some description bla bla bla', + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { type: ['object'], description: 'some description bla bla bla' }, + ]); + }); + + test('{string} some decription bla bla bla **Default:** `123`', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' some description bla bla bla ', + }, + { + type: 'strong', + children: [ + { + type: 'text', + value: 'Default:', + }, + ], + }, + { + type: 'text', + value: ' ', + }, + { + type: 'inlineCode', + value: '123', + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { + type: ['string'], + description: 'some description bla bla bla **Default:** `123`', + hasDefaultValue: true, + }, + ]); + }); + }); + + describe('Type: [description] [**Default:** `value`]', () => { + test('Type: Type: {number}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Type: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [{ type: ['number'] }]); + }); + + test('Type: {number[]}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Type: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [{ type: ['number[]'] }]); + }); + + test('Type: {number[]|boolean}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Type: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' | ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [{ type: ['number[]', 'boolean'] }]); + }); + + test('Type: {object} some decription bla bla bla', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Type: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' some description bla bla bla', + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { type: ['object'], description: 'some description bla bla bla' }, + ]); + }); + + test('Type: {string} some decription bla bla bla **Default:** `123`', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Type: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' some description bla bla bla ', + }, + { + type: 'strong', + children: [ + { + type: 'text', + value: 'Default:', + }, + ], + }, + { + type: 'text', + value: ' ', + }, + { + type: 'inlineCode', + value: '123', + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { + type: ['string'], + description: 'some description bla bla bla **Default:** `123`', + hasDefaultValue: true, + }, + ]); + }); + }); + + describe('Returns: [description] [**Default:** `value`]', () => { + test('Returns: {number}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Returns: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { type: ['number'], isReturnType: true }, + ]); + }); + + test('Returns: {number[]}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Returns: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { type: ['number[]'], isReturnType: true }, + ]); + }); + + test('Returns: {number[]|boolean}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Returns: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' | ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { type: ['number[]', 'boolean'], isReturnType: true }, + ]); + }); + + test('Returns: {object} some decription bla bla bla', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Returns: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' some description bla bla bla', + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { + type: ['object'], + description: 'some description bla bla bla', + isReturnType: true, + }, + ]); + }); + }); + + describe(' [description] [**Default:** `value`]', () => { + test('`parameter` {number}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'parameter', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { + name: 'parameter', + type: ['number'], + }, + ]); + }); + + test('`parameter` {number[]}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'parameter', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { + name: 'parameter', + type: ['number[]'], + }, + ]); + }); + + test('`parameter` {number[]|boolean}', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'parameter', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' | ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { + name: 'parameter', + type: ['number[]', 'boolean'], + }, + ]); + }); + + test('`parameter` {object} some decription bla bla bla', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'parameter', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' some description bla bla bla', + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { + name: 'parameter', + type: ['object'], + description: 'some description bla bla bla', + }, + ]); + }); + + test('`parameter` {string} some decription bla bla bla **Default:** `123`', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'parameter', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + { + type: 'text', + value: ' some description bla bla bla ', + }, + { + type: 'strong', + children: [ + { + type: 'text', + value: 'Default:', + }, + ], + }, + { + type: 'text', + value: ' ', + }, + { + type: 'inlineCode', + value: '123', + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { + name: 'parameter', + type: ['string'], + description: 'some description bla bla bla **Default:** `123`', + hasDefaultValue: true, + }, + ]); + }); + }); + + test('one of all', () => { + /** + * @type {import('mdast').List} + */ + const list = { + type: 'list', + children: [ + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Type: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'text', + value: 'Returns: ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + { + type: 'listItem', + children: [ + { + type: 'paragraph', + children: [ + { + type: 'inlineCode', + value: 'parameter', + }, + { + type: 'text', + value: ' ', + }, + { + type: 'link', + url: 'https://mdn-link', + children: [ + { + type: 'inlineCode', + value: '', + }, + ], + }, + ], + }, + ], + }, + ], + }; + + const parameters = parseParameterList(list); + + assert.deepStrictEqual(parameters, [ + { type: ['number'] }, + { type: ['string'] }, + { type: ['object'], isReturnType: true }, + { name: 'parameter', type: ['number'] }, + ]); + }); +}); diff --git a/src/utils/assertAstType.mjs b/src/utils/assertAstType.mjs new file mode 100644 index 00000000..a3ba1480 --- /dev/null +++ b/src/utils/assertAstType.mjs @@ -0,0 +1,52 @@ +'use strict'; + +import { enforceArray } from './array.mjs'; + +/** + * @typedef {import('mdast').BlockContentMap} BlockContentMap + * @typedef {import('mdast').ListContentMap} ListContentMap + * @typedef {import('mdast').FrontmatterContentMap} FrontmatterContentMap + * @typedef {import('mdast').PhrasingContentMap} PhrasingContentMap + * @typedef {import('mdast').RootContentMap} RootContentMap + * @typedef {import('mdast').RowContentMap} RowContentMap + * @typedef {import('mdast').TableContentMap} TableContentMap + * @typedef {import('mdast').DefinitionContentMap} DefinitionContentMap + * + * @typedef {BlockContentMap & ListContentMap & FrontmatterContentMap & PhrasingContentMap & RootContentMap & RowContentMap & TableContentMap & DefinitionContentMap} NodeTypes + */ + +/** + * @template {keyof NodeTypes} T + * + * @param {import('mdast').Node} node + * @param {T | Array} type + * @returns {NodeTypes[T]} + */ +export function assertAstType(node, type) { + if (node?.type === undefined) { + throw new TypeError(`expected node.type to be defined`); + } + + type = enforceArray(type); + + if (!type.includes(node.type)) { + throw new TypeError(`expected node to have type ${type}, got ${node.type}`); + } + + return node; +} + +/** + * @template {keyof NodeTypes} T + * + * @param {import('mdast').Node | undefined} node + * @param {T} type + * @returns {NodeTypes[T] | undefined} + */ +export function assertAstTypeOptional(node, type) { + if (!node) { + return undefined; + } + + return assertAstType(node, type); +} diff --git a/src/generators/legacy-json/utils/buildHierarchy.mjs b/src/utils/buildHierarchy.mjs similarity index 95% rename from src/generators/legacy-json/utils/buildHierarchy.mjs rename to src/utils/buildHierarchy.mjs index f7ad87cd..b66a85e7 100644 --- a/src/generators/legacy-json/utils/buildHierarchy.mjs +++ b/src/utils/buildHierarchy.mjs @@ -1,3 +1,11 @@ +'use strict'; + +/** + * @typedef {{ + * hierarchyChildren: Array + * } & ApiDocMetadataEntry} HierarchizedEntry + */ + /** * Recursively finds the most suitable parent entry for a given `entry` based on heading depth. * diff --git a/src/utils/parseParameterList.mjs b/src/utils/parseParameterList.mjs new file mode 100644 index 00000000..e371d95b --- /dev/null +++ b/src/utils/parseParameterList.mjs @@ -0,0 +1,213 @@ +// @ts-check + +import { assertAstType } from './assertAstType.mjs'; +import createQueries from './queries/index.mjs'; +import { transformNodeToString } from './unist.mjs'; + +/** + * @typedef {{ + * type: Array, + * name?: string, + * description?: string, + * isReturnType?: boolean, + * hasDefaultValue?: boolean, + * }} Parameter + */ + +/** + * @param {[import('mdast').Text, ...import('mdast').PhrasingContent[]]} children + * @param {Parameter} parameter + * @returns {boolean} + */ +function handleStarter(children, parameter) { + const starterMatch = children[0].value.match( + createQueries.QUERIES.typedListStarters + ); + + switch (starterMatch?.[1]) { + case 'Returns': { + parameter.isReturnType = true; + break; + } + case 'Type': { + // Do nothing + break; + } + default: { + // Invalid list item, ignore completely + return false; + } + } + + switch (children[1].type) { + case 'inlineCode': { + return extractParameterName(1, children, parameter); + } + case 'link': { + return extractParameterType(1, children, parameter); + } + default: { + // Invalid list item + return false; + } + } +} + +/** + * + * @param {number} idx + * @param {Array} children + * @param {Parameter} parameter + * @returns {boolean} + */ +function extractParameterName(idx, children, parameter) { + const nameElement = assertAstType(children[idx], 'inlineCode'); + parameter.name = nameElement.value; + + if (children[idx + 1]?.type !== 'text' || children[idx + 1].value !== ' ') { + return false; + } + + return extractParameterType(idx + 2, children, parameter); +} + +/** + * + * @param {number} idx + * @param {Array} children + * @param {Parameter} parameter + * @returns {boolean} + */ +function extractParameterType(idx, children, parameter) { + /** + * @type {Set} + */ + const types = new Set(); + + for (; idx < children.length; idx += 2) { + const child = children[idx]; + + if (child.type !== 'link') { + // Not a type + break; + } + + const typeName = assertAstType(child.children[0], ['text', 'inlineCode']); + types.add(typeName.value.replaceAll(/(<|>)/g, '')); + + const nextChild = children[idx + 1]; + if ( + !nextChild || + nextChild.type !== 'text' || + nextChild.value.trim() !== '|' + ) { + // No more types to parse + break; + } + } + + if (types.size === 0) { + return false; + } + + parameter.type = Array.from(types); + extractDescription(idx + 1, children, parameter); + + return true; +} + +/** + * + * @param {number} idx + * @param {Array} children + * @param {Parameter} parameter + */ +function extractDescription(idx, children, parameter) { + if (idx >= children.length) { + // No description + return; + } + + let description = ''; + + for (; idx < children.length; idx++) { + const node = children[idx]; + + // Check if this property has a default value + if (node.type === 'strong') { + const [child] = node.children; + + // TODO: it'd be great to actually extract the default value here and + // add it as a property in the section, there isn't really a standard + // way to specify the default values so that'd be pretty messy right + // now + if ( + child?.type === 'text' && + createQueries.QUERIES.defaultExpression.test(child?.value) + ) { + parameter.hasDefaultValue = true; + } + } + + const stringifiedNode = transformNodeToString(node).trim(); + + if (stringifiedNode.length > 0) { + description += `${stringifiedNode} `; + } + } + + if (description.length) { + parameter.description = description.trim(); + } +} + +/** + * TODO docs + * + * @param {import('mdast').ListItem} param0 + * @returns {Parameter | undefined} + */ +export function parseParameter({ children: listChildren }) { + /** + * @type {Parameter} + */ + const parameter = {}; + + if (listChildren.length === 0) { + return undefined; + } + + const { children } = assertAstType(listChildren[0], 'paragraph'); + + let isValidItem = false; + switch (children[0].type) { + case 'text': { + isValidItem = handleStarter(children, parameter); + break; + } + case 'inlineCode': { + isValidItem = extractParameterName(0, children, parameter); + break; + } + case 'link': { + isValidItem = extractParameterType(0, children, parameter); + break; + } + default: { + break; + } + } + + return isValidItem ? parameter : undefined; +} + +/** + * TODO docs + * @param {import('mdast').List} list + * @returns {Array} + */ +export function parseParameterList(list) { + return list.children + .map(parseParameter) + .filter(parameter => parameter !== undefined); +} diff --git a/src/utils/queries/index.mjs b/src/utils/queries/index.mjs index b8b4a9d9..01dacb45 100644 --- a/src/utils/queries/index.mjs +++ b/src/utils/queries/index.mjs @@ -205,6 +205,8 @@ createQueries.QUERIES = { unixManualPage: /\b([a-z.]+)\((\d)([a-z]?)\)/g, // ReGeX for determing a typed list's non-property names typedListStarters: /^(Returns|Extends|Type):?\s*/, + // ReGeX for determining if a node gives a property's default value + defaultExpression: /^(?:D|d)efault(?:s|):$/, }; createQueries.UNIST = { diff --git a/src/utils/unist.mjs b/src/utils/unist.mjs index fe3bccff..7cc37582 100644 --- a/src/utils/unist.mjs +++ b/src/utils/unist.mjs @@ -18,12 +18,18 @@ const escapeHTMLEntities = string => */ export const transformNodeToString = (node, escape) => { switch (node.type) { + case 'break': + return '\n'; case 'inlineCode': return `\`${escape ? escapeHTMLEntities(node.value) : node.value}\``; case 'strong': return `**${transformNodesToString(node.children, escape)}**`; case 'emphasis': return `_${transformNodesToString(node.children, escape)}_`; + case 'delete': + return `~~${transformNodesToString(node.children, escape)}~~`; + case 'link': + return `[${transformNodesToString(node.children, escape)}](${node.url})`; default: { if (node.children) { return transformNodesToString(node.children, escape);