@emasoft/svg-matrix 1.0.18 → 1.0.20
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 +256 -759
- package/bin/svg-matrix.js +171 -2
- package/bin/svglinter.cjs +1162 -0
- package/package.json +8 -2
- package/scripts/postinstall.js +6 -9
- package/src/animation-optimization.js +394 -0
- package/src/animation-references.js +440 -0
- package/src/arc-length.js +940 -0
- package/src/bezier-analysis.js +1626 -0
- package/src/bezier-intersections.js +1369 -0
- package/src/clip-path-resolver.js +110 -2
- package/src/convert-path-data.js +583 -0
- package/src/css-specificity.js +443 -0
- package/src/douglas-peucker.js +356 -0
- package/src/flatten-pipeline.js +109 -4
- package/src/geometry-to-path.js +126 -16
- package/src/gjk-collision.js +840 -0
- package/src/index.js +175 -2
- package/src/off-canvas-detection.js +1222 -0
- package/src/path-analysis.js +1241 -0
- package/src/path-data-plugins.js +928 -0
- package/src/path-optimization.js +825 -0
- package/src/path-simplification.js +1140 -0
- package/src/polygon-clip.js +376 -99
- package/src/svg-boolean-ops.js +898 -0
- package/src/svg-collections.js +910 -0
- package/src/svg-parser.js +175 -16
- package/src/svg-rendering-context.js +627 -0
- package/src/svg-toolbox.js +7495 -0
- package/src/svg-validation-data.js +944 -0
- package/src/transform-decomposition.js +810 -0
- package/src/transform-optimization.js +936 -0
- package/src/use-symbol-resolver.js +75 -7
|
@@ -0,0 +1,1162 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* svglinter - SVG validation CLI tool
|
|
4
|
+
*
|
|
5
|
+
* A linter for SVG files with ESLint/Ruff-style output.
|
|
6
|
+
* Validates SVG files against SVG 1.1 specification.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* svglinter [options] <file|dir|glob...>
|
|
10
|
+
*
|
|
11
|
+
* Examples:
|
|
12
|
+
* svglinter icon.svg
|
|
13
|
+
* svglinter src/icons/*.svg
|
|
14
|
+
* svglinter --fix broken.svg
|
|
15
|
+
* svglinter --ignore E001,W003 *.svg
|
|
16
|
+
* svglinter --list-rules
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use strict';
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const path = require('node:path');
|
|
23
|
+
|
|
24
|
+
// ============================================================================
|
|
25
|
+
// RULE REGISTRY - All validation rules with unique codes and descriptions
|
|
26
|
+
// ============================================================================
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Rule registry mapping issue types to codes and descriptions
|
|
30
|
+
* Format: { code, type, severity, description, fixable }
|
|
31
|
+
*
|
|
32
|
+
* Codes:
|
|
33
|
+
* E001-E099: Reference errors
|
|
34
|
+
* E100-E199: Structure errors
|
|
35
|
+
* E200-E299: Syntax errors
|
|
36
|
+
* W001-W099: Reference warnings
|
|
37
|
+
* W100-W199: Typo/unknown warnings
|
|
38
|
+
* W200-W299: Style warnings
|
|
39
|
+
*/
|
|
40
|
+
const RULES = {
|
|
41
|
+
// === ERRORS (E###) ===
|
|
42
|
+
|
|
43
|
+
// Reference errors (E001-E099)
|
|
44
|
+
E001: {
|
|
45
|
+
type: 'broken_reference',
|
|
46
|
+
severity: 'error',
|
|
47
|
+
description: 'Reference to non-existent ID (url(#id) or xlink:href="#id")',
|
|
48
|
+
fixable: false,
|
|
49
|
+
},
|
|
50
|
+
E002: {
|
|
51
|
+
type: 'broken_url_reference',
|
|
52
|
+
severity: 'error',
|
|
53
|
+
description: 'Broken URL reference in href or xlink:href attribute',
|
|
54
|
+
fixable: false,
|
|
55
|
+
},
|
|
56
|
+
E003: {
|
|
57
|
+
type: 'duplicate_id',
|
|
58
|
+
severity: 'error',
|
|
59
|
+
description: 'Duplicate ID attribute (IDs must be unique)',
|
|
60
|
+
fixable: true,
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
// Structure errors (E100-E199)
|
|
64
|
+
E101: {
|
|
65
|
+
type: 'missing_required_attribute',
|
|
66
|
+
severity: 'error',
|
|
67
|
+
description: 'Required attribute is missing on element',
|
|
68
|
+
fixable: false,
|
|
69
|
+
},
|
|
70
|
+
E102: {
|
|
71
|
+
type: 'invalid_child_element',
|
|
72
|
+
severity: 'error',
|
|
73
|
+
description: 'Invalid child element (not allowed by SVG spec)',
|
|
74
|
+
fixable: false,
|
|
75
|
+
},
|
|
76
|
+
E103: {
|
|
77
|
+
type: 'animation_in_empty_element',
|
|
78
|
+
severity: 'error',
|
|
79
|
+
description: 'Animation element inside empty element (no valid target)',
|
|
80
|
+
fixable: false,
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// Syntax errors (E200-E299)
|
|
84
|
+
E201: {
|
|
85
|
+
type: 'malformed_viewbox',
|
|
86
|
+
severity: 'error',
|
|
87
|
+
description: 'Malformed viewBox attribute (requires 4 numbers)',
|
|
88
|
+
fixable: false,
|
|
89
|
+
},
|
|
90
|
+
E202: {
|
|
91
|
+
type: 'malformed_points',
|
|
92
|
+
severity: 'error',
|
|
93
|
+
description: 'Malformed points attribute on polygon/polyline',
|
|
94
|
+
fixable: false,
|
|
95
|
+
},
|
|
96
|
+
E203: {
|
|
97
|
+
type: 'malformed_transform',
|
|
98
|
+
severity: 'error',
|
|
99
|
+
description: 'Malformed transform attribute',
|
|
100
|
+
fixable: false,
|
|
101
|
+
},
|
|
102
|
+
E204: {
|
|
103
|
+
type: 'invalid_enum_value',
|
|
104
|
+
severity: 'error',
|
|
105
|
+
description: 'Invalid enumeration value for attribute',
|
|
106
|
+
fixable: false,
|
|
107
|
+
},
|
|
108
|
+
E205: {
|
|
109
|
+
type: 'invalid_numeric_constraint',
|
|
110
|
+
severity: 'error',
|
|
111
|
+
description: 'Numeric value violates constraint (negative where positive required)',
|
|
112
|
+
fixable: false,
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
// === WARNINGS (W###) ===
|
|
116
|
+
|
|
117
|
+
// Reference warnings (W001-W099)
|
|
118
|
+
W001: {
|
|
119
|
+
type: 'invalid_attr_on_element',
|
|
120
|
+
severity: 'warning',
|
|
121
|
+
description: 'Attribute not valid on this element type',
|
|
122
|
+
fixable: false,
|
|
123
|
+
},
|
|
124
|
+
W002: {
|
|
125
|
+
type: 'missing_namespace',
|
|
126
|
+
severity: 'warning',
|
|
127
|
+
description: 'Missing xmlns namespace declaration on root SVG',
|
|
128
|
+
fixable: true,
|
|
129
|
+
},
|
|
130
|
+
W003: {
|
|
131
|
+
type: 'invalid_timing',
|
|
132
|
+
severity: 'warning',
|
|
133
|
+
description: 'Invalid timing value in animation element',
|
|
134
|
+
fixable: false,
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// Typo/unknown warnings (W100-W199)
|
|
138
|
+
W101: {
|
|
139
|
+
type: 'mistyped_element_detected',
|
|
140
|
+
severity: 'warning',
|
|
141
|
+
description: 'Possible typo in element name (similar to valid SVG element)',
|
|
142
|
+
fixable: true,
|
|
143
|
+
},
|
|
144
|
+
W102: {
|
|
145
|
+
type: 'unknown_element_detected',
|
|
146
|
+
severity: 'warning',
|
|
147
|
+
description: 'Unknown element (not in SVG 1.1 or SVG 2.0 spec)',
|
|
148
|
+
fixable: false,
|
|
149
|
+
},
|
|
150
|
+
W103: {
|
|
151
|
+
type: 'mistyped_attribute_detected',
|
|
152
|
+
severity: 'warning',
|
|
153
|
+
description: 'Possible typo in attribute name (similar to valid SVG attribute)',
|
|
154
|
+
fixable: true,
|
|
155
|
+
},
|
|
156
|
+
W104: {
|
|
157
|
+
type: 'unknown_attribute_detected',
|
|
158
|
+
severity: 'warning',
|
|
159
|
+
description: 'Unknown attribute (not in SVG 1.1 spec)',
|
|
160
|
+
fixable: false,
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
// Style warnings (W200-W299)
|
|
164
|
+
W201: {
|
|
165
|
+
type: 'uppercase_unit',
|
|
166
|
+
severity: 'warning',
|
|
167
|
+
description: 'Uppercase unit (should be lowercase: px, em, etc.)',
|
|
168
|
+
fixable: true,
|
|
169
|
+
},
|
|
170
|
+
W202: {
|
|
171
|
+
type: 'invalid_whitespace',
|
|
172
|
+
severity: 'warning',
|
|
173
|
+
description: 'Invalid whitespace in attribute value',
|
|
174
|
+
fixable: true,
|
|
175
|
+
},
|
|
176
|
+
W203: {
|
|
177
|
+
type: 'invalid_number',
|
|
178
|
+
severity: 'warning',
|
|
179
|
+
description: 'Invalid number format in attribute value',
|
|
180
|
+
fixable: false,
|
|
181
|
+
},
|
|
182
|
+
W204: {
|
|
183
|
+
type: 'invalid_color',
|
|
184
|
+
severity: 'warning',
|
|
185
|
+
description: 'Invalid color value (not a valid CSS color or SVG color keyword)',
|
|
186
|
+
fixable: false,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Create reverse lookup: type -> code
|
|
191
|
+
const TYPE_TO_CODE = {};
|
|
192
|
+
for (const [code, rule] of Object.entries(RULES)) {
|
|
193
|
+
TYPE_TO_CODE[rule.type] = code;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================================================
|
|
197
|
+
// ANSI COLORS
|
|
198
|
+
// ============================================================================
|
|
199
|
+
|
|
200
|
+
const colors = {
|
|
201
|
+
reset: '\x1b[0m',
|
|
202
|
+
bold: '\x1b[1m',
|
|
203
|
+
dim: '\x1b[2m',
|
|
204
|
+
red: '\x1b[31m',
|
|
205
|
+
yellow: '\x1b[33m',
|
|
206
|
+
green: '\x1b[32m',
|
|
207
|
+
cyan: '\x1b[36m',
|
|
208
|
+
magenta: '\x1b[35m',
|
|
209
|
+
gray: '\x1b[90m',
|
|
210
|
+
white: '\x1b[37m',
|
|
211
|
+
blue: '\x1b[34m',
|
|
212
|
+
bgRed: '\x1b[41m',
|
|
213
|
+
bgYellow: '\x1b[43m',
|
|
214
|
+
bgGreen: '\x1b[42m',
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const NO_COLOR = process.env.NO_COLOR || process.env.SVGLINT_NO_COLOR;
|
|
218
|
+
const FORCE_COLOR = process.env.FORCE_COLOR || process.env.SVGLINT_FORCE_COLOR;
|
|
219
|
+
let useColors = FORCE_COLOR || (!NO_COLOR && process.stdout.isTTY);
|
|
220
|
+
|
|
221
|
+
function c(color, text) {
|
|
222
|
+
if (!useColors) return text;
|
|
223
|
+
return `${colors[color]}${text}${colors.reset}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// CONFIGURATION
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
const CONFIG_FILES = [
|
|
231
|
+
'.svglintrc',
|
|
232
|
+
'.svglintrc.json',
|
|
233
|
+
'svglint.config.json',
|
|
234
|
+
'.svglintrc.js',
|
|
235
|
+
'svglint.config.js',
|
|
236
|
+
];
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Load configuration from file
|
|
240
|
+
*/
|
|
241
|
+
function loadConfig(configPath) {
|
|
242
|
+
const defaultConfig = {
|
|
243
|
+
ignore: [],
|
|
244
|
+
select: [],
|
|
245
|
+
fix: false,
|
|
246
|
+
quiet: false,
|
|
247
|
+
format: 'stylish',
|
|
248
|
+
maxWarnings: -1,
|
|
249
|
+
bail: false,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
// If explicit config path provided
|
|
253
|
+
if (configPath) {
|
|
254
|
+
if (!fs.existsSync(configPath)) {
|
|
255
|
+
console.error(c('red', `Error: Config file not found: ${configPath}`));
|
|
256
|
+
process.exit(2);
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
260
|
+
return { ...defaultConfig, ...JSON.parse(content) };
|
|
261
|
+
} catch (err) {
|
|
262
|
+
console.error(c('red', `Error parsing config file: ${err.message}`));
|
|
263
|
+
process.exit(2);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Search for config file in current directory
|
|
268
|
+
for (const filename of CONFIG_FILES) {
|
|
269
|
+
const filepath = path.join(process.cwd(), filename);
|
|
270
|
+
if (fs.existsSync(filepath)) {
|
|
271
|
+
try {
|
|
272
|
+
if (filename.endsWith('.js')) {
|
|
273
|
+
// For JS configs, we'd need dynamic import (not supported in CJS easily)
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const content = fs.readFileSync(filepath, 'utf8');
|
|
277
|
+
return { ...defaultConfig, ...JSON.parse(content) };
|
|
278
|
+
} catch {
|
|
279
|
+
// Ignore parse errors in auto-discovered configs
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return defaultConfig;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ============================================================================
|
|
288
|
+
// ARGUMENT PARSING
|
|
289
|
+
// ============================================================================
|
|
290
|
+
|
|
291
|
+
function parseArgs(argv) {
|
|
292
|
+
const args = {
|
|
293
|
+
files: [],
|
|
294
|
+
fix: false,
|
|
295
|
+
quiet: false,
|
|
296
|
+
format: 'stylish',
|
|
297
|
+
maxWarnings: -1,
|
|
298
|
+
errorsOnly: false,
|
|
299
|
+
help: false,
|
|
300
|
+
version: false,
|
|
301
|
+
noColor: false,
|
|
302
|
+
outputFile: null,
|
|
303
|
+
config: null,
|
|
304
|
+
ignore: [],
|
|
305
|
+
select: [],
|
|
306
|
+
listRules: false,
|
|
307
|
+
showIgnored: false,
|
|
308
|
+
bail: false,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
let i = 2;
|
|
312
|
+
while (i < argv.length) {
|
|
313
|
+
const arg = argv[i];
|
|
314
|
+
|
|
315
|
+
if (arg === '--help' || arg === '-h') {
|
|
316
|
+
args.help = true;
|
|
317
|
+
} else if (arg === '--version' || arg === '-v') {
|
|
318
|
+
args.version = true;
|
|
319
|
+
} else if (arg === '--fix') {
|
|
320
|
+
args.fix = true;
|
|
321
|
+
} else if (arg === '--quiet' || arg === '-q') {
|
|
322
|
+
args.quiet = true;
|
|
323
|
+
} else if (arg === '--errors-only' || arg === '-E') {
|
|
324
|
+
args.errorsOnly = true;
|
|
325
|
+
} else if (arg === '--no-color') {
|
|
326
|
+
args.noColor = true;
|
|
327
|
+
} else if (arg === '--list-rules' || arg === '--rules') {
|
|
328
|
+
args.listRules = true;
|
|
329
|
+
} else if (arg === '--show-ignored') {
|
|
330
|
+
args.showIgnored = true;
|
|
331
|
+
} else if (arg === '--bail' || arg === '--fail-fast' || arg === '-x') {
|
|
332
|
+
args.bail = true;
|
|
333
|
+
} else if (arg === '--format' || arg === '-f') {
|
|
334
|
+
i++;
|
|
335
|
+
args.format = argv[i] || 'stylish';
|
|
336
|
+
} else if (arg.startsWith('--format=')) {
|
|
337
|
+
args.format = arg.split('=')[1];
|
|
338
|
+
} else if (arg === '--max-warnings') {
|
|
339
|
+
i++;
|
|
340
|
+
args.maxWarnings = parseInt(argv[i], 10);
|
|
341
|
+
} else if (arg.startsWith('--max-warnings=')) {
|
|
342
|
+
args.maxWarnings = parseInt(arg.split('=')[1], 10);
|
|
343
|
+
} else if (arg === '--output' || arg === '-o') {
|
|
344
|
+
i++;
|
|
345
|
+
args.outputFile = argv[i];
|
|
346
|
+
} else if (arg.startsWith('--output=')) {
|
|
347
|
+
args.outputFile = arg.split('=')[1];
|
|
348
|
+
} else if (arg === '--config' || arg === '-c') {
|
|
349
|
+
i++;
|
|
350
|
+
args.config = argv[i];
|
|
351
|
+
} else if (arg.startsWith('--config=')) {
|
|
352
|
+
args.config = arg.split('=')[1];
|
|
353
|
+
} else if (arg === '--ignore' || arg === '-i') {
|
|
354
|
+
i++;
|
|
355
|
+
if (argv[i]) {
|
|
356
|
+
args.ignore.push(...argv[i].split(',').map(s => s.trim().toUpperCase()));
|
|
357
|
+
}
|
|
358
|
+
} else if (arg.startsWith('--ignore=')) {
|
|
359
|
+
args.ignore.push(...arg.split('=')[1].split(',').map(s => s.trim().toUpperCase()));
|
|
360
|
+
} else if (arg === '--select' || arg === '-s') {
|
|
361
|
+
i++;
|
|
362
|
+
if (argv[i]) {
|
|
363
|
+
args.select.push(...argv[i].split(',').map(s => s.trim().toUpperCase()));
|
|
364
|
+
}
|
|
365
|
+
} else if (arg.startsWith('--select=')) {
|
|
366
|
+
args.select.push(...arg.split('=')[1].split(',').map(s => s.trim().toUpperCase()));
|
|
367
|
+
} else if (!arg.startsWith('-')) {
|
|
368
|
+
args.files.push(arg);
|
|
369
|
+
} else {
|
|
370
|
+
console.error(c('red', `Unknown option: ${arg}`));
|
|
371
|
+
console.error(`Run 'svglinter --help' for usage information.`);
|
|
372
|
+
process.exit(1);
|
|
373
|
+
}
|
|
374
|
+
i++;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
return args;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// HELP & VERSION
|
|
382
|
+
// ============================================================================
|
|
383
|
+
|
|
384
|
+
function showHelp() {
|
|
385
|
+
console.log(`
|
|
386
|
+
${c('bold', 'svglinter')} - SVG validation CLI tool (ESLint/Ruff style)
|
|
387
|
+
|
|
388
|
+
${c('bold', 'USAGE')}
|
|
389
|
+
svglinter [options] <file|dir|glob...>
|
|
390
|
+
|
|
391
|
+
${c('bold', 'ARGUMENTS')}
|
|
392
|
+
<file|dir|glob...> Files, directories, or glob patterns to lint
|
|
393
|
+
|
|
394
|
+
${c('bold', 'OPTIONS')}
|
|
395
|
+
${c('cyan', '-h, --help')} Show this help message
|
|
396
|
+
${c('cyan', '-v, --version')} Show version number
|
|
397
|
+
${c('cyan', '--list-rules')} List all available rules with codes
|
|
398
|
+
${c('cyan', '--fix')} Automatically fix problems (when possible)
|
|
399
|
+
${c('cyan', '-q, --quiet')} Suppress output (only show summary)
|
|
400
|
+
${c('cyan', '-E, --errors-only')} Only report errors (ignore warnings)
|
|
401
|
+
${c('cyan', '-x, --bail')} Stop after first error (fail-fast mode)
|
|
402
|
+
|
|
403
|
+
${c('bold', 'RULE SELECTION')}
|
|
404
|
+
${c('cyan', '-i, --ignore')} <rules> Ignore specific rules (comma-separated)
|
|
405
|
+
${c('cyan', '-s, --select')} <rules> Only check specific rules (comma-separated)
|
|
406
|
+
${c('cyan', '--show-ignored')} Show which issues were ignored
|
|
407
|
+
|
|
408
|
+
${c('bold', 'OUTPUT')}
|
|
409
|
+
${c('cyan', '-f, --format')} <type> Output format: stylish, compact, json, tap, junit, ruff
|
|
410
|
+
${c('cyan', '-o, --output')} <file> Write report to file
|
|
411
|
+
${c('cyan', '--max-warnings')} <n> Exit with error if warnings exceed threshold
|
|
412
|
+
${c('cyan', '--no-color')} Disable colored output
|
|
413
|
+
|
|
414
|
+
${c('bold', 'CONFIGURATION')}
|
|
415
|
+
${c('cyan', '-c, --config')} <file> Path to config file (.svglintrc.json)
|
|
416
|
+
|
|
417
|
+
${c('bold', 'RULE CODES')}
|
|
418
|
+
${c('red', 'E001-E099')} Reference errors (broken refs, duplicates)
|
|
419
|
+
${c('red', 'E100-E199')} Structure errors (missing attrs, invalid children)
|
|
420
|
+
${c('red', 'E200-E299')} Syntax errors (malformed values)
|
|
421
|
+
${c('yellow', 'W001-W099')} Reference warnings (invalid attrs, timing)
|
|
422
|
+
${c('yellow', 'W100-W199')} Typo/unknown warnings (elements, attributes)
|
|
423
|
+
${c('yellow', 'W200-W299')} Style warnings (case, whitespace, colors)
|
|
424
|
+
|
|
425
|
+
${c('bold', 'EXAMPLES')}
|
|
426
|
+
${c('dim', '# Lint files')}
|
|
427
|
+
svglinter icon.svg
|
|
428
|
+
svglinter "src/**/*.svg"
|
|
429
|
+
|
|
430
|
+
${c('dim', '# Ignore specific rules')}
|
|
431
|
+
svglinter --ignore E001,W104 *.svg
|
|
432
|
+
svglinter --ignore W1 ${c('dim', '# Ignores all W1xx rules')}
|
|
433
|
+
|
|
434
|
+
${c('dim', '# Only check specific rules')}
|
|
435
|
+
svglinter --select E001,E002 *.svg
|
|
436
|
+
|
|
437
|
+
${c('dim', '# Fix issues automatically')}
|
|
438
|
+
svglinter --fix broken.svg
|
|
439
|
+
|
|
440
|
+
${c('dim', '# CI-friendly output')}
|
|
441
|
+
svglinter --format json --output report.json src/
|
|
442
|
+
|
|
443
|
+
${c('bold', 'CONFIG FILE')} (.svglintrc.json)
|
|
444
|
+
{
|
|
445
|
+
"ignore": ["W104", "W204"],
|
|
446
|
+
"maxWarnings": 10,
|
|
447
|
+
"fix": false
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
${c('bold', 'INLINE COMMENTS')}
|
|
451
|
+
<!-- svglint-disable --> Disable all rules
|
|
452
|
+
<!-- svglint-disable E001,W104 --> Disable specific rules
|
|
453
|
+
<!-- svglint-enable --> Re-enable rules
|
|
454
|
+
|
|
455
|
+
${c('bold', 'EXIT CODES')}
|
|
456
|
+
${c('green', '0')} No errors found
|
|
457
|
+
${c('red', '1')} Errors found or max-warnings exceeded
|
|
458
|
+
${c('red', '2')} Fatal error
|
|
459
|
+
|
|
460
|
+
${c('dim', 'Docs: https://github.com/Emasoft/SVG-MATRIX')}
|
|
461
|
+
`);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function showVersion() {
|
|
465
|
+
const packagePath = path.join(__dirname, '..', 'package.json');
|
|
466
|
+
try {
|
|
467
|
+
const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8'));
|
|
468
|
+
console.log(`svglinter ${pkg.version}`);
|
|
469
|
+
} catch {
|
|
470
|
+
console.log('svglinter (version unknown)');
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function listRules() {
|
|
475
|
+
console.log(`
|
|
476
|
+
${c('bold', 'SVGLINTER RULES')}
|
|
477
|
+
${'='.repeat(78)}
|
|
478
|
+
`);
|
|
479
|
+
|
|
480
|
+
// Group by severity and code range
|
|
481
|
+
const errors = Object.entries(RULES).filter(([code]) => code.startsWith('E'));
|
|
482
|
+
const warnings = Object.entries(RULES).filter(([code]) => code.startsWith('W'));
|
|
483
|
+
|
|
484
|
+
console.log(c('bold', c('red', 'ERRORS')) + '\n');
|
|
485
|
+
console.log(`${c('dim', 'Code')} ${c('dim', 'Fixable')} ${c('dim', 'Description')}`);
|
|
486
|
+
console.log(`${'-'.repeat(78)}`);
|
|
487
|
+
|
|
488
|
+
for (const [code, rule] of errors) {
|
|
489
|
+
const fixable = rule.fixable ? c('green', 'Yes') : c('dim', 'No ');
|
|
490
|
+
console.log(`${c('red', code)} ${fixable} ${rule.description}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
console.log(`\n${c('bold', c('yellow', 'WARNINGS'))}\n`);
|
|
494
|
+
console.log(`${c('dim', 'Code')} ${c('dim', 'Fixable')} ${c('dim', 'Description')}`);
|
|
495
|
+
console.log(`${'-'.repeat(78)}`);
|
|
496
|
+
|
|
497
|
+
for (const [code, rule] of warnings) {
|
|
498
|
+
const fixable = rule.fixable ? c('green', 'Yes') : c('dim', 'No ');
|
|
499
|
+
console.log(`${c('yellow', code)} ${fixable} ${rule.description}`);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
console.log(`
|
|
503
|
+
${c('dim', 'Total:')} ${errors.length} error rules, ${warnings.length} warning rules
|
|
504
|
+
${c('dim', 'Fixable:')} ${Object.values(RULES).filter(r => r.fixable).length} rules
|
|
505
|
+
|
|
506
|
+
${c('bold', 'Usage:')}
|
|
507
|
+
svglinter --ignore E001,W104 file.svg ${c('dim', '# Ignore specific rules')}
|
|
508
|
+
svglinter --ignore W1 file.svg ${c('dim', '# Ignore all W1xx rules')}
|
|
509
|
+
svglinter --select E001,E002 file.svg ${c('dim', '# Only check these rules')}
|
|
510
|
+
`);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// ============================================================================
|
|
514
|
+
// FILE DISCOVERY
|
|
515
|
+
// ============================================================================
|
|
516
|
+
|
|
517
|
+
async function expandFiles(patterns) {
|
|
518
|
+
const files = new Set();
|
|
519
|
+
|
|
520
|
+
for (const pattern of patterns) {
|
|
521
|
+
if (fs.existsSync(pattern)) {
|
|
522
|
+
const stat = fs.statSync(pattern);
|
|
523
|
+
if (stat.isFile() && pattern.endsWith('.svg')) {
|
|
524
|
+
files.add(path.resolve(pattern));
|
|
525
|
+
} else if (stat.isDirectory()) {
|
|
526
|
+
const dirFiles = await findSvgFiles(pattern);
|
|
527
|
+
dirFiles.forEach(f => files.add(f));
|
|
528
|
+
}
|
|
529
|
+
} else {
|
|
530
|
+
const globFiles = await globPromise(pattern);
|
|
531
|
+
globFiles.filter(f => f.endsWith('.svg')).forEach(f => files.add(path.resolve(f)));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return Array.from(files).sort();
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function findSvgFiles(dir) {
|
|
539
|
+
const results = [];
|
|
540
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
541
|
+
|
|
542
|
+
for (const entry of entries) {
|
|
543
|
+
const fullPath = path.join(dir, entry.name);
|
|
544
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
545
|
+
const subFiles = await findSvgFiles(fullPath);
|
|
546
|
+
results.push(...subFiles);
|
|
547
|
+
} else if (entry.isFile() && entry.name.endsWith('.svg')) {
|
|
548
|
+
results.push(path.resolve(fullPath));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return results;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function globPromise(pattern) {
|
|
556
|
+
const files = [];
|
|
557
|
+
|
|
558
|
+
if (pattern.includes('**')) {
|
|
559
|
+
const baseDir = pattern.split('**')[0] || '.';
|
|
560
|
+
const ext = pattern.split('**')[1]?.replace(/^[/\\]*\*/, '') || '.svg';
|
|
561
|
+
const dirFiles = await findSvgFilesWithExt(baseDir, ext);
|
|
562
|
+
files.push(...dirFiles);
|
|
563
|
+
} else if (pattern.includes('*')) {
|
|
564
|
+
const dir = path.dirname(pattern);
|
|
565
|
+
const filePattern = path.basename(pattern);
|
|
566
|
+
const regex = new RegExp('^' + filePattern.replace(/\*/g, '.*') + '$');
|
|
567
|
+
|
|
568
|
+
if (fs.existsSync(dir)) {
|
|
569
|
+
const entries = fs.readdirSync(dir);
|
|
570
|
+
for (const entry of entries) {
|
|
571
|
+
if (regex.test(entry)) {
|
|
572
|
+
files.push(path.resolve(path.join(dir, entry)));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return files;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function findSvgFilesWithExt(dir, ext) {
|
|
582
|
+
const results = [];
|
|
583
|
+
if (!fs.existsSync(dir)) return results;
|
|
584
|
+
|
|
585
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
586
|
+
|
|
587
|
+
for (const entry of entries) {
|
|
588
|
+
const fullPath = path.join(dir, entry.name);
|
|
589
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
590
|
+
const subFiles = await findSvgFilesWithExt(fullPath, ext);
|
|
591
|
+
results.push(...subFiles);
|
|
592
|
+
} else if (entry.isFile() && entry.name.endsWith(ext)) {
|
|
593
|
+
results.push(path.resolve(fullPath));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return results;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ============================================================================
|
|
601
|
+
// RULE FILTERING
|
|
602
|
+
// ============================================================================
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Check if a rule code should be ignored
|
|
606
|
+
*/
|
|
607
|
+
function shouldIgnoreRule(code, ignoreList) {
|
|
608
|
+
if (!ignoreList || ignoreList.length === 0) return false;
|
|
609
|
+
|
|
610
|
+
for (const pattern of ignoreList) {
|
|
611
|
+
// Exact match
|
|
612
|
+
if (code === pattern) return true;
|
|
613
|
+
|
|
614
|
+
// Prefix match (e.g., "W1" matches "W101", "W102", etc.)
|
|
615
|
+
if (code.startsWith(pattern)) return true;
|
|
616
|
+
|
|
617
|
+
// Category match (e.g., "E" matches all errors)
|
|
618
|
+
if (pattern.length === 1 && code.startsWith(pattern)) return true;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Check if a rule code is selected
|
|
626
|
+
*/
|
|
627
|
+
function isRuleSelected(code, selectList) {
|
|
628
|
+
if (!selectList || selectList.length === 0) return true;
|
|
629
|
+
|
|
630
|
+
for (const pattern of selectList) {
|
|
631
|
+
if (code === pattern) return true;
|
|
632
|
+
if (code.startsWith(pattern)) return true;
|
|
633
|
+
if (pattern.length === 1 && code.startsWith(pattern)) return true;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return false;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Parse inline disable comments from SVG content
|
|
641
|
+
*/
|
|
642
|
+
function parseInlineDisables(content) {
|
|
643
|
+
const disabledRanges = [];
|
|
644
|
+
const lines = content.split('\n');
|
|
645
|
+
|
|
646
|
+
let currentDisabled = null;
|
|
647
|
+
let disabledRules = new Set();
|
|
648
|
+
|
|
649
|
+
for (let i = 0; i < lines.length; i++) {
|
|
650
|
+
const line = lines[i];
|
|
651
|
+
const lineNum = i + 1;
|
|
652
|
+
|
|
653
|
+
// Check for disable comment
|
|
654
|
+
const disableMatch = line.match(/<!--\s*svglint-disable(?:\s+([A-Z0-9,\s]+))?\s*-->/i);
|
|
655
|
+
if (disableMatch) {
|
|
656
|
+
const rules = disableMatch[1]
|
|
657
|
+
? disableMatch[1].split(',').map(r => r.trim().toUpperCase())
|
|
658
|
+
: ['*'];
|
|
659
|
+
currentDisabled = lineNum;
|
|
660
|
+
disabledRules = new Set(rules);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Check for enable comment
|
|
664
|
+
const enableMatch = line.match(/<!--\s*svglint-enable\s*-->/i);
|
|
665
|
+
if (enableMatch && currentDisabled !== null) {
|
|
666
|
+
disabledRanges.push({
|
|
667
|
+
startLine: currentDisabled,
|
|
668
|
+
endLine: lineNum,
|
|
669
|
+
rules: disabledRules,
|
|
670
|
+
});
|
|
671
|
+
currentDisabled = null;
|
|
672
|
+
disabledRules = new Set();
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Check for disable-next-line comment
|
|
676
|
+
const nextLineMatch = line.match(/<!--\s*svglint-disable-next-line(?:\s+([A-Z0-9,\s]+))?\s*-->/i);
|
|
677
|
+
if (nextLineMatch) {
|
|
678
|
+
const rules = nextLineMatch[1]
|
|
679
|
+
? nextLineMatch[1].split(',').map(r => r.trim().toUpperCase())
|
|
680
|
+
: ['*'];
|
|
681
|
+
disabledRanges.push({
|
|
682
|
+
startLine: lineNum + 1,
|
|
683
|
+
endLine: lineNum + 1,
|
|
684
|
+
rules: new Set(rules),
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Handle unclosed disable (disable until end of file)
|
|
690
|
+
if (currentDisabled !== null) {
|
|
691
|
+
disabledRanges.push({
|
|
692
|
+
startLine: currentDisabled,
|
|
693
|
+
endLine: lines.length + 1,
|
|
694
|
+
rules: disabledRules,
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return disabledRanges;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Check if an issue is disabled by inline comments
|
|
703
|
+
*/
|
|
704
|
+
function isDisabledByInline(issue, disabledRanges) {
|
|
705
|
+
const code = TYPE_TO_CODE[issue.type] || '';
|
|
706
|
+
const line = issue.line || 0;
|
|
707
|
+
|
|
708
|
+
for (const range of disabledRanges) {
|
|
709
|
+
if (line >= range.startLine && line <= range.endLine) {
|
|
710
|
+
if (range.rules.has('*') || range.rules.has(code)) {
|
|
711
|
+
return true;
|
|
712
|
+
}
|
|
713
|
+
// Check prefix match
|
|
714
|
+
for (const rule of range.rules) {
|
|
715
|
+
if (code.startsWith(rule)) return true;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// ============================================================================
|
|
724
|
+
// FORMATTERS
|
|
725
|
+
// ============================================================================
|
|
726
|
+
|
|
727
|
+
function formatRuleCode(type) {
|
|
728
|
+
return TYPE_TO_CODE[type] || 'W000';
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function formatSeverity(severity, format = 'stylish') {
|
|
732
|
+
if (format === 'compact' || format === 'ruff') {
|
|
733
|
+
return severity === 'error' ? 'error' : 'warning';
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
if (severity === 'error') {
|
|
737
|
+
return c('red', 'error');
|
|
738
|
+
}
|
|
739
|
+
return c('yellow', 'warning');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function formatStylish(results, quiet, showIgnored) {
|
|
743
|
+
const lines = [];
|
|
744
|
+
let totalErrors = 0;
|
|
745
|
+
let totalWarnings = 0;
|
|
746
|
+
let totalIgnored = 0;
|
|
747
|
+
|
|
748
|
+
for (const result of results) {
|
|
749
|
+
if (result.issues.length === 0 && (!showIgnored || result.ignored === 0)) continue;
|
|
750
|
+
|
|
751
|
+
totalErrors += result.errorCount;
|
|
752
|
+
totalWarnings += result.warningCount;
|
|
753
|
+
totalIgnored += result.ignored || 0;
|
|
754
|
+
|
|
755
|
+
lines.push('');
|
|
756
|
+
lines.push(c('bold', path.relative(process.cwd(), result.file)));
|
|
757
|
+
|
|
758
|
+
for (const issue of result.issues) {
|
|
759
|
+
const line = issue.line || 0;
|
|
760
|
+
const column = issue.column || 0;
|
|
761
|
+
const pos = c('dim', `${line}:${column}`.padEnd(8));
|
|
762
|
+
const severity = formatSeverity(issue.severity);
|
|
763
|
+
const code = c('cyan', formatRuleCode(issue.type).padEnd(5));
|
|
764
|
+
const message = issue.reason || issue.type;
|
|
765
|
+
|
|
766
|
+
lines.push(` ${pos} ${severity.padEnd(useColors ? 17 : 7)} ${code} ${message}`);
|
|
767
|
+
|
|
768
|
+
if (issue.sourceLine && !quiet) {
|
|
769
|
+
const sourceLine = issue.sourceLine.replace(/\t/g, ' ');
|
|
770
|
+
lines.push(c('dim', ` ${sourceLine.substring(0, 72)}${sourceLine.length > 72 ? '...' : ''}`));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (showIgnored && result.ignored > 0) {
|
|
775
|
+
lines.push(c('dim', ` (${result.ignored} issue${result.ignored === 1 ? '' : 's'} ignored)`));
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (!quiet && (totalErrors > 0 || totalWarnings > 0)) {
|
|
780
|
+
lines.push('');
|
|
781
|
+
|
|
782
|
+
const problemCount = totalErrors + totalWarnings;
|
|
783
|
+
const summary = [];
|
|
784
|
+
|
|
785
|
+
if (totalErrors > 0) {
|
|
786
|
+
summary.push(c('red', `${totalErrors} error${totalErrors === 1 ? '' : 's'}`));
|
|
787
|
+
}
|
|
788
|
+
if (totalWarnings > 0) {
|
|
789
|
+
summary.push(c('yellow', `${totalWarnings} warning${totalWarnings === 1 ? '' : 's'}`));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const icon = totalErrors > 0 ? c('red', '\u2716') : c('yellow', '\u26A0');
|
|
793
|
+
lines.push(`${icon} ${problemCount} problem${problemCount === 1 ? '' : 's'} (${summary.join(', ')})`);
|
|
794
|
+
|
|
795
|
+
if (totalIgnored > 0) {
|
|
796
|
+
lines.push(c('dim', ` ${totalIgnored} issue${totalIgnored === 1 ? '' : 's'} ignored`));
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return lines.join('\n');
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
function formatRuff(results) {
|
|
804
|
+
// Ruff-style: file:line:col: CODE message
|
|
805
|
+
const lines = [];
|
|
806
|
+
|
|
807
|
+
for (const result of results) {
|
|
808
|
+
const relPath = path.relative(process.cwd(), result.file);
|
|
809
|
+
|
|
810
|
+
for (const issue of result.issues) {
|
|
811
|
+
const line = issue.line || 0;
|
|
812
|
+
const column = issue.column || 0;
|
|
813
|
+
const code = formatRuleCode(issue.type);
|
|
814
|
+
const message = issue.reason || issue.type;
|
|
815
|
+
|
|
816
|
+
lines.push(`${relPath}:${line}:${column}: ${c('cyan', code)} ${message}`);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return lines.join('\n');
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function formatCompact(results) {
|
|
824
|
+
const lines = [];
|
|
825
|
+
|
|
826
|
+
for (const result of results) {
|
|
827
|
+
const relPath = path.relative(process.cwd(), result.file);
|
|
828
|
+
|
|
829
|
+
for (const issue of result.issues) {
|
|
830
|
+
const line = issue.line || 0;
|
|
831
|
+
const column = issue.column || 0;
|
|
832
|
+
const severity = issue.severity === 'error' ? 'error' : 'warning';
|
|
833
|
+
const code = formatRuleCode(issue.type);
|
|
834
|
+
const message = issue.reason || issue.type;
|
|
835
|
+
|
|
836
|
+
lines.push(`${relPath}:${line}:${column}: ${severity} [${code}] ${message}`);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
return lines.join('\n');
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function formatJson(results) {
|
|
844
|
+
const output = results.map(r => ({
|
|
845
|
+
filePath: r.file,
|
|
846
|
+
messages: r.issues.map(issue => ({
|
|
847
|
+
ruleId: formatRuleCode(issue.type),
|
|
848
|
+
severity: issue.severity === 'error' ? 2 : 1,
|
|
849
|
+
message: issue.reason || issue.type,
|
|
850
|
+
line: issue.line || 0,
|
|
851
|
+
column: issue.column || 0,
|
|
852
|
+
element: issue.element,
|
|
853
|
+
attribute: issue.attr,
|
|
854
|
+
})),
|
|
855
|
+
errorCount: r.errorCount,
|
|
856
|
+
warningCount: r.warningCount,
|
|
857
|
+
ignoredCount: r.ignored || 0,
|
|
858
|
+
}));
|
|
859
|
+
|
|
860
|
+
return JSON.stringify(output, null, 2);
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function formatTap(results) {
|
|
864
|
+
const lines = ['TAP version 13'];
|
|
865
|
+
let testNum = 0;
|
|
866
|
+
|
|
867
|
+
for (const result of results) {
|
|
868
|
+
const relPath = path.relative(process.cwd(), result.file);
|
|
869
|
+
|
|
870
|
+
if (result.issues.length === 0) {
|
|
871
|
+
testNum++;
|
|
872
|
+
lines.push(`ok ${testNum} - ${relPath}`);
|
|
873
|
+
} else {
|
|
874
|
+
for (const issue of result.issues) {
|
|
875
|
+
testNum++;
|
|
876
|
+
const line = issue.line || 0;
|
|
877
|
+
const code = formatRuleCode(issue.type);
|
|
878
|
+
const message = issue.reason || issue.type;
|
|
879
|
+
lines.push(`not ok ${testNum} - ${relPath}:${line} [${code}] ${message}`);
|
|
880
|
+
lines.push(` ---`);
|
|
881
|
+
lines.push(` ruleId: ${code}`);
|
|
882
|
+
lines.push(` severity: ${issue.severity}`);
|
|
883
|
+
if (issue.element) lines.push(` element: ${issue.element}`);
|
|
884
|
+
if (issue.attr) lines.push(` attribute: ${issue.attr}`);
|
|
885
|
+
lines.push(` ...`);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
lines.push(`1..${testNum}`);
|
|
891
|
+
return lines.join('\n');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function formatJunit(results) {
|
|
895
|
+
const escapeXml = (str) => str
|
|
896
|
+
.replace(/&/g, '&')
|
|
897
|
+
.replace(/</g, '<')
|
|
898
|
+
.replace(/>/g, '>')
|
|
899
|
+
.replace(/"/g, '"')
|
|
900
|
+
.replace(/'/g, ''');
|
|
901
|
+
|
|
902
|
+
const lines = ['<?xml version="1.0" encoding="UTF-8"?>'];
|
|
903
|
+
|
|
904
|
+
let totalTests = 0;
|
|
905
|
+
let totalFailures = 0;
|
|
906
|
+
let totalErrors = 0;
|
|
907
|
+
|
|
908
|
+
const testSuites = [];
|
|
909
|
+
|
|
910
|
+
for (const result of results) {
|
|
911
|
+
const relPath = path.relative(process.cwd(), result.file);
|
|
912
|
+
totalTests++;
|
|
913
|
+
|
|
914
|
+
if (result.errorCount > 0) totalErrors++;
|
|
915
|
+
else if (result.warningCount > 0) totalFailures++;
|
|
916
|
+
|
|
917
|
+
const failures = result.issues.map(issue => {
|
|
918
|
+
const code = formatRuleCode(issue.type);
|
|
919
|
+
const message = escapeXml(issue.reason || issue.type);
|
|
920
|
+
const severity = issue.severity === 'error' ? 'error' : 'failure';
|
|
921
|
+
return ` <${severity} message="[${code}] ${message}" type="${code}">` +
|
|
922
|
+
`${escapeXml(relPath)}:${issue.line || 0}:${issue.column || 0}` +
|
|
923
|
+
`</${severity}>`;
|
|
924
|
+
}).join('\n');
|
|
925
|
+
|
|
926
|
+
testSuites.push(` <testsuite name="${escapeXml(relPath)}" tests="1" failures="${result.warningCount}" errors="${result.errorCount}">
|
|
927
|
+
<testcase name="${escapeXml(relPath)}" classname="svglinter">
|
|
928
|
+
${failures || ''}
|
|
929
|
+
</testcase>
|
|
930
|
+
</testsuite>`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
lines.push(`<testsuites tests="${totalTests}" failures="${totalFailures}" errors="${totalErrors}">`);
|
|
934
|
+
lines.push(testSuites.join('\n'));
|
|
935
|
+
lines.push('</testsuites>');
|
|
936
|
+
|
|
937
|
+
return lines.join('\n');
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
function formatResults(results, format, quiet, showIgnored) {
|
|
941
|
+
switch (format) {
|
|
942
|
+
case 'json':
|
|
943
|
+
return formatJson(results);
|
|
944
|
+
case 'compact':
|
|
945
|
+
return formatCompact(results);
|
|
946
|
+
case 'ruff':
|
|
947
|
+
return formatRuff(results);
|
|
948
|
+
case 'tap':
|
|
949
|
+
return formatTap(results);
|
|
950
|
+
case 'junit':
|
|
951
|
+
return formatJunit(results);
|
|
952
|
+
case 'stylish':
|
|
953
|
+
default:
|
|
954
|
+
return formatStylish(results, quiet, showIgnored);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ============================================================================
|
|
959
|
+
// MAIN
|
|
960
|
+
// ============================================================================
|
|
961
|
+
|
|
962
|
+
async function main() {
|
|
963
|
+
const args = parseArgs(process.argv);
|
|
964
|
+
|
|
965
|
+
if (args.help) {
|
|
966
|
+
showHelp();
|
|
967
|
+
process.exit(0);
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (args.version) {
|
|
971
|
+
showVersion();
|
|
972
|
+
process.exit(0);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (args.listRules) {
|
|
976
|
+
listRules();
|
|
977
|
+
process.exit(0);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
if (args.noColor) {
|
|
981
|
+
useColors = false;
|
|
982
|
+
Object.keys(colors).forEach(key => { colors[key] = ''; });
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// Load config file
|
|
986
|
+
const config = loadConfig(args.config);
|
|
987
|
+
|
|
988
|
+
// Merge config with CLI args (CLI takes precedence)
|
|
989
|
+
const ignore = [...config.ignore, ...args.ignore];
|
|
990
|
+
const select = args.select.length > 0 ? args.select : config.select;
|
|
991
|
+
const fix = args.fix || config.fix;
|
|
992
|
+
const quiet = args.quiet || config.quiet;
|
|
993
|
+
const maxWarnings = args.maxWarnings >= 0 ? args.maxWarnings : config.maxWarnings;
|
|
994
|
+
const bail = args.bail || config.bail;
|
|
995
|
+
|
|
996
|
+
if (args.files.length === 0) {
|
|
997
|
+
console.error(c('red', 'Error: No files specified'));
|
|
998
|
+
console.error(`Run 'svglinter --help' for usage information.`);
|
|
999
|
+
process.exit(2);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
const files = await expandFiles(args.files);
|
|
1003
|
+
|
|
1004
|
+
if (files.length === 0) {
|
|
1005
|
+
console.error(c('red', 'Error: No SVG files found matching the specified patterns'));
|
|
1006
|
+
process.exit(2);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
const { validateSvg, fixInvalidSvg } = await import('../src/svg-toolbox.js');
|
|
1010
|
+
|
|
1011
|
+
const results = [];
|
|
1012
|
+
let totalErrors = 0;
|
|
1013
|
+
let totalWarnings = 0;
|
|
1014
|
+
let totalIgnored = 0;
|
|
1015
|
+
let filesFixed = 0;
|
|
1016
|
+
|
|
1017
|
+
for (const file of files) {
|
|
1018
|
+
try {
|
|
1019
|
+
// Read file content for inline disable parsing
|
|
1020
|
+
const content = fs.readFileSync(file, 'utf8');
|
|
1021
|
+
const inlineDisables = parseInlineDisables(content);
|
|
1022
|
+
|
|
1023
|
+
if (fix) {
|
|
1024
|
+
const fixResult = await fixInvalidSvg(file, {
|
|
1025
|
+
outputFile: file,
|
|
1026
|
+
errorsOnly: args.errorsOnly,
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
if (fixResult.fixed && fixResult.fixed.length > 0) {
|
|
1030
|
+
filesFixed++;
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const result = await validateSvg(file, {
|
|
1035
|
+
errorsOnly: args.errorsOnly,
|
|
1036
|
+
includeSource: true,
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// Filter issues based on ignore/select and inline disables
|
|
1040
|
+
const filteredIssues = [];
|
|
1041
|
+
let ignoredCount = 0;
|
|
1042
|
+
|
|
1043
|
+
for (const issue of result.issues) {
|
|
1044
|
+
const code = TYPE_TO_CODE[issue.type] || 'W000';
|
|
1045
|
+
|
|
1046
|
+
// Check if rule is ignored
|
|
1047
|
+
if (shouldIgnoreRule(code, ignore)) {
|
|
1048
|
+
ignoredCount++;
|
|
1049
|
+
continue;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Check if rule is selected
|
|
1053
|
+
if (!isRuleSelected(code, select)) {
|
|
1054
|
+
ignoredCount++;
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// Check inline disables
|
|
1059
|
+
if (isDisabledByInline(issue, inlineDisables)) {
|
|
1060
|
+
ignoredCount++;
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
filteredIssues.push(issue);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const errorCount = filteredIssues.filter(i => i.severity === 'error').length;
|
|
1068
|
+
const warningCount = filteredIssues.filter(i => i.severity === 'warning').length;
|
|
1069
|
+
|
|
1070
|
+
results.push({
|
|
1071
|
+
file,
|
|
1072
|
+
issues: filteredIssues,
|
|
1073
|
+
errorCount,
|
|
1074
|
+
warningCount,
|
|
1075
|
+
ignored: ignoredCount,
|
|
1076
|
+
});
|
|
1077
|
+
|
|
1078
|
+
totalErrors += errorCount;
|
|
1079
|
+
totalWarnings += warningCount;
|
|
1080
|
+
totalIgnored += ignoredCount;
|
|
1081
|
+
|
|
1082
|
+
// Bail on first error if requested
|
|
1083
|
+
if (bail && errorCount > 0) {
|
|
1084
|
+
if (!quiet) {
|
|
1085
|
+
console.log(c('red', `\n\u2716 Bailing after first error (--bail mode)`));
|
|
1086
|
+
}
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
} catch (err) {
|
|
1090
|
+
results.push({
|
|
1091
|
+
file,
|
|
1092
|
+
issues: [{
|
|
1093
|
+
type: 'fatal_error',
|
|
1094
|
+
severity: 'error',
|
|
1095
|
+
reason: `Failed to process file: ${err.message}`,
|
|
1096
|
+
line: 0,
|
|
1097
|
+
column: 0,
|
|
1098
|
+
}],
|
|
1099
|
+
errorCount: 1,
|
|
1100
|
+
warningCount: 0,
|
|
1101
|
+
ignored: 0,
|
|
1102
|
+
});
|
|
1103
|
+
totalErrors++;
|
|
1104
|
+
|
|
1105
|
+
// Bail on fatal error if requested
|
|
1106
|
+
if (bail) {
|
|
1107
|
+
if (!quiet) {
|
|
1108
|
+
console.log(c('red', `\n\u2716 Bailing after first error (--bail mode)`));
|
|
1109
|
+
}
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Format and output
|
|
1116
|
+
const output = formatResults(results, args.format, quiet, args.showIgnored);
|
|
1117
|
+
|
|
1118
|
+
if (args.outputFile) {
|
|
1119
|
+
fs.writeFileSync(args.outputFile, output, 'utf8');
|
|
1120
|
+
if (!quiet) {
|
|
1121
|
+
console.log(c('green', `Report written to ${args.outputFile}`));
|
|
1122
|
+
}
|
|
1123
|
+
} else if (output) {
|
|
1124
|
+
console.log(output);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// Show fix summary
|
|
1128
|
+
if (fix && filesFixed > 0 && !quiet) {
|
|
1129
|
+
console.log('');
|
|
1130
|
+
console.log(c('green', `\u2714 Fixed issues in ${filesFixed} file${filesFixed === 1 ? '' : 's'}`));
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Exit codes
|
|
1134
|
+
if (totalErrors > 0) {
|
|
1135
|
+
process.exit(1);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (maxWarnings >= 0 && totalWarnings > maxWarnings) {
|
|
1139
|
+
if (!quiet) {
|
|
1140
|
+
console.log('');
|
|
1141
|
+
console.log(c('red', `\u2716 Max warnings exceeded: ${totalWarnings} > ${maxWarnings}`));
|
|
1142
|
+
}
|
|
1143
|
+
process.exit(1);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (!quiet && results.length > 0 && totalErrors === 0 && totalWarnings === 0) {
|
|
1147
|
+
let msg = `\u2714 ${files.length} file${files.length === 1 ? '' : 's'} checked`;
|
|
1148
|
+
if (totalIgnored > 0) {
|
|
1149
|
+
msg += ` (${totalIgnored} issue${totalIgnored === 1 ? '' : 's'} ignored)`;
|
|
1150
|
+
} else {
|
|
1151
|
+
msg += ' - no issues found';
|
|
1152
|
+
}
|
|
1153
|
+
console.log(c('green', msg));
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
process.exit(0);
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
main().catch(err => {
|
|
1160
|
+
console.error(c('red', `Fatal error: ${err.message}`));
|
|
1161
|
+
process.exit(2);
|
|
1162
|
+
});
|