From 33b17004f75a06495931405b08e093768bf3f50c Mon Sep 17 00:00:00 2001 From: GerkinDev Date: Fri, 25 Feb 2022 21:12:35 +0100 Subject: [PATCH] feat(plugin-code-blocks): use pluginutils ABasePlugin, use new options format --- .../typedoc-plugin-code-blocks/package.json | 4 + .../typedoc-plugin-code-blocks/src/index.ts | 9 +- .../typedoc-plugin-code-blocks/src/load.ts | 5 + .../typedoc-plugin-code-blocks/src/options.ts | 1 - ...de-block-plugin.spec.ts => plugin.spec.ts} | 91 +++++------ .../src/{code-block-plugin.ts => plugin.ts} | 152 ++++++++++++------ 6 files changed, 157 insertions(+), 105 deletions(-) create mode 100644 packages/typedoc-plugin-code-blocks/src/load.ts delete mode 100644 packages/typedoc-plugin-code-blocks/src/options.ts rename packages/typedoc-plugin-code-blocks/src/{code-block-plugin.spec.ts => plugin.spec.ts} (63%) rename packages/typedoc-plugin-code-blocks/src/{code-block-plugin.ts => plugin.ts} (54%) diff --git a/packages/typedoc-plugin-code-blocks/package.json b/packages/typedoc-plugin-code-blocks/package.json index 582041d8..3d56d18f 100644 --- a/packages/typedoc-plugin-code-blocks/package.json +++ b/packages/typedoc-plugin-code-blocks/package.json @@ -32,7 +32,11 @@ "prepublish": "npm run build:clean && npm run build", "test": "jest --config jest.config.js" }, + "dependencies": { + "@knodes/typedoc-pluginutils": "*" + }, "peerDependencies": { + "lodash": "^4.17.0", "typedoc": "^0.22.12" }, "devDependencies": { diff --git a/packages/typedoc-plugin-code-blocks/src/index.ts b/packages/typedoc-plugin-code-blocks/src/index.ts index a293fca4..5b0acf93 100644 --- a/packages/typedoc-plugin-code-blocks/src/index.ts +++ b/packages/typedoc-plugin-code-blocks/src/index.ts @@ -1,8 +1 @@ -import { Application, MarkdownEvent } from 'typedoc'; - -import { CodeBlockPlugin } from './code-block-plugin'; - -export const load = ( app: Application ) => { - const plugin = new CodeBlockPlugin( app ); - app.renderer.on( MarkdownEvent.PARSE, plugin.processMarkdown.bind( plugin ) ); -}; +export * from './load'; diff --git a/packages/typedoc-plugin-code-blocks/src/load.ts b/packages/typedoc-plugin-code-blocks/src/load.ts new file mode 100644 index 00000000..e9b5f0ed --- /dev/null +++ b/packages/typedoc-plugin-code-blocks/src/load.ts @@ -0,0 +1,5 @@ +import { autoload } from '@knodes/typedoc-pluginutils'; + +import { CodeBlockPlugin } from './plugin'; + +export const load = autoload( CodeBlockPlugin ); diff --git a/packages/typedoc-plugin-code-blocks/src/options.ts b/packages/typedoc-plugin-code-blocks/src/options.ts deleted file mode 100644 index c2370da4..00000000 --- a/packages/typedoc-plugin-code-blocks/src/options.ts +++ /dev/null @@ -1 +0,0 @@ -export const DIRECTORY = 'code-blocks-directories'; diff --git a/packages/typedoc-plugin-code-blocks/src/code-block-plugin.spec.ts b/packages/typedoc-plugin-code-blocks/src/plugin.spec.ts similarity index 63% rename from packages/typedoc-plugin-code-blocks/src/code-block-plugin.spec.ts rename to packages/typedoc-plugin-code-blocks/src/plugin.spec.ts index 7700b350..c72dd1ac 100644 --- a/packages/typedoc-plugin-code-blocks/src/code-block-plugin.spec.ts +++ b/packages/typedoc-plugin-code-blocks/src/plugin.spec.ts @@ -1,7 +1,7 @@ import { resolve } from 'path'; import mockFs from 'mock-fs'; -import { Application, MarkdownEvent } from 'typedoc'; +import { Application } from 'typedoc'; /* eslint-disable @typescript-eslint/no-var-requires */ jest.mock( 'marked' ); @@ -10,45 +10,54 @@ jest.mock( './code-sample-file' ); const { readCodeSample } = require( './code-sample-file' ) as jest.Mocked; /* eslint-enable @typescript-eslint/no-var-requires */ -import { CodeBlockPlugin } from './code-block-plugin'; import { DEFAULT_BLOCK_NAME } from './code-sample-file'; -import { DIRECTORY } from './options'; +import { CodeBlockPlugin } from './plugin'; let application: Application; let plugin: CodeBlockPlugin; const rootDir = resolve( __dirname, '..' ); +const DIRECTORIES = 'pluginCodeBlocks:directories'; beforeEach( () => { process.chdir( rootDir ); jest.clearAllMocks(); application = new Application(); plugin = new CodeBlockPlugin( application ); + plugin.initialize(); } ); afterEach( mockFs.restore ); describe( 'Options', () => { - describe( DIRECTORY, () => { + const tryOption = ( value: any ) => () => { + application.options.setValue( DIRECTORIES, value ); + plugin.directoriesOption.getValue(); + }; + describe( DIRECTORIES, () => { it( 'should throw an error if value is not an object', () => { - expect( () => application.options.setValue( DIRECTORY, null ) ).toThrow(); - expect( () => application.options.setValue( DIRECTORY, undefined ) ).toThrow(); - expect( () => application.options.setValue( DIRECTORY, [] ) ).toThrow(); - expect( () => application.options.setValue( DIRECTORY, 42 ) ).toThrow(); - expect( () => application.options.setValue( DIRECTORY, 'foo' ) ).toThrow(); + expect( tryOption( [] ) ).toThrow(); + expect( tryOption( 42 ) ).toThrow(); + expect( tryOption( 'foo' ) ).toThrow(); } ); it.each( [ '/', ' ' ] )( 'should throw an error if it contains invalid key', k => { - expect( () => application.options.setValue( DIRECTORY, { [k]: './test' } ) ).toThrow(); + expect( tryOption( { [k]: './test' } ) ).toThrow(); } ); it.each( [ '/test', 'hello' ] )( 'should throw an error if a value is not a relative path', path => { - expect( () => application.options.setValue( DIRECTORY, { foo: path } ) ).toThrow(); + expect( tryOption( { foo: path } ) ).toThrow(); } ); it.each( [ '/foo', '/bar' ] )( 'should throw an error if the path does not exist', path => { // existsSpy.mockReturnValue( false ); - expect( () => application.options.setValue( DIRECTORY, { foo: path } ) ).toThrow(); + expect( tryOption( { foo: path } ) ).toThrow(); + } ); + it( 'should throw if trying to set a forbidden value ~~', () => { + mockFs( { + hello: {}, + } ); + expect( tryOption( { '~~': './hello' } ) ).toThrow(); } ); it( 'should pass with valid options', () => { mockFs( { hello: {}, world: {}, } ); - application.options.setValue( DIRECTORY, { hello: './hello', world: './world' } ); + expect( tryOption( { hello: './hello', world: './world' } ) ).not.toThrow(); } ); } ); } ); @@ -65,20 +74,16 @@ describe( 'Behavior', () => { ...CSS_FS, foo: {}, } ); - application.options.setValue( DIRECTORY, { foo: './foo' } ); + application.options.setValue( DIRECTORIES, { foo: './foo' } ); } ); it( 'should not affect text if no code block', () => { - const event = new MarkdownEvent( MarkdownEvent.PARSE, '', 'Hello world' ); - const eventBck = { ...event }; - plugin.processMarkdown( event ); - expect( event ).toEqual( eventBck ); + const text = 'Hello world' ; + expect( plugin.replaceCodeBlocks( text ) ).toEqual( text ); } ); describe( 'Code block wrapper generation', ()=> { it( 'should output a code block', () => { readCodeSample.mockReturnValue( new Map( [[ DEFAULT_BLOCK_NAME, { code: 'Content of foo/qux.txt', startLine: 1, endLine: 1 } ]] ) ); - const event = new MarkdownEvent( MarkdownEvent.PARSE, '', '*Hello world !*\n\n{@codeblock foo/qux.txt}' ); - plugin.processMarkdown( event ); - expect( event.parsedText ).toMatchInlineSnapshot( ` + expect( plugin.replaceCodeBlocks( '*Hello world !*\n\n{@codeblock foo/qux.txt}' ) ).toMatchInlineSnapshot( ` " *Hello world !* @@ -88,9 +93,7 @@ describe( 'Behavior', () => { } ); it( 'should output a folded code block', () => { readCodeSample.mockReturnValue( new Map( [[ DEFAULT_BLOCK_NAME, { code: 'Content of foo/qux.txt', startLine: 1, endLine: 1 } ]] ) ); - const event = new MarkdownEvent( MarkdownEvent.PARSE, '', '*Hello world !*\n\n{@codeblock folded foo/qux.txt}' ); - plugin.processMarkdown( event ); - expect( event.parsedText ).toMatchInlineSnapshot( ` + expect( plugin.replaceCodeBlocks( '*Hello world !*\n\n{@codeblock folded foo/qux.txt}' ) ).toMatchInlineSnapshot( ` " *Hello world !* @@ -100,9 +103,7 @@ describe( 'Behavior', () => { } ); it( 'should output a foldable code block', () => { readCodeSample.mockReturnValue( new Map( [[ DEFAULT_BLOCK_NAME, { code: 'Content of foo/qux.txt', startLine: 1, endLine: 1 } ]] ) ); - const event = new MarkdownEvent( MarkdownEvent.PARSE, '', '*Hello world !*\n\n{@codeblock foldable foo/qux.txt}' ); - plugin.processMarkdown( event ); - expect( event.parsedText ).toMatchInlineSnapshot( ` + expect( plugin.replaceCodeBlocks( '*Hello world !*\n\n{@codeblock foldable foo/qux.txt}' ) ).toMatchInlineSnapshot( ` " *Hello world !* @@ -112,15 +113,15 @@ describe( 'Behavior', () => { } ); } ); describe( 'Header generation', () => { - const extractHeader = ( event: MarkdownEvent ) => { - const matchComplex = event.parsedText.match( /##\(From \[(.+?)\]\((.+?)\)\)##/ ); + const extractHeader = ( text: string ) => { + const matchComplex = text.match( /##\(From \[(.+?)\]\((.+?)\)\)##/ ); if( matchComplex ){ return { file: matchComplex[1], url: matchComplex[2], }; } - const matchSimple = event.parsedText.match( /##\(From (.+?)\)##/ ); + const matchSimple = text.match( /##\(From (.+?)\)##/ ); if( matchSimple ){ return { file: matchSimple[1], @@ -139,36 +140,32 @@ describe( 'Behavior', () => { describe( 'Filename', () => { it( 'should generate the correct default header', () => { readCodeSample.mockReturnValue( new Map( [[ DEFAULT_BLOCK_NAME, { code: 'Content of foo/qux.txt', startLine: 1, endLine: 1 } ]] ) ); - const event = new MarkdownEvent( MarkdownEvent.PARSE, '', '*Hello world !*\n\n{@codeblock foo/qux.txt}' ); - plugin.processMarkdown( event ); - expect( extractHeader( event ) ).toEqual( { + const replaced = plugin.replaceCodeBlocks( '*Hello world !*\n\n{@codeblock foo/qux.txt}' ); + expect( extractHeader( replaced ) ).toEqual( { file: './foo/qux.txt', url: undefined, } ); } ); it( 'should generate the correct header with explicit name', () => { readCodeSample.mockReturnValue( new Map( [[ DEFAULT_BLOCK_NAME, { code: 'Content of foo/qux.txt', startLine: 1, endLine: 1 } ]] ) ); - const event = new MarkdownEvent( MarkdownEvent.PARSE, '', '*Hello world !*\n\n{@codeblock foo/qux.txt | test.txt}' ); - plugin.processMarkdown( event ); - expect( extractHeader( event ) ).toEqual( { + const replaced = plugin.replaceCodeBlocks( '*Hello world !*\n\n{@codeblock foo/qux.txt | test.txt}' ); + expect( extractHeader( replaced ) ).toEqual( { file: 'test.txt', url: undefined, } ); } ); it( 'should generate the correct header with region', () => { readCodeSample.mockReturnValue( new Map( [[ 'hello', { code: 'Content of foo/qux.txt', startLine: 13, endLine: 24 } ]] ) ); - const event = new MarkdownEvent( MarkdownEvent.PARSE, '', '*Hello world !*\n\n{@codeblock foo/qux.txt#hello}' ); - plugin.processMarkdown( event ); - expect( extractHeader( event ) ).toEqual( { + const replaced = plugin.replaceCodeBlocks( '*Hello world !*\n\n{@codeblock foo/qux.txt#hello}' ); + expect( extractHeader( replaced ) ).toEqual( { file: './foo/qux.txt#13~24', url: undefined, } ); } ); it( 'should generate the correct header with region & explicit name', () => { readCodeSample.mockReturnValue( new Map( [[ 'hello', { code: 'Content of foo/qux.txt', startLine: 13, endLine: 24 } ]] ) ); - const event = new MarkdownEvent( MarkdownEvent.PARSE, '', '*Hello world !*\n\n{@codeblock foo/qux.txt#hello | test.txt}' ); - plugin.processMarkdown( event ); - expect( extractHeader( event ) ).toEqual( { + const replaced = plugin.replaceCodeBlocks( '*Hello world !*\n\n{@codeblock foo/qux.txt#hello | test.txt}' ); + expect( extractHeader( replaced ) ).toEqual( { file: 'test.txt', url: undefined, } ); @@ -181,9 +178,8 @@ describe( 'Behavior', () => { const file = resolve( rootDir, 'foo/qux.txt' ); it( 'should generate the correct default URL', () => { readCodeSample.mockReturnValue( new Map( [[ DEFAULT_BLOCK_NAME, { code: 'Content of foo/qux.txt', startLine: 1, endLine: 1 } ]] ) ); - const event = new MarkdownEvent( MarkdownEvent.PARSE, '', '*Hello world !*\n\n{@codeblock foo/qux.txt | test.txt}' ); - plugin.processMarkdown( event ); - expect( extractHeader( event ).url ).toEqual( `${FakeGitHub.REPO_URL}` ); + const replaced = plugin.replaceCodeBlocks( '*Hello world !*\n\n{@codeblock foo/qux.txt | test.txt}' ); + expect( extractHeader( replaced ).url ).toEqual( `${FakeGitHub.REPO_URL}` ); expect( FakeGitHub.getGitHubURL ).toHaveBeenCalledTimes( 1 ); expect( FakeGitHub.getGitHubURL ).toHaveBeenCalledWith( file ); expect( FakeGitHub.getRepository ).toHaveBeenCalledTimes( 1 ); @@ -191,9 +187,8 @@ describe( 'Behavior', () => { } ); it( 'should generate the correct URL with region', () => { readCodeSample.mockReturnValue( new Map( [[ 'hello', { code: 'Content of foo/qux.txt', startLine: 13, endLine: 24 } ]] ) ); - const event = new MarkdownEvent( MarkdownEvent.PARSE, '', '*Hello world !*\n\n{@codeblock foo/qux.txt#hello | test.txt}' ); - plugin.processMarkdown( event ); - expect( extractHeader( event ).url ).toEqual( `${FakeGitHub.REPO_URL}#L13-L24` ); + const replaced = plugin.replaceCodeBlocks( '*Hello world !*\n\n{@codeblock foo/qux.txt#hello | test.txt}' ); + expect( extractHeader( replaced ).url ).toEqual( `${FakeGitHub.REPO_URL}#L13-L24` ); expect( FakeGitHub.getGitHubURL ).toHaveBeenCalledTimes( 1 ); expect( FakeGitHub.getGitHubURL ).toHaveBeenCalledWith( file ); expect( FakeGitHub.getRepository ).toHaveBeenCalledTimes( 1 ); diff --git a/packages/typedoc-plugin-code-blocks/src/code-block-plugin.ts b/packages/typedoc-plugin-code-blocks/src/plugin.ts similarity index 54% rename from packages/typedoc-plugin-code-blocks/src/code-block-plugin.ts rename to packages/typedoc-plugin-code-blocks/src/plugin.ts index c95c9868..1d5a3900 100644 --- a/packages/typedoc-plugin-code-blocks/src/code-block-plugin.ts +++ b/packages/typedoc-plugin-code-blocks/src/plugin.ts @@ -1,70 +1,108 @@ -import { existsSync, readFileSync } from 'fs'; -import { extname, relative, resolve } from 'path'; +import { existsSync, readFileSync, statSync } from 'fs'; +import { dirname, extname, relative, resolve } from 'path'; +import { isNil, isPlainObject, isString } from 'lodash'; import { Renderer, marked } from 'marked'; -import { Application, MarkdownEvent, ParameterType } from 'typedoc'; +import { Application, Context, Converter, MarkdownEvent, ParameterType, Reflection } from 'typedoc'; + +import { ABasePlugin } from '@knodes/typedoc-pluginutils'; import { DEFAULT_BLOCK_NAME, ICodeSample, readCodeSample } from './code-sample-file'; -import { DIRECTORY } from './options'; const EXTRACT_CODE_BLOCKS_REGEX = /\{@codeblock\s+(?:(foldable|folded)\s+)?(.+?)(?:#(.+?))?(?:\s*\|\s*(.*?))?\}/; export type Foldable = 'foldable' | 'folded' | undefined; -const isPojo = ( v: any ): v is Record => v && typeof v === 'object' && Object.getPrototypeOf( v )?.constructor.name === 'Object'; - +type CodeBlockDirs = {'~~': string} & Record /** * Pages plugin for integrating your own pages into documentation output */ -export class CodeBlockPlugin { +export class CodeBlockPlugin extends ABasePlugin { + public readonly directoriesOption = this.addOption( { + name: 'directories', + help: `A map of base directories where to extract code blocks. Some well-known fields are always set: +* \`~~\` is the directory containing the TypeDoc config. +* In \`project\` \`entryPointStrategy\`, packages by name.`, + type: ParameterType.Mixed, + mapper: ( raw: unknown ) => { + let obj: Partial; + if( isNil( raw ) ){ + obj = {}; + } else if( isPlainObject( raw ) ){ + obj = raw as any; + } else { + throw new TypeError( 'Invalid option value type' ); + } + const pairs = Object.entries( obj ); + for( const [ key, value ] of pairs ){ + if( !key.match( /^\w+$/ ) ){ + throw new Error( 'Should have alphanumeric-only keys' ); + } + if( typeof value !== 'string' ){ + throw new Error( 'Should have only path values' ); + } + const resolved = resolve( value ); + if( !existsSync( resolved ) ){ + throw new Error( `Code block alias "${key}" (resolved to ${resolved}) does not exist.` ); + } + obj[key] = resolved; + } + if( '~~' in obj ){ + throw new Error( 'Can\'t explicitly set `~~` directory' ); + } + obj['~~'] = this._rootDir; + return obj as CodeBlockDirs; + }, + } ); private readonly _fileSamples = new Map>(); - private get _codeBlockDirs(): Record { - const dirs = this._application.options.getValue( DIRECTORY ) as any; - if( !isPojo( dirs ) ){ - throw new Error( `Missing "${DIRECTORY}" option` ); + + private _rootDirCache?: string; + private get _rootDir(): string { + if( !this._rootDirCache ) { + const opts = this.application.options.getValue( 'options' ); + const stat = statSync( opts ); + if( stat.isDirectory() ){ + this._rootDirCache = opts; + } else if( stat.isFile() ){ + this._rootDirCache = dirname( opts ); + } else { + throw new Error(); + } } - return dirs; + return this._rootDirCache; } - private get _optionsDir(){ - return this._application.options.getValue( 'options' ) as string; + private _currentReflection?: Reflection; + + public constructor( application: Application ){ + super( application, __filename ); } - public constructor( private readonly _application: Application ){ - this._application.options.addDeclaration( { - name: DIRECTORY, - help: 'A map of base directories where to extract code blocks.', - type: ParameterType.Mixed, - validate: obj => { - if( !isPojo( obj ) ){ - throw new Error( `Missing "${DIRECTORY}" option` ); - } - const pairs = Object.entries( obj ); - for( const [ key, value ] of pairs ){ - if( !key.match( /^\w+$/ ) ){ - throw new Error( `"${DIRECTORY}" option should have alphanumeric-only keys` ); - } - if( typeof value !== 'string' ){ - throw new Error( `"${DIRECTORY}" option should have only path values` ); - } - const resolved = resolve( value ); - if( !existsSync( resolved ) ){ - throw new Error( `"${DIRECTORY}" code block alias "${key}" (resolved to ${resolved}) does not exist.` ); - } - obj[key] = resolved; + /** + * + */ + public initialize(): void { + this.application.renderer.on( MarkdownEvent.PARSE, this._processMarkdown.bind( this ) ); + this.application.converter.on( Converter.EVENT_RESOLVE, ( _ctx: Context, reflection: Reflection ) => { + this._currentReflection = reflection; + if( reflection.comment ){ + reflection.comment.shortText = this.replaceCodeBlocks( reflection.comment.shortText ); + reflection.comment.text = this.replaceCodeBlocks( reflection.comment.text ); + if( reflection.comment.returns ){ + reflection.comment.returns = this.replaceCodeBlocks( reflection.comment.returns ); } - }, + } } ); } /** - * Transform the parsed text of the given {@link event MarkdownEvent} to replace code blocks. + * Replace code blocks in the given source. * - * @param event - The event to modify. + * @param sourceMd - The markdown text to replace. + * @returns the replaced markdown. */ - public processMarkdown( event: MarkdownEvent ) { - const originalText = event.parsedText; + public replaceCodeBlocks( sourceMd: string ): string { const regex = new RegExp( EXTRACT_CODE_BLOCKS_REGEX.toString().slice( 1, -1 ), 'g' ); - event.parsedText = originalText.replace( + const replaced = sourceMd.replace( regex, fullmatch => { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Re-run the exact same regex. @@ -72,11 +110,29 @@ export class CodeBlockPlugin { if( foldable !== 'foldable' && foldable !== 'folded' && foldable ){ throw new Error( `Invalid foldable state "${foldable}". Expected "foldable" | "folded"` ); } + this.logger.makeChildLogger( this._currentReflection?.sources?.[0].fileName ?? 'UNKNOWN SOURCE' ).verbose( [ + 'Generating', + foldable, + `code block to ${file}`, + block ? `(block "${block}")` : null, + fakedFileName ? `as "${fakedFileName}"` : null, + ].filter( isString ).join( ' ' ) ); return this._generateCodeBlock( file, block, fakedFileName, ( foldable || undefined ) as Foldable ); } ); - if( event.parsedText !== originalText ){ - event.parsedText = `\n\n${event.parsedText}`; + if( replaced !== sourceMd ){ + return `\n\n${replaced}`; } + return sourceMd; + } + + /** + * Transform the parsed text of the given {@link event MarkdownEvent} to replace code blocks. + * + * @param event - The event to modify. + */ + private _processMarkdown( event: MarkdownEvent ) { + const originalText = event.parsedText; + event.parsedText = this.replaceCodeBlocks( originalText ); } /** @@ -87,13 +143,13 @@ export class CodeBlockPlugin { */ private _resolveFile( file: string ){ const [ dir, ...path ] = file.split( '/' ); - const codeBlockDirs = this._codeBlockDirs; + const codeBlockDirs = this.directoriesOption.getValue(); const codeBlockDir = codeBlockDirs[dir]; if( !codeBlockDir ){ throw new Error( `Trying to use code block from named directory ${dir} (targetting file ${file}), but it is not defined.` ); } - const newPath = resolve( this._optionsDir, codeBlockDir, ...path ); + const newPath = resolve( codeBlockDir, ...path ); return newPath; } @@ -105,7 +161,7 @@ export class CodeBlockPlugin { * @returns the URL, or `null`. */ private _resolveCodeSampleUrl( file: string, codeSample: ICodeSample | null ){ - const gitHubComponent = this._application.converter.getComponent( 'git-hub' ); + const gitHubComponent = this.application.converter.getComponent( 'git-hub' ); if( !gitHubComponent ){ return null; } @@ -144,7 +200,7 @@ export class CodeBlockPlugin { throw new Error( `Missing block ${region} in ${resolvedFile}` ); } - const headerFileName = fakedFileName ?? `./${relative( this._optionsDir, resolvedFile )}${useWholeFile ? '' : `#${codeSample.startLine}~${codeSample.endLine}`}`; + const headerFileName = fakedFileName ?? `./${relative( this._rootDir, resolvedFile )}${useWholeFile ? '' : `#${codeSample.startLine}~${codeSample.endLine}`}`; const url = this._resolveCodeSampleUrl( resolvedFile, useWholeFile ? null : codeSample ); const header = marked( `From ${url ? `[${headerFileName}](${url})` : `${headerFileName}`}` );