diff --git a/src/commands/build/features/custom-resources/copy-docs-viewer-pdf-icon-asset.spec.ts b/src/commands/build/features/custom-resources/copy-docs-viewer-pdf-icon-asset.spec.ts new file mode 100644 index 000000000..5a4bf63aa --- /dev/null +++ b/src/commands/build/features/custom-resources/copy-docs-viewer-pdf-icon-asset.spec.ts @@ -0,0 +1,141 @@ +import type {Run} from '~/commands/build'; + +import {join} from 'node:path'; +import {describe, expect, it, vi} from 'vitest'; + +import {copyDocsViewerPdfIconAsset} from './copy-docs-viewer-pdf-icon-asset'; + +const maxAssetSize = 10_000; + +function makeRun(options: { + docsViewer?: unknown; + exists?: boolean; + fileSize?: number; + copyRejected?: boolean; +}): Run { + const {docsViewer, exists = false, fileSize = 100, copyRejected = false} = options; + + const copy = vi.fn().mockImplementation(() => { + if (copyRejected) { + return Promise.reject(new Error('copy failed')); + } + + return Promise.resolve(); + }); + + const existsFn = vi.fn().mockReturnValue(exists); + const statSync = vi.fn().mockReturnValue({size: fileSize}); + const logger = { + copy: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + return { + input: '/project/input', + output: '/project/output', + config: { + content: {maxAssetSize}, + ...(docsViewer === undefined ? {} : {'docs-viewer': docsViewer}), + } as Run['config'], + exists: existsFn, + copy, + fs: {statSync}, + logger, + } as unknown as Run; +} + +describe('copyDocsViewerPdfIconAsset', () => { + it('does nothing when docs-viewer pdf icon is absent', async () => { + const run = makeRun({docsViewer: {pdf: true}}); + + await copyDocsViewerPdfIconAsset(run); + + expect(run.copy).not.toHaveBeenCalled(); + expect(run.logger.copy).not.toHaveBeenCalled(); + }); + + it('does nothing when icon is not under _assets', async () => { + const run = makeRun({ + docsViewer: {pdf: {icon: ''}}, + exists: true, + }); + + await copyDocsViewerPdfIconAsset(run); + + expect(run.copy).not.toHaveBeenCalled(); + }); + + it('does nothing when path is not a media link extension', async () => { + const run = makeRun({ + docsViewer: {pdf: {icon: '_assets/icon.woff2'}}, + exists: true, + }); + + await copyDocsViewerPdfIconAsset(run); + + expect(run.exists).not.toHaveBeenCalled(); + expect(run.copy).not.toHaveBeenCalled(); + }); + + it('does nothing when source file is missing', async () => { + const run = makeRun({ + docsViewer: {pdf: {icon: '_assets/icon.svg'}}, + exists: false, + }); + + await copyDocsViewerPdfIconAsset(run); + + expect(run.exists).toHaveBeenCalledWith(join('/project/input', '_assets/icon.svg')); + expect(run.copy).not.toHaveBeenCalled(); + }); + + it('copies file and logs copy when asset exists and size is within limit', async () => { + const run = makeRun({ + docsViewer: {pdf: {icon: '_assets/icon.svg'}}, + exists: true, + fileSize: 50, + }); + + await copyDocsViewerPdfIconAsset(run); + + const from = join('/project/input', '_assets/icon.svg'); + const to = join('/project/output', '_assets/icon.svg'); + + expect(run.fs.statSync).toHaveBeenCalledWith(from); + expect(run.logger.copy).toHaveBeenCalledWith(from, to); + expect(run.copy).toHaveBeenCalledWith(from, to); + expect(run.logger.error).not.toHaveBeenCalled(); + }); + + it('logs YFM013 when file exceeds max size but still copies', async () => { + const run = makeRun({ + docsViewer: {pdf: {icon: '_assets/icon.png'}}, + exists: true, + fileSize: maxAssetSize + 1, + }); + + await copyDocsViewerPdfIconAsset(run); + + expect(run.logger.error).toHaveBeenCalledWith( + 'YFM013', + expect.stringContaining('YFM013 / File asset limit exceeded'), + ); + expect(run.copy).toHaveBeenCalled(); + }); + + it('logs warn when copy throws', async () => { + const run = makeRun({ + docsViewer: {pdf: {icon: '_assets/icon.pdf'}}, + exists: true, + copyRejected: true, + }); + + await copyDocsViewerPdfIconAsset(run); + + expect(run.logger.warn).toHaveBeenCalledWith( + 'Unable to copy docs-viewer pdf icon _assets/icon.pdf.', + expect.any(Error), + ); + }); +}); diff --git a/src/commands/build/features/custom-resources/copy-docs-viewer-pdf-icon-asset.ts b/src/commands/build/features/custom-resources/copy-docs-viewer-pdf-icon-asset.ts new file mode 100644 index 000000000..426a5a884 --- /dev/null +++ b/src/commands/build/features/custom-resources/copy-docs-viewer-pdf-icon-asset.ts @@ -0,0 +1,40 @@ +import type {Run} from '~/commands/build'; + +import {join} from 'node:path'; + +import {isMediaLink} from '~/core/utils'; + +import {getDocsViewerPdfIconAssetPath} from './docs-viewer-pdf-icon-path'; + +export async function copyDocsViewerPdfIconAsset(run: Run) { + const pdfIconPath = getDocsViewerPdfIconAssetPath(run.config as Hash); + if (!pdfIconPath) { + return; + } + + if (!isMediaLink(pdfIconPath)) { + return; + } + + const from = join(run.input, pdfIconPath); + const to = join(run.output, pdfIconPath); + + if (!run.exists(from)) { + return; + } + + try { + const size = run.fs.statSync(from).size; + if (typeof size === 'number' && size > run.config.content.maxAssetSize) { + run.logger.error( + 'YFM013', + `${pdfIconPath}: YFM013 / File asset limit exceeded: ${size} (limit is ${run.config.content.maxAssetSize})`, + ); + } + + run.logger.copy(from, to); + await run.copy(from, to); + } catch (error) { + run.logger.warn(`Unable to copy docs-viewer pdf icon ${pdfIconPath}.`, error); + } +} diff --git a/src/commands/build/features/custom-resources/docs-viewer-pdf-icon-path.spec.ts b/src/commands/build/features/custom-resources/docs-viewer-pdf-icon-path.spec.ts new file mode 100644 index 000000000..5999b25ff --- /dev/null +++ b/src/commands/build/features/custom-resources/docs-viewer-pdf-icon-path.spec.ts @@ -0,0 +1,53 @@ +import {describe, expect, it} from 'vitest'; + +import {getDocsViewerPdfIconAssetPath} from './docs-viewer-pdf-icon-path'; + +describe('getDocsViewerPdfIconAssetPath', () => { + it('returns undefined when pdf is boolean', () => { + expect( + getDocsViewerPdfIconAssetPath({ + 'docs-viewer': {pdf: true}, + }), + ).toBeUndefined(); + }); + + it('returns undefined for inline SVG string', () => { + expect( + getDocsViewerPdfIconAssetPath({ + 'docs-viewer': {pdf: {icon: ''}}, + }), + ).toBeUndefined(); + }); + + it('returns undefined when icon does not start with _assets', () => { + expect( + getDocsViewerPdfIconAssetPath({ + 'docs-viewer': {pdf: {icon: '/static/icon.svg'}}, + }), + ).toBeUndefined(); + }); + + it('returns normalized path for _assets file', () => { + expect( + getDocsViewerPdfIconAssetPath({ + 'docs-viewer': {pdf: {icon: '_assets/icons/pdf.svg'}}, + }), + ).toBe('_assets/icons/pdf.svg'); + }); + + it('strips query and hash', () => { + expect( + getDocsViewerPdfIconAssetPath({ + 'docs-viewer': {pdf: {icon: '_assets/icons/pdf.svg?v=1#frag'}}, + }), + ).toBe('_assets/icons/pdf.svg'); + }); + + it('returns undefined when path escapes with ..', () => { + expect( + getDocsViewerPdfIconAssetPath({ + 'docs-viewer': {pdf: {icon: '_assets/../secret.svg'}}, + }), + ).toBeUndefined(); + }); +}); diff --git a/src/commands/build/features/custom-resources/docs-viewer-pdf-icon-path.ts b/src/commands/build/features/custom-resources/docs-viewer-pdf-icon-path.ts new file mode 100644 index 000000000..7bdd0f4c0 --- /dev/null +++ b/src/commands/build/features/custom-resources/docs-viewer-pdf-icon-path.ts @@ -0,0 +1,48 @@ +import {get} from 'lodash'; + +import {normalizePath} from '~/core/utils'; + +/** + * Returns a project-relative path for `docs-viewer.pdf.icon` when it references + * a file under `_assets/`. Inline SVG, remote URLs, and other strings are ignored. + */ +export function getDocsViewerPdfIconAssetPath(config: Hash): NormalizedPath | undefined { + const docsViewer = get(config, 'docs-viewer'); + if (!docsViewer || typeof docsViewer !== 'object') { + return; + } + + const pdf = (docsViewer as {pdf?: unknown}).pdf; + if (pdf === null || typeof pdf !== 'object') { + return; + } + + const icon = (pdf as {icon?: unknown}).icon; + if (typeof icon !== 'string') { + return; + } + + const trimmed = icon.trim(); + if (!trimmed.startsWith('_assets')) { + return; + } + + const pathOnly = (trimmed.split('?')[0] ?? '').split('#')[0]?.trim() ?? ''; + if (!pathOnly.startsWith('_assets')) { + return; + } + + try { + const normalized = normalizePath(decodeURIComponent(pathOnly)) as NormalizedPath; + if (!normalized.startsWith('_assets')) { + return; + } + if (normalized.includes('..')) { + return; + } + + return normalized; + } catch { + return; + } +} diff --git a/src/commands/build/features/custom-resources/index.spec.ts b/src/commands/build/features/custom-resources/index.spec.ts new file mode 100644 index 000000000..4ae87d97a --- /dev/null +++ b/src/commands/build/features/custom-resources/index.spec.ts @@ -0,0 +1,104 @@ +import type {BuildConfig} from '../..'; + +import {join} from 'node:path'; +import {describe, expect, it, vi} from 'vitest'; +import {when} from 'vitest-when'; + +import {getHooks as getLeadingHooks} from '~/core/leading'; +import {getHooks as getMarkdownHooks} from '~/core/markdown'; +import {getHooks as getBaseHooks} from '~/core/program'; + +import {setupRun} from '../../__tests__'; +import {getHooks} from '../../hooks'; +import {Build} from '../..'; + +import {CustomResources} from './index'; + +describe('CustomResources feature', () => { + it('registers AfterRun md: copies docs-viewer icon then custom resources when flag is on', async () => { + const build = new Build(); + const feature = new CustomResources(); + feature.apply(build); + + const run = setupRun({ + allowCustomResources: true, + resources: { + style: ['_assets/style/a.css'], + script: [], + }, + content: {maxAssetSize: 1_000_000}, + 'docs-viewer': {pdf: {icon: '_assets/icons/pdf.svg'}}, + } as unknown as BuildConfig); + + const assetPath = join(run.input, '_assets/icons/pdf.svg'); + const stylePath = join(run.input, '_assets/style/a.css'); + + when(run.exists).calledWith(assetPath).thenReturn(true); + when(run.exists).calledWith(stylePath).thenReturn(true); + when(run.copy).calledWith(expect.anything(), expect.anything()).thenResolve(); + vi.spyOn(run.fs, 'statSync').mockReturnValue({size: 10} as ReturnType< + typeof run.fs.statSync + >); + + await getHooks(build).AfterRun.for('md').promise(run); + + expect(run.copy).toHaveBeenCalledWith(assetPath, join(run.output, '_assets/icons/pdf.svg')); + expect(run.copy).toHaveBeenCalledWith(stylePath, join(run.output, '_assets/style/a.css')); + }); + + it('AfterRun md: copies docs-viewer icon even when allowCustomResources is off', async () => { + const build = new Build(); + const feature = new CustomResources(); + feature.apply(build); + + const run = setupRun({ + allowCustomResources: false, + resources: {style: ['_assets/style/a.css'], script: []}, + content: {maxAssetSize: 1_000_000}, + 'docs-viewer': {pdf: {icon: '_assets/icons/pdf.svg'}}, + } as unknown as BuildConfig); + + const assetPath = join(run.input, '_assets/icons/pdf.svg'); + + when(run.exists).calledWith(assetPath).thenReturn(true); + when(run.copy).calledWith(expect.anything(), expect.anything()).thenResolve(); + vi.spyOn(run.fs, 'statSync').mockReturnValue({size: 10} as ReturnType< + typeof run.fs.statSync + >); + + await getHooks(build).AfterRun.for('md').promise(run); + + expect(run.copy).toHaveBeenCalledTimes(1); + expect(run.copy).toHaveBeenCalledWith(assetPath, join(run.output, '_assets/icons/pdf.svg')); + }); + + it('BeforeAnyRun: registers Loaded taps when allowCustomResources is true', async () => { + const build = new Build(); + const feature = new CustomResources(); + feature.apply(build); + + const runOff = setupRun({allowCustomResources: false} as unknown as BuildConfig); + const leadingTapsOffBefore = getLeadingHooks(runOff.leading).Loaded.taps.length; + + await getBaseHooks(build).BeforeAnyRun.promise(runOff); + + expect(getLeadingHooks(runOff.leading).Loaded.taps.length).toBe(leadingTapsOffBefore); + + const runOn = setupRun({ + allowCustomResources: true, + resources: {style: [], script: []}, + } as unknown as BuildConfig); + + await getBaseHooks(build).BeforeAnyRun.promise(runOn); + + const leadingCustom = getLeadingHooks(runOn.leading).Loaded.taps.filter( + (t) => t.name === 'CustomResources', + ); + const markdownCustom = getMarkdownHooks(runOn.markdown).Loaded.taps.filter( + (t) => t.name === 'CustomResources', + ); + + expect(leadingCustom.length).toBeGreaterThanOrEqual(1); + expect(markdownCustom.length).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/commands/build/features/custom-resources/index.ts b/src/commands/build/features/custom-resources/index.ts index 5bd28fa0c..1c22b0a01 100644 --- a/src/commands/build/features/custom-resources/index.ts +++ b/src/commands/build/features/custom-resources/index.ts @@ -10,6 +10,7 @@ import {getHooks as getBuildHooks} from '~/commands/build'; import {getHooks as getLeadingHooks} from '~/core/leading'; import {getHooks as getMarkdownHooks} from '~/core/markdown'; +import {copyDocsViewerPdfIconAsset} from './copy-docs-viewer-pdf-icon-asset'; import {options} from './config'; const name = 'CustomResources'; @@ -52,6 +53,8 @@ export class CustomResources { getBuildHooks(program) .AfterRun.for('md') .tapPromise(name, async (run) => { + await copyDocsViewerPdfIconAsset(run); + const {allowCustomResources, resources} = run.config; if (!allowCustomResources) { diff --git a/tests/e2e/__snapshots__/load-custom-resources.spec.ts.snap b/tests/e2e/__snapshots__/load-custom-resources.spec.ts.snap index 430403245..6d856bee6 100644 --- a/tests/e2e/__snapshots__/load-custom-resources.spec.ts.snap +++ b/tests/e2e/__snapshots__/load-custom-resources.spec.ts.snap @@ -271,6 +271,69 @@ exports[`Allow load custom resources > md2html with custom resources 4`] = ` exports[`Allow load custom resources > md2html with custom resources 5`] = `"window.__DATA__.data.toc = {"title":"Documentation","href":"index.html","items":[{"name":"Documentation","href":"page.html","id":"UUID"},{"name":"Config","href":"project/config.html","id":"UUID"}],"path":"toc.yaml","id":"UUID"};"`; +exports[`Allow load custom resources > md2md copies docs-viewer pdf icon from _assets without allow-custom-resources > filelist 1`] = ` +"[ + ".yfm", + "_assets/icons/pdf.svg", + "index.yaml", + "page.md", + "toc.yaml" +]" +`; + +exports[`Allow load custom resources > md2md copies docs-viewer pdf icon from _assets without allow-custom-resources 1`] = ` +"docs-viewer: + pdf: + icon: _assets/icons/pdf.svg +" +`; + +exports[`Allow load custom resources > md2md copies docs-viewer pdf icon from _assets without allow-custom-resources 2`] = ` +"title: Documentation +description: '' +meta: + metadata: + - name: generator + content: Diplodoc Platform vDIPLODOC-VERSION + title: Documentation + noIndex: true + vcsPath: index.yaml +links: + - title: Getting started with Documentation + description: This guide will show you the basics of working with Documentation + href: page.md +" +`; + +exports[`Allow load custom resources > md2md copies docs-viewer pdf icon from _assets without allow-custom-resources 3`] = ` +"--- +metadata: + - name: generator + content: Diplodoc Platform vDIPLODOC-VERSION + - name: yfm + content: builder in page +title: Page Title +description: Some test description +interface: + toc: false + favicon-src: /favicon.ico +vcsPath: page.md +--- + +Lorem +" +`; + +exports[`Allow load custom resources > md2md copies docs-viewer pdf icon from _assets without allow-custom-resources 4`] = ` +"title: Documentation +href: index.yaml +items: + - name: Documentation + href: page.md +path: toc.yaml +" +`; + exports[`Allow load custom resources > md2md with custom resources > filelist 1`] = ` "[ ".yfm", diff --git a/tests/e2e/load-custom-resources.spec.ts b/tests/e2e/load-custom-resources.spec.ts index 2c42014d7..10b883377 100644 --- a/tests/e2e/load-custom-resources.spec.ts +++ b/tests/e2e/load-custom-resources.spec.ts @@ -9,6 +9,12 @@ describe('Allow load custom resources', () => { {md2html: false, args: '--allow-custom-resources'}, ); + generateMapTestTemplate( + 'md2md copies docs-viewer pdf icon from _assets without allow-custom-resources', + 'mocks/load-custom-resources/md2md-docs-viewer-pdf-icon', + {md2html: false}, + ); + generateMapTestTemplate( 'md2html with custom resources', 'mocks/load-custom-resources/md2html-with-resources', diff --git a/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/.yfm b/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/.yfm new file mode 100644 index 000000000..25c4401a9 --- /dev/null +++ b/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/.yfm @@ -0,0 +1,3 @@ +docs-viewer: + pdf: + icon: _assets/icons/pdf.svg diff --git a/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/_assets/icons/pdf.svg b/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/_assets/icons/pdf.svg new file mode 100644 index 000000000..a3c15337f --- /dev/null +++ b/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/_assets/icons/pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/index.yaml b/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/index.yaml new file mode 100644 index 000000000..60f350c8d --- /dev/null +++ b/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/index.yaml @@ -0,0 +1,9 @@ +title: Documentation +description: "" +meta: + title: Documentation + noIndex: true +links: + - title: Getting started with Documentation + description: This guide will show you the basics of working with Documentation + href: page.md diff --git a/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/page.md b/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/page.md new file mode 100644 index 000000000..620d42169 --- /dev/null +++ b/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/page.md @@ -0,0 +1,14 @@ +--- +title: Page Title +description: Some test description + +interface: + toc: false + favicon-src: /favicon.ico + +metadata: + - name: yfm + content: builder in page +--- + +Lorem diff --git a/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/toc.yaml b/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/toc.yaml new file mode 100644 index 000000000..955c5fb54 --- /dev/null +++ b/tests/mocks/load-custom-resources/md2md-docs-viewer-pdf-icon/input/toc.yaml @@ -0,0 +1,5 @@ +title: Documentation +href: index.yaml +items: + - name: Documentation + href: page.md