UNPKG

kompendium

Version:

Documentation generator for Stencil components

666 lines (577 loc) 20 kB
import { Application, Reflection, ReflectionKind, DeclarationReflection, TypeDocOptions, CommentTag, TypeDocReader, TSConfigReader, } from 'typedoc'; import { JsonDocsTag, JsonDocsProp } from '@stencil/core/internal'; import { InterfaceDescription, AliasDescription, EnumDescription, EnumMember, TypeDescription, MethodDescription, ParameterDescription, ClassDescription, DecoratorDescription, } from '../types'; import { existsSync, readFileSync } from 'fs'; import ts from 'typescript'; export function parseFile( filename: string, tsconfig?: string, ): TypeDescription[] { if (!existsSync(filename)) { // eslint-disable-next-line no-console console.warn('typeRoot file does not exist', filename); return []; } const app = new Application(); // Use current working directory as TypeDoc's base path for clean relative paths const projectRoot = process.cwd(); const options: Partial<TypeDocOptions> = { entryPoints: [filename], skipErrorChecking: true, basePath: projectRoot, }; if (tsconfig) { options.tsconfig = tsconfig; } if (filename.endsWith('.d.ts')) { options.exclude = ['**/+(*test*|node_modules)/**']; } app.options.addReader(new TypeDocReader()); app.options.addReader(new TSConfigReader()); app.bootstrap(options); const project = app.convert(); if (!project) { // eslint-disable-next-line no-console console.warn('Could not find any type information'); return []; } const data: TypeDescription[] = []; const allInterfaces = project.getReflectionsByKind( ReflectionKind.Interface, ); const allClasses = project.getReflectionsByKind(ReflectionKind.Class); const allTypeAliases = project.getReflectionsByKind( ReflectionKind.TypeAlias, ); const allEnums = project.getReflectionsByKind(ReflectionKind.Enum); const interfaces = allInterfaces.filter((r) => shouldIncludeType(r as DeclarationReflection), ); const classes = allClasses.filter((r) => shouldIncludeType(r as DeclarationReflection), ); const typeAliases = allTypeAliases.filter((r) => shouldIncludeType(r as DeclarationReflection), ); const enums = allEnums.filter((r) => shouldIncludeType(r as DeclarationReflection), ); interfaces.forEach((reflection) => addInterface(reflection as DeclarationReflection, data as any), ); classes.forEach((reflection) => addClass(reflection as DeclarationReflection, data as any), ); typeAliases.forEach((reflection) => addType(reflection as DeclarationReflection, data as any), ); enums.forEach((reflection) => { const members: EnumMember[] = []; const enumReflection = reflection as DeclarationReflection; enumReflection.children?.forEach((child) => { if (child.kind === ReflectionKind.EnumMember) { addEnumMember(child, members); } }); data.push({ type: 'enum', name: enumReflection.name, docs: getDocs(enumReflection), docsTags: getDocsTags(enumReflection), members: members, sources: getSources(enumReflection), } as EnumDescription); }); return data; } /** * Determines if a type should be included in the documentation based on its source location * and documentation tags. * * Excludes types from: * - node_modules (third-party dependencies) * - examples directories * - test files (.test., .spec., /test/, /tests/) * - Types marked with @private or @internal tags * - Stencil auto-generated types (components.d.ts, Components namespace, CustomEvent wrappers) * @param {DeclarationReflection} reflection - The TypeDoc reflection to check * @returns {boolean} true if the type should be documented, false otherwise */ function shouldIncludeType(reflection: DeclarationReflection): boolean { if (!reflection.sources || reflection.sources.length === 0) { // No source information - include by default return true; } for (const source of reflection.sources) { const sourcePath = source.fullFileName || source.fileName || ''; if (shouldExcludeSource(sourcePath)) { return false; } } // Exclude CustomEvent wrapper types // These are generic wrappers around CustomEvent<T> that don't add useful // documentation if (reflection.name.endsWith('CustomEvent')) { return false; } // Exclude HTML element interface types // These are DOM element interfaces (HTMLLimelButtonElement, etc.) already // documented in the Components section if ( reflection.name.startsWith('HTML') && reflection.name.endsWith('Element') ) { return false; } // Exclude types in Stencil's Components namespace // Belt-and-suspenders: catches any component interfaces that were not // excluded based on source location if (isInComponentsNamespace(reflection)) { return false; } // Don't include anything marked @private or @internal if (reflection.comment?.blockTags) { const hasPrivateTag = reflection.comment.blockTags.some( (tag: any) => tag.tag === '@private' || tag.tag === '@internal', ); if (hasPrivateTag) { return false; } } // Include this type return true; } /** * Checks if a source path should be excluded from documentation. * @param {string} sourcePath - The file path to check * @returns {boolean} true if the source should be excluded, false otherwise */ function shouldExcludeSource(sourcePath: string): boolean { const normalizedPath = sourcePath.replaceAll('\\', '/'); if (normalizedPath.includes('node_modules/')) { return true; } if ( normalizedPath.includes('/examples/') || normalizedPath.includes('/example/') ) { return true; } if ( normalizedPath.includes('.test.') || normalizedPath.includes('.spec.') ) { return true; } // This file contains component prop interfaces and HTML element types that // are already documented in the Components section return normalizedPath.endsWith('components.d.ts'); } /** * Checks if a TypeDoc reflection is defined within Stencil's Components namespace. * * Stencil generates a Components namespace in components.d.ts containing interfaces * for component props. These types are already documented in the Components section * and should not be duplicated in the Types section. * @param {DeclarationReflection} reflection - The TypeDoc reflection to check * @returns {boolean} true if the reflection is in a Components namespace, false otherwise */ function isInComponentsNamespace(reflection: DeclarationReflection): boolean { let current: Reflection | undefined = reflection.parent; while (current) { if ( current.kind === ReflectionKind.Namespace && current.name === 'Components' ) { return true; } current = current.parent; } return false; } const fns = { [ReflectionKind.Interface]: addInterface, [ReflectionKind.Class]: addClass, [ReflectionKind.TypeAlias]: addType, [ReflectionKind.Enum]: addEnum, [ReflectionKind.EnumMember]: addEnumMember, }; const traverseCallback = (data: any) => (reflection: Reflection) => { const fn = fns[reflection.kind]; if (fn) { fn(reflection, data); } else { reflection.traverse(traverseCallback(data)); } }; function addInterface( reflection: DeclarationReflection, data: InterfaceDescription[], ) { data.push({ type: 'interface', name: reflection.name, typeParams: getTypeParams(reflection), docs: getDocs(reflection), docsTags: getDocsTags(reflection), props: reflection.children?.filter(isProperty).map(getProperty) || [], methods: reflection.children?.filter(isMethod).map(getMethod) || [], sources: getSources(reflection), }); } function addClass(reflection: DeclarationReflection, data: ClassDescription[]) { const decorators = getDecorators(reflection); const implementedInterfaces = getImplementedInterfaces(reflection); data.push({ type: 'class', name: reflection.name, typeParams: getTypeParams(reflection), docs: getDocs(reflection), docsTags: getDocsTags(reflection), props: reflection.children ?.filter(isProperty) .map((prop) => getPropertyWithInheritDoc(prop, implementedInterfaces), ) || [], methods: reflection.children ?.filter(isMethod) .map((method) => getMethodWithInheritDoc(method, implementedInterfaces), ) || [], sources: getSources(reflection), decorators: decorators, }); } function addType(reflection: DeclarationReflection, data: AliasDescription[]) { data.push({ type: 'alias', name: reflection.name, docs: getDocs(reflection), docsTags: getDocsTags(reflection), alias: reflection.type.toString(), sources: getSources(reflection), }); } function addEnum(reflection: DeclarationReflection, data: EnumDescription[]) { const members = []; reflection.traverse(traverseCallback(members)); data.push({ type: 'enum', name: reflection.name, docs: getDocs(reflection), docsTags: getDocsTags(reflection), members: members, sources: getSources(reflection), }); } function addEnumMember(reflection: DeclarationReflection, data: EnumMember[]) { let value: string; if (reflection.type && (reflection.type as any).type === 'literal') { const literalValue = (reflection.type as any).value; if (typeof literalValue === 'string') { value = `"${literalValue}"`; } else { value = String(literalValue); } } else { value = reflection.defaultValue; } data.push({ name: reflection.name, docs: getDocs(reflection), docsTags: getDocsTags(reflection), value: value, }); } function getDocs(reflection: Reflection): string { if (!reflection.comment) { return ''; } const text = reflection.comment.summary ?.map((part: any) => part.text) .join('') .trim() || ''; // Normalize multiple newlines to single newlines return text.replaceAll(/\n\n+/g, '\n'); } function getDocsTags(reflection: DeclarationReflection) { return reflection.comment?.blockTags?.map(getTag) || []; } function getTag(tag: CommentTag): JsonDocsTag { // tag already has @ prefix, so remove it const tagName = (tag.tag as string).replace(/^@+/, ''); return { name: tagName, text: tag.content ?.map((part: any) => part.text) .join('') .trim() || '', }; } function isProperty(reflection: DeclarationReflection): boolean { if (reflection.kind !== ReflectionKind.Property) { return false; } const type = reflection.type as any; return !(type?.type === 'reflection' && type.declaration?.signatures); } function isMethod(reflection: DeclarationReflection): boolean { if (reflection.kind !== ReflectionKind.Property) { return false; } const type = reflection.type as any; return type?.type === 'reflection' && type.declaration?.signatures; } function getProperty(reflection: DeclarationReflection): Partial<JsonDocsProp> { return { name: reflection.name, type: reflection.type.toString(), docs: getDocs(reflection), docsTags: getDocsTags(reflection), default: reflection.defaultValue, optional: reflection.flags.isOptional, }; } function getMethod(reflection: DeclarationReflection): MethodDescription { const type = reflection.type as any; const signature = type?.declaration?.signatures?.[0]; if (!signature) { return { name: reflection.name, docs: getDocs(reflection), docsTags: [], parameters: [], returns: { type: 'void', docs: '' }, }; } let docs = signature.comment?.summary ?.map((part: any) => part.text) .join('') .trim() || ''; // Normalize multiple newlines to single newlines docs = docs.replaceAll(/\n\n+/g, '\n'); const parameters: ParameterDescription[] = signature.parameters?.map((param: any) => ({ name: param.name, type: param.type?.toString() || 'any', docs: param.comment?.summary ?.map((part: any) => part.text) .join('') .trim() || '', default: param.defaultValue, optional: param.flags?.isOptional || false, })) || []; const returnsTag = signature.comment?.blockTags?.find( (tag: any) => tag.tag === '@returns', ); const returnsText = returnsTag?.content ?.map((part: any) => part.text) .join('') .trim() || ''; const returns = { type: signature.type?.toString() || 'void', docs: returnsText, }; const docsTags = signature.comment?.blockTags ?.filter( (tag: any) => tag.tag !== '@param' && tag.tag !== '@returns', ) .map(getTag) || []; return { name: reflection.name, docs: docs, docsTags: docsTags, parameters: parameters, returns: returns, }; } function getImplementedInterfaces( reflection: DeclarationReflection, ): DeclarationReflection[] { const interfaces: DeclarationReflection[] = []; const implemented = (reflection as any).implementedTypes; if (implemented) { implemented.forEach((type: any) => { if (type.reflection) { interfaces.push(type.reflection); } }); } return interfaces; } function getPropertyWithInheritDoc( reflection: DeclarationReflection, interfaces: DeclarationReflection[], ): Partial<JsonDocsProp> { const prop = getProperty(reflection); const hasInheritDoc = reflection.comment?.blockTags?.some( (tag: any) => tag.tag.toLowerCase() === '@inheritdoc', ); if (hasInheritDoc && interfaces.length > 0) { // Try to find the property in implemented interfaces for (const iface of interfaces) { const interfaceProp = iface.children?.find( (child) => child.name === reflection.name, ); if (interfaceProp) { return { ...prop, docs: getDocs(interfaceProp), docsTags: getDocsTags(interfaceProp), }; } } } return prop; } function getMethodWithInheritDoc( reflection: DeclarationReflection, interfaces: DeclarationReflection[], ): MethodDescription { const type = reflection.type as any; const signature = type?.declaration?.signatures?.[0]; const hasInheritDoc = signature?.comment?.blockTags?.some( (tag: any) => tag.tag.toLowerCase() === '@inheritdoc', ); if (hasInheritDoc && interfaces.length > 0) { for (const iface of interfaces) { const interfaceMethod = iface.children?.find( (child) => child.name === reflection.name, ); if (interfaceMethod) { return getMethod(interfaceMethod); } } } return getMethod(reflection); } /** * Extract decorator information from TypeScript source code using AST parsing * * TypeDoc 0.23 removed the decorators property from DeclarationReflection, * which was present in 0.17. This function works around that limitation by * parsing the original TypeScript source files to extract decorator information. * @param {DeclarationReflection} reflection - The TypeDoc reflection to extract decorators from * @returns {DecoratorDescription[]} Array of decorator descriptions with names and arguments * @remarks * This is a workaround for incomplete TypeDoc 0.23 API. TypeDoc removed * decorator information from their reflection model, but decorators are * essential for documenting Stencil components (e.g., @Component, @Prop). * * Performance considerations: * - Reads and parses source files on demand (I/O + parsing overhead) * - Caches nothing - each call re-parses the file * - For large codebases, consider caching parsed source files * * Known limitations: * - Only extracts class-level decorators, not property/method decorators * - Decorator arguments are captured as raw text, not parsed * - Fails silently if source file is unavailable or parsing fails * - Depends on TypeScript AST structure (may break with TS version changes) * @example * ```typescript * // For a class like: * @Component({ tag: 'my-component' }) * class MyComponent { } * * // Returns: * [{ * name: 'Component', * arguments: { _config: "{ tag: 'my-component' }" } * }] * ``` */ function getDecorators( reflection: DeclarationReflection, ): DecoratorDescription[] { if (!reflection.sources || reflection.sources.length === 0) { return []; } const source = reflection.sources[0]; const fileName = source.fullFileName || source.fileName; if (!existsSync(fileName)) { return []; } try { const sourceFile = ts.createSourceFile( fileName, readFileSync(fileName, 'utf8'), ts.ScriptTarget.Latest, true, ); let decorators: DecoratorDescription[] = []; const visit = (node: ts.Node): void => { if ( ts.isClassDeclaration(node) && node.name?.getText() === reflection.name ) { let nodeDecorators; if (ts.canHaveDecorators(node)) { nodeDecorators = ts.getDecorators(node); } if (nodeDecorators) { decorators = nodeDecorators.map((decorator: any) => { const expression = decorator.expression; let name = ''; let args: any = {}; if (ts.isCallExpression(expression)) { name = expression.expression.getText(); if (expression.arguments.length > 0) { args = { _config: expression.arguments[0].getText(), }; } } else { name = expression.getText(); } return { name: name, arguments: args }; }); } } ts.forEachChild(node, visit); }; visit(sourceFile); return decorators; } catch (error) { // eslint-disable-next-line no-console console.warn('Failed to parse decorators:', error); return []; } } function getTypeParams(reflection: DeclarationReflection) { return ( reflection.typeParameters?.map((param) => ({ name: param.name, })) || [] ); } function getSources(reflection: DeclarationReflection) { return reflection.sources?.map((source) => source.fileName) || []; }