feat(mdx-loader): preserve hash in image src; support GH themed images (#6323)

* feat(mdx-loader): preserve hash in image src; support GH themed images

* more refactor
This commit is contained in:
Joshua Chen 2022-01-13 10:22:48 +08:00 committed by GitHub
parent 472a4c881a
commit 217b62682d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 190 additions and 154 deletions

View file

@ -124,7 +124,14 @@ export default async function mdxLoader(
remarkPlugins: [ remarkPlugins: [
...(reqOptions.beforeDefaultRemarkPlugins || []), ...(reqOptions.beforeDefaultRemarkPlugins || []),
...DEFAULT_OPTIONS.remarkPlugins, ...DEFAULT_OPTIONS.remarkPlugins,
[transformImage, {staticDirs: reqOptions.staticDirs, filePath}], [
transformImage,
{
staticDirs: reqOptions.staticDirs,
filePath,
siteDir: reqOptions.siteDir,
},
],
[ [
transformLinks, transformLinks,
{ {

View file

@ -12,6 +12,16 @@
![img with "quotes"](./static/img.png ''Quoted' title') ![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 ## Heading
```md ```md

View file

@ -18,14 +18,24 @@ exports[`transformImage plugin transform md images to <img /> 1`] = `
<img alt={\\"img\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png\\").default} /> <img alt={\\"img\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png\\").default} />
<img alt={\\"img from second static folder\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/static2/img2.png\\").default} /> <img alt={\\"img from second static folder\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static2/img2.png\\").default} />
<img alt={\\"img from second static folder\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static2/img2.png\\").default} /> <img alt={\\"img from second static folder\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static2/img2.png\\").default} />
<img alt={\\"img\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png\\").default} title=\\"Title\\" /> <img alt={\\"img\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/__fixtures__/static/img.png\\").default} /> <img alt={\\"img\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png\\").default} title=\\"Title\\" /> <img alt={\\"img\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png\\").default} />
<img alt={\\"img with &quot;quotes&quot;\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png\\").default} title=\\"&#39;Quoted&#39; title\\" /> <img alt={\\"img with &quot;quotes&quot;\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png\\").default} title=\\"&#39;Quoted&#39; title\\" />
<img alt={\\"site alias\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png\\").default} />
<img alt={\\"img with hash\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png\\").default + '#light'} />
<img alt={\\"img with hash\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png\\").default + '#dark'} />
<img alt={\\"img with query\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png?w=10\\").default} />
<img alt={\\"img with query\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png?w=10&h=10\\").default} />
<img alt={\\"img with both\\"} src={require(\\"![CWD]/node_modules/url-loader/dist/cjs.js?limit=10000&name=assets/images/[name]-[hash].[ext]&fallback=[CWD]/node_modules/file-loader/dist/cjs.js!./static/img.png?w=10&h=10\\").default + '#light'} />
## Heading ## Heading
\`\`\`md \`\`\`md

View file

@ -28,17 +28,12 @@ const processFixture = async (name, options) => {
}; };
const staticDirs = [ const staticDirs = [
// avoid hardcoding absolute in the snapshot path.join(__dirname, '__fixtures__/static'),
`./${path.relative( path.join(__dirname, '__fixtures__/static2'),
process.cwd(),
path.join(__dirname, '__fixtures__/static'),
)}`,
`./${path.relative(
process.cwd(),
path.join(__dirname, '__fixtures__/static2'),
)}`,
]; ];
const siteDir = path.join(__dirname, '__fixtures__');
describe('transformImage plugin', () => { describe('transformImage plugin', () => {
test('fail if image does not exist', async () => { test('fail if image does not exist', async () => {
await expect( await expect(
@ -57,7 +52,7 @@ describe('transformImage plugin', () => {
}); });
test('transform md images to <img />', async () => { test('transform md images to <img />', async () => {
const result = await processFixture('img', {staticDirs}); const result = await processFixture('img', {staticDirs, siteDir});
expect(result).toMatchSnapshot(); expect(result).toMatchSnapshot();
}); });

View file

@ -5,17 +5,17 @@
* LICENSE file in the root directory of this source tree. * 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 visit from 'unist-util-visit';
import path from 'path'; import path from 'path';
import url from 'url'; import url from 'url';
import fs from 'fs-extra'; import fs from 'fs-extra';
import escapeHtml from 'escape-html'; import escapeHtml from 'escape-html';
import {
posixPath,
escapePath,
toMessageRelativeFilePath,
getFileLoaderUtils,
} from '@docusaurus/utils';
import type {Plugin, Transformer} from 'unified'; import type {Plugin, Transformer} from 'unified';
import type {Image, Literal} from 'mdast'; import type {Image, Literal} from 'mdast';
@ -26,27 +26,33 @@ const {
interface PluginOptions { interface PluginOptions {
filePath: string; filePath: string;
staticDirs: string[]; staticDirs: string[];
siteDir: string;
} }
const createJSX = (node: Image, pathUrl: string) => { function toImageRequireNode(node: Image, imagePath: string, filePath: string) {
const jsxNode = node; const jsxNode = node as Literal & Partial<Image>;
(jsxNode as unknown as Literal).type = 'jsx'; let relativeImagePath = posixPath(
(jsxNode as unknown as Literal).value = `<img ${ path.relative(path.dirname(filePath), imagePath),
node.alt ? `alt={"${escapeHtml(node.alt)}"} ` : '' );
}${`src={require("${inlineMarkdownImageFileLoader}${escapePath( relativeImagePath = `./${relativeImagePath}`;
pathUrl,
)}").default}`}${node.title ? ` title="${escapeHtml(node.title)}"` : ''} />`;
if (jsxNode.url) { const parsedUrl = url.parse(node.url);
delete (jsxNode as Partial<Image>).url; const hash = parsedUrl.hash ?? '';
} const search = parsedUrl.search ?? '';
if (jsxNode.alt) {
delete jsxNode.alt; const alt = node.alt ? `alt={"${escapeHtml(node.alt)}"} ` : '';
} const src = `require("${inlineMarkdownImageFileLoader}${
if (jsxNode.title) { escapePath(relativeImagePath) + search
delete jsxNode.title; }").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 = `<img ${alt}src={${src}}${title} />`;
}
async function ensureImageFileExist(imagePath: string, sourceFilePath: string) { async function ensureImageFileExist(imagePath: string, sourceFilePath: string) {
const imageExists = await fs.pathExists(imagePath); const imageExists = await fs.pathExists(imagePath);
@ -59,36 +65,53 @@ async function ensureImageFileExist(imagePath: string, sourceFilePath: string) {
} }
} }
async function findImage(possiblePaths: string[], sourceFilePath: string) { async function getImageAbsolutePath(
// eslint-disable-next-line no-restricted-syntax imagePath: string,
for (const possiblePath of possiblePaths) { {siteDir, filePath, staticDirs}: PluginOptions,
if (await fs.pathExists(possiblePath)) { ) {
return possiblePath; 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( async function processImageNode(node: Image, options: PluginOptions) {
node: Image,
{filePath, staticDirs}: PluginOptions,
) {
if (!node.url) { if (!node.url) {
throw new Error( throw new Error(
`Markdown image URL is mandatory in "${toMessageRelativeFilePath( `Markdown image URL is mandatory in "${toMessageRelativeFilePath(
filePath, options.filePath,
)}" file`, )}" file`,
); );
} }
const parsedUrl = url.parse(node.url); const parsedUrl = url.parse(node.url);
if (parsedUrl.protocol) { if (parsedUrl.protocol || !parsedUrl.pathname) {
// pathname:// is an escape hatch, // pathname:// is an escape hatch,
// in case user does not want his images to be converted to require calls going through webpack loader // 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, // we don't have to document this for now,
@ -96,24 +119,11 @@ async function processImageNode(
if (parsedUrl.protocol === 'pathname:') { if (parsedUrl.protocol === 'pathname:') {
node.url = node.url.replace('pathname://', ''); node.url = node.url.replace('pathname://', '');
} }
return;
} }
// images without protocol
else if (path.isAbsolute(node.url)) { const imagePath = await getImageAbsolutePath(parsedUrl.pathname, options);
// absolute paths are expected to exist in the static folder toImageRequireNode(node, imagePath, options.filePath);
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 plugin: Plugin<[PluginOptions]> = (options) => { const plugin: Plugin<[PluginOptions]> = (options) => {

View file

@ -23,7 +23,6 @@ import type {Link, Literal} from 'mdast';
const { const {
loaders: {inlineMarkdownLinkFileLoader}, loaders: {inlineMarkdownLinkFileLoader},
} = getFileLoaderUtils(); } = getFileLoaderUtils();
const hashRegex = /#.*$/;
interface PluginOptions { interface PluginOptions {
filePath: string; filePath: string;
@ -31,103 +30,69 @@ interface PluginOptions {
siteDir: string; siteDir: string;
} }
async function ensureAssetFileExist( // transform the link node to a jsx link with a require() call
fileSystemAssetPath: string, function toAssetRequireNode(node: Link, assetPath: string, filePath: string) {
sourceFilePath: string, const jsxNode = node as Literal & Partial<Link>;
) { let relativeAssetPath = posixPath(
const assetExists = await fs.pathExists(fileSystemAssetPath); 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 = `<a target="_blank" href={${href}}${title}>${children}</a>`;
}
async function ensureAssetFileExist(assetPath: string, sourceFilePath: string) {
const assetExists = await fs.pathExists(assetPath);
if (!assetExists) { if (!assetExists) {
throw new Error( throw new Error(
`Asset ${toMessageRelativeFilePath( `Asset ${toMessageRelativeFilePath(
fileSystemAssetPath, assetPath,
)} used in ${toMessageRelativeFilePath(sourceFilePath)} not found.`, )} used in ${toMessageRelativeFilePath(sourceFilePath)} not found.`,
); );
} }
} }
// transform the link node to a jsx link with a require() call async function getAssetAbsolutePath(
function toAssetRequireNode({ assetPath: string,
node, {siteDir, filePath, staticDirs}: PluginOptions,
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 = `<a target="_blank" href={${href}}${title}>${children}</a>`;
}
// 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,
) { ) {
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/')) { if (assetPath.startsWith('@site/')) {
const fileSystemAssetPath = path.join( const assetFilePath = path.join(siteDir, assetPath.replace('@site/', ''));
siteDir, // The @site alias is the only way to believe that the user wants an asset.
assetPath.replace('@site/', ''), // Everything else can just be a link URL
); await ensureAssetFileExist(assetFilePath, filePath);
await ensureAssetFileExist(fileSystemAssetPath, filePath); return assetFilePath;
toAssetLinkNode(fileSystemAssetPath);
} else if (path.isAbsolute(assetPath)) { } else if (path.isAbsolute(assetPath)) {
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
for (const staticDir of staticDirs) { for (const staticDir of staticDirs) {
const fileSystemAssetPath = path.join(staticDir, assetPath); const assetFilePath = path.join(staticDir, assetPath);
if (await fs.pathExists(fileSystemAssetPath)) { if (await fs.pathExists(assetFilePath)) {
toAssetLinkNode(fileSystemAssetPath); return assetFilePath;
return;
} }
} }
} else { } else {
const fileSystemAssetPath = path.join(path.dirname(filePath), assetPath); const assetFilePath = path.join(path.dirname(filePath), assetPath);
if (await fs.pathExists(fileSystemAssetPath)) { if (await fs.pathExists(assetFilePath)) {
toAssetLinkNode(fileSystemAssetPath); return assetFilePath;
} }
} }
return null;
} }
async function processLinkNode(node: Link, options: PluginOptions) { async function processLinkNode(node: Link, options: PluginOptions) {
@ -144,11 +109,22 @@ async function processLinkNode(node: Link, options: PluginOptions) {
} }
const parsedUrl = url.parse(node.url); const parsedUrl = url.parse(node.url);
if (parsedUrl.protocol) { if (parsedUrl.protocol || !parsedUrl.pathname) {
// Don't process pathname:// here, it's used by the <Link> component
return;
}
const hasSiteAlias = parsedUrl.pathname.startsWith('@site/');
const hasAssetLikeExtension =
path.extname(parsedUrl.pathname) &&
!parsedUrl.pathname.match(/\.(?:mdx?|html)(?:#|$)/);
if (!hasSiteAlias && !hasAssetLikeExtension) {
return; return;
} }
await convertToAssetLinkIfNeeded(node, options); const assetPath = await getAssetAbsolutePath(parsedUrl.pathname, options);
if (assetPath) {
toAssetRequireNode(node, assetPath, options.filePath);
}
} }
const plugin: Plugin<[PluginOptions]> = (options) => { const plugin: Plugin<[PluginOptions]> = (options) => {

View file

@ -160,6 +160,29 @@ import ThemedImage from '@theme/ThemedImage';
</BrowserWindow> </BrowserWindow>
``` ```
### 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)
```
<BrowserWindow>
![Docusaurus themed image](/img/docusaurus_keytar.svg#gh-light-mode-only)![Docusaurus themed image](/img/docusaurus_speed.svg#gh-dark-mode-only)
</BrowserWindow>
## Static assets {#static-assets} ## 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: 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:

View file

@ -172,6 +172,11 @@ div[class^='announcementBar_'] {
white-space: nowrap; 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 */ /* Used to test CSS insertion order */
.test-marker-site-custom-css-unique-rule { .test-marker-site-custom-css-unique-rule {
content: "site-custom-css-unique-rule"; content: "site-custom-css-unique-rule";