From 6683fa3648fe59f1f066bb9f4e713cc0862a21ed Mon Sep 17 00:00:00 2001 From: Josh Mock Date: Fri, 13 Dec 2024 14:39:40 -0600 Subject: [PATCH] WIP generating API reference docs with @microsoft/api-extractor --- api-extractor.json | 39 +++++ package.json | 2 + scripts/docgen.mjs | 371 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 412 insertions(+) create mode 100644 api-extractor.json create mode 100644 scripts/docgen.mjs diff --git a/api-extractor.json b/api-extractor.json new file mode 100644 index 000000000..bf29c6946 --- /dev/null +++ b/api-extractor.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "mainEntryPointFilePath": "/lib/client.d.ts", + "bundledPackages": [ + "@elastic/*" + ], + "apiReport": { + "enabled": false + }, + "docModel": { + "enabled": true, + "apiJsonFilePath": "/api-extractor/.api.json", + "includeForgottenExports": true + }, + "dtsRollup": { + "enabled": false + }, + "tsdocMetadata": { + "enabled": true, + "tsdocMetadataFilePath": "/api-extractor/tsdoc-metadata.json" + }, + "messages": { + "compilerMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "extractorMessageReporting": { + "default": { + "logLevel": "warning" + } + }, + "tsdocMessageReporting": { + "default": { + "logLevel": "warning" + } + } + } +} diff --git a/package.json b/package.json index 68e183f6c..3552eaa0a 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ }, "devDependencies": { "@elastic/request-converter": "8.16.2", + "@microsoft/api-extractor": "^7.47.11", + "@microsoft/api-extractor-model": "^7.29.8", "@sinonjs/fake-timers": "github:sinonjs/fake-timers#0bfffc1", "@types/debug": "4.1.12", "@types/ms": "0.7.34", diff --git a/scripts/docgen.mjs b/scripts/docgen.mjs new file mode 100644 index 000000000..6817c4ca6 --- /dev/null +++ b/scripts/docgen.mjs @@ -0,0 +1,371 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import path from 'path' +import fs from 'fs/promises' +import * as Extractor from '@microsoft/api-extractor-model' + +const header = `//////// +=========================================================================================================================== +|| || +|| || +|| || +|| ██████╗ ███████╗ █████╗ ██████╗ ███╗ ███╗███████╗ || +|| ██╔══██╗██╔════╝██╔══██╗██╔══██╗████╗ ████║██╔════╝ || +|| ██████╔╝█████╗ ███████║██║ ██║██╔████╔██║█████╗ || +|| ██╔══██╗██╔══╝ ██╔══██║██║ ██║██║╚██╔╝██║██╔══╝ || +|| ██║ ██║███████╗██║ ██║██████╔╝██║ ╚═╝ ██║███████╗ || +|| ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝╚══════╝ || +|| || +|| || +|| This file is autogenerated, DO NOT send pull requests that changes this file directly. || +|| You should update the script that does the generation, which can be found in scripts/docgen.mjs. || +|| || +|| || +|| || +=========================================================================================================================== +//////// + +++++ + +++++ +` + +const linkedRefs = new Set() +const documented = new Set() + +function nodesToText (nodes) { + let text = '' + for (const node of nodes) { + if (node.kind === 'Paragraph') { + for (const pNode of node.nodes) { + if (pNode.text) { + text += pNode.text + ' ' + } else if (pNode.kind === 'CodeSpan') { + text += '`' + pNode.code + '`' + } + } + } + } + text = text.replace(/\s+/g, ' ') + return text +} + +const skippableReferences = [ + 'Record', + 'URL', + 'Array', + 'Promise', + 'inspect.custom', + 'http.IncomingHttpHeaders', +] + +function generatePropertyType (tokens) { + let code = '' + tokens.forEach(token => { + if (token.kind === 'Reference' && !skippableReferences.includes(token.text)) { + let { text } = token + if (text.startsWith('T.')) { + text = text.split('.')[1] + } else if (text.startsWith('TB.')) { + text = text.split('.')[1] + '_2' + } + linkedRefs.add(text) + code += `<<${text}>>` + } else { + code += token.text.replace(/\n/g, '') + } + }) + return code.replace(/^export (declare )?/, '').replace(/\s+/g, ' ').trim() +} + +function generateDescription (comment) { + let code = '' + + if (comment == null) return code + + const { summarySection, customBlocks } = comment + + if (summarySection != null || customBlocks != null) { + if (summarySection != null) { + const summary = nodesToText(summarySection.nodes) + code += `${summary}\n\n` + } + + if (customBlocks != null) { + let defaultValue = '' + for (const block of customBlocks) { + if (block.blockTag.tagNameWithUpperCase === '@DEFAULTVALUE') { + defaultValue = nodesToText(block.content.nodes) + } + } + if (defaultValue.length > 0) { + code += `Default value: ${defaultValue}` + } + } + } + + return code.trim() +} + +function generateApiFunction (spec) { + let code = `[[${spec.displayName}_${spec.overloadIndex ?? ''}]]\n` + code += '[source,ts,subs=+macro]\n' + code += '----\n' + code += generatePropertyType(spec.excerptTokens) + code += '\n' + code += '----\n' + return code +} + +function generateInterface (spec) { + let code = `[[${spec.displayName}]]\n` + code += `== Interface ${spec.displayName}\n\n` + code += '[%autowidth]\n' + code += '|===\n' + code += '|Name |Type |Description\n\n' + + for (const member of spec.members) { + if (member.propertyTypeExcerpt == null) continue + code += `|\`${member.displayName}\`\n` + code += `|${generatePropertyType(member.propertyTypeExcerpt.spannedTokens)}\n` + code += `|${generateDescription(member.tsdocComment, false)}\n` + code += '|===\n' + } + + return code +} + +function generateClass(spec) { + let code = `[[${spec.displayName}]]\n` + code += `== ${spec.displayName}\n` + + code += '\n=== Constructor\n\n' + const cons = spec.members.filter(m => m.kind === 'Constructor') + for (const con of cons) { + code += '[source,ts,subs=+macros]\n' + code += '----\n' + code += generatePropertyType(con.excerptTokens).replace(/^constructor/, `new ${spec.displayName}`) + code += '\n' + code += '----\n' + } + + // generate properties + const props = spec.members.filter(m => m.kind === 'Property') + if (props.length > 0) { + code += '\n=== Properties\n' + code += '[%autowidth]\n' + code += '|===\n' + code += '|Name |Type |Description\n\n' + for (const prop of props) { + if (prop.propertyTypeExcerpt == null) continue + if (prop.displayName.startsWith('[k')) continue + + code += `|\`${prop.displayName}\`\n` + code += `|${generatePropertyType(prop.propertyTypeExcerpt.spannedTokens)}\n` + code += `|${generateDescription(prop.tsdocComment, false)}\n` + code += '|===\n' + } + } + + // generate methods + const methods = spec.members.filter(m => m.kind === 'Method') + if (methods.length > 0) { + code += '\n=== Methods\n' + code += '[%autowidth]\n' + code += '|===\n' + code += '|Name |Signature |Description\n\n' + for (const method of methods) { + code += `|\`${method.displayName}\`\n` + code += `|\`${generatePropertyType(method.excerptTokens)}\`\n` + code += `|${generateDescription(method.tsdocComment, false)}\n` + code += '|===\n' + } + } + + return code +} + +function generateAlias(spec) { + let code = `[[${spec.displayName}]]\n` + code += '[discrete]\n' + code += `== \`${spec.displayName}\`\n` + code += '[source,ts,subs=+macros]' + code += '----\n' + code += `${generatePropertyType(spec.excerpt.tokens)}\n` + code += '----\n' + return code +} + +/** + * Generates documentation for ClientOptions interface + * @param spec {Extractor.ApiItem} + * @returns {string} Asciidoc markup + */ +// function generateClientOptions (spec) { +// let code = `[reference-client-options-interface]\n\n== ClientOptions\n\n${header}\n\n` +// code += `[[${spec.displayName}]]\n` +// code += `=== ${spec.displayName}\n\n` +// code += generateInterface(spec) +// return code +// } + +// const standardTypes = { +// 'TlsConnectionOptions': 'https://nodejs.org/api/tls.html#tlsconnectoptions-callback[Node.js TLS connection options]', +// } + +/** + * @param spec {Extractor.ApiItem} + * @param model {Extractor.ApiModel} + * @returns string + */ +// function generateClientOptionsReference (spec, model) { +// let code = `${header}\n\n` +// for (const member of spec.members) { +// for (const token of member.excerptTokens) { +// if (token.kind === 'Reference' && !documented.has(token.text)) { +// documented.add(token.text) +// code += `[discrete]\n` +// code += `[[${token.text}]]\n` +// code += `=== ${token.text}\n\n` +// +// const item = model.packages[0].entryPoints[0].members.find(member => member.displayName === token.text) +// if (item != null) { +// code += generateDescription(item.tsdocComment, false) +// switch (item.kind) { +// case 'Interface': +// code += generateInterface(item) +// break; +// case 'TypeAlias': +// code += generateAlias(item) +// break +// case 'Class': +// console.log('Class', token.text) +// code += generateClass(item) +// break +// default: +// code += 'Undocumented type\n' +// break +// } +// } else if (standardTypes[token.text] != null) { +// code += `${standardTypes[token.text]}\n` +// } else { +// code += 'Unknown\n' +// } +// code += '\n' +// } +// } +// } +// return code +// } + +/** + * Generates documentation for the Client class + * @param spec {Extractor.ApiItem} + * @returns {string} Asciidoc markup + */ +// function generateClientDocs (spec) { +// let code = `[reference-client-class]\n\n== Client\n\n${header}\n\n` +// +// // generate constructor and client options +// code += '[discrete]\n' +// code += '=== Constructor\n\n' +// code += '[source,ts,subs=+macros]\n' +// code += '----\n' +// code += 'new Client(options: <>): Client\n' +// code += '----\n\n' +// +// // generate methods +// code += '[discrete]\n' +// code += '=== Methods\n\n' +// for (const method of spec.members.filter(m => m.kind === 'Method')) { +// code += `[[Client.${method.displayName}]]\n` +// code += '[discrete]\n' +// code += `==== Client.${method.displayName}\n\n` +// code += 'TODO\n\n' +// } +// +// // generate properties +// code += '[discrete]\n' +// code += '=== Properties\n\n' +// for (const prop of spec.members.filter(m => m.kind === 'Property')) { +// code += `[[Client.${prop.displayName}]]\n` +// code += '[discrete]\n' +// code += `==== Client.${prop.displayName}\n\n` +// code += 'TODO\n\n' +// } +// +// return code +// } + +async function write (name, code) { + const filePath = path.join(import.meta.dirname, '..', 'docs', 'reference2', `${name}.asciidoc`) + // console.log(`writing ${filePath}`) + await fs.writeFile(filePath, code, 'utf8') +} + +async function start () { + const model = new Extractor.ApiModel() + const pkg = model.loadPackage(path.join(import.meta.dirname, '..', 'api-extractor', 'elasticsearch.api.json')) + const entry = pkg.entryPoints[0] + + for (const member of entry.members) { + if (member.displayName.endsWith('_2')) continue + switch (member.kind) { + case 'Class': + await write(member.displayName, generateClass(member)) + break + case 'Interface': + await write(member.displayName, generateInterface(member)) + break + case 'TypeAlias': + await write(member.displayName, generateAlias(member)) + break + case 'Function': + if (member.fileUrlPath.startsWith('lib/api/api')) { + // if (member.displayName === 'CountApi') console.log(member) + // TODO: drop this: That stuff + // TODO: sub name with `client.foo.bar` + await write(`${member.displayName}_${member.overloadIndex ?? ''}`, generateApiFunction(member)) + // TODO: generate rollup page for each override + } + // console.log(member) + // process.exit(0) + break + case 'Namespace': + case 'Variable': + break + default: + console.log('unsupported type', member.kind, member.displayName) + break + } + // TODO: generate rollup page that includes a whole API namespace's functions, requests, responses + } +} + +start() + .then(() => process.exit(0)) + .catch(err => { + console.error(err) + process.exit(1) + })