aboutsummaryrefslogtreecommitdiff
path: root/scripts/apidocs/processing
diff options
context:
space:
mode:
authorST-DDT <[email protected]>2024-04-01 10:21:18 +0200
committerGitHub <[email protected]>2024-04-01 10:21:18 +0200
commit6191a5d883048b694404dbf42527caba395828ea (patch)
treed0f18f17789cb0bbdb5d6087f1a95772438dfe27 /scripts/apidocs/processing
parent7dae52bfcd93c41ec9d2c4dd4d96a07f31c3dfc1 (diff)
downloadfaker-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.ts223
-rw-r--r--scripts/apidocs/processing/error.ts40
-rw-r--r--scripts/apidocs/processing/jsdocs.ts91
-rw-r--r--scripts/apidocs/processing/method.ts197
-rw-r--r--scripts/apidocs/processing/parameter.ts203
-rw-r--r--scripts/apidocs/processing/signature.ts158
-rw-r--r--scripts/apidocs/processing/source.ts37
-rw-r--r--scripts/apidocs/processing/type.ts231
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,
+ };
+}