@jay-framework/a11y-validator 0.22.2 → 0.23.1
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/dist/index.js +196 -33
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { walkElements } from "@jay-framework/compiler-shared";
|
|
2
|
+
const A11Y_GUIDE = "\nSee: agent-kit/designer/a11y-patterns.md";
|
|
3
|
+
function pushFinding(findings, finding) {
|
|
4
|
+
if (finding.suggestion) {
|
|
5
|
+
finding.suggestion += A11Y_GUIDE;
|
|
6
|
+
}
|
|
7
|
+
findings.push(finding);
|
|
8
|
+
}
|
|
2
9
|
const INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set(["a", "button", "input", "select", "textarea"]);
|
|
3
10
|
const NON_INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set([
|
|
4
11
|
"div",
|
|
@@ -87,6 +94,19 @@ const VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
|
|
|
87
94
|
"treegrid",
|
|
88
95
|
"treeitem"
|
|
89
96
|
]);
|
|
97
|
+
const INTERACTIVE_CONTAINER_ROLES = /* @__PURE__ */ new Set(["button", "link"]);
|
|
98
|
+
const WIDGET_ROLES = /* @__PURE__ */ new Set([
|
|
99
|
+
"button",
|
|
100
|
+
"link",
|
|
101
|
+
"checkbox",
|
|
102
|
+
"radio",
|
|
103
|
+
"switch",
|
|
104
|
+
"tab",
|
|
105
|
+
"menuitem",
|
|
106
|
+
"option",
|
|
107
|
+
"textbox"
|
|
108
|
+
]);
|
|
109
|
+
const FOCUSABLE_ELEMENTS = /* @__PURE__ */ new Set(["button", "select", "textarea", "summary"]);
|
|
90
110
|
const LABELABLE_INPUTS = /* @__PURE__ */ new Set([
|
|
91
111
|
"text",
|
|
92
112
|
"password",
|
|
@@ -102,20 +122,35 @@ const LABELABLE_INPUTS = /* @__PURE__ */ new Set([
|
|
|
102
122
|
"week",
|
|
103
123
|
"color",
|
|
104
124
|
"file",
|
|
105
|
-
"range"
|
|
125
|
+
"range",
|
|
126
|
+
"checkbox",
|
|
127
|
+
"radio"
|
|
106
128
|
]);
|
|
129
|
+
const IGNORED_INPUT_TYPES = /* @__PURE__ */ new Set(["hidden", "submit", "button", "reset"]);
|
|
107
130
|
const validate = (ctx) => {
|
|
108
131
|
const findings = [];
|
|
109
132
|
const labelForIds = /* @__PURE__ */ new Set();
|
|
110
|
-
|
|
133
|
+
const allIds = /* @__PURE__ */ new Set();
|
|
134
|
+
const idCounts = /* @__PURE__ */ new Map();
|
|
135
|
+
collectDomIndex(ctx.body, labelForIds, allIds, idCounts);
|
|
136
|
+
for (const [id, count] of idCounts) {
|
|
137
|
+
if (count > 1) {
|
|
138
|
+
pushFinding(findings, {
|
|
139
|
+
severity: "error",
|
|
140
|
+
message: `Duplicate id="${id}" used ${count} times (WCAG 4.1.1)`,
|
|
141
|
+
suggestion: "Give each element a unique id. Duplicate ids break label associations and ARIA references.",
|
|
142
|
+
attribute: "id"
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
checkLabelsStructure(ctx.body, allIds, findings);
|
|
111
147
|
walkElements(ctx.body, ctx, (el) => {
|
|
112
148
|
const tag = el.rawTagName?.toLowerCase();
|
|
113
|
-
if (!tag)
|
|
114
|
-
return;
|
|
149
|
+
if (!tag) return;
|
|
115
150
|
if (tag === "img") {
|
|
116
151
|
const alt = el.getAttribute?.("alt");
|
|
117
152
|
if (alt === void 0 || alt === null) {
|
|
118
|
-
findings
|
|
153
|
+
pushFinding(findings, {
|
|
119
154
|
severity: "error",
|
|
120
155
|
message: "Image missing alt attribute (WCAG 1.1.1)",
|
|
121
156
|
suggestion: 'Add an alt attribute. Use descriptive text for informative images, or alt="" for purely decorative images.',
|
|
@@ -126,15 +161,14 @@ const validate = (ctx) => {
|
|
|
126
161
|
}
|
|
127
162
|
if (tag === "input") {
|
|
128
163
|
const type = (el.getAttribute?.("type") || "text").toLowerCase();
|
|
129
|
-
if (type
|
|
164
|
+
if (IGNORED_INPUT_TYPES.has(type)) {
|
|
130
165
|
return;
|
|
131
166
|
}
|
|
132
|
-
if (!LABELABLE_INPUTS.has(type))
|
|
133
|
-
|
|
134
|
-
checkLabel(el, tag, findings, labelForIds);
|
|
167
|
+
if (!LABELABLE_INPUTS.has(type)) return;
|
|
168
|
+
checkLabel(el, tag, findings, labelForIds, allIds);
|
|
135
169
|
}
|
|
136
170
|
if (tag === "select" || tag === "textarea") {
|
|
137
|
-
checkLabel(el, tag, findings, labelForIds);
|
|
171
|
+
checkLabel(el, tag, findings, labelForIds, allIds);
|
|
138
172
|
}
|
|
139
173
|
if (tag === "button") {
|
|
140
174
|
const text = el.textContent?.trim();
|
|
@@ -142,7 +176,7 @@ const validate = (ctx) => {
|
|
|
142
176
|
const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
|
|
143
177
|
const hasImg = el.querySelector?.("img[alt]");
|
|
144
178
|
if (!text && !ariaLabel && !ariaLabelledBy && !hasImg) {
|
|
145
|
-
findings
|
|
179
|
+
pushFinding(findings, {
|
|
146
180
|
severity: "error",
|
|
147
181
|
message: "Button has no accessible name (WCAG 4.1.2)",
|
|
148
182
|
suggestion: "Add text content, an aria-label, or an aria-labelledby attribute to the button.",
|
|
@@ -155,7 +189,7 @@ const validate = (ctx) => {
|
|
|
155
189
|
if (tabindex !== void 0 && tabindex !== null) {
|
|
156
190
|
const val = parseInt(tabindex, 10);
|
|
157
191
|
if (!isNaN(val) && val > 0) {
|
|
158
|
-
findings
|
|
192
|
+
pushFinding(findings, {
|
|
159
193
|
severity: "warning",
|
|
160
194
|
message: `Positive tabindex="${tabindex}" disrupts natural tab order (WCAG 2.4.3)`,
|
|
161
195
|
suggestion: 'Use tabindex="0" to add to natural tab order, or tabindex="-1" for programmatic focus. Avoid positive values — they override the DOM order and confuse keyboard users.',
|
|
@@ -170,7 +204,7 @@ const validate = (ctx) => {
|
|
|
170
204
|
if (autoplay !== void 0 && autoplay !== null) {
|
|
171
205
|
const muted = el.getAttribute?.("muted");
|
|
172
206
|
if (muted === void 0 || muted === null) {
|
|
173
|
-
findings
|
|
207
|
+
pushFinding(findings, {
|
|
174
208
|
severity: "error",
|
|
175
209
|
message: `<${tag}> has autoplay without muted (WCAG 1.4.2)`,
|
|
176
210
|
suggestion: `Add the muted attribute to <${tag} autoplay>, or remove autoplay. Autoplaying audio is disruptive to screen reader users.`,
|
|
@@ -183,7 +217,7 @@ const validate = (ctx) => {
|
|
|
183
217
|
const role = el.getAttribute?.("role");
|
|
184
218
|
if (role !== void 0 && role !== null) {
|
|
185
219
|
if (!VALID_ARIA_ROLES.has(role)) {
|
|
186
|
-
findings
|
|
220
|
+
pushFinding(findings, {
|
|
187
221
|
severity: "error",
|
|
188
222
|
message: `Invalid ARIA role="${role}" (WCAG 4.1.2)`,
|
|
189
223
|
suggestion: `"${role}" is not a valid WAI-ARIA role. Use a valid role such as "button", "link", "navigation", "dialog", etc.`,
|
|
@@ -197,7 +231,7 @@ const validate = (ctx) => {
|
|
|
197
231
|
if (tabindex !== void 0 && tabindex !== null) {
|
|
198
232
|
const val = parseInt(tabindex, 10);
|
|
199
233
|
if (!isNaN(val) && val >= 0 && !role) {
|
|
200
|
-
findings
|
|
234
|
+
pushFinding(findings, {
|
|
201
235
|
severity: "warning",
|
|
202
236
|
message: `<${tag}> is focusable via tabindex but has no role (WCAG 4.1.2)`,
|
|
203
237
|
suggestion: `Add a role attribute to indicate the element's purpose to screen readers. Example: <div tabindex="0" role="button"> or <span tabindex="0" role="link">.`,
|
|
@@ -209,12 +243,13 @@ const validate = (ctx) => {
|
|
|
209
243
|
}
|
|
210
244
|
});
|
|
211
245
|
checkDuplicateAdjacentText(ctx.body, findings);
|
|
246
|
+
checkNestedInteractive(ctx.body, findings);
|
|
212
247
|
if (ctx.head) {
|
|
213
248
|
const viewport = ctx.head.meta.find((m) => m.name?.toLowerCase() === "viewport");
|
|
214
249
|
if (viewport) {
|
|
215
250
|
const content = viewport.content.map((p) => p.value).join("").toLowerCase();
|
|
216
251
|
if (/user-scalable\s*=\s*no/.test(content)) {
|
|
217
|
-
findings
|
|
252
|
+
pushFinding(findings, {
|
|
218
253
|
severity: "error",
|
|
219
254
|
message: "Viewport meta disables user scaling (WCAG 1.4.4)",
|
|
220
255
|
suggestion: "Remove user-scalable=no from the viewport meta tag. Users must be able to zoom to at least 200%.",
|
|
@@ -224,7 +259,7 @@ const validate = (ctx) => {
|
|
|
224
259
|
}
|
|
225
260
|
const maxScaleMatch = content.match(/maximum-scale\s*=\s*([\d.]+)/);
|
|
226
261
|
if (maxScaleMatch && parseFloat(maxScaleMatch[1]) < 2) {
|
|
227
|
-
findings
|
|
262
|
+
pushFinding(findings, {
|
|
228
263
|
severity: "error",
|
|
229
264
|
message: `Viewport meta restricts zoom to ${maxScaleMatch[1]}x (WCAG 1.4.4)`,
|
|
230
265
|
suggestion: "Set maximum-scale to at least 2, or remove it entirely. Users must be able to zoom to at least 200%.",
|
|
@@ -236,21 +271,57 @@ const validate = (ctx) => {
|
|
|
236
271
|
}
|
|
237
272
|
return findings;
|
|
238
273
|
};
|
|
239
|
-
function checkLabel(el, tag, findings, labelForIds) {
|
|
274
|
+
function checkLabel(el, tag, findings, labelForIds, allIds) {
|
|
240
275
|
const id = el.getAttribute?.("id");
|
|
241
276
|
const ariaLabel = el.getAttribute?.("aria-label");
|
|
242
277
|
const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
278
|
+
let hasAccessibleName = false;
|
|
279
|
+
if (ariaLabel !== void 0 && ariaLabel !== null) {
|
|
280
|
+
if (!String(ariaLabel).trim()) {
|
|
281
|
+
pushFinding(findings, {
|
|
282
|
+
severity: "error",
|
|
283
|
+
message: `<${tag}> has empty aria-label (WCAG 4.1.2)`,
|
|
284
|
+
suggestion: "Provide a non-empty aria-label, use aria-labelledby with an existing id, or associate a <label>.",
|
|
285
|
+
element: `<${tag}>`,
|
|
286
|
+
attribute: "aria-label"
|
|
287
|
+
});
|
|
288
|
+
} else {
|
|
289
|
+
hasAccessibleName = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (ariaLabelledBy !== void 0 && ariaLabelledBy !== null) {
|
|
293
|
+
const tokens = String(ariaLabelledBy).trim().split(/\s+/).filter(Boolean);
|
|
294
|
+
if (tokens.length === 0) {
|
|
295
|
+
pushFinding(findings, {
|
|
296
|
+
severity: "error",
|
|
297
|
+
message: `<${tag}> has empty aria-labelledby (WCAG 4.1.2)`,
|
|
298
|
+
suggestion: "Set aria-labelledby to one or more element ids that exist in this file, or use a non-empty aria-label / <label>.",
|
|
299
|
+
element: `<${tag}>`,
|
|
300
|
+
attribute: "aria-labelledby"
|
|
301
|
+
});
|
|
302
|
+
} else {
|
|
303
|
+
const missing = tokens.filter((token) => !allIds.has(token));
|
|
304
|
+
if (missing.length > 0) {
|
|
305
|
+
pushFinding(findings, {
|
|
306
|
+
severity: "error",
|
|
307
|
+
message: `<${tag}> aria-labelledby references missing id(s): ${missing.join(", ")} (WCAG 1.3.1)`,
|
|
308
|
+
suggestion: `Add element(s) with id="${missing[0]}" (or fix the aria-labelledby tokens), or use a <label for="..."> / non-empty aria-label instead.`,
|
|
309
|
+
element: `<${tag}>`,
|
|
310
|
+
attribute: "aria-labelledby"
|
|
311
|
+
});
|
|
312
|
+
} else {
|
|
313
|
+
hasAccessibleName = true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (hasAccessibleName) return;
|
|
318
|
+
if (id && labelForIds.has(id)) return;
|
|
247
319
|
let parent = el.parentNode;
|
|
248
320
|
while (parent) {
|
|
249
|
-
if (parent.rawTagName?.toLowerCase() === "label")
|
|
250
|
-
return;
|
|
321
|
+
if (parent.rawTagName?.toLowerCase() === "label") return;
|
|
251
322
|
parent = parent.parentNode;
|
|
252
323
|
}
|
|
253
|
-
findings
|
|
324
|
+
pushFinding(findings, {
|
|
254
325
|
severity: "error",
|
|
255
326
|
message: `<${tag}> has no associated label (WCAG 1.3.1)`,
|
|
256
327
|
suggestion: `Add a <label for="${id || "inputId"}"> that references this ${tag}'s id, wrap it in a <label>, or add an aria-label attribute.`,
|
|
@@ -258,20 +329,112 @@ function checkLabel(el, tag, findings, labelForIds) {
|
|
|
258
329
|
attribute: "id"
|
|
259
330
|
});
|
|
260
331
|
}
|
|
261
|
-
function
|
|
332
|
+
function collectDomIndex(el, labelForIds, allIds, idCounts) {
|
|
333
|
+
const id = el.getAttribute?.("id");
|
|
334
|
+
if (id) {
|
|
335
|
+
allIds.add(id);
|
|
336
|
+
idCounts.set(id, (idCounts.get(id) ?? 0) + 1);
|
|
337
|
+
}
|
|
262
338
|
if (el.rawTagName?.toLowerCase() === "label") {
|
|
263
339
|
const forId = el.getAttribute?.("for");
|
|
264
|
-
if (forId)
|
|
265
|
-
ids.add(forId);
|
|
340
|
+
if (forId) labelForIds.add(forId);
|
|
266
341
|
}
|
|
267
342
|
for (const child of el.childNodes ?? []) {
|
|
268
|
-
if (child.nodeType === 1)
|
|
269
|
-
|
|
343
|
+
if (child.nodeType === 1) collectDomIndex(child, labelForIds, allIds, idCounts);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
function isLabelableControl(el) {
|
|
347
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
348
|
+
if (tag === "select" || tag === "textarea") return true;
|
|
349
|
+
if (tag !== "input") return false;
|
|
350
|
+
const type = (el.getAttribute?.("type") || "text").toLowerCase();
|
|
351
|
+
if (IGNORED_INPUT_TYPES.has(type)) return false;
|
|
352
|
+
return LABELABLE_INPUTS.has(type);
|
|
353
|
+
}
|
|
354
|
+
function countLabelableDescendants(el) {
|
|
355
|
+
let count = 0;
|
|
356
|
+
for (const child of el.childNodes ?? []) {
|
|
357
|
+
if (child.nodeType !== 1) continue;
|
|
358
|
+
if (isLabelableControl(child)) count += 1;
|
|
359
|
+
count += countLabelableDescendants(child);
|
|
360
|
+
}
|
|
361
|
+
return count;
|
|
362
|
+
}
|
|
363
|
+
function checkLabelsStructure(root, allIds, findings) {
|
|
364
|
+
function walk(el) {
|
|
365
|
+
if (el.rawTagName?.toLowerCase() === "label") {
|
|
366
|
+
const forId = el.getAttribute?.("for");
|
|
367
|
+
if (forId && !allIds.has(forId)) {
|
|
368
|
+
pushFinding(findings, {
|
|
369
|
+
severity: "warning",
|
|
370
|
+
message: `<label for="${forId}"> has no matching id in this file (WCAG 1.3.1)`,
|
|
371
|
+
suggestion: `Add id="${forId}" to the related form control, or fix the for attribute.`,
|
|
372
|
+
element: "<label>",
|
|
373
|
+
attribute: "for"
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
const controlCount = countLabelableDescendants(el);
|
|
377
|
+
if (controlCount > 1) {
|
|
378
|
+
pushFinding(findings, {
|
|
379
|
+
severity: "warning",
|
|
380
|
+
message: `<label> contains ${controlCount} form controls — screen readers only associate the first (WCAG 1.3.1)`,
|
|
381
|
+
suggestion: 'Use a separate <label for="id"> for each input (or one wrapping label per control). multiple form controls inside one label is not reliable.',
|
|
382
|
+
element: "<label>"
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
for (const child of el.childNodes ?? []) {
|
|
387
|
+
if (child.nodeType === 1) walk(child);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
walk(root);
|
|
391
|
+
}
|
|
392
|
+
function hasHref(el) {
|
|
393
|
+
const href = el.getAttribute?.("href");
|
|
394
|
+
return href !== void 0 && href !== null;
|
|
395
|
+
}
|
|
396
|
+
function isInteractiveContainer(el) {
|
|
397
|
+
const role = el.getAttribute?.("role")?.toLowerCase();
|
|
398
|
+
if (role) return INTERACTIVE_CONTAINER_ROLES.has(role);
|
|
399
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
400
|
+
if (tag === "button") return true;
|
|
401
|
+
if (tag === "a") return hasHref(el);
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
function isFocusable(el) {
|
|
405
|
+
const role = el.getAttribute?.("role")?.toLowerCase();
|
|
406
|
+
if (role && WIDGET_ROLES.has(role)) return true;
|
|
407
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
408
|
+
if (tag === "a") return hasHref(el);
|
|
409
|
+
if (tag === "input") return (el.getAttribute?.("type") || "text").toLowerCase() !== "hidden";
|
|
410
|
+
if (tag && FOCUSABLE_ELEMENTS.has(tag)) return true;
|
|
411
|
+
const tabindex = el.getAttribute?.("tabindex");
|
|
412
|
+
if (tabindex !== void 0 && tabindex !== null) {
|
|
413
|
+
const val = parseInt(tabindex, 10);
|
|
414
|
+
if (!isNaN(val) && val >= 0) return true;
|
|
415
|
+
}
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
function checkNestedInteractive(root, findings) {
|
|
419
|
+
function walk(el, ancestorTag) {
|
|
420
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
421
|
+
if (tag && ancestorTag && isFocusable(el)) {
|
|
422
|
+
pushFinding(findings, {
|
|
423
|
+
severity: "error",
|
|
424
|
+
message: `Interactive <${tag}> is nested inside <${ancestorTag}> (WCAG 4.1.2)`,
|
|
425
|
+
suggestion: `Interactive elements cannot be nested — browsers restructure the DOM and screen readers announce an ambiguous control. Move the <${tag}> outside the <${ancestorTag}>, or make the outer element a non-interactive container such as <div>.`,
|
|
426
|
+
element: `<${tag}>`
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
const childAncestor = isInteractiveContainer(el) ? tag ?? ancestorTag : ancestorTag;
|
|
430
|
+
for (const child of el.childNodes ?? []) {
|
|
431
|
+
if (child.nodeType === 1) walk(child, childAncestor);
|
|
432
|
+
}
|
|
270
433
|
}
|
|
434
|
+
walk(root, void 0);
|
|
271
435
|
}
|
|
272
436
|
function getVisibleText(el) {
|
|
273
|
-
if (el.getAttribute?.("aria-hidden") === "true")
|
|
274
|
-
return "";
|
|
437
|
+
if (el.getAttribute?.("aria-hidden") === "true") return "";
|
|
275
438
|
return (el.textContent ?? "").trim().replace(/\s+/g, " ");
|
|
276
439
|
}
|
|
277
440
|
function checkDuplicateAdjacentText(root, findings) {
|
|
@@ -284,7 +447,7 @@ function checkDuplicateAdjacentText(root, findings) {
|
|
|
284
447
|
const nextText = getVisibleText(next);
|
|
285
448
|
if (currentText && nextText && currentText === nextText && current.getAttribute?.("aria-hidden") !== "true" && next.getAttribute?.("aria-hidden") !== "true") {
|
|
286
449
|
const tag = next.rawTagName?.toLowerCase() || "element";
|
|
287
|
-
findings
|
|
450
|
+
pushFinding(findings, {
|
|
288
451
|
severity: "warning",
|
|
289
452
|
message: `Adjacent <${current.rawTagName?.toLowerCase()}> and <${tag}> have identical text "${currentText.slice(0, 40)}${currentText.length > 40 ? "..." : ""}" — screen readers will announce it twice`,
|
|
290
453
|
suggestion: 'Add aria-hidden="true" to the decorative duplicate. If both are meaningful, differentiate their text content.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jay-framework/a11y-validator",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Accessibility validation plugin for Jay Framework — checks jay-html templates for WCAG best practices",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"test": "vitest run"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@jay-framework/compiler-shared": "^0.
|
|
27
|
+
"@jay-framework/compiler-shared": "^0.23.1"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@jay-framework/dev-environment": "^0.
|
|
31
|
-
"@jay-framework/jay-stack-cli": "^0.
|
|
30
|
+
"@jay-framework/dev-environment": "^0.23.1",
|
|
31
|
+
"@jay-framework/jay-stack-cli": "^0.23.1",
|
|
32
32
|
"@types/node": "^22.15.21",
|
|
33
33
|
"node-html-parser": "^6.1.0",
|
|
34
34
|
"rimraf": "^5.0.5",
|