Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: onBrokenMarkdownAssets config option to catch bad asset paths without failing #5909

Closed
wants to merge 7 commits into from
Closed
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
5 changes: 5 additions & 0 deletions packages/docusaurus-mdx-loader/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import transformImage from './remark/transformImage';
import transformLinks from './remark/transformLinks';
import type {RemarkAndRehypePluginOptions} from '@docusaurus/mdx-loader';
import type {LoaderContext} from 'webpack';
import type {ReportingSeverity} from '@docusaurus/types';

const {
loaders: {inlineMarkdownImageFileLoader},
Expand All @@ -47,6 +48,7 @@ type Options = RemarkAndRehypePluginOptions & {
metadata: Record<string, unknown>;
}) => Record<string, unknown>;
filepath: string;
onBrokenMarkdownAssets: ReportingSeverity;
};

// When this throws, it generally means that there's no metadata file associated with this MDX document
Expand Down Expand Up @@ -118,6 +120,7 @@ export default async function mdxLoader(
});

const hasFrontMatter = Object.keys(frontMatter).length > 0;
const {onBrokenMarkdownAssets = 'throw'} = reqOptions;

const options: Options = {
...reqOptions,
Expand All @@ -129,13 +132,15 @@ export default async function mdxLoader(
{
staticDirs: reqOptions.staticDirs,
siteDir: reqOptions.siteDir,
onBrokenMarkdownAssets,
},
],
[
transformLinks,
{
staticDirs: reqOptions.staticDirs,
siteDir: reqOptions.siteDir,
onBrokenMarkdownAssets,
},
],
...(reqOptions.remarkPlugins || []),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
![foo](@site/foo.png)
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,28 @@ exports[`transformImage plugin fail if image relative path does not exist 1`] =

exports[`transformImage plugin fail if image url is absent 1`] = `"Markdown image URL is mandatory in \\"packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/noUrl.md\\" file"`;

exports[`transformImage plugin fail if image with site alias does not exist even when broken assets don't throw 1`] = `"Image packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/foo.png used in packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/nonExistentSiteAlias.md not found."`;

exports[`transformImage plugin pathname protocol 1`] = `
"![img](/img/unchecked.png)
"
`;

exports[`transformImage plugin succeeds if image is bad but broken assets don't throw 1`] = `
"![img](/img/doesNotExist.png)
"
`;

exports[`transformImage plugin succeeds if image is bad but broken assets don't throw 2`] = `
"![img](./notFound.png)
"
`;

exports[`transformImage plugin succeeds if image is bad but broken assets don't throw 3`] = `
"![img](<>)
"
`;

exports[`transformImage plugin transform md images to <img /> 1`] = `
"![img](https://example.com/img.png)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,26 @@ import vfile from 'to-vfile';
import plugin from '../index';
import headings from '../../headings/index';

const staticDirs = [
path.join(__dirname, '__fixtures__/static'),
path.join(__dirname, '__fixtures__/static2'),
];

const siteDir = path.join(__dirname, '__fixtures__');

const processFixture = async (name, options) => {
const filePath = path.join(__dirname, `__fixtures__/${name}.md`);
const file = await vfile.read(filePath);
const result = await remark()
.use(headings)
.use(mdx)
.use(plugin, {...options, filePath})
.use(plugin, {
staticDirs,
siteDir,
filePath,
onBrokenMarkdownAssets: 'throw',
...options,
})
.process(file);

return result
Expand All @@ -27,37 +40,50 @@ const processFixture = async (name, options) => {
.replace(new RegExp(process.cwd().replace(/\\/g, '/'), 'g'), '[CWD]');
};

const staticDirs = [
path.join(__dirname, '__fixtures__/static'),
path.join(__dirname, '__fixtures__/static2'),
];

const siteDir = path.join(__dirname, '__fixtures__');

describe('transformImage plugin', () => {
test('fail if image does not exist', async () => {
await expect(
processFixture('fail', {staticDirs}),
processFixture('fail', {}),
).rejects.toThrowErrorMatchingSnapshot();
});
test('fail if image relative path does not exist', async () => {
await expect(
processFixture('fail2', {staticDirs}),
processFixture('fail2', {}),
).rejects.toThrowErrorMatchingSnapshot();
});
test('fail if image url is absent', async () => {
await expect(
processFixture('noUrl', {staticDirs}),
processFixture('noUrl', {}),
).rejects.toThrowErrorMatchingSnapshot();
});
test("fail if image with site alias does not exist even when broken assets don't throw", async () => {
await expect(
processFixture('nonExistentSiteAlias', {onBrokenMarkdownAssets: 'warn'}),
).rejects.toThrowErrorMatchingSnapshot();
});
test("succeeds if image is bad but broken assets don't throw", async () => {
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
await expect(
processFixture('fail', {onBrokenMarkdownAssets: 'warn'}),
).resolves.toMatchSnapshot();
await expect(
processFixture('fail2', {onBrokenMarkdownAssets: 'warn'}),
).resolves.toMatchSnapshot();
await expect(
processFixture('noUrl', {onBrokenMarkdownAssets: 'warn'}),
).resolves.toMatchSnapshot();
expect(consoleMock).toBeCalledTimes(3);
});

test('transform md images to <img />', async () => {
const result = await processFixture('img', {staticDirs, siteDir});
const result = await processFixture('img', {});
expect(result).toMatchSnapshot();
});

test('pathname protocol', async () => {
const result = await processFixture('pathname', {staticDirs});
const result = await processFixture('pathname', {});
expect(result).toMatchSnapshot();
});
});
55 changes: 44 additions & 11 deletions packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
escapePath,
getFileLoaderUtils,
findAsyncSequential,
reportMessage,
} from '@docusaurus/utils';
import type {ReportingSeverity} from '@docusaurus/types';
import visit from 'unist-util-visit';
import path from 'path';
import url from 'url';
Expand All @@ -30,6 +32,7 @@ const {
type PluginOptions = {
staticDirs: string[];
siteDir: string;
onBrokenMarkdownAssets: ReportingSeverity;
};

type Context = PluginOptions & {
Expand Down Expand Up @@ -79,25 +82,45 @@ ${(e as Error).message}`;
jsxNode.value = `<img ${alt}src={${src}}${title}${width}${height} />`;
}

