Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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: '<svg></svg>'}},
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),
);
});
});
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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: '<svg xmlns="http://www.w3.org/2000/svg"/>'}},
}),
).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();
});
});
Original file line number Diff line number Diff line change
@@ -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;

Check warning on line 36 in src/commands/build/features/custom-resources/docs-viewer-pdf-icon-path.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=diplodoc-platform_cli&issues=AZ2V5voI1EyDTCZyN64u&open=AZ2V5voI1EyDTCZyN64u&pullRequest=1850
if (!normalized.startsWith('_assets')) {
return;
}
if (normalized.includes('..')) {
return;
}

return normalized;
} catch {
return;
}
}
104 changes: 104 additions & 0 deletions src/commands/build/features/custom-resources/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading