5etools-utils 0.15.3 → 0.15.5
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/README.md +5 -0
- package/bin/clean-html.js +5 -0
- package/bin/test-file-contents.js +19 -0
- package/bin/test-file-names.js +11 -1
- package/bin/test-img-file-extensions.js +5 -0
- package/bin/test-img-file-sizes.js +5 -0
- package/bin/test-img-source-names-brew.js +5 -0
- package/bin/test-img-source-names-ua.js +5 -0
- package/lib/Api.js +4 -0
- package/lib/BrewCleanerHtml.js +175 -0
- package/lib/BrewCleanerHtmlWorker.js +47 -0
- package/lib/BrewTester/BrewTesterFileContents.js +207 -0
- package/lib/BrewTester/BrewTesterFileNames.js +7 -2
- package/lib/BrewTester/BrewTesterImgDirectories.js +32 -0
- package/lib/BrewTester.js +5 -1
- package/lib/BrewTesterImg/BrewTesterImgConsts.js +31 -0
- package/lib/BrewTesterImg/BrewTesterImgFileExtensions.js +34 -0
- package/lib/BrewTesterImg/BrewTesterImgFileSizes.js +33 -0
- package/lib/BrewTesterImg/BrewTesterImgSourceNames.js +38 -0
- package/lib/BrewTesterImg.js +9 -0
- package/lib/UtilSource.js +70 -25
- package/package.json +11 -3
package/README.md
CHANGED
|
@@ -3,6 +3,11 @@
|
|
|
3
3
|
Available commands:
|
|
4
4
|
|
|
5
5
|
```
|
|
6
|
+
npx clean-html
|
|
7
|
+
npx test-file-names [--name-regex <regex>]
|
|
8
|
+
npx test-file-contents --img-repo-name <repo> --url-prefix-expected <prefix>
|
|
6
9
|
npx test-json-brew [file] [--dir <dir>]
|
|
7
10
|
npx test-json-ua [file] [--dir <dir>]
|
|
8
11
|
```
|
|
12
|
+
|
|
13
|
+
And more; see `bin/`.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import {Command} from "commander";
|
|
4
|
+
import {BrewTester} from "../lib/BrewTester.js";
|
|
5
|
+
|
|
6
|
+
const program = new Command()
|
|
7
|
+
.requiredOption("--img-repo-name <repo>", "Image repo short name for logs, e.g. \"homebrew-img\"")
|
|
8
|
+
.requiredOption("--url-prefix-expected <prefix>", "Expected URL prefix to match against image URLs")
|
|
9
|
+
.option("--path-error-log <path>", "Path to write full check output", "./_test/test-data.error.log")
|
|
10
|
+
;
|
|
11
|
+
|
|
12
|
+
program.parse(process.argv);
|
|
13
|
+
const opts = program.opts();
|
|
14
|
+
|
|
15
|
+
await BrewTester.pTestFileContents({
|
|
16
|
+
imgRepoName: opts.imgRepoName,
|
|
17
|
+
urlPrefixExpected: opts.urlPrefixExpected,
|
|
18
|
+
pathErrorLog: opts.pathErrorLog,
|
|
19
|
+
});
|
package/bin/test-file-names.js
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import {BrewTester} from "../lib/BrewTester.js";
|
|
4
|
+
import {Command} from "commander";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
const program = new Command()
|
|
7
|
+
.option("--name-regex <regex>", "Regex pattern used to validate file names")
|
|
8
|
+
;
|
|
9
|
+
|
|
10
|
+
program.parse(process.argv);
|
|
11
|
+
const opts = program.opts();
|
|
12
|
+
|
|
13
|
+
await BrewTester.pTestFileNames({
|
|
14
|
+
reNameFormat: opts.nameRegex ? new RegExp(opts.nameRegex) : null,
|
|
15
|
+
});
|
package/lib/Api.js
CHANGED
|
@@ -3,8 +3,10 @@ import Um from "./UtilMisc.js";
|
|
|
3
3
|
import * as Uf from "./UtilFs.js";
|
|
4
4
|
import {BrewIndexGenerator} from "./BrewIndexGenerator.js";
|
|
5
5
|
import {BrewCleaner} from "./BrewCleaner.js";
|
|
6
|
+
import {BrewCleanerHtml} from "./BrewCleanerHtml.js";
|
|
6
7
|
import {BrewTimestamper} from "./BrewTimestamper.js";
|
|
7
8
|
import {BrewTester} from "./BrewTester.js";
|
|
9
|
+
import {BrewTesterImg} from "./BrewTesterImg.js";
|
|
8
10
|
import {getCleanJson, getCleanString} from "./UtilClean.js";
|
|
9
11
|
import {DataTester, DataTesterBase, BraceCheck, EscapeCharacterCheck} from "./TestData.js";
|
|
10
12
|
import {ObjectWalker, SymObjectWalkerBreak} from "./ObjectWalker.js";
|
|
@@ -16,8 +18,10 @@ export {
|
|
|
16
18
|
Uf,
|
|
17
19
|
BrewIndexGenerator,
|
|
18
20
|
BrewCleaner,
|
|
21
|
+
BrewCleanerHtml,
|
|
19
22
|
BrewTimestamper,
|
|
20
23
|
BrewTester,
|
|
24
|
+
BrewTesterImg,
|
|
21
25
|
getCleanJson,
|
|
22
26
|
getCleanString,
|
|
23
27
|
DataTester,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import {Worker} from "node:worker_threads";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import he from "he";
|
|
5
|
+
import sanitizeHtml from "sanitize-html";
|
|
6
|
+
import {getCleanJson} from "./UtilClean.js";
|
|
7
|
+
import {ObjectWalker} from "./ObjectWalker.js";
|
|
8
|
+
import * as Uf from "./UtilFs.js";
|
|
9
|
+
import Um from "./UtilMisc.js";
|
|
10
|
+
import {Deferred, WorkerList} from "./WorkerList.js";
|
|
11
|
+
|
|
12
|
+
export class BrewCleanerHtml {
|
|
13
|
+
static _LOG_TAG = `HTML`;
|
|
14
|
+
|
|
15
|
+
static _OPTS_SANITIZE = {
|
|
16
|
+
allowedTags: [
|
|
17
|
+
// region Custom things which look like tags
|
|
18
|
+
"<$name$>",
|
|
19
|
+
// endregion
|
|
20
|
+
],
|
|
21
|
+
allowedAttributes: {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
static _getCleanFileMeta ({file}) {
|
|
25
|
+
const fileData = Uf.readJsonSync(file);
|
|
26
|
+
|
|
27
|
+
const messages = [];
|
|
28
|
+
|
|
29
|
+
const {_meta, _test} = fileData;
|
|
30
|
+
delete fileData._meta;
|
|
31
|
+
delete fileData._test;
|
|
32
|
+
|
|
33
|
+
const keyStack = [];
|
|
34
|
+
const objectStack = [];
|
|
35
|
+
|
|
36
|
+
const isInFoundryDescriptionEffect = () => {
|
|
37
|
+
if (objectStack.at(-1)?.key !== "system.description.value") return false;
|
|
38
|
+
return keyStack.at(-1) === "changes" && ["effects", "foundryEffects"].includes(keyStack.at(-2));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const fileOut = ObjectWalker.walk({
|
|
42
|
+
obj: fileData,
|
|
43
|
+
filePath: file,
|
|
44
|
+
primitiveHandlers: {
|
|
45
|
+
string: (str, {lastKey}) => {
|
|
46
|
+
if (lastKey === "value" && isInFoundryDescriptionEffect()) return str;
|
|
47
|
+
|
|
48
|
+
const clean = he.unescape(
|
|
49
|
+
sanitizeHtml(
|
|
50
|
+
str,
|
|
51
|
+
this._OPTS_SANITIZE,
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
if (clean !== str) {
|
|
56
|
+
const msg = `Sanitized ${keyStack.map(k => `"${k}"`).join(" -> ")}:\n${str}\n${clean}`;
|
|
57
|
+
messages.push(msg);
|
|
58
|
+
Um.info(this._LOG_TAG, msg);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return clean;
|
|
62
|
+
},
|
|
63
|
+
preObject: (obj) => objectStack.push(obj),
|
|
64
|
+
postObject: () => objectStack.pop(),
|
|
65
|
+
preArray: (_, {lastKey}) => keyStack.push(lastKey),
|
|
66
|
+
postArray: () => keyStack.pop(),
|
|
67
|
+
},
|
|
68
|
+
isModify: true,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const out = {$schema: fileOut.$schema, _meta, _test};
|
|
72
|
+
Object.assign(out, fileOut);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
messages,
|
|
76
|
+
out,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
static async _pUpdateDir (dir) {
|
|
81
|
+
Uf.listJsonFiles(dir)
|
|
82
|
+
.forEach(file => {
|
|
83
|
+
const {messages, out} = this._getCleanFileMeta({file});
|
|
84
|
+
if (!messages?.length) return;
|
|
85
|
+
|
|
86
|
+
fs.writeFileSync(file, getCleanJson(out));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static async pRun () {
|
|
91
|
+
await Uf.pRunOnDirs(
|
|
92
|
+
async (dir) => {
|
|
93
|
+
Um.info(this._LOG_TAG, `Sanitizing HTML in dir "${dir}"...`);
|
|
94
|
+
await this._pUpdateDir(dir);
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
isSerial: true,
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
Um.info(this._LOG_TAG, "Done!");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
static getFileMessages ({file}) {
|
|
104
|
+
return this._getCleanFileMeta({file});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
static async pGetErrorsOnDirsWorkers ({isFailFast = false} = {}) {
|
|
108
|
+
Um.info(this._LOG_TAG, `Testing for HTML...`);
|
|
109
|
+
|
|
110
|
+
const cntWorkers = Math.max(1, os.cpus().length - 1);
|
|
111
|
+
|
|
112
|
+
const messages = [];
|
|
113
|
+
|
|
114
|
+
const fileQueue = [];
|
|
115
|
+
Uf.runOnDirs((dir) => fileQueue.push(...Uf.listJsonFiles(dir)));
|
|
116
|
+
|
|
117
|
+
const workerList = new WorkerList();
|
|
118
|
+
|
|
119
|
+
let cntFailures = 0;
|
|
120
|
+
const workers = [...new Array(cntWorkers)]
|
|
121
|
+
.map(() => {
|
|
122
|
+
const worker = new Worker(new URL("./BrewCleanerHtmlWorker.js", import.meta.url));
|
|
123
|
+
|
|
124
|
+
worker.on("message", (msg) => {
|
|
125
|
+
switch (msg.type) {
|
|
126
|
+
case "ready":
|
|
127
|
+
case "done": {
|
|
128
|
+
if (msg.payload.isError) {
|
|
129
|
+
messages.push(...msg.payload.messages);
|
|
130
|
+
|
|
131
|
+
if (isFailFast) workers.forEach(worker => worker.postMessage({type: "cancel"}));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (worker.dIsActive) worker.dIsActive.resolve();
|
|
135
|
+
workerList.add(worker);
|
|
136
|
+
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
worker.on("error", e => {
|
|
143
|
+
console.error(e);
|
|
144
|
+
cntFailures++;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
worker.postMessage({
|
|
148
|
+
type: "init",
|
|
149
|
+
payload: {},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return worker;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
while (fileQueue.length) {
|
|
156
|
+
if (isFailFast && messages.length) break;
|
|
157
|
+
|
|
158
|
+
const file = fileQueue.shift();
|
|
159
|
+
const worker = await workerList.get();
|
|
160
|
+
|
|
161
|
+
worker.dIsActive = new Deferred();
|
|
162
|
+
worker.postMessage({
|
|
163
|
+
type: "work",
|
|
164
|
+
payload: {
|
|
165
|
+
file,
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
await Promise.all(workers.map(it => it.dIsActive?.promise));
|
|
171
|
+
await Promise.all(workers.map(it => it.terminate()));
|
|
172
|
+
|
|
173
|
+
return {messages, isUnknownError: !!cntFailures};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {isMainThread, parentPort} from "node:worker_threads";
|
|
2
|
+
import {BrewCleanerHtml} from "./BrewCleanerHtml.js";
|
|
3
|
+
|
|
4
|
+
if (isMainThread) throw new Error(`Worker must not be started in main thread!`);
|
|
5
|
+
|
|
6
|
+
let isCancelled = false;
|
|
7
|
+
|
|
8
|
+
parentPort
|
|
9
|
+
.on("message", async msg => {
|
|
10
|
+
switch (msg.type) {
|
|
11
|
+
case "init": {
|
|
12
|
+
parentPort.postMessage({
|
|
13
|
+
type: "ready",
|
|
14
|
+
payload: {},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
case "cancel": {
|
|
21
|
+
isCancelled = true;
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
case "work": {
|
|
26
|
+
if (isCancelled) {
|
|
27
|
+
parentPort.postMessage({
|
|
28
|
+
type: "done",
|
|
29
|
+
payload: {},
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const {messages = []} = BrewCleanerHtml.getFileMessages({file: msg.payload.file});
|
|
35
|
+
|
|
36
|
+
parentPort.postMessage({
|
|
37
|
+
type: "done",
|
|
38
|
+
payload: {
|
|
39
|
+
isError: !!messages.length,
|
|
40
|
+
messages,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
break;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import {BrewTesterBase} from "./BrewTesterBase.js";
|
|
3
|
+
import {DataTester, DataTesterBase, BraceCheck, EscapeCharacterCheck} from "../TestData.js";
|
|
4
|
+
import * as Uf from "../UtilFs.js";
|
|
5
|
+
import {ObjectWalker} from "../ObjectWalker.js";
|
|
6
|
+
import {UtilSource} from "../UtilSource.js";
|
|
7
|
+
import Um from "../UtilMisc.js";
|
|
8
|
+
|
|
9
|
+
class _CopySourceCheck extends DataTesterBase {
|
|
10
|
+
static _FileState = class {
|
|
11
|
+
sources;
|
|
12
|
+
dependencies;
|
|
13
|
+
internalCopies;
|
|
14
|
+
|
|
15
|
+
constructor ({contents}) {
|
|
16
|
+
this.sources = new Set(
|
|
17
|
+
(contents._meta?.sources?.map(src => src?.json) || [])
|
|
18
|
+
.filter(Boolean),
|
|
19
|
+
);
|
|
20
|
+
this.dependencies = Object.fromEntries(
|
|
21
|
+
Object.entries(contents._meta?.dependencies || {})
|
|
22
|
+
.map(([prop, arr]) => [prop, new Set(arr)]),
|
|
23
|
+
);
|
|
24
|
+
this.internalCopies = new Set(contents._meta?.internalCopies || []);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
registerParsedFileCheckers (parsedJsonChecker) {
|
|
29
|
+
parsedJsonChecker.registerFileHandler(this);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
handleFile (file, contents) {
|
|
33
|
+
if (!file.includes("Kobold Press; Scarlet Citadel.json")) return;
|
|
34
|
+
|
|
35
|
+
const fileState = new this.constructor._FileState({contents});
|
|
36
|
+
|
|
37
|
+
Object.entries(contents)
|
|
38
|
+
.forEach(([prop, arr]) => {
|
|
39
|
+
if (prop.startsWith("_")) return;
|
|
40
|
+
if (!(arr instanceof Array)) return;
|
|
41
|
+
|
|
42
|
+
arr.forEach(ent => {
|
|
43
|
+
const propStack = [prop];
|
|
44
|
+
const inlineDependencies = new Set();
|
|
45
|
+
|
|
46
|
+
ObjectWalker.walk({
|
|
47
|
+
obj: ent,
|
|
48
|
+
filePath: file,
|
|
49
|
+
primitiveHandlers: {
|
|
50
|
+
preObject: this._onPreObject.bind(this, {propStack, inlineDependencies}),
|
|
51
|
+
object: this._checkObject.bind(this, {fileState, propStack, inlineDependencies}),
|
|
52
|
+
postObject: this._onPostObject.bind(this, {propStack, inlineDependencies}),
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_onPreObject ({propStack, inlineDependencies}, obj) {
|
|
60
|
+
if (obj.type !== "statblockInline") return;
|
|
61
|
+
|
|
62
|
+
propStack.push(obj.dataType);
|
|
63
|
+
(obj.dependencies || []).forEach(dep => inlineDependencies.add(dep));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_onPostObject ({propStack, inlineDependencies}, obj) {
|
|
67
|
+
if (obj.type !== "statblockInline") return;
|
|
68
|
+
|
|
69
|
+
propStack.pop();
|
|
70
|
+
inlineDependencies.clear();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
_checkObject ({fileState, propStack, inlineDependencies}, obj, {filePath}) {
|
|
74
|
+
if (!obj._copy?.source) return;
|
|
75
|
+
|
|
76
|
+
const prop = propStack.at(-1);
|
|
77
|
+
const sourceCopy = obj._copy.source;
|
|
78
|
+
|
|
79
|
+
// Classes/subclasses have an alternate structure.
|
|
80
|
+
if (["class", "subclass"].includes(prop) && UtilSource.isSiteSource(sourceCopy)) {
|
|
81
|
+
const classNameLower = obj._copy.className?.toLowerCase();
|
|
82
|
+
if (
|
|
83
|
+
fileState.dependencies[prop]?.has(classNameLower)
|
|
84
|
+
|| inlineDependencies.has(classNameLower)
|
|
85
|
+
) return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// If a root entity, i.e. not in a `statblockInline`, allow internal copies.
|
|
89
|
+
if (
|
|
90
|
+
propStack.length === 1
|
|
91
|
+
&& fileState.internalCopies.has(prop)
|
|
92
|
+
&& fileState.sources.has(sourceCopy)
|
|
93
|
+
) return;
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
fileState.dependencies[prop]?.has(sourceCopy)
|
|
97
|
+
|| inlineDependencies.has(sourceCopy)
|
|
98
|
+
) return;
|
|
99
|
+
|
|
100
|
+
this._addMessage(`Entity "${propStack.join(" -> ")}" "${obj.name}" "_copy" source "${sourceCopy}" did not match sources found in dependencies in file "${filePath}"\n`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
class _ImageUrlCheck extends DataTesterBase {
|
|
105
|
+
static _RE_IMG_PATH = /^(?<type>img|pdf)\/(?<source>[^/]+)\//;
|
|
106
|
+
|
|
107
|
+
static _FileState = class {
|
|
108
|
+
sources;
|
|
109
|
+
|
|
110
|
+
constructor ({contents}) {
|
|
111
|
+
this.sources = new Set(
|
|
112
|
+
[
|
|
113
|
+
...(contents._meta?.sources?.map(src => src?.json) || [])
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.map(srcJson => srcJson.replace(/:/g, "")),
|
|
116
|
+
...(contents._test?.additionalImageSources || [])
|
|
117
|
+
.map(srcJson => srcJson.replace(/:/g, "")),
|
|
118
|
+
],
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
constructor ({imgRepoName, urlPrefixExpected}) {
|
|
124
|
+
super();
|
|
125
|
+
this._imgRepoName = imgRepoName;
|
|
126
|
+
this._urlPrefixExpected = urlPrefixExpected;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
registerParsedFileCheckers (parsedJsonChecker) {
|
|
130
|
+
parsedJsonChecker.registerFileHandler(this);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
handleFile (file, contents) {
|
|
134
|
+
const fileState = new this.constructor._FileState({contents});
|
|
135
|
+
|
|
136
|
+
ObjectWalker.walk({
|
|
137
|
+
obj: contents,
|
|
138
|
+
filePath: file,
|
|
139
|
+
primitiveHandlers: {
|
|
140
|
+
object: this._checkObject.bind(this, {fileState}),
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_checkObject ({fileState}, obj, {filePath}) {
|
|
146
|
+
if (obj.type !== "image" || obj.href?.type !== "external" || !obj.href?.url) return;
|
|
147
|
+
|
|
148
|
+
const {url} = obj.href;
|
|
149
|
+
if (!url.toLowerCase().startsWith(this._urlPrefixExpected.toLowerCase())) return;
|
|
150
|
+
|
|
151
|
+
const mPath = this.constructor._RE_IMG_PATH.exec(url.slice(this._urlPrefixExpected.length));
|
|
152
|
+
if (!mPath) {
|
|
153
|
+
this._addMessage(`Unknown "${this._imgRepoName}" URL pattern in file "${filePath}": "${url}"\n`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const {source, type} = mPath.groups;
|
|
158
|
+
if (fileState.sources.has(source)) return;
|
|
159
|
+
|
|
160
|
+
this._addMessage(`Image source part "${source}" in "${this._imgRepoName}" ${type} URL did not match sources found in file "_meta" or "_test" in file "${filePath}": "${url}"\n`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export class BrewTesterFileContents extends BrewTesterBase {
|
|
165
|
+
_LOG_TAG = "FILE_CONTENTS";
|
|
166
|
+
|
|
167
|
+
constructor ({imgRepoName, urlPrefixExpected, pathErrorLog} = {}) {
|
|
168
|
+
super();
|
|
169
|
+
this._imgRepoName = imgRepoName;
|
|
170
|
+
this._urlPrefixExpected = urlPrefixExpected;
|
|
171
|
+
this._pathErrorLog = pathErrorLog || "test-data.error.log";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async _pRun () {
|
|
175
|
+
if (!this._imgRepoName) throw new Error(`Image repo name was required!`);
|
|
176
|
+
if (!this._urlPrefixExpected) throw new Error(`Expected URL prefix was required!`);
|
|
177
|
+
|
|
178
|
+
Um.info(this._LOG_TAG, `Running checks for image repo "${this._imgRepoName}" with URL prefix "${this._urlPrefixExpected}"...`);
|
|
179
|
+
|
|
180
|
+
const dataTesters = [
|
|
181
|
+
new BraceCheck(),
|
|
182
|
+
new EscapeCharacterCheck(),
|
|
183
|
+
new _ImageUrlCheck({imgRepoName: this._imgRepoName, urlPrefixExpected: this._urlPrefixExpected}),
|
|
184
|
+
new _CopySourceCheck(),
|
|
185
|
+
];
|
|
186
|
+
DataTester.register({dataTesters});
|
|
187
|
+
|
|
188
|
+
await Uf.pRunOnDirs(
|
|
189
|
+
async (dir) => {
|
|
190
|
+
Um.info(this._LOG_TAG, `Checking dir "${dir}"...`);
|
|
191
|
+
await DataTester.pRun(dir, dataTesters);
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
isSerial: true,
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const outMessage = DataTester.getLogReport(dataTesters);
|
|
199
|
+
if (!outMessage) {
|
|
200
|
+
Um.info(this._LOG_TAG, `Complete.`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
fs.writeFileSync(this._pathErrorLog, outMessage, "utf-8");
|
|
205
|
+
throw new Error(`Checks failed! See "${this._pathErrorLog}" and logs above.`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -8,6 +8,11 @@ export class BrewTesterFileNames extends BrewTesterBase {
|
|
|
8
8
|
|
|
9
9
|
static _RE_NAME_FORMAT = /^[^;]+; .+\.json$/;
|
|
10
10
|
|
|
11
|
+
constructor ({reNameFormat = null} = {}) {
|
|
12
|
+
super();
|
|
13
|
+
this._reNameFormat = reNameFormat || this.constructor._RE_NAME_FORMAT;
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
async _pRun () {
|
|
12
17
|
Um.info(this._LOG_TAG, `Testing for incorrect file names...`);
|
|
13
18
|
|
|
@@ -24,8 +29,8 @@ export class BrewTesterFileNames extends BrewTesterBase {
|
|
|
24
29
|
|
|
25
30
|
errors.push(
|
|
26
31
|
...filenames
|
|
27
|
-
.filter(it => !this.
|
|
28
|
-
.map(it => `Filename did not match expected pattern "${this.
|
|
32
|
+
.filter(it => !this._reNameFormat.test(it))
|
|
33
|
+
.map(it => `Filename did not match expected pattern "${this._reNameFormat.toString()}" extension: ${it}`),
|
|
29
34
|
);
|
|
30
35
|
});
|
|
31
36
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import {BrewTesterBase} from "./BrewTesterBase.js";
|
|
3
|
+
import Um from "../UtilMisc.js";
|
|
4
|
+
|
|
5
|
+
export class BrewTesterImgDirectories extends BrewTesterBase {
|
|
6
|
+
_LOG_TAG = "IMG_DIR";
|
|
7
|
+
|
|
8
|
+
constructor ({dirAllowlist = null, pathImgDir = "_img"} = {}) {
|
|
9
|
+
super();
|
|
10
|
+
this._dirAllowlist = new Set(dirAllowlist || []);
|
|
11
|
+
this._pathImgDir = pathImgDir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async _pRun () {
|
|
15
|
+
if (!this._dirAllowlist.size) throw new Error(`Directory allowlist was required!`);
|
|
16
|
+
|
|
17
|
+
if (!fs.existsSync(this._pathImgDir)) {
|
|
18
|
+
Um.info(this._LOG_TAG, `Directory "${this._pathImgDir}" was not found; skipping.`);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const extraDirs = fs.readdirSync(this._pathImgDir)
|
|
23
|
+
.filter(dir => !this._dirAllowlist.has(dir));
|
|
24
|
+
|
|
25
|
+
if (!extraDirs.length) {
|
|
26
|
+
Um.info(this._LOG_TAG, `No unexpected entries found in "${this._pathImgDir}".`);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new Error(`Extra directories found in "${this._pathImgDir}":\n${extraDirs.map(d => `\t${d}`).join("\n")}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/lib/BrewTester.js
CHANGED
|
@@ -3,11 +3,15 @@ import {BrewTesterFileLocations} from "./BrewTester/BrewTesterFileLocations.js";
|
|
|
3
3
|
import {BrewTesterFileNames} from "./BrewTester/BrewTesterFileNames.js";
|
|
4
4
|
import {BrewTesterFileProps} from "./BrewTester/BrewTesterFileProps.js";
|
|
5
5
|
import {BrewTesterEdition} from "./BrewTester/BrewTesterEdition.js";
|
|
6
|
+
import {BrewTesterFileContents} from "./BrewTester/BrewTesterFileContents.js";
|
|
7
|
+
import {BrewTesterImgDirectories} from "./BrewTester/BrewTesterImgDirectories.js";
|
|
6
8
|
|
|
7
9
|
export class BrewTester {
|
|
8
10
|
static async pTestJson ({mode, filepath, dir}) { return (new BrewTesterJson({mode, filepath, dir})).pRun(); }
|
|
9
11
|
static pTestFileLocations () { return (new BrewTesterFileLocations()).pRun(); }
|
|
10
|
-
static pTestFileNames () { return (new BrewTesterFileNames()).pRun(); }
|
|
12
|
+
static pTestFileNames ({reNameFormat} = {}) { return (new BrewTesterFileNames({reNameFormat})).pRun(); }
|
|
11
13
|
static pTestFileProps () { return (new BrewTesterFileProps()).pRun(); }
|
|
14
|
+
static pTestFileContents ({imgRepoName, urlPrefixExpected, pathErrorLog}) { return (new BrewTesterFileContents({imgRepoName, urlPrefixExpected, pathErrorLog})).pRun(); }
|
|
15
|
+
static pTestImgDirectories ({dirAllowlist, pathImgDir} = {}) { return (new BrewTesterImgDirectories({dirAllowlist, pathImgDir})).pRun(); }
|
|
12
16
|
static pTestEditionSources () { return (new BrewTesterEdition()).pRun(); }
|
|
13
17
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const IMG_SOURCE_DIRS = [
|
|
2
|
+
"font",
|
|
3
|
+
"img",
|
|
4
|
+
"pdf",
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
export const IMG_ALLOWED_EXTENSIONS = {
|
|
8
|
+
"audio": new Set([
|
|
9
|
+
"mp3",
|
|
10
|
+
"wav",
|
|
11
|
+
]),
|
|
12
|
+
"font": new Set([
|
|
13
|
+
"otf",
|
|
14
|
+
"ttf",
|
|
15
|
+
"woff",
|
|
16
|
+
"woff2",
|
|
17
|
+
]),
|
|
18
|
+
"img": new Set([
|
|
19
|
+
"gif",
|
|
20
|
+
"jpeg",
|
|
21
|
+
"jpg",
|
|
22
|
+
"png",
|
|
23
|
+
"svg",
|
|
24
|
+
"webp",
|
|
25
|
+
]),
|
|
26
|
+
"pdf": new Set([
|
|
27
|
+
"pdf",
|
|
28
|
+
]),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const MAX_IMG_FILE_SIZE_BYTES = 25 * 1024 * 1024;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import {lsRecursiveSync} from "../UtilFs.js";
|
|
3
|
+
import Um from "../UtilMisc.js";
|
|
4
|
+
import {BrewTesterBase} from "../BrewTester/BrewTesterBase.js";
|
|
5
|
+
import {IMG_ALLOWED_EXTENSIONS} from "./BrewTesterImgConsts.js";
|
|
6
|
+
|
|
7
|
+
export class BrewTesterImgFileExtensions extends BrewTesterBase {
|
|
8
|
+
_LOG_TAG = "FILE_EXT";
|
|
9
|
+
|
|
10
|
+
constructor ({allowedExtensions = null} = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this._allowedExtensions = allowedExtensions || IMG_ALLOWED_EXTENSIONS;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async _pRun () {
|
|
16
|
+
Um.info(this._LOG_TAG, `Testing for incorrect file extensions...`);
|
|
17
|
+
|
|
18
|
+
const badPaths = Object.entries(this._allowedExtensions)
|
|
19
|
+
.flatMap(([dirName, allowedExts]) => {
|
|
20
|
+
if (!fs.existsSync(dirName) || !fs.statSync(dirName).isDirectory()) return [];
|
|
21
|
+
|
|
22
|
+
return lsRecursiveSync(dirName)
|
|
23
|
+
.filter(filePath => {
|
|
24
|
+
const ext = filePath.split(".").at(-1).toLowerCase();
|
|
25
|
+
return !allowedExts.has(ext);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!badPaths.length) return Um.info(this._LOG_TAG, `Files had expected extensions.`);
|
|
30
|
+
|
|
31
|
+
badPaths.forEach(filePath => Um.error(this._LOG_TAG, `File extension not in allowlist: ${filePath}`));
|
|
32
|
+
throw new Error(`Test failed! See above for more info`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import {lsRecursiveSync} from "../UtilFs.js";
|
|
3
|
+
import Um from "../UtilMisc.js";
|
|
4
|
+
import {BrewTesterBase} from "../BrewTester/BrewTesterBase.js";
|
|
5
|
+
import {IMG_SOURCE_DIRS, MAX_IMG_FILE_SIZE_BYTES} from "./BrewTesterImgConsts.js";
|
|
6
|
+
|
|
7
|
+
export class BrewTesterImgFileSizes extends BrewTesterBase {
|
|
8
|
+
_LOG_TAG = "FILE_SIZE";
|
|
9
|
+
|
|
10
|
+
constructor ({dirsSource = null, maxSizeBytes = null} = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this._dirsSource = dirsSource || IMG_SOURCE_DIRS;
|
|
13
|
+
this._maxSizeBytes = maxSizeBytes || MAX_IMG_FILE_SIZE_BYTES;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async _pRun () {
|
|
17
|
+
const maxSizeMb = this._maxSizeBytes / (1024 * 1024);
|
|
18
|
+
Um.info(this._LOG_TAG, `Testing for file sizes <= ${maxSizeMb} MiB...`);
|
|
19
|
+
|
|
20
|
+
const badPaths = this._dirsSource
|
|
21
|
+
.flatMap(dirName => {
|
|
22
|
+
if (!fs.existsSync(dirName) || !fs.statSync(dirName).isDirectory()) return [];
|
|
23
|
+
|
|
24
|
+
return lsRecursiveSync(dirName)
|
|
25
|
+
.filter(filePath => fs.statSync(filePath).size > this._maxSizeBytes);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (!badPaths.length) return Um.info(this._LOG_TAG, `Files had expected sizes.`);
|
|
29
|
+
|
|
30
|
+
badPaths.forEach(filePath => Um.error(this._LOG_TAG, `File larger than ${maxSizeMb} MiB: ${filePath}`));
|
|
31
|
+
throw new Error(`Test failed! See above for more info`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import Um from "../UtilMisc.js";
|
|
3
|
+
import {BrewTesterBase} from "../BrewTester/BrewTesterBase.js";
|
|
4
|
+
import {IMG_SOURCE_DIRS} from "./BrewTesterImgConsts.js";
|
|
5
|
+
import {UtilSource} from "../UtilSource.js";
|
|
6
|
+
|
|
7
|
+
export class BrewTesterImgSourceNames extends BrewTesterBase {
|
|
8
|
+
_LOG_TAG = "SRC_NAME";
|
|
9
|
+
|
|
10
|
+
constructor ({dirsSource = null, isPrerelease = false} = {}) {
|
|
11
|
+
super();
|
|
12
|
+
this._dirsSource = dirsSource || IMG_SOURCE_DIRS;
|
|
13
|
+
this._isPrerelease = isPrerelease;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async _pRun () {
|
|
17
|
+
const sourceType = this._isPrerelease ? "prerelease" : "homebrew";
|
|
18
|
+
Um.info(this._LOG_TAG, `Testing for valid ${sourceType} source names...`);
|
|
19
|
+
|
|
20
|
+
const fnIsValidSource = this._isPrerelease
|
|
21
|
+
? UtilSource.isValidPrereleaseSource.bind(UtilSource)
|
|
22
|
+
: UtilSource.isValidHomebrewSource.bind(UtilSource);
|
|
23
|
+
|
|
24
|
+
const badNames = this._dirsSource
|
|
25
|
+
.flatMap(dirName => {
|
|
26
|
+
if (!fs.existsSync(dirName) || !fs.statSync(dirName).isDirectory()) return [];
|
|
27
|
+
|
|
28
|
+
return fs.readdirSync(dirName)
|
|
29
|
+
.filter(name => fs.statSync(`${dirName}/${name}`).isDirectory())
|
|
30
|
+
.filter(name => !fnIsValidSource(name));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!badNames.length) return Um.info(this._LOG_TAG, `Directory names had expected sources.`);
|
|
34
|
+
|
|
35
|
+
badNames.forEach(name => Um.error(this._LOG_TAG, `Invalid ${sourceType} source directory name: ${name}`));
|
|
36
|
+
throw new Error(`Test failed! See above for more info`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import {BrewTesterImgFileExtensions} from "./BrewTesterImg/BrewTesterImgFileExtensions.js";
|
|
2
|
+
import {BrewTesterImgFileSizes} from "./BrewTesterImg/BrewTesterImgFileSizes.js";
|
|
3
|
+
import {BrewTesterImgSourceNames} from "./BrewTesterImg/BrewTesterImgSourceNames.js";
|
|
4
|
+
|
|
5
|
+
export class BrewTesterImg {
|
|
6
|
+
static pTestFileExtensions ({allowedExtensions} = {}) { return (new BrewTesterImgFileExtensions({allowedExtensions})).pRun(); }
|
|
7
|
+
static pTestFileSizes ({dirsSource, maxSizeBytes} = {}) { return (new BrewTesterImgFileSizes({dirsSource, maxSizeBytes})).pRun(); }
|
|
8
|
+
static pTestSourceNames ({dirsSource, isPrerelease} = {}) { return (new BrewTesterImgSourceNames({dirsSource, isPrerelease})).pRun(); }
|
|
9
|
+
}
|
package/lib/UtilSource.js
CHANGED
|
@@ -23,31 +23,76 @@ export class UtilSource {
|
|
|
23
23
|
/* -------------------------------------------- */
|
|
24
24
|
|
|
25
25
|
static _HOMEBREW_SOURCE_VALIDATOR = null;
|
|
26
|
+
static _PRERELEASE_SOURCE_VALIDATOR = null;
|
|
26
27
|
|
|
27
|
-
static
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return this._HOMEBREW_SOURCE_VALIDATOR
|
|
28
|
+
static _getHomebrewSourceValidator () {
|
|
29
|
+
if (this._HOMEBREW_SOURCE_VALIDATOR) return this._HOMEBREW_SOURCE_VALIDATOR;
|
|
30
|
+
|
|
31
|
+
this._HOMEBREW_SOURCE_VALIDATOR = UtilAjv.getValidator();
|
|
32
|
+
|
|
33
|
+
[
|
|
34
|
+
"util.json",
|
|
35
|
+
"sources-homebrew-legacy.json",
|
|
36
|
+
"sources-5etools.json",
|
|
37
|
+
]
|
|
38
|
+
.forEach(fname => {
|
|
39
|
+
this._HOMEBREW_SOURCE_VALIDATOR.addSchema(
|
|
40
|
+
Uf.readJsonSync(path.join(path.join(__dirname, "..", "schema", "brew", fname))),
|
|
41
|
+
fname,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
this._HOMEBREW_SOURCE_VALIDATOR.addSchema(
|
|
46
|
+
{
|
|
47
|
+
"$ref": "util.json#/$defs/sourceJson",
|
|
48
|
+
},
|
|
49
|
+
"homebrewSource",
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return this._HOMEBREW_SOURCE_VALIDATOR;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static _getPrereleaseSourceValidator () {
|
|
56
|
+
if (this._PRERELEASE_SOURCE_VALIDATOR) return this._PRERELEASE_SOURCE_VALIDATOR;
|
|
57
|
+
|
|
58
|
+
this._PRERELEASE_SOURCE_VALIDATOR = UtilAjv.getValidator();
|
|
59
|
+
|
|
60
|
+
[
|
|
61
|
+
"util.json",
|
|
62
|
+
]
|
|
63
|
+
.forEach(fname => {
|
|
64
|
+
this._PRERELEASE_SOURCE_VALIDATOR.addSchema(
|
|
65
|
+
Uf.readJsonSync(path.join(path.join(__dirname, "..", "schema", "brew", fname))),
|
|
66
|
+
fname,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
this._PRERELEASE_SOURCE_VALIDATOR.addSchema(
|
|
71
|
+
{
|
|
72
|
+
"type": "string",
|
|
73
|
+
"allOf": [
|
|
74
|
+
{
|
|
75
|
+
"$ref": "util.json#/$defs/_sourceString",
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"minLength": 6,
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"pattern": "^(?:UA|XUA)[-a-zA-Z0-9&+!]+$",
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
"prereleaseSource",
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return this._PRERELEASE_SOURCE_VALIDATOR;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
static isValidHomebrewSource (source) {
|
|
92
|
+
return this._getHomebrewSourceValidator().validate("homebrewSource", source);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static isValidPrereleaseSource (source) {
|
|
96
|
+
return this._getPrereleaseSourceValidator().validate("prereleaseSource", source);
|
|
52
97
|
}
|
|
53
98
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "5etools-utils",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.5",
|
|
4
4
|
"description": "Shared utilities for the 5etools ecosystem.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/Api.js",
|
|
@@ -12,12 +12,18 @@
|
|
|
12
12
|
"lib": "./lib"
|
|
13
13
|
},
|
|
14
14
|
"bin": {
|
|
15
|
+
"clean-html": "bin/clean-html.js",
|
|
16
|
+
"test-file-contents": "bin/test-file-contents.js",
|
|
15
17
|
"test-json-brew": "bin/test-json-brew.js",
|
|
16
18
|
"test-json-ua": "bin/test-json-ua.js",
|
|
17
19
|
"test-file-names": "bin/test-file-names.js",
|
|
18
20
|
"test-file-locations": "bin/test-file-locations.js",
|
|
19
21
|
"test-file-props": "bin/test-file-props.js",
|
|
20
|
-
"test-edition-sources": "bin/test-edition-sources.js"
|
|
22
|
+
"test-edition-sources": "bin/test-edition-sources.js",
|
|
23
|
+
"test-img-file-extensions": "bin/test-img-file-extensions.js",
|
|
24
|
+
"test-img-file-sizes": "bin/test-img-file-sizes.js",
|
|
25
|
+
"test-img-source-names-brew": "bin/test-img-source-names-brew.js",
|
|
26
|
+
"test-img-source-names-ua": "bin/test-img-source-names-ua.js"
|
|
21
27
|
},
|
|
22
28
|
"scripts": {
|
|
23
29
|
"build:dangerous:sources": "node node/fetch-5etools-sources.js",
|
|
@@ -43,8 +49,10 @@
|
|
|
43
49
|
"ajv-formats": "^3.0.1",
|
|
44
50
|
"commander": "^14.0.3",
|
|
45
51
|
"hasha": "^7.0.0",
|
|
52
|
+
"he": "^1.2.0",
|
|
46
53
|
"json-source-map": "^0.6.1",
|
|
47
|
-
"number-precision": "^1.6.0"
|
|
54
|
+
"number-precision": "^1.6.0",
|
|
55
|
+
"sanitize-html": "^2.17.1"
|
|
48
56
|
},
|
|
49
57
|
"devDependencies": {
|
|
50
58
|
"@eslint/js": "^10.0.1",
|