Skip to content

Commit 60ec68c

Browse files
author
Dimitri POSTOLOV
authoredJan 15, 2024
[v3] support importing images by markdown image definitions (#2631)
* add tests * upd * add another test * add test * aa * bbb * bbb * fix * aa * aa * add changeset * prettier * Update .changeset/hungry-planets-repeat.md
1 parent c74ae90 commit 60ec68c

File tree

5 files changed

+191
-19
lines changed

5 files changed

+191
-19
lines changed
 

‎.changeset/hungry-planets-repeat.md

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'nextra': minor
3+
---
4+
5+
improvements for remarkStaticImage:
6+
7+
- import same image only once
8+
- support importing images by markdown image definitions

‎.eslintrc.cjs

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ module.exports = {
6060
'unicorn/prefer-at': 'error',
6161
'sonarjs/no-small-switch': 'error',
6262
'prefer-const': ['error', { destructuring: 'all' }],
63+
'unicorn/prefer-array-index-of': 'error',
6364
// todo: enable
6465
'@typescript-eslint/no-explicit-any': 'off',
6566
'@typescript-eslint/no-non-null-assertion': 'off',
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
1+
## SVG
2+
13
![](/swr-logo.svg)
24

3-
![](/favicon/android-chrome-512x512.png)
5+
## Link Definition
6+
7+
![][link-def]
8+
9+
[link-def]: /favicon/android-chrome-192x192.png
10+
11+
## Absolute Import
12+
13+
![](/favicon/android-chrome-192x192.png)
14+
15+
## Relative Import
416

5-
![](../../../../public/favicon/android-chrome-512x512.png)
17+
![](../../../../public/favicon/android-chrome-192x192.png)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { clean } from '../../../../__test__/test-utils.js'
2+
import { compileMdx } from '../../compile.js'
3+
4+
describe('remarkStaticImages', () => {
5+
it('should insert same import only once', async () => {
6+
const { result } = await compileMdx(
7+
`
8+
![](../foo.png)
9+
10+
![](../bar.jpeg)
11+
12+
![](../foo.png)`,
13+
{
14+
mdxOptions: {
15+
jsx: true,
16+
outputFormat: 'program'
17+
},
18+
staticImage: true
19+
}
20+
)
21+
22+
expect(clean(result)).resolves.toMatchInlineSnapshot(`
23+
"import { useMDXComponents as _provideComponents } from 'nextra/mdx'
24+
import __img0 from '../foo.png'
25+
import __img1 from '../bar.jpeg'
26+
const title = ''
27+
const frontMatter = {}
28+
export function useTOC(props) {
29+
return []
30+
}
31+
function MDXLayout(props) {
32+
const _components = {
33+
img: 'img',
34+
p: 'p',
35+
..._provideComponents(),
36+
...props.components
37+
}
38+
return (
39+
<>
40+
<_components.p>
41+
<_components.img placeholder=\\"blur\\" src={__img0} />
42+
</_components.p>
43+
{'\\\\n'}
44+
<_components.p>
45+
<_components.img placeholder=\\"blur\\" src={__img1} />
46+
</_components.p>
47+
{'\\\\n'}
48+
<_components.p>
49+
<_components.img placeholder=\\"blur\\" src={__img0} />
50+
</_components.p>
51+
</>
52+
)
53+
}
54+
"
55+
`)
56+
})
57+
58+
it('should work with link definitions', async () => {
59+
const { result } = await compileMdx(
60+
`
61+
![One][link-def]
62+
63+
![](../foo.png)
64+
65+
![](./bar.svg)
66+
67+
![Two][link-def]
68+
69+
![External][external-link-def]
70+
71+
[link-def]: ../foo.png
72+
[external-link-def]: https://foo.png`,
73+
{
74+
mdxOptions: {
75+
jsx: true,
76+
outputFormat: 'program'
77+
},
78+
staticImage: true
79+
}
80+
)
81+
82+
expect(clean(result)).resolves.toMatchInlineSnapshot(`
83+
"import { useMDXComponents as _provideComponents } from 'nextra/mdx'
84+
import __img0 from '../foo.png'
85+
import __img1 from './bar.svg'
86+
const title = ''
87+
const frontMatter = {}
88+
export function useTOC(props) {
89+
return []
90+
}
91+
function MDXLayout(props) {
92+
const _components = {
93+
img: 'img',
94+
p: 'p',
95+
..._provideComponents(),
96+
...props.components
97+
}
98+
return (
99+
<>
100+
<_components.p>
101+
<_components.img alt=\\"One\\" placeholder=\\"blur\\" src={__img0} />
102+
</_components.p>
103+
{'\\\\n'}
104+
<_components.p>
105+
<_components.img placeholder=\\"blur\\" src={__img0} />
106+
</_components.p>
107+
{'\\\\n'}
108+
<_components.p>
109+
<_components.img src={__img1} />
110+
</_components.p>
111+
{'\\\\n'}
112+
<_components.p>
113+
<_components.img alt=\\"Two\\" placeholder=\\"blur\\" src={__img0} />
114+
</_components.p>
115+
{'\\\\n'}
116+
<_components.p>
117+
<_components.img src=\\"https://foo.png\\" alt=\\"External\\" />
118+
</_components.p>
119+
</>
120+
)
121+
}
122+
"
123+
`)
124+
})
125+
})

‎packages/nextra/src/server/remark-plugins/remark-static-image.ts

+43-17
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import path from 'node:path'
22
import type { ImportDeclaration } from 'estree'
3-
import fs from 'graceful-fs'
4-
import type { Root } from 'mdast'
3+
import type { Definition, Image, ImageReference, Root } from 'mdast'
54
import slash from 'slash'
65
import type { Plugin } from 'unified'
76
import { visit } from 'unist-util-visit'
@@ -14,14 +13,31 @@ import { truthy } from '../utils.js'
1413
*/
1514
const VALID_BLUR_EXT = ['.jpeg', '.png', '.webp', '.avif', '.jpg']
1615

16+
const VARIABLE_PREFIX = '__img'
17+
1718
// Based on the remark-embed-images project
1819
// https://github.com/remarkjs/remark-embed-images
1920
export const remarkStaticImage: Plugin<[], Root> = () => ast => {
20-
const importsToInject: { variableName: string; importPath: string }[] = []
21+
const definitionNodes: Definition[] = []
22+
23+
const imageImports = new Set<string>()
24+
const imageNodes: (Image | ImageReference)[] = []
25+
26+
visit(ast, 'definition', node => {
27+
definitionNodes.push(node)
28+
})
2129

22-
visit(ast, 'image', node => {
30+
visit(ast, ['image', 'imageReference'], _node => {
31+
const node = _node as Image | ImageReference
2332
// https://github.com/shuding/nextra/issues/1344
24-
let url = decodeURI(node.url)
33+
let url = decodeURI(
34+
node.type === 'image'
35+
? node.url
36+
: definitionNodes.find(
37+
definition => definition.identifier === node.identifier
38+
)?.url ?? ''
39+
)
40+
2541
if (!url) {
2642
return
2743
}
@@ -33,15 +49,22 @@ export const remarkStaticImage: Plugin<[], Root> = () => ast => {
3349

3450
if (url.startsWith('/')) {
3551
const urlPath = path.join(PUBLIC_DIR, url)
36-
if (!fs.existsSync(urlPath)) {
37-
return
38-
}
3952
url = slash(urlPath)
4053
}
41-
// Unique variable name for the given static image URL
42-
const variableName = `__img${importsToInject.length}`
54+
imageImports.add(url)
55+
// @ts-expect-error -- we assign explicitly
56+
node.url = url
57+
imageNodes.push(node)
58+
})
59+
60+
const imageUrls = [...imageImports]
61+
62+
for (const node of imageNodes) {
63+
// @ts-expect-error -- we assigned explicitly
64+
const { url } = node
65+
const imageIndex = imageUrls.indexOf(url)
66+
const variableName = `${VARIABLE_PREFIX}${imageIndex}`
4367
const hasBlur = VALID_BLUR_EXT.some(ext => url.endsWith(ext))
44-
importsToInject.push({ variableName, importPath: url })
4568
// Replace the image node with an MDX component node (Next.js Image)
4669
Object.assign(node, {
4770
type: 'mdxJsxFlowElement',
@@ -78,24 +101,27 @@ export const remarkStaticImage: Plugin<[], Root> = () => ast => {
78101
}
79102
].filter(truthy)
80103
})
81-
})
104+
}
82105

83-
if (importsToInject.length) {
106+
if (imageUrls.length) {
84107
ast.children.unshift(
85-
...importsToInject.map(
86-
({ variableName, importPath }) =>
108+
...imageUrls.map(
109+
(imageUrl, index) =>
87110
({
88111
type: 'mdxjsEsm',
89112
data: {
90113
estree: {
91114
body: [
92115
{
93116
type: 'ImportDeclaration',
94-
source: { type: 'Literal', value: importPath },
117+
source: { type: 'Literal', value: imageUrl },
95118
specifiers: [
96119
{
97120
type: 'ImportDefaultSpecifier',
98-
local: { type: 'Identifier', name: variableName }
121+
local: {
122+
type: 'Identifier',
123+
name: `${VARIABLE_PREFIX}${index}`
124+
}
99125
}
100126
]
101127
} satisfies ImportDeclaration

0 commit comments

Comments
 (0)
Please sign in to comment.