Skip to content

Commit 8f075c8

Browse files
committed
refactor to fix major bugs
1 parent 3abdeb6 commit 8f075c8

File tree

6 files changed

+297
-226
lines changed

6 files changed

+297
-226
lines changed

.npmignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Page Intentionally Left Blank

.vscode/settings.json

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"typescript.tsdk": "node_modules/typescript/lib"
3+
}

package.json

+6-5
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
{
22
"name": "@helmturner/recma-next-static-images",
3-
"version": "1.0.3",
3+
"version": "1.1.0",
44
"type": "module",
55
"licenses": [
66
{
77
"type": "ISC",
88
"url": "https://opensource.org/licenses/ISC"
9-
}],
9+
}
10+
],
1011
"engines": {
1112
"node": ">=14.16"
1213
},
@@ -16,10 +17,10 @@
1617
"scripts": {
1718
"lint": "eslint --ext .js,.ts ./",
1819
"test": "echo \"Error: no test specified\" && exit 1",
19-
"build": "tsc && npm run lint && npm pack --pack-destination ./dist",
20+
"build": "tsc && npm run lint && npm pack",
2021
"prepublishOnly": "yarn run build",
21-
"publishUnstable": "npm publish ./dist/*.tgz --tag unstable",
22-
"publish": "npm publish ./dist/*.tgz"
22+
"publishUnstable": "npm publish --tag unstable",
23+
"publish": "npm publish"
2324
},
2425
"eslintConfig": {
2526
"env": {

src/index.ts

+169-115
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
/* eslint-disable unicorn/numeric-separators-style */
22
import node_fs from "node:fs";
3+
import async_node_fs from "node:fs/promises";
34
import node_path from "node:path";
45
import node_fetch from "node-fetch";
56
import node_crypto from "node:crypto";
67
import { visit, SKIP, CONTINUE } from "estree-util-visit";
8+
import { randomUUID } from "node:crypto";
79

810
import type * as NodeFetch from "node-fetch";
911
import type * as Unified from "unified";
1012
import type * as ESTreeJsx from "estree-jsx";
1113
import type * as TreeWalker from "estree-util-visit";
1214

15+
const uuid = () => randomUUID().replace(/-/g, "");
16+
1317
export type Options =
1418
| {
1519
cacheDirectory: string | undefined;
@@ -21,94 +25,195 @@ export type Options =
2125
| null
2226
| undefined;
2327

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+
2459
const recmaStaticImages: Unified.Plugin<
2560
[(Options | undefined | void)?],
2661
ESTreeJsx.Program,
2762
ESTreeJsx.Program
28-
> = function (options) {
63+
> = function (this, options) {
2964
// deconstruct options (if provided) and set defaults where applicable
3065
const { cacheDirectory, customFetch: _fetch = node_fetch } = options ?? {};
3166
if (!cacheDirectory) throw new Error("cacheDirectory is required");
32-
67+
let _cacheDirectory: string = cacheDirectory;
68+
if (!/^(\.\/)?public\/.*$/.test(cacheDirectory)) {
69+
console.warn(
70+
`cacheDirectory should be in the /public directory. Using public/${cacheDirectory} instead.}`
71+
);
72+
_cacheDirectory = `public/${cacheDirectory.replace(/^\.\//, "")}`;
73+
}
3374
// 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+
3577
if (!node_fs.existsSync(cache)) node_fs.mkdirSync(cache);
3678

79+
const images = new Map<ESTreeJsx.Property & {
80+
key: ESTreeJsx.Identifier & { name: "src" };
81+
value: ESTreeJsx.SimpleLiteral & { value: string }
82+
}, ImageData
83+
>();
84+
3785
return async function (tree, vfile) {
3886
if (!vfile.history[0])
3987
throw new Error(`File history is empty for ${vfile}`);
4088

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+
);
6294

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+
);
76112

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+
}})());
84157
}
85158

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+
})());
88185

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+
];
92199

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;
98201
}
202+
);
99203

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+
);
107208
};
108209
};
109-
110210
export default recmaStaticImages;
111211

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+
112217
function prependImportsToTree(
113218
tree: ESTreeJsx.Program,
114219
imports: (ESTreeJsx.ImportDeclaration | undefined)[]
@@ -137,18 +242,7 @@ function buildImageJsxFactoryTest(tree: ESTreeJsx.Program) {
137242
}
138243
return CONTINUE;
139244
});
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 {
152246
return (
153247
node.type === "CallExpression" &&
154248
"callee" in node &&
@@ -166,46 +260,6 @@ function sha256(data: node_crypto.BinaryLike) {
166260
return node_crypto.createHash("sha256").update(data).digest("hex");
167261
}
168262

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-
}
209263
/**
210264
* No async visitor is provided, so we must make our own.
211265
* @see https://github.com/syntax-tree/unist-util-visit-parents/issues/8
@@ -220,6 +274,6 @@ async function visitAsync<T extends TreeWalker.Node>(
220274
if (test(node)) matches.push(node);
221275
});
222276
const promises = matches.map((match) => asyncVisitor(match));
223-
await Promise.all(promises);
277+
await Promise.allSettled(promises);
224278
return;
225279
}

tsconfig.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
4747
"declarationMap": true, /* Create sourcemaps for d.ts files. */
4848
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
49-
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
49+
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
5050
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
5151
"outDir": "./dist", /* Specify an output folder for all emitted files. */
5252
"removeComments": false, /* Disable emitting comments. */

0 commit comments

Comments
 (0)