packages/pluginutils/src/reflection-paths.ts
import assert from 'assert';
import { existsSync, readdirSync } from 'fs';
import { memoize } from 'lodash';
import { LiteralUnion } from 'type-fest';
import { DeclarationReflection, ProjectReflection, Reflection, ReflectionKind, normalizePath } from 'typedoc';
import { dirname, parse, resolve } from './utils/path';
const isRootFile = ( dir: string, file: string ) => !!( file.match( /^readme.md$/i ) || file.match( /^package.json$/ ) );
export const findModuleRoot = memoize( ( reflection: Reflection, rootMatcher: ( dir: string, file: string ) => boolean = isRootFile ) => {
const projectReflection = reflection.project;
const projectRootFile = projectReflection.sources?.find( src => {
const { dir, base } = parse( src.fullFileName );
return rootMatcher( dir, base );
} )?.fullFileName ?? assert.fail( 'Can\'t get the project root' );
const projectRootDir = normalizePath( dirname( projectRootFile ) );
if( reflection === projectReflection ){
return projectRootDir;
}
for ( const source of reflection.sources ?? [] ) {
const root = _findModuleRoot( normalizePath( dirname( source.fullFileName ) ), projectRootDir, rootMatcher );
if( root ){
return root;
}
}
return dirname( reflection.sources?.[0].fullFileName ?? assert.fail( `Reflection ${reflection.getFriendlyFullName()} has no known source` ) );
} );
const _findModuleRoot = memoize( ( moduleDir: string, projectRoot: string, rootMatcher: ( dir: string, file: string ) => boolean ): string | null => {
if( moduleDir === projectRoot ){
return null;
}
const files = readdirSync( moduleDir );
if( files.some( f => rootMatcher( moduleDir, f ) ) ){
return moduleDir;
}
return _findModuleRoot( normalizePath( dirname( moduleDir ) ), projectRoot, rootMatcher );
} );
export const getWorkspaces = ( project: ProjectReflection ): [ProjectReflection, ...DeclarationReflection[]] => {
const modules = project.getReflectionsByKind( ReflectionKind.Module );
assert( modules.every( ( m ): m is DeclarationReflection => m instanceof DeclarationReflection ) );
return [
project,
...modules,
];
};
export const isModule = ( reflection: Reflection ): reflection is DeclarationReflection => reflection instanceof DeclarationReflection && reflection.kindOf( ReflectionKind.Module );
export const getReflectionParentMatching: {
<T extends Reflection>( reflection: Reflection, filter: ( reflection: Reflection ) => reflection is T ): T | undefined;
( reflection: Reflection, filter: ( reflection: Reflection ) => boolean ): Reflection | undefined;
} = ( reflection: Reflection, filter: ( reflection: Reflection ) => boolean ) => {
let reflectionCursor = reflection as Reflection | undefined;
while( reflectionCursor ){
if( filter( reflectionCursor ) ){
return reflectionCursor;
}
reflectionCursor = reflectionCursor.parent;
}
return reflectionCursor;
};
export const getReflectionModule = ( reflection: Reflection ) => getReflectionParentMatching( reflection, isModule ) ?? reflection.project;
/**
* Don't worry about typings, it's just a string with special prefixes. See {@page resolving-paths.md} for details.
*/
export type NamedPath = LiteralUnion<NamedPath.Relative | NamedPath.Project | NamedPath.ExplicitModule | NamedPath.CurrentModule, string>
export namespace NamedPath {
export type Relative = `.${'.' | ''}/${string}`;
export type Project = `~~:${string}`;
export type ExplicitModule = `~${string}:${string}`
export type CurrentModule = LiteralUnion<`~:${string}`, string>
}
export class ResolveError extends Error {
public constructor( public readonly triedPath: string, options?: ErrorOptions ){
super( `Could not resolve ${triedPath}`, options );
}
}
/**
* Resolve a named path. See {@page resolving-paths.md} for details.
*
* @param args - The reflection to resolve from, an optional container folder, and the target path specifier.
* @returns the resolved path.
*/
export const resolveNamedPath: {
/**
* Resolve a named path. See {@page resolving-paths.md} for details.
*
* @param currentReflection - The reflection to resolve from.
* @param containerFolder - An optional container folder.
* @param path - The target path specifier.
* @returns the resolved path.
*/
( currentReflection: Reflection, containerFolder: string | undefined, path: NamedPath ): string;
/**
* Resolve a named path. See {@page resolving-paths.md} for details.
*
* @param currentReflection - The reflection to resolve from.
* @param path - The target path specifier.
* @returns the resolved path.
*/
( currentReflection: Reflection, path: NamedPath ): string;
} = ( ...args: [Reflection, string | undefined, NamedPath] | [Reflection, NamedPath] ) => {
const [ currentReflection, containerFolder, path ] = args.length === 3 ? args : [ args[0], undefined, args[1] ];
let containerFolderMut = containerFolder;
let pathMut = normalizePath( path );
let reflectionRoots = findModuleRoot( getReflectionModule( currentReflection ) );
if( pathMut.startsWith( '~~:' ) ){
pathMut = pathMut.slice( 3 );
reflectionRoots = findModuleRoot( currentReflection.project );
} else if( pathMut.match( /^~.+?:/ ) ){
const workspaces = getWorkspaces( currentReflection.project ).slice( 1 ).filter( w => pathMut.startsWith( `~${w.name}:` ) );
const workspace = workspaces[0];
if( !workspace ){
throw new Error( `Could not get a module corresponding to the path ${path.slice( 1 )}` );
} else if( workspaces.length > 1 ){
throw new Error( `Ambiguous reference for path ${pathMut}. Matched ${workspaces.map( w => w.name ).join( ', ' )}` );
}
pathMut = pathMut.slice( workspace.name.length + 2 );
reflectionRoots = findModuleRoot( workspace );
} else if( pathMut.match( /^~:/ ) ){
pathMut = pathMut.slice( 2 );
reflectionRoots = findModuleRoot( getReflectionModule( currentReflection ) );
} else if( pathMut.match( /^\.{1,2}\// ) ) {
containerFolderMut = undefined;
reflectionRoots = dirname( currentReflection.sources?.[0].fullFileName ?? assert.fail() );
}
assert( reflectionRoots );
const resolved = normalizePath( resolve( reflectionRoots, containerFolderMut ?? '.', pathMut ) );
if( existsSync( resolved ) ){
return resolved;
}
throw new ResolveError( resolved );
};