async function ensureImageFileExist(imagePath: string, sourceFilePath: string) {
/**
* If `onBrokenMarkdownAssets` is set to anything but `throw`, this function
* may return `false` if the file doesn't exist
*/
async function imageFileExists(
imagePath: string,
sourceFilePath: string,
onBrokenMarkdownAssets: ReportingSeverity,
): Promise<boolean> {
const imageExists = await fs.pathExists(imagePath);
if (!imageExists) {
throw new Error(
reportMessage(
`Image ${toMessageRelativeFilePath(
imagePath,
)} used in ${toMessageRelativeFilePath(sourceFilePath)} not found.`,
onBrokenMarkdownAssets,
);
return false;
}
return true;
}

/**
* @returns `null` if image not found and `onBrokenMarkdownAssets` is anything
* but `throw`
*/
async function getImageAbsolutePath(
imagePath: string,
{siteDir, filePath, staticDirs}: Context,
) {
{siteDir, filePath, staticDirs, onBrokenMarkdownAssets}: Context,
): Promise<string | null> {
if (imagePath.startsWith('@site/')) {
const imageFilePath = path.join(siteDir, imagePath.replace('@site/', ''));
await ensureImageFileExist(imageFilePath, filePath);
return imageFilePath;
if (
// Always throw in this case because `@site/` doesn't make sense as URL
await imageFileExists(imageFilePath, filePath, 'throw')
) {
return imageFilePath;
}
return null;
} else if (path.isAbsolute(imagePath)) {
// absolute paths are expected to exist in the static folder
const possiblePaths = staticDirs.map((dir) => path.join(dir, imagePath));
Expand All @@ -106,13 +129,15 @@ async function getImageAbsolutePath(
fs.pathExists,
);
if (!imageFilePath) {
throw new Error(
reportMessage(
`Image ${possiblePaths
.map((p) => toMessageRelativeFilePath(p))
.join(' or ')} used in ${toMessageRelativeFilePath(
filePath,
)} not found.`,
onBrokenMarkdownAssets,
);
return null;
}
return imageFilePath;
}
Expand All @@ -121,18 +146,24 @@ async function getImageAbsolutePath(
else {
// relative paths are resolved against the source file's folder
const imageFilePath = path.join(path.dirname(filePath), imagePath);
await ensureImageFileExist(imageFilePath, filePath);
return imageFilePath;
if (
await imageFileExists(imageFilePath, filePath, onBrokenMarkdownAssets)
) {
return imageFilePath;
}
return null;
}
}

async function processImageNode(node: Image, context: Context) {
if (!node.url) {
throw new Error(
reportMessage(
`Markdown image URL is mandatory in "${toMessageRelativeFilePath(
context.filePath,
)}" file`,
context.onBrokenMarkdownAssets,
);
return;
}

const parsedUrl = url.parse(node.url);
Expand All @@ -148,7 +179,9 @@ async function processImageNode(node: Image, context: Context) {
}

const imagePath = await getImageAbsolutePath(parsedUrl.pathname, context);
await toImageRequireNode(node, imagePath, context.filePath);
if (imagePath) {
await toImageRequireNode(node, imagePath, context.filePath);
}
}

const plugin: Plugin<[PluginOptions]> = (options) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,18 @@ exports[`transformAsset plugin fail if asset url is absent 1`] = `"Markdown link

exports[`transformAsset plugin fail if asset with site alias does not exist 1`] = `"Asset packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/foo.pdf used in packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/nonexistentSiteAlias.md not found."`;

exports[`transformAsset plugin fail if asset with site alias does not exist even when broken assets don't throw 1`] = `"Asset packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/foo.pdf used in packages/docusaurus-mdx-loader/src/remark/transformLinks/__tests__/__fixtures__/nonexistentSiteAlias.md not found."`;

exports[`transformAsset plugin pathname protocol 1`] = `
"[asset](pathname:///asset/unchecked.pdf)
"
`;

exports[`transformAsset plugin succeeds when link is empty and broken assets don't throw 1`] = `
"[asset](<>)
"
`;

exports[`transformAsset plugin transform md links to <a /> 1`] = `
"[asset](https://example.com/asset.pdf)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@ const processFixture = async (name: string, options?) => {
const file = await vfile.read(filePath);
const result = await remark()
.use(mdx)
.use(transformImage, {...options, filePath, staticDirs})
.use(plugin, {
.use(transformImage, {
...options,
filePath,
staticDirs,
onBrokenMarkdownAssets: 'throw',
})
.use(plugin, {
filePath,
staticDirs,
siteDir: path.join(__dirname, '__fixtures__'),
onBrokenMarkdownAssets: 'throw',
...options,
})
.process(file);

Expand All @@ -39,7 +45,7 @@ const processFixture = async (name: string, options?) => {
describe('transformAsset plugin', () => {
test('fail if asset url is absent', async () => {
await expect(
processFixture('noUrl'),
processFixture('noUrl', {}),
).rejects.toThrowErrorMatchingSnapshot();
});

Expand All @@ -49,13 +55,29 @@ describe('transformAsset plugin', () => {
).rejects.toThrowErrorMatchingSnapshot();
});

test("fail if asset with site alias does not exist even when broken assets don't throw", async () => {
await expect(
processFixture('nonexistentSiteAlias', {onBrokenMarkdownAssets: 'warn'}),
).rejects.toThrowErrorMatchingSnapshot();
});

test("succeeds when link is empty and broken assets don't throw", async () => {
const consoleMock = jest
.spyOn(console, 'warn')
.mockImplementation(() => {});
await expect(
processFixture('noUrl', {onBrokenMarkdownAssets: 'warn'}),
).resolves.toMatchSnapshot();
expect(consoleMock).toBeCalledTimes(1);
});

test('transform md links to <a />', async () => {
const result = await processFixture('asset');
const result = await processFixture('asset', {});
expect(result).toMatchSnapshot();
});

test('pathname protocol', async () => {
const result = await processFixture('pathname');
const result = await processFixture('pathname', {});
expect(result).toMatchSnapshot();
});
});
Loading