diff options
| author | ST-DDT <[email protected]> | 2024-04-01 10:21:18 +0200 |
|---|---|---|
| committer | GitHub <[email protected]> | 2024-04-01 10:21:18 +0200 |
| commit | 6191a5d883048b694404dbf42527caba395828ea (patch) | |
| tree | d0f18f17789cb0bbdb5d6087f1a95772438dfe27 /scripts/apidocs/processing | |
| parent | 7dae52bfcd93c41ec9d2c4dd4d96a07f31c3dfc1 (diff) | |
| download | faker-6191a5d883048b694404dbf42527caba395828ea.tar.xz faker-6191a5d883048b694404dbf42527caba395828ea.zip | |
docs: rewrite api-docs generation using ts-morph (#2628)
Diffstat (limited to 'scripts/apidocs/processing')
| -rw-r--r-- | scripts/apidocs/processing/class.ts | 223 | ||||
| -rw-r--r-- | scripts/apidocs/processing/error.ts | 40 | ||||
| -rw-r--r-- | scripts/apidocs/processing/jsdocs.ts | 91 | ||||
| -rw-r--r-- | scripts/apidocs/processing/method.ts | 197 | ||||
| -rw-r--r-- | scripts/apidocs/processing/parameter.ts | 203 | ||||
| -rw-r--r-- | scripts/apidocs/processing/signature.ts | 158 | ||||
| -rw-r--r-- | scripts/apidocs/processing/source.ts | 37 | ||||
| -rw-r--r-- | scripts/apidocs/processing/type.ts | 231 |
8 files changed, 1180 insertions, 0 deletions
diff --git a/scripts/apidocs/processing/class.ts b/scripts/apidocs/processing/class.ts new file mode 100644 index 00000000..5af5e874 --- /dev/null +++ b/scripts/apidocs/processing/class.ts @@ -0,0 +1,223 @@ +import type { ClassDeclaration, InterfaceDeclaration, Project } from 'ts-morph'; +import { required, valuesForKeys } from '../utils/value-checks'; +import { newProcessingError } from './error'; +import type { JSDocableLikeNode } from './jsdocs'; +import { + getDeprecated, + getDescription, + getExamples, + getJsDocs, +} from './jsdocs'; +import type { RawApiDocsMethod } from './method'; +import { + processClassConstructors, + processClassMethods, + processInterfaceMethods, + processProjectFunctions, +} from './method'; + +/** + * Represents a raw page in the API docs. + */ +export interface RawApiDocsPage { + /** + * The title of the page as shown to users. + */ + title: string; + /** + * The title of the page in camel case as used in paths. + */ + camelTitle: string; + /** + * The category of the page, if it has one. + */ + category: string | undefined; + /** + * The deprecation notice of the page, if it has one. + */ + deprecated: string | undefined; + /** + * The description of the page. + */ + description: string; + /** + * The usage examples of the elements on the page. + */ + examples: string[]; + /** + * The api methods on the page. + */ + methods: RawApiDocsMethod[]; +} + +// Classes + +function getAllClasses( + project: Project, + filter: (name: string) => boolean = () => true +): Record<string, ClassDeclaration> { + return Object.fromEntries( + project + .getSourceFiles() + .flatMap((file) => file.getClasses()) + .map((clazz) => [clazz.getNameOrThrow(), clazz] as const) + .filter(([name]) => filter(name)) + ); +} + +export function processProjectClasses(project: Project): RawApiDocsPage[] { + return processClasses( + valuesForKeys(getAllClasses(project), ['Faker', 'SimpleFaker']) + ); +} + +function processClasses(classes: ClassDeclaration[]): RawApiDocsPage[] { + return classes.map((clazz) => { + try { + return processClass(clazz); + } catch (error) { + throw newProcessingError({ + type: 'class', + name: clazz.getNameOrThrow(), + source: clazz, + cause: error, + }); + } + }); +} + +export function processClass(clazz: ClassDeclaration): RawApiDocsPage { + const result = processModule(clazz); + result.methods.unshift(...processClassConstructors(clazz)); + return result; +} + +// Modules + +export function processModuleClasses(project: Project): RawApiDocsPage[] { + return processModules( + Object.values( + getAllClasses( + project, + (module: string): boolean => + module.endsWith('Module') && !module.startsWith('Simple') + ) + ).sort((a, b) => a.getNameOrThrow().localeCompare(b.getNameOrThrow())) + ); +} + +function processModules(modules: ClassDeclaration[]): RawApiDocsPage[] { + return modules.map((module) => { + try { + return processModule(module, 'Modules'); + } catch (error: unknown) { + throw newProcessingError({ + type: 'module', + name: getModuleName(module), + source: module, + cause: error, + }); + } + }); +} + +function processModule( + module: ClassDeclaration, + category: string | undefined = undefined +): RawApiDocsPage { + const title = getModuleName(module); + + return { + ...preparePage(module, title, category), + methods: processClassMethods(module), + }; +} + +function getModuleName(module: ClassDeclaration): string { + return required(module.getName(), 'module name').replace(/Module$/, ''); +} + +// Interfaces + +function getAllInterfaces( + project: Project +): Record<string, InterfaceDeclaration> { + return Object.fromEntries( + project + .getSourceFiles() + .flatMap((file) => file.getInterfaces()) + .map((iface) => [iface.getName(), iface] as const) + ); +} + +export function processProjectInterfaces(project: Project): RawApiDocsPage[] { + return processInterfaces( + valuesForKeys(getAllInterfaces(project), ['Randomizer']) + ); +} + +function processInterfaces( + interfaces: InterfaceDeclaration[] +): RawApiDocsPage[] { + return interfaces.map((iface) => { + try { + return processInterface(iface); + } catch (error) { + throw newProcessingError({ + type: 'interface', + name: iface.getName(), + source: iface, + cause: error, + }); + } + }); +} + +function processInterface(iface: InterfaceDeclaration): RawApiDocsPage { + return { + ...preparePage(iface, iface.getName()), + methods: processInterfaceMethods(iface), + }; +} + +// Utilities + +export function processProjectUtilities(project: Project): RawApiDocsPage { + console.log(`- Utilities`); + + return { + title: 'Utilities', + camelTitle: 'utils', + category: undefined, + deprecated: undefined, + description: 'A list of all the utilities available in Faker.js.', + examples: [], + methods: processProjectFunctions(project, 'mergeLocales'), + }; +} + +// Helpers + +function preparePage( + module: JSDocableLikeNode, + title: string, + category: string | undefined = undefined +): RawApiDocsPage { + console.log(`- ${title}`); + + const jsdocs = getJsDocs(module); + + return { + title, + camelTitle: toCamelCase(title), + category, + deprecated: getDeprecated(jsdocs), + description: getDescription(jsdocs), + examples: getExamples(jsdocs), + methods: [], + }; +} + +function toCamelCase(value: string): string { + return value.substring(0, 1).toLowerCase() + value.substring(1); +} diff --git a/scripts/apidocs/processing/error.ts b/scripts/apidocs/processing/error.ts new file mode 100644 index 00000000..f171d6b4 --- /dev/null +++ b/scripts/apidocs/processing/error.ts @@ -0,0 +1,40 @@ +import { FakerError } from '../../../src/errors/faker-error'; +import type { SourceableNode } from './source'; +import { getSourcePath } from './source'; + +export class FakerApiDocsProcessingError extends FakerError { + constructor(options: { + type: string; + name: string; + source: string | SourceableNode; + cause: unknown; + }) { + const { type, name, source, cause } = options; + const sourceText = + typeof source === 'string' ? source : getSourcePathText(source); + const causeText = cause instanceof Error ? cause.message : ''; + super(`Failed to process ${type} ${name} at ${sourceText} : ${causeText}`, { + cause, + }); + } +} + +export function newProcessingError(options: { + type: string; + name: string; + source: string | SourceableNode; + cause: unknown; +}): FakerApiDocsProcessingError { + const { cause } = options; + + if (cause instanceof FakerApiDocsProcessingError) { + return cause; + } + + return new FakerApiDocsProcessingError(options); +} + +function getSourcePathText(source: SourceableNode): string { + const { filePath, line, column } = getSourcePath(source); + return `${filePath}:${line}:${column}`; +} diff --git a/scripts/apidocs/processing/jsdocs.ts b/scripts/apidocs/processing/jsdocs.ts new file mode 100644 index 00000000..4a8e5b08 --- /dev/null +++ b/scripts/apidocs/processing/jsdocs.ts @@ -0,0 +1,91 @@ +import type { JSDoc, JSDocTag, JSDocableNode } from 'ts-morph'; +import { JSDocParameterTag, JSDocTemplateTag } from 'ts-morph'; +import { + allRequired, + exactlyOne, + optionalOne, + required, +} from '../utils/value-checks'; + +export type JSDocableLikeNode = Pick<JSDocableNode, 'getJsDocs'>; + +export function getJsDocs(node: JSDocableLikeNode): JSDoc { + return exactlyOne(node.getJsDocs(), 'jsdocs'); +} + +export function getDeprecated(jsdocs: JSDoc): string | undefined { + return getOptionalTagFromJSDoc(jsdocs, 'deprecated'); +} + +export function getDescription(jsdocs: JSDoc | JSDocTag): string { + return required(jsdocs.getCommentText(), 'jsdocs description'); +} + +export function getSince(jsdocs: JSDoc): string { + return getExactlyOneTagFromJSDoc(jsdocs, 'since'); +} + +export function getTypeParameterTags(jsdocs: JSDoc): Record<string, JSDocTag> { + return Object.fromEntries( + jsdocs + .getTags() + .filter((tag) => tag.getTagName() === 'template') + .filter((tag) => tag instanceof JSDocTemplateTag) + .map((tag) => tag as JSDocTemplateTag) + .map((tag) => [tag.getTypeParameters()[0].getName(), tag] as const) + ); +} + +export function getParameterTags(jsdocs: JSDoc): Record<string, JSDocTag> { + return Object.fromEntries( + jsdocs + .getTags() + .filter((tag) => tag.getTagName() === 'param') + .filter((tag) => tag instanceof JSDocParameterTag) + .map((tag) => tag as JSDocParameterTag) + .map((tag) => [tag.getName(), tag] as const) + ); +} + +export function getDefault(jsdocs: JSDoc): string | undefined { + return getOptionalTagFromJSDoc(jsdocs, `default`); +} + +export function getThrows(jsdocs: JSDoc): string[] { + return getTagsFromJSDoc(jsdocs, 'throws'); +} + +export function getExamples(jsdocs: JSDoc): string[] { + return getTagsFromJSDoc(jsdocs, 'example'); +} + +export function getSeeAlsos(jsdocs: JSDoc): string[] { + return getTagsFromJSDoc(jsdocs, 'see', true); +} + +function getOptionalTagFromJSDoc( + jsdocs: JSDoc, + type: string +): string | undefined { + return optionalOne(getTagsFromJSDoc(jsdocs, type), `@${type}`); +} + +function getExactlyOneTagFromJSDoc(jsdocs: JSDoc, type: string): string { + return exactlyOne(getTagsFromJSDoc(jsdocs, type), `@${type}`); +} + +function getTagsFromJSDoc( + jsdocs: JSDoc, + type: string, + full: boolean = false +): string[] { + return allRequired( + jsdocs + .getTags() + .filter((tag) => tag.getTagName() === type) + .map((tag) => + full ? tag.getStructure().text?.toString() : tag.getCommentText() + ), + `@${type}` + ); +} diff --git a/scripts/apidocs/processing/method.ts b/scripts/apidocs/processing/method.ts new file mode 100644 index 00000000..b9d1aa38 --- /dev/null +++ b/scripts/apidocs/processing/method.ts @@ -0,0 +1,197 @@ +import type { + ClassDeclaration, + FunctionDeclaration, + InterfaceDeclaration, + MethodSignature, + Project, +} from 'ts-morph'; +import { + SyntaxKind, + type ConstructorDeclaration, + type MethodDeclaration, +} from 'ts-morph'; +import { groupBy } from '../../../src/internal/group-by'; +import { valuesForKeys } from '../utils/value-checks'; +import { newProcessingError } from './error'; +import type { + RawApiDocsSignature, + SignatureLikeDeclaration, +} from './signature'; +import { processSignatures } from './signature'; +import type { RawApiDocsSource } from './source'; +import { getSourcePath as getSource } from './source'; + +/** + * Represents a method in the raw API docs. + */ +export interface RawApiDocsMethod { + /** + * The name of the method. + */ + name: string; + /** + * The signatures of the method. + */ + signatures: RawApiDocsSignature[]; + /** + * The source of the method. + */ + source: RawApiDocsSource; +} + +// Constructors + +export function processClassConstructors( + clazz: ClassDeclaration +): RawApiDocsMethod[] { + return processConstructors(clazz.getConstructors()); +} + +function processConstructors( + constructors: ConstructorDeclaration[] +): RawApiDocsMethod[] { + return processMethodLikes(constructors, () => 'constructor'); +} + +// Class Methods + +export function processClassMethods( + clazz: ClassDeclaration +): RawApiDocsMethod[] { + return processMethods(getAllMethods(clazz)); +} + +function getAllMethods(clazz: ClassDeclaration): MethodDeclaration[] { + const parents: ClassDeclaration[] = [clazz]; + let parent: ClassDeclaration | undefined = clazz; + while ((parent = parent.getBaseClass()) != null) { + parents.unshift(parent); + } + + const methods: Record<string, MethodDeclaration> = {}; + + for (const parent of parents) { + for (const method of parent.getMethods()) { + methods[method.getName()] = method; + } + } + + return Object.values(methods).sort((a, b) => + a.getName().localeCompare(b.getName()) + ); +} + +type NamedMethodLikeDeclaration = MethodLikeDeclaration & + Pick<MethodDeclaration, 'getName'>; + +function processMethods( + methods: NamedMethodLikeDeclaration[] +): RawApiDocsMethod[] { + return processMethodLikes(methods, (v) => v.getName()); +} + +// Interface Methods + +export function processInterfaceMethods( + iface: InterfaceDeclaration +): RawApiDocsMethod[] { + return processMethodSignatures(iface.getMethods()); +} + +function processMethodSignatures( + methods: MethodSignature[] +): RawApiDocsMethod[] { + const groupedSignatures = groupBy(methods, (v) => v.getName()); + + const methodLikes: NamedMethodLikeDeclaration[] = Object.values( + groupedSignatures + ).map((signatures) => { + const signature = signatures[0]; + + return { + getName: () => signature.getName(), + hasModifier: () => false, + getOverloads: () => signatures, + getTypeParameters: () => signature.getTypeParameters(), + getParameters: () => signature.getParameters(), + getReturnType: () => signature.getReturnType(), + getJsDocs: () => signature.getJsDocs(), + getSourceFile: () => signature.getSourceFile(), + getStart: () => signature.getStart(), + getText: () => signature.getText(), + }; + }); + + return processMethods(methodLikes); +} + +// Functions + +function getAllFunctions( + project: Project +): Record<string, FunctionDeclaration> { + return Object.fromEntries( + project + .getSourceFiles() + .flatMap((file) => file.getFunctions()) + .map((fn) => [fn.getNameOrThrow(), fn] as const) + ); +} + +export function processProjectFunctions( + project: Project, + ...names: string[] +): RawApiDocsMethod[] { + return processMethodLikes( + valuesForKeys(getAllFunctions(project), names), + (f) => f.getNameOrThrow() + ); +} + +// Method-likes + +type MethodLikeDeclaration = SignatureLikeDeclaration & + Pick<MethodDeclaration, 'hasModifier'> & { + getOverloads(): SignatureLikeDeclaration[]; + }; + +function processMethodLikes<T extends MethodLikeDeclaration>( + methods: T[], + nameResolver: (value: T) => string +): RawApiDocsMethod[] { + return methods + .filter((method) => !method.hasModifier(SyntaxKind.PrivateKeyword)) + .map((method) => { + const name = nameResolver(method); + try { + return processMethodLike(name, method); + } catch (error) { + throw newProcessingError({ + type: 'method', + name, + source: method, + cause: error, + }); + } + }) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +export function processMethodLike( + name: string, + method: MethodLikeDeclaration +): RawApiDocsMethod { + console.log(` - ${name}`); + const overloads = method.getOverloads(); + const signatureData: SignatureLikeDeclaration[] = + overloads.length > 0 ? overloads : [method]; + + const signatures = processSignatures(name, signatureData, method); + const source = getSource(method); + + return { + name, + signatures, + source, + }; +} diff --git a/scripts/apidocs/processing/parameter.ts b/scripts/apidocs/processing/parameter.ts new file mode 100644 index 00000000..7a6b67da --- /dev/null +++ b/scripts/apidocs/processing/parameter.ts @@ -0,0 +1,203 @@ +import type { + PropertySignature, + Type, + TypeParameterDeclaration, +} from 'ts-morph'; +import { type JSDoc, type JSDocTag, type ParameterDeclaration } from 'ts-morph'; +import { exactlyOne, valueForKey } from '../utils/value-checks'; +import { newProcessingError } from './error'; +import { + getDefault, + getDeprecated, + getDescription, + getJsDocs, + getParameterTags, + getTypeParameterTags, +} from './jsdocs'; +import type { RawApiDocsType } from './type'; +import { getNameSuffix, getTypeText, isOptionsLikeType } from './type'; + +/** + * Represents a parameter in the raw API docs. + */ +export interface RawApiDocsParameter { + /** + * The name of the parameter. + */ + name: string; + /** + * The type of the parameter. + */ + type: RawApiDocsType; + /** + * The default value or expression of the parameter, if it has one. + */ + default: string | undefined; + /** + * The description of the parameter. + */ + description: string; +} + +export function processTypeParameters( + parameters: TypeParameterDeclaration[], + jsdocs: JSDoc +): RawApiDocsParameter[] { + const paramTags = getTypeParameterTags(jsdocs); + + return parameters.flatMap((parameter) => { + try { + return processTypeParameterEntry(parameter, paramTags); + } catch (error) { + throw newProcessingError({ + type: 'type parameter', + name: parameter.getName(), + source: parameter, + cause: error, + }); + } + }); +} + +function processTypeParameterEntry( + parameter: TypeParameterDeclaration, + paramTags: Record<string, JSDocTag> +): RawApiDocsParameter { + return { + name: `<${parameter.getName()}>`, + type: getTypeText(parameter.getType(), { resolveAliases: true }), + default: parameter.getDefault()?.getText(), + description: getDescription(valueForKey(paramTags, parameter.getName())), + }; +} + +export function processParameters( + signatureParameters: ParameterDeclaration[], + implParameters: ParameterDeclaration[], + jsdocs: JSDoc +): RawApiDocsParameter[] { + const paramTags = getParameterTags(jsdocs); + const implParameterDefaults = Object.fromEntries( + implParameters.map((parameter) => [ + parameter.getName(), + getDefaultValue(parameter), + ]) + ); + + return signatureParameters.flatMap((parameter) => { + try { + return processParameter( + parameter, + paramTags, + implParameterDefaults[parameter.getName()] + ); + } catch (error) { + throw newProcessingError({ + type: 'parameter', + name: parameter.getName(), + source: parameter, + cause: error, + }); + } + }); +} + +function processParameter( + parameter: ParameterDeclaration, + paramTags: Record<string, JSDocTag>, + implementationDefault: string | undefined +): RawApiDocsParameter[] { + const name = parameter.getName(); + return [ + processSimpleParameter( + parameter, + valueForKey(paramTags, name), + implementationDefault + ), + ...processComplexParameter(name, parameter.getType()), + ]; +} + +type ParameterLikeDeclaration = Pick< + ParameterDeclaration, + 'getName' | 'getType' +> & + Partial<Pick<ParameterDeclaration, 'getInitializer'>>; + +function processSimpleParameter( + parameter: ParameterLikeDeclaration, + jsdocTag: JSDocTag, + implementationDefault: string | undefined +): RawApiDocsParameter { + const name = parameter.getName(); + const type = parameter.getType(); + return { + name: `${name}${getNameSuffix(type)}`, + type: getTypeText(type, { + abbreviate: true, + stripUndefined: true, + }), + default: getDefaultValue(parameter) ?? implementationDefault, + description: getDescription(jsdocTag), + }; +} + +function getDefaultValue( + parameter: ParameterLikeDeclaration +): string | undefined { + return parameter + .getInitializer?.() + ?.getText() + .replace(/ as .+$/, ''); +} + +function processComplexParameter( + name: string, + type: Type +): RawApiDocsParameter[] { + if (type.isNullable()) { + return processComplexParameter(name, type.getNonNullableType()); + } else if (type.isUnion()) { + return type + .getUnionTypes() + .flatMap((unionType) => processComplexParameter(name, unionType)); + } else if (type.isArray()) { + return processComplexParameter( + `${name}[]`, + type.getArrayElementTypeOrThrow() + ); + } else if (type.isObject()) { + if (!isOptionsLikeType(type)) { + return []; + } + + return type + .getApparentProperties() + .flatMap((parameter) => { + const declaration = exactlyOne( + parameter.getDeclarations(), + 'property declaration' + ) as PropertySignature; + const propertyType = declaration.getType(); + const jsdocs = getJsDocs(declaration); + const deprecated = getDeprecated(jsdocs); + + return [ + { + name: `${name}.${parameter.getName()}${getNameSuffix(propertyType)}`, + type: getTypeText(propertyType, { + abbreviate: false, + stripUndefined: true, + }), + default: getDefault(jsdocs), + description: + getDescription(jsdocs) + + (deprecated ? `\n\n**DEPRECATED:** ${deprecated}` : ''), + }, + ]; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + return []; +} diff --git a/scripts/apidocs/processing/signature.ts b/scripts/apidocs/processing/signature.ts new file mode 100644 index 00000000..c7ae872b --- /dev/null +++ b/scripts/apidocs/processing/signature.ts @@ -0,0 +1,158 @@ +import type { MethodDeclaration } from 'ts-morph'; +import { getProject } from '../project'; +import { exactlyOne } from '../utils/value-checks'; +import { newProcessingError } from './error'; +import type { JSDocableLikeNode } from './jsdocs'; +import { + getDeprecated, + getDescription, + getExamples, + getJsDocs, + getSeeAlsos, + getSince, + getThrows, +} from './jsdocs'; +import type { RawApiDocsParameter } from './parameter'; +import { processParameters, processTypeParameters } from './parameter'; +import type { SourceableNode } from './source'; +import type { RawApiDocsType } from './type'; +import { getTypeText } from './type'; + +/** + * Represents a method signature in the raw API docs. + */ +export interface RawApiDocsSignature { + /** + * The deprecation notice of the signature, if it has one. + */ + deprecated: string | undefined; + /** + * The description of the signature. + */ + description: string; + /** + * The version when the signature was added. + */ + since: string; + /** + * The parameters of the signature. + */ + parameters: RawApiDocsParameter[]; + /** + * The return type of the signature. + */ + returns: RawApiDocsType; + /** + * The exceptions thrown by the signature. + */ + throws: string[]; + /** + * The full call signature as text. + */ + signature: string; + /** + * The usage examples of the signature. + */ + examples: string[]; + /** + * The see also links of the signature. + */ + seeAlsos: string[]; +} + +export type SignatureLikeDeclaration = Pick< + MethodDeclaration, + 'getTypeParameters' | 'getParameters' | 'getReturnType' | 'getText' +> & + JSDocableLikeNode & + SourceableNode; + +export function processSignatures( + name: string, + signatures: SignatureLikeDeclaration[], + implementation: SignatureLikeDeclaration +): RawApiDocsSignature[] { + return signatures.map((signature, i) => { + try { + return processSignature(signature, implementation); + } catch (error) { + throw newProcessingError({ + type: 'signature', + name: `${name}/${i}`, + source: signature, + cause: error, + }); + } + }); +} + +function processSignature( + signature: SignatureLikeDeclaration, + implementation: SignatureLikeDeclaration +): RawApiDocsSignature { + const jsdocs = getJsDocs(signature); + const parameters = [ + ...processTypeParameters(signature.getTypeParameters(), jsdocs), + ...processParameters( + signature.getParameters(), + implementation.getParameters(), + jsdocs + ), + ]; + const returns = getTypeText(signature.getReturnType()); + + try { + return { + deprecated: getDeprecated(jsdocs), + description: getDescription(jsdocs), + since: getSince(jsdocs), + parameters, + returns, + throws: getThrows(jsdocs), + signature: getSignatureText(signature), + examples: getExamples(jsdocs), + seeAlsos: getSeeAlsos(jsdocs), + }; + } catch (error) { + throw newProcessingError({ + type: 'jsdocs', + name: signature.getText(), + source: jsdocs, + cause: error, + }); + } +} + +// Cache the project for performance reasons +const signatureExtractionProject = getProject({ + skipAddingFilesFromTsConfig: true, +}); + +function getSignatureText(signature: SignatureLikeDeclaration): string { + const fullText = signature + .getText() + // Remove all jsdocs + .replaceAll(/ *\/\*\*[^\n]*\n(\s*\*[^\n]*\n)*\s*\*\/\n/g, '') + // Remove all empty lines + .replaceAll(/\n\n+/g, '\n') + // Remove the export function keyword for consistency with member methods + .replace(/^export function /, ''); + + // Is this already a signature + if (fullText.endsWith(';')) { + // Restore the function keyword + return `function ${fullText}`; + } + + // Create a copy of the signature to keep the line numbers unchanged + // and for performance reasons, as removing and re-adding the body is slow. + // We use a function here to avoid unnecessary boilerplate + const fn = exactlyOne( + signatureExtractionProject + .createSourceFile('temp.ts', `function ${fullText}`, { overwrite: true }) + .getFunctions(), + 'function signature' + ); + fn.removeBody(); + return fn.getText(); +} diff --git a/scripts/apidocs/processing/source.ts b/scripts/apidocs/processing/source.ts new file mode 100644 index 00000000..ac23c9fb --- /dev/null +++ b/scripts/apidocs/processing/source.ts @@ -0,0 +1,37 @@ +import type { Node } from 'ts-morph'; +import { FILE_PATH_PROJECT } from '../utils/paths'; + +/** + * Represents a source element in the raw API docs. + */ +export interface RawApiDocsSource { + /** + * The file path of the target element. + */ + filePath: string; + /** + * The line number of the target element. + */ + line: number; + /** + * The column number of the target element. + */ + column: number; +} + +export type SourceableNode = Pick<Node, 'getSourceFile' | 'getStart'>; + +export function getSourcePath(node: SourceableNode): RawApiDocsSource { + const sourceFile = node.getSourceFile(); + const filePath = sourceFile + .getFilePath() + .substring(FILE_PATH_PROJECT.length + 1); + const startPosition = node.getStart(); + const { line, column } = sourceFile.getLineAndColumnAtPos(startPosition); + + return { + filePath, + line, + column, + }; +} diff --git a/scripts/apidocs/processing/type.ts b/scripts/apidocs/processing/type.ts new file mode 100644 index 00000000..25ec30c9 --- /dev/null +++ b/scripts/apidocs/processing/type.ts @@ -0,0 +1,231 @@ +import { TypeFlags, type Type } from 'ts-morph'; +import { atLeastOneAndAllRequired, required } from '../utils/value-checks'; + +export type RawApiDocsType = + | RawApiDocsSimpleType + | RawApiDocsGenericType + | RawApiDocsUnionType + | RawApiDocsShadowType; + +interface RawApiDocsBaseType { + type: string; + text: string; +} + +export interface RawApiDocsSimpleType extends RawApiDocsBaseType { + type: 'simple'; +} + +export interface RawApiDocsGenericType extends RawApiDocsBaseType { + type: 'generic'; + typeParameters: RawApiDocsType[]; +} + +export interface RawApiDocsUnionType extends RawApiDocsBaseType { + type: 'union'; + types: RawApiDocsType[]; +} + +export interface RawApiDocsShadowType extends RawApiDocsBaseType { + type: 'shadow'; + resolvedType: RawApiDocsType; +} + +export function getNameSuffix(type: Type): string { + return type.isNullable() ? '?' : ''; +} + +export function getTypeText( + type: Type, + options: { + abbreviate?: boolean; + stripUndefined?: boolean; + resolveAliases?: boolean; + } = {} +): RawApiDocsType { + const { + abbreviate = false, + stripUndefined = false, + resolveAliases = false, + } = options; + + if ( + type.isAny() || + type.isUnknown() || + type.isBoolean() || + type.isBooleanLiteral() || + type.isNumber() || + type.isNumberLiteral() || + type.getFlags() & TypeFlags.BigInt || + type.getFlags() & TypeFlags.ESSymbol || + type.isString() || + type.isUndefined() || + type.isNull() || + type.isVoid() || + type.isNever() + ) { + return newSimpleType(type.getText()); + } else if (type.isStringLiteral()) { + return newSimpleType(type.getText().replace(/^"(.*)"$/, "'$1'")); + } else if (type.isArray()) { + return newArrayType( + getTypeText(type.getArrayElementTypeOrThrow(), options) + ); + } else if (stripUndefined && type.isNullable()) { + return getTypeText(type.getNonNullableType(), options); + } + + const symbol = type.getSymbol() ?? type.getAliasSymbol(); + if (!resolveAliases && symbol) { + const name = symbol.getName(); + if (name !== '__type') { + const typeArguments = [ + ...type.getTypeArguments(), + ...type.getAliasTypeArguments(), + ]; + + if (name === 'LiteralUnion') { + const displayType = getTypeText(typeArguments[0], options); + const baseType = typeArguments[1] + ? getTypeText(typeArguments[1], options) + : newSimpleType('string'); + + return newUnionType([displayType, baseType]); + } + + const typeParameters = typeArguments.map((t) => getTypeText(t, options)); + + if (typeParameters.length === 0) { + const resolvedType = getTypeText(type, { + ...options, + resolveAliases: true, + }); + + if (name === resolvedType.text) { + return newSimpleType(name); + } + + return newShadowType(name, resolvedType); + } + + return newGenericType(name, typeParameters); + } + } + + if (type.isUnion()) { + let unionTypes = type + .getUnionTypes() + .map((unionType) => getTypeText(unionType, options)) + .filter((unionType) => !stripUndefined || unionType.text !== 'undefined'); + + const trueIndex = unionTypes.findIndex( + (unionType) => unionType.text === 'true' + ); + if ( + trueIndex !== -1 && + unionTypes.some((unionType) => unionType.text === 'false') + ) { + unionTypes[trueIndex] = newSimpleType('boolean'); + unionTypes = unionTypes.filter( + (unionType) => unionType.text !== 'true' && unionType.text !== 'false' + ); + } + + if (unionTypes.length === 1) { + return unionTypes[0]; + } + + return newUnionType(unionTypes); + } + + if (abbreviate && isOptionsLikeType(type)) { + return newSimpleType('{ ... }'); + } + + if (resolveAliases && type.isTypeParameter()) { + const text = getTypeText(type.getApparentType(), { + ...options, + resolveAliases: true, + }); + + if (text.text === 'unknown') { + return newSimpleType('any'); + } + + return text; + } + + return newSimpleType(type.getText().replaceAll(/import\([^)]*\)\./g, '')); +} + +export function isOptionsLikeType(type: Type): boolean { + return ( + type.isObject() && + type.isAnonymous() && + type.getCallSignatures().length === 0 && + type.getTupleElements().length === 0 + ); +} + +function newSimpleType(name: string): RawApiDocsSimpleType { + required(name, 'name'); + return { type: 'simple', text: name }; +} + +function newArrayType(typeParameter: RawApiDocsType): RawApiDocsGenericType { + const { text } = required(typeParameter, 'array type'); + const useGeneric = text.includes('|') || text.includes('{'); + return { + type: 'generic', + typeParameters: [typeParameter], + text: useGeneric ? `Array<${text}>` : `${text}[]`, + }; +} + +function newGenericType( + name: string, + typeParameters: RawApiDocsType[] +): RawApiDocsType { + required(name, 'name'); + atLeastOneAndAllRequired(typeParameters, 'type parameters'); + return { + type: 'generic', + typeParameters, + text: `${name}<${typeParameters.map((t) => t.text).join(', ')}>`, + }; +} + +function newUnionType(types: RawApiDocsType[]): RawApiDocsUnionType { + atLeastOneAndAllRequired(types, 'unions'); + return { + type: 'union', + types, + text: types + .map((type) => type.text) + .map((text) => + // Remove LiteralUnion shadow types + text.endsWith(' & { zz_IGNORE_ME?: undefined; }') + ? text.slice(0, -32) + : text + ) + .map((text) => { + // () => T -> (() => T) + const isFunctionSignature = text.startsWith('('); + return isFunctionSignature ? `(${text})` : text; + }) + .join(' | '), + }; +} + +function newShadowType( + displayText: string, + resolvedType: RawApiDocsType +): RawApiDocsShadowType { + required(displayText, 'display text'); + required(resolvedType, 'resolved type'); + return { + type: 'shadow', + resolvedType, + text: displayText, + }; +} |
