diff --git a/scripts/api-extractor.ts b/scripts/api-extractor.ts index 87f0695491..7305252936 100644 --- a/scripts/api-extractor.ts +++ b/scripts/api-extractor.ts @@ -576,6 +576,216 @@ forking rushstash to modify the api-documenter markdown generation, which otherwise is the recommended way to do customizations. */ +type ExcerptToken = { + kind: string; + text: string; + canonicalReference?: string; +}; + +class ExcerptTokenMatcher { + readonly #tokens: ExcerptToken[]; + + constructor(tokens: ExcerptToken[]) { + this.#tokens = tokens.slice(); + } + + nextContent() { + const token = this.#tokens.shift(); + if (token?.kind === 'Content') { + return token.text; + } + return undefined; + } + + matchContent(expectedText: string) { + const text = this.nextContent(); + return text !== expectedText; + } + + getTokensUntilArrow() { + const tokens = []; + for (;;) { + const token = this.#tokens.shift(); + if (token === undefined) { + return undefined; + } + if (token.kind === 'Content' && token.text === ') => ') { + return tokens; + } + tokens.push(token); + } + } + + getComponentReturnTokens() { + const first = this.#tokens.shift(); + if (!first) { + return undefined; + } + const second = this.#tokens.shift(); + + if (this.#tokens.length !== 0) { + return undefined; + } + if (first.kind !== 'Reference' || first.text !== 'JSX.Element') { + return undefined; + } + if (!second) { + return [first]; + } else if (second.kind === 'Content' && second.text === ' | null') { + return [first, second]; + } + return undefined; + } +} + +class ApiModelTransforms { + static deserializeWithTransforms( + serialized: any, + transforms: Array<(member: any) => any>, + ): ApiPackage { + if (serialized.kind !== 'Package') { + throw new Error( + `Unexpected root kind in serialized ApiPackage, ${serialized.kind}`, + ); + } + if (serialized.members.length !== 1) { + throw new Error( + `Unexpected members in serialized ApiPackage, [${serialized.members + .map(m => m.kind) + .join(' ')}]`, + ); + } + const [entryPoint] = serialized.members; + if (entryPoint.kind !== 'EntryPoint') { + throw new Error( + `Unexpected kind in serialized ApiPackage member, ${entryPoint.kind}`, + ); + } + + const transformed = { + ...serialized, + members: [ + { + ...entryPoint, + members: entryPoint.members.map(member => + transforms.reduce((m, t) => t(m), member), + ), + }, + ], + }; + + return ApiPackage.deserialize( + transformed, + transformed.metadata, + ) as ApiPackage; + } + + static transformArrowComponents = (member: any) => { + if (member.kind !== 'Variable') { + return member; + } + + const { name, excerptTokens } = member; + + // First letter in name must be uppercase + const [firstChar] = name; + if (firstChar.toLocaleUpperCase('en-US') !== firstChar) { + return member; + } + + // First content must match expected declaration format + const tokens = new ExcerptTokenMatcher(excerptTokens); + if (tokens.nextContent() !== `${name}: `) { + return member; + } + + // Next needs to be an arrow with `props` parameters or no parameters + // followed by a return type of `JSX.Element | null` or just `JSX.Element` + const declStart = tokens.nextContent(); + if (declStart === '(props: ' || declStart === '(_props: ') { + const props = tokens.getTokensUntilArrow(); + const ret = tokens.getComponentReturnTokens(); + if (props && ret) { + return this.makeComponentMember(member, ret, props); + } + } else if (declStart === '() => ') { + const ret = tokens.getComponentReturnTokens(); + if (ret) { + return this.makeComponentMember(member, ret); + } + } + return member; + }; + + static makeComponentMember( + member: any, + ret: ExcerptToken[], + props?: ExcerptToken[], + ) { + const declTokens = props + ? [ + { + kind: 'Content', + text: `export declare function ${member.name}(props: `, + }, + ...props, + { + kind: 'Content', + text: '): ', + }, + ] + : [ + { + kind: 'Content', + text: `export declare function ${member.name}(): `, + }, + ]; + + return { + kind: 'Function', + name: member.name, + releaseTag: member.releaseTag, + docComment: member.docComment ?? '', + canonicalReference: member.canonicalReference, + excerptTokens: [...declTokens, ...ret], + returnTypeTokenRange: { + startIndex: declTokens.length, + endIndex: declTokens.length + ret.length, + }, + parameters: props + ? [ + { + parameterName: 'props', + parameterTypeTokenRange: { + startIndex: 1, + endIndex: 1 + props.length, + }, + }, + ] + : [], + overloadIndex: 1, + }; + } + + static transformTrimDeclare = (member: any) => { + const { excerptTokens } = member; + const firstContent = new ExcerptTokenMatcher(excerptTokens).nextContent(); + if (firstContent && firstContent.startsWith('export declare ')) { + return { + ...member, + excerptTokens: [ + { + kind: 'Content', + text: firstContent.slice('export declare '.length), + }, + ...excerptTokens.slice(1), + ], + }; + } + return member; + }; +} + async function buildDocs({ inputDir, outputDir, @@ -599,13 +809,12 @@ async function buildDocs({ const newModel = new ApiModel(); for (const serialized of serializedPackages) { - // Add any docs filtering logic here - - const pkg = ApiPackage.deserialize( - serialized, - serialized.metadata, - ) as ApiPackage; - newModel.addMember(pkg); + newModel.addMember( + ApiModelTransforms.deserializeWithTransforms(serialized, [ + ApiModelTransforms.transformArrowComponents, + ApiModelTransforms.transformTrimDeclare, + ]), + ); } // The doc AST need to be extended with custom nodes if we want to