From 6191a5d883048b694404dbf42527caba395828ea Mon Sep 17 00:00:00 2001 From: ST-DDT Date: Mon, 1 Apr 2024 10:21:18 +0200 Subject: docs: rewrite api-docs generation using ts-morph (#2628) --- scripts/apidoc.ts | 7 - scripts/apidoc/diff.ts | 94 ------- scripts/apidoc/faker-class.ts | 86 ------ scripts/apidoc/faker-utilities.ts | 40 --- scripts/apidoc/format.ts | 31 --- scripts/apidoc/generate.ts | 42 --- scripts/apidoc/markdown.ts | 87 ------ scripts/apidoc/module-methods.ts | 122 --------- scripts/apidoc/parameter-defaults.ts | 136 ---------- scripts/apidoc/signature.ts | 365 -------------------------- scripts/apidoc/typedoc.ts | 421 ------------------------------ scripts/apidoc/utils.ts | 79 ------ scripts/apidoc/writer.ts | 249 ------------------ scripts/apidocs.ts | 7 + scripts/apidocs/diff.ts | 97 +++++++ scripts/apidocs/generate.ts | 45 ++++ scripts/apidocs/output/constants.ts | 1 + scripts/apidocs/output/diff-index.ts | 81 ++++++ scripts/apidocs/output/page-index.ts | 38 +++ scripts/apidocs/output/page.ts | 172 ++++++++++++ scripts/apidocs/output/search-index.ts | 34 +++ scripts/apidocs/output/source-base-url.ts | 38 +++ scripts/apidocs/processing/class.ts | 223 ++++++++++++++++ scripts/apidocs/processing/error.ts | 40 +++ scripts/apidocs/processing/jsdocs.ts | 91 +++++++ scripts/apidocs/processing/method.ts | 197 ++++++++++++++ scripts/apidocs/processing/parameter.ts | 203 ++++++++++++++ scripts/apidocs/processing/signature.ts | 158 +++++++++++ scripts/apidocs/processing/source.ts | 37 +++ scripts/apidocs/processing/type.ts | 231 ++++++++++++++++ scripts/apidocs/project.ts | 9 + scripts/apidocs/utils/format.ts | 31 +++ scripts/apidocs/utils/markdown.ts | 111 ++++++++ scripts/apidocs/utils/paths.ts | 24 ++ scripts/apidocs/utils/value-checks.ts | 69 +++++ scripts/diff.ts | 8 +- scripts/generate-locales.ts | 2 +- 37 files changed, 1942 insertions(+), 1764 deletions(-) delete mode 100644 scripts/apidoc.ts delete mode 100644 scripts/apidoc/diff.ts delete mode 100644 scripts/apidoc/faker-class.ts delete mode 100644 scripts/apidoc/faker-utilities.ts delete mode 100644 scripts/apidoc/format.ts delete mode 100644 scripts/apidoc/generate.ts delete mode 100644 scripts/apidoc/markdown.ts delete mode 100644 scripts/apidoc/module-methods.ts delete mode 100644 scripts/apidoc/parameter-defaults.ts delete mode 100644 scripts/apidoc/signature.ts delete mode 100644 scripts/apidoc/typedoc.ts delete mode 100644 scripts/apidoc/utils.ts delete mode 100644 scripts/apidoc/writer.ts create mode 100644 scripts/apidocs.ts create mode 100644 scripts/apidocs/diff.ts create mode 100644 scripts/apidocs/generate.ts create mode 100644 scripts/apidocs/output/constants.ts create mode 100644 scripts/apidocs/output/diff-index.ts create mode 100644 scripts/apidocs/output/page-index.ts create mode 100644 scripts/apidocs/output/page.ts create mode 100644 scripts/apidocs/output/search-index.ts create mode 100644 scripts/apidocs/output/source-base-url.ts create mode 100644 scripts/apidocs/processing/class.ts create mode 100644 scripts/apidocs/processing/error.ts create mode 100644 scripts/apidocs/processing/jsdocs.ts create mode 100644 scripts/apidocs/processing/method.ts create mode 100644 scripts/apidocs/processing/parameter.ts create mode 100644 scripts/apidocs/processing/signature.ts create mode 100644 scripts/apidocs/processing/source.ts create mode 100644 scripts/apidocs/processing/type.ts create mode 100644 scripts/apidocs/project.ts create mode 100644 scripts/apidocs/utils/format.ts create mode 100644 scripts/apidocs/utils/markdown.ts create mode 100644 scripts/apidocs/utils/paths.ts create mode 100644 scripts/apidocs/utils/value-checks.ts (limited to 'scripts') diff --git a/scripts/apidoc.ts b/scripts/apidoc.ts deleted file mode 100644 index 42eee5c3..00000000 --- a/scripts/apidoc.ts +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env node - -import { generate } from './apidoc/generate'; -import { initMarkdownRenderer } from './apidoc/markdown'; - -await initMarkdownRenderer(); -await generate(); diff --git a/scripts/apidoc/diff.ts b/scripts/apidoc/diff.ts deleted file mode 100644 index 101200cd..00000000 --- a/scripts/apidoc/diff.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { DocsApiDiffIndex } from './utils'; -import { nameDocsDiffIndexFile, pathDocsDiffIndexFile } from './utils'; - -/** - * Loads the diff index from the given source url. - * - * @param url The url to load the diff index from. - */ -async function loadRemote(url: string): Promise { - return fetch(url).then((res) => { - if (!res.ok) { - throw new Error( - `Failed to load remote diff index from ${url}: ${res.statusText}` - ); - } - - return res.json() as Promise; - }); -} - -/** - * Loads the diff index from the given local path. - * - * @param path The path to load the diff index from. Should start with `file://` for cross platform compatibility. - */ -async function loadLocal(path: string): Promise { - return import(path).then((imp) => imp.default as DocsApiDiffIndex); -} - -/** - * Loads the diff index from the given source. - * If the source starts with `https://` it will be loaded from the remote url. - * Otherwise it will be loaded from the local path. - * - * @param source The source to load the diff index from. - */ -async function load(source: string): Promise { - return source.startsWith('https://') ? loadRemote(source) : loadLocal(source); -} - -/** - * Returns a set of all keys from the given entries. - * - * @param entries The entries to get the keys from. - */ -function allKeys( - ...entries: ReadonlyArray> -): Set { - return new Set(entries.flatMap(Object.keys)); -} - -/** - * Compares the target (reference) and source (changed) diff index and returns the differences. - * The returned object contains the module names as keys and the method names as values. - * If the module name is `ADDED` or `REMOVED` it means that the module was added or removed in the local diff index. - * - * @param targetDiffIndex The url to the target (reference) diff index. Defaults to the next.fakerjs.dev diff index. - * @param sourceDiffIndex The path to the source (changed) index. Defaults to the local diff index. - */ -export async function diff( - targetDiffIndex = `https://next.fakerjs.dev/${nameDocsDiffIndexFile}`, - sourceDiffIndex = `file://${pathDocsDiffIndexFile}` -): Promise> { - const target = await load(targetDiffIndex); - const source = await load(sourceDiffIndex); - - const diff: Record = {}; - - for (const moduleName of allKeys(target, source)) { - const remoteModule = target[moduleName]; - const localModule = source[moduleName]; - - if (!remoteModule) { - diff[moduleName] = ['ADDED']; - continue; - } - - if (!localModule) { - diff[moduleName] = ['REMOVED']; - continue; - } - - for (const methodName of allKeys(remoteModule, localModule)) { - const remoteMethod = remoteModule[methodName]; - const localMethod = localModule[methodName]; - - if (remoteMethod !== localMethod) { - (diff[moduleName] ??= []).push(methodName); - } - } - } - - return diff; -} diff --git a/scripts/apidoc/faker-class.ts b/scripts/apidoc/faker-class.ts deleted file mode 100644 index a9acad3c..00000000 --- a/scripts/apidoc/faker-class.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { DeclarationReflection, ProjectReflection } from 'typedoc'; -import { ReflectionKind } from 'typedoc'; -import type { Method } from '../../docs/.vitepress/components/api-docs/method'; -import { analyzeModule, processModuleMethods } from './module-methods'; -import { analyzeSignature } from './signature'; -import { extractModuleFieldName, selectApiSignature } from './typedoc'; -import type { ModuleSummary } from './utils'; -import { writeApiDocsModule } from './writer'; - -export async function processFakerClasses( - project: ProjectReflection -): Promise { - const fakerClasses = project - .getChildrenByKind(ReflectionKind.Class) - .filter((clazz) => clazz.name === 'Faker' || clazz.name === 'SimpleFaker'); - - if (fakerClasses.length !== 2) { - throw new Error('Faker classes not found'); - } - - return Promise.all(fakerClasses.map(processClass)); -} - -export async function processFakerRandomizer( - project: ProjectReflection -): Promise { - const randomizerClass = project - .getChildrenByKind(ReflectionKind.Interface) - .find((clazz) => clazz.name === 'Randomizer'); - - if (randomizerClass == null) { - throw new Error('Randomizer class not found'); - } - - return processClass(randomizerClass); -} - -async function processClass( - clazz: DeclarationReflection -): Promise { - const { name } = clazz; - const moduleFieldName = extractModuleFieldName(clazz); - - console.log(`Processing ${name} class`); - - const { comment, deprecated, examples } = analyzeModule(clazz); - const methods: Method[] = []; - - if (hasConstructor(clazz)) { - console.debug(`- constructor`); - methods.push(await processConstructor(clazz)); - } - - methods.push(...(await processModuleMethods(clazz, `${moduleFieldName}.`))); - - return writeApiDocsModule( - name, - moduleFieldName, - comment, - examples, - deprecated, - methods, - '' - ); -} - -function hasConstructor(clazz: DeclarationReflection): boolean { - return clazz - .getChildrenByKind(ReflectionKind.Constructor) - .some((constructor) => (constructor.signatures?.length ?? 0) > 0); -} - -async function processConstructor( - clazz: DeclarationReflection -): Promise { - const constructor = clazz.getChildrenByKind(ReflectionKind.Constructor)[0]; - - const signature = selectApiSignature(constructor); - - const method = await analyzeSignature(signature, '', `new ${clazz.name}`); - - return { - ...method, - name: 'constructor', - }; -} diff --git a/scripts/apidoc/faker-utilities.ts b/scripts/apidoc/faker-utilities.ts deleted file mode 100644 index ad1f74c3..00000000 --- a/scripts/apidoc/faker-utilities.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { DeclarationReflection, ProjectReflection } from 'typedoc'; -import { ReflectionKind } from 'typedoc'; -import type { Method } from '../../docs/.vitepress/components/api-docs/method'; -import { processMethods } from './module-methods'; -import { selectApiSignature } from './typedoc'; -import type { ModuleSummary } from './utils'; -import { writeApiDocsModule } from './writer'; - -export async function processFakerUtilities( - project: ProjectReflection -): Promise { - const fakerUtilities = project - .getChildrenByKind(ReflectionKind.Function) - .filter((method) => !method.flags.isPrivate); - - return processUtilities(fakerUtilities); -} - -async function processUtilities( - fakerUtilities: DeclarationReflection[] -): Promise { - console.log(`Processing Faker Utilities`); - const comment = 'A list of all the utilities available in Faker.js.'; - - const methods: Method[] = await processMethods( - Object.fromEntries( - fakerUtilities.map((method) => [method.name, selectApiSignature(method)]) - ) - ); - - return writeApiDocsModule( - 'Utilities', - 'utils', - comment, - undefined, - undefined, - methods, - '' - ); -} diff --git a/scripts/apidoc/format.ts b/scripts/apidoc/format.ts deleted file mode 100644 index a8f63ac0..00000000 --- a/scripts/apidoc/format.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { Options } from 'prettier'; -import { format } from 'prettier'; -import prettierConfig from '../../.prettierrc.js'; - -/** - * Formats markdown contents. - * - * @param text The text to format. - */ -export async function formatMarkdown(text: string): Promise { - return format(text, prettierMarkdown); -} - -/** - * Formats typedoc contents. - * - * @param text The text to format. - */ -export async function formatTypescript(text: string): Promise { - return format(text, prettierTypescript); -} - -const prettierMarkdown: Options = { - ...prettierConfig, - parser: 'markdown', -}; - -const prettierTypescript: Options = { - ...prettierConfig, - parser: 'typescript', -}; diff --git a/scripts/apidoc/generate.ts b/scripts/apidoc/generate.ts deleted file mode 100644 index eb00cea4..00000000 --- a/scripts/apidoc/generate.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { resolve } from 'node:path'; -import { processFakerClasses, processFakerRandomizer } from './faker-class'; -import { processFakerUtilities } from './faker-utilities'; -import { processModules } from './module-methods'; -import { loadProject } from './typedoc'; -import { pathOutputDir } from './utils'; -import { - writeApiDiffIndex, - writeApiPagesIndex, - writeApiSearchIndex, - writeSourceBaseUrl, -} from './writer'; - -const pathOutputJson = resolve(pathOutputDir, 'typedoc.json'); - -/** - * Generates the API documentation. - */ -export async function generate(): Promise { - const [app, project] = await loadProject(); - - // Useful for manually analyzing the content - await app.generateJson(project, pathOutputJson); - - const pages = [ - ...(await processFakerClasses(project)), - await processFakerRandomizer(project), - await processFakerUtilities(project), - ...(await processModules(project)).sort((a, b) => - a.text.localeCompare(b.text) - ), - ]; - await writeApiPagesIndex( - pages.map(({ text, link, category }) => ({ text, link, category })) - ); - writeApiDiffIndex( - Object.fromEntries(pages.map(({ text, diff }) => [text, diff])) - ); - writeApiSearchIndex(pages); - - await writeSourceBaseUrl(project); -} diff --git a/scripts/apidoc/markdown.ts b/scripts/apidoc/markdown.ts deleted file mode 100644 index 0b1e3b40..00000000 --- a/scripts/apidoc/markdown.ts +++ /dev/null @@ -1,87 +0,0 @@ -import sanitizeHtml from 'sanitize-html'; -import type { MarkdownRenderer } from 'vitepress'; -import { createMarkdownRenderer } from 'vitepress'; -import vitepressConfig from '../../docs/.vitepress/config'; -import { adjustUrls, pathOutputDir } from './utils'; - -let markdown: MarkdownRenderer; - -export async function initMarkdownRenderer(): Promise { - markdown = await createMarkdownRenderer( - pathOutputDir, - vitepressConfig.markdown, - '/' - ); -} - -const htmlSanitizeOptions: sanitizeHtml.IOptions = { - allowedTags: [ - 'a', - 'button', - 'code', - 'div', - 'li', - 'p', - 'pre', - 'span', - 'strong', - 'ul', - ], - allowedAttributes: { - a: ['href', 'target', 'rel'], - button: ['class', 'title'], - div: ['class'], - pre: ['class', 'tabindex', 'v-pre'], - span: ['class', 'style'], - }, - selfClosing: [], -}; - -function comparableSanitizedHtml(html: string): string { - return html - .replaceAll(/&#x[0-9A-F]{2};/g, (x) => - String.fromCodePoint(Number.parseInt(x.slice(3, -1), 16)) - ) - .replaceAll('>', '>') - .replaceAll('<', '<') - .replaceAll('&', '&') - .replaceAll('"', '"') - .replaceAll('=""', '') - .replaceAll(' ', ''); -} - -/** - * Converts a Typescript code block to an HTML string and sanitizes it. - * - * @param code The code to convert. - * - * @returns The converted HTML string. - */ -export function codeToHtml(code: string): string { - const delimiter = '```'; - return mdToHtml(`${delimiter}ts\n${code}\n${delimiter}`); -} - -/** - * Converts Markdown to an HTML string and sanitizes it. - * - * @param md The markdown to convert. - * @param inline Whether to render the markdown as inline, without a wrapping `

` tag. Defaults to `false`. - * - * @returns The converted HTML string. - */ -export function mdToHtml(md: string, inline: boolean = false): string { - const rawHtml = inline ? markdown.renderInline(md) : markdown.render(md); - - const safeHtml: string = sanitizeHtml(rawHtml, htmlSanitizeOptions); - // Revert some escaped characters for comparison. - if (comparableSanitizedHtml(rawHtml) === comparableSanitizedHtml(safeHtml)) { - return adjustUrls(safeHtml); - } - - console.debug('Rejected unsafe md:', md); - console.error('Rejected unsafe html:', rawHtml); - console.error('Rejected unsafe html:', comparableSanitizedHtml(rawHtml)); - console.error('Expected safe html:', comparableSanitizedHtml(safeHtml)); - throw new Error('Found unsafe html'); -} diff --git a/scripts/apidoc/module-methods.ts b/scripts/apidoc/module-methods.ts deleted file mode 100644 index c21ccf97..00000000 --- a/scripts/apidoc/module-methods.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { - DeclarationReflection, - ProjectReflection, - SignatureReflection, -} from 'typedoc'; -import type { Method } from '../../docs/.vitepress/components/api-docs/method'; -import { codeToHtml } from './markdown'; -import { analyzeSignature } from './signature'; -import { - extractDeprecated, - extractDescription, - extractJoinedRawExamples, - extractModuleFieldName, - extractModuleName, - selectApiMethodSignatures, - selectApiModules, -} from './typedoc'; -import type { ModuleSummary } from './utils'; -import { adjustUrls } from './utils'; -import { writeApiDocsModule } from './writer'; - -/** - * Analyzes and writes the documentation for modules and their methods such as `faker.animal.cat()`. - * - * @param project The project used to extract the modules. - * - * @returns The generated pages. - */ -export async function processModules( - project: ProjectReflection -): Promise { - return Promise.all(selectApiModules(project).map(processModule)); -} - -/** - * Analyzes and writes the documentation for a module and its methods such as `faker.animal.cat()`. - * - * @param module The module to process. - * - * @returns The generated pages. - */ -async function processModule( - module: DeclarationReflection -): Promise { - const moduleName = extractModuleName(module); - console.log(`Processing Module ${moduleName}`); - const moduleFieldName = extractModuleFieldName(module); - const { comment, deprecated, examples } = analyzeModule(module); - const methods = await processModuleMethods( - module, - `faker.${moduleFieldName}.` - ); - - return writeApiDocsModule( - moduleName, - moduleFieldName, - comment, - examples, - deprecated, - methods, - 'Modules' - ); -} - -/** - * Analyzes the documentation for a class. - * - * @param module The class to process. - * - * @returns The class information. - */ -export function analyzeModule(module: DeclarationReflection): { - comment: string; - deprecated: string | undefined; - examples: string | undefined; -} { - const examplesRaw = extractJoinedRawExamples(module); - const examples = examplesRaw ? codeToHtml(examplesRaw) : undefined; - - return { - comment: adjustUrls(extractDescription(module)), - deprecated: extractDeprecated(module), - examples, - }; -} - -/** - * Processes all api methods of the given class. This does not include the constructor. - * - * @param module The module to process. - * @param accessor The code used to access the methods within the module. - * - * @returns A list containing the documentation for the api methods in the given module. - */ -export async function processModuleMethods( - module: DeclarationReflection, - accessor: string -): Promise { - return processMethods(selectApiMethodSignatures(module), accessor); -} - -/** - * Processes all api methods. - * - * @param signatures The signatures to process. - * @param accessor The code used to access the methods. - * - * @returns A list containing the documentation for the api methods. - */ -export async function processMethods( - signatures: Record, - accessor: string = '' -): Promise { - const methods: Method[] = []; - - for (const [methodName, signature] of Object.entries(signatures)) { - console.debug(`- ${methodName}`); - methods.push(await analyzeSignature(signature, accessor, methodName)); - } - - return methods; -} diff --git a/scripts/apidoc/parameter-defaults.ts b/scripts/apidoc/parameter-defaults.ts deleted file mode 100644 index a7f53c2b..00000000 --- a/scripts/apidoc/parameter-defaults.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { - Context, - DeclarationReflection, - EventCallback, - JSONOutput, - ProjectReflection, - SerializerComponent, - SignatureReflection, -} from 'typedoc'; -import { Reflection, ReflectionKind, TypeScript } from 'typedoc'; - -const reflectionKindFunctionOrMethod = - ReflectionKind.Function | ReflectionKind.Method; - -interface ParameterDefaultsAware extends Reflection { - implementationDefaultParameters: Array; -} - -/** - * TypeDoc EventCallback for EVENT_CREATE_DECLARATION events that reads the default parameters from the implementation. - * - * @param context The converter context. - * @param reflection The reflection to read the default parameters from. - */ -export const parameterDefaultReader: EventCallback = ( - context: Context, - reflection: Reflection -): void => { - const symbol = context.project.getSymbolFromReflection(reflection); - if (!symbol) return; - - if ( - reflection.kindOf(reflectionKindFunctionOrMethod) && - symbol.declarations?.length - ) { - const lastDeclaration = symbol.declarations.at(-1); - if (TypeScript.isFunctionLike(lastDeclaration)) { - (reflection as ParameterDefaultsAware).implementationDefaultParameters = - lastDeclaration.parameters.map((param) => - cleanParameterDefault(param.initializer?.getText()) - ); - } - } -}; - -/** - * Removes compile expressions that don't add any value for readers. - * - * @param value The default value to clean. - * - * @returns The cleaned default value. - */ -function cleanParameterDefault(value: string): string; -function cleanParameterDefault(value?: string): string | undefined; -function cleanParameterDefault(value?: string): string | undefined { - if (value == null) { - return undefined; - } - - // Strip type casts: "'foobar' as unknown as T" => "'foobar'" - return value.replace(/( as unknown)? as [A-Za-z<>]+/, ''); -} - -/** - * Serializer that adds the `implementationDefaultParameters` to the JSON output. - */ -export class DefaultParameterAwareSerializer - implements SerializerComponent -{ - readonly priority = 0; - - supports(item: unknown): item is Reflection { - return item instanceof Reflection; - } - - toObject( - item: Reflection, - obj: Partial - ): Partial { - (obj as unknown as ParameterDefaultsAware).implementationDefaultParameters = - (item as ParameterDefaultsAware).implementationDefaultParameters; - return obj; - } -} - -/** - * Replaces all methods' last signature's parameter's default value with the default value read from the implementation. - * - * @param project The project to patch. - */ -export function patchProjectParameterDefaults( - project: ProjectReflection -): void { - const functionOrMethods = project.getReflectionsByKind( - reflectionKindFunctionOrMethod - ) as DeclarationReflection[]; - for (const functionOrMethod of functionOrMethods) { - patchMethodParameterDefaults(functionOrMethod); - } -} - -/** - * Replaces the last signature's parameter's default value with the default value read from the implementation. - * - * @param method The method to patch. - */ -function patchMethodParameterDefaults(method: DeclarationReflection): void { - const signatures = method.signatures; - const signature = signatures?.[signatures.length - 1]; - const parameterDefaults = (method as unknown as ParameterDefaultsAware) - .implementationDefaultParameters; - if (signature && parameterDefaults) { - patchSignatureParameterDefaults(signature, parameterDefaults); - } -} - -/** - * Replaces the given signature's parameter's default value with the given default values. - * - * @param signature The signature to patch. - * @param parameterDefaults The defaults to add. - */ -function patchSignatureParameterDefaults( - signature: SignatureReflection, - parameterDefaults: Array -): void { - const signatureParameters = - signature.parameters ?? Array.from({ length: parameterDefaults.length }); - if (signatureParameters.length !== parameterDefaults.length) { - throw new Error('Unexpected parameter length mismatch'); - } - - for (const [index, param] of signatureParameters.entries()) { - param.defaultValue = parameterDefaults[index] || param.defaultValue; - } -} diff --git a/scripts/apidoc/signature.ts b/scripts/apidoc/signature.ts deleted file mode 100644 index 64910534..00000000 --- a/scripts/apidoc/signature.ts +++ /dev/null @@ -1,365 +0,0 @@ -import type { - Comment, - DeclarationReflection, - ParameterReflection, - Reflection, - ReflectionType, - SignatureReflection, - SomeType, - Type, -} from 'typedoc'; -import { ReflectionFlag, ReflectionKind } from 'typedoc'; -import type { - Method, - MethodParameter, -} from '../../docs/.vitepress/components/api-docs/method'; -import { formatTypescript } from './format'; -import { codeToHtml, mdToHtml } from './markdown'; -import { - extractDeprecated, - extractDescription, - extractJoinedRawExamples, - extractRawDefault, - extractSeeAlsos, - extractSince, - extractSourcePath, - extractSummaryDefault, - extractThrows, - toBlock, -} from './typedoc'; - -export async function analyzeSignature( - signature: SignatureReflection, - accessor: string, - methodName: string -): Promise { - const parameters: MethodParameter[] = []; - - // Collect Type Parameters - const typeParameters = signature.typeParameters || []; - const signatureTypeParameters: string[] = []; - for (const parameter of typeParameters) { - signatureTypeParameters.push(parameter.name); - parameters.push({ - name: `<${parameter.name}>`, - type: parameter.type ? await typeToText(parameter.type) : undefined, - description: mdToHtml(extractDescription(parameter)), - }); - } - - // Collect Parameters - const signatureParameters: string[] = []; - for ( - let index = 0; - signature.parameters && index < signature.parameters.length; - index++ - ) { - const parameter = signature.parameters[index]; - - const aParam = await analyzeParameter(parameter); - signatureParameters.push(aParam.signature); - parameters.push(...aParam.parameters); - } - - // Generate usage section - - let signatureTypeParametersString = ''; - if (signatureTypeParameters.length > 0) { - signatureTypeParametersString = `<${signatureTypeParameters.join(', ')}>`; - } - - const signatureParametersString = signatureParameters.join(', '); - - let examples = `${accessor}${methodName}${signatureTypeParametersString}(${signatureParametersString}): ${signature.type?.toString()}\n`; - - const exampleTags = extractJoinedRawExamples(signature); - if (exampleTags) { - examples += exampleTags; - } - - const seeAlsos = extractSeeAlsos(signature).map((seeAlso) => - mdToHtml(seeAlso, true) - ); - const deprecatedMessage = extractDeprecated(signature); - const deprecated = deprecatedMessage - ? mdToHtml(deprecatedMessage) - : undefined; - const throwsMessage = extractThrows(signature); - const throws = throwsMessage ? mdToHtml(throwsMessage, true) : undefined; - - return { - name: methodName, - description: mdToHtml(extractDescription(signature)), - parameters: parameters, - since: extractSince(signature), - sourcePath: extractSourcePath(signature), - throws, - returns: await typeToText(signature.type), - examples: codeToHtml(examples), - deprecated, - seeAlsos, - }; -} - -async function analyzeParameter(parameter: ParameterReflection): Promise<{ - parameters: MethodParameter[]; - signature: string; -}> { - const name = parameter.name; - const declarationName = name + (isOptional(parameter) ? '?' : ''); - const type = parameter.type; - const defaultValue = extractDefaultFromParameter(parameter); - - let signatureText = ''; - if (defaultValue) { - signatureText = ` = ${defaultValue}`; - } - - const signature = `${declarationName}: ${await typeToText( - type - )}${signatureText}`; - - const parameters: MethodParameter[] = [ - { - name: declarationName, - type: await typeToText(type, true), - default: defaultValue, - description: mdToHtml(extractDescription(parameter)), - }, - ]; - parameters.push(...(await analyzeParameterOptions(name, type))); - - return { - parameters, - signature, - }; -} - -// keep in sync with assertNestedParameterDefault -async function analyzeParameterOptions( - name: string, - parameterType?: SomeType -): Promise { - if (!parameterType) { - return []; - } - - switch (parameterType.type) { - case 'array': { - return analyzeParameterOptions(`${name}[]`, parameterType.elementType); - } - - case 'union': { - return Promise.all( - parameterType.types.map((type) => analyzeParameterOptions(name, type)) - ).then((options) => options.flat()); - } - - case 'reflection': { - const properties = parameterType.declaration.children ?? []; - return Promise.all( - properties.map(async (property) => { - const reflection = property.comment - ? property - : (property.type as ReflectionType)?.declaration?.signatures?.[0]; - const comment = reflection?.comment; - const deprecated = extractDeprecated(reflection); - return { - name: `${name}.${property.name}${isOptional(property) ? '?' : ''}`, - type: await declarationTypeToText(property), - default: extractDefaultFromComment(comment), - description: mdToHtml( - toBlock(comment) + - (deprecated ? `\n\n**DEPRECATED:** ${deprecated}` : '') - ), - }; - }) - ); - } - - case 'typeOperator': { - return analyzeParameterOptions(name, parameterType.target); - } - - default: { - return []; - } - } -} - -function isOptional(parameter: Reflection): boolean { - return parameter.flags.hasFlag(ReflectionFlag.Optional); -} - -async function typeToText(type_?: Type, short = false): Promise { - if (!type_) { - return '?'; - } - - const type = type_ as SomeType; - switch (type.type) { - case 'array': { - const text = await typeToText(type.elementType, short); - const isComplexType = text.includes('|') || text.includes('{'); - return isComplexType ? `Array<${text}>` : `${text}[]`; - } - - case 'union': { - return (await Promise.all(type.types.map((t) => typeToText(t, short)))) - .map((t) => (t.includes('=>') ? `(${t})` : t)) - .sort() - .join(' | '); - } - - case 'reference': { - if (!type.typeArguments || type.typeArguments.length === 0) { - const reflection = type.reflection as DeclarationReflection | undefined; - const reflectionType = reflection?.type; - if ( - (reflectionType?.type === 'literal' || - reflectionType?.type === 'union') && - !type.name.endsWith('Char') - ) { - return typeToText(reflectionType, short); - } - - return type.name; - } else if (type.name === 'LiteralUnion') { - return [ - await typeToText(type.typeArguments[0], short), - await typeToText(type.typeArguments[1], short), - ].join(' | '); - } - - return `${type.name}<${( - await Promise.all(type.typeArguments.map((t) => typeToText(t, short))) - ).join(', ')}>`; - } - - case 'reflection': { - return declarationTypeToText(type.declaration, short); - } - - case 'indexedAccess': { - return `${await typeToText(type.objectType, short)}[${await typeToText( - type.indexType, - short - )}]`; - } - - case 'literal': { - return (await formatTypescript(type.toString())).replace(/;\n$/, ''); - } - - case 'typeOperator': { - const text = await typeToText(type.target, short); - if (short && type.operator === 'readonly') { - return text; - } - - return `${type.operator} ${text}`; - } - - default: { - return type.toString(); - } - } -} - -async function declarationTypeToText( - declaration: DeclarationReflection, - short = false -): Promise { - switch (declaration.kind) { - case ReflectionKind.Method: { - return signatureTypeToText(declaration.signatures?.[0]); - } - - case ReflectionKind.Property: { - return typeToText(declaration.type); - } - - case ReflectionKind.TypeLiteral: { - if (declaration.children?.length) { - if (short) { - // This is too long for the parameter table, thus we abbreviate this. - return '{ ... }'; - } - - const list = ( - await Promise.all( - declaration.children.map( - async (c) => ` ${c.name}: ${await declarationTypeToText(c)}` - ) - ) - ).join(',\n'); - - return `{\n${list}\n}`; - } else if (declaration.signatures?.length) { - return signatureTypeToText(declaration.signatures[0]); - } - - return declaration.toString(); - } - - default: { - return declaration.toString(); - } - } -} - -async function signatureTypeToText( - signature?: SignatureReflection -): Promise { - if (!signature) { - return '(???) => ?'; - } - - return `(${( - await Promise.all( - signature.parameters?.map( - async (p) => `${p.name}: ${await typeToText(p.type)}` - ) ?? [] - ) - ).join(', ')}) => ${await typeToText(signature.type)}`; -} - -/** - * Extracts and optionally removes the parameter default from the parameter. - * - * @param parameter The parameter to extract the default from. - * @param eraseDefault Whether to erase the default text from the parameter comment. - * - * @returns The extracted default value. - */ -function extractDefaultFromParameter( - parameter: ParameterReflection, - eraseDefault = true -): string | undefined { - const commentDefault = extractDefaultFromComment( - parameter.comment, - eraseDefault - ); - return parameter.defaultValue ?? commentDefault; -} - -/** - * Extracts and optionally removes the parameter default from the comments. - * - * @param comment The comment to extract the default from. - * @param eraseDefault Whether to erase the default text from the comment. - * - * @returns The extracted default value. - */ -function extractDefaultFromComment( - comment?: Comment, - eraseDefault = true -): string | undefined { - if (!comment) { - return; - } - - const tagDefault = extractRawDefault({ comment }); - const summaryDefault = extractSummaryDefault(comment, eraseDefault); - return tagDefault || summaryDefault; -} diff --git a/scripts/apidoc/typedoc.ts b/scripts/apidoc/typedoc.ts deleted file mode 100644 index 1cdcf3e1..00000000 --- a/scripts/apidoc/typedoc.ts +++ /dev/null @@ -1,421 +0,0 @@ -import type { - Comment, - CommentDisplayPart, - CommentTag, - DeclarationReflection, - ProjectReflection, - Reflection, - SignatureReflection, - TypeDocOptions, -} from 'typedoc'; -import { - Application, - Converter, - ReflectionKind, - TSConfigReader, -} from 'typedoc'; -import { faker } from '../../src'; -import { - DefaultParameterAwareSerializer, - parameterDefaultReader, - patchProjectParameterDefaults, -} from './parameter-defaults'; -import { mapByName } from './utils'; - -type CommentHolder = Pick; - -/** - * Loads the project using TypeDoc. - * - * @param options The options to use for the project. - * - * @returns The TypeDoc application and the project reflection. - */ -export async function loadProject( - options: Partial = { - entryPoints: ['src/index.ts'], - pretty: true, - cleanOutputDir: true, - tsconfig: 'tsconfig.build.json', - } -): Promise<[Application, ProjectReflection]> { - const app = await newTypeDocApp(options); - - const project = await app.convert(); - - if (!project) { - throw new Error('Failed to convert project'); - } - - patchProjectParameterDefaults(project); - - return [app, project]; -} - -/** - * Creates and configures a new typedoc application. - * - * @param options The options to use for the project. - */ -async function newTypeDocApp( - options?: Partial -): Promise { - const app = await Application.bootstrapWithPlugins(options, [ - new TSConfigReader(), - ]); - - // Read parameter defaults - app.converter.on(Converter.EVENT_CREATE_DECLARATION, parameterDefaultReader); - // Add to debug json output - app.serializer.addSerializer(new DefaultParameterAwareSerializer()); - - return app; -} - -/** - * Selects the modules from the project that needs to be documented. - * - * @param project The project to extract the modules from. - * @param includeTestModules Whether to include test modules. - * - * @returns The modules to document. - */ -export function selectApiModules( - project: ProjectReflection, - includeTestModules = false -): DeclarationReflection[] { - return project - .getChildrenByKind(ReflectionKind.Class) - .filter( - (module) => - faker[extractModuleFieldName(module) as keyof typeof faker] != null || - includeTestModules - ); -} - -/** - * Selects the methods from the module that needs to be documented. - * - * @param module The module to extract the methods from. - * - * @returns The methods to document. - */ -export function selectApiMethods( - module: DeclarationReflection -): DeclarationReflection[] { - return module - .getChildrenByKind(ReflectionKind.Method) - .filter((method) => !method.flags.isPrivate); -} - -/** - * Selects the signature from the method that needs to be documented. - * - * @param method The method to extract the signature from. - * - * @returns The signature to document. - */ -export function selectApiSignature( - method: DeclarationReflection -): SignatureReflection { - const signatures = method.signatures; - if (signatures == null || signatures.length === 0) { - throw new Error(`Method ${method.name} has no signature.`); - } - - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return signatures.at(-1)!; -} - -/** - * Selects the method signatures from the module that needs to be documented. - * Method-Name -> Method-Signature - * - * @param module The module to extract the method signatures from. - * - * @returns The method signatures to document. - */ -export function selectApiMethodSignatures( - module: DeclarationReflection -): Record { - return mapByName(selectApiMethods(module), selectApiSignature); -} - -export function extractModuleName(module: DeclarationReflection): string { - const { name } = module; - // TODO @ST-DDT 2022-10-16: Remove in v10. - // Typedoc prefers the name of the module that is exported first. - if (name === 'AddressModule') { - return 'Location'; - } else if (name === 'NameModule') { - return 'Person'; - } - - return name.replace(/Module$/, ''); -} - -export function extractModuleFieldName(module: DeclarationReflection): string { - const moduleName = extractModuleName(module); - return moduleName[0].toLowerCase() + moduleName.substring(1); -} - -export const MISSING_DESCRIPTION = 'Missing'; - -export function toBlock(comment?: Comment): string { - return joinTagParts(comment?.summary) || MISSING_DESCRIPTION; -} - -export function extractDescription(reflection: Reflection): string { - return toBlock(reflection.comment); -} - -/** - * Extracts the source url from the jsdocs. - * - * @param reflection The reflection instance to extract the source url from. - */ -function extractSourceUrl( - reflection: DeclarationReflection | SignatureReflection -): string { - const source = reflection.sources?.[0]; - return source?.url ?? ''; -} - -/** - * Extracts the source base url from the jsdocs. - * - * @param reflection The reflection instance to extract the source base url from. - */ -export function extractSourceBaseUrl( - reflection: DeclarationReflection | SignatureReflection -): string { - return extractSourceUrl(reflection).replace( - /^(.*\/blob\/[0-9a-f]+\/)(.*)$/, - '$1' - ); -} - -/** - * Extracts the relative source path from the jsdocs. - * - * @param reflection The reflection instance to extract the source path from. - */ -export function extractSourcePath( - reflection: DeclarationReflection | SignatureReflection -): string { - return extractSourceUrl(reflection).replace( - /^(.*\/blob\/[0-9a-f]+\/)(.*)$/, - '$2' - ); -} - -/** - * Extracts the text (md) from a jsdoc tag. - * - * @param tag The tag to extract the text from. - * @param reflection The reflection to extract the text from. - * @param tagProcessor The function used to extract the text from the tag. - */ -export function extractTagContent( - tag: `@${string}`, - reflection?: CommentHolder, - tagProcessor: (tag: CommentTag) => string[] = joinTagContent -): string[] { - const tags = - reflection?.comment - ?.getTags(tag) - .flatMap(tagProcessor) - .map((tag) => tag.trim()) ?? []; - if (tags.some((tag) => tag.length === 0)) { - throw new Error(`Expected non-empty ${tag} tag.`); - } - - return tags; -} - -/** - * Extracts the text (md) from a single jsdoc tag. - * - * @param tag The tag to extract the text from. - * @param reflection The reflection to extract the text from. - * @param tagProcessor The function used to extract the text from the tag. - * - * @throws If there are multiple tags of that type. - */ -function extractSingleTagContent( - tag: `@${string}`, - reflection?: CommentHolder, - tagProcessor: (tag: CommentTag) => string[] = joinTagContent -): string | undefined { - const tags = extractTagContent(tag, reflection, tagProcessor); - if (tags.length === 0) { - return undefined; - } else if (tags.length === 1) { - return tags[0]; - } - - throw new Error(`Expected 1 ${tag} tag, but got ${tags.length}.`); -} - -/** - * Extracts the raw code from the jsdocs without the surrounding md code block. - * - * @param tag The tag to extract the code from. - * @param reflection The reflection to extract the code from. - */ -function extractRawCode( - tag: `@${string}`, - reflection?: CommentHolder -): string[] { - return extractTagContent(tag, reflection).map((tag) => - tag.replace(/^```ts\n/, '').replace(/\n```$/, '') - ); -} - -/** - * Extracts the default from the jsdocs without the surrounding md code block. - * - * @param reflection The reflection to extract the examples from. - */ -export function extractRawDefault(reflection?: CommentHolder): string { - return extractRawCode('@default', reflection)[0] ?? ''; -} - -/** - * Extracts and optionally removes the default from the comment summary. - * - * @param comment The comment to extract the default from. - * @param eraseDefault Whether to erase the default text from the comment. - * - * @returns The extracted default value. - */ -export function extractSummaryDefault( - comment?: Comment, - eraseDefault = true -): string | undefined { - if (!comment) { - return; - } - - const summary = comment.summary; - const text = joinTagParts(summary).trim(); - if (!text) { - return; - } - - const result = /^(.*)[ \n]Defaults to `([^`]+)`\.(.*)$/s.exec(text); - if (!result) { - return; - } - - if (result[3].trim()) { - throw new Error(`Found description text after the default value:\n${text}`); - } - - if (eraseDefault) { - summary.splice(-2, 2); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const lastSummaryPart = summary.at(-1)!; - lastSummaryPart.text = lastSummaryPart.text.replace( - /[ \n]Defaults to $/, - '' - ); - } - - return result[2]; -} - -/** - * Extracts the examples from the jsdocs without the surrounding md code block. - * - * @param reflection The reflection to extract the examples from. - */ -function extractRawExamples(reflection?: CommentHolder): string[] { - return extractRawCode('@example', reflection); -} - -/** - * Extracts the examples from the jsdocs without the surrounding md code block, then joins them with newlines and trims. - * - * @param reflection The reflection to extract the examples from. - */ -export function extractJoinedRawExamples( - reflection?: CommentHolder -): string | undefined { - const examples = extractRawExamples(reflection); - return examples.length === 0 ? undefined : examples.join('\n').trim(); -} - -/** - * Extracts all the `@see` references from the jsdocs separately. - * - * @param reflection The reflection to extract the see also references from. - */ -export function extractSeeAlsos(reflection?: CommentHolder): string[] { - return extractTagContent('@see', reflection, (tag) => - // If the @see tag contains code in backticks, the content is split into multiple parts. - // So we join together, split on newlines and filter out empty tags. - joinTagParts(tag.content) - .split('\n') - .map((link) => { - link = link.trim(); - if (link.startsWith('-')) { - link = link.slice(1).trimStart(); - } - - return link; - }) - .filter((link) => link.length > 0) - ); -} - -/** - * Joins the parts of the given jsdocs tag. - * - * @param tag The tag to join the parts of. - */ -export function joinTagContent(tag: CommentTag): string[] { - return [joinTagParts(tag?.content)]; -} - -export function joinTagParts(parts: CommentDisplayPart[]): string; -export function joinTagParts(parts?: CommentDisplayPart[]): string | undefined; -export function joinTagParts(parts?: CommentDisplayPart[]): string | undefined { - return parts?.map((part) => part.text).join(''); -} - -/** - * Checks if the given reflection is deprecated. - * - * @param reflection The reflection to check. - * - * @returns The message explaining the deprecation if deprecated, otherwise `undefined`. - */ -export function extractDeprecated( - reflection?: CommentHolder -): string | undefined { - return extractSingleTagContent('@deprecated', reflection); -} - -/** - * Extracts the "throws" tag from the provided signature. - * - * @param reflection The reflection to check. - * - * @returns The message explaining the conditions when this method throws. Or `undefined` if it does not throw. - */ -export function extractThrows(reflection?: CommentHolder): string | undefined { - const content = extractTagContent('@throws', reflection).join('\n'); - return content.length === 0 ? undefined : content; -} - -/** - * Extracts the "since" tag from the provided signature. - * - * @param reflection The signature to check. - * - * @returns The contents of the `@since` tag. - */ -export function extractSince(reflection: CommentHolder): string { - return extractSingleTagContent('@since', reflection) || MISSING_DESCRIPTION; -} diff --git a/scripts/apidoc/utils.ts b/scripts/apidoc/utils.ts deleted file mode 100644 index b23f3568..00000000 --- a/scripts/apidoc/utils.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { createHash } from 'node:crypto'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import type { Method } from '../../docs/.vitepress/components/api-docs/method'; - -// Types - -export type Page = { text: string; link: string; category: string }; - -export type ModuleSummary = Page & { - methods: Method[]; - diff: DocsApiDiff; -}; - -export interface DocsApiDiffIndex { - /** - * The methods in the module by name. - */ - [module: string]: DocsApiDiff; -} - -export interface DocsApiDiff { - /** - * The checksum of the entire module. - */ - moduleHash: string; - /** - * The checksum of the method by name. - */ - [method: string]: string; -} - -// Paths - -const pathRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); -export const pathDocsDir = resolve(pathRoot, 'docs'); -const pathPublicDir = resolve(pathDocsDir, 'public'); -export const nameDocsDiffIndexFile = 'api-diff-index.json'; -export const pathDocsDiffIndexFile = resolve( - pathPublicDir, - nameDocsDiffIndexFile -); -export const pathOutputDir = resolve(pathDocsDir, 'api'); - -// Functions - -export function adjustUrls(description: string): string { - return description.replaceAll(/https:\/\/(next.)?fakerjs.dev\//g, '/'); -} - -export function mapByName( - input: TInput[], - valueExtractor: (item: TInput) => TValue -): Record { - return Object.fromEntries( - input.map((item) => [item.name, valueExtractor(item)]) - ); -} - -/** - * Creates a diff hash for the given method by removing the line number from the source path. - * - * @param method The method to create a hash for. - */ -export function methodDiffHash(method: Method): string { - return diffHash({ - ...method, - sourcePath: method.sourcePath.replaceAll(/#.*/g, ''), - }); -} - -/** - * Creates a diff hash for the given object. - * - * @param object The object to create a hash for. - */ -export function diffHash(object: unknown): string { - return createHash('md5').update(JSON.stringify(object)).digest('hex'); -} diff --git a/scripts/apidoc/writer.ts b/scripts/apidoc/writer.ts deleted file mode 100644 index b03cfe85..00000000 --- a/scripts/apidoc/writer.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { writeFileSync } from 'node:fs'; -import { resolve } from 'node:path'; -import type { ProjectReflection } from 'typedoc'; -import { ReflectionKind } from 'typedoc'; -import type { DefaultTheme } from 'vitepress'; -import type { Method } from '../../docs/.vitepress/components/api-docs/method'; -import type { APIGroup } from '../../docs/api/api-types'; -import { groupBy } from '../../src/internal/group-by'; -import { formatMarkdown, formatTypescript } from './format'; -import { extractSourceBaseUrl } from './typedoc'; -import type { DocsApiDiffIndex, ModuleSummary, Page } from './utils'; -import { - diffHash, - methodDiffHash, - pathDocsDiffIndexFile, - pathDocsDir, - pathOutputDir, -} from './utils'; - -const pathDocsApiPages = resolve(pathDocsDir, '.vitepress', 'api-pages.ts'); -const pathDocsApiSearchIndex = resolve( - pathDocsDir, - 'api', - 'api-search-index.json' -); - -const scriptCommand = 'pnpm run generate:api-docs'; - -// Moved here because this must not be formatted by prettier -const vitePressInFileOptions = `--- -editLink: false ---- - -`; - -/** - * Writes the api docs for the given modules. - * - * @param moduleName The name of the module to write the docs for. - * @param lowerModuleName The lowercase name of the module. - * @param comment The module comments. - * @param examples The example code. - * @param deprecated The deprecation message. - * @param methods The methods of the module. - * @param category The category of the module. - */ -export async function writeApiDocsModule( - moduleName: string, - lowerModuleName: string, - comment: string, - examples: string | undefined, - deprecated: string | undefined, - methods: Method[], - category: string -): Promise { - await writeApiDocsModulePage( - moduleName, - lowerModuleName, - comment, - examples, - deprecated, - methods - ); - writeApiDocsModuleData(lowerModuleName, methods); - - return { - text: moduleName, - link: `/api/${lowerModuleName}.html`, - methods, - category, - diff: { - moduleHash: diffHash({ - name: moduleName, - field: lowerModuleName, - deprecated, - comment, - }), - ...Object.fromEntries( - methods.map((method) => [method.name, methodDiffHash(method)]) - ), - }, - }; -} - -/** - * Writes the api page for the given module to the correct location. - * - * @param moduleName The name of the module to write the docs for. - * @param lowerModuleName The lowercase name of the module. - * @param comment The module comments. - * @param examples The example code. - * @param deprecated The deprecation message. - * @param methods The methods of the module. - */ -async function writeApiDocsModulePage( - moduleName: string, - lowerModuleName: string, - comment: string, - examples: string | undefined, - deprecated: string | undefined, - methods: Method[] -): Promise { - // Write api docs page - let content = ` - - - - - - # ${moduleName} - - ::: v-pre - - ${ - deprecated == null - ? '' - : `

-

Deprecated

-

This module is deprecated and will be removed in a future version.

- ${deprecated} -
` - } - - ${comment} - - ${examples == null ? '' : `
${examples}
`} - - ::: - - ${methods - .map( - (method) => ` - ## ${method.name} - - - ` - ) - .join('')} - `.replaceAll(/\n +/g, '\n'); - - content = vitePressInFileOptions + (await formatMarkdown(content)); - - writeFileSync(resolve(pathOutputDir, `${lowerModuleName}.md`), content); -} - -/** - * Writes the api docs data to correct location. - * - * @param lowerModuleName The lowercase name of the module. - * @param methods The methods data to save. - */ -function writeApiDocsModuleData( - lowerModuleName: string, - methods: Method[] -): void { - const content = JSON.stringify( - Object.fromEntries(methods.map((method) => [method.name, method])) - ); - - writeFileSync(resolve(pathOutputDir, `${lowerModuleName}.json`), content); -} - -/** - * Writes the api docs index to correct location. - * - * @param pages The pages to write into the index. - */ -export async function writeApiPagesIndex(pages: Page[]): Promise { - const pagesByCategory: Record = groupBy( - pages, - (page) => page.category, - ({ text, link }) => ({ text, link }) - ); - const pageTree = Object.entries(pagesByCategory).flatMap( - ([category, items]) => (category ? [{ text: category, items }] : items) - ); - - // Write api-pages.ts - console.log('Updating api-pages.ts'); - pageTree.splice(0, 0, { text: 'Overview', link: '/api/' }); - let apiPagesContent = ` - // This file is automatically generated. - // Run '${scriptCommand}' to update - export const apiPages = ${JSON.stringify(pageTree)}; - `.replace(/\n +/, '\n'); - - apiPagesContent = await formatTypescript(apiPagesContent); - - writeFileSync(pathDocsApiPages, apiPagesContent); -} - -/** - * Writes the api diff index to the correct location. - * - * @param diffIndex The diff index project to write. - */ -export function writeApiDiffIndex(diffIndex: DocsApiDiffIndex): void { - writeFileSync(pathDocsDiffIndexFile, JSON.stringify(diffIndex)); -} - -/** - * Writes the api search index to the correct location. - * - * @param pages The pages to write into the index. - */ -export function writeApiSearchIndex(pages: ModuleSummary[]): void { - const apiIndex: APIGroup[] = [ - { - text: 'Module API', - items: pages.map((module) => ({ - text: module.text, - link: module.link, - headers: module.methods.map((method) => ({ - anchor: method.name, - text: method.name, - deprecated: !!method.deprecated, - })), - })), - }, - ]; - - writeFileSync(pathDocsApiSearchIndex, JSON.stringify(apiIndex)); -} - -/** - * Writes the source base url to the correct location. - * - * @param project The typedoc project. - */ -export async function writeSourceBaseUrl( - project: ProjectReflection -): Promise { - const baseUrl = extractSourceBaseUrl( - project.getChildrenByKind(ReflectionKind.Class)[0] - ); - - let content = ` - // This file is automatically generated. - // Run '${scriptCommand}' to update - export const sourceBaseUrl = '${baseUrl}'; - `.replace(/\n +/, '\n'); - - content = await formatTypescript(content); - - writeFileSync(resolve(pathOutputDir, 'source-base-url.ts'), content); -} diff --git a/scripts/apidocs.ts b/scripts/apidocs.ts new file mode 100644 index 00000000..4a109006 --- /dev/null +++ b/scripts/apidocs.ts @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import { generate } from './apidocs/generate'; +import { initMarkdownRenderer } from './apidocs/utils/markdown'; + +await initMarkdownRenderer(); +await generate(); diff --git a/scripts/apidocs/diff.ts b/scripts/apidocs/diff.ts new file mode 100644 index 00000000..0ccdad6e --- /dev/null +++ b/scripts/apidocs/diff.ts @@ -0,0 +1,97 @@ +import type { ApiDiffHashes } from './output/diff-index'; +import { + FILE_NAME_DOCS_DIFF_INDEX, + FILE_PATH_DOCS_DIFF_INDEX, +} from './output/diff-index'; + +/** + * Loads the diff index from the given source url. + * + * @param url The url to load the diff index from. + */ +async function loadRemote(url: string): Promise { + return fetch(url).then((res) => { + if (!res.ok) { + throw new Error( + `Failed to load remote diff index from ${url}: ${res.statusText}` + ); + } + + return res.json() as Promise; + }); +} + +/** + * Loads the diff index from the given local path. + * + * @param path The path to load the diff index from. Should start with `file://` for cross platform compatibility. + */ +async function loadLocal(path: string): Promise { + return import(path).then((imp) => imp.default as ApiDiffHashes); +} + +/** + * Loads the diff index from the given source. + * If the source starts with `https://` it will be loaded from the remote url. + * Otherwise it will be loaded from the local path. + * + * @param source The source to load the diff index from. + */ +async function load(source: string): Promise { + return source.startsWith('https://') ? loadRemote(source) : loadLocal(source); +} + +/** + * Returns a set of all keys from the given entries. + * + * @param entries The entries to get the keys from. + */ +function allKeys( + ...entries: ReadonlyArray> +): Set { + return new Set(entries.flatMap(Object.keys)); +} + +/** + * Compares the target (reference) and source (changed) diff index and returns the differences. + * The returned object contains the module names as keys and the method names as values. + * If the module name is `ADDED` or `REMOVED` it means that the module was added or removed in the local diff index. + * + * @param targetDiffIndex The url to the target (reference) diff index. Defaults to the next.fakerjs.dev diff index. + * @param sourceDiffIndex The path to the source (changed) index. Defaults to the local diff index. + */ +export async function diff( + targetDiffIndex = `https://next.fakerjs.dev/${FILE_NAME_DOCS_DIFF_INDEX}`, + sourceDiffIndex = `file://${FILE_PATH_DOCS_DIFF_INDEX}` +): Promise> { + const target = await load(targetDiffIndex); + const source = await load(sourceDiffIndex); + + const diff: Record = {}; + + for (const moduleName of allKeys(target, source)) { + const remoteModule = target[moduleName]; + const localModule = source[moduleName]; + + if (!remoteModule) { + diff[moduleName] = ['ADDED']; + continue; + } + + if (!localModule) { + diff[moduleName] = ['REMOVED']; + continue; + } + + for (const methodName of allKeys(remoteModule, localModule)) { + const remoteMethod = remoteModule[methodName]; + const localMethod = localModule[methodName]; + + if (remoteMethod !== localMethod) { + (diff[moduleName] ??= []).push(methodName); + } + } + } + + return diff; +} diff --git a/scripts/apidocs/generate.ts b/scripts/apidocs/generate.ts new file mode 100644 index 00000000..81021c5f --- /dev/null +++ b/scripts/apidocs/generate.ts @@ -0,0 +1,45 @@ +import type { Project } from 'ts-morph'; +import { writeDiffIndex } from './output/diff-index'; +import { writePages } from './output/page'; +import { writePageIndex } from './output/page-index'; +import { writeSearchIndex } from './output/search-index'; +import { writeSourceBaseUrl } from './output/source-base-url'; +import type { RawApiDocsPage } from './processing/class'; +import { + processModuleClasses, + processProjectClasses, + processProjectInterfaces, + processProjectUtilities, +} from './processing/class'; +import { getProject } from './project'; + +export async function generate(): Promise { + console.log('Reading project'); + const project = getProject(); + console.log('Processing components'); + const apiDocsPages = processComponents(project); + console.log('Writing files'); + await writeFiles(apiDocsPages); +} + +export function processComponents(project: Project): RawApiDocsPage[] { + return [ + ...processProjectClasses(project), + ...processProjectInterfaces(project), + processProjectUtilities(project), + ...processModuleClasses(project), + ]; +} + +async function writeFiles(apiDocsPages: RawApiDocsPage[]): Promise { + console.log('- diff index'); + writeDiffIndex(apiDocsPages); + console.log('- page index'); + await writePageIndex(apiDocsPages); + console.log('- pages'); + await writePages(apiDocsPages); + console.log('- search index'); + writeSearchIndex(apiDocsPages); + console.log('- source base url'); + await writeSourceBaseUrl(); +} diff --git a/scripts/apidocs/output/constants.ts b/scripts/apidocs/output/constants.ts new file mode 100644 index 00000000..ec579e3c --- /dev/null +++ b/scripts/apidocs/output/constants.ts @@ -0,0 +1 @@ +export const SCRIPT_COMMAND = 'pnpm run generate:api-docs'; diff --git a/scripts/apidocs/output/diff-index.ts b/scripts/apidocs/output/diff-index.ts new file mode 100644 index 00000000..f32c5dfe --- /dev/null +++ b/scripts/apidocs/output/diff-index.ts @@ -0,0 +1,81 @@ +import { createHash } from 'node:crypto'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { RawApiDocsPage } from '../processing/class'; +import type { RawApiDocsMethod } from '../processing/method'; +import { FILE_PATH_PUBLIC } from '../utils/paths'; + +export const FILE_NAME_DOCS_DIFF_INDEX = 'api-diff-index.json'; +export const FILE_PATH_DOCS_DIFF_INDEX = resolve( + FILE_PATH_PUBLIC, + FILE_NAME_DOCS_DIFF_INDEX +); + +/** + * The diff hashes for the entire api. + */ +export interface ApiDiffHashes { + /** + * The pages with their diff hashes. + */ + [pages: string]: ApiPageDiffHashes; +} + +/** + * The diff hashes for a single api doc page. + */ +export interface ApiPageDiffHashes { + /** + * The checksum of the entire page. + */ + pageHash: string; + /** + * The checksum of the method by name. + */ + [method: string]: string; +} + +/** + * Writes the api diff index to the correct location. + * + * @param pages The pages to write into the index. + */ +export function writeDiffIndex(pages: RawApiDocsPage[]): void { + const diffIndex: ApiDiffHashes = Object.fromEntries( + pages.map((page) => [page.title, pageDiffHashes(page)]) + ); + writeFileSync(FILE_PATH_DOCS_DIFF_INDEX, JSON.stringify(diffIndex)); +} + +function pageDiffHashes(page: RawApiDocsPage): ApiPageDiffHashes { + return { + pageHash: diffHash({ + ...page, + methods: undefined, + } satisfies Partial), + ...Object.fromEntries( + page.methods.map((method) => [method.name, methodDiffHash(method)]) + ), + }; +} + +/** + * Creates a diff hash for the given method by removing the line number from the source path. + * + * @param method The method to create a hash for. + */ +function methodDiffHash(method: RawApiDocsMethod): string { + return diffHash({ + ...method, + source: method.source.filePath, + } satisfies Record); +} + +/** + * Creates a diff hash for the given object. + * + * @param object The object to create a hash for. + */ +function diffHash(object: unknown): string { + return createHash('md5').update(JSON.stringify(object)).digest('hex'); +} diff --git a/scripts/apidocs/output/page-index.ts b/scripts/apidocs/output/page-index.ts new file mode 100644 index 00000000..b676ca24 --- /dev/null +++ b/scripts/apidocs/output/page-index.ts @@ -0,0 +1,38 @@ +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { DefaultTheme } from 'vitepress'; +import { groupBy } from '../../../src/internal/group-by'; +import type { RawApiDocsPage } from '../processing/class'; +import { formatTypescript } from '../utils/format'; +import { FILE_PATH_DOCS } from '../utils/paths'; +import { SCRIPT_COMMAND } from './constants'; + +const pathDocsApiPages = resolve(FILE_PATH_DOCS, '.vitepress', 'api-pages.ts'); + +/** + * Writes the api docs index to correct location. + * + * @param pages The pages to write into the index. + */ +export async function writePageIndex(pages: RawApiDocsPage[]): Promise { + const pagesByCategory: Record = groupBy( + pages, + (page) => page.category ?? '', + ({ title: text, camelTitle }) => ({ text, link: `/api/${camelTitle}.html` }) + ); + const pageTree = Object.entries(pagesByCategory).flatMap( + ([category, items]) => (category ? [{ text: category, items }] : items) + ); + + // Write api-pages.ts + pageTree.unshift({ text: 'Overview', link: '/api/' }); + let apiPagesContent = ` + // This file is automatically generated. + // Run '${SCRIPT_COMMAND}' to update + export const apiPages = ${JSON.stringify(pageTree)}; + `.replace(/\n +/, '\n'); + + apiPagesContent = await formatTypescript(apiPagesContent); + + writeFileSync(pathDocsApiPages, apiPagesContent); +} diff --git a/scripts/apidocs/output/page.ts b/scripts/apidocs/output/page.ts new file mode 100644 index 00000000..ae1c3c4f --- /dev/null +++ b/scripts/apidocs/output/page.ts @@ -0,0 +1,172 @@ +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { ApiDocsMethod } from '../../../docs/.vitepress/components/api-docs/method'; +import type { RawApiDocsPage } from '../processing/class'; +import type { RawApiDocsMethod } from '../processing/method'; +import { formatMarkdown } from '../utils/format'; +import { adjustUrls, codeToHtml, mdToHtml } from '../utils/markdown'; +import { FILE_PATH_API_DOCS } from '../utils/paths'; +import { required } from '../utils/value-checks'; +import { SCRIPT_COMMAND } from './constants'; + +// Extracted to a constant because the contents must not be formatted by prettier +const vitePressInFileOptions = `--- +editLink: false +--- + +`; + +/** + * Writes the api docs page and data for the given modules to the correct location. + * + * @param pages The pages to write. + */ +export async function writePages(pages: RawApiDocsPage[]): Promise { + await Promise.all(pages.map(writePage)); +} + +/** + * Writes the api docs page and data for the given module to the correct location. + * + * @param page The page to write. + */ +async function writePage(page: RawApiDocsPage): Promise { + try { + await writePageMarkdown(page); + writePageJsonData(page); + } catch (error) { + throw new Error(`Error writing page ${page.title}`, { cause: error }); + } +} + +/** + * Writes the api docs page for the given module to the correct location. + * + * @param page The page to write. + */ +async function writePageMarkdown(page: RawApiDocsPage): Promise { + const { title, camelTitle, deprecated, description, examples, methods } = + page; + // Write api docs page + let content = ` + + + + + + # ${title} + + ::: v-pre + + ${ + deprecated == null + ? '' + : `
+

Deprecated

+

This module is deprecated and will be removed in a future version.

+ ${deprecated} +
` + } + + ${adjustUrls(description)} + + ${examples.length === 0 ? '' : `
${codeToHtml(examples.join('\n'))}
`} + + ::: + + ${methods + .map( + (method) => ` + ## ${method.name} + + + ` + ) + .join('')} + `.replaceAll(/\n +/g, '\n'); + + content = vitePressInFileOptions + (await formatMarkdown(content)); + + writeFileSync(resolve(FILE_PATH_API_DOCS, `${camelTitle}.md`), content); +} + +/** + * Writes the api docs data for the given module to correct location. + * + * @param page The page to write. + */ +function writePageJsonData(page: RawApiDocsPage): void { + const { camelTitle, methods } = page; + const pageData: Record = Object.fromEntries( + methods.map((method) => [method.name, toMethodData(method)]) + ); + const content = JSON.stringify(pageData, null, 2); + + writeFileSync(resolve(FILE_PATH_API_DOCS, `${camelTitle}.json`), content); +} + +const defaultCommentRegex = /\s+Defaults to `([^`]+)`\..*/; + +function toMethodData(method: RawApiDocsMethod): ApiDocsMethod { + const { name, signatures, source } = method; + const signatureData = required(signatures.at(-1), 'method signature'); + const { + deprecated, + description, + since, + parameters, + returns, + throws, + signature, + examples, + seeAlsos, + } = signatureData; + const { filePath, line } = source; + + /* Target order, omitted to improve diff to old files + return { + name, + deprecated: mdToHtml(deprecated), + description: mdToHtml(description), + since, + parameters: parameters.map((param) => ({ + ...param, + type: param.type.text, + default: + param.default ?? defaultCommentRegex.exec(param.description)?.[1], + description: mdToHtml(param.description.replace(defaultCommentRegex, '')), + })), + returns: returns.text, + throws: throws.length === 0 ? undefined : mdToHtml(throws.join('\n'), true), + // signature: codeToHtml(signature), + examples: codeToHtml([signature, ...examples].join('\n')), + seeAlsos: seeAlsos.map((seeAlso) => mdToHtml(seeAlso, true)), + sourcePath: sourcePath.replace(/:(\d+):\d+/g, '#L$1'), + }; + */ + + return { + name, + description: mdToHtml(description), + parameters: parameters.map((param) => ({ + ...param, + type: param.type.text, + default: param.default ?? extractSummaryDefault(param.description), + description: mdToHtml(param.description.replace(defaultCommentRegex, '')), + })), + since, + sourcePath: `${filePath}#L${line}`, + throws: throws.length === 0 ? undefined : mdToHtml(throws.join('\n'), true), + returns: returns.text, + examples: codeToHtml([signature, ...examples].join('\n')), + deprecated: mdToHtml(deprecated), + seeAlsos: seeAlsos.map((seeAlso) => mdToHtml(seeAlso, true)), + }; +} + +export function extractSummaryDefault(description: string): string | undefined { + return defaultCommentRegex.exec(description)?.[1]; +} diff --git a/scripts/apidocs/output/search-index.ts b/scripts/apidocs/output/search-index.ts new file mode 100644 index 00000000..9bff4a4e --- /dev/null +++ b/scripts/apidocs/output/search-index.ts @@ -0,0 +1,34 @@ +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import type { APIGroup } from '../../../docs/api/api-types'; +import type { RawApiDocsPage } from '../processing/class'; +import { FILE_PATH_API_DOCS } from '../utils/paths'; + +const pathDocsApiSearchIndex = resolve( + FILE_PATH_API_DOCS, + 'api-search-index.json' +); + +/** + * Writes the api search index to the correct location. + * + * @param pages The pages to write into the index. + */ +export function writeSearchIndex(pages: RawApiDocsPage[]): void { + const apiIndex: APIGroup[] = [ + { + text: 'Module API', + items: pages.map((page) => ({ + text: page.title, + link: `/api/${page.camelTitle}.html`, + headers: page.methods.map((method) => ({ + anchor: method.name, + text: method.name, + deprecated: method.signatures.every((s) => !!s.deprecated), + })), + })), + }, + ]; + + writeFileSync(pathDocsApiSearchIndex, JSON.stringify(apiIndex)); +} diff --git a/scripts/apidocs/output/source-base-url.ts b/scripts/apidocs/output/source-base-url.ts new file mode 100644 index 00000000..ca6df85a --- /dev/null +++ b/scripts/apidocs/output/source-base-url.ts @@ -0,0 +1,38 @@ +import { execSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { formatTypescript } from '../utils/format'; +import { FILE_PATH_API_DOCS } from '../utils/paths'; +import { SCRIPT_COMMAND } from './constants'; + +const pathSourceBaseUrlFile = resolve(FILE_PATH_API_DOCS, 'source-base-url.ts'); + +/** + * Writes the source base url to the correct location. + */ +export async function writeSourceBaseUrl(): Promise { + const baseUrl = getSourceBaseUrl(); + + let content = ` + // This file is automatically generated. + // Run '${SCRIPT_COMMAND}' to update + export const sourceBaseUrl = '${baseUrl}'; + `.replace(/\n +/, '\n'); + + content = await formatTypescript(content); + + writeFileSync(pathSourceBaseUrlFile, content); +} + +function getSourceBaseUrl(): string { + return `https://github.com/faker-js/faker/blob/${getCommitHash() || 'next'}/`; +} + +function getCommitHash(): string | undefined { + try { + return execSync('git rev-parse --verify HEAD').toString('utf8').trim(); + } catch (error) { + console.warn('Failed to get commit hash', error); + return undefined; + } +} 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 { + 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 { + 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; + +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 { + 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 { + 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 = {}; + + 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; + +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 { + 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 & { + getOverloads(): SignatureLikeDeclaration[]; + }; + +function processMethodLikes( + 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 +): 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, + 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>; + +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; + +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, + }; +} diff --git a/scripts/apidocs/project.ts b/scripts/apidocs/project.ts new file mode 100644 index 00000000..c63bad8e --- /dev/null +++ b/scripts/apidocs/project.ts @@ -0,0 +1,9 @@ +import type { ProjectOptions } from 'ts-morph'; +import { Project } from 'ts-morph'; + +export function getProject(options: Partial = {}): Project { + return new Project({ + ...options, + tsConfigFilePath: options.tsConfigFilePath ?? 'tsconfig.build.json', + }); +} diff --git a/scripts/apidocs/utils/format.ts b/scripts/apidocs/utils/format.ts new file mode 100644 index 00000000..ab9f524d --- /dev/null +++ b/scripts/apidocs/utils/format.ts @@ -0,0 +1,31 @@ +import type { Options } from 'prettier'; +import { format } from 'prettier'; +import prettierConfig from '../../../.prettierrc.js'; + +/** + * Formats markdown contents. + * + * @param text The text to format. + */ +export async function formatMarkdown(text: string): Promise { + return format(text, prettierMarkdown); +} + +/** + * Formats typedoc contents. + * + * @param text The text to format. + */ +export async function formatTypescript(text: string): Promise { + return format(text, prettierTypescript); +} + +const prettierMarkdown: Options = { + ...prettierConfig, + parser: 'markdown', +}; + +const prettierTypescript: Options = { + ...prettierConfig, + parser: 'typescript', +}; diff --git a/scripts/apidocs/utils/markdown.ts b/scripts/apidocs/utils/markdown.ts new file mode 100644 index 00000000..b0cc68be --- /dev/null +++ b/scripts/apidocs/utils/markdown.ts @@ -0,0 +1,111 @@ +import sanitizeHtml from 'sanitize-html'; +import type { MarkdownRenderer } from 'vitepress'; +import { createMarkdownRenderer } from 'vitepress'; +import vitepressConfig from '../../../docs/.vitepress/config'; +import { FILE_PATH_API_DOCS } from './paths'; + +let markdown: MarkdownRenderer; + +export async function initMarkdownRenderer(): Promise { + markdown = await createMarkdownRenderer( + FILE_PATH_API_DOCS, + vitepressConfig.markdown, + '/' + ); +} + +const htmlSanitizeOptions: sanitizeHtml.IOptions = { + allowedTags: [ + 'a', + 'button', + 'code', + 'div', + 'li', + 'p', + 'pre', + 'span', + 'strong', + 'ul', + ], + allowedAttributes: { + a: ['href', 'target', 'rel'], + button: ['class', 'title'], + div: ['class'], + pre: ['class', 'v-pre'], + span: ['class', 'style'], + }, + selfClosing: [], +}; + +function comparableSanitizedHtml(html: string): string { + return html + .replaceAll(/&#x[0-9A-F]{2};/g, (x) => + String.fromCodePoint(Number.parseInt(x.slice(3, -1), 16)) + ) + .replaceAll('>', '>') + .replaceAll('<', '<') + .replaceAll('&', '&') + .replaceAll('=""', '') + .replaceAll(' ', ''); +} + +/** + * Converts a Typescript code block to an HTML string and sanitizes it. + * + * @param code The code to convert. + * + * @returns The converted HTML string. + */ +export function codeToHtml(code: string): string { + const delimiter = '```'; + return mdToHtml(`${delimiter}ts\n${code}\n${delimiter}`); +} + +/** + * Converts Markdown to an HTML string and sanitizes it. + * + * @param md The markdown to convert. + * @param inline Whether to render the markdown as inline, without a wrapping `

` tag. Defaults to `false`. + * + * @returns The converted HTML string. + */ +export function mdToHtml(md: string, inline?: boolean): string; +/** + * Converts Markdown to an HTML string and sanitizes it. + * + * @param md The markdown to convert. + * @param inline Whether to render the markdown as inline, without a wrapping `

` tag. Defaults to `false`. + * + * @returns The converted HTML string. + */ +export function mdToHtml( + md: string | undefined, + inline?: boolean +): string | undefined; +export function mdToHtml( + md: string | undefined, + inline: boolean = false +): string | undefined { + if (md == null) { + return undefined; + } + + const rawHtml = inline ? markdown.renderInline(md) : markdown.render(md); + + const safeHtml: string = sanitizeHtml(rawHtml, htmlSanitizeOptions); + // Revert some escaped characters for comparison. + if (comparableSanitizedHtml(rawHtml) === comparableSanitizedHtml(safeHtml)) { + return adjustUrls(safeHtml); + } + + console.debug('Rejected unsafe md:\n', md); + console.error('Rejected unsafe html:\n', rawHtml); + console.error('Clean unsafe html:\n', comparableSanitizedHtml(rawHtml)); + console.error('Clean safe html:\n', comparableSanitizedHtml(safeHtml)); + console.log('-'.repeat(80)); + throw new Error('Found unsafe html'); +} + +export function adjustUrls(description: string): string { + return description.replaceAll(/https:\/\/(next.)?fakerjs.dev\//g, '/'); +} diff --git a/scripts/apidocs/utils/paths.ts b/scripts/apidocs/utils/paths.ts new file mode 100644 index 00000000..8abca1ea --- /dev/null +++ b/scripts/apidocs/utils/paths.ts @@ -0,0 +1,24 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const FILE_PATH_THIS = dirname(fileURLToPath(import.meta.url)); +/** + * The path to the project directory. + */ +// Required for converting the source file paths to relative paths +export const FILE_PATH_PROJECT = resolve(FILE_PATH_THIS, '..', '..', '..'); +/** + * The path to the docs directory. + */ +// Required for writing the api page vitepress config +export const FILE_PATH_DOCS = resolve(FILE_PATH_PROJECT, 'docs'); +/** + * The path to the website's public directory. + */ +// Required for publishing the diff index +export const FILE_PATH_PUBLIC = resolve(FILE_PATH_DOCS, 'public'); +/** + * The path to the api docs directory. + */ +// Required for writing various api docs files +export const FILE_PATH_API_DOCS = resolve(FILE_PATH_DOCS, 'api'); diff --git a/scripts/apidocs/utils/value-checks.ts b/scripts/apidocs/utils/value-checks.ts new file mode 100644 index 00000000..f8578ccc --- /dev/null +++ b/scripts/apidocs/utils/value-checks.ts @@ -0,0 +1,69 @@ +export function exactlyOne(input: ReadonlyArray, property: string): T { + if (input.length !== 1) { + throw new Error( + `Expected exactly one element for ${property}, got ${input.length}` + ); + } + + return input[0]; +} + +export function optionalOne( + input: ReadonlyArray, + property: string +): T | undefined { + if (input.length > 1) { + throw new Error( + `Expected one optional element for ${property}, got ${input.length}` + ); + } + + return input[0]; +} + +export function required( + input: T | undefined, + property: string +): NonNullable { + if (input == null) { + throw new Error(`Expected a value for ${property}, got undefined`); + } + + return input; +} + +export function allRequired( + input: ReadonlyArray, + property: string +): Array> { + return input.map((v, i) => required(v, `${property}[${i}]`)); +} + +export function atLeastOne( + input: ReadonlyArray, + property: string +): ReadonlyArray { + if (input.length === 0) { + throw new Error(`Expected at least one element for ${property}`); + } + + return input; +} + +export function atLeastOneAndAllRequired( + input: ReadonlyArray, + property: string +): ReadonlyArray> { + return atLeastOne(allRequired(input, property), property); +} + +export function valueForKey(input: Record, key: string): T { + return required(input[key], key); +} + +export function valuesForKeys( + input: Record, + keys: string[] +): T[] { + return keys.map((key) => valueForKey(input, key)); +} diff --git a/scripts/diff.ts b/scripts/diff.ts index 813154f2..f52076aa 100644 --- a/scripts/diff.ts +++ b/scripts/diff.ts @@ -2,14 +2,14 @@ import { existsSync } from 'node:fs'; import { argv } from 'node:process'; -import { diff } from './apidoc/diff'; -import { pathDocsDiffIndexFile } from './apidoc/utils'; +import { diff } from './apidocs/diff'; +import { FILE_PATH_DOCS_DIFF_INDEX } from './apidocs/output/diff-index'; const [target, source] = argv.slice(2); -if (!source && !existsSync(pathDocsDiffIndexFile)) { +if (!source && !existsSync(FILE_PATH_DOCS_DIFF_INDEX)) { throw new Error( - `Unable to find local diff index file at: ${pathDocsDiffIndexFile}\n + `Unable to find local diff index file at: ${FILE_PATH_DOCS_DIFF_INDEX}\n You can run \`pnpm run generate:api-docs\` to generate it.` ); } diff --git a/scripts/generate-locales.ts b/scripts/generate-locales.ts index 90ea9a34..cba70edc 100644 --- a/scripts/generate-locales.ts +++ b/scripts/generate-locales.ts @@ -25,7 +25,7 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { LocaleDefinition, MetadataDefinition } from '../src/definitions'; import { keys } from '../src/internal/keys'; -import { formatMarkdown, formatTypescript } from './apidoc/format'; +import { formatMarkdown, formatTypescript } from './apidocs/utils/format'; // Constants -- cgit v1.2.3