diff --git a/.changeset/json-generator.md b/.changeset/json-generator.md new file mode 100644 index 000000000..f7a361315 --- /dev/null +++ b/.changeset/json-generator.md @@ -0,0 +1,7 @@ +--- +'@doc-kit/core': minor +'@doc-kit/generator-react': patch +'@node-core/doc-kit-legacy': patch +--- + +feat: the `json` and `json-all` generators diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 9a92f7202..4802812ae 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -94,6 +94,10 @@ jobs: - target: json-simple input: './node/doc/api/*.md' + - target: json + input: './node/doc/api/*.md' + compare: object-assertion + - target: legacy-json input: './node/doc/api/*.md' compare: object-assertion diff --git a/.prettierignore b/.prettierignore index 3450ac795..7218112d7 100644 --- a/.prettierignore +++ b/.prettierignore @@ -16,6 +16,7 @@ www/out/ # Generated Files packages/core/src/generators/metadata/maps/mdn.json +packages/core/src/generators/json/generated/ # The specification uses things that prettier would not # approve of, such as bullets with `*` diff --git a/README.md b/README.md index 9e3948492..7f3f1a446 100644 --- a/README.md +++ b/README.md @@ -69,10 +69,11 @@ Options: --config-file Config file -i, --input Input file patterns (glob) -t, --target Target generator(s): a built-in name - (json-simple, legacy-html, legacy-html-all, - man-page, legacy-json, legacy-json-all, - addon-verify, api-links, orama-db, llms-txt, - sitemap, html) or an import specifier for a + (json, json-all, json-simple, legacy-html, + legacy-html-all, man-page, legacy-json, + legacy-json-all, addon-verify, api-links, + orama-db, llms-txt, sitemap, html, + section-pages) or an import specifier for a custom generator --ignore Ignore file patterns (glob) -o, --output The output directory diff --git a/docs/generators.md b/docs/generators.md index 729864a23..890ce0a13 100644 --- a/docs/generators.md +++ b/docs/generators.md @@ -20,9 +20,11 @@ npx @doc-kit/cli generate -t html -t orama-db -t sitemap -i "docs/**/*.md" -o ou ### JSON ([`@doc-kit/core`](./packages/core.md)) -| Target | Output | -| -------------------------------------------- | -------------------------------------------------------- | -| [`json-simple`](./generators/json-simple.md) | A simplified JSON rendering of the parsed documentation. | +| Target | Output | +| -------------------------------------------- | ------------------------------------------------------------------ | +| [`json`](./generators/json.md) | One schema-described JSON document per source file. | +| [`json-all`](./generators/json-all.md) | Those documents bundled into a single `all.json`. | +| [`json-simple`](./generators/json-simple.md) | A dump of the parsed metadata entries, for debugging the pipeline. | ### Legacy ([`@node-core/doc-kit-legacy`](./packages/node-legacy.md)) diff --git a/package.json b/package.json index e5a2c0fbc..95e33c516 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "eslint-plugin-react-x": "5.18.1", "globals": "~17.7.0", "husky": "9.1.7", + "json-schema-to-typescript": "^16.0.0", "lint-staged": "17.3.0", "prettier": "3.9.6" } diff --git a/packages/core/README.md b/packages/core/README.md index 9f6f9e2f8..c9a1d97d3 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -10,7 +10,8 @@ The command-line interface lives in the companion ## Generators Output formats are provided by generators. This package ships the shared -pipeline stages and `json-simple`; the rest come from companion packages: +pipeline stages and the JSON generators (`json`, `json-all`, and the +debugging-only `json-simple`); the rest come from companion packages: | Package | Generators | | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | diff --git a/packages/core/package.json b/packages/core/package.json index f1a0d3262..655067853 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -20,6 +20,8 @@ ".": "./src/generators.mjs", "./ast": "./src/generators/ast/index.mjs", "./ast-js": "./src/generators/ast-js/index.mjs", + "./json": "./src/generators/json/index.mjs", + "./json-all": "./src/generators/json-all/index.mjs", "./json-simple": "./src/generators/json-simple/index.mjs", "./metadata": "./src/generators/metadata/index.mjs", "./package.json": "./package.json", @@ -59,6 +61,8 @@ "github-slugger": "^2.0.0", "glob-parent": "^6.0.2", "hastscript": "^9.0.1", + "mdast-util-slice-markdown": "^2.0.1", + "mdast-util-to-string": "^4.0.0", "piscina": "^5.3.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", @@ -79,13 +83,14 @@ "yaml": "^2.9.0" }, "devDependencies": { + "ajv": "^8.20.0", "hast-util-to-html": "^9.0.5", "hast-util-to-string": "^3.0.1" }, "peerDependencies": { + "@doc-kit/generator-react": "workspace:>=0.1.0", "@node-core/doc-kit": "workspace:>=2.0.0", - "@node-core/doc-kit-legacy": "workspace:>=1.0.0", - "@doc-kit/generator-react": "workspace:>=0.1.0" + "@node-core/doc-kit-legacy": "workspace:>=1.0.0" }, "peerDependenciesMeta": { "@node-core/doc-kit": { diff --git a/packages/core/src/generators/index.mjs b/packages/core/src/generators/index.mjs index 5acae57d9..5303430a3 100644 --- a/packages/core/src/generators/index.mjs +++ b/packages/core/src/generators/index.mjs @@ -10,6 +10,8 @@ * which is how third-party generator packages are loaded. */ export const publicGenerators = { + json: '@doc-kit/core/json', + 'json-all': '@doc-kit/core/json-all', 'json-simple': '@doc-kit/core/json-simple', 'legacy-html': '@node-core/doc-kit-legacy/legacy-html', 'legacy-html-all': '@node-core/doc-kit-legacy/legacy-html-all', diff --git a/packages/core/src/generators/json-all/README.md b/packages/core/src/generators/json-all/README.md new file mode 100644 index 000000000..dfacb652a --- /dev/null +++ b/packages/core/src/generators/json-all/README.md @@ -0,0 +1,31 @@ +# `json-all` Generator + +The `json-all` generator bundles the documents of the [`json`](./json.md) +generator into a single `all.json` file. + +```sh +npx @doc-kit/cli generate -t json-all -i "doc/api/*.md" -o out --index doc/api/index.md +``` + +```json +{ + "$schema": "https://doc-kit.nodejs.org/schemas/api-doc-all/1.0.0.json", + "documents": [] +} +``` + +`documents` holds every document in the order of the configured `index`, +then the rest by `id`. The bundle's schema, shipped as +`@doc-kit/core/generators/json-all/schema.json`, refers to the `json` +generator's schema for the documents. + +## Configuring + +- `output` {string} The directory where `all.json` will be written. +- `minify` {boolean} Whether to minify the output. Inherited from `global`. + **Default:** `true`. +- `index` {Array} The `{ api }` objects defining the document order. Inherited + from `global`. +- `schemaURL` {string} Where the bundle's schema is published. + `{schemaVersion}` is filled in. **Default:** + `'https://doc-kit.nodejs.org/schemas/api-doc-all/{schemaVersion}.json'`. diff --git a/packages/core/src/generators/json-all/__tests__/generate.test.mjs b/packages/core/src/generators/json-all/__tests__/generate.test.mjs new file mode 100644 index 000000000..6ac7a9843 --- /dev/null +++ b/packages/core/src/generators/json-all/__tests__/generate.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { setConfig } from '#utils/configuration/index.mjs'; + +import { SCHEMA_VERSION } from '../../json/constants.mjs'; +import { generate } from '../generate.mjs'; + +const config = await setConfig({ target: ['json-all'] }); + +const document = id => ({ id, path: `/${id}`, children: [] }); + +describe('json-all', () => { + it('bundles the documents in index order, then by id', async () => { + config['json-all'].index = [ + { section: 'HTTP', api: 'http' }, + { section: 'File system', api: 'fs' }, + ]; + config['json-all'].output = undefined; + + const bundle = await generate( + ['zlib', 'fs', 'assert', 'http'].map(document) + ); + + assert.equal( + bundle.$schema, + `https://doc-kit.nodejs.org/schemas/api-doc-all/${SCHEMA_VERSION}.json` + ); + assert.deepEqual( + bundle.documents.map(({ id }) => id), + ['http', 'fs', 'assert', 'zlib'] + ); + }); +}); diff --git a/packages/core/src/generators/json-all/constants.mjs b/packages/core/src/generators/json-all/constants.mjs new file mode 100644 index 000000000..c5fda19b1 --- /dev/null +++ b/packages/core/src/generators/json-all/constants.mjs @@ -0,0 +1,5 @@ +'use strict'; + +// Where a version of the bundle's schema is published. +export const SCHEMA_URL = + 'https://doc-kit.nodejs.org/schemas/api-doc-all/{schemaVersion}.json'; diff --git a/packages/core/src/generators/json-all/generate.mjs b/packages/core/src/generators/json-all/generate.mjs new file mode 100644 index 000000000..f93d5b371 --- /dev/null +++ b/packages/core/src/generators/json-all/generate.mjs @@ -0,0 +1,35 @@ +'use strict'; + +import { join } from 'node:path'; + +import getConfig from '#utils/configuration/index.mjs'; +import { writeJSON } from '#utils/file.mjs'; + +import { resolveSchemaURL } from '../json/utils/schema.mjs'; + +/** + * Bundles the `json` generator's documents into one `all.json` file. + * + * @type {import('./types').Generator['generate']} + */ +export async function generate(input) { + const config = getConfig('json-all'); + + // Documents follow the configured index; the rest go after it, by id + const order = new Map(config.index?.map(({ api }, i) => [api, i])); + + const documents = input.toSorted( + (a, b) => + (order.get(a.id) ?? Infinity) - (order.get(b.id) ?? Infinity) || + a.id.localeCompare(b.id) + ); + + /** @type {import('./types').Bundle} */ + const bundle = { $schema: resolveSchemaURL(config), documents }; + + if (config.output) { + await writeJSON(join(config.output, 'all.json'), bundle, config.minify); + } + + return bundle; +} diff --git a/packages/core/src/generators/json-all/index.mjs b/packages/core/src/generators/json-all/index.mjs new file mode 100644 index 000000000..f6c96d2e0 --- /dev/null +++ b/packages/core/src/generators/json-all/index.mjs @@ -0,0 +1,25 @@ +'use strict'; + +import { SCHEMA_URL } from './constants.mjs'; +import { generate } from './generate.mjs'; + +/** + * This generator bundles the documents of the `json` generator into a single + * `all.json` file + * + * @type {import('./types').Generator} + */ +export default { + name: 'json-all', + + description: + 'Bundles the documents of the `json` generator into a single `all.json` file', + + dependsOn: '@doc-kit/core/json', + + defaultConfiguration: { + schemaURL: SCHEMA_URL, + }, + + generate, +}; diff --git a/packages/core/src/generators/json-all/schema.json b/packages/core/src/generators/json-all/schema.json new file mode 100644 index 000000000..6b63773e6 --- /dev/null +++ b/packages/core/src/generators/json-all/schema.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://doc-kit.nodejs.org/schemas/api-doc-all/1.0.0.json", + "title": "Bundle", + "description": "Every document of a documentation set, as emitted by the doc-kit `json-all` generator, in index order.", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The URL of the schema this bundle conforms to. Its last path segment is the schema version." + }, + "documents": { + "type": "array", + "items": { + "$ref": "https://doc-kit.nodejs.org/schemas/api-doc/1.0.0.json" + }, + "description": "The documents, in the order of the configured index, then by id." + } + }, + "required": ["$schema", "documents"], + "additionalProperties": false +} diff --git a/packages/core/src/generators/json-all/types.d.ts b/packages/core/src/generators/json-all/types.d.ts new file mode 100644 index 000000000..748ae48c7 --- /dev/null +++ b/packages/core/src/generators/json-all/types.d.ts @@ -0,0 +1,20 @@ +import type { Document } from '../json/generated/schema'; + +/** + * Every document of a documentation set, in index order. + */ +export interface Bundle { + /** The URL of the schema the bundle conforms to */ + $schema: string; + documents: Array; +} + +export interface Configuration { + /** Where the schema is published; `{schemaVersion}` is filled in */ + schemaURL: string; +} + +export type Generator = GeneratorMetadata< + Configuration, + Generate, Promise> +>; diff --git a/packages/core/src/generators/json-simple/generate.mjs b/packages/core/src/generators/json-simple/generate.mjs index 093bf82ef..98c7b195a 100644 --- a/packages/core/src/generators/json-simple/generate.mjs +++ b/packages/core/src/generators/json-simple/generate.mjs @@ -5,7 +5,7 @@ import { join } from 'node:path'; import { remove } from 'unist-util-remove'; import getConfig from '#utils/configuration/index.mjs'; -import { writeFile } from '#utils/file.mjs'; +import { writeJSON } from '#utils/file.mjs'; import { UNIST } from '#utils/queries/index.mjs'; /** @@ -23,10 +23,7 @@ export async function generate(input) { if (config.output) { // Writes all the API docs stringified content into one file // Note: The full JSON generator in the future will create one JSON file per top-level API doc file - await writeFile( - join(config.output, 'api-docs.json'), - config.minify ? JSON.stringify(input) : JSON.stringify(input, null, 2) - ); + await writeJSON(join(config.output, 'api-docs.json'), input, config.minify); } return input; diff --git a/packages/core/src/generators/json/README.md b/packages/core/src/generators/json/README.md new file mode 100644 index 000000000..f8617c503 --- /dev/null +++ b/packages/core/src/generators/json/README.md @@ -0,0 +1,151 @@ +# `json` Generator + +The `json` generator writes one JSON document per source file. Each document +is a tree of the file's headings, in document order, with the metadata, +signature or type, Markdown body, and code examples of every one of them. + +The output is described by a JSON schema, published at the URL every document +carries in `$schema`, and shipped with the package as +`@doc-kit/core/generators/json/schema.json`. + +```sh +npx @doc-kit/cli generate -t json -i "doc/api/*.md" -o out +``` + +Files keep the input's directory layout: `doc/api/fs.md` becomes `out/fs.json`. + +## Configuring + +- `output` {string} The directory to write the documents to. +- `minify` {boolean} Whether to minify the output. Inherited from `global`. + **Default:** `true`. +- `repository` {string} The `owner/name` repository source links resolve + against. Inherited from `global`; without one, `sourceLink.url` is `null`. +- `schemaURL` {string} Where the schema is published. `{schemaVersion}` is + filled in. **Default:** + `'https://doc-kit.nodejs.org/schemas/api-doc/{schemaVersion}.json'`. + +## The document + +```json +{ + "$schema": "https://doc-kit.nodejs.org/schemas/api-doc/1.0.0.json", + "id": "fs", + "path": "/fs", + "type": "module", + "module": "fs", + "title": "File system", + "introducedIn": "v0.10.0", + "sourceLink": { + "path": "lib/fs.js", + "url": "https://github.com/nodejs/node/blob/HEAD/lib/fs.js" + }, + "stability": { "index": "2", "level": 2, "description": "Stable" }, + "added": [], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "description": "The `node:fs` module enables interacting with the file system in a\nway modeled on standard POSIX functions.\n\n…", + "summary": "The `node:fs` module enables interacting with the file system in a way modeled on standard POSIX functions.", + "examples": [], + "children": [] +} +``` + +- `id` is the file's path, slugged; `path` is that path without extension. + Cross-document links in Markdown target `.html`. +- `type` is the file's `type=` directive: `module` (the default), `misc`, or + `global`. `module` is the module's name, from the `name=` directive. +- `introducedIn` and `sourceLink` are the `introduced_in=` and `source_link=` + directives. +- Everything from `title` on is what every heading carries, described below. +- `children` are the file's headings, nested by depth. + +## Every entry + +The document and every node carry: + +- `title` The heading text as authored, inline Markdown included. +- `stability` The stability index, or `null`: `{ index, level, description }`, + where `index` is the text as authored (`"1.1"`), `level` its integer part, + and `description` the Markdown after it. +- `added`, `deprecated`, `removed` Arrays of version strings, as authored. +- `napiVersion` An array of numbers. +- `changes` The change history: `{ versions, prUrl, commit, description }`. +- `description` The body as Markdown: everything under the heading except + its metadata, its stability index, and the typed list a signature or type + was taken from. Links are rewritten as they are for HTML output. +- `summary` A plain-text paragraph: the `llm_description` when there is one, + else the first paragraph. +- `examples` The fenced code blocks of the body, `{ language, displayName, code }`. + They stay in the description too. + +Every key is always present. What is missing is `null` or an empty array. + +## Nodes + +A node is a heading below the title. Its `kind` says what the heading +documents, and decides which further properties it has: + +| `kind` | Heading | Further properties | +| -------------- | ---------------------------------------------- | ---------------------------- | +| `section` | Anything else: prose, `DEP0005: …`, `--flag` | none | +| `class` | `` Class: `net.Server` `` | `extends` (`Type` or `null`) | +| `constructor` | `` `new Agent([options])` `` | `signature` | +| `method` | `` `fs.readFile(path[, options], callback)` `` | `signature` | +| `staticMethod` | `` Static method: `Buffer.from(string)` `` | `signature` | +| `property` | `` `buf.length` `` | `type`, `default` | +| `event` | `` Event: `'close'` `` | `parameters` | + +Every node also has: + +- `id` The heading's slug, and its anchor in HTML output. +- `name` The bare identifier: `readFile`, `Server`, `close`. A section's + plain heading text. A `name=` directive overrides it. +- `scope` `module`, or `global` for entries typed `global`. +- `overloadOf` When several sibling headings document one callable, the `id` + of the first on the second and later ones; otherwise `null`. +- `children` The headings nested under it. + +## Signatures and types + +```json +"signature": { + "parameters": [ + { + "name": "options", + "type": { "text": "Object | string", "links": [{ "name": "Object", "href": "https://developer.mozilla.org/…", "start": 0, "end": 6 }] }, + "description": "", + "default": null, + "optional": true, + "rest": false, + "properties": [ + { "name": "encoding", "type": { "text": "string | null", "links": [] }, "description": "", "default": "null", "optional": true, "rest": false, "properties": [] } + ] + } + ], + "returns": { "type": { "text": "Promise", "links": [] }, "description": "Fulfills upon success." } +} +``` + +- A signature's `parameters` are the ones the heading declares, in order, + described by the typed list under it. `optional` is set for parameters + bracketed in the heading or documented with a default; `rest` for + `...args`. `properties` are the nested list items: the properties of an + options object, or the arguments of a callback. +- `returns` is the `Returns:` item, or `null`. +- A `Type` is the annotation's TypeScript text, normalised to one line with + `|` between union members, plus the names in it that resolved to + documentation, with their character offsets. A type that was not + documented is `null`, never guessed. +- An event's `parameters` are the arguments its listeners receive. + +## The schema + +`schema.json` is the source of truth. After changing it, bump its `$id` and +`SCHEMA_VERSION` in `constants.mjs` together, and regenerate the types: + +```sh +node scripts/generate-json-types.mjs +``` diff --git a/packages/core/src/generators/json/__tests__/fixtures/misc.md b/packages/core/src/generators/json/__tests__/fixtures/misc.md new file mode 100644 index 000000000..f8042472f --- /dev/null +++ b/packages/core/src/generators/json/__tests__/fixtures/misc.md @@ -0,0 +1,21 @@ +# Guide + + + +An introduction. + +## Class: `Thing` + +- Extends: {EventEmitter} + +Documented even in a guide. + +## Setup + +Type: Documentation-only + +- `first` {string} A list under a section stays prose. + +# Appendix + +More. diff --git a/packages/core/src/generators/json/__tests__/fixtures/module.md b/packages/core/src/generators/json/__tests__/fixtures/module.md new file mode 100644 index 000000000..58a94923a --- /dev/null +++ b/packages/core/src/generators/json/__tests__/fixtures/module.md @@ -0,0 +1,108 @@ +# Widgets + + + + + + + +> Stability: 2 - Stable + +The `node:widgets` module makes {Widget} objects. See [`widgets.create()`][]. + +```js displayName="Making a widget" +const { create } = require('node:widgets'); +``` + +## Class: `widgets.Widget` + + + +- Extends: {EventEmitter} + +A widget. + +### `new Widget(name[, options])` + + + +- `name` {string} The widget's name. +- `options` {Object} + - `size` {number} The size. **Default:** `1`. + - `signal` {AbortSignal} Cancels the widget. + +### `new Widget(options)` + + + +- `options` {Object} + +### Event: `'ready'` + + + +- `widget` {Widget} The widget that is ready. + +Emitted once the widget is ready. + +### `widget.render([...targets])` + +- `...targets` {string[]} Where to render. +- Returns: {Promise} Fulfills once rendered. + +Renders the widget. + +```mjs +await widget.render(); +``` + +### `widget.size` + + + +> Stability: 0 - Deprecated: Use [`widget.render()`][] instead. + +- Type: {number} The widget's size. **Default:** `1`. + +### Static method: `Widget.from(source)` + +- `source` {string|Buffer} A serialized widget. +- Returns: {Widget} + +## Notes + +A prose section with a list that stays prose: + +- One thing +- Another thing + +### `globalThis.widget` + + + +- Type: {Widget} + +The default widget. + +[`widgets.create()`]: #widgetscreate +[`widget.render()`]: #widgetrendertargets diff --git a/packages/core/src/generators/json/__tests__/generate.test.mjs b/packages/core/src/generators/json/__tests__/generate.test.mjs new file mode 100644 index 000000000..0e770b8f8 --- /dev/null +++ b/packages/core/src/generators/json/__tests__/generate.test.mjs @@ -0,0 +1,277 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { basename } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import Ajv from 'ajv'; +import { globSync } from 'tinyglobby'; + +import { parseApiDoc } from '#generators/metadata/utils/parse.mjs'; +import { QUERIES } from '#utils/queries/index.mjs'; +import { getRemark } from '#utils/remark.mjs'; + +import schema from '../schema.json' with { type: 'json' }; +import { buildDocument } from '../utils/document.mjs'; + +const fixtures = new URL('./fixtures/', import.meta.url); + +const typeMap = { + EventEmitter: 'events.html#class-eventemitter', + Widget: 'widgets.html#class-widgetswidget', +}; + +const dependencies = { + schemaURL: 'https://example.com/api-doc.json', + sourceURL: 'https://github.com/example/widgets/blob/HEAD/', +}; + +const validate = new Ajv({ allErrors: true, strict: true }).compile(schema); + +/** + * Runs a fixture through the metadata stage and builds its document. + */ +const buildFixture = async name => { + const content = await readFile(new URL(name, fixtures), 'utf-8'); + + // The AST stage links the stability prefix; mirror that + const source = content.replace( + QUERIES.stabilityIndexPrefix, + match => `[${match}](documentation.html#stability-index)` + ); + + const path = `/${basename(name, '.md')}`; + const entries = parseApiDoc( + { path, tree: getRemark().parse(source) }, + typeMap + ); + + return buildDocument(entries[0], entries, dependencies); +}; + +/** + * Finds the first node of a document matching a predicate. + */ +const find = ({ children }, predicate) => { + for (const node of children) { + const found = predicate(node) ? node : find(node, predicate); + + if (found) { + return found; + } + } + + return undefined; +}; + +describe('json', () => { + for (const name of globSync('*.md', { cwd: fileURLToPath(fixtures) })) { + it(`builds a valid document for ${name}`, async t => { + const document = await buildFixture(name); + + assert.ok(validate(document), JSON.stringify(validate.errors, null, 2)); + + t.assert.snapshot(document); + }); + } + + describe('a module document', async () => { + const document = await buildFixture('module.md'); + + it('takes the document-level directives', () => { + assert.equal(document.id, 'module'); + assert.equal(document.type, 'module'); + assert.equal(document.module, 'widgets'); + assert.equal(document.introducedIn, 'v1.0.0'); + assert.deepEqual(document.sourceLink, { + path: 'lib/widgets.js', + url: 'https://github.com/example/widgets/blob/HEAD/lib/widgets.js', + }); + assert.deepEqual(document.stability, { + index: '2', + level: 2, + description: 'Stable', + }); + }); + + it('keeps prose as Markdown, with references resolved and types kept', () => { + assert.match( + document.description, + /\{Widget\} objects\. See \[`widgets\.create\(\)`\]\(#widgetscreate\)/ + ); + assert.equal( + document.summary, + 'The `node:widgets` module makes {Widget} objects. See `widgets.create()`.' + ); + assert.deepEqual(document.examples, [ + { + language: 'js', + displayName: 'Making a widget', + code: "const { create } = require('node:widgets');", + }, + ]); + }); + + it('nests the headings by depth, in document order', () => { + assert.deepEqual( + document.children.map(({ kind, name }) => `${kind}:${name}`), + ['class:Widget', 'section:Notes'] + ); + assert.deepEqual( + document.children[0].children.map( + ({ kind, name }) => `${kind}:${name}` + ), + [ + 'constructor:Widget', + 'constructor:Widget', + 'event:ready', + 'method:render', + 'property:size', + 'staticMethod:from', + ] + ); + }); + + it('lifts the extends clause out of a class', () => { + const widget = find(document, ({ kind }) => kind === 'class'); + + assert.deepEqual(widget.extends, { + text: 'EventEmitter', + links: [ + { + name: 'EventEmitter', + href: 'events.html#class-eventemitter', + start: 0, + end: 12, + }, + ], + }); + assert.equal(widget.description, 'A widget.'); + assert.deepEqual(widget.changes, [ + { + versions: ['v2.0.0'], + prUrl: 'https://github.com/example/widgets/pull/2', + commit: null, + description: "Widgets now emit `'ready'`.", + }, + ]); + }); + + it('merges the heading and the typed list into a signature', () => { + const [first, second] = document.children[0].children; + + assert.deepEqual(first.added, ['v1.0.0', 'v0.9.0']); + assert.equal(first.overloadOf, null); + assert.equal(second.overloadOf, first.id); + + const [name, options] = first.signature.parameters; + + assert.equal(name.name, 'name'); + assert.equal(name.type.text, 'string'); + assert.equal(name.description, "The widget's name."); + assert.equal(name.optional, false); + + assert.equal(options.optional, true); + assert.equal(options.description, ''); + assert.deepEqual( + options.properties.map(({ name, default: value, optional }) => [ + name, + value, + optional, + ]), + [ + ['size', '1', true], + ['signal', null, false], + ] + ); + assert.equal(options.properties[0].description, 'The size.'); + assert.equal(first.signature.returns, null); + }); + + it('handles rest parameters and return values', () => { + const render = find(document, ({ name }) => name === 'render'); + const [targets] = render.signature.parameters; + + assert.deepEqual( + [targets.name, targets.rest, targets.optional, targets.type.text], + ['targets', true, true, 'string[]'] + ); + assert.deepEqual(render.signature.returns, { + type: { text: 'Promise', links: render.signature.returns.type.links }, + description: 'Fulfills once rendered.', + }); + assert.equal( + render.description, + 'Renders the widget.\n\n```mjs\nawait widget.render();\n```' + ); + assert.equal(render.examples.length, 1); + }); + + it('takes a property type, default and description from its list', () => { + const size = find(document, ({ kind }) => kind === 'property'); + + assert.equal(size.type.text, 'number'); + assert.equal(size.default, '1'); + assert.equal(size.description, "The widget's size."); + assert.deepEqual(size.deprecated, ['v2.0.0']); + assert.deepEqual(size.stability, { + index: '0', + level: 0, + description: + 'Deprecated: Use [`widget.render()`](#widgetrendertargets) instead.', + }); + }); + + it('takes event parameters from the list', () => { + const ready = find(document, ({ kind }) => kind === 'event'); + + assert.equal(ready.parameters.length, 1); + assert.equal(ready.parameters[0].name, 'widget'); + assert.equal( + ready.parameters[0].type.links[0].href, + 'widgets.html#class-widgetswidget' + ); + assert.equal(ready.description, 'Emitted once the widget is ready.'); + }); + + it('leaves the lists of a section in its prose', () => { + const notes = find(document, ({ name }) => name === 'Notes'); + + assert.match(notes.description, /\* One thing\n\* Another thing/); + }); + + it('scopes an entry typed global without losing its kind', () => { + const widget = find(document, ({ name }) => name === 'widget'); + + assert.equal(widget.kind, 'property'); + assert.equal(widget.scope, 'global'); + assert.equal(widget.type.text, 'Widget'); + }); + }); + + describe('a misc document', async () => { + const document = await buildFixture('misc.md'); + + it('is typed misc and has no module', () => { + assert.equal(document.type, 'misc'); + assert.equal(document.module, null); + assert.equal(document.sourceLink, null); + }); + + it('still classifies its headings, and keeps a second title as a section', () => { + assert.deepEqual( + document.children.map(({ kind, name }) => `${kind}:${name}`), + ['class:Thing', 'section:Setup', 'section:Appendix'] + ); + }); + + it('keeps a typed list under a section as prose', () => { + const setup = find(document, ({ name }) => name === 'Setup'); + + assert.match( + setup.description, + /^Type: Documentation-only\n\n\* `first` \{string\}/ + ); + }); + }); +}); diff --git a/packages/core/src/generators/json/__tests__/generate.test.mjs.snapshot b/packages/core/src/generators/json/__tests__/generate.test.mjs.snapshot new file mode 100644 index 000000000..6f58b029d --- /dev/null +++ b/packages/core/src/generators/json/__tests__/generate.test.mjs.snapshot @@ -0,0 +1,559 @@ +exports[`json > builds a valid document for misc.md 1`] = ` +{ + "$schema": "https://example.com/api-doc.json", + "id": "misc", + "path": "/misc", + "type": "misc", + "module": null, + "title": "Guide", + "introducedIn": null, + "sourceLink": null, + "stability": null, + "added": [], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "description": "An introduction.", + "summary": "An introduction.", + "examples": [], + "children": [ + { + "kind": "class", + "id": "class-thing", + "name": "Thing", + "title": "Class: \`Thing\`", + "scope": "module", + "overloadOf": null, + "stability": null, + "added": [], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "extends": { + "text": "EventEmitter", + "links": [ + { + "name": "EventEmitter", + "href": "events.html#class-eventemitter", + "start": 0, + "end": 12 + } + ] + }, + "description": "Documented even in a guide.", + "summary": "Documented even in a guide.", + "examples": [], + "children": [] + }, + { + "kind": "section", + "id": "setup", + "name": "Setup", + "title": "Setup", + "scope": "module", + "overloadOf": null, + "stability": null, + "added": [], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "description": "Type: Documentation-only\\n\\n* \`first\` {string} A list under a section stays prose.", + "summary": "Type: Documentation-only", + "examples": [], + "children": [] + }, + { + "kind": "section", + "id": "appendix", + "name": "Appendix", + "title": "Appendix", + "scope": "module", + "overloadOf": null, + "stability": null, + "added": [], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "description": "More.", + "summary": "More.", + "examples": [], + "children": [] + } + ] +} +`; + +exports[`json > builds a valid document for module.md 1`] = ` +{ + "$schema": "https://example.com/api-doc.json", + "id": "module", + "path": "/module", + "type": "module", + "module": "widgets", + "title": "Widgets", + "introducedIn": "v1.0.0", + "sourceLink": { + "path": "lib/widgets.js", + "url": "https://github.com/example/widgets/blob/HEAD/lib/widgets.js" + }, + "stability": { + "index": "2", + "level": 2, + "description": "Stable" + }, + "added": [], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "description": "The \`node:widgets\` module makes {Widget} objects. See [\`widgets.create()\`](#widgetscreate).\\n\\n\`\`\`js displayName=\\"Making a widget\\"\\nconst { create } = require('node:widgets');\\n\`\`\`", + "summary": "The \`node:widgets\` module makes {Widget} objects. See \`widgets.create()\`.", + "examples": [ + { + "language": "js", + "displayName": "Making a widget", + "code": "const { create } = require('node:widgets');" + } + ], + "children": [ + { + "kind": "class", + "id": "class-widgetswidget", + "name": "Widget", + "title": "Class: \`widgets.Widget\`", + "scope": "module", + "overloadOf": null, + "stability": null, + "added": [ + "v1.0.0" + ], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [ + { + "versions": [ + "v2.0.0" + ], + "prUrl": "https://github.com/example/widgets/pull/2", + "commit": null, + "description": "Widgets now emit \`'ready'\`." + } + ], + "extends": { + "text": "EventEmitter", + "links": [ + { + "name": "EventEmitter", + "href": "events.html#class-eventemitter", + "start": 0, + "end": 12 + } + ] + }, + "description": "A widget.", + "summary": "A widget.", + "examples": [], + "children": [ + { + "kind": "constructor", + "id": "new-widgetname-options", + "name": "Widget", + "title": "\`new Widget(name[, options])\`", + "scope": "module", + "overloadOf": null, + "stability": null, + "added": [ + "v1.0.0", + "v0.9.0" + ], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "signature": { + "parameters": [ + { + "name": "name", + "type": { + "text": "string", + "links": [ + { + "name": "string", + "href": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#string_type", + "start": 0, + "end": 6 + } + ] + }, + "description": "The widget's name.", + "default": null, + "optional": false, + "rest": false, + "properties": [] + }, + { + "name": "options", + "type": { + "text": "Object", + "links": [ + { + "name": "Object", + "href": "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object", + "start": 0, + "end": 6 + } + ] + }, + "description": "", + "default": null, + "optional": true, + "rest": false, + "properties": [ + { + "name": "size", + "type": { + "text": "number", + "links": [ + { + "name": "number", + "href": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#number_type", + "start": 0, + "end": 6 + } + ] + }, + "description": "The size.", + "default": "1", + "optional": true, + "rest": false, + "properties": [] + }, + { + "name": "signal", + "type": { + "text": "AbortSignal", + "links": [ + { + "name": "AbortSignal", + "href": "https://developer.mozilla.org/docs/Web/API/AbortSignal", + "start": 0, + "end": 11 + } + ] + }, + "description": "Cancels the widget.", + "default": null, + "optional": false, + "rest": false, + "properties": [] + } + ] + } + ], + "returns": null + }, + "description": "", + "summary": "", + "examples": [], + "children": [] + }, + { + "kind": "constructor", + "id": "new-widgetoptions", + "name": "Widget", + "title": "\`new Widget(options)\`", + "scope": "module", + "overloadOf": "new-widgetname-options", + "stability": null, + "added": [ + "v1.5.0" + ], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "signature": { + "parameters": [ + { + "name": "options", + "type": { + "text": "Object", + "links": [ + { + "name": "Object", + "href": "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object", + "start": 0, + "end": 6 + } + ] + }, + "description": "", + "default": null, + "optional": false, + "rest": false, + "properties": [] + } + ], + "returns": null + }, + "description": "", + "summary": "", + "examples": [], + "children": [] + }, + { + "kind": "event", + "id": "event-ready", + "name": "ready", + "title": "Event: \`'ready'\`", + "scope": "module", + "overloadOf": null, + "stability": null, + "added": [ + "v2.0.0" + ], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "parameters": [ + { + "name": "widget", + "type": { + "text": "Widget", + "links": [ + { + "name": "Widget", + "href": "widgets.html#class-widgetswidget", + "start": 0, + "end": 6 + } + ] + }, + "description": "The widget that is ready.", + "default": null, + "optional": false, + "rest": false, + "properties": [] + } + ], + "description": "Emitted once the widget is ready.", + "summary": "Emitted once the widget is ready.", + "examples": [], + "children": [] + }, + { + "kind": "method", + "id": "widgetrendertargets", + "name": "render", + "title": "\`widget.render([...targets])\`", + "scope": "module", + "overloadOf": null, + "stability": null, + "added": [], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "signature": { + "parameters": [ + { + "name": "targets", + "type": { + "text": "string[]", + "links": [ + { + "name": "string", + "href": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#string_type", + "start": 0, + "end": 6 + } + ] + }, + "description": "Where to render.", + "default": null, + "optional": true, + "rest": true, + "properties": [] + } + ], + "returns": { + "type": { + "text": "Promise", + "links": [ + { + "name": "Promise", + "href": "https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Promise", + "start": 0, + "end": 7 + } + ] + }, + "description": "Fulfills once rendered." + } + }, + "description": "Renders the widget.\\n\\n\`\`\`mjs\\nawait widget.render();\\n\`\`\`", + "summary": "Renders the widget.", + "examples": [ + { + "language": "mjs", + "displayName": null, + "code": "await widget.render();" + } + ], + "children": [] + }, + { + "kind": "property", + "id": "widgetsize", + "name": "size", + "title": "\`widget.size\`", + "scope": "module", + "overloadOf": null, + "stability": { + "index": "0", + "level": 0, + "description": "Deprecated: Use [\`widget.render()\`](#widgetrendertargets) instead." + }, + "added": [ + "v1.0.0" + ], + "deprecated": [ + "v2.0.0" + ], + "removed": [], + "napiVersion": [], + "changes": [], + "type": { + "text": "number", + "links": [ + { + "name": "number", + "href": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#number_type", + "start": 0, + "end": 6 + } + ] + }, + "default": "1", + "description": "The widget's size.", + "summary": "", + "examples": [], + "children": [] + }, + { + "kind": "staticMethod", + "id": "static-method-widgetfromsource", + "name": "from", + "title": "Static method: \`Widget.from(source)\`", + "scope": "module", + "overloadOf": null, + "stability": null, + "added": [], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "signature": { + "parameters": [ + { + "name": "source", + "type": { + "text": "string | Buffer", + "links": [ + { + "name": "string", + "href": "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Data_structures#string_type", + "start": 0, + "end": 6 + } + ] + }, + "description": "A serialized widget.", + "default": null, + "optional": false, + "rest": false, + "properties": [] + } + ], + "returns": { + "type": { + "text": "Widget", + "links": [ + { + "name": "Widget", + "href": "widgets.html#class-widgetswidget", + "start": 0, + "end": 6 + } + ] + }, + "description": "" + } + }, + "description": "", + "summary": "", + "examples": [], + "children": [] + } + ] + }, + { + "kind": "section", + "id": "notes", + "name": "Notes", + "title": "Notes", + "scope": "module", + "overloadOf": null, + "stability": null, + "added": [], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "description": "A prose section with a list that stays prose:\\n\\n* One thing\\n* Another thing", + "summary": "A prose section with a list that stays prose:", + "examples": [], + "children": [ + { + "kind": "property", + "id": "globalthiswidget", + "name": "widget", + "title": "\`globalThis.widget\`", + "scope": "global", + "overloadOf": null, + "stability": null, + "added": [ + "v1.0.0" + ], + "deprecated": [], + "removed": [], + "napiVersion": [], + "changes": [], + "type": { + "text": "Widget", + "links": [ + { + "name": "Widget", + "href": "widgets.html#class-widgetswidget", + "start": 0, + "end": 6 + } + ] + }, + "default": null, + "description": "The default widget.", + "summary": "The default widget.", + "examples": [], + "children": [] + } + ] + } + ] +} +`; diff --git a/packages/core/src/generators/json/__tests__/schema.test.mjs b/packages/core/src/generators/json/__tests__/schema.test.mjs new file mode 100644 index 000000000..f045d49c9 --- /dev/null +++ b/packages/core/src/generators/json/__tests__/schema.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { describe, it } from 'node:test'; + +import Ajv from 'ajv'; + +import { + compileSchemaTypes, + TYPES_PATH, +} from '../../../../../../scripts/generate-json-types.mjs'; +import bundleSchema from '../../json-all/schema.json' with { type: 'json' }; +import { SCHEMA_VERSION } from '../constants.mjs'; +import schema from '../schema.json' with { type: 'json' }; + +describe('schema', () => { + it('compiles in strict mode, and the bundle schema resolves it', () => { + const ajv = new Ajv({ strict: true }); + + ajv.addSchema(schema); + + assert.ok(ajv.compile(bundleSchema)); + }); + + it('is versioned like the generator', () => { + assert.ok(schema.$id.endsWith(`/${SCHEMA_VERSION}.json`)); + assert.ok(bundleSchema.$id.endsWith(`/${SCHEMA_VERSION}.json`)); + assert.equal(bundleSchema.properties.documents.items.$ref, schema.$id); + }); + + it('has its generated types committed', async () => { + assert.equal( + await readFile(TYPES_PATH, 'utf-8'), + await compileSchemaTypes() + ); + }); +}); diff --git a/packages/core/src/generators/json/constants.mjs b/packages/core/src/generators/json/constants.mjs new file mode 100644 index 000000000..b3369333d --- /dev/null +++ b/packages/core/src/generators/json/constants.mjs @@ -0,0 +1,57 @@ +'use strict'; + +// See the `version` in schema.json. These values should be in sync. +export const SCHEMA_VERSION = '1.0.0'; + +// Where a schema version is published. +export const SCHEMA_URL = + 'https://doc-kit.nodejs.org/schemas/api-doc/{schemaVersion}.json'; + +// The heading classifications the metadata generator assigns, and the node +// kind each one becomes. +export const KINDS = { + class: 'class', + ctor: 'constructor', + method: 'method', + classMethod: 'staticMethod', + property: 'property', + event: 'event', +}; + +// The kind of a heading that documents no API entry +export const SECTION_KIND = 'section'; + +// The kinds whose leading typed list is lifted out of the body as data +export const KINDS_WITH_TYPED_LIST = new Set([ + 'class', + 'constructor', + 'method', + 'staticMethod', + 'property', + 'event', +]); + +// The kinds whose heading declares the parameters of a signature +export const CALLABLE_KINDS = new Set([ + 'constructor', + 'method', + 'staticMethod', +]); + +// The document-level `type` classifications +export const DOCUMENT_TYPES = new Set(['module', 'misc', 'global']); + +// The `displayName="..."` attribute of a fenced code block's info string +export const DISPLAY_NAME = /displayName="([^"]*)"/; + +// A rest parameter's marker +export const REST_MARKER = /^\.\.\./; + +// The code span around a default value +export const CODE_SPAN = /^`([^]*)`$/; + +// The `=` a default value declared in a heading starts with +export const DECLARED_DEFAULT = /^=\s*/; + +// The `extends` clause a class heading may carry: `Class: \`Foo extends Bar\`` +export const EXTENDS_CLAUSE = / extends +/; diff --git a/packages/core/src/generators/json/generate.mjs b/packages/core/src/generators/json/generate.mjs new file mode 100644 index 000000000..6ce545eb8 --- /dev/null +++ b/packages/core/src/generators/json/generate.mjs @@ -0,0 +1,65 @@ +'use strict'; + +import { join } from 'node:path'; + +import getConfig from '#utils/configuration/index.mjs'; +import { GITHUB_BLOB_URL, populate } from '#utils/configuration/templates.mjs'; +import { withExt, writeJSON } from '#utils/file.mjs'; +import { groupNodesByModule } from '#utils/generators.mjs'; + +import { buildDocument } from './utils/document.mjs'; +import { resolveSchemaURL } from './utils/schema.mjs'; + +/** + * Builds the documents of a chunk of modules in a worker thread. + * + * @type {import('./types').Generator['processChunk']} + */ +export async function processChunk(slicedInput, itemIndices, dependencies) { + return itemIndices.map(idx => { + const { head, entries } = slicedInput[idx]; + + return buildDocument(head, entries, dependencies); + }); +} + +/** + * Generates one JSON document per input file. + * + * @type {import('./types').Generator['generate']} + */ +export async function* generate(input, worker) { + const config = getConfig('json'); + + /** @type {import('./types').Dependencies} */ + const dependencies = { + schemaURL: resolveSchemaURL(config), + // Source links resolve against the repository, when one is configured + sourceURL: config.repository ? populate(GITHUB_BLOB_URL, config) : null, + }; + + // Pages other generators added to the pipeline are theirs to render + const entries = input.filter(entry => !entry.synthetic && !entry.chunk); + + // One item per module, so a worker gets everything a document needs + const modules = [...groupNodesByModule(entries).values()].map(nodes => ({ + head: nodes.find(({ heading }) => heading.depth === 1) ?? nodes[0], + entries: nodes, + })); + + for await (const chunk of worker.stream(modules, dependencies)) { + if (config.output) { + await Promise.all( + chunk.map(document => + writeJSON( + join(config.output, withExt(document.path, 'json')), + document, + config.minify + ) + ) + ); + } + + yield chunk; + } +} diff --git a/packages/core/src/generators/json/generated/schema.d.ts b/packages/core/src/generators/json/generated/schema.d.ts new file mode 100644 index 000000000..b3b609526 --- /dev/null +++ b/packages/core/src/generators/json/generated/schema.d.ts @@ -0,0 +1,351 @@ +/* eslint-disable */ +/** + * Generated from `schema.json` by `scripts/generate-json-types.mjs`. + * Do not edit: change the schema and regenerate instead. + */ + +/** + * One API documentation source file, as emitted by the doc-kit `json` generator. The file is the root of a tree of nodes, one per heading, in document order. + */ +export type Document = Entry & { + /** + * The URL of the schema this document conforms to. Its last path segment is the schema version. + */ + $schema: string; + /** + * The document's identifier: its path, slugged. Unique within a documentation set. + */ + id: string; + /** + * The document's path inside the input tree, without extension. Cross-document links target `.html`. + */ + path: string; + /** + * The document's declared type: a module reference, a miscellaneous (conceptual) page, or a page of globals. + */ + type: 'module' | 'misc' | 'global'; + /** + * The name of the module the document describes, when it describes one. + */ + module: string | null; + /** + * The version the document itself was introduced in. + */ + introducedIn: string | null; + /** + * The implementation the document describes, when it links to one. + */ + sourceLink: SourceLink | null; + /** + * The document's headings, nested by depth, in document order. + */ + children: Node[]; +}; +/** + * A version string as authored, such as `v18.0.0`, or a release-process placeholder such as `REPLACEME`. + */ +export type Version = string; +/** + * A heading below the document root. + */ +export type Node = SectionNode | ClassNode | ConstructorNode | MethodNode | StaticMethodNode | PropertyNode | EventNode; +/** + * A heading that documents no API entry: prose, a deprecation, a command-line option. + */ +export type SectionNode = Entry & + NodeBase & { + kind: 'section'; + }; +/** + * A class. Its constructors, methods, properties and events are its children. + */ +export type ClassNode = Entry & + NodeBase & { + kind: 'class'; + /** + * The class the class extends, when documented. + */ + extends: Type | null; + }; +/** + * A class constructor. + */ +export type ConstructorNode = Entry & + NodeBase & { + kind: 'constructor'; + signature: Signature; + }; +/** + * A function or method. + */ +export type MethodNode = Entry & + NodeBase & { + kind: 'method'; + signature: Signature; + }; +/** + * A static method of a class. + */ +export type StaticMethodNode = Entry & + NodeBase & { + kind: 'staticMethod'; + signature: Signature; + }; +/** + * A property of a module, class or object. + */ +export type PropertyNode = Entry & + NodeBase & { + kind: 'property'; + /** + * The property's type, when documented. + */ + type: Type | null; + /** + * The property's default value as authored, when documented. + */ + default: string | null; + }; +/** + * An event emitted by the parent class or module. + */ +export type EventNode = Entry & + NodeBase & { + kind: 'event'; + /** + * The arguments passed to the event's listeners. + */ + parameters: Parameter[]; + }; + +/** + * What every heading, the document's own included, carries: its metadata and its body. + */ +export interface Entry { + /** + * The heading text as authored, inline Markdown included. + */ + title: string; + /** + * The entry's stability index, when declared. + */ + stability: Stability | null; + /** + * The versions the entry was added in, as authored. + */ + added: Version[]; + /** + * The versions the entry was deprecated in. + */ + deprecated: Version[]; + /** + * The versions the entry was removed in. + */ + removed: Version[]; + /** + * The Node-API versions the entry is available in. + */ + napiVersion: number[]; + /** + * The entry's change history, as authored. + */ + changes: Change[]; + /** + * The entry's body as Markdown: everything under the heading except its metadata, stability index, and the typed list a signature or type was taken from. Empty when the entry has no body. + */ + description: string; + /** + * A one-paragraph plain-text summary: the entry's `llm_description` when declared, else its first paragraph. + */ + summary: string; + /** + * The fenced code blocks in the body, in order. They remain in the description too. + */ + examples: Example[]; +} +/** + * An entry's stability index. + */ +export interface Stability { + /** + * The index as authored, including any sub-level, such as `1.1`. + */ + index: string; + /** + * The text following the index, as Markdown. + */ + description: string; +} +/** + * One record of an entry's change history. + */ +export interface Change { + /** + * The versions the change shipped in. + */ + versions: Version[]; + /** + * The pull request that made the change. + */ + prUrl: string | null; + /** + * The commit that made the change, on records that predate pull requests. + */ + commit: string | null; + /** + * What changed, as Markdown. + */ + description: string; +} +/** + * A fenced code block from an entry's body. + */ +export interface Example { + /** + * The code block's language identifier. + */ + language: string | null; + /** + * The code block's `displayName` attribute. + */ + displayName: string | null; + /** + * The code. + */ + code: string; +} +/** + * A link to the implementation a document describes. + */ +export interface SourceLink { + /** + * The implementation's path, relative to the repository root, as authored. + */ + path: string; + /** + * The implementation's URL, when a repository is configured. + */ + url: string | null; +} +/** + * What every node below the document root carries, on top of an entry. + */ +export interface NodeBase { + /** + * What the heading documents. Decides which further properties the node has. + */ + kind: 'section' | 'class' | 'constructor' | 'method' | 'staticMethod' | 'property' | 'event'; + /** + * The heading's slug, unique within the document. It is the heading's anchor in HTML output. + */ + id: string; + /** + * The bare identifier the heading documents, or the heading's plain text for a section. + */ + name: string; + /** + * Whether the entry is reached through its module, or available globally. + */ + scope: 'module' | 'global'; + /** + * For the second and later of several sibling headings documenting one callable, the `id` of the first. + */ + overloadOf: string | null; + /** + * The headings nested under this one, in document order. + */ + children: Node[]; +} +/** + * A type annotation. + */ +export interface Type { + /** + * The annotation as a TypeScript type expression, normalised: single-line, union members separated by ` | `. + */ + text: string; + /** + * The resolved names in the text, by offset, non-overlapping. + */ + links: TypeLink[]; +} +/** + * A type name inside a type's text, resolved to documentation. + */ +export interface TypeLink { + /** + * The resolved name, exactly as it appears in the text. + */ + name: string; + /** + * Where the name is documented: a URL, or a link relative to the document. + */ + href: string; + /** + * The offset of the name's first character in the text. + */ + start: number; + /** + * The offset after the name's last character in the text. + */ + end: number; +} +/** + * A callable's signature: the parameters declared in its heading, described by its typed list. + */ +export interface Signature { + /** + * The parameters, in declaration order. + */ + parameters: Parameter[]; + /** + * The return value, when documented. + */ + returns: Return | null; +} +/** + * A parameter of a signature or event, or a property of an object parameter. + */ +export interface Parameter { + /** + * The parameter's name, without any rest marker. + */ + name: string; + /** + * The parameter's type, when documented. + */ + type: Type | null; + /** + * The parameter's description, as Markdown, without its default value. + */ + description: string; + /** + * The default value as authored, such as `'utf8'` or `false`. + */ + default: string | null; + /** + * Whether the parameter may be omitted: bracketed in the signature, or documented with a default. + */ + optional: boolean; + /** + * Whether the parameter is a rest parameter. + */ + rest: boolean; + /** + * The documented properties of an object parameter, or the arguments of a callback. + */ + properties: Parameter[]; +} +/** + * A signature's return value. + */ +export interface Return { + /** + * The return type, when documented. + */ + type: Type | null; + /** + * The return value's description, as Markdown. + */ + description: string; +} diff --git a/packages/core/src/generators/json/index.mjs b/packages/core/src/generators/json/index.mjs new file mode 100644 index 000000000..aa4266ac9 --- /dev/null +++ b/packages/core/src/generators/json/index.mjs @@ -0,0 +1,27 @@ +'use strict'; + +import { SCHEMA_URL } from './constants.mjs'; +import { generate, processChunk } from './generate.mjs'; + +/** + * This generator turns each API doc into a JSON document + * + * @type {import('./types').Generator} + */ +export default { + name: 'json', + + description: + 'Generates one JSON document per API doc, described by the doc-kit JSON schema', + + dependsOn: '@doc-kit/core/metadata', + + defaultConfiguration: { + schemaURL: SCHEMA_URL, + }, + + hasParallelProcessor: true, + + generate, + processChunk, +}; diff --git a/packages/core/src/generators/json/schema.json b/packages/core/src/generators/json/schema.json new file mode 100644 index 000000000..54acebe36 --- /dev/null +++ b/packages/core/src/generators/json/schema.json @@ -0,0 +1,529 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://doc-kit.nodejs.org/schemas/api-doc/1.0.0.json", + "title": "Document", + "description": "One API documentation source file, as emitted by the doc-kit `json` generator. The file is the root of a tree of nodes, one per heading, in document order.", + "allOf": [ + { "$ref": "#/definitions/Entry" }, + { + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "The URL of the schema this document conforms to. Its last path segment is the schema version." + }, + "id": { + "type": "string", + "description": "The document's identifier: its path, slugged. Unique within a documentation set.", + "examples": ["fs", "child_process"] + }, + "path": { + "type": "string", + "description": "The document's path inside the input tree, without extension. Cross-document links target `.html`.", + "examples": ["/fs"] + }, + "type": { + "type": "string", + "enum": ["module", "misc", "global"], + "description": "The document's declared type: a module reference, a miscellaneous (conceptual) page, or a page of globals." + }, + "module": { + "type": ["string", "null"], + "description": "The name of the module the document describes, when it describes one.", + "examples": ["fs"] + }, + "introducedIn": { + "type": ["string", "null"], + "description": "The version the document itself was introduced in." + }, + "sourceLink": { + "oneOf": [{ "$ref": "#/definitions/SourceLink" }, { "type": "null" }], + "description": "The implementation the document describes, when it links to one." + }, + "children": { + "type": "array", + "items": { "$ref": "#/definitions/Node" }, + "description": "The document's headings, nested by depth, in document order." + } + }, + "required": [ + "$schema", + "id", + "path", + "type", + "module", + "introducedIn", + "sourceLink", + "children" + ] + } + ], + "definitions": { + "Version": { + "title": "Version", + "type": "string", + "description": "A version string as authored, such as `v18.0.0`, or a release-process placeholder such as `REPLACEME`.", + "examples": ["v18.0.0"] + }, + "SourceLink": { + "title": "SourceLink", + "type": "object", + "description": "A link to the implementation a document describes.", + "properties": { + "path": { + "type": "string", + "description": "The implementation's path, relative to the repository root, as authored.", + "examples": ["lib/fs.js"] + }, + "url": { + "type": ["string", "null"], + "description": "The implementation's URL, when a repository is configured." + } + }, + "required": ["path", "url"], + "additionalProperties": false + }, + "Stability": { + "title": "Stability", + "type": "object", + "description": "An entry's stability index.", + "properties": { + "index": { + "type": "string", + "pattern": "^[0-5](\\.[0-9])?$", + "description": "The index as authored, including any sub-level, such as `1.1`." + }, + "description": { + "type": "string", + "description": "The text following the index, as Markdown." + } + }, + "required": ["index", "level", "description"], + "additionalProperties": false + }, + "Change": { + "title": "Change", + "type": "object", + "description": "One record of an entry's change history.", + "properties": { + "versions": { + "type": "array", + "items": { "$ref": "#/definitions/Version" }, + "description": "The versions the change shipped in." + }, + "prUrl": { + "type": ["string", "null"], + "description": "The pull request that made the change." + }, + "commit": { + "type": ["string", "null"], + "description": "The commit that made the change, on records that predate pull requests." + }, + "description": { + "type": "string", + "description": "What changed, as Markdown." + } + }, + "required": ["versions", "prUrl", "commit", "description"], + "additionalProperties": false + }, + "Example": { + "title": "Example", + "type": "object", + "description": "A fenced code block from an entry's body.", + "properties": { + "language": { + "type": ["string", "null"], + "description": "The code block's language identifier." + }, + "displayName": { + "type": ["string", "null"], + "description": "The code block's `displayName` attribute." + }, + "code": { + "type": "string", + "description": "The code." + } + }, + "required": ["language", "displayName", "code"], + "additionalProperties": false + }, + "TypeLink": { + "title": "TypeLink", + "type": "object", + "description": "A type name inside a type's text, resolved to documentation.", + "properties": { + "name": { + "type": "string", + "description": "The resolved name, exactly as it appears in the text." + }, + "href": { + "type": "string", + "description": "Where the name is documented: a URL, or a link relative to the document." + }, + "start": { + "type": "integer", + "minimum": 0, + "description": "The offset of the name's first character in the text." + }, + "end": { + "type": "integer", + "minimum": 0, + "description": "The offset after the name's last character in the text." + } + }, + "required": ["name", "href", "start", "end"], + "additionalProperties": false + }, + "Type": { + "title": "Type", + "type": "object", + "description": "A type annotation.", + "properties": { + "text": { + "type": "string", + "description": "The annotation as a TypeScript type expression, normalised: single-line, union members separated by ` | `.", + "examples": ["string | Buffer | URL"] + }, + "links": { + "type": "array", + "items": { "$ref": "#/definitions/TypeLink" }, + "description": "The resolved names in the text, by offset, non-overlapping." + } + }, + "required": ["text", "links"], + "additionalProperties": false + }, + "Parameter": { + "title": "Parameter", + "type": "object", + "description": "A parameter of a signature or event, or a property of an object parameter.", + "properties": { + "name": { + "type": "string", + "description": "The parameter's name, without any rest marker." + }, + "type": { + "oneOf": [{ "$ref": "#/definitions/Type" }, { "type": "null" }], + "description": "The parameter's type, when documented." + }, + "description": { + "type": "string", + "description": "The parameter's description, as Markdown, without its default value." + }, + "default": { + "type": ["string", "null"], + "description": "The default value as authored, such as `'utf8'` or `false`.", + "examples": ["'utf8'"] + }, + "optional": { + "type": "boolean", + "description": "Whether the parameter may be omitted: bracketed in the signature, or documented with a default." + }, + "rest": { + "type": "boolean", + "description": "Whether the parameter is a rest parameter." + }, + "properties": { + "type": "array", + "items": { "$ref": "#/definitions/Parameter" }, + "description": "The documented properties of an object parameter, or the arguments of a callback." + } + }, + "required": [ + "name", + "type", + "description", + "default", + "optional", + "rest", + "properties" + ], + "additionalProperties": false + }, + "Return": { + "title": "Return", + "type": "object", + "description": "A signature's return value.", + "properties": { + "type": { + "oneOf": [{ "$ref": "#/definitions/Type" }, { "type": "null" }], + "description": "The return type, when documented." + }, + "description": { + "type": "string", + "description": "The return value's description, as Markdown." + } + }, + "required": ["type", "description"], + "additionalProperties": false + }, + "Signature": { + "title": "Signature", + "type": "object", + "description": "A callable's signature: the parameters declared in its heading, described by its typed list.", + "properties": { + "parameters": { + "type": "array", + "items": { "$ref": "#/definitions/Parameter" }, + "description": "The parameters, in declaration order." + }, + "returns": { + "oneOf": [{ "$ref": "#/definitions/Return" }, { "type": "null" }], + "description": "The return value, when documented." + } + }, + "required": ["parameters", "returns"], + "additionalProperties": false + }, + "Entry": { + "title": "Entry", + "type": "object", + "description": "What every heading, the document's own included, carries: its metadata and its body.", + "properties": { + "title": { + "type": "string", + "description": "The heading text as authored, inline Markdown included.", + "examples": ["`fs.readFile(path[, options], callback)`"] + }, + "stability": { + "oneOf": [{ "$ref": "#/definitions/Stability" }, { "type": "null" }], + "description": "The entry's stability index, when declared." + }, + "added": { + "type": "array", + "items": { "$ref": "#/definitions/Version" }, + "description": "The versions the entry was added in, as authored." + }, + "deprecated": { + "type": "array", + "items": { "$ref": "#/definitions/Version" }, + "description": "The versions the entry was deprecated in." + }, + "removed": { + "type": "array", + "items": { "$ref": "#/definitions/Version" }, + "description": "The versions the entry was removed in." + }, + "napiVersion": { + "type": "array", + "items": { "type": "number" }, + "description": "The Node-API versions the entry is available in." + }, + "changes": { + "type": "array", + "items": { "$ref": "#/definitions/Change" }, + "description": "The entry's change history, as authored." + }, + "description": { + "type": "string", + "description": "The entry's body as Markdown: everything under the heading except its metadata, stability index, and the typed list a signature or type was taken from. Empty when the entry has no body." + }, + "summary": { + "type": "string", + "description": "A one-paragraph plain-text summary: the entry's `llm_description` when declared, else its first paragraph." + }, + "examples": { + "type": "array", + "items": { "$ref": "#/definitions/Example" }, + "description": "The fenced code blocks in the body, in order. They remain in the description too." + } + }, + "required": [ + "title", + "stability", + "added", + "deprecated", + "removed", + "napiVersion", + "changes", + "description", + "summary", + "examples" + ] + }, + "NodeBase": { + "title": "NodeBase", + "type": "object", + "description": "What every node below the document root carries, on top of an entry.", + "properties": { + "kind": { + "type": "string", + "enum": [ + "section", + "class", + "constructor", + "method", + "staticMethod", + "property", + "event" + ], + "description": "What the heading documents. Decides which further properties the node has." + }, + "id": { + "type": "string", + "description": "The heading's slug, unique within the document. It is the heading's anchor in HTML output.", + "examples": ["fsreadfilepath-options-callback"] + }, + "name": { + "type": "string", + "description": "The bare identifier the heading documents, or the heading's plain text for a section.", + "examples": ["readFile", "Server", "close"] + }, + "scope": { + "type": "string", + "enum": ["module", "global"], + "description": "Whether the entry is reached through its module, or available globally." + }, + "overloadOf": { + "type": ["string", "null"], + "description": "For the second and later of several sibling headings documenting one callable, the `id` of the first." + }, + "children": { + "type": "array", + "items": { "$ref": "#/definitions/Node" }, + "description": "The headings nested under this one, in document order." + } + }, + "required": ["kind", "id", "name", "scope", "overloadOf", "children"] + }, + "SectionNode": { + "title": "SectionNode", + "description": "A heading that documents no API entry: prose, a deprecation, a command-line option.", + "allOf": [ + { "$ref": "#/definitions/Entry" }, + { "$ref": "#/definitions/NodeBase" }, + { + "type": "object", + "properties": { + "kind": { "const": "section" } + }, + "required": ["kind"] + } + ] + }, + "ClassNode": { + "title": "ClassNode", + "description": "A class. Its constructors, methods, properties and events are its children.", + "allOf": [ + { "$ref": "#/definitions/Entry" }, + { "$ref": "#/definitions/NodeBase" }, + { + "type": "object", + "properties": { + "kind": { "const": "class" }, + "extends": { + "oneOf": [{ "$ref": "#/definitions/Type" }, { "type": "null" }], + "description": "The class the class extends, when documented." + } + }, + "required": ["kind", "extends"] + } + ] + }, + "ConstructorNode": { + "title": "ConstructorNode", + "description": "A class constructor.", + "allOf": [ + { "$ref": "#/definitions/Entry" }, + { "$ref": "#/definitions/NodeBase" }, + { + "type": "object", + "properties": { + "kind": { "const": "constructor" }, + "signature": { "$ref": "#/definitions/Signature" } + }, + "required": ["kind", "signature"] + } + ] + }, + "MethodNode": { + "title": "MethodNode", + "description": "A function or method.", + "allOf": [ + { "$ref": "#/definitions/Entry" }, + { "$ref": "#/definitions/NodeBase" }, + { + "type": "object", + "properties": { + "kind": { "const": "method" }, + "signature": { "$ref": "#/definitions/Signature" } + }, + "required": ["kind", "signature"] + } + ] + }, + "StaticMethodNode": { + "title": "StaticMethodNode", + "description": "A static method of a class.", + "allOf": [ + { "$ref": "#/definitions/Entry" }, + { "$ref": "#/definitions/NodeBase" }, + { + "type": "object", + "properties": { + "kind": { "const": "staticMethod" }, + "signature": { "$ref": "#/definitions/Signature" } + }, + "required": ["kind", "signature"] + } + ] + }, + "PropertyNode": { + "title": "PropertyNode", + "description": "A property of a module, class or object.", + "allOf": [ + { "$ref": "#/definitions/Entry" }, + { "$ref": "#/definitions/NodeBase" }, + { + "type": "object", + "properties": { + "kind": { "const": "property" }, + "type": { + "oneOf": [{ "$ref": "#/definitions/Type" }, { "type": "null" }], + "description": "The property's type, when documented." + }, + "default": { + "type": ["string", "null"], + "description": "The property's default value as authored, when documented." + } + }, + "required": ["kind", "type", "default"] + } + ] + }, + "EventNode": { + "title": "EventNode", + "description": "An event emitted by the parent class or module.", + "allOf": [ + { "$ref": "#/definitions/Entry" }, + { "$ref": "#/definitions/NodeBase" }, + { + "type": "object", + "properties": { + "kind": { "const": "event" }, + "parameters": { + "type": "array", + "items": { "$ref": "#/definitions/Parameter" }, + "description": "The arguments passed to the event's listeners." + } + }, + "required": ["kind", "parameters"] + } + ] + }, + "Node": { + "title": "Node", + "description": "A heading below the document root.", + "oneOf": [ + { "$ref": "#/definitions/SectionNode" }, + { "$ref": "#/definitions/ClassNode" }, + { "$ref": "#/definitions/ConstructorNode" }, + { "$ref": "#/definitions/MethodNode" }, + { "$ref": "#/definitions/StaticMethodNode" }, + { "$ref": "#/definitions/PropertyNode" }, + { "$ref": "#/definitions/EventNode" } + ] + } + } +} diff --git a/packages/core/src/generators/json/types.d.ts b/packages/core/src/generators/json/types.d.ts new file mode 100644 index 000000000..54ccd28a9 --- /dev/null +++ b/packages/core/src/generators/json/types.d.ts @@ -0,0 +1,29 @@ +import type { MetadataEntry } from '../metadata/types'; +import type { Document } from './generated/schema'; + +export type * from './generated/schema'; + +/** + * What a worker needs, besides the entries, to build a document. + */ +export interface Dependencies { + /** The resolved `$schema` URL every document carries */ + schemaURL: string; + /** The base URL source links resolve against, or `null` without a repository */ + sourceURL: string | null; +} + +export interface Configuration { + /** Where the schema is published */ + schemaURL: string; +} + +export type Generator = GeneratorMetadata< + Configuration, + Generate, AsyncGenerator>>, + ProcessChunk< + { head: MetadataEntry; entries: Array }, + Array, + Dependencies + > +>; diff --git a/packages/core/src/generators/json/utils/__tests__/entry.test.mjs b/packages/core/src/generators/json/utils/__tests__/entry.test.mjs new file mode 100644 index 000000000..1e4d26a81 --- /dev/null +++ b/packages/core/src/generators/json/utils/__tests__/entry.test.mjs @@ -0,0 +1,170 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { u } from 'unist-builder'; + +import { buildEntry, entryBody, takeTypedItems } from '../entry.mjs'; + +const code = value => u('inlineCode', value); +const text = value => u('text', value); +const type = value => u('typeAnnotation', { value }); +const item = children => u('listItem', [u('paragraph', children)]); +const paragraph = value => u('paragraph', [text(value)]); + +const typedList = (...items) => u('list', items); + +describe('takeTypedItems', () => { + const list = typedList( + item([code('name'), text(' '), type('string')]), + item([code('options'), text(' '), type('Object')]), + item([text('A prose bullet that shares the list.')]) + ); + + it('lifts the leading typed items out, leaving the rest as prose', () => { + const { body, items } = takeTypedItems( + [list, paragraph('After.')], + 'method' + ); + + assert.equal(items.length, 2); + assert.deepEqual(body, [ + { ...list, children: [list.children[2]] }, + paragraph('After.'), + ]); + }); + + it('removes a fully typed list from the body', () => { + const fullyTyped = typedList(...list.children.slice(0, 2)); + + const { body, items } = takeTypedItems( + [paragraph('Before.'), fullyTyped], + 'event' + ); + + assert.equal(items.length, 2); + assert.deepEqual(body, [paragraph('Before.')]); + }); + + it('leaves the list of a section alone', () => { + const body = [list]; + + assert.deepEqual(takeTypedItems(body, 'section'), { body, items: [] }); + }); + + it('only lifts a class list that has an Extends item', () => { + const body = [list]; + + assert.deepEqual(takeTypedItems(body, 'class'), { body, items: [] }); + + const extendsList = typedList( + item([text('Extends: '), type('EventEmitter')]) + ); + + assert.deepEqual(takeTypedItems([extendsList], 'class'), { + body: [], + items: extendsList.children, + }); + }); + + it('does nothing without a typed list', () => { + const body = [paragraph('Only prose.')]; + + assert.deepEqual(takeTypedItems(body, 'method'), { body, items: [] }); + }); +}); + +describe('entryBody', () => { + it('drops the heading and the stability index', () => { + const heading = u('heading', { depth: 2 }, [text('Title')]); + const stability = u( + 'blockquote', + { data: { index: '1', description: 'Experimental' } }, + [paragraph('Stability: 1 - Experimental')] + ); + const prose = paragraph('Body.'); + + assert.deepEqual( + entryBody({ + heading, + stability, + content: u('root', [heading, stability, prose]), + }), + [prose] + ); + }); + + it('keeps a stability blockquote the entry does not own', () => { + const heading = u('heading', { depth: 2 }, [text('Title')]); + const example = u( + 'blockquote', + { data: { index: '2', description: 'Stable' } }, + [paragraph('Stability: 2 - Stable')] + ); + + assert.deepEqual( + entryBody({ heading, content: u('root', [heading, example]) }), + [example] + ); + }); +}); + +describe('buildEntry', () => { + it('normalises the metadata and renders the body', () => { + const heading = u('heading', { depth: 2, data: { text: '`foo()`' } }, [ + code('foo()'), + ]); + const body = [ + paragraph('Does a thing.'), + u('code', { lang: 'js', meta: 'displayName="Usage"' }, 'foo();'), + ]; + + const entry = buildEntry( + { + heading, + content: u('root', [heading, ...body]), + added: 'v1.0.0', + napiVersion: 3, + changes: [{ version: ['v2.0.0', 'v1.5.0'], description: 'Changed.' }], + }, + body + ); + + assert.deepEqual(entry, { + title: '`foo()`', + stability: null, + added: ['v1.0.0'], + deprecated: [], + removed: [], + napiVersion: [3], + changes: [ + { + versions: ['v2.0.0', 'v1.5.0'], + prUrl: null, + commit: null, + description: 'Changed.', + }, + ], + description: 'Does a thing.\n\n```js displayName="Usage"\nfoo();\n```', + summary: 'Does a thing.', + examples: [{ language: 'js', displayName: 'Usage', code: 'foo();' }], + }); + }); + + it('prefers the llm_description as the summary', () => { + const heading = u('heading', { depth: 2, data: { text: 'Foo' } }, [ + text('Foo'), + ]); + const body = [paragraph('The first paragraph.')]; + + const { summary } = buildEntry( + { + heading, + content: u('root', [heading, ...body]), + llm_description: 'For machines.', + }, + body + ); + + assert.equal(summary, 'For machines.'); + }); +}); diff --git a/packages/core/src/generators/json/utils/__tests__/lifecycle.test.mjs b/packages/core/src/generators/json/utils/__tests__/lifecycle.test.mjs new file mode 100644 index 000000000..1a5509910 --- /dev/null +++ b/packages/core/src/generators/json/utils/__tests__/lifecycle.test.mjs @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { u } from 'unist-builder'; + +import { + toChanges, + toNumbers, + toStability, + toVersions, +} from '../lifecycle.mjs'; + +describe('toVersions', () => { + it('always yields an array of strings', () => { + assert.deepEqual(toVersions(undefined), []); + assert.deepEqual(toVersions(null), []); + assert.deepEqual(toVersions('v1.0.0'), ['v1.0.0']); + assert.deepEqual(toVersions(['v2.0.0', 'v1.5.0']), ['v2.0.0', 'v1.5.0']); + assert.deepEqual(toVersions('REPLACEME'), ['REPLACEME']); + }); +}); + +describe('toNumbers', () => { + it('always yields an array of numbers', () => { + assert.deepEqual(toNumbers(undefined), []); + assert.deepEqual(toNumbers(3), [3]); + assert.deepEqual(toNumbers(['1', 2]), [1, 2]); + }); +}); + +describe('toChanges', () => { + it('normalises every record', () => { + assert.deepEqual( + toChanges([ + { + version: 'v1.0.0', + 'pr-url': 'https://github.com/example/pull/1', + description: ' Trimmed. ', + }, + { + version: ['v0.2.0', 'v0.1.0'], + commit: 'abc123', + description: 'Old.', + }, + ]), + [ + { + versions: ['v1.0.0'], + prUrl: 'https://github.com/example/pull/1', + commit: null, + description: 'Trimmed.', + }, + { + versions: ['v0.2.0', 'v0.1.0'], + prUrl: null, + commit: 'abc123', + description: 'Old.', + }, + ] + ); + }); + + it('is empty without changes', () => { + assert.deepEqual(toChanges(undefined), []); + }); +}); + +describe('toStability', () => { + const stability = (children, index = '1.1', description = '') => + u('blockquote', { data: { index, description } }, [ + u('paragraph', children), + ]); + + it('is null without a stability index', () => { + assert.equal(toStability(undefined), null); + }); + + it('keeps the sub-level and renders the description after the prefix', () => { + const node = stability([ + u('link', { url: 'documentation.html#stability-index' }, [ + u('text', 'Stability: 1.1'), + ]), + u('text', ' - Active development. Use '), + u('inlineCode', 'other()'), + u('text', ' instead.'), + ]); + + assert.deepEqual(toStability(node), { + index: '1.1', + level: 1, + description: 'Active development. Use `other()` instead.', + }); + }); + + it('handles an unlinked prefix and a missing description', () => { + assert.deepEqual( + toStability(stability([u('text', 'Stability: 2 - Stable')], '2')), + { index: '2', level: 2, description: 'Stable' } + ); + + assert.deepEqual( + toStability(stability([u('text', 'Stability: 0')], '0', 'fallback')), + { index: '0', level: 0, description: 'fallback' } + ); + }); +}); diff --git a/packages/core/src/generators/json/utils/__tests__/markdown.test.mjs b/packages/core/src/generators/json/utils/__tests__/markdown.test.mjs new file mode 100644 index 000000000..0ce56d8fb --- /dev/null +++ b/packages/core/src/generators/json/utils/__tests__/markdown.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { u } from 'unist-builder'; + +import { + blocksToMarkdown, + extractExamples, + inlineToMarkdown, +} from '../markdown.mjs'; + +describe('blocksToMarkdown', () => { + it('serialises blocks, type annotations included', () => { + const nodes = [ + u('paragraph', [ + u('text', 'Returns a '), + u('typeAnnotation', { value: 'Promise' }), + u('text', '.'), + ]), + u('code', { lang: 'js' }, 'foo();'), + ]; + + assert.equal( + blocksToMarkdown(nodes), + 'Returns a {Promise}.\n\n```js\nfoo();\n```' + ); + }); + + it('is empty without nodes', () => { + assert.equal(blocksToMarkdown([]), ''); + assert.equal(inlineToMarkdown([]), ''); + }); +}); + +describe('inlineToMarkdown', () => { + it('serialises phrasing content', () => { + assert.equal( + inlineToMarkdown([ + u('text', 'See '), + u('link', { url: '#foo' }, [u('inlineCode', 'foo()')]), + u('text', '.'), + ]), + 'See [`foo()`](#foo).' + ); + }); +}); + +describe('extractExamples', () => { + it('collects fenced code blocks in order, with their info string', () => { + const nodes = [ + u('paragraph', [u('text', 'Text')]), + u('code', { lang: 'mjs', meta: 'displayName="ESM"' }, 'import x;'), + u('blockquote', [u('code', { lang: null, meta: null }, 'plain')]), + ]; + + assert.deepEqual(extractExamples(nodes), [ + { language: 'mjs', displayName: 'ESM', code: 'import x;' }, + { language: null, displayName: null, code: 'plain' }, + ]); + }); +}); diff --git a/packages/core/src/generators/json/utils/__tests__/node.test.mjs b/packages/core/src/generators/json/utils/__tests__/node.test.mjs new file mode 100644 index 000000000..fedff6996 --- /dev/null +++ b/packages/core/src/generators/json/utils/__tests__/node.test.mjs @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { u } from 'unist-builder'; + +import { classify, nodeName } from '../node.mjs'; + +const entry = (text, type, extra = {}) => ({ + heading: { + type: 'heading', + depth: 3, + children: [u('inlineCode', text.replace(/`/g, ''))], + data: { text, name: text, type }, + }, + ...extra, +}); + +describe('classify', () => { + it('maps the heading classification to a kind', () => { + assert.deepEqual(classify(entry('`foo()`', 'method')), { kind: 'method' }); + assert.deepEqual(classify(entry('`new Foo()`', 'ctor')), { + kind: 'constructor', + }); + assert.deepEqual(classify(entry('`Foo.bar()`', 'classMethod')), { + kind: 'staticMethod', + }); + assert.deepEqual(classify(entry('Notes', undefined)), { kind: 'section' }); + assert.deepEqual(classify(entry('Notes', 'misc')), { kind: 'section' }); + assert.deepEqual(classify(entry('Example', 'example')), { + kind: 'section', + }); + }); + + it('turns a global override into a scope, re-reading the kind from the heading', () => { + assert.deepEqual(classify(entry('`globalThis.foo()`', 'global')), { + kind: 'method', + scope: 'global', + }); + }); +}); + +describe('nodeName', () => { + it('prefers a name directive', () => { + assert.equal( + nodeName( + entry('Signal events', 'event', { name: 'SIGINT, SIGHUP, etc.' }), + 'event' + ), + 'SIGINT, SIGHUP, etc.' + ); + }); + + it('uses the plain heading text for a section', () => { + const section = { + heading: { + children: [ + u('text', 'DEP0005: '), + u('inlineCode', 'Buffer()'), + u('text', ' constructor'), + ], + data: { + text: 'DEP0005: `Buffer()` constructor', + name: 'DEP0005: `Buffer()` constructor', + }, + }, + }; + + assert.equal(nodeName(section, 'section'), 'DEP0005: Buffer() constructor'); + }); + + it('strips the qualifier and extends clause from class names', () => { + const named = name => ({ + heading: { children: [], data: { text: name, name } }, + }); + + assert.equal(nodeName(named('http.Server'), 'class'), 'Server'); + assert.equal(nodeName(named('Foo extends Bar'), 'class'), 'Foo'); + assert.equal(nodeName(named('buffer.Blob'), 'constructor'), 'Blob'); + }); + + it('keeps the bare name of everything else', () => { + const named = name => ({ + heading: { children: [], data: { text: name, name } }, + }); + + assert.equal(nodeName(named('readFile'), 'method'), 'readFile'); + assert.equal(nodeName(named('close'), 'event'), 'close'); + }); +}); diff --git a/packages/core/src/generators/json/utils/__tests__/signature.test.mjs b/packages/core/src/generators/json/utils/__tests__/signature.test.mjs new file mode 100644 index 000000000..f03e38ecb --- /dev/null +++ b/packages/core/src/generators/json/utils/__tests__/signature.test.mjs @@ -0,0 +1,219 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { u } from 'unist-builder'; + +import { + buildEventParameters, + buildExtends, + buildPropertyType, + buildSignature, +} from '../signature.mjs'; + +const code = value => u('inlineCode', value); +const text = value => u('text', value); +const type = (value, links = []) => + u('typeAnnotation', { value, data: { links } }); +const strong = value => u('strong', [text(value)]); +const item = (children, ...blocks) => + u('listItem', [u('paragraph', children), ...blocks]); +const heading = (textValue, name = textValue, type) => ({ + depth: 3, + data: { text: textValue, name, type }, +}); + +describe('buildSignature', () => { + it('describes the parameters the heading declares with the list', () => { + const items = [ + item([code('path'), text(' '), type('string | URL'), text(' The file.')]), + item( + [code('options'), text(' '), type('Object')], + u('list', [ + item([ + code('encoding'), + text(' '), + type('string'), + text(' The encoding. '), + strong('Default:'), + text(' '), + code("'utf8'"), + text('.'), + ]), + ]) + ), + item([ + text('Returns: '), + type('Promise'), + text(' Fulfills with the file.'), + ]), + ]; + + const signature = buildSignature( + heading('`fs.readFile(path[, options], callback)`'), + items + ); + + assert.deepEqual( + signature.parameters.map(({ name, optional, rest }) => [ + name, + optional, + rest, + ]), + [ + ['path', false, false], + ['options', true, false], + ['callback', false, false], + ] + ); + + const [path, options, callback] = signature.parameters; + + assert.deepEqual(path.type, { text: 'string | URL', links: [] }); + assert.equal(path.description, 'The file.'); + assert.equal(path.default, null); + + assert.deepEqual(options.properties, [ + { + name: 'encoding', + type: { text: 'string', links: [] }, + description: 'The encoding.', + default: "'utf8'", + optional: true, + rest: false, + properties: [], + }, + ]); + + // Declared in the heading, absent from the list + assert.deepEqual(callback, { + name: 'callback', + type: null, + description: '', + default: null, + optional: false, + rest: false, + properties: [], + }); + + assert.deepEqual(signature.returns, { + type: { text: 'Promise', links: [] }, + description: 'Fulfills with the file.', + }); + }); + + it('carries the type links through', () => { + const links = [ + { text: 'Buffer', href: 'buffer.html#class-buffer', start: 0, end: 6 }, + ]; + const items = [item([code('buf'), text(' '), type('Buffer', links)])]; + + const { parameters } = buildSignature(heading('`fill(buf)`'), items); + + assert.deepEqual(parameters[0].type.links, [ + { name: 'Buffer', href: 'buffer.html#class-buffer', start: 0, end: 6 }, + ]); + }); + + it('marks rest parameters', () => { + const items = [item([code('...paths'), text(' '), type('string')])]; + + const { parameters } = buildSignature( + heading('`path.join([...paths])`'), + items + ); + + assert.deepEqual(parameters, [ + { + name: 'paths', + type: { text: 'string', links: [] }, + description: '', + default: null, + optional: true, + rest: true, + properties: [], + }, + ]); + }); + + it('has no parameters and no return value without a list or parentheses', () => { + assert.deepEqual(buildSignature(heading('`emitter.close()`'), []), { + parameters: [], + returns: null, + }); + }); +}); + +describe('buildExtends', () => { + it('takes the Extends item', () => { + const items = [item([text('Extends: '), type('EventEmitter')])]; + + assert.deepEqual( + buildExtends(heading('Class: `net.Server`', 'net.Server'), items), + { + text: 'EventEmitter', + links: [], + } + ); + }); + + it("falls back to the heading's extends clause", () => { + assert.deepEqual( + buildExtends(heading('Class: `Foo extends Bar`', 'Foo extends Bar'), []), + { text: 'Bar', links: [] } + ); + }); + + it('is null without either', () => { + assert.equal(buildExtends(heading('Class: `Foo`', 'Foo'), []), null); + }); +}); + +describe('buildPropertyType', () => { + it('takes the type, default and description of the first item', () => { + const items = [ + item([ + text('Type: '), + type('number'), + text(' The size. '), + strong('Default:'), + text(' '), + code('1'), + ]), + ]; + + assert.deepEqual(buildPropertyType(items), { + type: { text: 'number', links: [] }, + default: '1', + description: 'The size.', + }); + }); + + it('is empty without items', () => { + assert.deepEqual(buildPropertyType([]), { + type: null, + default: null, + description: '', + }); + }); +}); + +describe('buildEventParameters', () => { + it('describes every item', () => { + const items = [ + item([code('code'), text(' '), type('number'), text(' The exit code.')]), + item([code('signal'), text(' '), type('string | null')]), + ]; + + assert.deepEqual( + buildEventParameters(items).map(({ name, type, description }) => [ + name, + type.text, + description, + ]), + [ + ['code', 'number', 'The exit code.'], + ['signal', 'string | null', ''], + ] + ); + }); +}); diff --git a/packages/core/src/generators/json/utils/document.mjs b/packages/core/src/generators/json/utils/document.mjs new file mode 100644 index 000000000..b9031f5f3 --- /dev/null +++ b/packages/core/src/generators/json/utils/document.mjs @@ -0,0 +1,58 @@ +'use strict'; + +import { buildHierarchy } from '#utils/hierarchy.mjs'; +import { annotateOverloads } from '#utils/overloads.mjs'; + +import { DOCUMENT_TYPES } from '../constants.mjs'; +import { buildEntry, entryBody } from './entry.mjs'; +import { buildNode } from './node.mjs'; + +/** + * Builds a document from a module's entries. + * + * @param {import('../../metadata/types').MetadataEntry} head The module's title entry + * @param {Array} entries All of the module's entries, in document order + * @param {import('../types').Dependencies} dependencies + * @returns {import('../types').Document} + */ +export const buildDocument = (head, entries, { schemaURL, sourceURL }) => { + annotateOverloads(entries); + + const type = DOCUMENT_TYPES.has(head.type) ? head.type : 'module'; + const scope = type === 'global' ? 'global' : 'module'; + + // The title entry is the root; any other root (a second title, an entry + // with no shallower heading before it) is treated as a child of it + const roots = buildHierarchy(entries); + const root = roots.find(node => node.entry === head) ?? roots[0]; + const children = [ + ...(root?.children ?? []), + ...roots.filter(node => node !== root), + ].map(node => buildNode(node, scope)); + + const { title, ...entry } = buildEntry(head, entryBody(head)); + + return { + $schema: schemaURL, + id: head.api, + path: head.path, + type, + module: + typeof head.name === 'string' + ? head.name + : type === 'module' + ? head.api + : null, + title, + introducedIn: + head.introduced_in == null ? null : String(head.introduced_in), + sourceLink: head.source_link + ? { + path: String(head.source_link), + url: sourceURL ? `${sourceURL}${head.source_link}` : null, + } + : null, + ...entry, + children, + }; +}; diff --git a/packages/core/src/generators/json/utils/entry.mjs b/packages/core/src/generators/json/utils/entry.mjs new file mode 100644 index 000000000..468fd07f3 --- /dev/null +++ b/packages/core/src/generators/json/utils/entry.mjs @@ -0,0 +1,87 @@ +'use strict'; + +import { getEntryDescription } from '#utils/generators.mjs'; +import { splitTypedItems, UNIST } from '#utils/queries/index.mjs'; +import { extractListItem } from '#utils/signature/extractListItem.mjs'; + +import { KINDS_WITH_TYPED_LIST } from '../constants.mjs'; +import { toChanges, toNumbers, toStability, toVersions } from './lifecycle.mjs'; +import { blocksToMarkdown, extractExamples } from './markdown.mjs'; + +/** + * Whether a node is a stability index the metadata generator recognised. + * + * @param {import('mdast').RootContent} node + */ +const isStabilityIndex = node => + node.type === 'blockquote' && node.data?.index !== undefined; + +/** + * An entry's body: its content without its heading and, when the entry + * carries one, its stability index. + * + * @param {import('../../metadata/types').MetadataEntry} entry + * @returns {Array} + */ +export const entryBody = entry => + entry.content.children.filter( + node => + !UNIST.isHeading(node) && !(entry.stability && isStabilityIndex(node)) + ); + +/** + * Lifts the leading typed items of the body's first typed list out of the + * body, for the kinds that turn them into data. + * + * @param {Array} body + * @param {string} kind The node's kind + * @returns {{ body: Array, items: Array }} + */ +export const takeTypedItems = (body, kind) => { + const index = KINDS_WITH_TYPED_LIST.has(kind) + ? body.findIndex(UNIST.isStronglyTypedList) + : -1; + + if (index === -1) { + return { body, items: [] }; + } + + const list = body[index]; + const { typed: items, rest } = splitTypedItems(list); + + if ( + kind === 'class' && + !items.some(item => extractListItem(item).prefix === 'Extends') + ) { + return { body, items: [] }; + } + + return { + body: body.toSpliced( + index, + 1, + ...(rest.length ? [{ ...list, children: rest }] : []) + ), + items, + }; +}; + +/** + * Builds an entry's metadata and body. + * + * @param {import('../../metadata/types').MetadataEntry} entry + * @param {Array} body The entry's body, minus anything lifted out of it + * @returns {import('../types').Entry} + */ +export const buildEntry = (entry, body) => ({ + title: entry.heading.data.text, + stability: toStability(entry.stability, entry.mdx), + added: toVersions(entry.added), + deprecated: toVersions(entry.deprecated), + removed: toVersions(entry.removed), + napiVersion: toNumbers(entry.napiVersion), + changes: toChanges(entry.changes), + description: blocksToMarkdown(body, entry.mdx), + summary: getEntryDescription(entry), + examples: extractExamples(body), +}); diff --git a/packages/core/src/generators/json/utils/lifecycle.mjs b/packages/core/src/generators/json/utils/lifecycle.mjs new file mode 100644 index 000000000..7b99e196c --- /dev/null +++ b/packages/core/src/generators/json/utils/lifecycle.mjs @@ -0,0 +1,61 @@ +'use strict'; + +import { enforceArray } from '#utils/array.mjs'; +import { removeStabilityPrefix } from '#utils/stability.mjs'; + +import { blocksToMarkdown } from './markdown.mjs'; + +/** + * Normalises a YAML version field + * + * @param {unknown} value + * @returns {Array} + */ +export const toVersions = value => + value == null ? [] : enforceArray(value).map(String); + +/** + * Normalises a YAML number field + * + * @param {unknown} value + * @returns {Array} + */ +export const toNumbers = value => + value == null ? [] : enforceArray(value).map(Number); + +/** + * Normalises a YAML `changes` list + * + * @param {unknown} changes + * @returns {Array} + */ +export const toChanges = changes => + enforceArray(changes ?? []).map(change => ({ + versions: toVersions(change.version), + prUrl: change['pr-url'] == null ? null : String(change['pr-url']), + commit: change.commit == null ? null : String(change.commit), + description: String(change.description ?? '').trim(), + })); + +/** + * Builds an entry's stability index + * + * @param {import('../../metadata/types').StabilityNode | undefined} node + * @param {boolean} [mdx] Whether the node comes from an MDX document + * @returns {import('../types').Stability | null} + */ +export const toStability = (node, mdx) => { + if (!node) { + return null; + } + + const { index, description } = node.data; + const body = removeStabilityPrefix(node); + + return { + index: String(index), + description: body?.children.length + ? blocksToMarkdown(body.children, mdx) + : description, + }; +}; diff --git a/packages/core/src/generators/json/utils/markdown.mjs b/packages/core/src/generators/json/utils/markdown.mjs new file mode 100644 index 000000000..5829fd839 --- /dev/null +++ b/packages/core/src/generators/json/utils/markdown.mjs @@ -0,0 +1,57 @@ +'use strict'; + +import { u as createTree } from 'unist-builder'; +import { visit } from 'unist-util-visit'; + +import { getRemark, getRemarkMdx } from '#utils/remark.mjs'; + +import { DISPLAY_NAME } from '../constants.mjs'; + +/** + * Serialises block nodes back to Markdown. + * + * @param {Array} nodes + * @param {boolean} [mdx] Whether the nodes come from an MDX document + * @returns {string} + */ +export const blocksToMarkdown = (nodes, mdx = false) => { + if (nodes.length === 0) { + return ''; + } + + const processor = mdx ? getRemarkMdx() : getRemark(); + + return processor.stringify(createTree('root', nodes)).trim(); +}; + +/** + * Serialises phrasing (inline) nodes back to Markdown. + * + * @param {Array} nodes + * @param {boolean} [mdx] Whether the nodes come from an MDX document + * @returns {string} + */ +export const inlineToMarkdown = (nodes, mdx) => + nodes.length === 0 + ? '' + : blocksToMarkdown([createTree('paragraph', nodes)], mdx); + +/** + * Collects the fenced code blocks of a body, in order. + * + * @param {Array} nodes + * @returns {Array} + */ +export const extractExamples = nodes => { + const examples = []; + + visit(createTree('root', nodes), 'code', node => { + examples.push({ + language: node.lang ?? null, + displayName: DISPLAY_NAME.exec(node.meta ?? '')?.[1] ?? null, + code: node.value, + }); + }); + + return examples; +}; diff --git a/packages/core/src/generators/json/utils/node.mjs b/packages/core/src/generators/json/utils/node.mjs new file mode 100644 index 000000000..f9a54397d --- /dev/null +++ b/packages/core/src/generators/json/utils/node.mjs @@ -0,0 +1,153 @@ +'use strict'; + +import { toString } from 'mdast-util-to-string'; + +import { transformNodeToHeading } from '#generators/metadata/utils/transformers.mjs'; + +import { + CALLABLE_KINDS, + EXTENDS_CLAUSE, + KINDS, + SECTION_KIND, +} from '../constants.mjs'; +import { buildEntry, entryBody, takeTypedItems } from './entry.mjs'; +import { + buildEventParameters, + buildExtends, + buildPropertyType, + buildSignature, +} from './signature.mjs'; + +/** + * Classifies an entry: the kind its heading documents and, when a `global` + * override replaced that classification, the scope it sets instead. + * + * @param {import('../../metadata/types').MetadataEntry} entry + * @returns {{ kind: string, scope?: 'global' }} + */ +export const classify = entry => { + const { type } = entry.heading.data; + + if (type === 'global') { + const { type: structural } = transformNodeToHeading(entry.heading); + + return { kind: KINDS[structural] ?? SECTION_KIND, scope: 'global' }; + } + + return { kind: KINDS[type] ?? SECTION_KIND }; +}; + +/** + * The bare identifier an entry documents + * + * @param {import('../../metadata/types').MetadataEntry} entry + * @param {string} kind The entry's kind + * @returns {string} + */ +export const nodeName = (entry, kind) => { + if (typeof entry.name === 'string') { + return entry.name; + } + + const { name } = entry.heading.data; + + switch (kind) { + case SECTION_KIND: + return toString(entry.heading.children).trim(); + case 'class': + case 'constructor': + // `http.Server`, `buffer.Blob`, `Foo extends Bar` + return name.split(EXTENDS_CLAUSE)[0].split('.').at(-1); + default: + return name; + } +}; + +/** + * The properties a node has on top of its entry, by kind. + * + * @param {string} kind + * @param {import('../../metadata/types').MetadataEntry} entry + * @param {Array} items The entry's typed list items + * @returns {{ properties: object, description?: string }} + */ +const kindProperties = (kind, entry, items) => { + if (CALLABLE_KINDS.has(kind)) { + return { + properties: { + signature: buildSignature(entry.heading, items, entry.mdx), + }, + }; + } + + switch (kind) { + case 'class': + return { properties: { extends: buildExtends(entry.heading, items) } }; + case 'property': { + const { + type, + default: value, + description, + } = buildPropertyType(items, entry.mdx); + + return { properties: { type, default: value }, description }; + } + case 'event': + return { + properties: { parameters: buildEventParameters(items, entry.mdx) }, + }; + default: + return { properties: {} }; + } +}; + +/** + * Builds a node and its subtree from a hierarchized entry. + * + * @param {import('#utils/hierarchy.mjs').HierarchizedEntry} node + * @param {'module' | 'global'} scope The document's scope + * @returns {import('../types').Node} + */ +export const buildNode = ({ entry, children }, scope) => { + const { kind, scope: entryScope = scope } = classify(entry); + const { body, items } = takeTypedItems(entryBody(entry), kind); + const { properties, description: typeDescription } = kindProperties( + kind, + entry, + items + ); + + const { + title, + stability, + added, + deprecated, + removed, + napiVersion, + changes, + description, + summary, + examples, + } = buildEntry(entry, body); + + return { + kind, + id: entry.heading.data.slug, + name: nodeName(entry, kind), + title, + scope: entryScope, + overloadOf: entry.heading.data.overloadOf ?? null, + stability, + added, + deprecated, + removed, + napiVersion, + changes, + ...properties, + // A property's type item may describe it; that comes first + description: [typeDescription, description].filter(Boolean).join('\n\n'), + summary, + examples, + children: children.map(child => buildNode(child, scope)), + }; +}; diff --git a/packages/core/src/generators/json/utils/schema.mjs b/packages/core/src/generators/json/utils/schema.mjs new file mode 100644 index 000000000..00528a16a --- /dev/null +++ b/packages/core/src/generators/json/utils/schema.mjs @@ -0,0 +1,15 @@ +'use strict'; + +import { populate } from '#utils/configuration/templates.mjs'; + +import { SCHEMA_VERSION } from '../constants.mjs'; + +/** + * The `$schema` URL a generator's output carries: its `schemaURL` option with + * the schema version filled in. + * + * @param {{ schemaURL: string }} config The generator's configuration + * @returns {string} + */ +export const resolveSchemaURL = config => + populate(config.schemaURL, { ...config, schemaVersion: SCHEMA_VERSION }); diff --git a/packages/core/src/generators/json/utils/signature.mjs b/packages/core/src/generators/json/utils/signature.mjs new file mode 100644 index 000000000..b60177ded --- /dev/null +++ b/packages/core/src/generators/json/utils/signature.mjs @@ -0,0 +1,185 @@ +'use strict'; + +import { u as createTree } from 'unist-builder'; + +import { + extractListItem, + removeDefault, +} from '#utils/signature/extractListItem.mjs'; +import parseSignature from '#utils/signature/parseSignature.mjs'; + +import { + CODE_SPAN, + DECLARED_DEFAULT, + EXTENDS_CLAUSE, + REST_MARKER, +} from '../constants.mjs'; +import { blocksToMarkdown } from './markdown.mjs'; +import { plainType, toType } from './types.mjs'; + +// What a parameter declared in a heading but absent from the typed list has +const EMPTY_ITEM = { + name: undefined, + prefix: undefined, + annotation: undefined, + text: [], + blocks: [], + default: undefined, + items: [], +}; + +/** + * The Markdown of a list item's description + * + * @param {import('#utils/signature/extractListItem.mjs').ExtractedListItem} item + * @param {boolean} [mdx] Whether the item comes from an MDX document + * @returns {string} + */ +export const itemDescription = ({ text, blocks }, mdx) => { + const description = removeDefault(text); + + return blocksToMarkdown( + [ + ...(description.length ? [createTree('paragraph', description)] : []), + ...blocks, + ], + mdx + ); +}; + +/** + * A default value as authored, without the code span around it. + * + * @param {string | undefined} value + * @returns {string | null} + */ +const toDefault = value => + value === undefined + ? null + : value.replace(DECLARED_DEFAULT, '').replace(CODE_SPAN, '$1'); + +/** + * Builds a parameter from its list item and, for a callable, its declaration + * in the heading. + * + * @param {import('#utils/signature/extractListItem.mjs').ExtractedListItem} item + * @param {import('#utils/signature/types').Parameter} [declared] The heading's declaration + * @param {boolean} [mdx] Whether the item comes from an MDX document + * @returns {import('../types').Parameter} + */ +export const toParameter = (item, declared = {}, mdx) => { + const name = declared.name ?? item.name ?? ''; + const value = item.default ?? declared.default; + + return { + name: name.replace(REST_MARKER, ''), + type: toType(item.annotation), + description: itemDescription(item, mdx), + default: toDefault(value), + optional: Boolean(declared.optional) || value !== undefined, + rest: REST_MARKER.test(name), + properties: item.items.map(nested => + toParameter(extractListItem(nested), undefined, mdx) + ), + }; +}; + +/** + * Wraps an extracted item the way `parseSignature` matches parameters + * (by name, with the nested items as `options`) + * + * @param {import('#utils/signature/extractListItem.mjs').ExtractedListItem} item + */ +const toMarkdownParameter = item => ({ + name: item.prefix + ? item.prefix === 'Returns' + ? 'return' + : item.prefix.toLowerCase() + : item.name, + options: item.items.map(nested => + toMarkdownParameter(extractListItem(nested)) + ), + item, +}); + +/** + * Builds a return value from its `Returns:` item. + * + * @param {import('#utils/signature/extractListItem.mjs').ExtractedListItem} item + * @param {boolean} [mdx] Whether the item comes from an MDX document + * @returns {import('../types').Return} + */ +const toReturn = (item, mdx) => ({ + type: toType(item.annotation), + description: itemDescription(item, mdx), +}); + +/** + * Builds the parameters a callable heading declares + * + * @param {import('../../metadata/types').HeadingNode} heading + * @param {Array} items The typed list's items + * @param {boolean} [mdx] Whether the items come from an MDX document + * @returns {import('../types').Signature} + */ +export const buildSignature = (heading, items, mdx) => { + const signature = parseSignature( + heading.data.text, + items.map(item => toMarkdownParameter(extractListItem(item))) + ); + + return { + parameters: signature.params.map(declared => + toParameter(declared.item ?? EMPTY_ITEM, declared, mdx) + ), + returns: signature.return ? toReturn(signature.return.item, mdx) : null, + }; +}; + +/** + * @param {import('../../metadata/types').HeadingNode} heading + * @param {Array} items The typed list's items + * @returns {import('../types').Type | null} + */ +export const buildExtends = (heading, items) => { + const item = items + .map(extractListItem) + .find(({ prefix }) => prefix === 'Extends'); + + if (item) { + return toType(item.annotation); + } + + const [, base] = heading.data.name.split(EXTENDS_CLAUSE); + + return base ? plainType(base) : null; +}; + +/** + * A property's basic metadata + * + * @param {Array} items The typed list's items + * @param {boolean} [mdx] Whether the items come from an MDX document + * @returns {{ type: import('../types').Type | null, default: string | null, description: string }} + */ +export const buildPropertyType = (items, mdx) => { + const [item] = items.map(extractListItem); + + return item + ? { + type: toType(item.annotation), + default: toDefault(item.default), + description: itemDescription(item, mdx), + } + : { type: null, default: null, description: '' }; +}; + +/** + * The arguments an event's listeners receive. + * + * @param {Array} items The typed list's items + * @param {boolean} [mdx] Whether the items come from an MDX document + * @returns {Array} + */ +export const buildEventParameters = (items, mdx) => + items.map(item => toParameter(extractListItem(item), undefined, mdx)); diff --git a/packages/core/src/generators/json/utils/types.mjs b/packages/core/src/generators/json/utils/types.mjs new file mode 100644 index 000000000..01c00d8ac --- /dev/null +++ b/packages/core/src/generators/json/utils/types.mjs @@ -0,0 +1,28 @@ +'use strict'; + +/** + * Builds a type from a resolved `typeAnnotation` node. + * + * @param {import('mdast').Node | undefined} node + * @returns {import('../types').Type | null} + */ +export const toType = node => + node + ? { + text: node.value, + links: (node.data?.links ?? []).map(({ text, href, start, end }) => ({ + name: text, + href, + start, + end, + })), + } + : null; + +/** + * Builds a type from bare text, with nothing resolved. + * + * @param {string} text + * @returns {import('../types').Type} + */ +export const plainType = text => ({ text, links: [] }); diff --git a/packages/core/src/generators/metadata/types.d.ts b/packages/core/src/generators/metadata/types.d.ts index 6be3d2e5d..9529d77ad 100644 --- a/packages/core/src/generators/metadata/types.d.ts +++ b/packages/core/src/generators/metadata/types.d.ts @@ -97,6 +97,11 @@ export interface HeadingData extends Data { * from the ToC while still rendering on the page. */ isOverload?: boolean; + /** + * The slug of the first heading of the overloaded function this heading is + * an overload of. Set alongside `isOverload`. + */ + overloadOf?: string; } /** diff --git a/packages/node-legacy/src/legacy-json/utils/__tests__/buildHierarchy.test.mjs b/packages/core/src/utils/__tests__/hierarchy.test.mjs similarity index 63% rename from packages/node-legacy/src/legacy-json/utils/__tests__/buildHierarchy.test.mjs rename to packages/core/src/utils/__tests__/hierarchy.test.mjs index efd936b2b..61cc9da37 100644 --- a/packages/node-legacy/src/legacy-json/utils/__tests__/buildHierarchy.test.mjs +++ b/packages/core/src/utils/__tests__/hierarchy.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { findParent, buildHierarchy } from '../buildHierarchy.mjs'; +import { findParent, buildHierarchy } from '../hierarchy.mjs'; describe('findParent', () => { it('finds parent with lower depth', () => { @@ -13,9 +13,19 @@ describe('findParent', () => { assert.equal(parent, nodes[0]); }); - it('throws when no parent exists', () => { + it('skips over siblings and deeper entries', () => { + const nodes = [ + { entry: { heading: { depth: 2 } }, children: [] }, + { entry: { heading: { depth: 4 } }, children: [] }, + { entry: { heading: { depth: 3 } }, children: [] }, + ]; + const parent = findParent(nodes[2].entry, nodes, 1); + assert.equal(parent, nodes[0]); + }); + + it('returns undefined when no parent exists', () => { const nodes = [{ entry: { heading: { depth: 2 } }, children: [] }]; - assert.throws(() => findParent(nodes[0].entry, nodes, -1)); + assert.equal(findParent(nodes[0].entry, nodes, -1), undefined); }); }); @@ -52,4 +62,13 @@ describe('buildHierarchy', () => { assert.equal(result.length, 1); assert.equal(result[0].children[0].children.length, 1); }); + + it('treats an entry without a shallower predecessor as a root', () => { + const entries = [{ heading: { depth: 2 } }, { heading: { depth: 3 } }]; + const result = buildHierarchy(entries); + + assert.equal(result.length, 1); + assert.equal(result[0].entry, entries[0]); + assert.equal(result[0].children[0].entry, entries[1]); + }); }); diff --git a/packages/react/src/jsx-ast/utils/__tests__/overloads.test.mjs b/packages/core/src/utils/__tests__/overloads.test.mjs similarity index 87% rename from packages/react/src/jsx-ast/utils/__tests__/overloads.test.mjs rename to packages/core/src/utils/__tests__/overloads.test.mjs index 44436f015..94f9ddd0f 100644 --- a/packages/react/src/jsx-ast/utils/__tests__/overloads.test.mjs +++ b/packages/core/src/utils/__tests__/overloads.test.mjs @@ -19,9 +19,13 @@ describe('annotateOverloads', () => { // The first (most stable) heading is left as-is... assert.ok(!entries[0].heading.data.isOverload); - // ...and the rest are flagged so the ToC can drop them. + assert.equal(entries[0].heading.data.overloadOf, undefined); + // ...and the rest are flagged so the ToC can drop them, pointing back at + // the heading they overload. assert.ok(entries[1].heading.data.isOverload); assert.ok(entries[2].heading.data.isOverload); + assert.equal(entries[1].heading.data.overloadOf, 'fsreadfd'); + assert.equal(entries[2].heading.data.overloadOf, 'fsreadfd'); }); it('leaves a single-signature function untouched', () => { diff --git a/packages/core/src/utils/__tests__/stability.test.mjs b/packages/core/src/utils/__tests__/stability.test.mjs new file mode 100644 index 000000000..06c7828e5 --- /dev/null +++ b/packages/core/src/utils/__tests__/stability.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { u } from 'unist-builder'; + +import { removeStabilityPrefix } from '../stability.mjs'; + +const stability = (...children) => + u('blockquote', { data: { index: '1.1', description: '' } }, children); + +describe('removeStabilityPrefix', () => { + it('drops a linked prefix and its separator', () => { + const node = stability( + u('paragraph', [ + u('link', { url: '#stability-index' }, [u('text', 'Stability: 1.1')]), + u('text', ' - Active development. Use '), + u('inlineCode', 'other()'), + u('text', ' instead.'), + ]) + ); + + assert.deepEqual(removeStabilityPrefix(node).children[0].children, [ + u('text', 'Active development. Use '), + u('inlineCode', 'other()'), + u('text', ' instead.'), + ]); + }); + + it('drops a plain prefix and keeps later paragraphs', () => { + const node = stability( + u('paragraph', [u('text', 'Stability: 2 - Stable')]), + u('paragraph', [u('text', 'More.')]) + ); + + const { children } = removeStabilityPrefix(node); + + assert.deepEqual(children[0].children, [u('text', 'Stable')]); + assert.deepEqual(children[1].children, [u('text', 'More.')]); + }); + + it('leaves the source untouched', () => { + const node = stability( + u('paragraph', [u('text', 'Stability: 2 - Stable')]) + ); + const before = structuredClone(node); + + removeStabilityPrefix(node); + + assert.deepEqual(node, before); + }); +}); diff --git a/packages/core/src/utils/file.mjs b/packages/core/src/utils/file.mjs index e7c284bb4..314d39743 100644 --- a/packages/core/src/utils/file.mjs +++ b/packages/core/src/utils/file.mjs @@ -18,3 +18,13 @@ export const writeFile = (file, ...args) => fs .mkdir(dirname(file), { recursive: true }) .then(() => fs.writeFile(file, ...args)); + +/** + * Writes a value as JSON + * + * @param {string} file + * @param {unknown} value + * @param {boolean} [minify] + */ +export const writeJSON = (file, value, minify = false) => + writeFile(file, JSON.stringify(value, null, minify ? 0 : 2)); diff --git a/packages/core/src/utils/hierarchy.mjs b/packages/core/src/utils/hierarchy.mjs new file mode 100644 index 000000000..da82bd8a7 --- /dev/null +++ b/packages/core/src/utils/hierarchy.mjs @@ -0,0 +1,50 @@ +'use strict'; + +/** + * A node in the entry hierarchy. + * + * @typedef {object} HierarchizedEntry + * @property {import('../generators/metadata/types').MetadataEntry} entry The metadata entry this node wraps + * @property {Array} children Entries nested under this one, by heading depth + */ + +/** + * Finds the closest preceding node whose heading is shallower than the + * entry's, which is the entry's parent. + * + * @param {import('../generators/metadata/types').MetadataEntry} entry The entry to find a parent for + * @param {Array} nodes Wrapper nodes, index-aligned with the entries + * @param {number} startIdx The index to search backwards from + * @returns {HierarchizedEntry | undefined} The parent, or `undefined` when no shallower entry precedes it + */ +export const findParent = (entry, nodes, startIdx) => { + for (let i = startIdx; i >= 0; i--) { + if (nodes[i].entry.heading.depth < entry.heading.depth) { + return nodes[i]; + } + } + + return undefined; +}; + +/** + * @param {Array} entries Entries in document order + * @returns {Array} The root nodes + */ +export const buildHierarchy = entries => { + const roots = []; + + // Wrapper nodes, index-aligned with `entries` + const nodes = entries.map(entry => ({ entry, children: [] })); + + nodes.forEach((node, i) => { + const parent = + node.entry.heading.depth <= 1 + ? undefined + : findParent(node.entry, nodes, i - 1); + + (parent?.children ?? roots).push(node); + }); + + return roots; +}; diff --git a/packages/react/src/jsx-ast/utils/overloads.mjs b/packages/core/src/utils/overloads.mjs similarity index 56% rename from packages/react/src/jsx-ast/utils/overloads.mjs rename to packages/core/src/utils/overloads.mjs index cd9f5ac5b..cbf799861 100644 --- a/packages/react/src/jsx-ast/utils/overloads.mjs +++ b/packages/core/src/utils/overloads.mjs @@ -8,8 +8,8 @@ const OVERLOADABLE_TYPES = new Set(['method', 'ctor', 'classMethod']); * Two headings document the same function (i.e. are overloads of one another) * when they sit at the same depth and share the same resolved name and type. * - * @param {import('@doc-kit/core/generators/metadata/types').HeadingNode} a - * @param {import('@doc-kit/core/generators/metadata/types').HeadingNode} b + * @param {import('../generators/metadata/types').HeadingNode} a + * @param {import('../generators/metadata/types').HeadingNode} b */ const isSameFunction = (a, b) => a.depth === b.depth && @@ -17,21 +17,22 @@ const isSameFunction = (a, b) => a.data.name === b.data.name; /** - * Flags overloaded function headings so the Table of Contents shows a single - * entry per function. + * Flags overloaded function headings so consumers can present a single entry + * per function. * * Node.js documents each overload of a function as its own heading (e.g. the * five `new Buffer(...)` signatures). This marks the 2nd..nth heading of each - * such run with `isOverload` so they can be dropped from the ToC while still - * rendering in full on the page. The first (most stable) heading is left as-is, - * and the ToC links to its existing anchor. + * such run with `isOverload`, and with `overloadOf` pointing at the slug of + * the run's first heading. The first (most stable) heading is left as-is. * - * @param {Array} entries - Page entries, in render order. - * @returns {Array} The same entries (mutated). + * @param {Array} entries - Entries, in document order. + * @returns {Array} The same entries (mutated). */ export const annotateOverloads = entries => { for (let i = 0; i < entries.length; i++) { - if (!OVERLOADABLE_TYPES.has(entries[i].heading.data.type)) { + const { heading } = entries[i]; + + if (!OVERLOADABLE_TYPES.has(heading.data.type)) { continue; } @@ -39,9 +40,10 @@ export const annotateOverloads = entries => { let end = i + 1; while ( end < entries.length && - isSameFunction(entries[end].heading, entries[i].heading) + isSameFunction(entries[end].heading, heading) ) { entries[end].heading.data.isOverload = true; + entries[end].heading.data.overloadOf = heading.data.slug; end++; } diff --git a/packages/core/src/utils/queries/__tests__/splitTypedItems.test.mjs b/packages/core/src/utils/queries/__tests__/splitTypedItems.test.mjs new file mode 100644 index 000000000..53a783617 --- /dev/null +++ b/packages/core/src/utils/queries/__tests__/splitTypedItems.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { u } from 'unist-builder'; + +import { splitTypedItems } from '../index.mjs'; + +const item = children => u('listItem', [u('paragraph', children)]); +const typed = item([ + u('inlineCode', 'name'), + u('text', ' '), + u('typeAnnotation', { value: 'string' }), +]); +const prose = item([u('text', 'A prose bullet.')]); + +describe('splitTypedItems', () => { + it('separates the leading typed items from the rest', () => { + assert.deepEqual(splitTypedItems(u('list', [typed, typed, prose, typed])), { + typed: [typed, typed], + rest: [prose, typed], + }); + }); + + it('has no rest when every item is typed', () => { + assert.deepEqual(splitTypedItems(u('list', [typed])), { + typed: [typed], + rest: [], + }); + }); +}); diff --git a/packages/core/src/utils/queries/index.mjs b/packages/core/src/utils/queries/index.mjs index f574e9784..4bcd0ed98 100644 --- a/packages/core/src/utils/queries/index.mjs +++ b/packages/core/src/utils/queries/index.mjs @@ -4,6 +4,8 @@ import { transformNodesToString } from '#utils/unist.mjs'; import { isTypedListItem, isTypedList } from './utils.mjs'; +export { splitTypedItems } from './utils.mjs'; + // This defines the actual REGEX Queries export const QUERIES = { // Fixes the references to Markdown pages into the API documentation diff --git a/packages/core/src/utils/queries/utils.mjs b/packages/core/src/utils/queries/utils.mjs index 3dea14362..0be3c9a9a 100644 --- a/packages/core/src/utils/queries/utils.mjs +++ b/packages/core/src/utils/queries/utils.mjs @@ -67,3 +67,18 @@ export const isTypedList = list => { return getTypedConfidence(list.children?.[0]?.children?.[0]?.children?.[0]); }; + +/** + * Splits a typed list's items into the leading typed ones and the rest: + * prose bullets that happen to share the list in the source markdown. + * + * @param {import('@types/mdast').List} list + * @returns {{ typed: Array, rest: Array }} + */ +export const splitTypedItems = ({ children }) => { + const end = children.findIndex(item => !isTypedListItem(item)); + + return end === -1 + ? { typed: children, rest: [] } + : { typed: children.slice(0, end), rest: children.slice(end) }; +}; diff --git a/packages/core/src/utils/remark.mjs b/packages/core/src/utils/remark.mjs index 4fc3d9e9d..42b1ead17 100644 --- a/packages/core/src/utils/remark.mjs +++ b/packages/core/src/utils/remark.mjs @@ -63,7 +63,7 @@ export const getRemark = lazy(() => * `{` for type annotations that MDX would otherwise try to parse. */ export const getRemarkMdx = lazy(() => - unified().use(remarkParse).use(remarkMdx).use(remarkGfm) + unified().use(remarkParse).use(remarkMdx).use(remarkGfm).use(remarkStringify) ); /** diff --git a/packages/core/src/utils/signature/__tests__/extractListItem.test.mjs b/packages/core/src/utils/signature/__tests__/extractListItem.test.mjs new file mode 100644 index 000000000..f1fa825b6 --- /dev/null +++ b/packages/core/src/utils/signature/__tests__/extractListItem.test.mjs @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { u } from 'unist-builder'; + +import { extractListItem, removeDefault } from '../extractListItem.mjs'; + +const code = value => u('inlineCode', value); +const text = value => u('text', value); +const type = value => u('typeAnnotation', { value }); +const strong = value => u('strong', [text(value)]); +const item = (children, ...blocks) => + u('listItem', [u('paragraph', children), ...blocks]); + +describe('extractListItem', () => { + it('takes a named, typed item apart', () => { + const result = extractListItem( + item([code('path'), text(' '), type('string | URL'), text(' The file.')]) + ); + + assert.equal(result.name, 'path'); + assert.equal(result.prefix, undefined); + assert.equal(result.annotation.value, 'string | URL'); + assert.deepEqual(result.text, [text('The file.')]); + assert.equal(result.default, undefined); + assert.deepEqual(result.blocks, []); + assert.deepEqual(result.items, []); + }); + + it('recognises the special prefixes', () => { + const returns = extractListItem( + item([text('Returns: '), type('Promise'), text(' Fulfills on close.')]) + ); + assert.equal(returns.prefix, 'Returns'); + assert.equal(returns.name, undefined); + assert.equal(returns.annotation.value, 'Promise'); + assert.deepEqual(returns.text, [text('Fulfills on close.')]); + + const extendsItem = extractListItem( + item([text('Extends: '), type('EventEmitter')]) + ); + assert.equal(extendsItem.prefix, 'Extends'); + assert.equal(extendsItem.annotation.value, 'EventEmitter'); + assert.deepEqual(extendsItem.text, []); + + const typeItem = extractListItem( + item([text('Type:'), text(' '), type('integer')]) + ); + assert.equal(typeItem.prefix, 'Type'); + assert.equal(typeItem.annotation.value, 'integer'); + }); + + it('keeps a prefix remainder that is not blank', () => { + const result = extractListItem(item([text('Returns: description here')])); + + assert.equal(result.prefix, 'Returns'); + assert.deepEqual(result.text, [text('description here')]); + }); + + it('trims leading separators from the description', () => { + const result = extractListItem( + item([code('opt'), text(' '), type('boolean'), text(' - the option')]) + ); + + assert.deepEqual(result.text, [text('the option')]); + }); + + it('drops a description that is only separators', () => { + const result = extractListItem(item([code('opt'), text(': ')])); + + assert.deepEqual(result.text, []); + }); + + it('extracts the default value and leaves its marker in the text', () => { + const nodes = [ + code('encoding'), + text(' '), + type('string'), + text(' The encoding. '), + strong('Default:'), + text(' '), + code("'utf8'"), + text('.'), + ]; + const result = extractListItem(item(nodes)); + + assert.equal(result.default, "`'utf8'`"); + assert.deepEqual( + result.text, + nodes.slice(3).with(0, text('The encoding. ')) + ); + assert.deepEqual(removeDefault(result.text), [text('The encoding.')]); + }); + + it('separates the nested typed list from other blocks', () => { + const nested = u('list', [item([code('flag'), text(' '), type('string')])]); + const note = u('paragraph', [text('A note.')]); + const result = extractListItem( + item([code('options'), text(' '), type('Object')], nested, note) + ); + + assert.deepEqual(result.items, nested.children); + assert.deepEqual(result.blocks, [note]); + }); + + it('leaves the source nodes untouched', () => { + const paragraph = u('paragraph', [ + code('a'), + text(' '), + type('b'), + text(' - c'), + ]); + const source = u('listItem', [paragraph]); + const before = structuredClone(source); + + extractListItem(source); + + assert.deepEqual(source, before); + }); + + it('handles an item without a paragraph', () => { + const result = extractListItem(u('listItem', [])); + + assert.deepEqual(result.text, []); + assert.equal(result.name, undefined); + }); +}); + +describe('removeDefault', () => { + it('returns the nodes unchanged when there is no default', () => { + const nodes = [text('No default.')]; + + assert.equal(removeDefault(nodes), nodes); + }); + + it('drops the marker and everything after it', () => { + const nodes = [ + text('Before'), + text(' '), + strong('Default:'), + code('1'), + text(' after'), + ]; + + assert.deepEqual(removeDefault(nodes), [text('Before')]); + }); +}); diff --git a/packages/core/src/utils/signature/constants.mjs b/packages/core/src/utils/signature/constants.mjs index 16b524b2c..4c77ab293 100644 --- a/packages/core/src/utils/signature/constants.mjs +++ b/packages/core/src/utils/signature/constants.mjs @@ -4,6 +4,9 @@ export const NAME_EXPRESSION = /^['`"]?([^'`": {]+)['`"]?\s*:?\s*/; // Checks if there's a leading hyphen export const LEADING_HYPHEN = /^-\s*/; +// The separators between a parameter's type and its description +export const TRIMMABLE_PADDING_REGEX = /^[\s:-]+/; + // Grabs the default value if present export const DEFAULT_EXPRESSION = /\s*\*\*Default:\*\*\s*([^]+)$/i; diff --git a/packages/core/src/utils/signature/extractListItem.mjs b/packages/core/src/utils/signature/extractListItem.mjs new file mode 100644 index 000000000..ae8382f62 --- /dev/null +++ b/packages/core/src/utils/signature/extractListItem.mjs @@ -0,0 +1,140 @@ +'use strict'; + +import { QUERIES, UNIST } from '#utils/queries/index.mjs'; +import { transformNodesToString } from '#utils/unist.mjs'; + +import { DEFAULT_EXPRESSION, TRIMMABLE_PADDING_REGEX } from './constants.mjs'; + +// The text of the `**Default:**` marker introducing a default value +const DEFAULT_MARKER = /^default:$/i; + +/** + * A typed list item taken apart into its parts, none of them stringified, so + * each consumer can render the description its own way. + * + * @typedef {object} ExtractedListItem + * @property {string | undefined} name The parameter name (the leading code span), if any + * @property {'Returns' | 'Extends' | 'Type' | undefined} prefix The special prefix the item starts with, if any + * @property {import('mdast').Node | undefined} annotation The leading `typeAnnotation` node, if any + * @property {Array} text The rest of the first paragraph: the description, + * with leading separators trimmed and any `**Default:**` marker still in place + * @property {Array} blocks The item's further block content, excluding its nested typed list + * @property {string | undefined} default The `**Default:**` value as written (code span included), if any + * @property {Array} items The items of the nested typed list, if any + */ + +/** + * Replaces the first node's text, or drops the node when nothing is left. + * + * @param {Array} nodes + * @param {string} value The new text of the first node + */ +const replaceLeadingText = (nodes, value) => { + if (value.trim()) { + nodes[0] = { ...nodes[0], value }; + } else { + nodes.shift(); + } +}; + +/** + * Takes a typed list item apart: `` `name` {Type} Description. **Default:** `value` ``, + * or one of the `Returns:`, `Extends:` and `Type:` forms. + * + * @param {import('mdast').ListItem} item + * @returns {ExtractedListItem} + */ +export const extractListItem = item => { + const [paragraph, ...rest] = item.children ?? []; + const text = [...(paragraph?.children ?? [])]; + + const result = { + name: undefined, + prefix: undefined, + annotation: undefined, + text, + blocks: [], + default: undefined, + items: [], + }; + + const [first] = text; + + if (first?.type === 'inlineCode') { + result.name = first.value.trimEnd(); + text.shift(); + } else if (first?.type === 'text') { + const match = first.value.match(QUERIES.typedListStarters); + + if (match) { + result.prefix = match[1]; + replaceLeadingText(text, first.value.slice(match[0].length)); + } + } + + // The whitespace between the name and the type + if (text[0]?.type === 'text' && !text[0].value.trim()) { + text.shift(); + } + + if (text[0]?.type === 'typeAnnotation') { + result.annotation = text.shift(); + } + + // Leading separators: `- description`, `: description` + if (text[0]?.type === 'text') { + replaceLeadingText( + text, + text[0].value.replace(TRIMMABLE_PADDING_REGEX, '') + ); + } + + result.default = DEFAULT_EXPRESSION.exec(transformNodesToString(text))?.[1] + .trim() + .replace(/\.$/, ''); + + for (const node of rest) { + if (result.items.length === 0 && UNIST.isLooselyTypedList(node)) { + result.items = node.children; + } else { + result.blocks.push(node); + } + } + + return result; +}; + +/** + * Drops the `**Default:**` marker, and everything after it, from an item's + * description. + * + * @param {Array} nodes The item's description + * @returns {Array} The description without its default value + */ +export const removeDefault = nodes => { + const index = nodes.findIndex( + node => + node.type === 'strong' && + DEFAULT_MARKER.test(transformNodesToString(node.children).trim()) + ); + + if (index === -1) { + return nodes; + } + + const kept = nodes.slice(0, index); + const last = kept.at(-1); + + // The whitespace that separated the description from its default + if (last?.type === 'text') { + const value = last.value.trimEnd(); + + kept.pop(); + + if (value) { + kept.push({ ...last, value }); + } + } + + return kept; +}; diff --git a/packages/core/src/utils/stability.mjs b/packages/core/src/utils/stability.mjs new file mode 100644 index 000000000..577fe5489 --- /dev/null +++ b/packages/core/src/utils/stability.mjs @@ -0,0 +1,24 @@ +'use strict'; + +import { slice } from 'mdast-util-slice-markdown'; +import { toString } from 'mdast-util-to-string'; + +import { QUERIES } from './queries/index.mjs'; + +/** + * The content of a stability blockquote without its `Stability: N - ` + * prefix, as a copy of the blockquote. The prefix may have been turned into a + * link by the AST stage; the separator goes with it. + * + * @param {import('../generators/metadata/types').StabilityNode} node + * @returns {import('mdast').Blockquote | null} The copy, or `null` when nothing follows the prefix + */ +export const removeStabilityPrefix = node => { + const text = toString(node.children[0]); + const match = QUERIES.stabilityIndex.exec(text); + const start = match ? text.length - match[2].length : 0; + + return slice(node, start, undefined, { + textHandling: { boundaries: 'preserve' }, + }).node; +}; diff --git a/packages/node-legacy/src/legacy-json/types.d.ts b/packages/node-legacy/src/legacy-json/types.d.ts index b3ab44046..2de6e43ad 100644 --- a/packages/node-legacy/src/legacy-json/types.d.ts +++ b/packages/node-legacy/src/legacy-json/types.d.ts @@ -2,21 +2,6 @@ import { ListItem } from '@types/mdast'; import { MetadataEntry } from '@doc-kit/core/generators/metadata/types'; import { MethodSignature } from '@doc-kit/core/utils/signature/types'; -/** - * A node in the entry hierarchy. - */ -export interface HierarchizedEntry { - /** - * The metadata entry this node wraps. - */ - entry: MetadataEntry; - - /** - * Child nodes nested under this entry, based on heading depth. - */ - children: HierarchizedEntry[]; -} - /** * Contains metadata related to changes, additions, removals, and deprecated statuses of an entry. */ diff --git a/packages/node-legacy/src/legacy-json/utils/buildHierarchy.mjs b/packages/node-legacy/src/legacy-json/utils/buildHierarchy.mjs deleted file mode 100644 index 889f17569..000000000 --- a/packages/node-legacy/src/legacy-json/utils/buildHierarchy.mjs +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Recursively finds the most suitable parent node for a given `entry` based on heading depth. - * - * @param {import('@doc-kit/core/generators/metadata/types').MetadataEntry} entry - * @param {Array} nodes - * @param {number} startIdx - * @returns {import('../types.d.ts').HierarchizedEntry} - */ -export function findParent(entry, nodes, startIdx) { - // Base case: if we're at the beginning of the list, no valid parent exists. - if (startIdx < 0) { - throw new Error( - `Cannot find a suitable parent for entry at index ${startIdx + 1}` - ); - } - - const candidateParent = nodes[startIdx]; - - // If we find a suitable parent, return it. - if (candidateParent.entry.heading.depth < entry.heading.depth) { - return candidateParent; - } - - // Recurse upwards to find a suitable parent. - return findParent(entry, nodes, startIdx - 1); -} - -/** - * We need the files to be in a hierarchy based off of depth, but they're - * given to us flattened. So, let's fix that. - * - * Assuming that {@link entries} is in the same order as the elements are in - * the markdown, we can use the entry's depth property to reassemble the - * hierarchy. - * - * If depth <= 1, it's a top-level element (aka a root). - * - * Otherwise, its parent is the nearest earlier entry with a lower depth, - * found by looping through entries in reverse starting at the current - * index - 1. - * - * @param {Array} entries - * @returns {Array} - */ -export function buildHierarchy(entries) { - const roots = []; - - // Wrapper nodes, index-aligned with `entries`. - const nodes = entries.map(entry => ({ entry, children: [] })); - - // Main loop to construct the hierarchy. - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - - // Top-level entries are added directly to roots. - if (node.entry.heading.depth <= 1) { - roots.push(node); - continue; - } - - // For non-root entries, find the appropriate parent. - findParent(node.entry, nodes, i - 1).children.push(node); - } - - return roots; -} diff --git a/packages/node-legacy/src/legacy-json/utils/buildSection.mjs b/packages/node-legacy/src/legacy-json/utils/buildSection.mjs index 0fef89ae9..74dbd7aed 100644 --- a/packages/node-legacy/src/legacy-json/utils/buildSection.mjs +++ b/packages/node-legacy/src/legacy-json/utils/buildSection.mjs @@ -1,9 +1,9 @@ import { enforceArray } from '@doc-kit/core/utils/array.mjs'; +import { buildHierarchy } from '@doc-kit/core/utils/hierarchy.mjs'; import { getRemarkRehype as remark } from '@doc-kit/core/utils/remark.mjs'; import { parseList } from '@doc-kit/core/utils/signature/parseList.mjs'; import { transformNodesToString } from '@doc-kit/core/utils/unist.mjs'; -import { buildHierarchy } from './buildHierarchy.mjs'; import { SECTION_TYPE_PLURALS, UNPROMOTED_KEYS } from '../constants.mjs'; /** @@ -163,7 +163,7 @@ export const createSectionBuilder = () => { /** * Handles a hierarchy node and updates the parent section. - * @param {import('../types.d.ts').HierarchizedEntry} node - The hierarchy node to process. + * @param {import('@doc-kit/core/utils/hierarchy.mjs').HierarchizedEntry} node - The hierarchy node to process. * @param {import('../types.d.ts').Section} parent - The parent section. */ const handleEntry = ({ entry, children }, parent) => { diff --git a/packages/react/src/jsx-ast/constants.mjs b/packages/react/src/jsx-ast/constants.mjs index ffeec38c8..0327b8bcb 100644 --- a/packages/react/src/jsx-ast/constants.mjs +++ b/packages/react/src/jsx-ast/constants.mjs @@ -47,9 +47,6 @@ export const STABILITY_LEVELS = [ // How deep should the Table of Contents go? export const TOC_MAX_HEADING_DEPTH = 3; -// 'Stability: '.length + ' - '.length -export const STABILITY_PREFIX_LENGTH = 14; - // 'Type: '.length export const TYPE_PREFIX_LENGTH = 6; @@ -204,9 +201,6 @@ export const TYPES_WITH_METHOD_SIGNATURES = [ 'classMethod', ]; -// Regex to trim leading whitespace, colons, and hyphens from strings -export const TRIMMABLE_PADDING_REGEX = /^[\s:-]+/; - // Patterns to map deprecation "Type" text to AlertBox levels. // Order matters: first match wins. export const DEPRECATION_TYPE_PATTERNS = [ diff --git a/packages/react/src/jsx-ast/utils/__tests__/types.test.mjs b/packages/react/src/jsx-ast/utils/__tests__/types.test.mjs index de963d4f4..75c8b2949 100644 --- a/packages/react/src/jsx-ast/utils/__tests__/types.test.mjs +++ b/packages/react/src/jsx-ast/utils/__tests__/types.test.mjs @@ -12,169 +12,18 @@ mock.module('../remark.mjs', { }, }); -const { extractPropertyName, extractTypeAnnotation, parseListIntoProperties } = - await import('../types.mjs'); +const { parseListIntoProperties } = await import('../types.mjs'); -describe('extractPropertyName', () => { - it('extracts name from inline code and removes it from nodes', () => { - const nodes = [ - { type: 'inlineCode', value: 'propName' }, - { type: 'text', value: ' remaining text' }, - ]; - const current = {}; - - extractPropertyName(nodes, current); - - assert.deepStrictEqual(current, { name: 'propName' }); - assert.strictEqual(nodes.length, 1); - assert.strictEqual(nodes[0].value, ' remaining text'); - }); - - it('trims trailing whitespace from inline code value', () => { - const nodes = [{ type: 'inlineCode', value: 'propName ' }]; - const current = {}; - - extractPropertyName(nodes, current); - - assert.deepStrictEqual(current, { name: 'propName' }); - assert.strictEqual(nodes.length, 0); - }); - - it('handles empty nodes array gracefully', () => { - const nodes = []; - const current = {}; - - extractPropertyName(nodes, current); - - assert.deepStrictEqual(current, {}); - assert.strictEqual(nodes.length, 0); - }); - - it('does nothing when first node is not text or inlineCode', () => { - const nodes = [ - { type: 'emphasis', children: [{ type: 'text', value: 'emphasized' }] }, - ]; - const current = {}; - - extractPropertyName(nodes, current); - - assert.deepStrictEqual(current, {}); - assert.strictEqual(nodes.length, 1); - }); - - describe('text node processing', () => { - it('extracts "Returns" and sets kind to "return"', () => { - const nodes = [{ type: 'text', value: 'Returns: description here' }]; - const current = {}; - - extractPropertyName(nodes, current); - - assert.deepStrictEqual(current, { name: 'Returns', kind: 'return' }); - assert.strictEqual(nodes[0].value, 'description here'); - }); - - it('preserves node with remaining non-whitespace content', () => { - const nodes = [{ type: 'text', value: 'Returns: some content' }]; - const current = {}; - - extractPropertyName(nodes, current); - - assert.strictEqual(nodes.length, 1); - assert.strictEqual(nodes[0].value, 'some content'); - }); - - it('does nothing when text does not match typed list starters', () => { - const nodes = [{ type: 'text', value: 'regular text without colon' }]; - const current = {}; - - extractPropertyName(nodes, current); - - assert.deepStrictEqual(current, {}); - assert.strictEqual(nodes.length, 1); - assert.strictEqual(nodes[0].value, 'regular text without colon'); - }); - }); -}); - -describe('extractTypeAnnotation', () => { - it('extracts a leading type annotation and returns its expression', () => { - const nodes = [ - { type: 'typeAnnotation', value: 'string' }, - { type: 'text', value: ' description follows' }, - ]; - - const result = extractTypeAnnotation(nodes); - - assert.strictEqual(result, 'mock-expression'); - assert.strictEqual(nodes.length, 1); - assert.strictEqual(nodes[0].value, ' description follows'); - }); - - it('extracts union types written as one annotation', () => { - const nodes = [ - { type: 'typeAnnotation', value: 'string|number' }, - { type: 'text', value: ' description' }, - ]; - - const result = extractTypeAnnotation(nodes); - - assert.strictEqual(result, 'mock-expression'); - assert.strictEqual(nodes.length, 1); - assert.strictEqual(nodes[0].value, ' description'); - }); - - it('returns undefined when no type annotation leads', () => { - const nodes = [ - { type: 'text', value: 'regular text' }, - { type: 'typeAnnotation', value: 'string' }, - ]; - - const result = extractTypeAnnotation(nodes); - - assert.strictEqual(result, undefined); - assert.strictEqual(nodes.length, 2); - }); - - it('consumes only the leading annotation', () => { - const nodes = [ - { type: 'typeAnnotation', value: 'string' }, - { type: 'emphasis', children: [{ type: 'text', value: 'not a type' }] }, - { type: 'typeAnnotation', value: 'number' }, - ]; - - const result = extractTypeAnnotation(nodes); - - assert.strictEqual(result, 'mock-expression'); - assert.strictEqual(nodes.length, 2); - assert.strictEqual(nodes[0].type, 'emphasis'); - }); - - it('handles empty nodes array', () => { - const nodes = []; - - const result = extractTypeAnnotation(nodes); - - assert.strictEqual(result, undefined); - assert.strictEqual(nodes.length, 0); - }); +const list = (...items) => ({ + children: items.map(children => ({ children: [{ children }] })), }); describe('parseListIntoProperties', () => { it('parses simple property with inline code name', () => { - const node = { - children: [ - { - children: [ - { - children: [ - { type: 'inlineCode', value: 'propName' }, - { type: 'text', value: ' description here' }, - ], - }, - ], - }, - ], - }; + const node = list([ + { type: 'inlineCode', value: 'propName' }, + { type: 'text', value: ' description here' }, + ]); const result = parseListIntoProperties(node); @@ -190,21 +39,11 @@ describe('parseListIntoProperties', () => { }); it('parses property with a type annotation', () => { - const node = { - children: [ - { - children: [ - { - children: [ - { type: 'inlineCode', value: 'prop' }, - { type: 'typeAnnotation', value: 'string' }, - { type: 'text', value: ' description' }, - ], - }, - ], - }, - ], - }; + const node = list([ + { type: 'inlineCode', value: 'prop' }, + { type: 'typeAnnotation', value: 'string' }, + { type: 'text', value: ' description' }, + ]); const result = parseListIntoProperties(node); @@ -219,21 +58,35 @@ describe('parseListIntoProperties', () => { ]); }); - it('detects optional properties with default expressions', () => { - const node = { - children: [ - { - children: [ - { - children: [ - { type: 'inlineCode', value: 'optionalProp' }, - { type: 'text', value: ' optional parameter description' }, - ], - }, - ], - }, - ], - }; + it('marks properties with a default value as optional', () => { + const node = list([ + { type: 'inlineCode', value: 'encoding' }, + { type: 'typeAnnotation', value: 'string' }, + { type: 'text', value: ' The encoding. ' }, + { type: 'strong', children: [{ type: 'text', value: 'Default:' }] }, + { type: 'text', value: ' ' }, + { type: 'inlineCode', value: "'utf8'" }, + ]); + + const result = parseListIntoProperties(node); + + assert.deepStrictEqual(result, [ + { + children: undefined, + description: 'mock-expression', + name: 'encoding', + optional: true, + type: 'mock-expression', + }, + ]); + }); + + it('names return items after their prefix and gives them a kind', () => { + const node = list([ + { type: 'text', value: 'Returns: ' }, + { type: 'typeAnnotation', value: 'Promise' }, + { type: 'text', value: ' Fulfills on close.' }, + ]); const result = parseListIntoProperties(node); @@ -241,25 +94,16 @@ describe('parseListIntoProperties', () => { { children: undefined, description: 'mock-expression', - name: 'optionalProp', + kind: 'return', + name: 'Returns', optional: false, - type: undefined, + type: 'mock-expression', }, ]); }); it('handles properties without descriptions', () => { - const node = { - children: [ - { - children: [ - { - children: [{ type: 'inlineCode', value: 'propOnly' }], - }, - ], - }, - ], - }; + const node = list([{ type: 'inlineCode', value: 'propOnly' }]); const result = parseListIntoProperties(node); @@ -273,20 +117,10 @@ describe('parseListIntoProperties', () => { }); it('trims padding from description text', () => { - const node = { - children: [ - { - children: [ - { - children: [ - { type: 'inlineCode', value: 'prop' }, - { type: 'text', value: ' - description with padding' }, - ], - }, - ], - }, - ], - }; + const node = list([ + { type: 'inlineCode', value: 'prop' }, + { type: 'text', value: ' - description with padding' }, + ]); const result = parseListIntoProperties(node); @@ -354,30 +188,16 @@ describe('parseListIntoProperties', () => { }); it('handles multiple list items', () => { - const node = { - children: [ - { - children: [ - { - children: [ - { type: 'inlineCode', value: 'first' }, - { type: 'text', value: ' first description' }, - ], - }, - ], - }, - { - children: [ - { - children: [ - { type: 'inlineCode', value: 'second' }, - { type: 'text', value: ' second description' }, - ], - }, - ], - }, + const node = list( + [ + { type: 'inlineCode', value: 'first' }, + { type: 'text', value: ' first description' }, ], - }; + [ + { type: 'inlineCode', value: 'second' }, + { type: 'text', value: ' second description' }, + ] + ); const result = parseListIntoProperties(node); @@ -398,96 +218,4 @@ describe('parseListIntoProperties', () => { }, ]); }); - - it('handles properties with typed list starters', () => { - const node = { - children: [ - { - children: [ - { - children: [ - { type: 'text', value: 'Returns: result description' }, - ], - }, - ], - }, - ], - }; - - const result = parseListIntoProperties(node); - - assert.deepStrictEqual(result, [ - { - children: undefined, - description: 'mock-expression', - kind: 'return', - name: 'Returns', - optional: false, - type: undefined, - }, - ]); - }); - - it('handles empty list', () => { - const node = { - children: [], - }; - - const result = parseListIntoProperties(node); - - assert.deepStrictEqual(result, []); - }); - - it('handles complex nested structure', () => { - const node = { - children: [ - { - children: [ - { - children: [ - { type: 'inlineCode', value: 'config' }, - { type: 'typeAnnotation', value: 'Object' }, - { type: 'text', value: ' configuration object' }, - ], - }, - { - type: 'list', - children: [ - { - children: [ - { - children: [ - { type: 'inlineCode', value: 'timeout' }, - { type: 'typeAnnotation', value: 'number' }, - { type: 'text', value: ' timeout in milliseconds' }, - ], - }, - ], - }, - ], - }, - ], - }, - ], - }; - - const result = parseListIntoProperties(node); - assert.deepStrictEqual(result, [ - { - children: [ - { - children: undefined, - description: 'mock-expression', - name: 'timeout', - optional: false, - type: 'mock-expression', - }, - ], - description: 'mock-expression', - name: 'config', - optional: false, - type: 'mock-expression', - }, - ]); - }); }); diff --git a/packages/react/src/jsx-ast/utils/buildContent.mjs b/packages/react/src/jsx-ast/utils/buildContent.mjs index 17eb44f16..a950e312f 100644 --- a/packages/react/src/jsx-ast/utils/buildContent.mjs +++ b/packages/react/src/jsx-ast/utils/buildContent.mjs @@ -8,7 +8,9 @@ import { } from '@doc-kit/core/utils/configuration/templates.mjs'; import { parseInline } from '@doc-kit/core/utils/inline.mjs'; import { omitKeys } from '@doc-kit/core/utils/misc.mjs'; -import { UNIST } from '@doc-kit/core/utils/queries/index.mjs'; +import { annotateOverloads } from '@doc-kit/core/utils/overloads.mjs'; +import { splitTypedItems, UNIST } from '@doc-kit/core/utils/queries/index.mjs'; +import { removeStabilityPrefix } from '@doc-kit/core/utils/stability.mjs'; import { transformNodesToString } from '@doc-kit/core/utils/unist.mjs'; import { h as createElement } from 'hastscript'; import { slice } from 'mdast-util-slice-markdown'; @@ -17,7 +19,6 @@ import { SKIP, visit } from 'unist-util-visit'; import { createJSXElement } from './ast.mjs'; import { extractHeadings, extractTextContent } from './buildBarProps.mjs'; -import { annotateOverloads } from './overloads.mjs'; import { getRemarkRecma as remark } from './remark.mjs'; import { renderAsJSX } from './render.mjs'; import { JSX_IMPORTS } from '../../html/constants.mjs'; @@ -25,7 +26,6 @@ import { STABILITY_LEVELS, LIFECYCLE_LABELS, INTERNATIONALIZABLE, - STABILITY_PREFIX_LENGTH, DEPRECATION_TYPE_PATTERNS, ALERT_LEVELS, TYPES_WITH_METHOD_SIGNATURES, @@ -192,14 +192,10 @@ export const createHeadingElement = (content, changeElement) => { * @param {import('unist').Parent} parent - The parent node containing the stability node */ export const transformStabilityNode = (node, index, parent) => { - // Calculate slice start to skip the stability prefix + index length - const start = STABILITY_PREFIX_LENGTH + node.data.index.length; const stabilityLevel = parseInt(node.data.index, 10); parent.children[index] = createJSXElement(JSX_IMPORTS.AlertBox.name, { - children: slice(node, start, undefined, { - textHandling: { boundaries: 'preserve' }, - }).node.children[0].children, + children: removeStabilityPrefix(node).children[0].children, level: STABILITY_LEVELS[stabilityLevel], title: `Stability: ${node.data.index}`, }); @@ -293,25 +289,18 @@ export const processEntry = entry => { // bullets that happen to share the same loose list in the source // markdown). Split those off so they render as regular content instead // of being silently swallowed by the signature table. - const firstNonTyped = node.children.findIndex( - item => !UNIST.isTypedListItem(item) - ); + const { typed, rest } = splitTypedItems(node); - if (firstNonTyped === -1) { + if (rest.length === 0) { parent.children[idx] = createSignatureTable(node); return; } - const typedItems = node.children.slice(0, firstNonTyped); - const restItems = node.children.slice(firstNonTyped); - const replacements = []; - if (typedItems.length > 0) { - replacements.push( - createSignatureTable({ ...node, children: typedItems }) - ); + if (typed.length > 0) { + replacements.push(createSignatureTable({ ...node, children: typed })); } - replacements.push({ ...node, children: restItems }); + replacements.push({ ...node, children: rest }); parent.children.splice(idx, 1, ...replacements); }); diff --git a/packages/react/src/jsx-ast/utils/types.mjs b/packages/react/src/jsx-ast/utils/types.mjs index d5dc23d5f..6f6b304e4 100644 --- a/packages/react/src/jsx-ast/utils/types.mjs +++ b/packages/react/src/jsx-ast/utils/types.mjs @@ -1,76 +1,6 @@ -import { QUERIES, UNIST } from '@doc-kit/core/utils/queries/index.mjs'; -import { DEFAULT_EXPRESSION } from '@doc-kit/core/utils/signature/constants.mjs'; -import { transformNodesToString } from '@doc-kit/core/utils/unist.mjs'; +import { extractListItem } from '@doc-kit/core/utils/signature/extractListItem.mjs'; import { renderAsJSX } from './render.mjs'; -import { TRIMMABLE_PADDING_REGEX } from '../constants.mjs'; - -/** - * Removes and returns the leading node if it's blank text. - * - * @param {Array} nodes - */ -export const shiftIfBlankText = nodes => { - if (nodes[0]?.type === 'text' && !nodes[0].value.trim()) { - nodes.shift(); - } -}; - -/** - * Extracts a property name from the front of a paragraph's children. - * Mutates `nodes` by shifting consumed nodes and updates the current object. - * - * @param {Array} nodes - * @param {Object} current - The current property object being built - */ -export const extractPropertyName = (nodes, current) => { - const first = nodes[0]; - if (!first) { - return; - } - - // `propName` → propName - if (first.type === 'inlineCode') { - nodes.shift(); - current.name = first.value.trimEnd(); - return; - } - - if (first.type !== 'text') { - return; - } - - // "Type:" / "Param:" etc. - const match = first.value.match(QUERIES.typedListStarters); - if (!match) { - return; - } - - // Consume the matched prefix; drop the node entirely if nothing remains - first.value = first.value.slice(match[0].length); - shiftIfBlankText(nodes); - - if (match[1]) { - current.name = match[1]; - // NOTE: We currently only have one "kind". Should others be added for other - // starters, just replace the `undefined` with the other kinds. - current.kind = match[1] === 'Returns' ? 'return' : undefined; - } -}; - -/** - * Consumes a leading type annotation from the front of `nodes` and compiles - * it to a JSX expression (unions live inside a single annotation). - * - * @param {Array} nodes - */ -export const extractTypeAnnotation = nodes => { - if (nodes[0]?.type !== 'typeAnnotation') { - return undefined; - } - - return renderAsJSX([nodes.shift()]); -}; /** * Parses each list item into a structured property descriptor @@ -79,32 +9,38 @@ export const extractTypeAnnotation = nodes => { */ export const parseListIntoProperties = node => node?.children.map(item => { - const [{ children }, ...rest] = item.children; - const current = {}; - - extractPropertyName(children, current); - - // Strip stale whitespace left over after name extraction - shiftIfBlankText(children); + const { + name, + prefix, + annotation, + text, + default: defaultValue, + items, + } = extractListItem(item); - current.type = extractTypeAnnotation(children); + const current = {}; - if (children.length > 0) { - children[0].value &&= children[0].value.replace( - TRIMMABLE_PADDING_REGEX, - '' - ); + if (prefix) { + current.name = prefix; + // NOTE: We currently only have one "kind". Should others be added for other + // starters, just replace the `undefined` with the other kinds. + current.kind = prefix === 'Returns' ? 'return' : undefined; + } else if (name !== undefined) { + current.name = name; + } - current.optional = DEFAULT_EXPRESSION.test( - transformNodesToString(children) - ); + // Unions live inside a single annotation + current.type = annotation && renderAsJSX([annotation]); - current.description = renderAsJSX(children); + if (text.length > 0) { + current.optional = defaultValue !== undefined; + current.description = renderAsJSX(text); } - current.children = parseListIntoProperties( - rest.find(UNIST.isLooselyTypedList) - ); + current.children = + items.length > 0 + ? parseListIntoProperties({ children: items }) + : undefined; return current; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0df83080a..0555221f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: husky: specifier: 9.1.7 version: 9.1.7 + json-schema-to-typescript: + specifier: ^16.0.0 + version: 16.0.0 lint-staged: specifier: 17.3.0 version: 17.3.0 @@ -116,6 +119,12 @@ importers: hastscript: specifier: ^9.0.1 version: 9.0.1 + mdast-util-slice-markdown: + specifier: ^2.0.1 + version: 2.0.1 + mdast-util-to-string: + specifier: ^4.0.0 + version: 4.0.0 piscina: specifier: ^5.3.0 version: 5.3.0 @@ -171,6 +180,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + ajv: + specifier: ^8.20.0 + version: 8.20.0 hast-util-to-html: specifier: ^9.0.5 version: 9.0.5 @@ -323,6 +335,10 @@ packages: '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} + '@apidevtools/json-schema-ref-parser@11.9.3': + resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} + engines: {node: '>= 16'} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -576,6 +592,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1338,6 +1357,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + '@types/mdast@3.0.15': resolution: {integrity: sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==} @@ -1553,6 +1575,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1996,6 +2021,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2393,6 +2421,10 @@ packages: resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true + js-yaml@5.4.1: + resolution: {integrity: sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==} + hasBin: true + jsdoc-type-pratt-parser@8.0.0: resolution: {integrity: sha512-uQu/fXVqVaMg6gM8/E5G5+eygVcZ1NV0Z51CvqhNa2bDWxvHMl484ETr6vph4oPyC+KUcbP/w2W2pewfCiR9aQ==} engines: {node: '>=20.0.0'} @@ -2403,9 +2435,17 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-to-typescript@16.0.0: + resolution: {integrity: sha512-Ah6kK4q6SjlMzWBH1eNOQkdRh5Qhr5T/RCFwfjQqWZA7UDW+N47A8wfyoiN0L884s4L4bAEI54IjIuN3/u0B/A==} + engines: {node: '>=16.0.0'} + hasBin: true + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -2512,6 +2552,9 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + longest-streak@3.1.0: resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} @@ -2701,6 +2744,9 @@ packages: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -3033,6 +3079,10 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} engines: {node: '>=18'} @@ -3552,6 +3602,12 @@ snapshots: '@actions/io@3.0.2': {} + '@apidevtools/json-schema-ref-parser@11.9.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.3.1 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3929,6 +3985,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@jsdevtools/ono@7.1.3': {} + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.29.7 @@ -4651,6 +4709,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/lodash@4.17.25': {} + '@types/mdast@3.0.15': dependencies: '@types/unist': 2.0.11 @@ -4837,6 +4897,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-colors@4.1.3: {} ansi-regex@5.0.1: {} @@ -5372,6 +5439,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.6: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -5848,14 +5917,31 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@5.4.1: + dependencies: + argparse: 2.0.1 + jsdoc-type-pratt-parser@8.0.0: {} json-buffer@3.0.1: {} json-parse-even-better-errors@2.3.1: {} + json-schema-to-typescript@16.0.0: + dependencies: + '@apidevtools/json-schema-ref-parser': 11.9.3 + '@types/json-schema': 7.0.15 + '@types/lodash': 4.17.25 + js-yaml: 5.4.1 + lodash: 4.18.1 + minimist: 1.2.8 + prettier: 3.9.6 + tinyglobby: 0.2.17 + json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} jsonfile@4.0.0: @@ -5940,6 +6026,8 @@ snapshots: lodash.startcase@4.4.0: {} + lodash@4.18.1: {} + longest-streak@3.1.0: {} lru-cache@11.5.2: {} @@ -6396,6 +6484,8 @@ snapshots: dependencies: brace-expansion: 5.0.9 + minimist@1.2.8: {} + minipass@7.1.3: {} mri@1.2.0: {} @@ -6772,6 +6862,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + require-from-string@2.0.2: {} + reserved-identifiers@1.2.0: {} resolve-from@4.0.0: {} diff --git a/scripts/generate-json-types.mjs b/scripts/generate-json-types.mjs new file mode 100644 index 000000000..e769bdb62 --- /dev/null +++ b/scripts/generate-json-types.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node + +// Regenerates the TypeScript types of the `json` generator's output from its +// JSON schema. Run it after editing `schema.json`; a test fails while the +// committed types are stale. + +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { compile } from 'json-schema-to-typescript'; + +const GENERATOR = new URL( + '../packages/core/src/generators/json/', + import.meta.url +); + +export const SCHEMA_PATH = new URL('schema.json', GENERATOR); +export const TYPES_PATH = new URL('generated/schema.d.ts', GENERATOR); + +const PRETTIER_CONFIG = new URL('../.prettierrc.json', import.meta.url); + +/** + * Compiles the schema into the TypeScript source of its types. + * + * @returns {Promise} + */ +export const compileSchemaTypes = async () => { + const schema = JSON.parse(await readFile(SCHEMA_PATH, 'utf-8')); + + // The repository's formatting, minus the per-path overrides + const style = JSON.parse(await readFile(PRETTIER_CONFIG, 'utf-8')); + delete style.overrides; + + return compile(schema, 'Document', { + bannerComment: + '/* eslint-disable */\n' + + '/**\n' + + ' * Generated from `schema.json` by `scripts/generate-json-types.mjs`.\n' + + ' * Do not edit: change the schema and regenerate instead.\n' + + ' */', + // The schema lists every property; consumers get no index signatures + additionalProperties: false, + style, + }); +}; + +if (import.meta.main) { + await writeFile(TYPES_PATH, await compileSchemaTypes()); + + console.log(`Wrote ${fileURLToPath(TYPES_PATH)}`); +} diff --git a/scripts/vercel-build.sh b/scripts/vercel-build.sh index 62e7f6793..c019901ac 100755 --- a/scripts/vercel-build.sh +++ b/scripts/vercel-build.sh @@ -7,7 +7,7 @@ NODE_VERSION=$(cat .node-tag) node packages/cli/bin/cli.mjs generate \ -t orama-db \ - -t legacy-json \ + -t json \ -t llms-txt \ -t html \ -i "./node/doc/api/*.md" \ diff --git a/www/doc-kit.config.mjs b/www/doc-kit.config.mjs index ed9c4c090..1a5b8f742 100644 --- a/www/doc-kit.config.mjs +++ b/www/doc-kit.config.mjs @@ -2,8 +2,11 @@ import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { SCHEMA_VERSION } from '../packages/core/src/generators/json/constants.mjs'; + const ROOT = dirname(fileURLToPath(import.meta.url)); const REPO = join(ROOT, '..'); +const GENERATORS = join(REPO, 'packages', 'core', 'src', 'generators'); const { version } = JSON.parse( readFileSync(join(REPO, 'packages', 'core', 'package.json'), 'utf-8') @@ -15,6 +18,8 @@ const PUBLIC_GENERATORS = [ 'llms-txt', 'sitemap', 'section-pages', + 'json', + 'json-all', 'json-simple', 'legacy-html', 'legacy-html-all', @@ -67,7 +72,15 @@ export default { // each slug back to its true origin. editURL: `https://github.com/${REPOSITORY}`, - pathsToCopy: [{ [join(ROOT, 'content')]: '.' }], + pathsToCopy: [ + { [join(ROOT, 'content')]: '.' }, + { + [join(GENERATORS, 'json', 'schema.json')]: + `schemas/api-doc/${SCHEMA_VERSION}.json`, + [join(GENERATORS, 'json-all', 'schema.json')]: + `schemas/api-doc-all/${SCHEMA_VERSION}.json`, + }, + ], navigation: { showCrossLinks: true,