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 @@ ![img with "quotes"](./static/img.png ''Quoted' title') +![site alias](@site/static/img.png) + +![img with hash](/img.png#light) +![img with hash](/img.png#dark) + +![img with query](/img.png?w=10) +![img with query](/img.png?w=10&h=10) + +![img with both](/img.png?w=10&h=10#light) + ## 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`] = ` {\\"img\\"} -{\\"img +{\\"img {\\"img -{\\"img\\"} {\\"img\\"} +{\\"img\\"} {\\"img\\"} {\\"img +{\\"site + +{\\"img +{\\"img + +{\\"img +{\\"img + +{\\"img + ## 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 +![Docusaurus themed image](/img/docusaurus_keytar.svg#gh-light-mode-only)![Docusaurus themed image](/img/docusaurus_speed.svg#gh-dark-mode-only) +``` + + + +![Docusaurus themed image](/img/docusaurus_keytar.svg#gh-light-mode-only)![Docusaurus themed image](/img/docusaurus_speed.svg#gh-dark-mode-only) + + + ## 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";