@uxf/resizer 11.125.0 → 11.129.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.resizer-config.json +4 -4
- package/README.md +35 -25
- package/bin/uxf-resizer.js +20 -20
- package/bin/uxf-resizer.ts +19 -17
- package/package.json +4 -9
- package/src/handler.d.ts +10 -0
- package/src/handler.js +90 -0
- package/src/handler.test.js +65 -0
- package/src/handler.test.ts +79 -0
- package/src/handler.ts +121 -0
- package/src/utils/get-source-filename.d.ts +1 -1
- package/src/utils/get-source-filename.js +14 -15
- package/src/utils/get-source-filename.test.js +21 -0
- package/src/utils/get-source-filename.test.ts +33 -0
- package/src/utils/get-source-filename.ts +16 -16
- package/src/utils/match-route.d.ts +13 -0
- package/src/utils/match-route.js +38 -0
- package/src/utils/match-route.test.d.ts +1 -0
- package/src/utils/match-route.test.js +79 -0
- package/src/utils/match-route.test.ts +94 -0
- package/src/utils/match-route.ts +41 -0
- package/src/utils/tools.d.ts +10 -10
- package/src/utils/tools.js +2 -2
- package/src/utils/tools.test.d.ts +1 -0
- package/src/utils/tools.test.js +46 -0
- package/src/utils/tools.test.ts +48 -0
- package/src/utils/tools.ts +12 -11
- package/src/middleware.d.ts +0 -5
- package/src/middleware.js +0 -92
- package/src/middleware.ts +0 -73
- package/src/utils/parse-http-source.d.ts +0 -7
- package/src/utils/parse-http-source.js +0 -7
- package/src/utils/parse-http-source.test.js +0 -26
- package/src/utils/parse-http-source.test.ts +0 -25
- package/src/utils/parse-http-source.ts +0 -9
- package/src/utils/repair-params.d.ts +0 -8
- package/src/utils/repair-params.js +0 -19
- package/src/utils/repair-params.ts +0 -15
- /package/src/{utils/parse-http-source.test.d.ts → handler.test.d.ts} +0 -0
package/src/handler.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import { copyFile, mkdir, readFile, stat, writeFile } from "fs/promises";
|
|
3
|
+
import { dirname } from "path";
|
|
4
|
+
import { getSourceFilename } from "./utils/get-source-filename";
|
|
5
|
+
import { convertImage } from "./utils/image-converter";
|
|
6
|
+
import { log } from "./utils/log";
|
|
7
|
+
import { createRouteMatcher } from "./utils/match-route";
|
|
8
|
+
import { CONTENT_TYPES, Params } from "./utils/tools";
|
|
9
|
+
|
|
10
|
+
export type Config = Array<{
|
|
11
|
+
route: string;
|
|
12
|
+
source: string;
|
|
13
|
+
}>;
|
|
14
|
+
|
|
15
|
+
interface CompiledRoute {
|
|
16
|
+
match: (pathname: string) => Params | undefined;
|
|
17
|
+
source: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface MatchedRoute {
|
|
21
|
+
params: Params;
|
|
22
|
+
source: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function matchRoute(routes: CompiledRoute[], pathname: string): MatchedRoute | undefined {
|
|
26
|
+
for (const route of routes) {
|
|
27
|
+
const params = route.match(pathname);
|
|
28
|
+
if (params !== undefined) {
|
|
29
|
+
return { params, source: route.source };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function fileResponse(filename: string, contentType: string): Promise<Response> {
|
|
37
|
+
const [data, stats] = await Promise.all([readFile(filename), stat(filename)]);
|
|
38
|
+
|
|
39
|
+
return new Response(data, {
|
|
40
|
+
headers: {
|
|
41
|
+
"Content-Type": contentType,
|
|
42
|
+
"Content-Length": String(stats.size),
|
|
43
|
+
"Last-Modified": stats.mtime.toUTCString(),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function fetchSource(url: string): Promise<ArrayBuffer> {
|
|
49
|
+
const response = await fetch(url);
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`Source ${url} responded with ${response.status}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return response.arrayBuffer();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Builds a framework-agnostic request handler for the configured routes: the same `Request -> Response`
|
|
59
|
+
* shape the Next.js route handlers use, so it can sit behind Hono, `@hono/node-server`, or any other
|
|
60
|
+
* WHATWG-fetch-compatible server.
|
|
61
|
+
*/
|
|
62
|
+
export function createResizerHandler(config: Config): (request: Request) => Promise<Response> {
|
|
63
|
+
const routes = config.map((c, i): CompiledRoute => {
|
|
64
|
+
log(`Config #${i + 1}:`);
|
|
65
|
+
log(` Route: ${c.route}`);
|
|
66
|
+
log(` Source: ${c.source}`);
|
|
67
|
+
|
|
68
|
+
return { match: createRouteMatcher(c.route), source: c.source };
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return async (request) => {
|
|
72
|
+
try {
|
|
73
|
+
const { pathname } = new URL(request.url);
|
|
74
|
+
log("----------------------------------------");
|
|
75
|
+
log(`${request.method} ${pathname}`);
|
|
76
|
+
|
|
77
|
+
const matched = matchRoute(routes, pathname);
|
|
78
|
+
if (matched === undefined) {
|
|
79
|
+
log("No route matched", "warn");
|
|
80
|
+
return new Response("Not Found", { status: 404 });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { params, source } = matched;
|
|
84
|
+
|
|
85
|
+
const generatedFilename = (process.env.UXF_RESIZER_GENERATE_PATH ?? process.cwd()) + pathname;
|
|
86
|
+
log(`Generated filename: ${generatedFilename}`);
|
|
87
|
+
|
|
88
|
+
const contentType =
|
|
89
|
+
params.toFormat === "svg" ? "image/svg+xml" : (CONTENT_TYPES[params.toFormat] ?? "image/jpeg");
|
|
90
|
+
|
|
91
|
+
if (existsSync(generatedFilename)) {
|
|
92
|
+
return await fileResponse(generatedFilename, contentType);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await mkdir(dirname(generatedFilename), { recursive: true });
|
|
96
|
+
|
|
97
|
+
const sourceFilename = getSourceFilename(source, params);
|
|
98
|
+
log(`Source filename: ${sourceFilename}`);
|
|
99
|
+
|
|
100
|
+
const isRemote = /^https?:\/\//.test(sourceFilename);
|
|
101
|
+
|
|
102
|
+
if (params.toFormat === "svg") {
|
|
103
|
+
if (isRemote) {
|
|
104
|
+
await writeFile(generatedFilename, Buffer.from(await fetchSource(sourceFilename)));
|
|
105
|
+
} else {
|
|
106
|
+
await copyFile(sourceFilename, generatedFilename);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return await fileResponse(generatedFilename, contentType);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const sourceFile = isRemote ? await fetchSource(sourceFilename) : sourceFilename;
|
|
113
|
+
await convertImage(sourceFile, generatedFilename, params);
|
|
114
|
+
|
|
115
|
+
return await fileResponse(generatedFilename, contentType);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
log(e, "error");
|
|
118
|
+
return new Response("Not Found", { status: 404 });
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare function getSourceFilename(source: string, params: Record<string,
|
|
1
|
+
export declare function getSourceFilename(source: string, params: Partial<Record<string, string | string[]>>): string;
|
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.getSourceFilename = getSourceFilename;
|
|
4
|
-
const path_to_regexp_1 = require("path-to-regexp");
|
|
5
4
|
const log_1 = require("./log");
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `:name`, optionally followed by a path-to-regexp v6 modifier (`+`, `*`, `?`) that is ignored here –
|
|
7
|
+
* the matched value already contains any slashes. A name must start with a letter, so the port in
|
|
8
|
+
* `http://localhost:3000/...` is never mistaken for a parameter.
|
|
9
|
+
*/
|
|
10
|
+
const PARAM_PATTERN = /:([A-Za-z_]\w*)[+*?]?/g;
|
|
8
11
|
function getSourceFilename(source, params) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const compiled = (0, path_to_regexp_1.compile)(`/${domain}/${parsedHttpSource.path}`, {})(repairedParams);
|
|
18
|
-
return `${parsedHttpSource.protocol}:/${compiled}`;
|
|
19
|
-
}
|
|
20
|
-
return (0, path_to_regexp_1.compile)(source, {})(repairedParams);
|
|
12
|
+
(0, log_1.log)(params);
|
|
13
|
+
return source.replace(PARAM_PATTERN, (_match, key) => {
|
|
14
|
+
const value = params[key];
|
|
15
|
+
if (value === undefined) {
|
|
16
|
+
throw new Error(`Missing parameter "${key}" for source "${source}"`);
|
|
17
|
+
}
|
|
18
|
+
return Array.isArray(value) ? value.join("/") : value;
|
|
19
|
+
});
|
|
21
20
|
}
|
|
@@ -20,3 +20,24 @@ test("get source filename", () => {
|
|
|
20
20
|
extension: "jpg",
|
|
21
21
|
})).toStrictEqual("/var/www/path/to/file.jpg");
|
|
22
22
|
});
|
|
23
|
+
test("accepts a string value containing slashes (what URLPattern yields for :filename(.*))", () => {
|
|
24
|
+
expect((0, get_source_filename_1.getSourceFilename)("https://static.example.dev/:filename+.:extension", {
|
|
25
|
+
filename: "_next/static/media/logo.941ec59a",
|
|
26
|
+
extension: "png",
|
|
27
|
+
})).toStrictEqual("https://static.example.dev/_next/static/media/logo.941ec59a.png");
|
|
28
|
+
});
|
|
29
|
+
test("ignores the * and ? modifiers", () => {
|
|
30
|
+
expect((0, get_source_filename_1.getSourceFilename)("/var/www/:dir*/:file?.:ext", { dir: "a/b", file: "c", ext: "png" })).toStrictEqual("/var/www/a/b/c.png");
|
|
31
|
+
});
|
|
32
|
+
test("keeps the uploaded-image source intact", () => {
|
|
33
|
+
expect((0, get_source_filename_1.getSourceFilename)("https://s3.example.dev/:namespace/:p1/:p2/:filename.:extension", {
|
|
34
|
+
namespace: "product",
|
|
35
|
+
p1: "0",
|
|
36
|
+
p2: "f",
|
|
37
|
+
filename: "0f1e2d3c-1111-2222-3333-444455556666",
|
|
38
|
+
extension: "jpg",
|
|
39
|
+
})).toStrictEqual("https://s3.example.dev/product/0/f/0f1e2d3c-1111-2222-3333-444455556666.jpg");
|
|
40
|
+
});
|
|
41
|
+
test("throws when a parameter is missing", () => {
|
|
42
|
+
expect(() => (0, get_source_filename_1.getSourceFilename)("/var/www/:filename.:extension", { filename: "x" })).toThrow('Missing parameter "extension"');
|
|
43
|
+
});
|
|
@@ -30,3 +30,36 @@ test("get source filename", () => {
|
|
|
30
30
|
}),
|
|
31
31
|
).toStrictEqual("/var/www/path/to/file.jpg");
|
|
32
32
|
});
|
|
33
|
+
|
|
34
|
+
test("accepts a string value containing slashes (what URLPattern yields for :filename(.*))", () => {
|
|
35
|
+
expect(
|
|
36
|
+
getSourceFilename("https://static.example.dev/:filename+.:extension", {
|
|
37
|
+
filename: "_next/static/media/logo.941ec59a",
|
|
38
|
+
extension: "png",
|
|
39
|
+
}),
|
|
40
|
+
).toStrictEqual("https://static.example.dev/_next/static/media/logo.941ec59a.png");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("ignores the * and ? modifiers", () => {
|
|
44
|
+
expect(getSourceFilename("/var/www/:dir*/:file?.:ext", { dir: "a/b", file: "c", ext: "png" })).toStrictEqual(
|
|
45
|
+
"/var/www/a/b/c.png",
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("keeps the uploaded-image source intact", () => {
|
|
50
|
+
expect(
|
|
51
|
+
getSourceFilename("https://s3.example.dev/:namespace/:p1/:p2/:filename.:extension", {
|
|
52
|
+
namespace: "product",
|
|
53
|
+
p1: "0",
|
|
54
|
+
p2: "f",
|
|
55
|
+
filename: "0f1e2d3c-1111-2222-3333-444455556666",
|
|
56
|
+
extension: "jpg",
|
|
57
|
+
}),
|
|
58
|
+
).toStrictEqual("https://s3.example.dev/product/0/f/0f1e2d3c-1111-2222-3333-444455556666.jpg");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("throws when a parameter is missing", () => {
|
|
62
|
+
expect(() => getSourceFilename("/var/www/:filename.:extension", { filename: "x" })).toThrow(
|
|
63
|
+
'Missing parameter "extension"',
|
|
64
|
+
);
|
|
65
|
+
});
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import { compile } from "path-to-regexp";
|
|
2
1
|
import { log } from "./log";
|
|
3
|
-
import { parseHttpSource } from "./parse-http-source";
|
|
4
|
-
import { repairParams } from "./repair-params";
|
|
5
2
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
/**
|
|
4
|
+
* `:name`, optionally followed by a path-to-regexp v6 modifier (`+`, `*`, `?`) that is ignored here –
|
|
5
|
+
* the matched value already contains any slashes. A name must start with a letter, so the port in
|
|
6
|
+
* `http://localhost:3000/...` is never mistaken for a parameter.
|
|
7
|
+
*/
|
|
8
|
+
const PARAM_PATTERN = /:([A-Za-z_]\w*)[+*?]?/g;
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const domain = parsedHttpSource.domain.startsWith(":")
|
|
14
|
-
? parsedHttpSource.domain
|
|
15
|
-
: parsedHttpSource.domain.replace(":", "\\:");
|
|
16
|
-
const compiled = compile(`/${domain}/${parsedHttpSource.path}`, {})(repairedParams);
|
|
17
|
-
return `${parsedHttpSource.protocol}:/${compiled}`;
|
|
18
|
-
}
|
|
10
|
+
export function getSourceFilename(source: string, params: Partial<Record<string, string | string[]>>): string {
|
|
11
|
+
log(params);
|
|
19
12
|
|
|
20
|
-
return
|
|
13
|
+
return source.replace(PARAM_PATTERN, (_match, key: string) => {
|
|
14
|
+
const value = params[key];
|
|
15
|
+
if (value === undefined) {
|
|
16
|
+
throw new Error(`Missing parameter "${key}" for source "${source}"`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return Array.isArray(value) ? value.join("/") : value;
|
|
20
|
+
});
|
|
21
21
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Params } from "./tools";
|
|
2
|
+
/**
|
|
3
|
+
* Express 4 accepted `:name(*)` as "match anything, slashes included". URLPattern rejects a bare `*`
|
|
4
|
+
* inside a regexp group, so the legacy form is rewritten to the equivalent `(.*)`.
|
|
5
|
+
*/
|
|
6
|
+
export declare function normalizeRoute(route: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Compiles a `route` from the resizer config into a matcher. The syntax is the one built into Node's
|
|
9
|
+
* `URLPattern` (path-to-regexp v6 compatible): `:name`, `:name(regexp)`, `:name+`, several parameters
|
|
10
|
+
* per segment. Matching is case-insensitive and the captured values are percent-decoded, mirroring the
|
|
11
|
+
* Express 4 router this replaced.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createRouteMatcher(route: string): (pathname: string) => Params | undefined;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeRoute = normalizeRoute;
|
|
4
|
+
exports.createRouteMatcher = createRouteMatcher;
|
|
5
|
+
const log_1 = require("./log");
|
|
6
|
+
const LEGACY_WILDCARD = "(*)";
|
|
7
|
+
/**
|
|
8
|
+
* Express 4 accepted `:name(*)` as "match anything, slashes included". URLPattern rejects a bare `*`
|
|
9
|
+
* inside a regexp group, so the legacy form is rewritten to the equivalent `(.*)`.
|
|
10
|
+
*/
|
|
11
|
+
function normalizeRoute(route) {
|
|
12
|
+
if (!route.includes(LEGACY_WILDCARD)) {
|
|
13
|
+
return route;
|
|
14
|
+
}
|
|
15
|
+
(0, log_1.log)(`Route "${route}" uses the deprecated "(*)" group – use "(.*)" instead.`, "warn");
|
|
16
|
+
return route.replaceAll(LEGACY_WILDCARD, "(.*)");
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Compiles a `route` from the resizer config into a matcher. The syntax is the one built into Node's
|
|
20
|
+
* `URLPattern` (path-to-regexp v6 compatible): `:name`, `:name(regexp)`, `:name+`, several parameters
|
|
21
|
+
* per segment. Matching is case-insensitive and the captured values are percent-decoded, mirroring the
|
|
22
|
+
* Express 4 router this replaced.
|
|
23
|
+
*/
|
|
24
|
+
function createRouteMatcher(route) {
|
|
25
|
+
const pattern = new URLPattern({ pathname: normalizeRoute(route) }, { ignoreCase: true });
|
|
26
|
+
return (pathname) => {
|
|
27
|
+
var _a;
|
|
28
|
+
const groups = (_a = pattern.exec({ pathname })) === null || _a === void 0 ? void 0 : _a.pathname.groups;
|
|
29
|
+
if (groups === undefined) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
const params = {};
|
|
33
|
+
Object.entries(groups).forEach(([key, value]) => {
|
|
34
|
+
params[key] = value === undefined ? undefined : decodeURIComponent(value);
|
|
35
|
+
});
|
|
36
|
+
return params;
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
/**
|
|
4
|
+
* @jest-environment node
|
|
5
|
+
*/
|
|
6
|
+
const match_route_1 = require("./match-route");
|
|
7
|
+
// the two shapes `resizerGetDefaultConfig` (@uxf/core) emits
|
|
8
|
+
const STATIC_ROUTE = "/generated/static/:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([a-z]+)_:trim([a-z]+)_:quality(\\d+|x)/:version/:filename(.*).:extension.:toFormat";
|
|
9
|
+
const UPLOAD_ROUTE = "/generated/:namespace/:p1/:p2/:filename([a-f0-9\\-]+)_:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([a-z]+)_:trim([a-z]+)_:quality(\\d+|x)_:extension.:toFormat";
|
|
10
|
+
test("matches the uploaded-image route produced by resizerImageUrl", () => {
|
|
11
|
+
const match = (0, match_route_1.createRouteMatcher)(UPLOAD_ROUTE);
|
|
12
|
+
expect(match("/generated/product/0/f/0f1e2d3c-1111-2222-3333-444455556666_300_x_cv_c_FFF_nt_80_jpg.webp")).toEqual({
|
|
13
|
+
namespace: "product",
|
|
14
|
+
p1: "0",
|
|
15
|
+
p2: "f",
|
|
16
|
+
filename: "0f1e2d3c-1111-2222-3333-444455556666",
|
|
17
|
+
width: "300",
|
|
18
|
+
height: "x",
|
|
19
|
+
fit: "cv",
|
|
20
|
+
position: "c",
|
|
21
|
+
background: "FFF",
|
|
22
|
+
trim: "nt",
|
|
23
|
+
quality: "80",
|
|
24
|
+
extension: "jpg",
|
|
25
|
+
toFormat: "webp",
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
test("matches the static route with a nested, dotted filename", () => {
|
|
29
|
+
const match = (0, match_route_1.createRouteMatcher)(STATIC_ROUTE);
|
|
30
|
+
expect(match("/generated/static/300_200_cv_c_t_nt_x/1/_next/static/media/logo.941ec59a.png.avif")).toEqual({
|
|
31
|
+
width: "300",
|
|
32
|
+
height: "200",
|
|
33
|
+
fit: "cv",
|
|
34
|
+
position: "c",
|
|
35
|
+
background: "t",
|
|
36
|
+
trim: "nt",
|
|
37
|
+
quality: "x",
|
|
38
|
+
version: "1",
|
|
39
|
+
filename: "_next/static/media/logo.941ec59a",
|
|
40
|
+
extension: "png",
|
|
41
|
+
toFormat: "avif",
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
test("legacy (*) group matches exactly like (.*)", () => {
|
|
45
|
+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
46
|
+
const legacy = (0, match_route_1.createRouteMatcher)(STATIC_ROUTE.replace("(.*)", "(*)"));
|
|
47
|
+
const current = (0, match_route_1.createRouteMatcher)(STATIC_ROUTE);
|
|
48
|
+
const pathname = "/generated/static/x_x_cn_lt_FFF_nt_75/3/images/deep/dir/file.name.jpg.webp";
|
|
49
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
50
|
+
warnSpy.mockRestore();
|
|
51
|
+
expect(legacy(pathname)).toEqual(current(pathname));
|
|
52
|
+
expect(current(pathname)).toMatchObject({
|
|
53
|
+
filename: "images/deep/dir/file.name",
|
|
54
|
+
extension: "jpg",
|
|
55
|
+
toFormat: "webp",
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
test("normalizeRoute rewrites every (*) and leaves other routes alone", () => {
|
|
59
|
+
expect((0, match_route_1.normalizeRoute)("/a/:x(*)/:y(*)")).toBe("/a/:x(.*)/:y(.*)");
|
|
60
|
+
expect((0, match_route_1.normalizeRoute)(STATIC_ROUTE)).toBe(STATIC_ROUTE);
|
|
61
|
+
});
|
|
62
|
+
test("matching is case-insensitive (background FFF against [a-z]+)", () => {
|
|
63
|
+
const match = (0, match_route_1.createRouteMatcher)("/x/:background([a-z]+)");
|
|
64
|
+
expect(match("/x/FFF")).toEqual({ background: "FFF" });
|
|
65
|
+
expect(match("/X/fff")).toEqual({ background: "fff" });
|
|
66
|
+
});
|
|
67
|
+
test("percent-decodes captured values", () => {
|
|
68
|
+
const match = (0, match_route_1.createRouteMatcher)("/files/:filename(.*).:extension");
|
|
69
|
+
expect(match("/files/My%20Photo.jpg")).toEqual({ filename: "My Photo", extension: "jpg" });
|
|
70
|
+
});
|
|
71
|
+
test("returns undefined when the path does not match", () => {
|
|
72
|
+
const match = (0, match_route_1.createRouteMatcher)(UPLOAD_ROUTE);
|
|
73
|
+
expect(match("/generated/static/300_200/x.png.webp")).toBeUndefined();
|
|
74
|
+
expect(match("/generated/product/0/f/not-a-uuid_300_x_cv_c_FFF_nt_80_jpg.webp")).toBeUndefined();
|
|
75
|
+
expect(match("/generated/product/0/f/0f1e2d3c_abc_x_cv_c_FFF_nt_80_jpg.webp")).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
test("throws on an invalid route at construction time", () => {
|
|
78
|
+
expect(() => (0, match_route_1.createRouteMatcher)("/x/:a([")).toThrow();
|
|
79
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { createRouteMatcher, normalizeRoute } from "./match-route";
|
|
5
|
+
|
|
6
|
+
// the two shapes `resizerGetDefaultConfig` (@uxf/core) emits
|
|
7
|
+
const STATIC_ROUTE =
|
|
8
|
+
"/generated/static/:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([a-z]+)_:trim([a-z]+)_:quality(\\d+|x)/:version/:filename(.*).:extension.:toFormat";
|
|
9
|
+
const UPLOAD_ROUTE =
|
|
10
|
+
"/generated/:namespace/:p1/:p2/:filename([a-f0-9\\-]+)_:width(\\d+|x)_:height(\\d+|x)_:fit([a-z]+)_:position([a-z]+)_:background([a-z]+)_:trim([a-z]+)_:quality(\\d+|x)_:extension.:toFormat";
|
|
11
|
+
|
|
12
|
+
test("matches the uploaded-image route produced by resizerImageUrl", () => {
|
|
13
|
+
const match = createRouteMatcher(UPLOAD_ROUTE);
|
|
14
|
+
|
|
15
|
+
expect(match("/generated/product/0/f/0f1e2d3c-1111-2222-3333-444455556666_300_x_cv_c_FFF_nt_80_jpg.webp")).toEqual({
|
|
16
|
+
namespace: "product",
|
|
17
|
+
p1: "0",
|
|
18
|
+
p2: "f",
|
|
19
|
+
filename: "0f1e2d3c-1111-2222-3333-444455556666",
|
|
20
|
+
width: "300",
|
|
21
|
+
height: "x",
|
|
22
|
+
fit: "cv",
|
|
23
|
+
position: "c",
|
|
24
|
+
background: "FFF",
|
|
25
|
+
trim: "nt",
|
|
26
|
+
quality: "80",
|
|
27
|
+
extension: "jpg",
|
|
28
|
+
toFormat: "webp",
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("matches the static route with a nested, dotted filename", () => {
|
|
33
|
+
const match = createRouteMatcher(STATIC_ROUTE);
|
|
34
|
+
|
|
35
|
+
expect(match("/generated/static/300_200_cv_c_t_nt_x/1/_next/static/media/logo.941ec59a.png.avif")).toEqual({
|
|
36
|
+
width: "300",
|
|
37
|
+
height: "200",
|
|
38
|
+
fit: "cv",
|
|
39
|
+
position: "c",
|
|
40
|
+
background: "t",
|
|
41
|
+
trim: "nt",
|
|
42
|
+
quality: "x",
|
|
43
|
+
version: "1",
|
|
44
|
+
filename: "_next/static/media/logo.941ec59a",
|
|
45
|
+
extension: "png",
|
|
46
|
+
toFormat: "avif",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("legacy (*) group matches exactly like (.*)", () => {
|
|
51
|
+
const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
52
|
+
const legacy = createRouteMatcher(STATIC_ROUTE.replace("(.*)", "(*)"));
|
|
53
|
+
const current = createRouteMatcher(STATIC_ROUTE);
|
|
54
|
+
const pathname = "/generated/static/x_x_cn_lt_FFF_nt_75/3/images/deep/dir/file.name.jpg.webp";
|
|
55
|
+
|
|
56
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
57
|
+
warnSpy.mockRestore();
|
|
58
|
+
expect(legacy(pathname)).toEqual(current(pathname));
|
|
59
|
+
expect(current(pathname)).toMatchObject({
|
|
60
|
+
filename: "images/deep/dir/file.name",
|
|
61
|
+
extension: "jpg",
|
|
62
|
+
toFormat: "webp",
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("normalizeRoute rewrites every (*) and leaves other routes alone", () => {
|
|
67
|
+
expect(normalizeRoute("/a/:x(*)/:y(*)")).toBe("/a/:x(.*)/:y(.*)");
|
|
68
|
+
expect(normalizeRoute(STATIC_ROUTE)).toBe(STATIC_ROUTE);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("matching is case-insensitive (background FFF against [a-z]+)", () => {
|
|
72
|
+
const match = createRouteMatcher("/x/:background([a-z]+)");
|
|
73
|
+
|
|
74
|
+
expect(match("/x/FFF")).toEqual({ background: "FFF" });
|
|
75
|
+
expect(match("/X/fff")).toEqual({ background: "fff" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("percent-decodes captured values", () => {
|
|
79
|
+
const match = createRouteMatcher("/files/:filename(.*).:extension");
|
|
80
|
+
|
|
81
|
+
expect(match("/files/My%20Photo.jpg")).toEqual({ filename: "My Photo", extension: "jpg" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("returns undefined when the path does not match", () => {
|
|
85
|
+
const match = createRouteMatcher(UPLOAD_ROUTE);
|
|
86
|
+
|
|
87
|
+
expect(match("/generated/static/300_200/x.png.webp")).toBeUndefined();
|
|
88
|
+
expect(match("/generated/product/0/f/not-a-uuid_300_x_cv_c_FFF_nt_80_jpg.webp")).toBeUndefined();
|
|
89
|
+
expect(match("/generated/product/0/f/0f1e2d3c_abc_x_cv_c_FFF_nt_80_jpg.webp")).toBeUndefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("throws on an invalid route at construction time", () => {
|
|
93
|
+
expect(() => createRouteMatcher("/x/:a([")).toThrow();
|
|
94
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { log } from "./log";
|
|
2
|
+
import { Params } from "./tools";
|
|
3
|
+
|
|
4
|
+
const LEGACY_WILDCARD = "(*)";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Express 4 accepted `:name(*)` as "match anything, slashes included". URLPattern rejects a bare `*`
|
|
8
|
+
* inside a regexp group, so the legacy form is rewritten to the equivalent `(.*)`.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizeRoute(route: string): string {
|
|
11
|
+
if (!route.includes(LEGACY_WILDCARD)) {
|
|
12
|
+
return route;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
log(`Route "${route}" uses the deprecated "(*)" group – use "(.*)" instead.`, "warn");
|
|
16
|
+
return route.replaceAll(LEGACY_WILDCARD, "(.*)");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compiles a `route` from the resizer config into a matcher. The syntax is the one built into Node's
|
|
21
|
+
* `URLPattern` (path-to-regexp v6 compatible): `:name`, `:name(regexp)`, `:name+`, several parameters
|
|
22
|
+
* per segment. Matching is case-insensitive and the captured values are percent-decoded, mirroring the
|
|
23
|
+
* Express 4 router this replaced.
|
|
24
|
+
*/
|
|
25
|
+
export function createRouteMatcher(route: string): (pathname: string) => Params | undefined {
|
|
26
|
+
const pattern = new URLPattern({ pathname: normalizeRoute(route) }, { ignoreCase: true });
|
|
27
|
+
|
|
28
|
+
return (pathname) => {
|
|
29
|
+
const groups = pattern.exec({ pathname })?.pathname.groups;
|
|
30
|
+
if (groups === undefined) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const params: Partial<Record<string, string>> = {};
|
|
35
|
+
Object.entries(groups).forEach(([key, value]) => {
|
|
36
|
+
params[key] = value === undefined ? undefined : decodeURIComponent(value);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return params as unknown as Params;
|
|
40
|
+
};
|
|
41
|
+
}
|
package/src/utils/tools.d.ts
CHANGED
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
import type { FitEnum } from "sharp";
|
|
2
2
|
export declare const CONTENT_TYPES: Record<string, string | undefined>;
|
|
3
|
-
export
|
|
4
|
-
width: string;
|
|
5
|
-
height: string;
|
|
6
|
-
fit: string;
|
|
7
|
-
position: string;
|
|
8
|
-
background: string;
|
|
9
|
-
trim: string;
|
|
10
|
-
quality: string;
|
|
3
|
+
export type Params = {
|
|
4
|
+
width: string | undefined;
|
|
5
|
+
height: string | undefined;
|
|
6
|
+
fit: string | undefined;
|
|
7
|
+
position: string | undefined;
|
|
8
|
+
background: string | undefined;
|
|
9
|
+
trim: string | undefined;
|
|
10
|
+
quality: string | undefined;
|
|
11
11
|
filename: string;
|
|
12
12
|
extension: string;
|
|
13
13
|
toFormat: string;
|
|
14
14
|
p1: string | undefined;
|
|
15
15
|
p2: string | undefined;
|
|
16
16
|
namespace: string | undefined;
|
|
17
|
-
}
|
|
17
|
+
};
|
|
18
18
|
export declare const getQuality: ({ quality }: Params) => number | undefined;
|
|
19
19
|
export declare const getWidth: ({ width }: Params) => number | undefined;
|
|
20
20
|
export declare const getHeight: ({ height }: Params) => number | undefined;
|
|
21
|
-
export declare const getBackground: ({ background }: Params) => string;
|
|
21
|
+
export declare const getBackground: ({ background: bg }: Params) => string;
|
|
22
22
|
export declare const getFit: ({ fit }: Params) => keyof FitEnum;
|
|
23
23
|
export declare const getPosition: ({ position }: Params) => string | number | undefined;
|
|
24
24
|
export declare const getWithoutEnlargement: ({ extension }: Params) => boolean;
|
package/src/utils/tools.js
CHANGED
|
@@ -33,7 +33,7 @@ const getWidth = ({ width }) => (width && width !== "x" ? Number(width) : undefi
|
|
|
33
33
|
exports.getWidth = getWidth;
|
|
34
34
|
const getHeight = ({ height }) => (height && height !== "x" ? Number(height) : undefined);
|
|
35
35
|
exports.getHeight = getHeight;
|
|
36
|
-
const getBackground = ({ background }) =>
|
|
36
|
+
const getBackground = ({ background: bg }) => bg === undefined || bg === "t" ? "transparent" : `#${bg}`;
|
|
37
37
|
exports.getBackground = getBackground;
|
|
38
38
|
const getFit = ({ fit }) => { var _a; return (fit ? ((_a = FIT_OPTIONS[fit]) !== null && _a !== void 0 ? _a : "cover") : "cover"); };
|
|
39
39
|
exports.getFit = getFit;
|
|
@@ -41,5 +41,5 @@ const getPosition = ({ position }) => { var _a; return (position ? ((_a = POSITI
|
|
|
41
41
|
exports.getPosition = getPosition;
|
|
42
42
|
const getWithoutEnlargement = ({ extension }) => extension !== "svg";
|
|
43
43
|
exports.getWithoutEnlargement = getWithoutEnlargement;
|
|
44
|
-
const getTrim = ({ trim }) => (trim === "nt" ? undefined : Number(trim));
|
|
44
|
+
const getTrim = ({ trim }) => (trim === undefined || trim === "nt" ? undefined : Number(trim));
|
|
45
45
|
exports.getTrim = getTrim;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const tools_1 = require("./tools");
|
|
4
|
+
const minimal = {
|
|
5
|
+
width: "146",
|
|
6
|
+
height: "100",
|
|
7
|
+
fit: undefined,
|
|
8
|
+
position: undefined,
|
|
9
|
+
background: undefined,
|
|
10
|
+
trim: undefined,
|
|
11
|
+
quality: undefined,
|
|
12
|
+
filename: "logo",
|
|
13
|
+
extension: "png",
|
|
14
|
+
toFormat: "avif",
|
|
15
|
+
p1: undefined,
|
|
16
|
+
p2: undefined,
|
|
17
|
+
namespace: undefined,
|
|
18
|
+
};
|
|
19
|
+
test("a route without the optional parameters falls back to defaults", () => {
|
|
20
|
+
expect((0, tools_1.getWidth)(minimal)).toBe(146);
|
|
21
|
+
expect((0, tools_1.getHeight)(minimal)).toBe(100);
|
|
22
|
+
expect((0, tools_1.getFit)(minimal)).toBe("cover");
|
|
23
|
+
expect((0, tools_1.getPosition)(minimal)).toBeUndefined();
|
|
24
|
+
expect((0, tools_1.getBackground)(minimal)).toBe("transparent");
|
|
25
|
+
expect((0, tools_1.getTrim)(minimal)).toBeUndefined();
|
|
26
|
+
expect((0, tools_1.getQuality)(minimal)).toBeUndefined();
|
|
27
|
+
});
|
|
28
|
+
test("explicit parameters are mapped", () => {
|
|
29
|
+
const params = {
|
|
30
|
+
...minimal,
|
|
31
|
+
width: "x",
|
|
32
|
+
fit: "cn",
|
|
33
|
+
position: "lt",
|
|
34
|
+
background: "t",
|
|
35
|
+
trim: "10",
|
|
36
|
+
quality: "75",
|
|
37
|
+
};
|
|
38
|
+
expect((0, tools_1.getWidth)(params)).toBeUndefined();
|
|
39
|
+
expect((0, tools_1.getFit)(params)).toBe("contain");
|
|
40
|
+
expect((0, tools_1.getPosition)(params)).toBe("left top");
|
|
41
|
+
expect((0, tools_1.getBackground)(params)).toBe("transparent");
|
|
42
|
+
expect((0, tools_1.getTrim)(params)).toBe(10);
|
|
43
|
+
expect((0, tools_1.getQuality)(params)).toBe(75);
|
|
44
|
+
expect((0, tools_1.getBackground)({ ...minimal, background: "ff0000" })).toBe("#ff0000");
|
|
45
|
+
expect((0, tools_1.getTrim)({ ...minimal, trim: "nt" })).toBeUndefined();
|
|
46
|
+
});
|