diff --git a/packages/docusaurus-mdx-loader/src/index.ts b/packages/docusaurus-mdx-loader/src/index.ts
index c12c5dc938..0dae5617b5 100644
--- a/packages/docusaurus-mdx-loader/src/index.ts
+++ b/packages/docusaurus-mdx-loader/src/index.ts
@@ -124,7 +124,14 @@ export default async function mdxLoader(
remarkPlugins: [
...(reqOptions.beforeDefaultRemarkPlugins || []),
...DEFAULT_OPTIONS.remarkPlugins,
- [transformImage, {staticDirs: reqOptions.staticDirs, filePath}],
+ [
+ transformImage,
+ {
+ staticDirs: reqOptions.staticDirs,
+ filePath,
+ siteDir: reqOptions.siteDir,
+ },
+ ],
[
transformLinks,
{
diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/img.md b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/img.md
index 1f7b20985d..e57e4c2948 100644
--- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/img.md
+++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/img.md
@@ -12,6 +12,16 @@

+
+
+
+
+
+
+
+
+
+
## Heading
```md
diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__snapshots__/index.test.ts.snap
index 21552ed6a3..bb3cfdabb0 100644
--- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__snapshots__/index.test.ts.snap
+++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__snapshots__/index.test.ts.snap
@@ -18,14 +18,24 @@ exports[`transformImage plugin transform md images to
1`] = `
-
+
-
+
+
+
+
+
+
+
+
+
+
+
## Heading
\`\`\`md
diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts
index bd064e1933..e56fe47c19 100644
--- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts
+++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.ts
@@ -28,17 +28,12 @@ const processFixture = async (name, options) => {
};
const staticDirs = [
- // avoid hardcoding absolute in the snapshot
- `./${path.relative(
- process.cwd(),
- path.join(__dirname, '__fixtures__/static'),
- )}`,
- `./${path.relative(
- process.cwd(),
- path.join(__dirname, '__fixtures__/static2'),
- )}`,
+ 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(
@@ -57,7 +52,7 @@ describe('transformImage plugin', () => {
});
test('transform md images to
', async () => {
- const result = await processFixture('img', {staticDirs});
+ const result = await processFixture('img', {staticDirs, siteDir});
expect(result).toMatchSnapshot();
});
diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts b/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts
index bf5733b6f5..6d172fddef 100644
--- a/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts
+++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/index.ts
@@ -5,17 +5,17 @@
* LICENSE file in the root directory of this source tree.
*/
+import {
+ toMessageRelativeFilePath,
+ posixPath,
+ escapePath,
+ getFileLoaderUtils,
+} from '@docusaurus/utils';
import visit from 'unist-util-visit';
import path from 'path';
import url from 'url';
import fs from 'fs-extra';
import escapeHtml from 'escape-html';
-import {
- posixPath,
- escapePath,
- toMessageRelativeFilePath,
- getFileLoaderUtils,
-} from '@docusaurus/utils';
import type {Plugin, Transformer} from 'unified';
import type {Image, Literal} from 'mdast';
@@ -26,27 +26,33 @@ const {
interface PluginOptions {
filePath: string;
staticDirs: string[];
+ siteDir: string;
}
-const createJSX = (node: Image, pathUrl: string) => {
- const jsxNode = node;
- (jsxNode as unknown as Literal).type = 'jsx';
- (jsxNode as unknown as Literal).value = `
`;
+function toImageRequireNode(node: Image, imagePath: string, filePath: string) {
+ const jsxNode = node as Literal & Partial;
+ let relativeImagePath = posixPath(
+ path.relative(path.dirname(filePath), imagePath),
+ );
+ relativeImagePath = `./${relativeImagePath}`;
- if (jsxNode.url) {
- delete (jsxNode as Partial).url;
- }
- if (jsxNode.alt) {
- delete jsxNode.alt;
- }
- if (jsxNode.title) {
- delete jsxNode.title;
- }
-};
+ const parsedUrl = url.parse(node.url);
+ const hash = parsedUrl.hash ?? '';
+ const search = parsedUrl.search ?? '';
+
+ const alt = node.alt ? `alt={"${escapeHtml(node.alt)}"} ` : '';
+ const src = `require("${inlineMarkdownImageFileLoader}${
+ escapePath(relativeImagePath) + search
+ }").default${hash ? ` + '${hash}'` : ''}`;
+ const title = node.title ? ` title="${escapeHtml(node.title)}"` : '';
+
+ Object.keys(jsxNode).forEach(
+ (key) => delete jsxNode[key as keyof typeof jsxNode],
+ );
+
+ (jsxNode as Literal).type = 'jsx';
+ jsxNode.value = `
`;
+}
async function ensureImageFileExist(imagePath: string, sourceFilePath: string) {
const imageExists = await fs.pathExists(imagePath);
@@ -59,36 +65,53 @@ async function ensureImageFileExist(imagePath: string, sourceFilePath: string) {
}
}
-async function findImage(possiblePaths: string[], sourceFilePath: string) {
- // eslint-disable-next-line no-restricted-syntax
- for (const possiblePath of possiblePaths) {
- if (await fs.pathExists(possiblePath)) {
- return possiblePath;
+async function getImageAbsolutePath(
+ imagePath: string,
+ {siteDir, filePath, staticDirs}: PluginOptions,
+) {
+ if (imagePath.startsWith('@site/')) {
+ const imageFilePath = path.join(siteDir, imagePath.replace('@site/', ''));
+ await ensureImageFileExist(imageFilePath, filePath);
+ return imageFilePath;
+ } else if (path.isAbsolute(imagePath)) {
+ // absolute paths are expected to exist in the static folder
+ const possiblePaths = staticDirs.map((dir) => path.join(dir, imagePath));
+ // eslint-disable-next-line no-restricted-syntax
+ for (const possiblePath of possiblePaths) {
+ const imageFilePath = possiblePath;
+ if (await fs.pathExists(imageFilePath)) {
+ return imageFilePath;
+ }
}
+ throw new Error(
+ `Image ${possiblePaths
+ .map((p) => toMessageRelativeFilePath(p))
+ .join(' or ')} used in ${toMessageRelativeFilePath(
+ filePath,
+ )} not found.`,
+ );
+ }
+ // We try to convert image urls without protocol to images with require calls
+ // going through webpack ensures that image assets exist at build time
+ 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;
}
- throw new Error(
- `Image ${possiblePaths
- .map((p) => toMessageRelativeFilePath(p))
- .join(' or ')} used in ${toMessageRelativeFilePath(
- sourceFilePath,
- )} not found.`,
- );
}
-async function processImageNode(
- node: Image,
- {filePath, staticDirs}: PluginOptions,
-) {
+async function processImageNode(node: Image, options: PluginOptions) {
if (!node.url) {
throw new Error(
`Markdown image URL is mandatory in "${toMessageRelativeFilePath(
- filePath,
+ options.filePath,
)}" file`,
);
}
const parsedUrl = url.parse(node.url);
- if (parsedUrl.protocol) {
+ if (parsedUrl.protocol || !parsedUrl.pathname) {
// pathname:// is an escape hatch,
// in case user does not want his images to be converted to require calls going through webpack loader
// we don't have to document this for now,
@@ -96,24 +119,11 @@ async function processImageNode(
if (parsedUrl.protocol === 'pathname:') {
node.url = node.url.replace('pathname://', '');
}
+ return;
}
- // images without protocol
- else if (path.isAbsolute(node.url)) {
- // absolute paths are expected to exist in the static folder
- const possibleImagePaths = staticDirs.map((dir) =>
- path.join(dir, node.url),
- );
- const imagePath = await findImage(possibleImagePaths, filePath);
- createJSX(node, posixPath(imagePath));
- }
- // We try to convert image urls without protocol to images with require calls
- // going through webpack ensures that image assets exist at build time
- else {
- // relative paths are resolved against the source file's folder
- const expectedImagePath = path.join(path.dirname(filePath), node.url);
- await ensureImageFileExist(expectedImagePath, filePath);
- createJSX(node, node.url.startsWith('./') ? node.url : `./${node.url}`);
- }
+
+ const imagePath = await getImageAbsolutePath(parsedUrl.pathname, options);
+ toImageRequireNode(node, imagePath, options.filePath);
}
const plugin: Plugin<[PluginOptions]> = (options) => {
diff --git a/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts b/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts
index 752cea202a..b337185b81 100644
--- a/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts
+++ b/packages/docusaurus-mdx-loader/src/remark/transformLinks/index.ts
@@ -23,7 +23,6 @@ import type {Link, Literal} from 'mdast';
const {
loaders: {inlineMarkdownLinkFileLoader},
} = getFileLoaderUtils();
-const hashRegex = /#.*$/;
interface PluginOptions {
filePath: string;
@@ -31,103 +30,69 @@ interface PluginOptions {
siteDir: string;
}
-async function ensureAssetFileExist(
- fileSystemAssetPath: string,
- sourceFilePath: string,
-) {
- const assetExists = await fs.pathExists(fileSystemAssetPath);
+// transform the link node to a jsx link with a require() call
+function toAssetRequireNode(node: Link, assetPath: string, filePath: string) {
+ const jsxNode = node as Literal & Partial;
+ let relativeAssetPath = posixPath(
+ path.relative(path.dirname(filePath), assetPath),
+ );
+ // require("assets/file.pdf") means requiring from a package called assets
+ relativeAssetPath = `./${relativeAssetPath}`;
+
+ const parsedUrl = url.parse(node.url);
+ const hash = parsedUrl.hash ?? '';
+ const search = parsedUrl.search ?? '';
+
+ const href = `require('${inlineMarkdownLinkFileLoader}${
+ escapePath(relativeAssetPath) + search
+ }').default${hash ? ` + '${hash}'` : ''}`;
+ const children = stringifyContent(node);
+ const title = node.title ? ` title="${escapeHtml(node.title)}"` : '';
+
+ Object.keys(jsxNode).forEach(
+ (key) => delete jsxNode[key as keyof typeof jsxNode],
+ );
+
+ (jsxNode as Literal).type = 'jsx';
+ jsxNode.value = `${children}`;
+}
+
+async function ensureAssetFileExist(assetPath: string, sourceFilePath: string) {
+ const assetExists = await fs.pathExists(assetPath);
if (!assetExists) {
throw new Error(
`Asset ${toMessageRelativeFilePath(
- fileSystemAssetPath,
+ assetPath,
)} used in ${toMessageRelativeFilePath(sourceFilePath)} not found.`,
);
}
}
-// transform the link node to a jsx link with a require() call
-function toAssetRequireNode({
- node,
- filePath,
- requireAssetPath,
-}: {
- node: Link;
- filePath: string;
- requireAssetPath: string;
-}) {
- let relativeRequireAssetPath = posixPath(
- path.relative(path.dirname(filePath), requireAssetPath),
- );
- const hash = hashRegex.test(node.url)
- ? node.url.substring(node.url.indexOf('#'))
- : '';
-
- // require("assets/file.pdf") means requiring from a package called assets
- relativeRequireAssetPath = relativeRequireAssetPath.startsWith('./')
- ? relativeRequireAssetPath
- : `./${relativeRequireAssetPath}`;
-
- const href = `require('${inlineMarkdownLinkFileLoader}${escapePath(
- relativeRequireAssetPath,
- )}').default${hash ? ` + '${hash}'` : ''}`;
- const children = stringifyContent(node);
- const title = node.title ? ` title="${escapeHtml(node.title)}"` : '';
-
- (node as unknown as Literal).type = 'jsx';
- (
- node as unknown as Literal
- ).value = `${children}`;
-}
-
-// If the link looks like an asset link, we'll link to the asset,
-// and use a require("assetUrl") (using webpack url-loader/file-loader)
-// instead of navigating to such link
-async function convertToAssetLinkIfNeeded(
- node: Link,
- {filePath, siteDir, staticDirs}: PluginOptions,
+async function getAssetAbsolutePath(
+ assetPath: string,
+ {siteDir, filePath, staticDirs}: PluginOptions,
) {
- const assetPath = node.url.replace(hashRegex, '');
-
- const hasSiteAlias = assetPath.startsWith('@site/');
- const hasAssetLikeExtension =
- path.extname(assetPath) && !assetPath.match(/#|\.md$|\.mdx$|\.html$/);
-
- const looksLikeAssetLink = hasSiteAlias || hasAssetLikeExtension;
-
- if (!looksLikeAssetLink) {
- return;
- }
-
- function toAssetLinkNode(requireAssetPath: string) {
- toAssetRequireNode({
- node,
- filePath,
- requireAssetPath,
- });
- }
-
if (assetPath.startsWith('@site/')) {
- const fileSystemAssetPath = path.join(
- siteDir,
- assetPath.replace('@site/', ''),
- );
- await ensureAssetFileExist(fileSystemAssetPath, filePath);
- toAssetLinkNode(fileSystemAssetPath);
+ const assetFilePath = path.join(siteDir, assetPath.replace('@site/', ''));
+ // The @site alias is the only way to believe that the user wants an asset.
+ // Everything else can just be a link URL
+ await ensureAssetFileExist(assetFilePath, filePath);
+ return assetFilePath;
} else if (path.isAbsolute(assetPath)) {
// eslint-disable-next-line no-restricted-syntax
for (const staticDir of staticDirs) {
- const fileSystemAssetPath = path.join(staticDir, assetPath);
- if (await fs.pathExists(fileSystemAssetPath)) {
- toAssetLinkNode(fileSystemAssetPath);
- return;
+ const assetFilePath = path.join(staticDir, assetPath);
+ if (await fs.pathExists(assetFilePath)) {
+ return assetFilePath;
}
}
} else {
- const fileSystemAssetPath = path.join(path.dirname(filePath), assetPath);
- if (await fs.pathExists(fileSystemAssetPath)) {
- toAssetLinkNode(fileSystemAssetPath);
+ const assetFilePath = path.join(path.dirname(filePath), assetPath);
+ if (await fs.pathExists(assetFilePath)) {
+ return assetFilePath;
}
}
+ return null;
}
async function processLinkNode(node: Link, options: PluginOptions) {
@@ -144,11 +109,22 @@ async function processLinkNode(node: Link, options: PluginOptions) {
}
const parsedUrl = url.parse(node.url);
- if (parsedUrl.protocol) {
+ if (parsedUrl.protocol || !parsedUrl.pathname) {
+ // Don't process pathname:// here, it's used by the component
+ return;
+ }
+ const hasSiteAlias = parsedUrl.pathname.startsWith('@site/');
+ const hasAssetLikeExtension =
+ path.extname(parsedUrl.pathname) &&
+ !parsedUrl.pathname.match(/\.(?:mdx?|html)(?:#|$)/);
+ if (!hasSiteAlias && !hasAssetLikeExtension) {
return;
}
- await convertToAssetLinkIfNeeded(node, options);
+ const assetPath = await getAssetAbsolutePath(parsedUrl.pathname, options);
+ if (assetPath) {
+ toAssetRequireNode(node, assetPath, options.filePath);
+ }
}
const plugin: Plugin<[PluginOptions]> = (options) => {
diff --git a/website/docs/guides/markdown-features/markdown-features-assets.mdx b/website/docs/guides/markdown-features/markdown-features-assets.mdx
index 79da53ab77..de1d18905a 100644
--- a/website/docs/guides/markdown-features/markdown-features-assets.mdx
+++ b/website/docs/guides/markdown-features/markdown-features-assets.mdx
@@ -160,6 +160,29 @@ import ThemedImage from '@theme/ThemedImage';
```
+### GitHub-style themed images
+
+GitHub uses its own [image theming approach](https://github.blog/changelog/2021-11-24-specify-theme-context-for-images-in-markdown/) with path fragments, which you can easily implement yourself.
+
+To toggle the visibility of an image using the path fragment (for GitHub, it's `#gh-dark-mode-only` and `#gh-light-mode-only`), add the following to your custom CSS (you can also use your own suffix if you don't want to be coupled to GitHub):
+
+```css title="src/css/custom.css"
+html[data-theme='light'] img[src$='#gh-dark-mode-only'],
+html[data-theme='dark'] img[src$='#gh-light-mode-only'] {
+ display: none;
+}
+```
+
+```md
+
+```
+
+
+
+
+
+
+
## Static assets {#static-assets}
If a Markdown link or image has an absolute path, the path will be seen as a file path and will be resolved from the static directories. For example, if you have configured [static directories](../../static-assets.md) to be `['public', 'static']`, then for the following image:
diff --git a/website/src/css/custom.css b/website/src/css/custom.css
index a19f239fca..9dad43f6f5 100644
--- a/website/src/css/custom.css
+++ b/website/src/css/custom.css
@@ -172,6 +172,11 @@ div[class^='announcementBar_'] {
white-space: nowrap;
}
+html[data-theme='light'] img[src$='#gh-dark-mode-only'],
+html[data-theme='dark'] img[src$='#gh-light-mode-only'] {
+ display: none;
+}
+
/* Used to test CSS insertion order */
.test-marker-site-custom-css-unique-rule {
content: "site-custom-css-unique-rule";