1
1
/* eslint-disable unicorn/numeric-separators-style */
2
2
import node_fs from "node:fs" ;
3
+ import async_node_fs from "node:fs/promises" ;
3
4
import node_path from "node:path" ;
4
5
import node_fetch from "node-fetch" ;
5
6
import node_crypto from "node:crypto" ;
6
7
import { visit , SKIP , CONTINUE } from "estree-util-visit" ;
8
+ import { randomUUID } from "node:crypto" ;
7
9
8
10
import type * as NodeFetch from "node-fetch" ;
9
11
import type * as Unified from "unified" ;
10
12
import type * as ESTreeJsx from "estree-jsx" ;
11
13
import type * as TreeWalker from "estree-util-visit" ;
12
14
15
+ const uuid = ( ) => randomUUID ( ) . replace ( / - / g, "" ) ;
16
+
13
17
export type Options =
14
18
| {
15
19
cacheDirectory : string | undefined ;
@@ -21,94 +25,195 @@ export type Options =
21
25
| null
22
26
| undefined ;
23
27
28
+ declare module "vfile" {
29
+ interface DataMap {
30
+ staticImages : {
31
+ properties : ( ESTreeJsx . Property | ESTreeJsx . SpreadElement ) [ ] ;
32
+ declarations : ( ESTreeJsx . ImportDeclaration | undefined ) [ ] ;
33
+ sourceMap : Map < string , string > ;
34
+ } ;
35
+ }
36
+ }
37
+
38
+ type ImageJsxFactory = ESTreeJsx . SimpleCallExpression & {
39
+ callee : ESTreeJsx . Identifier ;
40
+ arguments : [
41
+ component : ESTreeJsx . MemberExpression & {
42
+ property : ESTreeJsx . Identifier & { name : "img" } ;
43
+ } ,
44
+ children : ESTreeJsx . ObjectExpression ,
45
+ ...rest : ( ESTreeJsx . Expression | ESTreeJsx . SpreadElement ) [ ]
46
+ ] ;
47
+ } ;
48
+
49
+ type ImageData = {
50
+ uuid : string ;
51
+ replacementSourcePropertyNode : ESTreeJsx . Property ;
52
+ fileExtension : string ;
53
+ importedAs : string ;
54
+ importedFrom : string | undefined ;
55
+ buffer : Buffer | undefined ;
56
+ importDeclaration : ESTreeJsx . ImportDeclaration | undefined ;
57
+ } ;
58
+
24
59
const recmaStaticImages : Unified . Plugin <
25
60
[ ( Options | undefined | void ) ?] ,
26
61
ESTreeJsx . Program ,
27
62
ESTreeJsx . Program
28
- > = function ( options ) {
63
+ > = function ( this , options ) {
29
64
// deconstruct options (if provided) and set defaults where applicable
30
65
const { cacheDirectory, customFetch : _fetch = node_fetch } = options ?? { } ;
31
66
if ( ! cacheDirectory ) throw new Error ( "cacheDirectory is required" ) ;
32
-
67
+ let _cacheDirectory : string = cacheDirectory ;
68
+ if ( ! / ^ ( \. \/ ) ? p u b l i c \/ .* $ / . test ( cacheDirectory ) ) {
69
+ console . warn (
70
+ `cacheDirectory should be in the /public directory. Using public/${ cacheDirectory } instead.}`
71
+ ) ;
72
+ _cacheDirectory = `public/${ cacheDirectory . replace ( / ^ \. \/ / , "" ) } ` ;
73
+ }
33
74
// resolve the cache directory and remove trailing slashes; make sure it exists
34
- const cache = node_path . resolve ( cacheDirectory ) . replace ( / \/ + $ / , "" ) ;
75
+ const cache = node_path . resolve ( _cacheDirectory ) . replace ( / \/ + $ / , "" ) ;
76
+
35
77
if ( ! node_fs . existsSync ( cache ) ) node_fs . mkdirSync ( cache ) ;
36
78
79
+ const images = new Map < ESTreeJsx . Property & {
80
+ key : ESTreeJsx . Identifier & { name : "src" } ;
81
+ value : ESTreeJsx . SimpleLiteral & { value : string }
82
+ } , ImageData
83
+ > ( ) ;
84
+
37
85
return async function ( tree , vfile ) {
38
86
if ( ! vfile . history [ 0 ] )
39
87
throw new Error ( `File history is empty for ${ vfile } ` ) ;
40
88
41
- let imageCounter = 0 ;
42
- const sourceDirectory = vfile . history [ 0 ] . replace ( / [ ^ / ] * $ / , "" ) ;
43
- const imports : ( ESTreeJsx . ImportDeclaration | undefined ) [ ] = [ ] ;
44
- const isImageJsxFactory = buildImageJsxFactoryTest ( tree ) ;
45
-
46
- await visitAsync ( tree , isImageJsxFactory , async function ( node ) {
47
- const [ argument0 , argument1 , ...rest ] = node . arguments ;
48
- const newProperties : ( ESTreeJsx . Property | ESTreeJsx . SpreadElement ) [ ] =
49
- [ ] ;
50
-
51
- for ( const property of argument1 . properties ) {
52
- if (
53
- property . type !== "Property" ||
54
- property . key . type !== "Identifier" ||
55
- property . key . name !== "src" ||
56
- property . value . type !== "Literal" ||
57
- typeof property . value . value !== "string"
58
- ) {
59
- newProperties . push ( property ) ;
60
- continue ;
61
- }
89
+ vfile . path = vfile . history [ 0 ] ;
90
+ vfile . dirname = node_path . dirname ( vfile . path ) ;
91
+ vfile . info (
92
+ `Processing ${ vfile . path } with history: ${ vfile . history . join ( ", " ) } `
93
+ ) ;
62
94
63
- imageCounter += 1 ;
64
- const value = property . value . value ;
65
- let url : URL | undefined ;
66
- let buffer : Buffer | undefined ;
67
-
68
- try {
69
- // will fail for relative paths
70
- url = new URL ( value ) ;
71
- } catch {
72
- // handle relative paths
73
- const source = node_path . resolve ( sourceDirectory , value ) ;
74
- buffer = node_fs . readFileSync ( source ) ;
75
- }
95
+ await visitAsync (
96
+ tree ,
97
+ buildImageJsxFactoryTest ( tree ) ,
98
+ async function ( node ) {
99
+ const previousSourcePropertyNode = node . arguments [ 1 ] . properties . find (
100
+ (
101
+ property
102
+ ) : property is ESTreeJsx . Property & {
103
+ key : ESTreeJsx . Identifier & { name : "src" } ;
104
+ value : ESTreeJsx . SimpleLiteral & { value : string } ;
105
+ } =>
106
+ property . type === "Property" &&
107
+ property . key . type === "Identifier" &&
108
+ property . key . name === "src" &&
109
+ property . value . type === "Literal" &&
110
+ typeof property . value . value === "string"
111
+ ) ;
76
112
77
- if ( url ) {
78
- const chunks = await _fetch ( url . href ) . then ( ( r ) => {
79
- if ( r . status !== 200 )
80
- throw new Error ( `Failed to fetch ${ url ?. href } ` ) ;
81
- return r . arrayBuffer ( ) ;
82
- } ) ;
83
- buffer = Buffer . from ( chunks ) ;
113
+ if ( ! previousSourcePropertyNode ) return SKIP ;
114
+
115
+ if ( ! images . has ( previousSourcePropertyNode ) ) {
116
+ images . set ( previousSourcePropertyNode , await ( async ( ) => {
117
+ const id = uuid ( ) ;
118
+ const source = previousSourcePropertyNode . value . value ;
119
+ return {
120
+ uuid : id ,
121
+ fileExtension : node_path
122
+ . extname ( source )
123
+ . replace ( / ( \? | # ) .* $ / , "" ) ,
124
+ importedAs : `__RecmaStaticImage${ id } ` ,
125
+ replacementSourcePropertyNode : {
126
+ ...previousSourcePropertyNode ,
127
+ value : {
128
+ type : "Identifier" ,
129
+ name : `__RecmaStaticImage${ id } `
130
+ }
131
+ } ,
132
+ buffer : await ( async ( ) => {
133
+ let url : URL | undefined ;
134
+ try {
135
+ // will fail for relative paths
136
+ url = new URL ( source ) ;
137
+ } catch {
138
+ // handle relative paths
139
+ const _source = node_path . resolve (
140
+ assertAndReturn ( vfile . dirname ) ,
141
+ source
142
+ ) ;
143
+ return async_node_fs . readFile ( _source ) ;
144
+ }
145
+ if ( ! url ) return ;
146
+ return _fetch ( url . href )
147
+ . then ( ( r ) => {
148
+ if ( r . status !== 200 )
149
+ throw new Error ( `Failed to fetch ${ url ?. href } ` ) ;
150
+ return r . arrayBuffer ( ) ;
151
+ } )
152
+ . then ( ( r ) => Buffer . from ( r ) ) ;
153
+ } ) ( ) ,
154
+ importedFrom : undefined ,
155
+ importDeclaration : undefined ,
156
+ } } ) ( ) ) ;
84
157
}
85
158
86
- if ( ! buffer )
87
- throw new Error ( `Failed to read the file from ${ url ?. href } ` ) ;
159
+ images . set ( previousSourcePropertyNode , await ( async ( ) => {
160
+ const previous = assertAndReturn ( images . get ( previousSourcePropertyNode ) ) ;
161
+ const _buffer = assertAndReturn ( previous . buffer ) ;
162
+ const _source = `${ cache } /${ sha256 ( _buffer ) } ${ previous . fileExtension } ` ;
163
+ await async_node_fs . writeFile ( _source , _buffer ) ;
164
+ return {
165
+ ...previous ,
166
+ importedFrom : _source ,
167
+ importDeclaration : {
168
+ source : {
169
+ type : "Literal" ,
170
+ value : _source ,
171
+ } ,
172
+ specifiers : [
173
+ {
174
+ type : "ImportDefaultSpecifier" ,
175
+ local : {
176
+ name : assertAndReturn ( previous . importedAs ) ,
177
+ type : "Identifier" ,
178
+ } ,
179
+ } ,
180
+ ] ,
181
+ type : "ImportDeclaration" ,
182
+ }
183
+ } ;
184
+ } ) ( ) ) ;
88
185
89
- const extension = node_path . extname ( value ) . replace ( / ( \? | # ) .* $ / , "" ) ;
90
- const path = `${ cache } /${ sha256 ( buffer ) } ${ extension } ` ;
91
- const declaration = generateImportDeclaration ( path , imageCounter ) ;
186
+ node . arguments = [
187
+ node . arguments [ 0 ] ,
188
+ {
189
+ ...node . arguments [ 1 ] ,
190
+ properties : node . arguments [ 1 ] . properties . map ( ( property ) => {
191
+ if ( images . has ( property as typeof previousSourcePropertyNode ) ) {
192
+ return assertAndReturn ( images . get ( property as typeof previousSourcePropertyNode ) )
193
+ . replacementSourcePropertyNode ;
194
+ }
195
+ return property ;
196
+ } )
197
+ }
198
+ ] ;
92
199
93
- imports . push ( declaration ) ;
94
- newProperties . push ( buildSrcPropertyNode ( imageCounter ) ) ;
95
- node_fs . writeFile ( path , buffer , ( error ) => {
96
- if ( error ) throw error ;
97
- } ) ;
200
+ return SKIP ;
98
201
}
202
+ ) ;
99
203
100
- node . arguments = [
101
- argument0 ,
102
- { ...argument1 , properties : newProperties } ,
103
- ...rest ,
104
- ] ;
105
- } ) ;
106
- prependImportsToTree ( tree , imports ) ;
204
+ await prependImportsToTree (
205
+ tree ,
206
+ [ ...images . values ( ) ] . map ( ( image ) => image . importDeclaration )
207
+ ) ;
107
208
} ;
108
209
} ;
109
-
110
210
export default recmaStaticImages ;
111
211
212
+ function assertAndReturn < T > ( value : T | null | undefined ) : T {
213
+ if ( value === null || value === undefined ) throw new Error ( "Unexpected null" ) ;
214
+ return value ;
215
+ }
216
+
112
217
function prependImportsToTree (
113
218
tree : ESTreeJsx . Program ,
114
219
imports : ( ESTreeJsx . ImportDeclaration | undefined ) [ ]
@@ -137,18 +242,7 @@ function buildImageJsxFactoryTest(tree: ESTreeJsx.Program) {
137
242
}
138
243
return CONTINUE ;
139
244
} ) ;
140
- return function (
141
- node : TreeWalker . Node
142
- ) : node is ESTreeJsx . SimpleCallExpression & {
143
- callee : ESTreeJsx . Identifier ;
144
- arguments : [
145
- component : ESTreeJsx . MemberExpression & {
146
- property : ESTreeJsx . Identifier & { name : "img" } ;
147
- } ,
148
- children : ESTreeJsx . ObjectExpression ,
149
- ...rest : ( ESTreeJsx . Expression | ESTreeJsx . SpreadElement ) [ ]
150
- ] ;
151
- } {
245
+ return function ( node : TreeWalker . Node ) : node is ImageJsxFactory {
152
246
return (
153
247
node . type === "CallExpression" &&
154
248
"callee" in node &&
@@ -166,46 +260,6 @@ function sha256(data: node_crypto.BinaryLike) {
166
260
return node_crypto . createHash ( "sha256" ) . update ( data ) . digest ( "hex" ) ;
167
261
}
168
262
169
- function generateImportDeclaration (
170
- path : string ,
171
- index : number
172
- ) : ESTreeJsx . ImportDeclaration {
173
- return {
174
- source : {
175
- type : "Literal" ,
176
- value : path ,
177
- } ,
178
- specifiers : [
179
- {
180
- type : "ImportDefaultSpecifier" ,
181
- local : {
182
- name : `static_image_${ index } ` ,
183
- type : "Identifier" ,
184
- } ,
185
- } ,
186
- ] ,
187
- type : "ImportDeclaration" ,
188
- } ;
189
- }
190
-
191
- // eslint-disable-next-line unicorn/prevent-abbreviations
192
- function buildSrcPropertyNode ( index : number ) : ESTreeJsx . Property {
193
- return {
194
- type : "Property" ,
195
- key : {
196
- type : "Identifier" ,
197
- name : "src" ,
198
- } ,
199
- value : {
200
- type : "Identifier" ,
201
- name : `static_image_${ index } ` ,
202
- } ,
203
- kind : "init" ,
204
- method : false ,
205
- shorthand : false ,
206
- computed : false ,
207
- } ;
208
- }
209
263
/**
210
264
* No async visitor is provided, so we must make our own.
211
265
* @see https://github.com/syntax-tree/unist-util-visit-parents/issues/8
@@ -220,6 +274,6 @@ async function visitAsync<T extends TreeWalker.Node>(
220
274
if ( test ( node ) ) matches . push ( node ) ;
221
275
} ) ;
222
276
const promises = matches . map ( ( match ) => asyncVisitor ( match ) ) ;
223
- await Promise . all ( promises ) ;
277
+ await Promise . allSettled ( promises ) ;
224
278
return ;
225
279
}
0 commit comments