Core (Remark Image): Support fetching image size of external urls

This commit is contained in:
fuma-nama
2024-08-13 15:11:48 +08:00
parent 3a209f5430
commit 17fa173b63
25 changed files with 971 additions and 589 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Remark Image: Support fetching image size of external urls
@@ -1,11 +1,11 @@
---
title: Remark Image
description: Convert images into static imports
description: Make images compatible with Next.js Image Optimization
---
## Usage
Convert images into imports (MDX only).
Add it to your Remark plugins.
```ts
import { remarkImage } from 'fumadocs-core/mdx-plugins';
@@ -15,19 +15,82 @@ export default {
};
```
### Example
Supported:
- Local Images
- External URLs
- Next.js static imports
### How It Works
It transforms your `![image](./test.png)` into Next.js Image usage, and add required props like `width` and `height`.
By default, it uses Next.js static imports to import local images, which supports the `placeholder` option of Next.js Image.
Next.js can handle image imports with its built-in image loader.
```mdx
![Hello](./hello.png)
import MyImage from './public/image.png';
<img alt="image" src={MyImage} />
```
Otherwise, it obtains the image size from file-system or HTTP request.
```mdx
<img alt="image" src="/image.png" widht="1980" height="1080" />
```
### Options
```json doc-gen:typescript
{
"file": "./content/docs/headless/props.ts",
"name": "RemarkImageOptions"
}
```
### Example: With Imports
```mdx
![Hello](/hello.png)
![Test](https://example.com/image.png)
```
Yields:
```mdx
import HelloImage from './hello.png';
import HelloImage from './public/hello.png';
<img alt="Hello" src={HelloImage} />
<img
alt="Test"
src="https://example.com/image.png"
width="1980"
height="1080"
/>
```
You need an image loader to handle image imports.
In Next.js, this is already done by a Webpack/Turbopack plugin
### Example: Without Imports
You can disable Next.js static imports on local images.
```ts
import { remarkImage } from 'fumadocs-core/mdx-plugins';
export default {
remarkPlugins: [[remarkImage, { useImport: false }]],
};
```
```mdx
![Hello](/hello.png)
![Test](https://example.com/image.png)
```
Yields:
```mdx
<img alt="Hello" src"/hello.png" width="1980" height="1080" />
<img alt="Test" src"https://example.com/image.png" width="1980" height="1080" />
```
+3 -1
View File
@@ -3,7 +3,7 @@ import type * as TOC from 'fumadocs-core/toc';
import type * as Server from 'fumadocs-core/server';
import type * as Sidebar from 'fumadocs-core/sidebar';
import type { SortedResult as OriginalSortedResult } from 'fumadocs-core/search/shared';
import type { ComponentPropsWithoutRef, ElementType } from 'react';
import type { ElementType } from 'react';
import type * as MDX from 'fumadocs-core/mdx-plugins';
export type SortedResult = OriginalSortedResult;
@@ -24,3 +24,5 @@ export type PageTreeItem = Server.PageTree.Item;
export type PageTreeFolder = Server.PageTree.Folder;
export type PageTreeRoot = Server.PageTree.Root;
export type PageTreeSeparator = Server.PageTree.Separator;
export type RemarkImageOptions = MDX.RemarkImageOptions;
+7 -7
View File
@@ -29,7 +29,7 @@
"fumadocs-ui": "workspace:*",
"geist": "^1.3.1",
"katex": "^0.16.11",
"lucide-react": "^0.414.0",
"lucide-react": "^0.427.0",
"next": "^14.2.5",
"phenomenon": "^1.6.0",
"react": "^18.3.1",
@@ -37,8 +37,8 @@
"rehype-katex": "^7.0.0",
"remark-math": "^6.0.0",
"satori": "^0.10.14",
"shiki": "^1.11.1",
"tailwind-merge": "^2.4.0",
"shiki": "^1.12.1",
"tailwind-merge": "^2.5.2",
"zod": "^3.23.8"
},
"devDependencies": {
@@ -48,15 +48,15 @@
"@types/node": "20.14.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"autoprefixer": "^10.4.20",
"eslint-config-custom": "workspace:*",
"glslify": "^7.1.1",
"glslx": "^0.3.0",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"postcss": "^8.4.41",
"tailwindcss": "^3.4.9",
"tailwindcss-animate": "^1.0.7",
"tsconfig": "workspace:*",
"tsx": "^4.16.2",
"tsx": "^4.17.0",
"typescript": "^5.5.4",
"webpack": "^5.90.3"
}
+1 -1
View File
@@ -16,7 +16,7 @@
"react-dom": "18.3.1"
},
"devDependencies": {
"@content-collections/core": "^0.6.3",
"@content-collections/core": "^0.6.4",
"@content-collections/mdx": "^0.1.3",
"@content-collections/next": "^0.2.0",
"@types/mdx": "^2.0.13",
+2
View File
@@ -34,3 +34,5 @@ curl -XPOST "https://api.unkey.dev/v1/keys.createKey" \
-H "Content-Type: application/json" \
-d '{"apiId": "api_123", "name": "My Key"}'
```
![img](https://miro.medium.com/v2/resize:fit:4800/format:webp/1*qHtOm0c0IXsefOd2W7VECg.png)
+2 -2
View File
@@ -14,10 +14,10 @@
"fumadocs-openapi": "workspace:*",
"fumadocs-ui": "workspace:*",
"next": "^14.2.5",
"postcss": "^8.4.39",
"postcss": "^8.4.41",
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwindcss": "^3.4.6",
"tailwindcss": "^3.4.9",
"zod": "^3.23.8"
},
"devDependencies": {
+3 -3
View File
@@ -19,8 +19,8 @@
"prettier": "@vercel/style-guide/prettier",
"devDependencies": {
"@changesets/cli": "^2.27.7",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@typescript-eslint/eslint-plugin": "^8.1.0",
"@typescript-eslint/parser": "^8.1.0",
"@vercel/style-guide": "^6.0.0",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
@@ -30,7 +30,7 @@
"tsup": "8.2.4",
"turbo": "2.0.12",
"typescript": "^5.5.4",
"vitest": "^2.0.4"
"vitest": "^2.0.5"
},
"packageManager": "[email protected]",
"engines": {
+1 -1
View File
@@ -48,7 +48,7 @@
"rehype-img-size": "^1.0.1"
},
"devDependencies": {
"@content-collections/core": "^0.6.3",
"@content-collections/core": "^0.6.4",
"@content-collections/mdx": "^0.1.3",
"eslint-config-custom": "workspace:*",
"fumadocs-core": "workspace:*",
+4 -3
View File
@@ -127,10 +127,11 @@
},
"dependencies": {
"@formatjs/intl-localematcher": "^0.5.4",
"@shikijs/rehype": "^1.11.1",
"@shikijs/transformers": "^1.11.1",
"@shikijs/rehype": "^1.12.1",
"@shikijs/transformers": "^1.12.1",
"flexsearch": "0.7.21",
"github-slugger": "^2.0.0",
"image-size": "^1.1.1",
"negotiator": "^0.6.3",
"npm-to-yarn": "^2.2.1",
"react-remove-scroll": "^2.5.10",
@@ -138,7 +139,7 @@
"remark-gfm": "^4.0.0",
"remark-mdx": "^3.0.1",
"scroll-into-view-if-needed": "^3.1.0",
"shiki": "^1.11.1",
"shiki": "^1.12.1",
"swr": "^2.2.5",
"unist-util-visit": "^5.0.0"
},
+139 -57
View File
@@ -1,95 +1,155 @@
import { existsSync } from 'node:fs';
import path from 'node:path';
import type { Root, RootContent } from 'mdast';
import type { Transformer } from 'unified';
import { visit } from 'unist-util-visit';
import sizeOf from 'image-size';
import { type ISizeCalculationResult } from 'image-size/dist/types/interface';
import slash from '@/utils/slash';
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 {
/**
* Directory to resolve absolute image paths
*/
publicDir?: string;
/**
* Preferred placeholder type
*
* @defaultValue 'blur'
*/
placeholder?: 'blur' | 'none';
/**
* Import images in the file, and let bundlers handle it.
*
* ```tsx
* import MyImage from "./public/img.png";
*
* <img src={MyImage} />
* ```
*
* When disabled, `placeholder` will be ignored.
*
* @defaultValue true
*/
useImport?: boolean;
/**
* Fetch image size of external URLs
*
* @defaultValue true
*/
external?: boolean;
}
// Based on the Nextra: https://github.com/shuding/nextra
/**
* Turn images into static imports
* Turn images into Next.js Image compatible usage.
*/
export function remarkImage({
placeholder = 'blur',
external = true,
useImport = true,
publicDir = path.join(process.cwd(), 'public'),
}: RemarkImageOptions = {}): Transformer<Root, Root> {
return (tree, _file, done) => {
return async (tree) => {
const importsToInject: { variableName: string; importPath: string }[] = [];
const promises: Promise<void>[] = [];
visit(tree, 'image', (node) => {
let url = decodeURI(node.url);
const src = decodeURI(node.url);
if (!src) return;
const isExternal = EXTERNAL_URL_REGEX.test(src);
if (!url || EXTERNAL_URL_REGEX.test(url)) {
return;
}
if ((isExternal && external) || !useImport) {
promises.push(
getImageSize(src, publicDir).then((size) => {
if (!size.width || !size.height) return;
if (url.startsWith('/')) {
const urlPath = path.join(PUBLIC_DIR, url);
Object.assign(node, {
type: 'mdxJsxFlowElement',
name: 'img',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'alt',
value: node.alt ?? 'image',
},
{
type: 'mdxJsxAttribute',
name: 'src',
value: src,
},
{
type: 'mdxJsxAttribute',
name: 'width',
value: size.width.toString(),
},
{
type: 'mdxJsxAttribute',
name: 'height',
value: size.height.toString(),
},
],
});
}),
);
} else if (!isExternal) {
// Unique variable name for the given static image URL
const variableName = `__img${importsToInject.length.toString()}`;
const hasBlur =
placeholder === 'blur' &&
VALID_BLUR_EXT.some((ext) => src.endsWith(ext));
if (!existsSync(urlPath)) {
return;
}
importsToInject.push({
variableName,
importPath: slash(resolveSrc(src, publicDir)),
});
url = slash(urlPath);
}
// Unique variable name for the given static image URL
const variableName = `__img${importsToInject.length.toString()}`;
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 },
},
],
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),
});
].filter(Boolean),
});
}
});
if (importsToInject.length) {
await Promise.all(promises);
if (importsToInject.length > 0) {
const imports = importsToInject.map(
({ variableName, importPath }) =>
({
@@ -115,7 +175,29 @@ export function remarkImage({
tree.children.unshift(...imports);
}
done();
};
}
/**
* Resolve `src` to an absolute path
*/
function resolveSrc(src: string, dir: string): string {
return src.startsWith('/') || !path.isAbsolute(src)
? path.join(dir, src)
: src;
}
async function getImageSize(
src: string,
dir: string,
): Promise<ISizeCalculationResult> {
if (EXTERNAL_URL_REGEX.test(src)) {
const res = await fetch(src);
return sizeOf(
await res.arrayBuffer().then((buffer) => new Uint8Array(buffer)),
);
}
return sizeOf(resolveSrc(src, dir));
}
@@ -25,19 +25,21 @@ export function useAnchorObserver(watch: string[]): string | undefined {
return f ?? watch[0];
});
},
{ rootMargin: `-100px 0% -75% 0%`, threshold: 1 },
{ rootMargin: `-80px 0% -78% 0%`, threshold: 1 },
);
const scroll = (): void => {
const element = document.scrollingElement;
if (!element) return;
// select the last item when reached the bottom
if (
if (element.scrollTop === 0) {
setActiveAnchor(watch.at(0));
} else if (
element.scrollTop >=
// assume you have a 10px margin
element.scrollHeight - element.clientHeight - 10
) {
// select the last item when reached the bottom
setActiveAnchor(watch.at(-1));
}
};
@@ -0,0 +1,34 @@
import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
function _createMdxContent(props) {
const _components = {
img: "img",
p: "p",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.p, {
children: _jsx(_components.img, {
alt: "Test",
src: "./test.png",
width: "1299",
height: "731"
})
}), "\n", _jsx(_components.p, {
children: _jsx(_components.img, {
alt: "External",
src: "https://picsum.photos/id/237/200/300",
width: "200",
height: "300"
})
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}
+3
View File
@@ -0,0 +1,3 @@
![Test](./test.png)
![External](https://picsum.photos/id/237/200/300)
+34
View File
@@ -0,0 +1,34 @@
import {Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs} from "react/jsx-runtime";
import __img0 from "/Users/xred/dev/fumadocs/packages/core/test/fixtures/test.png";
function _createMdxContent(props) {
const _components = {
img: "img",
p: "p",
...props.components
};
return _jsxs(_Fragment, {
children: [_jsx(_components.p, {
children: _jsx(_components.img, {
alt: "Test",
placeholder: "blur",
src: __img0
})
}), "\n", _jsx(_components.p, {
children: _jsx(_components.img, {
alt: "External",
src: "https://picsum.photos/id/237/200/300",
width: "200",
height: "300"
})
})]
});
}
export default function MDXContent(props = {}) {
const {wrapper: MDXLayout} = props.components || ({});
return MDXLayout ? _jsx(MDXLayout, {
...props,
children: _jsx(_createMdxContent, {
...props
})
}) : _createMdxContent(props);
}

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 391 KiB

+31
View File
@@ -5,10 +5,12 @@ import { remark } from 'remark';
import {
remarkAdmonition,
remarkHeading,
remarkImage,
remarkStructure,
} from '@/mdx-plugins';
import { fileURLToPath } from 'node:url';
import remarkMdx from 'remark-mdx';
import { compile } from '@mdx-js/mdx';
const cwd = path.dirname(fileURLToPath(import.meta.url));
@@ -47,3 +49,32 @@ test('Remark Admonition', async () => {
path.resolve(cwd, './fixtures/remark-admonition.output.mdx'),
);
});
test('Remark Image', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/remark-image.md'));
const result = await compile(content, {
remarkPlugins: [
[remarkImage, { publicDir: path.resolve(cwd, './fixtures') }],
],
});
expect(result.value).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-image.output.js'),
);
});
test('Remark Image: Without Import', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/remark-image.md'));
const result = await compile(content, {
remarkPlugins: [
[
remarkImage,
{ publicDir: path.resolve(cwd, './fixtures'), useImport: false },
],
],
});
expect(result.value).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-image-without-import.output.js'),
);
});
+4 -4
View File
@@ -5,18 +5,18 @@
"description": "Used to track dependency versions in create-fumadocs-app",
"license": "MIT",
"dependencies": {
"@content-collections/core": "^0.6.3",
"@content-collections/core": "^0.6.4",
"@content-collections/mdx": "^0.1.3",
"@content-collections/next": "^0.2.0",
"@types/mdx": "^2.0.13",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.19",
"autoprefixer": "^10.4.20",
"next": "^14.2.5",
"postcss": "^8.4.39",
"postcss": "^8.4.41",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^3.4.6",
"tailwindcss": "^3.4.9",
"typescript": "^5.5.4"
}
}
+1 -1
View File
@@ -75,7 +75,7 @@
"@types/micromatch": "^4.0.9",
"@types/node": "20.14.12",
"@types/react": "^18.3.3",
"@types/ws": "^8.5.11",
"@types/ws": "^8.5.12",
"eslint-config-custom": "workspace:*",
"fumadocs-core": "workspace:*",
"next": "^14.2.5",
+5 -5
View File
@@ -51,7 +51,7 @@
"types:check": "tsc --noEmit"
},
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^11.6.4",
"@apidevtools/json-schema-ref-parser": "^11.7.0",
"@mdx-js/mdx": "^3.0.1",
"@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-slot": "^1.1.0",
@@ -63,11 +63,11 @@
"github-slugger": "^2.0.0",
"js-yaml": "^4.1.0",
"json-schema-to-typescript": "^15.0.0",
"lucide-react": "^0.414.0",
"lucide-react": "^0.427.0",
"openapi-sampler": "^1.5.1",
"react-hook-form": "^7.52.1",
"react-hook-form": "^7.52.2",
"remark": "^15.0.0",
"shiki": "^1.11.1",
"shiki": "^1.12.1",
"swr": "^2.2.5"
},
"devDependencies": {
@@ -75,7 +75,7 @@
"@types/node": "20.14.12",
"@types/openapi-sampler": "^1.0.3",
"@types/react": "^18.3.3",
"bunchee": "^5.3.1",
"bunchee": "^5.3.2",
"eslint-config-custom": "workspace:*",
"next": "^14.2.5",
"openapi-types": "^12.1.3",
+2 -2
View File
@@ -25,7 +25,7 @@
"types:check": "tsc --noEmit"
},
"dependencies": {
"@shikijs/twoslash": "^1.11.1",
"@shikijs/twoslash": "^1.12.1",
"mdast-util-from-markdown": "^2.0.1",
"mdast-util-gfm": "^3.0.0",
"mdast-util-to-hast": "^13.2.0"
@@ -35,7 +35,7 @@
"@types/mdast": "^4.0.3",
"eslint-config-custom": "workspace:*",
"fumadocs-ui": "workspace:*",
"shiki": "^1.11.1",
"shiki": "^1.12.1",
"tsconfig": "workspace:*"
},
"peerDependencies": {
+6 -6
View File
@@ -106,15 +106,15 @@
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-tabs": "^1.1.0",
"@tailwindcss/typography": "^0.5.13",
"@tailwindcss/typography": "^0.5.14",
"class-variance-authority": "^0.7.0",
"cmdk": "^1.0.0",
"fumadocs-core": "workspace:*",
"lucide-react": "^0.414.0",
"lucide-react": "^0.427.0",
"next-themes": "^0.3.0",
"react-medium-image-zoom": "^5.2.8",
"swr": "^2.2.5",
"tailwind-merge": "^2.4.0"
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@algolia/client-search": "^4.24.0",
@@ -124,10 +124,10 @@
"algoliasearch": "^4.24.0",
"eslint-config-custom": "workspace:*",
"next": "^14.2.5",
"postcss": "^8.4.39",
"postcss": "^8.4.41",
"postcss-cli": "^11.0.0",
"postcss-lightningcss": "^1.0.0",
"tailwindcss": "^3.4.6",
"postcss-lightningcss": "^1.0.1",
"tailwindcss": "^3.4.9",
"tsconfig": "workspace:*"
},
"peerDependencies": {
+8 -1
View File
@@ -12,11 +12,18 @@ import { Card, Cards } from '@/components/card';
import { Callout } from '@/components/callout';
import { Heading } from '@/components/heading';
import { defaultImageSizes } from '@/utils/shared';
import { cn } from '@/utils/cn';
declare const { Pre }: typeof import('./mdx.client');
function Image(props: ImgHTMLAttributes<HTMLImageElement>): React.ReactElement {
return <NextImage sizes={defaultImageSizes} {...(props as ImageProps)} />;
return (
<NextImage
sizes={defaultImageSizes}
{...(props as ImageProps)}
className={cn('rounded-lg', props.className)}
/>
);
}
function Table(
+1 -1
View File
@@ -2,7 +2,7 @@ import type { PageTree } from 'fumadocs-core/server';
import type { ReactNode } from 'react';
export const defaultImageSizes =
'(max-width: 768px) 100vw, (max-width: 1200px) 70vw, 800px';
'(max-width: 768px) 100vw, (max-width: 1200px) 70vw, 900px';
export function isActive(
url: string,
+600 -484
View File
File diff suppressed because it is too large Load Diff