@emasoft/svg-matrix 1.0.19 → 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.
@@ -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, '&amp;')
897
+ .replace(/</g, '&lt;')
898
+ .replace(/>/g, '&gt;')
899
+ .replace(/"/g, '&quot;')
900
+ .replace(/'/g, '&apos;');
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
+ });