diff --git a/packages/core/src/node/mdx/options.ts b/packages/core/src/node/mdx/options.ts index f935771a29..02e38bbc46 100644 --- a/packages/core/src/node/mdx/options.ts +++ b/packages/core/src/node/mdx/options.ts @@ -97,7 +97,20 @@ export async function createMDXOptions(options: { remarkLinkOptions, }, ], - remarkImage, + [ + remarkImage, + isSsgMd + ? { + docDirectory, + remarkImageOptions: { + checkDeadImages: false, + }, + } + : { + docDirectory, + remarkImageOptions: config?.markdown?.image, + }, + ], isSsgMd && [ remarkSplitMdx, typeof config?.llms === 'object' diff --git a/packages/core/src/node/mdx/remarkPlugins/image.test.ts b/packages/core/src/node/mdx/remarkPlugins/image.test.ts index 916d7068aa..82d19131d5 100644 --- a/packages/core/src/node/mdx/remarkPlugins/image.test.ts +++ b/packages/core/src/node/mdx/remarkPlugins/image.test.ts @@ -1,4 +1,7 @@ -import { describe, expect, it } from '@rstest/core'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from '@rstest/core'; import { compile } from '../processor'; describe('mdx', () => { @@ -19,3 +22,84 @@ describe('mdx', () => { expect(result).toMatchSnapshot(); }); }); + +describe('checkDeadImages', () => { + let docDir: string; + let origNodeEnv: string | undefined; + + beforeAll(() => { + docDir = mkdtempSync(path.join(tmpdir(), 'rspress-test-')); + mkdirSync(path.join(docDir, 'public'), { recursive: true }); + writeFileSync(path.join(docDir, 'existing.png'), ''); + writeFileSync(path.join(docDir, 'public', 'logo.png'), ''); + origNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + }); + + afterAll(() => { + process.env.NODE_ENV = origNodeEnv; + rmSync(docDir, { recursive: true }); + }); + + it('existing absolute image', async () => { + const result = await compile({ + source: '![alt](/logo.png)', + docDirectory: docDir, + filepath: path.join(docDir, 'index.mdx'), + config: null, + pluginDriver: null, + routeService: null, + }); + expect(result).toBeDefined(); + }); + + it('missing absolute image', async () => { + await expect( + compile({ + source: '![alt](/missing.png)', + docDirectory: docDir, + filepath: path.join(docDir, 'index.mdx'), + config: null, + pluginDriver: null, + routeService: null, + }), + ).rejects.toThrow('Dead image found'); + }); + + it('existing relative image', async () => { + const result = await compile({ + source: '![alt](./existing.png)', + docDirectory: docDir, + filepath: path.join(docDir, 'index.mdx'), + config: null, + pluginDriver: null, + routeService: null, + }); + expect(result).toBeDefined(); + }); + + it('missing relative image', async () => { + await expect( + compile({ + source: '![alt](./missing.png)', + docDirectory: docDir, + filepath: path.join(docDir, 'index.mdx'), + config: null, + pluginDriver: null, + routeService: null, + }), + ).rejects.toThrow('Dead image found'); + }); + + it('external image', async () => { + const result = await compile({ + source: '![alt](https://example.com/image.png)', + docDirectory: docDir, + filepath: path.join(docDir, 'index.mdx'), + config: null, + pluginDriver: null, + routeService: null, + }); + expect(result).toBeDefined(); + }); +}); diff --git a/packages/core/src/node/mdx/remarkPlugins/image.ts b/packages/core/src/node/mdx/remarkPlugins/image.ts index 4356a4a736..fb4e120e8e 100644 --- a/packages/core/src/node/mdx/remarkPlugins/image.ts +++ b/packages/core/src/node/mdx/remarkPlugins/image.ts @@ -1,10 +1,21 @@ -import { isExternalUrl } from '@rspress/shared'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { + isDataUrl, + isExternalUrl, + isProduction, + type MarkdownOptions, + parseUrl, +} from '@rspress/shared'; +import { logger } from '@rspress/shared/logger'; import { getNodeAttribute } from '@rspress/shared/node-utils'; import type { Root } from 'mdast'; import type { MdxjsEsm } from 'mdast-util-mdxjs-esm'; -import type { Plugin } from 'unified'; +import picocolors from 'picocolors'; import { visit } from 'unist-util-visit'; -import { getDefaultImportAstNode } from '../../utils'; +import type { VFile } from 'vfile'; +import { PUBLIC_DIR } from '../../constants'; +import { createError, getDefaultImportAstNode } from '../../utils'; const normalizeImageUrl = (imageUrl: string): string => { if (isExternalUrl(imageUrl) || imageUrl.startsWith('/')) { @@ -14,92 +25,191 @@ const normalizeImageUrl = (imageUrl: string): string => { return imageUrl; }; -export const remarkImage: Plugin<[], Root> = () => (tree, _file) => { - const images: MdxjsEsm[] = []; - const getMdxSrcAttribute = (tempVar: string) => { - return { - type: 'mdxJsxAttribute', - name: 'src', - value: { - type: 'mdxJsxAttributeValueExpression', - value: tempVar, - data: { - estree: { - type: 'Program', - sourceType: 'module', - body: [ - { - type: 'ExpressionStatement', - expression: { - type: 'Identifier', - name: tempVar, - }, - }, - ], - }, - }, - }, - }; - }; - - visit(tree, 'image', node => { - const { alt, url } = node; - if (!url) { +function checkDeadImages( + checkDeadImages: + | boolean + | { excludes: string[] | ((url: string) => boolean) }, + deadImages: Map, + file: VFile, + lint?: boolean, +) { + const errorInfos: [string, string][] = []; + const excludes = + typeof checkDeadImages === 'object' ? checkDeadImages.excludes : undefined; + const excludesUrl = Array.isArray(excludes) ? new Set(excludes) : undefined; + const excludesFn = typeof excludes === 'function' ? excludes : undefined; + + [...deadImages.entries()].forEach(([imageUrl, resolvedPath]) => { + if (excludesUrl?.has(imageUrl)) { return; } - const imagePath = normalizeImageUrl(url); - if (!imagePath) { + if (excludesFn?.(imageUrl)) { return; } - // relative path - const tempVariableName = `image${images.length}`; - - Object.assign(node, { - type: 'mdxJsxFlowElement', - name: 'img', - children: [], - attributes: [ - alt && { - type: 'mdxJsxAttribute', - name: 'alt', - value: alt, - }, - getMdxSrcAttribute(tempVariableName), - ].filter(Boolean), - }); - images.push(getDefaultImportAstNode(tempVariableName, imagePath)); + errorInfos.push([imageUrl, resolvedPath]); }); - visit(tree, node => { - if ( - (node.type !== 'mdxJsxFlowElement' && - node.type !== 'mdxJsxTextElement') || - // get all img src - node.name !== 'img' - ) { - return; - } + // output error info + if (errorInfos.length > 0) { + const message = `Dead images found${lint ? '' : ` in ${picocolors.cyan(file.path)}`}: +${errorInfos.map(([imageUrl, resolved]) => ` ${picocolors.green(`"![..](${imageUrl})"`)} ${picocolors.gray(resolved)}`).join('\n')}`; - const srcAttr = getNodeAttribute(node, 'src', true); + if (lint) { + file.message(message); + } else { + logger.error(message); - if (typeof srcAttr?.value !== 'string') { - return; + if (isProduction()) { + throw createError('Dead image found'); + } } + } +} + +function resolveImage( + imageUrl: string, + filePath: string, + docDirectory: string, + deadImages: Map, +): void { + if (!imageUrl) { + return; + } + if (isExternalUrl(imageUrl)) { + return; + } + if (isDataUrl(imageUrl)) { + return; + } - const imagePath = normalizeImageUrl(srcAttr.value); + const { url } = parseUrl(imageUrl); - if (!imagePath) { - return; + if (url.startsWith('/')) { + const resolvedPath = path.join(docDirectory, PUBLIC_DIR, url); + if (!existsSync(resolvedPath)) { + deadImages.set(imageUrl, resolvedPath); + } + } else { + const resolvedPath = path.resolve(path.dirname(filePath), url); + if (!existsSync(resolvedPath)) { + deadImages.set(imageUrl, resolvedPath); } + } +} + +export const remarkImage = + ({ + docDirectory, + remarkImageOptions, + lint, + }: { + docDirectory: string; + remarkImageOptions?: MarkdownOptions['image']; + lint?: boolean; + }) => + (tree: Root, file: VFile) => { + const { checkDeadImages: shouldCheckDeadImages = true } = + remarkImageOptions ?? {}; + const deadImages = new Map(); + const images: MdxjsEsm[] = []; + const getMdxSrcAttribute = (tempVar: string) => { + return { + type: 'mdxJsxAttribute', + name: 'src', + value: { + type: 'mdxJsxAttributeValueExpression', + value: tempVar, + data: { + estree: { + type: 'Program', + sourceType: 'module', + body: [ + { + type: 'ExpressionStatement', + expression: { + type: 'Identifier', + name: tempVar, + }, + }, + ], + }, + }, + }, + }; + }; + + visit(tree, 'image', node => { + const { alt, url } = node; + if (!url) { + return; + } + + if (shouldCheckDeadImages) { + resolveImage(url, file.path, docDirectory, deadImages); + } + + const imagePath = normalizeImageUrl(url); + if (!imagePath) { + return; + } + // relative path + const tempVariableName = `image${images.length}`; + + Object.assign(node, { + type: 'mdxJsxFlowElement', + name: 'img', + children: [], + attributes: [ + alt && { + type: 'mdxJsxAttribute', + name: 'alt', + value: alt, + }, + getMdxSrcAttribute(tempVariableName), + ].filter(Boolean), + }); - const tempVariableName = `image${images.length}`; + images.push(getDefaultImportAstNode(tempVariableName, imagePath)); + }); - Object.assign(srcAttr, getMdxSrcAttribute(tempVariableName)); + visit(tree, node => { + if ( + (node.type !== 'mdxJsxFlowElement' && + node.type !== 'mdxJsxTextElement') || + // get all img src + node.name !== 'img' + ) { + return; + } - images.push(getDefaultImportAstNode(tempVariableName, imagePath)); - }); + const srcAttr = getNodeAttribute(node, 'src', true); - tree.children.unshift(...images); -}; + if (typeof srcAttr?.value !== 'string') { + return; + } + + if (shouldCheckDeadImages) { + resolveImage(srcAttr.value, file.path, docDirectory, deadImages); + } + + const imagePath = normalizeImageUrl(srcAttr.value); + + if (!imagePath) { + return; + } + + const tempVariableName = `image${images.length}`; + + Object.assign(srcAttr, getMdxSrcAttribute(tempVariableName)); + + images.push(getDefaultImportAstNode(tempVariableName, imagePath)); + }); + + tree.children.unshift(...images); + + if (shouldCheckDeadImages) { + checkDeadImages(shouldCheckDeadImages, deadImages, file, lint); + } + }; diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 7d2d8dd738..f6d343cedf 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -524,10 +524,21 @@ export type RemarkLinkOptions = { autoPrefix?: boolean; }; +export type RemarkImageOptions = { + /** + * Whether to enable check dead images + * @default true + */ + checkDeadImages?: + | boolean + | { excludes: string[] | ((url: string) => boolean) }; +}; + export interface MarkdownOptions { remarkPlugins?: PluggableList; rehypePlugins?: PluggableList; link?: RemarkLinkOptions; + image?: RemarkImageOptions; showLineNumbers?: boolean; /** * Whether to wrap code by default diff --git a/website/docs/en/api/config/config-build.mdx b/website/docs/en/api/config/config-build.mdx index 04b490d3ca..67cab6f915 100644 --- a/website/docs/en/api/config/config-build.mdx +++ b/website/docs/en/api/config/config-build.mdx @@ -255,6 +255,47 @@ After enabling this config, Rspress will automatically add prefixes to links in If a user writes a link `[](/guide/getting-started)` in `docs/zh/guide/index.md`, Rspress will automatically convert it to `[](/zh/guide/getting-started)`. +### markdown.image + +- **Type**: + +```ts +export type RemarkImageOptions = { + checkDeadImages?: + | boolean + | { excludes: string[] | ((url: string) => boolean) }; +}; +``` + +- **Default**: `{ checkDeadImages: true }` + +Configure image-related options. + +#### markdown.image.checkDeadImages + +- **Type**: `boolean | { excludes: string[] | ((url: string) => boolean) }` +- **Default**: `true` + +After enabling this configuration, Rspress will check the local images in the document. If an image references a non-existent file, the build will throw an error and exit. + +For relative image paths (e.g., `./image.png`), the file is resolved relative to the current document. For absolute image paths (e.g., `/image.png`), the file is resolved from the `public` directory. + +If there is a misjudgment of images, you can ignore the error through the `excludes` configuration: + +```ts title="rspress.config.ts" +import { defineConfig } from '@rspress/core'; + +export default defineConfig({ + markdown: { + image: { + checkDeadImages: { + excludes: ['/generated-diagram.png'], + }, + }, + }, +}); +``` + ### markdown.showLineNumbers - **Type**: `boolean` diff --git a/website/docs/en/guide/use-mdx/link.mdx b/website/docs/en/guide/use-mdx/link.mdx index b0a2325c29..1d6ae8e51d 100644 --- a/website/docs/en/guide/use-mdx/link.mdx +++ b/website/docs/en/guide/use-mdx/link.mdx @@ -256,3 +256,21 @@ export default defineConfig({ }, }); ``` + +## Dead images checking + +Similar to dead link checking, Rspress can also check for broken image references in your documentation. This catches images that reference non-existent local files, including both relative paths and absolute paths that reference the `public` directory. + +Configure through [markdown.image.checkDeadImages](/api/config/config-build#markdownimagecheckdeadimages) to automatically check for invalid images. + +```ts title="rspress.config.ts" +import { defineConfig } from '@rspress/core'; + +export default defineConfig({ + markdown: { + image: { + checkDeadImages: true, + }, + }, +}); +```