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