KnodesCommunity/typedoc-plugins

View on GitHub
packages/pluginutils/src/text-replacers/markdown/markdown-replacer.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import assert from 'assert';

import { escapeRegExp, isNil, isNumber, uniq } from 'lodash';
import { JSX, MarkdownEvent } from 'typedoc';

import { SourceMapContainer } from './source-map-container';
import { ABasePlugin, IPluginComponent, PluginAccessor, getPlugin } from '../../base-plugin';
import { CurrentPageMemo } from '../../current-page-memo';
import { PluginLogger } from '../../plugin-logger';
import { miscUtils, reflectionSourceUtils } from '../../utils';
import { jsxToString } from '../../utils/text';
import { Tag } from '../types';

const spitArgs = ( ...args: Parameters<Parameters<typeof String.prototype.replace>[1]> ) => {
    const indexIdx = args.findIndex( isNumber );
    assert( indexIdx > 0 );
    return {
        fullMatch: args[0] as string,
        captures: args.slice( 1, indexIdx ) as Array<string | null>,
        index: args[indexIdx] as number,
        source: args[indexIdx + 1] as string,
    };
};
const mergeFlags = ( ...flags: string[] ) => uniq( flags.join( '' ).split( '' ) ).join( '' );
const buildMarkdownRegExp = ( tagName: string, paramsRegExp: RegExp | null ) => paramsRegExp ?
    new RegExp( `${escapeRegExp( tagName )}(?:\\s+${paramsRegExp.source})?`, mergeFlags( paramsRegExp.flags, 'g' ) ) :
    new RegExp( `${escapeRegExp( tagName )}`, 'g' );

export class MarkdownReplacer implements IPluginComponent {
    private static readonly _mapContainers = new WeakMap<MarkdownEvent, SourceMapContainer>();

    public readonly plugin: ABasePlugin;
    private readonly _logger: PluginLogger;
    private readonly _currentPageMemo: CurrentPageMemo;

    /**
     * Get the list of source map containers for the given event.
     *
     * @param event - The event to get source maps for.
     * @returns the source map list.
     */
    private static _getEventMapContainer( event: MarkdownEvent ): SourceMapContainer {
        const container = this._mapContainers.get( event ) ?? new SourceMapContainer();
        MarkdownReplacer._mapContainers.set( event, container );
        return container;
    }

    public constructor( pluginAccessor: PluginAccessor ){
        this.plugin = getPlugin( pluginAccessor );
        this._logger = this.plugin.logger.makeChildLogger( 'MarkdownReplacer' );
        this._currentPageMemo = CurrentPageMemo.for( this );
    }

    /**
     * Register an inline tag (eg. `{@tag ....}`) to replace in markdown with optional params regex and execute a callback to replace it.
     *
     * @param tagName - The name of the tag to match.
     * @param paramsRegExp - An optional regex to capture params.
     * @param callback - The callback to execute to replace the match.
     * @param options - Extra options.
     */
    public registerMarkdownTag( tagName: Tag, paramsRegExp: RegExp | null, callback: MarkdownReplacer.ReplaceCallback, options: MarkdownReplacer.IRegisterOptions = {} ){
        const mdRegexBase = buildMarkdownRegExp( tagName, paramsRegExp );
        const tagRegex = new RegExp( `\\{${mdRegexBase.source}\\s*?\\}`, mdRegexBase.flags );
        this._currentPageMemo.initialize();
        const { excludedMatches, priority } = {
            excludedMatches: [],
            priority: 100,
            ...options,
        };
        this.plugin.application.renderer.on(
            MarkdownEvent.PARSE,
            this._processMarkdown.bind(
                this,
                tagRegex,
                ( { fullMatch, captures, event }, sourceHint ) => {
                    const newFullMatch = fullMatch.slice( 2 ).slice( 0, -1 );
                    return callback( { fullMatch: newFullMatch, captures, event }, sourceHint );
                },
                tagName,
                excludedMatches ),
            undefined,
            priority );
    }


    /**
     * Match every strings for {@link regex} & replace them with the return value of the {@link callback}. This method mutates the {@link event}.
     *
     * @param regex - The regex to match.
     * @param callback - The callback to execute with fullMatch, captures, & a source hint.
     * @param label - The replacer name.
     * @param excludeMatches - A list of matches to skip.
     * @param event - The event to modify.
     */
    private _processMarkdown(
        regex: RegExp,
        callback: MarkdownReplacer.ReplaceCallback,
        label: string,
        excludeMatches: string[] | undefined,
        event: MarkdownEvent,
    ) {
        const originalText = event.parsedText;
        const mapLayer = MarkdownReplacer._getEventMapContainer( event ).addLayer( label, originalText );
        const sourceFile = this._currentPageMemo.hasCurrent ? reflectionSourceUtils.getReflectionSourceFileName( this._currentPageMemo.currentReflection ) : undefined;
        event.parsedText = originalText.replace(
            regex,
            ( ...args ) => {
                const { captures, fullMatch, index } = spitArgs( ...args );
                if( excludeMatches?.includes( fullMatch ) ){
                    return fullMatch;
                }
                const getSourceHint = mapLayer.sourceHint.bind( mapLayer, sourceFile, index );
                const replacement = miscUtils.catchWrap(
                    () => jsxToString( callback( { fullMatch, captures, event }, getSourceHint ) ),
                    err => `In ${getSourceHint()}: ${err.message}` );
                if( isNil( replacement ) ){
                    return fullMatch;
                }
                mapLayer.addEdition( index, fullMatch, replacement );
                return replacement;
            } );
    }
}
export namespace MarkdownReplacer {
    export type SourceHint = () => string;
    export interface Match {
        fullMatch: string;
        captures: Array<string | null>;
        event: MarkdownEvent;
    }
    export type ReplaceCallback = ( match: Match, sourceHint: SourceHint ) => string | JSX.Element | undefined;

    export interface IRegisterOptions {
        excludedMatches?: string[];
        priority?: number;
    }
}