Core: Support URL format publicDir in Remark Image plugin

This commit is contained in:
Fuma Nama
2024-10-29 16:11:37 +08:00
parent 164b9e6d84
commit 1573d632ea
14 changed files with 187 additions and 112 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support URL format `publicDir` in Remark Image plugin
@@ -23,23 +23,12 @@ Supported:
### 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.
It transforms your `![image](/test.png)` into Next.js Image usage, and add required props like `width` and `height`.
By default, it uses **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
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" />
```
Otherwise, it uses the file system or an HTTP request to download the image and obtain its size.
### Options
@@ -69,6 +58,8 @@ import HelloImage from './public/hello.png';
/>
```
Where `./public/hello.png` points to the image in public directory.
### Example: Without Imports
You can disable Next.js static imports on local images.
@@ -97,3 +88,48 @@ Yields:
height="1080"
/>
```
### Example: Relative Paths
When `useImport` is enabled, you can reference local images using relative paths.
```mdx
![Hello](./hello.png)
```
Be careful that using it with `useImport` disabled **doesn't work**.
Next.js will not add the image to public assets unless you have imported it in code.
For images in public directory, you can just reference them without relative paths.
### Example: Public Directory
Customise the path of public directory
```ts
import { remarkImage } from 'fumadocs-core/mdx-plugins';
import path from 'node:path';
export default {
remarkPlugins: [
remarkImage,
{
publicDir: path.join(process.cwd(), 'dir'),
},
],
};
```
You can pass a URL too.
```ts
import { remarkImage } from 'fumadocs-core/mdx-plugins';
export default {
remarkPlugins: [
remarkImage,
{
publicDir: 'http://localhost:3000/images',
},
],
};
```
@@ -102,3 +102,10 @@ export default function Page({ params }: { params: { slug?: string[] } }) {
);
}
```
#### Images
On some platforms like Vercel, the original `public` folder (including static assets like images) will be removed after `next build`.
`compileMDX` might no longer be able to access local images in `public`.
When referencing images, make sure to use a URL.
+35 -33
View File
@@ -5,14 +5,14 @@ import { visit } from 'unist-util-visit';
import sizeOf from 'image-size';
import { type ISizeCalculationResult } from 'image-size/dist/types/interface';
import type { MdxjsEsm } from 'mdast-util-mdxjs-esm';
import slash from '@/utils/slash';
import { resolvePath, slash } from '@/utils/path';
const VALID_BLUR_EXT = ['.jpeg', '.png', '.webp', '.avif', '.jpg'];
const EXTERNAL_URL_REGEX = /^https?:\/\//;
export interface RemarkImageOptions {
/**
* Directory to resolve absolute image paths
* Directory or base URL to resolve absolute image paths
*/
publicDir?: string;
@@ -63,27 +63,25 @@ export function remarkImage({
function getImportPath(src: string): string {
if (!src.startsWith('/')) return src;
const to = path.join(publicDir, src);
if (file.path) {
const relative = path.relative(
path.dirname(file.path),
path.join(publicDir, src),
);
if (file.dirname) {
const relative = slash(path.relative(file.dirname, to));
return relative.startsWith('./') ? relative : `./${relative}`;
}
return path.join(publicDir, src);
return slash(to);
}
visit(tree, 'image', (node) => {
const src = decodeURI(node.url);
if (!src) return;
const isExternal = EXTERNAL_URL_REGEX.test(src);
const url = decodeURI(node.url);
if (!url) return;
const isExternal = EXTERNAL_URL_REGEX.test(url);
if ((isExternal && external) || !useImport) {
promises.push(
getImageSize(src, publicDir).then((size) => {
const task = getImageSize(url, publicDir)
.then((size) => {
if (!size.width || !size.height) return;
Object.assign(node, {
@@ -98,7 +96,7 @@ export function remarkImage({
{
type: 'mdxJsxAttribute',
name: 'src',
value: src,
value: url,
},
{
type: 'mdxJsxAttribute',
@@ -112,18 +110,24 @@ export function remarkImage({
},
],
});
}),
);
})
.catch(() => {
console.error(
`[Remark Image] Failed obtain image size for ${url} with public directory ${publicDir}`,
);
});
promises.push(task);
} 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));
VALID_BLUR_EXT.some((ext) => url.endsWith(ext));
importsToInject.push({
variableName,
importPath: slash(getImportPath(src)),
importPath: getImportPath(url),
});
Object.assign(node, {
@@ -194,26 +198,24 @@ export function remarkImage({
};
}
/**
* 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);
const isRelative = src.startsWith('/') || !path.isAbsolute(src);
let url: string;
return sizeOf(
await res.arrayBuffer().then((buffer) => new Uint8Array(buffer)),
);
if (EXTERNAL_URL_REGEX.test(src)) {
url = src;
} else if (EXTERNAL_URL_REGEX.test(dir) && isRelative) {
const base = new URL(dir);
base.pathname = resolvePath(base.pathname, src);
url = base.toString();
} else {
return sizeOf(isRelative ? path.join(dir, src) : src);
}
return sizeOf(resolveSrc(src, dir));
const buffer = await fetch(url).then((res) => res.arrayBuffer());
return sizeOf(new Uint8Array(buffer));
}
+2 -6
View File
@@ -1,10 +1,6 @@
import type { MetaData, PageData } from '@/source/types';
import {
parseFilePath,
parseFolderPath,
splitPath,
type FileInfo,
} from './path';
import { parseFilePath, parseFolderPath, type FileInfo } from './path';
import { splitPath } from '@/utils/path';
export interface MetaFile {
file: FileInfo;
@@ -3,7 +3,7 @@ import type { I18nConfig } from '@/i18n';
import { removeUndefined } from '@/utils/remove-undefined';
import type * as PageTree from '../server/page-tree';
import type { File, Folder, MetaFile, PageFile, Storage } from './file-system';
import { resolvePath } from './path';
import { resolvePath } from '@/utils/path';
import { type UrlFn } from './types';
interface PageTreeBuilderContext {
+1 -41
View File
@@ -1,4 +1,4 @@
import slash from '@/utils/slash';
import { slash, splitPath } from '@/utils/path';
export interface FileInfo {
/**
@@ -82,43 +82,3 @@ export function normalizePath(path: string): string {
throw new Error("It must not start with './' or '../'");
return segments.join('/');
}
/**
* Split path into segments, trailing/leading slashes are removed
*/
export function splitPath(path: string): string[] {
return path.split('/').filter((p) => p.length > 0);
}
/**
* Resolve paths, slashes within the path will be ignored
* @param from - Path to resolve from
* @param join - Paths to resolve
* @example
* ```
* ['a','b'] // 'a/b'
* ['/a'] // 'a'
* ['a', '/b'] // 'a/b'
* ['a', '../b/c'] // 'b/c'
* ```
*/
export function resolvePath(from: string, join: string): string {
const v1 = splitPath(from),
v2 = splitPath(join);
while (v2.length > 0) {
switch (v2[0]) {
case '..':
v1.pop();
break;
case '.':
break;
default:
v1.push(v2[0]);
}
v2.shift();
}
return v1.join('/');
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Split path into segments, trailing/leading slashes are removed
*/
export function splitPath(path: string): string[] {
return path.split('/').filter((p) => p.length > 0);
}
/**
* Resolve paths, slashes within the path will be ignored
* @param from - Path to resolve from
* @param join - Paths to resolve
* @example
* ```
* ['a','b'] // 'a/b'
* ['/a'] // 'a'
* ['a', '/b'] // 'a/b'
* ['a', '../b/c'] // 'b/c'
* ```
*/
export function resolvePath(from: string, join: string): string {
const v1 = splitPath(from),
v2 = splitPath(join);
while (v2.length > 0) {
switch (v2[0]) {
case '..':
v1.pop();
break;
case '.':
break;
default:
v1.push(v2[0]);
}
v2.shift();
}
return v1.join('/');
}
export function slash(path: string): string {
const isExtendedLengthPath = path.startsWith('\\\\?\\');
if (isExtendedLengthPath) {
return path;
}
return path.replaceAll('\\', '/');
}
-9
View File
@@ -1,9 +0,0 @@
export default function slash(path: string): string {
const isExtendedLengthPath = path.startsWith('\\\\?\\');
if (isExtendedLengthPath) {
return path;
}
return path.replaceAll('\\', '/');
}
@@ -0,0 +1 @@
![External](/237/200/300)
@@ -0,0 +1 @@
<img alt="External" src="/237/200/300" width="200" height="300" />
+2 -6
View File
@@ -1,14 +1,10 @@
import {
resolvePath,
parseFilePath,
parseFolderPath,
splitPath,
} from '@/source/path';
import { parseFilePath, parseFolderPath } from '@/source/path';
import { describe, expect, test } from 'vitest';
import type { Root } from '@/server/page-tree';
import { findNeighbour } from '@/server/page-tree-utils';
import { PageTree } from '../dist/server';
import { getBreadcrumbItems } from '@/breadcrumb';
import { resolvePath, splitPath } from '@/utils/path';
test('Find Neighbours', () => {
const tree: Root = {
+33
View File
@@ -68,6 +68,22 @@ test('Remark Image', async () => {
);
});
test('Remark Image: With Path', async () => {
const file = path.resolve(cwd, './fixtures/remark-image.md');
const content = readFileSync(file);
const processor = remark()
.use(remarkImage, { publicDir: path.resolve(cwd, './fixtures') })
.use(remarkMdx);
const result = await processor.run(processor.parse(content), {
path: file,
});
expect(result).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-image.output.json'),
);
});
test('Remark Image: Without Import', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/remark-image.md'));
const result = await remark()
@@ -83,6 +99,23 @@ test('Remark Image: Without Import', async () => {
);
});
test('Remark Image: `publicDir` with URL', async () => {
const content = readFileSync(
path.resolve(cwd, './fixtures/remark-image-public-dir.md'),
);
const result = await remark()
.use(remarkImage, {
publicDir: 'https://picsum.photos/id',
useImport: false,
})
.use(remarkMdx)
.process(content);
expect(result.value).toMatchFileSnapshot(
path.resolve(cwd, './fixtures/remark-image-public-dir.output.mdx'),
);
});
test('Rehype Toc', async () => {
const content = readFileSync(path.resolve(cwd, './fixtures/rehype-toc.md'));
-2
View File
@@ -81,8 +81,6 @@ const cases = [
for (const { name, collection, global } of cases) {
test(`generate JS index file: ${name}`, async () => {
console.log(global);
const out = await generateJS(
path.join(file, './fixtures/config.ts'),
{