@plasius/images 1.0.0 → 1.0.2
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/CHANGELOG.md +31 -1
- package/dist/index.cjs +225 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +27 -0
- package/dist/index.d.ts +27 -2
- package/dist/index.js +186 -1
- package/dist/index.js.map +1 -0
- package/docs/adrs/index.md +4 -0
- package/package.json +17 -16
- package/dist/detectFormat.d.ts +0 -2
- package/dist/detectFormat.d.ts.map +0 -1
- package/dist/detectFormat.js +0 -20
- package/dist/index.d.ts.map +0 -1
- package/dist/parse/index.d.ts +0 -5
- package/dist/parse/index.d.ts.map +0 -1
- package/dist/parse/index.js +0 -4
- package/dist/parse/parseJpeg.d.ts +0 -5
- package/dist/parse/parseJpeg.d.ts.map +0 -1
- package/dist/parse/parseJpeg.js +0 -18
- package/dist/parse/parsePng.d.ts +0 -5
- package/dist/parse/parsePng.d.ts.map +0 -1
- package/dist/parse/parsePng.js +0 -6
- package/dist/parse/parseSVG.d.ts +0 -5
- package/dist/parse/parseSVG.d.ts.map +0 -1
- package/dist/parse/parseSVG.js +0 -23
- package/dist/parse/parseWebp.d.ts +0 -5
- package/dist/parse/parseWebp.d.ts.map +0 -1
- package/dist/parse/parseWebp.js +0 -17
- package/dist/validators/avatarValidator.d.ts +0 -9
- package/dist/validators/avatarValidator.d.ts.map +0 -1
- package/dist/validators/avatarValidator.js +0 -51
- package/dist/validators/index.d.ts +0 -3
- package/dist/validators/index.d.ts.map +0 -1
- package/dist/validators/index.js +0 -2
- package/dist/validators/svgValidator.d.ts +0 -18
- package/dist/validators/svgValidator.d.ts.map +0 -1
- package/dist/validators/svgValidator.js +0 -139
- package/dist-cjs/detectFormat.d.ts +0 -2
- package/dist-cjs/detectFormat.d.ts.map +0 -1
- package/dist-cjs/detectFormat.js +0 -23
- package/dist-cjs/index.d.ts +0 -2
- package/dist-cjs/index.d.ts.map +0 -1
- package/dist-cjs/index.js +0 -17
- package/dist-cjs/parse/index.d.ts +0 -5
- package/dist-cjs/parse/index.d.ts.map +0 -1
- package/dist-cjs/parse/index.js +0 -20
- package/dist-cjs/parse/parseJpeg.d.ts +0 -5
- package/dist-cjs/parse/parseJpeg.d.ts.map +0 -1
- package/dist-cjs/parse/parseJpeg.js +0 -21
- package/dist-cjs/parse/parsePng.d.ts +0 -5
- package/dist-cjs/parse/parsePng.d.ts.map +0 -1
- package/dist-cjs/parse/parsePng.js +0 -9
- package/dist-cjs/parse/parseSVG.d.ts +0 -5
- package/dist-cjs/parse/parseSVG.d.ts.map +0 -1
- package/dist-cjs/parse/parseSVG.js +0 -26
- package/dist-cjs/parse/parseWebp.d.ts +0 -5
- package/dist-cjs/parse/parseWebp.d.ts.map +0 -1
- package/dist-cjs/parse/parseWebp.js +0 -20
- package/dist-cjs/validators/avatarValidator.d.ts +0 -9
- package/dist-cjs/validators/avatarValidator.d.ts.map +0 -1
- package/dist-cjs/validators/avatarValidator.js +0 -57
- package/dist-cjs/validators/index.d.ts +0 -3
- package/dist-cjs/validators/index.d.ts.map +0 -1
- package/dist-cjs/validators/index.js +0 -18
- package/dist-cjs/validators/svgValidator.d.ts +0 -18
- package/dist-cjs/validators/svgValidator.d.ts.map +0 -1
- package/dist-cjs/validators/svgValidator.js +0 -143
package/CHANGELOG.md
CHANGED
|
@@ -20,6 +20,34 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
|
|
|
20
20
|
- **Security**
|
|
21
21
|
- (placeholder)
|
|
22
22
|
|
|
23
|
+
## [1.0.2] - 2026-02-22
|
|
24
|
+
|
|
25
|
+
- **Added**
|
|
26
|
+
- (placeholder)
|
|
27
|
+
|
|
28
|
+
- **Changed**
|
|
29
|
+
- (placeholder)
|
|
30
|
+
|
|
31
|
+
- **Fixed**
|
|
32
|
+
- (placeholder)
|
|
33
|
+
|
|
34
|
+
- **Security**
|
|
35
|
+
- (placeholder)
|
|
36
|
+
|
|
37
|
+
## [1.0.1] - 2026-02-13
|
|
38
|
+
|
|
39
|
+
- **Added**
|
|
40
|
+
- (placeholder)
|
|
41
|
+
|
|
42
|
+
- **Changed**
|
|
43
|
+
- Replace dual-`tsc` build steps with `tsup` to emit ESM + CJS + types side-by-side in `dist/` (`index.js`, `index.cjs`, `index.d.ts`).
|
|
44
|
+
|
|
45
|
+
- **Fixed**
|
|
46
|
+
- (placeholder)
|
|
47
|
+
|
|
48
|
+
- **Security**
|
|
49
|
+
- (placeholder)
|
|
50
|
+
|
|
23
51
|
## [1.0.0] - 2026-02-12
|
|
24
52
|
|
|
25
53
|
- **Added**
|
|
@@ -48,7 +76,7 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
|
|
|
48
76
|
|
|
49
77
|
---
|
|
50
78
|
|
|
51
|
-
[Unreleased]: https://github.com/Plasius-LTD/images/compare/v1.0.
|
|
79
|
+
[Unreleased]: https://github.com/Plasius-LTD/images/compare/v1.0.2...HEAD
|
|
52
80
|
|
|
53
81
|
## [1.0.0] - 2026-02-11
|
|
54
82
|
|
|
@@ -64,3 +92,5 @@ The format is based on **[Keep a Changelog](https://keepachangelog.com/en/1.1.0/
|
|
|
64
92
|
- **Security**
|
|
65
93
|
- (placeholder)
|
|
66
94
|
[1.0.0]: https://github.com/Plasius-LTD/images/releases/tag/v1.0.0
|
|
95
|
+
[1.0.1]: https://github.com/Plasius-LTD/images/releases/tag/v1.0.1
|
|
96
|
+
[1.0.2]: https://github.com/Plasius-LTD/images/releases/tag/v1.0.2
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
sanitiseSVG: () => sanitiseSVG,
|
|
34
|
+
validateAvatarImage: () => validateAvatarImage,
|
|
35
|
+
validateSvgAvatar: () => validateSvgAvatar
|
|
36
|
+
});
|
|
37
|
+
module.exports = __toCommonJS(index_exports);
|
|
38
|
+
|
|
39
|
+
// src/validators/svgValidator.ts
|
|
40
|
+
var import_xmldom = require("@xmldom/xmldom");
|
|
41
|
+
function sanitiseSVG(svgText, options) {
|
|
42
|
+
const strippedTags = [];
|
|
43
|
+
const strippedAttributes = [];
|
|
44
|
+
const parser = new import_xmldom.DOMParser({
|
|
45
|
+
onError: (level, msg) => {
|
|
46
|
+
switch (level) {
|
|
47
|
+
case "warning":
|
|
48
|
+
break;
|
|
49
|
+
default:
|
|
50
|
+
throw new Error(msg);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const serializer = new import_xmldom.XMLSerializer();
|
|
55
|
+
const doc = parser.parseFromString(svgText, "image/svg+xml");
|
|
56
|
+
function cleanNode(node) {
|
|
57
|
+
if (node === null) return;
|
|
58
|
+
if (node.nodeType === 8 && !options.allowComments) {
|
|
59
|
+
node.parentNode?.removeChild(node);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (node.nodeType === 1) {
|
|
63
|
+
const el = node;
|
|
64
|
+
const tagName = el.tagName.toLowerCase();
|
|
65
|
+
if (tagName === "style") {
|
|
66
|
+
strippedTags.push(tagName);
|
|
67
|
+
node.parentNode?.removeChild(node);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!options.allowedTags.includes(tagName) && !options.allowUnknownElements) {
|
|
71
|
+
strippedTags.push(tagName);
|
|
72
|
+
node.parentNode?.removeChild(node);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const allowedAttrs = options.allowedAttributes["*"] || [];
|
|
76
|
+
const toRemove = [];
|
|
77
|
+
for (const attr of Array.from(el.attributes)) {
|
|
78
|
+
const attrName = attr.name.toLowerCase();
|
|
79
|
+
if (attrName.startsWith("on")) {
|
|
80
|
+
toRemove.push(attr.name);
|
|
81
|
+
strippedAttributes.push(`${tagName}.${attr.name}`);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (attrName === "xlink:href" || attrName === "xmlns:xlink") {
|
|
85
|
+
toRemove.push(attr.name);
|
|
86
|
+
strippedAttributes.push(`${tagName}.${attr.name}`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (attrName === "style") {
|
|
90
|
+
toRemove.push(attr.name);
|
|
91
|
+
strippedAttributes.push(`${tagName}.${attr.name}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!allowedAttrs.includes(attr.name) && !options.allowUnknownAttributes) {
|
|
95
|
+
toRemove.push(attr.name);
|
|
96
|
+
strippedAttributes.push(`${tagName}.${attr.name}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
toRemove.forEach((attrName) => el.removeAttribute(attrName));
|
|
100
|
+
}
|
|
101
|
+
const children = Array.from(node.childNodes);
|
|
102
|
+
children.forEach((child) => cleanNode(child));
|
|
103
|
+
}
|
|
104
|
+
cleanNode(doc.documentElement);
|
|
105
|
+
return {
|
|
106
|
+
svg: serializer.serializeToString(doc),
|
|
107
|
+
audit: { strippedTags, strippedAttributes }
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function validateSvgAvatar(buffer) {
|
|
111
|
+
const svgText = buffer.toString("utf8");
|
|
112
|
+
const { svg: cleanSvg } = sanitiseSVG(svgText, {
|
|
113
|
+
allowedTags: [
|
|
114
|
+
"svg",
|
|
115
|
+
"g",
|
|
116
|
+
"rect",
|
|
117
|
+
"circle",
|
|
118
|
+
"ellipse",
|
|
119
|
+
"line",
|
|
120
|
+
"polygon",
|
|
121
|
+
"polyline",
|
|
122
|
+
"path",
|
|
123
|
+
"text",
|
|
124
|
+
"title",
|
|
125
|
+
"desc"
|
|
126
|
+
],
|
|
127
|
+
allowedAttributes: {
|
|
128
|
+
"*": [
|
|
129
|
+
"width",
|
|
130
|
+
"height",
|
|
131
|
+
"viewBox",
|
|
132
|
+
"xmlns",
|
|
133
|
+
"transform",
|
|
134
|
+
"x",
|
|
135
|
+
"y",
|
|
136
|
+
"cx",
|
|
137
|
+
"cy",
|
|
138
|
+
"r",
|
|
139
|
+
"rx",
|
|
140
|
+
"ry",
|
|
141
|
+
"x1",
|
|
142
|
+
"y1",
|
|
143
|
+
"x2",
|
|
144
|
+
"y2",
|
|
145
|
+
"points",
|
|
146
|
+
"d",
|
|
147
|
+
"fill",
|
|
148
|
+
"stroke",
|
|
149
|
+
"stroke-width",
|
|
150
|
+
"font-family",
|
|
151
|
+
"font-size",
|
|
152
|
+
"text-anchor"
|
|
153
|
+
]
|
|
154
|
+
},
|
|
155
|
+
allowComments: false,
|
|
156
|
+
allowUnknownElements: false,
|
|
157
|
+
allowUnknownAttributes: false
|
|
158
|
+
});
|
|
159
|
+
const cleanBuffer = Buffer.from(cleanSvg, "utf8");
|
|
160
|
+
if (cleanBuffer.length > 256 * 1024) {
|
|
161
|
+
throw new Error("SVG too large (max 256 KB)");
|
|
162
|
+
}
|
|
163
|
+
return Promise.resolve({
|
|
164
|
+
width: 0,
|
|
165
|
+
// SVG is scalable, can’t guarantee pixel dimensions
|
|
166
|
+
height: 0,
|
|
167
|
+
format: "svg",
|
|
168
|
+
size: cleanBuffer.length,
|
|
169
|
+
safeBuffer: cleanBuffer
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/validators/avatarValidator.ts
|
|
174
|
+
var import_sharp = __toESM(require("sharp"), 1);
|
|
175
|
+
async function validateAvatarImage(buffer) {
|
|
176
|
+
const metadata = await (0, import_sharp.default)(buffer).metadata();
|
|
177
|
+
const format = metadata.format;
|
|
178
|
+
if (!format) {
|
|
179
|
+
throw new Error("Unsupported or unknown image format");
|
|
180
|
+
}
|
|
181
|
+
const width = metadata.width;
|
|
182
|
+
const height = metadata.height;
|
|
183
|
+
if (!width || !height) {
|
|
184
|
+
throw new Error("Could not determine image dimensions");
|
|
185
|
+
}
|
|
186
|
+
if (width < 64 || height < 64) {
|
|
187
|
+
throw new Error("Image too small (min 64x64)");
|
|
188
|
+
}
|
|
189
|
+
if (width > 2048 || height > 2048) {
|
|
190
|
+
throw new Error("Image too large (max 2048x2048)");
|
|
191
|
+
}
|
|
192
|
+
const targetFormat = "png";
|
|
193
|
+
let safeBuffer;
|
|
194
|
+
if (format === "svg") {
|
|
195
|
+
const svgText = buffer.toString("utf8");
|
|
196
|
+
const svgValidation = await validateSvgAvatar(
|
|
197
|
+
Buffer.from(svgText, "utf-8")
|
|
198
|
+
);
|
|
199
|
+
safeBuffer = await (0, import_sharp.default)(svgValidation.safeBuffer).resize({
|
|
200
|
+
width: Math.min(width, 2048),
|
|
201
|
+
height: Math.min(height, 2048),
|
|
202
|
+
fit: "inside"
|
|
203
|
+
}).toFormat(targetFormat).toBuffer();
|
|
204
|
+
} else {
|
|
205
|
+
safeBuffer = await (0, import_sharp.default)(buffer).resize({
|
|
206
|
+
width: Math.min(width, 2048),
|
|
207
|
+
height: Math.min(height, 2048),
|
|
208
|
+
fit: "inside"
|
|
209
|
+
}).toFormat(targetFormat, { quality: 90 }).toBuffer();
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
width,
|
|
213
|
+
height,
|
|
214
|
+
format,
|
|
215
|
+
size: buffer.length,
|
|
216
|
+
safeBuffer
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
220
|
+
0 && (module.exports = {
|
|
221
|
+
sanitiseSVG,
|
|
222
|
+
validateAvatarImage,
|
|
223
|
+
validateSvgAvatar
|
|
224
|
+
});
|
|
225
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/validators/svgValidator.ts","../src/validators/avatarValidator.ts"],"sourcesContent":["export * from \"./validators/index.js\";\n","import type { ImageValidationResult } from \"./avatarValidator.js\";\nimport { DOMParser, XMLSerializer } from \"@xmldom/xmldom\";\nimport type { Element as XmlElement } from \"@xmldom/xmldom\";\n\nexport function sanitiseSVG(\n svgText: string,\n options: {\n allowedTags: string[];\n allowedAttributes: { \"*\": string[] };\n allowComments: boolean;\n allowUnknownElements: boolean;\n allowUnknownAttributes: boolean;\n }\n): {\n svg: string;\n audit: { strippedTags: string[]; strippedAttributes: string[] };\n} {\n const strippedTags: string[] = [];\n const strippedAttributes: string[] = [];\n\n const parser = new DOMParser({\n onError: (\n level: \"warning\" | \"error\" | \"fatalError\",\n msg: string\n ): void => {\n switch (level) {\n case \"warning\":\n break;\n default:\n throw new Error(msg);\n }\n },\n });\n const serializer = new XMLSerializer();\n const doc = parser.parseFromString(svgText, \"image/svg+xml\");\n\n function cleanNode(node: Node | null) {\n // TODO: optionally sanitize <style> elements and inline style attributes\n\n if (node === null) return;\n\n if (node.nodeType === 8 /* Comment */ && !options.allowComments) {\n node.parentNode?.removeChild(node);\n return;\n }\n\n if (node.nodeType === 1 /* Element */) {\n const el = node as unknown as XmlElement;\n const tagName = el.tagName.toLowerCase();\n\n // Strip <style> elements entirely\n if (tagName === \"style\") {\n strippedTags.push(tagName);\n node.parentNode?.removeChild(node);\n return;\n }\n\n if (\n !options.allowedTags.includes(tagName) &&\n !options.allowUnknownElements\n ) {\n strippedTags.push(tagName);\n node.parentNode?.removeChild(node);\n return;\n }\n\n // Clean attributes\n const allowedAttrs = options.allowedAttributes[\"*\"] || [];\n\n const toRemove: string[] = [];\n for (const attr of Array.from(el.attributes)) {\n const attrName = attr.name.toLowerCase();\n\n if (attrName.startsWith(\"on\")) {\n toRemove.push(attr.name);\n strippedAttributes.push(`${tagName}.${attr.name}`);\n continue;\n }\n if (attrName === \"xlink:href\" || attrName === \"xmlns:xlink\") {\n toRemove.push(attr.name);\n strippedAttributes.push(`${tagName}.${attr.name}`);\n continue;\n }\n if (attrName === \"style\") {\n toRemove.push(attr.name);\n strippedAttributes.push(`${tagName}.${attr.name}`);\n continue;\n }\n\n if (\n !allowedAttrs.includes(attr.name) &&\n !options.allowUnknownAttributes\n ) {\n toRemove.push(attr.name);\n strippedAttributes.push(`${tagName}.${attr.name}`);\n }\n }\n toRemove.forEach((attrName) => el.removeAttribute(attrName));\n }\n\n // Recursively clean children\n const children = Array.from(node.childNodes);\n children.forEach((child) => cleanNode(child));\n }\n\n cleanNode(doc.documentElement as unknown as Node);\n\n return {\n svg: serializer.serializeToString(doc),\n audit: { strippedTags, strippedAttributes },\n };\n}\n\nexport function validateSvgAvatar(\n buffer: Buffer\n): Promise<ImageValidationResult> {\n const svgText = buffer.toString(\"utf8\");\n\n const { svg: cleanSvg } = sanitiseSVG(svgText, {\n allowedTags: [\n \"svg\",\n \"g\",\n \"rect\",\n \"circle\",\n \"ellipse\",\n \"line\",\n \"polygon\",\n \"polyline\",\n \"path\",\n \"text\",\n \"title\",\n \"desc\",\n ],\n allowedAttributes: {\n \"*\": [\n \"width\",\n \"height\",\n \"viewBox\",\n \"xmlns\",\n \"transform\",\n \"x\",\n \"y\",\n \"cx\",\n \"cy\",\n \"r\",\n \"rx\",\n \"ry\",\n \"x1\",\n \"y1\",\n \"x2\",\n \"y2\",\n \"points\",\n \"d\",\n \"fill\",\n \"stroke\",\n \"stroke-width\",\n \"font-family\",\n \"font-size\",\n \"text-anchor\",\n ],\n },\n\n allowComments: false,\n allowUnknownElements: false,\n allowUnknownAttributes: false,\n });\n\n const cleanBuffer = Buffer.from(cleanSvg, \"utf8\");\n\n // Basic sanity checks\n if (cleanBuffer.length > 256 * 1024) {\n throw new Error(\"SVG too large (max 256 KB)\");\n }\n\n return Promise.resolve({\n width: 0, // SVG is scalable, can’t guarantee pixel dimensions\n height: 0,\n format: \"svg\",\n size: cleanBuffer.length,\n safeBuffer: cleanBuffer,\n });\n}\n","import sharp from \"sharp\";\nimport { validateSvgAvatar } from \"./svgValidator.js\";\n\nexport interface ImageValidationResult {\n width: number;\n height: number;\n format: string;\n size: number;\n safeBuffer: Buffer;\n}\n\nexport async function validateAvatarImage(\n buffer: Buffer\n): Promise<ImageValidationResult> {\n const metadata = await sharp(buffer).metadata();\n const format = metadata.format;\n\n if (!format) {\n throw new Error(\"Unsupported or unknown image format\");\n }\n\n const width = metadata.width;\n const height = metadata.height;\n\n if (!width || !height) {\n throw new Error(\"Could not determine image dimensions\");\n }\n\n if (width < 64 || height < 64) {\n throw new Error(\"Image too small (min 64x64)\");\n }\n\n if (width > 2048 || height > 2048) {\n throw new Error(\"Image too large (max 2048x2048)\");\n }\n\n const targetFormat = \"png\"; // common safe web format\n\n let safeBuffer: Buffer;\n\n if (format === \"svg\") {\n const svgText = buffer.toString(\"utf8\");\n const svgValidation = await validateSvgAvatar(\n Buffer.from(svgText, \"utf-8\")\n );\n safeBuffer = await sharp(svgValidation.safeBuffer)\n .resize({\n width: Math.min(width, 2048),\n height: Math.min(height, 2048),\n fit: \"inside\",\n })\n .toFormat(targetFormat)\n .toBuffer();\n } else {\n safeBuffer = await sharp(buffer)\n .resize({\n width: Math.min(width, 2048),\n height: Math.min(height, 2048),\n fit: \"inside\",\n })\n .toFormat(targetFormat, { quality: 90 })\n .toBuffer();\n }\n\n return {\n width,\n height,\n format,\n size: buffer.length,\n safeBuffer,\n };\n}\n"],"mappings":"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","names":["sharp"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
interface ImageValidationResult {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
format: string;
|
|
5
|
+
size: number;
|
|
6
|
+
safeBuffer: Buffer;
|
|
7
|
+
}
|
|
8
|
+
declare function validateAvatarImage(buffer: Buffer): Promise<ImageValidationResult>;
|
|
9
|
+
|
|
10
|
+
declare function sanitiseSVG(svgText: string, options: {
|
|
11
|
+
allowedTags: string[];
|
|
12
|
+
allowedAttributes: {
|
|
13
|
+
"*": string[];
|
|
14
|
+
};
|
|
15
|
+
allowComments: boolean;
|
|
16
|
+
allowUnknownElements: boolean;
|
|
17
|
+
allowUnknownAttributes: boolean;
|
|
18
|
+
}): {
|
|
19
|
+
svg: string;
|
|
20
|
+
audit: {
|
|
21
|
+
strippedTags: string[];
|
|
22
|
+
strippedAttributes: string[];
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
declare function validateSvgAvatar(buffer: Buffer): Promise<ImageValidationResult>;
|
|
26
|
+
|
|
27
|
+
export { type ImageValidationResult, sanitiseSVG, validateAvatarImage, validateSvgAvatar };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
interface ImageValidationResult {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
format: string;
|
|
5
|
+
size: number;
|
|
6
|
+
safeBuffer: Buffer;
|
|
7
|
+
}
|
|
8
|
+
declare function validateAvatarImage(buffer: Buffer): Promise<ImageValidationResult>;
|
|
9
|
+
|
|
10
|
+
declare function sanitiseSVG(svgText: string, options: {
|
|
11
|
+
allowedTags: string[];
|
|
12
|
+
allowedAttributes: {
|
|
13
|
+
"*": string[];
|
|
14
|
+
};
|
|
15
|
+
allowComments: boolean;
|
|
16
|
+
allowUnknownElements: boolean;
|
|
17
|
+
allowUnknownAttributes: boolean;
|
|
18
|
+
}): {
|
|
19
|
+
svg: string;
|
|
20
|
+
audit: {
|
|
21
|
+
strippedTags: string[];
|
|
22
|
+
strippedAttributes: string[];
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
declare function validateSvgAvatar(buffer: Buffer): Promise<ImageValidationResult>;
|
|
26
|
+
|
|
27
|
+
export { type ImageValidationResult, sanitiseSVG, validateAvatarImage, validateSvgAvatar };
|
package/dist/index.js
CHANGED
|
@@ -1 +1,186 @@
|
|
|
1
|
-
|
|
1
|
+
// src/validators/svgValidator.ts
|
|
2
|
+
import { DOMParser, XMLSerializer } from "@xmldom/xmldom";
|
|
3
|
+
function sanitiseSVG(svgText, options) {
|
|
4
|
+
const strippedTags = [];
|
|
5
|
+
const strippedAttributes = [];
|
|
6
|
+
const parser = new DOMParser({
|
|
7
|
+
onError: (level, msg) => {
|
|
8
|
+
switch (level) {
|
|
9
|
+
case "warning":
|
|
10
|
+
break;
|
|
11
|
+
default:
|
|
12
|
+
throw new Error(msg);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
const serializer = new XMLSerializer();
|
|
17
|
+
const doc = parser.parseFromString(svgText, "image/svg+xml");
|
|
18
|
+
function cleanNode(node) {
|
|
19
|
+
if (node === null) return;
|
|
20
|
+
if (node.nodeType === 8 && !options.allowComments) {
|
|
21
|
+
node.parentNode?.removeChild(node);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (node.nodeType === 1) {
|
|
25
|
+
const el = node;
|
|
26
|
+
const tagName = el.tagName.toLowerCase();
|
|
27
|
+
if (tagName === "style") {
|
|
28
|
+
strippedTags.push(tagName);
|
|
29
|
+
node.parentNode?.removeChild(node);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (!options.allowedTags.includes(tagName) && !options.allowUnknownElements) {
|
|
33
|
+
strippedTags.push(tagName);
|
|
34
|
+
node.parentNode?.removeChild(node);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const allowedAttrs = options.allowedAttributes["*"] || [];
|
|
38
|
+
const toRemove = [];
|
|
39
|
+
for (const attr of Array.from(el.attributes)) {
|
|
40
|
+
const attrName = attr.name.toLowerCase();
|
|
41
|
+
if (attrName.startsWith("on")) {
|
|
42
|
+
toRemove.push(attr.name);
|
|
43
|
+
strippedAttributes.push(`${tagName}.${attr.name}`);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (attrName === "xlink:href" || attrName === "xmlns:xlink") {
|
|
47
|
+
toRemove.push(attr.name);
|
|
48
|
+
strippedAttributes.push(`${tagName}.${attr.name}`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (attrName === "style") {
|
|
52
|
+
toRemove.push(attr.name);
|
|
53
|
+
strippedAttributes.push(`${tagName}.${attr.name}`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!allowedAttrs.includes(attr.name) && !options.allowUnknownAttributes) {
|
|
57
|
+
toRemove.push(attr.name);
|
|
58
|
+
strippedAttributes.push(`${tagName}.${attr.name}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
toRemove.forEach((attrName) => el.removeAttribute(attrName));
|
|
62
|
+
}
|
|
63
|
+
const children = Array.from(node.childNodes);
|
|
64
|
+
children.forEach((child) => cleanNode(child));
|
|
65
|
+
}
|
|
66
|
+
cleanNode(doc.documentElement);
|
|
67
|
+
return {
|
|
68
|
+
svg: serializer.serializeToString(doc),
|
|
69
|
+
audit: { strippedTags, strippedAttributes }
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
function validateSvgAvatar(buffer) {
|
|
73
|
+
const svgText = buffer.toString("utf8");
|
|
74
|
+
const { svg: cleanSvg } = sanitiseSVG(svgText, {
|
|
75
|
+
allowedTags: [
|
|
76
|
+
"svg",
|
|
77
|
+
"g",
|
|
78
|
+
"rect",
|
|
79
|
+
"circle",
|
|
80
|
+
"ellipse",
|
|
81
|
+
"line",
|
|
82
|
+
"polygon",
|
|
83
|
+
"polyline",
|
|
84
|
+
"path",
|
|
85
|
+
"text",
|
|
86
|
+
"title",
|
|
87
|
+
"desc"
|
|
88
|
+
],
|
|
89
|
+
allowedAttributes: {
|
|
90
|
+
"*": [
|
|
91
|
+
"width",
|
|
92
|
+
"height",
|
|
93
|
+
"viewBox",
|
|
94
|
+
"xmlns",
|
|
95
|
+
"transform",
|
|
96
|
+
"x",
|
|
97
|
+
"y",
|
|
98
|
+
"cx",
|
|
99
|
+
"cy",
|
|
100
|
+
"r",
|
|
101
|
+
"rx",
|
|
102
|
+
"ry",
|
|
103
|
+
"x1",
|
|
104
|
+
"y1",
|
|
105
|
+
"x2",
|
|
106
|
+
"y2",
|
|
107
|
+
"points",
|
|
108
|
+
"d",
|
|
109
|
+
"fill",
|
|
110
|
+
"stroke",
|
|
111
|
+
"stroke-width",
|
|
112
|
+
"font-family",
|
|
113
|
+
"font-size",
|
|
114
|
+
"text-anchor"
|
|
115
|
+
]
|
|
116
|
+
},
|
|
117
|
+
allowComments: false,
|
|
118
|
+
allowUnknownElements: false,
|
|
119
|
+
allowUnknownAttributes: false
|
|
120
|
+
});
|
|
121
|
+
const cleanBuffer = Buffer.from(cleanSvg, "utf8");
|
|
122
|
+
if (cleanBuffer.length > 256 * 1024) {
|
|
123
|
+
throw new Error("SVG too large (max 256 KB)");
|
|
124
|
+
}
|
|
125
|
+
return Promise.resolve({
|
|
126
|
+
width: 0,
|
|
127
|
+
// SVG is scalable, can’t guarantee pixel dimensions
|
|
128
|
+
height: 0,
|
|
129
|
+
format: "svg",
|
|
130
|
+
size: cleanBuffer.length,
|
|
131
|
+
safeBuffer: cleanBuffer
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/validators/avatarValidator.ts
|
|
136
|
+
import sharp from "sharp";
|
|
137
|
+
async function validateAvatarImage(buffer) {
|
|
138
|
+
const metadata = await sharp(buffer).metadata();
|
|
139
|
+
const format = metadata.format;
|
|
140
|
+
if (!format) {
|
|
141
|
+
throw new Error("Unsupported or unknown image format");
|
|
142
|
+
}
|
|
143
|
+
const width = metadata.width;
|
|
144
|
+
const height = metadata.height;
|
|
145
|
+
if (!width || !height) {
|
|
146
|
+
throw new Error("Could not determine image dimensions");
|
|
147
|
+
}
|
|
148
|
+
if (width < 64 || height < 64) {
|
|
149
|
+
throw new Error("Image too small (min 64x64)");
|
|
150
|
+
}
|
|
151
|
+
if (width > 2048 || height > 2048) {
|
|
152
|
+
throw new Error("Image too large (max 2048x2048)");
|
|
153
|
+
}
|
|
154
|
+
const targetFormat = "png";
|
|
155
|
+
let safeBuffer;
|
|
156
|
+
if (format === "svg") {
|
|
157
|
+
const svgText = buffer.toString("utf8");
|
|
158
|
+
const svgValidation = await validateSvgAvatar(
|
|
159
|
+
Buffer.from(svgText, "utf-8")
|
|
160
|
+
);
|
|
161
|
+
safeBuffer = await sharp(svgValidation.safeBuffer).resize({
|
|
162
|
+
width: Math.min(width, 2048),
|
|
163
|
+
height: Math.min(height, 2048),
|
|
164
|
+
fit: "inside"
|
|
165
|
+
}).toFormat(targetFormat).toBuffer();
|
|
166
|
+
} else {
|
|
167
|
+
safeBuffer = await sharp(buffer).resize({
|
|
168
|
+
width: Math.min(width, 2048),
|
|
169
|
+
height: Math.min(height, 2048),
|
|
170
|
+
fit: "inside"
|
|
171
|
+
}).toFormat(targetFormat, { quality: 90 }).toBuffer();
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
width,
|
|
175
|
+
height,
|
|
176
|
+
format,
|
|
177
|
+
size: buffer.length,
|
|
178
|
+
safeBuffer
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export {
|
|
182
|
+
sanitiseSVG,
|
|
183
|
+
validateAvatarImage,
|
|
184
|
+
validateSvgAvatar
|
|
185
|
+
};
|
|
186
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/validators/svgValidator.ts","../src/validators/avatarValidator.ts"],"sourcesContent":["import type { ImageValidationResult } from \"./avatarValidator.js\";\nimport { DOMParser, XMLSerializer } from \"@xmldom/xmldom\";\nimport type { Element as XmlElement } from \"@xmldom/xmldom\";\n\nexport function sanitiseSVG(\n svgText: string,\n options: {\n allowedTags: string[];\n allowedAttributes: { \"*\": string[] };\n allowComments: boolean;\n allowUnknownElements: boolean;\n allowUnknownAttributes: boolean;\n }\n): {\n svg: string;\n audit: { strippedTags: string[]; strippedAttributes: string[] };\n} {\n const strippedTags: string[] = [];\n const strippedAttributes: string[] = [];\n\n const parser = new DOMParser({\n onError: (\n level: \"warning\" | \"error\" | \"fatalError\",\n msg: string\n ): void => {\n switch (level) {\n case \"warning\":\n break;\n default:\n throw new Error(msg);\n }\n },\n });\n const serializer = new XMLSerializer();\n const doc = parser.parseFromString(svgText, \"image/svg+xml\");\n\n function cleanNode(node: Node | null) {\n // TODO: optionally sanitize <style> elements and inline style attributes\n\n if (node === null) return;\n\n if (node.nodeType === 8 /* Comment */ && !options.allowComments) {\n node.parentNode?.removeChild(node);\n return;\n }\n\n if (node.nodeType === 1 /* Element */) {\n const el = node as unknown as XmlElement;\n const tagName = el.tagName.toLowerCase();\n\n // Strip <style> elements entirely\n if (tagName === \"style\") {\n strippedTags.push(tagName);\n node.parentNode?.removeChild(node);\n return;\n }\n\n if (\n !options.allowedTags.includes(tagName) &&\n !options.allowUnknownElements\n ) {\n strippedTags.push(tagName);\n node.parentNode?.removeChild(node);\n return;\n }\n\n // Clean attributes\n const allowedAttrs = options.allowedAttributes[\"*\"] || [];\n\n const toRemove: string[] = [];\n for (const attr of Array.from(el.attributes)) {\n const attrName = attr.name.toLowerCase();\n\n if (attrName.startsWith(\"on\")) {\n toRemove.push(attr.name);\n strippedAttributes.push(`${tagName}.${attr.name}`);\n continue;\n }\n if (attrName === \"xlink:href\" || attrName === \"xmlns:xlink\") {\n toRemove.push(attr.name);\n strippedAttributes.push(`${tagName}.${attr.name}`);\n continue;\n }\n if (attrName === \"style\") {\n toRemove.push(attr.name);\n strippedAttributes.push(`${tagName}.${attr.name}`);\n continue;\n }\n\n if (\n !allowedAttrs.includes(attr.name) &&\n !options.allowUnknownAttributes\n ) {\n toRemove.push(attr.name);\n strippedAttributes.push(`${tagName}.${attr.name}`);\n }\n }\n toRemove.forEach((attrName) => el.removeAttribute(attrName));\n }\n\n // Recursively clean children\n const children = Array.from(node.childNodes);\n children.forEach((child) => cleanNode(child));\n }\n\n cleanNode(doc.documentElement as unknown as Node);\n\n return {\n svg: serializer.serializeToString(doc),\n audit: { strippedTags, strippedAttributes },\n };\n}\n\nexport function validateSvgAvatar(\n buffer: Buffer\n): Promise<ImageValidationResult> {\n const svgText = buffer.toString(\"utf8\");\n\n const { svg: cleanSvg } = sanitiseSVG(svgText, {\n allowedTags: [\n \"svg\",\n \"g\",\n \"rect\",\n \"circle\",\n \"ellipse\",\n \"line\",\n \"polygon\",\n \"polyline\",\n \"path\",\n \"text\",\n \"title\",\n \"desc\",\n ],\n allowedAttributes: {\n \"*\": [\n \"width\",\n \"height\",\n \"viewBox\",\n \"xmlns\",\n \"transform\",\n \"x\",\n \"y\",\n \"cx\",\n \"cy\",\n \"r\",\n \"rx\",\n \"ry\",\n \"x1\",\n \"y1\",\n \"x2\",\n \"y2\",\n \"points\",\n \"d\",\n \"fill\",\n \"stroke\",\n \"stroke-width\",\n \"font-family\",\n \"font-size\",\n \"text-anchor\",\n ],\n },\n\n allowComments: false,\n allowUnknownElements: false,\n allowUnknownAttributes: false,\n });\n\n const cleanBuffer = Buffer.from(cleanSvg, \"utf8\");\n\n // Basic sanity checks\n if (cleanBuffer.length > 256 * 1024) {\n throw new Error(\"SVG too large (max 256 KB)\");\n }\n\n return Promise.resolve({\n width: 0, // SVG is scalable, can’t guarantee pixel dimensions\n height: 0,\n format: \"svg\",\n size: cleanBuffer.length,\n safeBuffer: cleanBuffer,\n });\n}\n","import sharp from \"sharp\";\nimport { validateSvgAvatar } from \"./svgValidator.js\";\n\nexport interface ImageValidationResult {\n width: number;\n height: number;\n format: string;\n size: number;\n safeBuffer: Buffer;\n}\n\nexport async function validateAvatarImage(\n buffer: Buffer\n): Promise<ImageValidationResult> {\n const metadata = await sharp(buffer).metadata();\n const format = metadata.format;\n\n if (!format) {\n throw new Error(\"Unsupported or unknown image format\");\n }\n\n const width = metadata.width;\n const height = metadata.height;\n\n if (!width || !height) {\n throw new Error(\"Could not determine image dimensions\");\n }\n\n if (width < 64 || height < 64) {\n throw new Error(\"Image too small (min 64x64)\");\n }\n\n if (width > 2048 || height > 2048) {\n throw new Error(\"Image too large (max 2048x2048)\");\n }\n\n const targetFormat = \"png\"; // common safe web format\n\n let safeBuffer: Buffer;\n\n if (format === \"svg\") {\n const svgText = buffer.toString(\"utf8\");\n const svgValidation = await validateSvgAvatar(\n Buffer.from(svgText, \"utf-8\")\n );\n safeBuffer = await sharp(svgValidation.safeBuffer)\n .resize({\n width: Math.min(width, 2048),\n height: Math.min(height, 2048),\n fit: \"inside\",\n })\n .toFormat(targetFormat)\n .toBuffer();\n } else {\n safeBuffer = await sharp(buffer)\n .resize({\n width: Math.min(width, 2048),\n height: Math.min(height, 2048),\n fit: \"inside\",\n })\n .toFormat(targetFormat, { quality: 90 })\n .toBuffer();\n }\n\n return {\n width,\n height,\n format,\n size: buffer.length,\n safeBuffer,\n };\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,26 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plasius/images",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"main": "./dist
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"main": "./dist/index.cjs",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"private": false,
|
|
7
7
|
"type": "module",
|
|
8
8
|
"description": "Image validator and uploader for Plasius projects",
|
|
9
9
|
"scripts": {
|
|
10
|
-
"build": "
|
|
10
|
+
"build": "tsup",
|
|
11
11
|
"test": "vitest run",
|
|
12
12
|
"test:watch": "vitest",
|
|
13
13
|
"test:coverage": "vitest run --coverage",
|
|
14
14
|
"test:coverage:watch": "vitest --coverage",
|
|
15
|
-
"clean": "rimraf dist-cjs
|
|
15
|
+
"clean": "rimraf dist dist-cjs tsconfig.tsbuildinfo",
|
|
16
16
|
"reset:clean": "rm -rf node_modules package-lock.json && npm run clean",
|
|
17
17
|
"audit:ts": "tsc --noEmit --pretty",
|
|
18
18
|
"audit:eslint": "eslint \"{src,apps,packages}/**/*.{ts,tsx}\" --max-warnings=0 --ext .ts,.tsx",
|
|
19
|
-
"audit:deps": "
|
|
19
|
+
"audit:deps": "npm ls --all --omit=optional --omit=peer > /dev/null 2>&1 || true",
|
|
20
20
|
"audit:npm": "npm audit --audit-level=moderate || true",
|
|
21
21
|
"audit:test": "vitest run --coverage",
|
|
22
22
|
"audit:all": "npm-run-all -l audit:ts audit:eslint audit:deps audit:npm audit:test",
|
|
23
|
-
"build:cjs": "tsc -p tsconfig.json --module commonjs --moduleResolution node --outDir dist-cjs --tsBuildInfoFile dist-cjs/tsconfig.tsbuildinfo",
|
|
24
23
|
"lint": "eslint .",
|
|
25
24
|
"prepare": "npm run build"
|
|
26
25
|
},
|
|
@@ -36,35 +35,34 @@
|
|
|
36
35
|
},
|
|
37
36
|
"devDependencies": {
|
|
38
37
|
"@azure/cosmos": "^4.4.1",
|
|
39
|
-
"@types/node": "^24.3.1",
|
|
40
38
|
"@testing-library/react": "^16.3.0",
|
|
39
|
+
"@types/node": "^24.3.1",
|
|
41
40
|
"@types/react": "^19.1.8",
|
|
42
41
|
"@types/uuid": "^10.0.0",
|
|
43
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
44
|
-
"@typescript-eslint/parser": "^8.
|
|
45
|
-
"@vitest/coverage-v8": "^
|
|
42
|
+
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
|
43
|
+
"@typescript-eslint/parser": "^8.56.0",
|
|
44
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
46
45
|
"ajv": "^6.12.6",
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"npm-run-all": "^4.1.5",
|
|
46
|
+
"eslint": "^10.0.1",
|
|
47
|
+
"npm-run-all": "^1.1.3",
|
|
50
48
|
"react": "^19.1.0",
|
|
49
|
+
"tsup": "^8.5.0",
|
|
51
50
|
"tsx": "^4.20.3",
|
|
52
51
|
"typescript": "^5.8.3",
|
|
53
|
-
"vitest": "^
|
|
52
|
+
"vitest": "^4.0.18",
|
|
54
53
|
"zod": "^4.0.17"
|
|
55
54
|
},
|
|
56
55
|
"exports": {
|
|
57
56
|
".": {
|
|
58
57
|
"types": "./dist/index.d.ts",
|
|
59
58
|
"import": "./dist/index.js",
|
|
60
|
-
"require": "./dist
|
|
59
|
+
"require": "./dist/index.cjs"
|
|
61
60
|
},
|
|
62
61
|
"./package.json": "./package.json"
|
|
63
62
|
},
|
|
64
63
|
"module": "./dist/index.js",
|
|
65
64
|
"files": [
|
|
66
65
|
"dist",
|
|
67
|
-
"dist-cjs",
|
|
68
66
|
"src",
|
|
69
67
|
"README.md",
|
|
70
68
|
"CHANGELOG.md",
|
|
@@ -98,5 +96,8 @@
|
|
|
98
96
|
],
|
|
99
97
|
"engines": {
|
|
100
98
|
"node": ">=22.12"
|
|
99
|
+
},
|
|
100
|
+
"overrides": {
|
|
101
|
+
"minimatch": "^10.2.1"
|
|
101
102
|
}
|
|
102
103
|
}
|
package/dist/detectFormat.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"detectFormat.d.ts","sourceRoot":"","sources":["../src/detectFormat.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,CAC1B,MAAM,EAAE,MAAM,GACb,MAAM,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,SAAS,CAyB7C"}
|