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: [
...(reqOptions.beforeDefaultRemarkPlugins || []),
...DEFAULT_OPTIONS.remarkPlugins,
[transformImage, {staticDirs: reqOptions.staticDirs, filePath}],
[
transformImage,
{
staticDirs: reqOptions.staticDirs,
filePath,
siteDir: reqOptions.siteDir,
},
],
[
transformLinks,
{

View file

@ -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

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 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\\"} 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={\\"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
\`\`\`md

View file

@ -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'),
)}`,
];
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 <img />', async () => {
const result = await processFixture('img', {staticDirs});
const result = await processFixture('img', {staticDirs, siteDir});
expect(result).toMatchSnapshot();
});

View file

@ -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 = `<img ${
node.alt ? `alt={"${escapeHtml(node.alt)}"} ` : ''
}${`src={require("${inlineMarkdownImageFileLoader}${escapePath(
pathUrl,
)}").default}`}${node.title ? ` title="${escapeHtml(node.title)}"` : ''} />`;
function toImageRequireNode(node: Image, imagePath: string, filePath: string) {
const jsxNode = node as Literal & Partial<Image>;
let relativeImagePath = posixPath(
path.relative(path.dirname(filePath), imagePath),
);
relativeImagePath = `./${relativeImagePath}`;
if (jsxNode.url) {
delete (jsxNode as Partial<Image>).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 = `<img ${alt}src={${src}}${title} />`;
}
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) {
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) {
if (await fs.pathExists(possiblePath)) {
return possiblePath;
const imageFilePath = possiblePath;
if (await fs.pathExists(imageFilePath)) {
return imageFilePath;
}
}
throw new Error(
`Image ${possiblePaths
.map((p) => toMessageRelativeFilePath(p))
.join(' or ')} used in ${toMessageRelativeFilePath(
sourceFilePath,
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;
}
}
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) => {

View file

@ -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<Link>;
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 = `<a target="_blank" href={${href}}${title}>${children}</a>`;
}
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 = `<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,
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 <Link> 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) => {

View file

@ -160,6 +160,29 @@ import ThemedImage from '@theme/ThemedImage';
</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}
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;
}
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";