Core: Add remark-image plugin

This commit is contained in:
Fuma Nama
2024-01-20 22:53:50 +08:00
parent 60db195cdb
commit 1a346a1683
11 changed files with 175 additions and 20 deletions
+19
View File
@@ -0,0 +1,19 @@
---
'fumadocs-mdx': major
---
**Enable `remark-image` plugin by default**
You can add image embeds easily. They will be converted to static image imports.
```mdx
![banner](/image.png)
```
Become:
```mdx
import img_banner from "../../public/image.png"
<img alt='banner' src={img_banner} />
```
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': minor
---
Add `remark-image` plugin that converts relative image urls into static image imports (Inspired by Nextra)
@@ -3,8 +3,6 @@ title: Image Optimization
description: Use Next.js image optimization in your MDX documents
---
import Banner from '@/public/banner.png';
Contentlayer integration supports the built-in `next/image` component by passing `width`
and `height` property automatically. You can enjoy the benefits of Next.js Image
Optimization without extra configurations.
@@ -24,4 +22,4 @@ Assume the image is under the `/public` folder.
![banner](/banner.png)
```
<Image alt="Banner" src={Banner} />
![banner](/banner.png)
-1
View File
@@ -14,7 +14,6 @@ export function useMDXComponents(components: MDXComponents): MDXComponents {
pre: (props) => (
<Pre {...props} ref={undefined} pre={{ className: 'max-h-[400px]' }} />
),
Image: defaultComponents.img,
Tabs,
Tab,
Callout,
+1 -1
View File
@@ -7,9 +7,9 @@ import remarkMath from 'remark-math';
const withAnalyzer = createBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
/** @type {import('next').NextConfig} */
const config = {
pageExtensions: ['ts', 'tsx', 'js', 'jsx', 'md', 'mdx'],
reactStrictMode: true,
eslint: {
// Replaced by root workspace command
@@ -12,16 +12,18 @@ import type {
FieldDef,
} from 'contentlayer/source-files';
import { defineDocumentType } from 'contentlayer/source-files';
import type { Options as ImgSizeOptions } from 'rehype-img-size';
import type { MDXOptions } from 'contentlayer/core';
import type { PluggableList } from 'unified';
import type { PluggableList, Plugin } from 'unified';
import rehypeImgSize, {
type Options as RehypeImgSizeOptions,
} from 'rehype-img-size';
import type { Root } from 'hast';
import {
rehypeImgSize,
rehypeNextDocs,
remarkGfm,
structure,
type RehypeNextDocsOptions,
} from '@/mdx-plugins';
import type { RehypeNextDocsOptions } from '@/mdx-plugins/rehype-next-docs';
import { getTableOfContents } from '@/server/get-toc';
import type { DocsPageBase } from './types';
@@ -79,11 +81,10 @@ export function create(options: Options = {}): Config {
const rehypePlugins: PluggableList = [
[rehypeNextDocs, pluginOptions],
[
// @ts-expect-error -- invalid options type
rehypeImgSize,
rehypeImgSize as Plugin<[RehypeImgSizeOptions], Root>,
{
dir: imgDirPath,
} satisfies ImgSizeOptions,
},
],
...(mdx.rehypePlugins ?? []),
];
+1 -4
View File
@@ -1,11 +1,8 @@
export {
default as rehypeImgSize,
type Options as RehypeImgSizeOptions,
} from 'rehype-img-size';
export {
default as remarkGfm,
type Options as RemarkGfmOptions,
} from 'remark-gfm';
export * from './remark-image';
export * from './rehype-next-docs';
export * from './remark-dynamic-content';
export * from './remark-structure';
@@ -0,0 +1,121 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import type { Image, Root, RootContent } from 'mdast';
import type { Transformer } from 'unified';
import slash from '@/utils/slash';
import { visit } from './unist-visit';
const VALID_BLUR_EXT = ['.jpeg', '.png', '.webp', '.avif', '.jpg'];
const EXTERNAL_URL_REGEX = /^https?:\/\//;
const PUBLIC_DIR = path.join(process.cwd(), 'public');
export interface RemarkImageOptions {
/**
* Preferred placeholder type
*
* @defaultValue 'blur'
*/
placeholder?: 'blur' | 'none';
}
// Based on the Nextra: https://github.com/shuding/nextra
/**
* Turn images into static imports
*/
export function remarkImage({
placeholder = 'blur',
}: RemarkImageOptions = {}): Transformer<Root, Root> {
return (tree, _file, done) => {
const importsToInject: { variableName: string; importPath: string }[] = [];
visit(tree, ['image'], (node: Image) => {
let url = decodeURI(node.url);
if (!url || EXTERNAL_URL_REGEX.test(url)) {
return;
}
if (url.startsWith('/')) {
const urlPath = path.join(PUBLIC_DIR, url);
if (!existsSync(urlPath)) {
return;
}
url = slash(urlPath);
}
// Unique variable name for the given static image URL
const variableName = `__img${importsToInject.length}`;
const hasBlur =
placeholder === 'blur' &&
VALID_BLUR_EXT.some((ext) => url.endsWith(ext));
importsToInject.push({ variableName, importPath: url });
// Replace the image node with an MDX component node (Next.js Image)
Object.assign(node, {
type: 'mdxJsxFlowElement',
name: 'img',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'alt',
value: node.alt ?? 'image',
},
hasBlur && {
type: 'mdxJsxAttribute',
name: 'placeholder',
value: 'blur',
},
{
type: 'mdxJsxAttribute',
name: 'src',
value: {
type: 'mdxJsxAttributeValueExpression',
value: variableName,
data: {
estree: {
body: [
{
type: 'ExpressionStatement',
expression: { type: 'Identifier', name: variableName },
},
],
},
},
},
},
].filter(Boolean),
});
});
if (importsToInject.length) {
const imports = importsToInject.map(
({ variableName, importPath }) =>
({
type: 'mdxjsEsm',
data: {
estree: {
body: [
{
type: 'ImportDeclaration',
source: { type: 'Literal', value: importPath },
specifiers: [
{
type: 'ImportDefaultSpecifier',
local: { type: 'Identifier', name: variableName },
},
],
},
],
},
},
}) as RootContent,
);
tree.children.unshift(...imports);
}
done();
};
}
+5 -4
View File
@@ -1,9 +1,10 @@
import { parse } from 'node:path';
import slash from '@/utils/slash';
import type { FileInfo } from './types';
export function parseFilePath(path: string): FileInfo {
const parsed = parse(path);
const dir = parsed.dir.replace('\\', '/');
const dir = slash(parsed.dir);
const flattenedPath = joinPaths([dir, parsed.name]);
const [name, locale] = parsed.name.split('.');
@@ -49,7 +50,7 @@ export function splitPath(path: string): string[] {
/**
* Convert paths to an array, slashes within the path will be ignored
* @param paths - Paths to join
* @param slash - whether to add a trailing/leading slash to path
* @param slashMode - whether to add a trailing/leading slash to path
* @example
* ```
* ['a','b','c'] // 'a/b/c'
@@ -60,14 +61,14 @@ export function splitPath(path: string): string[] {
*/
export function joinPaths(
paths: string[],
slash: 'leading' | 'trailing' | 'none' = 'none',
slashMode: 'leading' | 'trailing' | 'none' = 'none',
): string {
const joined = paths
// avoid slashes in path and filter empty
.flatMap((path) => splitPath(path))
.join('/');
switch (slash) {
switch (slashMode) {
case 'leading':
return `/${joined}`;
case 'trailing':
+9
View File
@@ -0,0 +1,9 @@
export default function slash(path: string): string {
const isExtendedLengthPath = path.startsWith('\\\\?\\');
if (isExtendedLengthPath) {
return path;
}
return path.replace('\\', '/');
}
+5
View File
@@ -6,6 +6,8 @@ import {
remarkStructure,
remarkHeading,
type RehypeNextDocsOptions,
remarkImage,
type RemarkImageOptions,
} from 'fumadocs-core/mdx-plugins';
import type { PluggableList } from 'unified';
import type { Configuration } from 'webpack';
@@ -26,6 +28,8 @@ type MDXOptions = Omit<
*/
valueToExport?: string[];
remarkImageOptions?: RemarkImageOptions;
/**
* built-in `fumadocs-core` rehype plugin options
*/
@@ -88,6 +92,7 @@ const createNextDocs =
(v) => [
remarkGfm,
remarkHeading,
[remarkImage, mdxOptions.remarkImageOptions],
...v,
remarkStructure,
[remarkMdxExport, { values: valueToExport }],