packages/plugin-pages/src/output/theme/default-pages-renderer.tsx
import assert from 'assert';
import { copyFileSync } from 'fs';
import { isString } from 'lodash';
import { CommentDisplayPart, DeclarationReflection, DefaultTheme, IndexEvent, JSX, PageEvent, ProjectReflection, Reflection, ReflectionKind, RendererEvent, UrlMapping } from 'typedoc';
import { CurrentPageMemo, IPluginComponent, getReflectionModule, reflectionSourceUtils } from '@knodes/typedoc-pluginutils';
import { join } from '@knodes/typedoc-pluginutils/path';
import { IPagesPluginThemeMethods, RenderPageLinkProps } from './types';
import { ANodeReflection, PageReflection, PagesPluginReflectionKind } from '../../models/reflections';
import type { PagesPlugin } from '../../plugin';
const getPageNameComponents = ( reflection: Reflection ): string[] => reflection.parent instanceof ANodeReflection ?
[ ...getPageNameComponents( reflection.parent ), reflection.name ] :
[];
const getFullPageName = ( page: PageReflection ) => getPageNameComponents( page ).join( ' > ' );
const CSS_FILE_NAME = 'assets/pages.css';
export class DefaultPagesRenderer implements IPagesPluginThemeMethods, IPluginComponent<PagesPlugin> {
private readonly _currentPageMemo = CurrentPageMemo.for( this );
private readonly _theme: DefaultTheme;
private readonly _modulesPages: ANodeReflection[];
private readonly _allPages: PageReflection[];
private readonly _nodeDeclarationMappingCache = new WeakMap<ANodeReflection, DeclarationReflection>();
private _renderPageRestore?: () => void;
public constructor( public readonly plugin: PagesPlugin, event: RendererEvent ){
assert( plugin.application.renderer.theme instanceof DefaultTheme );
this._theme = plugin.application.renderer.theme;
const modulesPages = event.project.getReflectionsByKind( PagesPluginReflectionKind.ROOT as any );
assert( modulesPages.every( ( n ): n is ANodeReflection => n instanceof ANodeReflection ) );
this._modulesPages = modulesPages;
modulesPages.forEach( n => this._mapNode( n ) );
const pages = event.project.getReflectionsByKind( PagesPluginReflectionKind.PAGE as any ).filter( p => !p.kindOf( PagesPluginReflectionKind.ROOT as any ) );
assert( pages.every( ( n ): n is PageReflection => n instanceof PageReflection ) );
this._allPages = pages;
event.urls = [ ...( event.urls ?? [] ), ...pages.map( p => new UrlMapping( p.url, p, this._renderPage.bind( this ) ) ) ];
plugin.application.renderer.on( PageEvent.BEGIN, this._onRendererBeginPage.bind( this ) );
plugin.application.renderer.on( PageEvent.END, this._onRendererEndPage.bind( this ) );
plugin.application.renderer.on( RendererEvent.END, this._onRendererEnd.bind( this ) );
plugin.application.renderer.hooks.on( 'head.end', context => <link rel="stylesheet" href={context.relativeURL( CSS_FILE_NAME )} /> );
plugin.application.renderer.on( IndexEvent.PREPARE_INDEX, this._onRendererPrepareIndex.bind( this ) );
}
/**
* Render a link to a given page.
*
* @param root0 - The rendering context with the target page & label.
* @returns the generated link.
*/
public renderPageLink( { page, label }: RenderPageLinkProps ): JSX.Element {
return <a href={this._theme.markedPlugin.getRelativeUrl( page.isModuleAppendix ? page.module.url ?? assert.fail() : page.url )}>{label ?? page.originalName}</a>;
}
/**
* Render a single page reflection.
*
* @param props - The page event for the page reflection.
* @returns the rendered page.
*/
private _renderPage( props: PageEvent<PageReflection> ): JSX.Element {
const { icons } = this._theme.getRenderContext( props );
const icon = () => icons[ReflectionKind.Module]();
( icons as any )[PagesPluginReflectionKind.PAGE] = icon;
( icons as any )[PagesPluginReflectionKind.PAGE | PagesPluginReflectionKind.ROOT] = icon;
( icons as any )[PagesPluginReflectionKind.MENU] = icon;
( icons as any )[PagesPluginReflectionKind.MENU | PagesPluginReflectionKind.ROOT] = icon;
const castedProps: PageEvent<ProjectReflection> = props as any;
return this._theme.indexTemplate( castedProps );
}
/**
* Map a node reflection to a similar declaration, mimicing Typedoc default rendering process.
*
* @param node - The node to map.
* @param parent - The parent to set on the node (for recursive calls).
* @returns the new declaration reflection.
*/
private _mapNode( node: ANodeReflection, parent: Reflection = node.module ): DeclarationReflection {
const declaration = new DeclarationReflection( node.name, ReflectionKind.Namespace, parent );
declaration.url = node instanceof PageReflection ? node.url : undefined;
declaration.children = node.childrenNodes?.map( c => this._mapNode( c, node.isModuleAppendix ? node.module : declaration ) );
declaration.cssClasses = [
'pages-entry',
`pages-entry-${node instanceof PageReflection ? 'page' : 'menu'}`,
`pages-entry-depth-${node.depth}`,
].filter( isString ).join( ' ' );
declaration.readme = node.comment?.summary;
declaration.sources = node.sources;
this._nodeDeclarationMappingCache.set( node, declaration );
return declaration;
}
/**
* Append a new restoration hook to execute on page rendering end.
*
* @see _onRendererBeginPage
* @see _onRendererEndPage
* @param collect - A function to get the current state.
* @param fn - A function executed with the state retrieved via {@link collect} that restores the previous state.
*/
private _addRestore<T>( collect: () => T, fn: ( data: T ) => void ){
const collected = collect();
const restore = this._renderPageRestore;
this._renderPageRestore = () => {
restore?.();
fn( collected );
};
}
/**
* Retrieve declaration reflections that are mirrors of inputted node reflections.
*
* @param nodeReflections - The node reflections to map.
* @returns the mapped node reflections.
*/
private _mapNodeReflectionsToDeclarations( nodeReflections?: ANodeReflection[] ){
return nodeReflections?.map( cc => {
const node = this._nodeDeclarationMappingCache.get( cc );
assert( node );
return node;
} ) ?? [];
}
/**
* Event callback executed on every page on {@link PageEvent.BEGIN}.
*
* @see _onRendererBeginPageAlterModel
* @see _onRendererBeginPageAlterNavigation
* @param pageEvent - The page event to alter.
*/
private _onRendererBeginPage( pageEvent: PageEvent<any> ){
this._onRendererBeginPageAlterModel( pageEvent );
this._onRendererBeginPageAlterNavigation( pageEvent );
}
/**
* Partial implementation of {@link _onRendererBeginPage} that prepares the navigation for a single page.
*
* @param pageEvent - The page event to alter.
*/
private _onRendererBeginPageAlterNavigation( pageEvent: PageEvent<any> ) {
this._addRestore( () => pageEvent.project.children, prev => pageEvent.project.children = prev );
const modelModule = getReflectionModule( pageEvent.model );
const projectPages = this._modulesPages.find( p => p.module === pageEvent.project );
pageEvent.project.children = [
...this._mapNodeReflectionsToDeclarations( projectPages?.childrenNodes ),
...( pageEvent.project.children ?? [] ).map( projectChild => {
if( projectChild.kindOf( ReflectionKind.Module ) && modelModule === projectChild ) {
const modulePage = this._modulesPages.find( p => p.module === projectChild );
if( modulePage ){
this._addRestore( () => projectChild.children, prev => projectChild.children = prev );
projectChild.children = [
...this._mapNodeReflectionsToDeclarations( modulePage.childrenNodes ),
...( projectChild.children ?? [] ),
];
}
}
return projectChild;
} ),
];
}
/**
* Partial implementation of {@link _onRendererBeginPage} responsible of modifying the page mode. Node reflections are replaced with a similar declaration,
* and modules/projects are prepended with root pages sources.
*
* @param pageEvent - The page event to alter.
*/
private _onRendererBeginPageAlterModel( pageEvent: PageEvent<any> ) {
if( pageEvent.model instanceof ANodeReflection ){
const newModel = this._nodeDeclarationMappingCache.get( pageEvent.model );
assert( newModel );
this._addRestore( () => newModel.children, v => newModel.children = v );
newModel.children = [
...( newModel.children ?? [] ),
...( pageEvent.model.module.children?.filter( c => !c.kindOf( ReflectionKind.SomeModule ) ) ?? [] ),
];
pageEvent.model = newModel;
} else if(
( pageEvent.model instanceof ProjectReflection && pageEvent.url === 'index.html' ) ||
( pageEvent.model instanceof DeclarationReflection && pageEvent.model.kindOf( ReflectionKind.Module ) )
){
const modulePage = this._modulesPages.find( p => p.module === pageEvent.model );
if( modulePage instanceof PageReflection ){
pageEvent.model.sources = [
...( pageEvent.model.sources ?? [] ),
reflectionSourceUtils.createSourceReference( this, modulePage.sourceFilePath ),
];
pageEvent.model.readme = [
...( pageEvent.model.readme?.concat( [ { kind: 'text', text: '\n\n---\n\n' } ] ) ?? [] ),
...( modulePage.comment?.summary ?
[
{ kind: 'text', text: `\n\n<!-- Page ${modulePage.namedPath} -->\n\n` } as CommentDisplayPart,
...modulePage.comment.summary,
] :
[] ),
];
}
}
}
/**
* Event callback executed on every page on {@link PageEvent.END}.
* It undoes changes made by {@link _onRendererBeginPage}.
*
* @param _pageEvent - The page event to alter.
*/
private _onRendererEndPage( _pageEvent: PageEvent<any> ){
assert( this._renderPageRestore );
this._renderPageRestore();
}
/**
* Event callback executed once on {@link RendererEvent.END}.
* Copy assets to the output directory.
*
* @param event - The {@link RendererEvent.END} event.
*/
private _onRendererEnd( event: RendererEvent ) {
const dest = join( event.outputDirectory, CSS_FILE_NAME );
const src = this.plugin.resolvePackageFile( 'static/pages.css' );
copyFileSync( src, dest );
}
/**
* Event callback executed once on {@link IndexEvent.PREPARE_INDEX}.
* Adds the plugin's pages to the search index.
*
* @param indexEvent - The original index event.
*/
private _onRendererPrepareIndex( indexEvent: IndexEvent ) {
indexEvent.searchResults = [
...indexEvent.searchResults,
...this._allPages.map( r => {
const searchResPage = new DeclarationReflection( getFullPageName( r ), PagesPluginReflectionKind.PAGE as any, undefined );
searchResPage.cssClasses = 'pages-entry pages-entry-page';
searchResPage.url = r.url;
return searchResPage;
} ),
];
}
}