zemdomu 1.3.6 → 1.3.8
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/out/cli.js +2 -2
- package/out/component-analyzer.d.ts +0 -1
- package/out/component-analyzer.js +66 -56
- package/out/component-path-resolver.js +9 -6
- package/out/rules/enforceHeadingOrder.d.ts +12 -1
- package/out/rules/enforceHeadingOrder.js +64 -39
- package/out/src/cli.js +2 -2
- package/out/src/component-analyzer.js +66 -56
- package/out/src/component-path-resolver.js +9 -6
- package/out/src/rules/enforceHeadingOrder.js +64 -39
- package/out/tests/crossComponent/cross-heading-order.test.js +8 -3
- package/package.json +14 -15
package/out/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
5
|
};
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
const glob_1 =
|
|
7
|
+
const glob_1 = require("glob");
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const project_linter_1 = require("./project-linter");
|
|
10
10
|
function parsePatterns(inputs) {
|
|
@@ -66,7 +66,7 @@ async function run() {
|
|
|
66
66
|
}
|
|
67
67
|
const files = new Set();
|
|
68
68
|
for (const pattern of patterns) {
|
|
69
|
-
const matches = glob_1.
|
|
69
|
+
const matches = (0, glob_1.globSync)(pattern, { nodir: true });
|
|
70
70
|
for (const m of matches)
|
|
71
71
|
files.add(m);
|
|
72
72
|
}
|
|
@@ -63,7 +63,6 @@ export declare class ComponentAnalyzer {
|
|
|
63
63
|
private getRuleType;
|
|
64
64
|
analyzeComponentTree(): LintResult[];
|
|
65
65
|
private findCrossComponentH1Issues;
|
|
66
|
-
private findReferenceForComp;
|
|
67
66
|
/**
|
|
68
67
|
* Improved implementation to find heading order issues across components
|
|
69
68
|
*/
|
|
@@ -320,59 +320,46 @@ class ComponentAnalyzer {
|
|
|
320
320
|
findCrossComponentH1Issues(results) {
|
|
321
321
|
var _a;
|
|
322
322
|
const entryPoints = this.findEntryPoints();
|
|
323
|
+
const emitted = new Set();
|
|
324
|
+
const getDisplayName = (component) => {
|
|
325
|
+
if (component.name)
|
|
326
|
+
return component.name;
|
|
327
|
+
return path.basename(component.filePath, path.extname(component.filePath));
|
|
328
|
+
};
|
|
323
329
|
for (const entry of entryPoints) {
|
|
324
330
|
const comps = this.findComponentsWithRule(entry, 'singleH1', 0);
|
|
325
|
-
if (comps.length
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
331
|
+
if (comps.length <= 1)
|
|
332
|
+
continue;
|
|
333
|
+
for (const comp of comps) {
|
|
334
|
+
if (!comp)
|
|
335
|
+
continue;
|
|
336
|
+
const compName = getDisplayName(comp);
|
|
337
|
+
const issues = (_a = comp.issues.get('singleH1')) !== null && _a !== void 0 ? _a : [];
|
|
338
|
+
if (!issues.length)
|
|
339
|
+
continue;
|
|
340
|
+
const conflictingNames = comps
|
|
341
|
+
.filter(other => other.filePath !== comp.filePath)
|
|
342
|
+
.map(getDisplayName);
|
|
343
|
+
if (!conflictingNames.length)
|
|
344
|
+
continue;
|
|
345
|
+
const conflicts = conflictingNames.map(name => `'${name}'`).join(', ');
|
|
346
|
+
const context = `This <h1> in '${compName}' conflicts with ${conflicts}.`;
|
|
347
|
+
for (const issue of issues) {
|
|
348
|
+
const key = `${entry.filePath}|${comp.filePath}|${issue.line}|${issue.column}`;
|
|
349
|
+
if (emitted.has(key))
|
|
330
350
|
continue;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
column: location.column,
|
|
340
|
-
message: `Multiple <h1> tags: component '${comp.name}' brings an extra <h1>. Use a lower-level heading.`,
|
|
341
|
-
rule: 'singleH1'
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
else {
|
|
345
|
-
const issue = (_a = comp.issues.get('singleH1')) === null || _a === void 0 ? void 0 : _a[0];
|
|
346
|
-
if (issue) {
|
|
347
|
-
results.push({
|
|
348
|
-
filePath: comp.filePath,
|
|
349
|
-
line: issue.line,
|
|
350
|
-
column: issue.column,
|
|
351
|
-
message: `Multiple <h1> across components - consider using lower-level headings.`,
|
|
352
|
-
rule: 'singleH1'
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
}
|
|
351
|
+
emitted.add(key);
|
|
352
|
+
results.push({
|
|
353
|
+
filePath: comp.filePath,
|
|
354
|
+
line: issue.line,
|
|
355
|
+
column: issue.column,
|
|
356
|
+
message: `Multiple <h1> tags across components. ${context}`,
|
|
357
|
+
rule: 'singleH1'
|
|
358
|
+
});
|
|
356
359
|
}
|
|
357
360
|
}
|
|
358
361
|
}
|
|
359
362
|
}
|
|
360
|
-
findReferenceForComp(root, targetPath, depth = 0) {
|
|
361
|
-
if (this.maxDepth !== undefined && depth > this.maxDepth)
|
|
362
|
-
return null;
|
|
363
|
-
for (const ref of root.usesComponents) {
|
|
364
|
-
if (ref.path === targetPath)
|
|
365
|
-
return ref;
|
|
366
|
-
}
|
|
367
|
-
for (const ref of root.usesComponents) {
|
|
368
|
-
if (ref.path && this.componentRegistry.has(ref.path)) {
|
|
369
|
-
const nested = this.findReferenceForComp(this.componentRegistry.get(ref.path), targetPath, depth + 1);
|
|
370
|
-
if (nested)
|
|
371
|
-
return ref;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
return null;
|
|
375
|
-
}
|
|
376
363
|
/**
|
|
377
364
|
* Improved implementation to find heading order issues across components
|
|
378
365
|
*/
|
|
@@ -389,7 +376,7 @@ class ComponentAnalyzer {
|
|
|
389
376
|
* and checks for heading level issues
|
|
390
377
|
*/
|
|
391
378
|
analyzeHeadingHierarchy(component, results, depth = 0) {
|
|
392
|
-
var _a, _b, _c, _d
|
|
379
|
+
var _a, _b, _c, _d;
|
|
393
380
|
if (this.maxDepth !== undefined && depth > this.maxDepth)
|
|
394
381
|
return;
|
|
395
382
|
if (this.processingComponentStack.has(component.filePath)) {
|
|
@@ -404,21 +391,44 @@ class ComponentAnalyzer {
|
|
|
404
391
|
for (const heading of allHeadings) {
|
|
405
392
|
if (lastLevel > 0) {
|
|
406
393
|
if (heading.heading.level > lastLevel + 1) {
|
|
407
|
-
|
|
394
|
+
const locationFile = heading.heading.filePath;
|
|
395
|
+
const locationLine = heading.heading.line;
|
|
396
|
+
const locationColumn = heading.heading.column;
|
|
397
|
+
const usageComponent = ((_a = heading.usageLocation) === null || _a === void 0 ? void 0 : _a.filePath)
|
|
398
|
+
? this.componentRegistry.get(heading.usageLocation.filePath)
|
|
399
|
+
: null;
|
|
400
|
+
const usageName = usageComponent
|
|
401
|
+
? path.basename(usageComponent.filePath, path.extname(usageComponent.filePath))
|
|
402
|
+
: ((_b = heading.usageLocation) === null || _b === void 0 ? void 0 : _b.filePath)
|
|
403
|
+
? path.basename(heading.usageLocation.filePath, path.extname(heading.usageLocation.filePath))
|
|
404
|
+
: null;
|
|
405
|
+
const messageSuffix = usageName ? ` (rendered via '${usageName}')` : '';
|
|
408
406
|
results.push({
|
|
409
|
-
filePath:
|
|
410
|
-
line:
|
|
411
|
-
column:
|
|
412
|
-
message: `Cross-component heading level skipped: <h${heading.heading.level}> after <h${lastLevel}
|
|
407
|
+
filePath: locationFile,
|
|
408
|
+
line: locationLine,
|
|
409
|
+
column: locationColumn,
|
|
410
|
+
message: `Cross-component heading level skipped: <h${heading.heading.level}> after <h${lastLevel}>${messageSuffix}`,
|
|
413
411
|
rule: 'enforceHeadingOrder'
|
|
414
412
|
});
|
|
415
413
|
}
|
|
416
414
|
else if (heading.heading.level === 1 && lastLevel !== 1) {
|
|
415
|
+
const locationFile = heading.heading.filePath;
|
|
416
|
+
const locationLine = heading.heading.line;
|
|
417
|
+
const locationColumn = heading.heading.column;
|
|
418
|
+
const usageComponent = ((_c = heading.usageLocation) === null || _c === void 0 ? void 0 : _c.filePath)
|
|
419
|
+
? this.componentRegistry.get(heading.usageLocation.filePath)
|
|
420
|
+
: null;
|
|
421
|
+
const usageName = usageComponent
|
|
422
|
+
? path.basename(usageComponent.filePath, path.extname(usageComponent.filePath))
|
|
423
|
+
: ((_d = heading.usageLocation) === null || _d === void 0 ? void 0 : _d.filePath)
|
|
424
|
+
? path.basename(heading.usageLocation.filePath, path.extname(heading.usageLocation.filePath))
|
|
425
|
+
: null;
|
|
426
|
+
const messageSuffix = usageName ? ` (rendered via '${usageName}')` : '';
|
|
417
427
|
results.push({
|
|
418
|
-
filePath:
|
|
419
|
-
line:
|
|
420
|
-
column:
|
|
421
|
-
message: `Cross-component heading level skipped: <h${heading.heading.level}> after <h${lastLevel}
|
|
428
|
+
filePath: locationFile,
|
|
429
|
+
line: locationLine,
|
|
430
|
+
column: locationColumn,
|
|
431
|
+
message: `Cross-component heading level skipped: <h${heading.heading.level}> after <h${lastLevel}>${messageSuffix}`,
|
|
422
432
|
rule: 'enforceHeadingOrder'
|
|
423
433
|
});
|
|
424
434
|
}
|
|
@@ -36,8 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.ComponentPathResolver = void 0;
|
|
37
37
|
const fs = __importStar(require("fs/promises"));
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
|
-
|
|
40
|
-
const glob = require('glob');
|
|
39
|
+
const glob_1 = require("glob");
|
|
41
40
|
let vscodeApi;
|
|
42
41
|
try {
|
|
43
42
|
vscodeApi = require('vscode');
|
|
@@ -182,8 +181,10 @@ class ComponentPathResolver {
|
|
|
182
181
|
let alias = ComponentPathResolver.aliasCache.get(prefix);
|
|
183
182
|
if (!alias) {
|
|
184
183
|
const pattern = `**/${prefix}/**/*.{tsx,jsx,ts,js}`;
|
|
185
|
-
const files = await
|
|
186
|
-
|
|
184
|
+
const files = await (0, glob_1.glob)(pattern, {
|
|
185
|
+
cwd: ComponentPathResolver.rootDir,
|
|
186
|
+
ignore: '**/node_modules/**',
|
|
187
|
+
nodir: true,
|
|
187
188
|
});
|
|
188
189
|
alias = new Map();
|
|
189
190
|
for (const relPath of files.slice(0, ComponentPathResolver.aliasFileLimit)) {
|
|
@@ -218,8 +219,10 @@ class ComponentPathResolver {
|
|
|
218
219
|
}
|
|
219
220
|
continue;
|
|
220
221
|
}
|
|
221
|
-
const matches = await
|
|
222
|
-
|
|
222
|
+
const matches = await (0, glob_1.glob)(ptn, {
|
|
223
|
+
cwd: ComponentPathResolver.rootDir,
|
|
224
|
+
ignore: '**/node_modules/**',
|
|
225
|
+
nodir: true,
|
|
223
226
|
});
|
|
224
227
|
if (matches.length) {
|
|
225
228
|
result = path.resolve(ComponentPathResolver.rootDir, matches[0]);
|
|
@@ -1,2 +1,13 @@
|
|
|
1
|
-
import { Rule } from
|
|
1
|
+
import { Rule } from "../linter";
|
|
2
|
+
/**
|
|
3
|
+
* Enforce heading order with symmetric skip detection.
|
|
4
|
+
*
|
|
5
|
+
* Flags:
|
|
6
|
+
* - Upward skip: h2 -> h4 (new > last + 1)
|
|
7
|
+
* - Downward skip: h6 -> h4 (last > new + 1)
|
|
8
|
+
* - Reset to h1: any h1 after a non-h1 (e.g. h6 -> h1)
|
|
9
|
+
*
|
|
10
|
+
* First heading in a file never warns.
|
|
11
|
+
* Does not auto-reset on <section>/<article> yet; add if you want outline semantics.
|
|
12
|
+
*/
|
|
2
13
|
export default function enforceHeadingOrder(): Rule;
|
|
@@ -2,57 +2,82 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.default = enforceHeadingOrder;
|
|
4
4
|
const utils_1 = require("./utils");
|
|
5
|
+
/**
|
|
6
|
+
* Enforce heading order with symmetric skip detection.
|
|
7
|
+
*
|
|
8
|
+
* Flags:
|
|
9
|
+
* - Upward skip: h2 -> h4 (new > last + 1)
|
|
10
|
+
* - Downward skip: h6 -> h4 (last > new + 1)
|
|
11
|
+
* - Reset to h1: any h1 after a non-h1 (e.g. h6 -> h1)
|
|
12
|
+
*
|
|
13
|
+
* First heading in a file never warns.
|
|
14
|
+
* Does not auto-reset on <section>/<article> yet; add if you want outline semantics.
|
|
15
|
+
*/
|
|
5
16
|
function enforceHeadingOrder() {
|
|
6
|
-
let last = 0;
|
|
7
|
-
let seen = false;
|
|
17
|
+
let last = 0; // 0 means “no heading seen yet”
|
|
8
18
|
return {
|
|
9
|
-
name:
|
|
19
|
+
name: "enforceHeadingOrder",
|
|
10
20
|
init() {
|
|
11
21
|
last = 0;
|
|
12
|
-
seen = false;
|
|
13
22
|
},
|
|
14
23
|
enterHtml(node) {
|
|
15
|
-
if (node.type ===
|
|
16
|
-
const lvl = parseInt(node.tagName.charAt(1), 10);
|
|
17
|
-
let message = null;
|
|
18
|
-
if (last && lvl > last + 1) {
|
|
19
|
-
message = `Heading level skipped: <${node.tagName}> after <h${last}>`;
|
|
20
|
-
}
|
|
21
|
-
else if (seen && lvl === 1 && last !== 1) {
|
|
22
|
-
message = `Heading level skipped: <${node.tagName}> after <h${last}>`;
|
|
23
|
-
}
|
|
24
|
-
last = lvl;
|
|
25
|
-
seen = true;
|
|
26
|
-
if (message) {
|
|
27
|
-
last = lvl;
|
|
28
|
-
return [{ line: 0, column: 0, message, rule: 'enforceHeadingOrder' }];
|
|
29
|
-
}
|
|
24
|
+
if (!(node.type === "element" && /^h[1-6]$/.test(node.tagName)))
|
|
30
25
|
return [];
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
const lvl = parseInt(node.tagName[1], 10);
|
|
27
|
+
const msg = computeMessage(lvl, last, node.tagName);
|
|
28
|
+
last = lvl;
|
|
29
|
+
if (!msg)
|
|
30
|
+
return [];
|
|
31
|
+
// simpleHtmlParser nodes don’t carry source positions here; anchor at (0,0)
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
line: 0,
|
|
35
|
+
column: 0,
|
|
36
|
+
message: msg,
|
|
37
|
+
rule: "enforceHeadingOrder",
|
|
38
|
+
},
|
|
39
|
+
];
|
|
33
40
|
},
|
|
34
41
|
enterJsx(path) {
|
|
35
42
|
var _a, _b, _c, _d;
|
|
36
43
|
const tag = (0, utils_1.getTag)(path);
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
else if (seen && lvl === 1 && last !== 1) {
|
|
44
|
-
message = `Heading level skipped: <${tag}> after <h${last}>`;
|
|
45
|
-
}
|
|
46
|
-
last = lvl;
|
|
47
|
-
seen = true;
|
|
48
|
-
if (message) {
|
|
49
|
-
const line = ((_b = (_a = path.node.loc) === null || _a === void 0 ? void 0 : _a.start.line) !== null && _b !== void 0 ? _b : 1) - 1;
|
|
50
|
-
const column = (_d = (_c = path.node.loc) === null || _c === void 0 ? void 0 : _c.start.column) !== null && _d !== void 0 ? _d : 0;
|
|
51
|
-
return [{ line, column, message, rule: 'enforceHeadingOrder' }];
|
|
52
|
-
}
|
|
44
|
+
if (!/^h[1-6]$/.test(tag))
|
|
45
|
+
return [];
|
|
46
|
+
const lvl = parseInt(tag[1], 10);
|
|
47
|
+
const msg = computeMessage(lvl, last, tag);
|
|
48
|
+
last = lvl;
|
|
49
|
+
if (!msg)
|
|
53
50
|
return [];
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
const line = ((_b = (_a = path.node.loc) === null || _a === void 0 ? void 0 : _a.start.line) !== null && _b !== void 0 ? _b : 1) - 1; // VS Code is 0-based
|
|
52
|
+
const column = (_d = (_c = path.node.loc) === null || _c === void 0 ? void 0 : _c.start.column) !== null && _d !== void 0 ? _d : 0;
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
line,
|
|
56
|
+
column,
|
|
57
|
+
message: msg,
|
|
58
|
+
rule: "enforceHeadingOrder",
|
|
59
|
+
},
|
|
60
|
+
];
|
|
56
61
|
},
|
|
57
62
|
};
|
|
58
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Return a human-readable message if the new level violates order, else null.
|
|
66
|
+
*/
|
|
67
|
+
function computeMessage(newLvl, lastLvl, newTag) {
|
|
68
|
+
if (lastLvl === 0)
|
|
69
|
+
return null; // first heading seen
|
|
70
|
+
// Any h1 after a non-h1 is considered a reset-skip
|
|
71
|
+
if (newLvl === 1 && lastLvl !== 1) {
|
|
72
|
+
return `Heading level skipped: <${newTag}> after <h${lastLvl}>`;
|
|
73
|
+
}
|
|
74
|
+
// Upward skip (e.g. h2 -> h4)
|
|
75
|
+
if (newLvl > lastLvl + 1) {
|
|
76
|
+
return `Heading level skipped: <${newTag}> after <h${lastLvl}>`;
|
|
77
|
+
}
|
|
78
|
+
// Downward skip (e.g. h6 -> h4)
|
|
79
|
+
if (lastLvl > newLvl + 1) {
|
|
80
|
+
return `Heading level skipped: <${newTag}> after <h${lastLvl}>`;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
package/out/src/cli.js
CHANGED
|
@@ -4,7 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
5
|
};
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
const glob_1 =
|
|
7
|
+
const glob_1 = require("glob");
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const project_linter_1 = require("./project-linter");
|
|
10
10
|
function parsePatterns(inputs) {
|
|
@@ -66,7 +66,7 @@ async function run() {
|
|
|
66
66
|
}
|
|
67
67
|
const files = new Set();
|
|
68
68
|
for (const pattern of patterns) {
|
|
69
|
-
const matches = glob_1.
|
|
69
|
+
const matches = (0, glob_1.globSync)(pattern, { nodir: true });
|
|
70
70
|
for (const m of matches)
|
|
71
71
|
files.add(m);
|
|
72
72
|
}
|
|
@@ -320,59 +320,46 @@ class ComponentAnalyzer {
|
|
|
320
320
|
findCrossComponentH1Issues(results) {
|
|
321
321
|
var _a;
|
|
322
322
|
const entryPoints = this.findEntryPoints();
|
|
323
|
+
const emitted = new Set();
|
|
324
|
+
const getDisplayName = (component) => {
|
|
325
|
+
if (component.name)
|
|
326
|
+
return component.name;
|
|
327
|
+
return path.basename(component.filePath, path.extname(component.filePath));
|
|
328
|
+
};
|
|
323
329
|
for (const entry of entryPoints) {
|
|
324
330
|
const comps = this.findComponentsWithRule(entry, 'singleH1', 0);
|
|
325
|
-
if (comps.length
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
331
|
+
if (comps.length <= 1)
|
|
332
|
+
continue;
|
|
333
|
+
for (const comp of comps) {
|
|
334
|
+
if (!comp)
|
|
335
|
+
continue;
|
|
336
|
+
const compName = getDisplayName(comp);
|
|
337
|
+
const issues = (_a = comp.issues.get('singleH1')) !== null && _a !== void 0 ? _a : [];
|
|
338
|
+
if (!issues.length)
|
|
339
|
+
continue;
|
|
340
|
+
const conflictingNames = comps
|
|
341
|
+
.filter(other => other.filePath !== comp.filePath)
|
|
342
|
+
.map(getDisplayName);
|
|
343
|
+
if (!conflictingNames.length)
|
|
344
|
+
continue;
|
|
345
|
+
const conflicts = conflictingNames.map(name => `'${name}'`).join(', ');
|
|
346
|
+
const context = `This <h1> in '${compName}' conflicts with ${conflicts}.`;
|
|
347
|
+
for (const issue of issues) {
|
|
348
|
+
const key = `${entry.filePath}|${comp.filePath}|${issue.line}|${issue.column}`;
|
|
349
|
+
if (emitted.has(key))
|
|
330
350
|
continue;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
column: location.column,
|
|
340
|
-
message: `Multiple <h1> tags: component '${comp.name}' brings an extra <h1>. Use a lower-level heading.`,
|
|
341
|
-
rule: 'singleH1'
|
|
342
|
-
});
|
|
343
|
-
}
|
|
344
|
-
else {
|
|
345
|
-
const issue = (_a = comp.issues.get('singleH1')) === null || _a === void 0 ? void 0 : _a[0];
|
|
346
|
-
if (issue) {
|
|
347
|
-
results.push({
|
|
348
|
-
filePath: comp.filePath,
|
|
349
|
-
line: issue.line,
|
|
350
|
-
column: issue.column,
|
|
351
|
-
message: `Multiple <h1> across components - consider using lower-level headings.`,
|
|
352
|
-
rule: 'singleH1'
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
}
|
|
351
|
+
emitted.add(key);
|
|
352
|
+
results.push({
|
|
353
|
+
filePath: comp.filePath,
|
|
354
|
+
line: issue.line,
|
|
355
|
+
column: issue.column,
|
|
356
|
+
message: `Multiple <h1> tags across components. ${context}`,
|
|
357
|
+
rule: 'singleH1'
|
|
358
|
+
});
|
|
356
359
|
}
|
|
357
360
|
}
|
|
358
361
|
}
|
|
359
362
|
}
|
|
360
|
-
findReferenceForComp(root, targetPath, depth = 0) {
|
|
361
|
-
if (this.maxDepth !== undefined && depth > this.maxDepth)
|
|
362
|
-
return null;
|
|
363
|
-
for (const ref of root.usesComponents) {
|
|
364
|
-
if (ref.path === targetPath)
|
|
365
|
-
return ref;
|
|
366
|
-
}
|
|
367
|
-
for (const ref of root.usesComponents) {
|
|
368
|
-
if (ref.path && this.componentRegistry.has(ref.path)) {
|
|
369
|
-
const nested = this.findReferenceForComp(this.componentRegistry.get(ref.path), targetPath, depth + 1);
|
|
370
|
-
if (nested)
|
|
371
|
-
return ref;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
return null;
|
|
375
|
-
}
|
|
376
363
|
/**
|
|
377
364
|
* Improved implementation to find heading order issues across components
|
|
378
365
|
*/
|
|
@@ -389,7 +376,7 @@ class ComponentAnalyzer {
|
|
|
389
376
|
* and checks for heading level issues
|
|
390
377
|
*/
|
|
391
378
|
analyzeHeadingHierarchy(component, results, depth = 0) {
|
|
392
|
-
var _a, _b, _c, _d
|
|
379
|
+
var _a, _b, _c, _d;
|
|
393
380
|
if (this.maxDepth !== undefined && depth > this.maxDepth)
|
|
394
381
|
return;
|
|
395
382
|
if (this.processingComponentStack.has(component.filePath)) {
|
|
@@ -404,21 +391,44 @@ class ComponentAnalyzer {
|
|
|
404
391
|
for (const heading of allHeadings) {
|
|
405
392
|
if (lastLevel > 0) {
|
|
406
393
|
if (heading.heading.level > lastLevel + 1) {
|
|
407
|
-
|
|
394
|
+
const locationFile = heading.heading.filePath;
|
|
395
|
+
const locationLine = heading.heading.line;
|
|
396
|
+
const locationColumn = heading.heading.column;
|
|
397
|
+
const usageComponent = ((_a = heading.usageLocation) === null || _a === void 0 ? void 0 : _a.filePath)
|
|
398
|
+
? this.componentRegistry.get(heading.usageLocation.filePath)
|
|
399
|
+
: null;
|
|
400
|
+
const usageName = usageComponent
|
|
401
|
+
? path.basename(usageComponent.filePath, path.extname(usageComponent.filePath))
|
|
402
|
+
: ((_b = heading.usageLocation) === null || _b === void 0 ? void 0 : _b.filePath)
|
|
403
|
+
? path.basename(heading.usageLocation.filePath, path.extname(heading.usageLocation.filePath))
|
|
404
|
+
: null;
|
|
405
|
+
const messageSuffix = usageName ? ` (rendered via '${usageName}')` : '';
|
|
408
406
|
results.push({
|
|
409
|
-
filePath:
|
|
410
|
-
line:
|
|
411
|
-
column:
|
|
412
|
-
message: `Cross-component heading level skipped: <h${heading.heading.level}> after <h${lastLevel}
|
|
407
|
+
filePath: locationFile,
|
|
408
|
+
line: locationLine,
|
|
409
|
+
column: locationColumn,
|
|
410
|
+
message: `Cross-component heading level skipped: <h${heading.heading.level}> after <h${lastLevel}>${messageSuffix}`,
|
|
413
411
|
rule: 'enforceHeadingOrder'
|
|
414
412
|
});
|
|
415
413
|
}
|
|
416
414
|
else if (heading.heading.level === 1 && lastLevel !== 1) {
|
|
415
|
+
const locationFile = heading.heading.filePath;
|
|
416
|
+
const locationLine = heading.heading.line;
|
|
417
|
+
const locationColumn = heading.heading.column;
|
|
418
|
+
const usageComponent = ((_c = heading.usageLocation) === null || _c === void 0 ? void 0 : _c.filePath)
|
|
419
|
+
? this.componentRegistry.get(heading.usageLocation.filePath)
|
|
420
|
+
: null;
|
|
421
|
+
const usageName = usageComponent
|
|
422
|
+
? path.basename(usageComponent.filePath, path.extname(usageComponent.filePath))
|
|
423
|
+
: ((_d = heading.usageLocation) === null || _d === void 0 ? void 0 : _d.filePath)
|
|
424
|
+
? path.basename(heading.usageLocation.filePath, path.extname(heading.usageLocation.filePath))
|
|
425
|
+
: null;
|
|
426
|
+
const messageSuffix = usageName ? ` (rendered via '${usageName}')` : '';
|
|
417
427
|
results.push({
|
|
418
|
-
filePath:
|
|
419
|
-
line:
|
|
420
|
-
column:
|
|
421
|
-
message: `Cross-component heading level skipped: <h${heading.heading.level}> after <h${lastLevel}
|
|
428
|
+
filePath: locationFile,
|
|
429
|
+
line: locationLine,
|
|
430
|
+
column: locationColumn,
|
|
431
|
+
message: `Cross-component heading level skipped: <h${heading.heading.level}> after <h${lastLevel}>${messageSuffix}`,
|
|
422
432
|
rule: 'enforceHeadingOrder'
|
|
423
433
|
});
|
|
424
434
|
}
|
|
@@ -36,8 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.ComponentPathResolver = void 0;
|
|
37
37
|
const fs = __importStar(require("fs/promises"));
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
|
-
|
|
40
|
-
const glob = require('glob');
|
|
39
|
+
const glob_1 = require("glob");
|
|
41
40
|
let vscodeApi;
|
|
42
41
|
try {
|
|
43
42
|
vscodeApi = require('vscode');
|
|
@@ -182,8 +181,10 @@ class ComponentPathResolver {
|
|
|
182
181
|
let alias = ComponentPathResolver.aliasCache.get(prefix);
|
|
183
182
|
if (!alias) {
|
|
184
183
|
const pattern = `**/${prefix}/**/*.{tsx,jsx,ts,js}`;
|
|
185
|
-
const files = await
|
|
186
|
-
|
|
184
|
+
const files = await (0, glob_1.glob)(pattern, {
|
|
185
|
+
cwd: ComponentPathResolver.rootDir,
|
|
186
|
+
ignore: '**/node_modules/**',
|
|
187
|
+
nodir: true,
|
|
187
188
|
});
|
|
188
189
|
alias = new Map();
|
|
189
190
|
for (const relPath of files.slice(0, ComponentPathResolver.aliasFileLimit)) {
|
|
@@ -218,8 +219,10 @@ class ComponentPathResolver {
|
|
|
218
219
|
}
|
|
219
220
|
continue;
|
|
220
221
|
}
|
|
221
|
-
const matches = await
|
|
222
|
-
|
|
222
|
+
const matches = await (0, glob_1.glob)(ptn, {
|
|
223
|
+
cwd: ComponentPathResolver.rootDir,
|
|
224
|
+
ignore: '**/node_modules/**',
|
|
225
|
+
nodir: true,
|
|
223
226
|
});
|
|
224
227
|
if (matches.length) {
|
|
225
228
|
result = path.resolve(ComponentPathResolver.rootDir, matches[0]);
|
|
@@ -2,57 +2,82 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.default = enforceHeadingOrder;
|
|
4
4
|
const utils_1 = require("./utils");
|
|
5
|
+
/**
|
|
6
|
+
* Enforce heading order with symmetric skip detection.
|
|
7
|
+
*
|
|
8
|
+
* Flags:
|
|
9
|
+
* - Upward skip: h2 -> h4 (new > last + 1)
|
|
10
|
+
* - Downward skip: h6 -> h4 (last > new + 1)
|
|
11
|
+
* - Reset to h1: any h1 after a non-h1 (e.g. h6 -> h1)
|
|
12
|
+
*
|
|
13
|
+
* First heading in a file never warns.
|
|
14
|
+
* Does not auto-reset on <section>/<article> yet; add if you want outline semantics.
|
|
15
|
+
*/
|
|
5
16
|
function enforceHeadingOrder() {
|
|
6
|
-
let last = 0;
|
|
7
|
-
let seen = false;
|
|
17
|
+
let last = 0; // 0 means “no heading seen yet”
|
|
8
18
|
return {
|
|
9
|
-
name:
|
|
19
|
+
name: "enforceHeadingOrder",
|
|
10
20
|
init() {
|
|
11
21
|
last = 0;
|
|
12
|
-
seen = false;
|
|
13
22
|
},
|
|
14
23
|
enterHtml(node) {
|
|
15
|
-
if (node.type ===
|
|
16
|
-
const lvl = parseInt(node.tagName.charAt(1), 10);
|
|
17
|
-
let message = null;
|
|
18
|
-
if (last && lvl > last + 1) {
|
|
19
|
-
message = `Heading level skipped: <${node.tagName}> after <h${last}>`;
|
|
20
|
-
}
|
|
21
|
-
else if (seen && lvl === 1 && last !== 1) {
|
|
22
|
-
message = `Heading level skipped: <${node.tagName}> after <h${last}>`;
|
|
23
|
-
}
|
|
24
|
-
last = lvl;
|
|
25
|
-
seen = true;
|
|
26
|
-
if (message) {
|
|
27
|
-
last = lvl;
|
|
28
|
-
return [{ line: 0, column: 0, message, rule: 'enforceHeadingOrder' }];
|
|
29
|
-
}
|
|
24
|
+
if (!(node.type === "element" && /^h[1-6]$/.test(node.tagName)))
|
|
30
25
|
return [];
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
const lvl = parseInt(node.tagName[1], 10);
|
|
27
|
+
const msg = computeMessage(lvl, last, node.tagName);
|
|
28
|
+
last = lvl;
|
|
29
|
+
if (!msg)
|
|
30
|
+
return [];
|
|
31
|
+
// simpleHtmlParser nodes don’t carry source positions here; anchor at (0,0)
|
|
32
|
+
return [
|
|
33
|
+
{
|
|
34
|
+
line: 0,
|
|
35
|
+
column: 0,
|
|
36
|
+
message: msg,
|
|
37
|
+
rule: "enforceHeadingOrder",
|
|
38
|
+
},
|
|
39
|
+
];
|
|
33
40
|
},
|
|
34
41
|
enterJsx(path) {
|
|
35
42
|
var _a, _b, _c, _d;
|
|
36
43
|
const tag = (0, utils_1.getTag)(path);
|
|
37
|
-
if (
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
else if (seen && lvl === 1 && last !== 1) {
|
|
44
|
-
message = `Heading level skipped: <${tag}> after <h${last}>`;
|
|
45
|
-
}
|
|
46
|
-
last = lvl;
|
|
47
|
-
seen = true;
|
|
48
|
-
if (message) {
|
|
49
|
-
const line = ((_b = (_a = path.node.loc) === null || _a === void 0 ? void 0 : _a.start.line) !== null && _b !== void 0 ? _b : 1) - 1;
|
|
50
|
-
const column = (_d = (_c = path.node.loc) === null || _c === void 0 ? void 0 : _c.start.column) !== null && _d !== void 0 ? _d : 0;
|
|
51
|
-
return [{ line, column, message, rule: 'enforceHeadingOrder' }];
|
|
52
|
-
}
|
|
44
|
+
if (!/^h[1-6]$/.test(tag))
|
|
45
|
+
return [];
|
|
46
|
+
const lvl = parseInt(tag[1], 10);
|
|
47
|
+
const msg = computeMessage(lvl, last, tag);
|
|
48
|
+
last = lvl;
|
|
49
|
+
if (!msg)
|
|
53
50
|
return [];
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
const line = ((_b = (_a = path.node.loc) === null || _a === void 0 ? void 0 : _a.start.line) !== null && _b !== void 0 ? _b : 1) - 1; // VS Code is 0-based
|
|
52
|
+
const column = (_d = (_c = path.node.loc) === null || _c === void 0 ? void 0 : _c.start.column) !== null && _d !== void 0 ? _d : 0;
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
line,
|
|
56
|
+
column,
|
|
57
|
+
message: msg,
|
|
58
|
+
rule: "enforceHeadingOrder",
|
|
59
|
+
},
|
|
60
|
+
];
|
|
56
61
|
},
|
|
57
62
|
};
|
|
58
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Return a human-readable message if the new level violates order, else null.
|
|
66
|
+
*/
|
|
67
|
+
function computeMessage(newLvl, lastLvl, newTag) {
|
|
68
|
+
if (lastLvl === 0)
|
|
69
|
+
return null; // first heading seen
|
|
70
|
+
// Any h1 after a non-h1 is considered a reset-skip
|
|
71
|
+
if (newLvl === 1 && lastLvl !== 1) {
|
|
72
|
+
return `Heading level skipped: <${newTag}> after <h${lastLvl}>`;
|
|
73
|
+
}
|
|
74
|
+
// Upward skip (e.g. h2 -> h4)
|
|
75
|
+
if (newLvl > lastLvl + 1) {
|
|
76
|
+
return `Heading level skipped: <${newTag}> after <h${lastLvl}>`;
|
|
77
|
+
}
|
|
78
|
+
// Downward skip (e.g. h6 -> h4)
|
|
79
|
+
if (lastLvl > newLvl + 1) {
|
|
80
|
+
return `Heading level skipped: <${newTag}> after <h${lastLvl}>`;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
@@ -41,9 +41,14 @@ describe("cross component heading order", () => {
|
|
|
41
41
|
assert_1.default.ok(((_a = byRule["singleH1"]) !== null && _a !== void 0 ? _a : 0) >= 1, "Expected at least one singleH1");
|
|
42
42
|
assert_1.default.ok(((_b = byRule["enforceHeadingOrder"]) !== null && _b !== void 0 ? _b : 0) >= 1, // set to >=2 if you expect more
|
|
43
43
|
"Expected at least one enforceHeadingOrder");
|
|
44
|
-
const
|
|
45
|
-
.filter((r) => r.rule === "
|
|
44
|
+
const singleH1Files = new Set(results
|
|
45
|
+
.filter((r) => r.rule === "singleH1" && r.filePath)
|
|
46
|
+
.map((r) => path_1.default.basename(r.filePath)));
|
|
47
|
+
assert_1.default.ok(singleH1Files.has("Page.tsx"), "Expected cross-component singleH1 to surface on Page.tsx");
|
|
48
|
+
assert_1.default.ok(singleH1Files.has("Button.tsx"), "Expected cross-component singleH1 to surface on Button.tsx");
|
|
49
|
+
const headingLocations = results
|
|
50
|
+
.filter((r) => r.rule === "enforceHeadingOrder" && r.filePath)
|
|
46
51
|
.map((r) => path_1.default.basename(r.filePath));
|
|
47
|
-
assert_1.default.ok(
|
|
52
|
+
assert_1.default.ok(headingLocations.includes("Button.tsx"), "Expected heading order issue to highlight the component containing the offending heading");
|
|
48
53
|
});
|
|
49
54
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zemdomu",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.8",
|
|
4
4
|
"description": "Semantic HTML linter for HTML, JSX, and TSX. Detects accessibility, SEO, and structure issues before deployment.",
|
|
5
5
|
"main": "./out/index.js",
|
|
6
6
|
"types": "./out/index.d.ts",
|
|
@@ -42,25 +42,24 @@
|
|
|
42
42
|
"author": "Zacharias Eryd Berlin",
|
|
43
43
|
"license": "ISC",
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@types/babel__traverse": "^7.
|
|
45
|
+
"@types/babel__traverse": "^7.28.0",
|
|
46
46
|
"@types/babel-traverse": "^6.25.10",
|
|
47
47
|
"@types/babel-types": "^7.0.16",
|
|
48
|
-
"@types/glob": "^8.1.0",
|
|
49
48
|
"@types/mocha": "^10.0.10",
|
|
50
|
-
"@types/node": "^
|
|
51
|
-
"@types/react": "^19.
|
|
52
|
-
"@types/react-dom": "^19.
|
|
53
|
-
"esbuild": "^0.25.
|
|
54
|
-
"mocha": "^
|
|
55
|
-
"typescript": "^5.
|
|
49
|
+
"@types/node": "^24.9.1",
|
|
50
|
+
"@types/react": "^19.2.2",
|
|
51
|
+
"@types/react-dom": "^19.2.2",
|
|
52
|
+
"esbuild": "^0.25.11",
|
|
53
|
+
"mocha": "^11.7.4",
|
|
54
|
+
"typescript": "^5.9.3"
|
|
56
55
|
},
|
|
57
56
|
"dependencies": {
|
|
58
|
-
"@babel/parser": "^7.
|
|
59
|
-
"@babel/traverse": "^7.
|
|
60
|
-
"@babel/types": "^7.
|
|
61
|
-
"glob": "^
|
|
62
|
-
"react": "^19.
|
|
63
|
-
"react-dom": "^19.
|
|
57
|
+
"@babel/parser": "^7.28.4",
|
|
58
|
+
"@babel/traverse": "^7.28.4",
|
|
59
|
+
"@babel/types": "^7.28.4",
|
|
60
|
+
"glob": "^11.0.3",
|
|
61
|
+
"react": "^19.2.0",
|
|
62
|
+
"react-dom": "^19.2.0"
|
|
64
63
|
},
|
|
65
64
|
"jest": {
|
|
66
65
|
"transform": {
|