Skip to content

Commit 096a1d1

Browse files
committed
feat: suggest correct paths based on levenshtein distance
1 parent 8c76c2d commit 096a1d1

File tree

5 files changed

+159
-6
lines changed

5 files changed

+159
-6
lines changed

.changeset/gentle-crabs-provide.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'check-html-links': minor
3+
---
4+
5+
suggest correct paths based on levenshtein distance

packages/check-html-links/src/CheckHtmlLinksCli.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ export class CheckHtmlLinksCli {
7575
)} missing reference targets (used by ${referenceCount} links) while checking ${
7676
files.length
7777
} files:`,
78-
...formatErrors(errors)
78+
...formatErrors(errors, { files })
7979
.split('\n')
8080
.map(line => ` ${line}`),
8181
`Checking links duration: ${performance[0]}s ${performance[1] / 1000000}ms`,

packages/check-html-links/src/formatErrors.js

+25-2
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import path from 'path';
22
import chalk from 'chalk';
3+
import levenshtein from './levenshtein.js';
34

45
/** @typedef {import('../types/main').Error} Error */
56

67
/**
78
* @param {Error[]} errors
8-
* @param {*} relativeFrom
9+
* @param {{ relativeFrom?: string; files: string[] }} opts
910
*/
10-
export function formatErrors(errors, relativeFrom = process.cwd()) {
11+
// @ts-expect-error we need empty obj to destructure from
12+
export function formatErrors(errors, { relativeFrom = process.cwd(), files } = {}) {
1113
let output = [];
1214
let number = 0;
1315
for (const error of errors) {
@@ -41,6 +43,27 @@ export function formatErrors(errors, relativeFrom = process.cwd()) {
4143
output.push(` ... ${more} more references to this target`);
4244
}
4345
output.push('');
46+
47+
/**
48+
* Also consider finding the updated path. This can be useful when documentation is restructured
49+
* For instance, the folder name was changed, but the file name was not.
50+
*/
51+
let suggestion;
52+
let lowestScore = -1;
53+
files.forEach(file => {
54+
const filePathToCompare = file.replace(relativeFrom + '/', '');
55+
const score = levenshtein(filePathToCompare, filePath);
56+
if (score && (lowestScore === -1 || score < lowestScore)) {
57+
lowestScore = score;
58+
suggestion = filePathToCompare;
59+
}
60+
});
61+
62+
if (suggestion) {
63+
output.push(
64+
chalk.italic(`Suggestion: did you mean ${chalk.magenta(suggestion)} instead?\n\n`),
65+
);
66+
}
4467
}
4568
return output.join('\n');
4669
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
/* eslint-disable */
2+
// https://github.com/gustf/js-levenshtein/blob/master/index.js
3+
4+
/**
5+
* @param {number} d0
6+
* @param {number} d1
7+
* @param {number} d2
8+
* @param {any} bx
9+
* @param {any} ay
10+
*/
11+
function _min(d0, d1, d2, bx, ay) {
12+
return d0 < d1 || d2 < d1 ? (d0 > d2 ? d2 + 1 : d0 + 1) : bx === ay ? d1 : d1 + 1;
13+
}
14+
15+
/**
16+
* @param {string} a
17+
* @param {string} b
18+
* @returns {number|undefined}
19+
*/
20+
export default function (a, b) {
21+
if (a === b) {
22+
return 0;
23+
}
24+
25+
if (a.length > b.length) {
26+
var tmp = a;
27+
a = b;
28+
b = tmp;
29+
}
30+
31+
var la = a.length;
32+
var lb = b.length;
33+
34+
while (la > 0 && a.charCodeAt(la - 1) === b.charCodeAt(lb - 1)) {
35+
la--;
36+
lb--;
37+
}
38+
39+
var offset = 0;
40+
41+
while (offset < la && a.charCodeAt(offset) === b.charCodeAt(offset)) {
42+
offset++;
43+
}
44+
45+
la -= offset;
46+
lb -= offset;
47+
48+
if (la === 0 || lb < 3) {
49+
return lb;
50+
}
51+
52+
var x = 0;
53+
var y;
54+
var d0;
55+
var d1;
56+
var d2;
57+
var d3;
58+
var dd;
59+
var dy;
60+
var ay;
61+
var bx0;
62+
var bx1;
63+
var bx2;
64+
var bx3;
65+
66+
var vector = [];
67+
68+
for (y = 0; y < la; y++) {
69+
vector.push(y + 1);
70+
vector.push(a.charCodeAt(offset + y));
71+
}
72+
73+
var len = vector.length - 1;
74+
75+
for (; x < lb - 3; ) {
76+
bx0 = b.charCodeAt(offset + (d0 = x));
77+
bx1 = b.charCodeAt(offset + (d1 = x + 1));
78+
bx2 = b.charCodeAt(offset + (d2 = x + 2));
79+
bx3 = b.charCodeAt(offset + (d3 = x + 3));
80+
dd = x += 4;
81+
for (y = 0; y < len; y += 2) {
82+
dy = vector[y];
83+
ay = vector[y + 1];
84+
d0 = _min(dy, d0, d1, bx0, ay);
85+
d1 = _min(d0, d1, d2, bx1, ay);
86+
d2 = _min(d1, d2, d3, bx2, ay);
87+
dd = _min(d2, d3, dd, bx3, ay);
88+
vector[y] = dd;
89+
d3 = d2;
90+
d2 = d1;
91+
d1 = d0;
92+
d0 = dy;
93+
}
94+
}
95+
96+
for (; x < lb; ) {
97+
bx0 = b.charCodeAt(offset + (d0 = x));
98+
dd = ++x;
99+
for (y = 0; y < len; y += 2) {
100+
dy = vector[y];
101+
vector[y] = dd = _min(dy, d0, dd, bx0, vector[y + 1]);
102+
d0 = dy;
103+
}
104+
}
105+
106+
return dd;
107+
}
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
11
import chai from 'chai';
22
import chalk from 'chalk';
3-
import { execute } from './test-helpers.js';
43
import { formatErrors } from 'check-html-links';
4+
import path from 'path';
5+
import { fileURLToPath } from 'url';
6+
import { execute } from './test-helpers.js';
7+
import { listFiles } from '../src/listFiles.js';
58

69
const { expect } = chai;
10+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
711

812
async function executeAndFormat(inPath) {
913
const { errors, cleanup } = await execute(inPath);
10-
return formatErrors(cleanup(errors));
14+
const testDir = path.join(__dirname, inPath.split('/').join(path.sep));
15+
const rootDir = path.resolve(testDir);
16+
const files = await listFiles('**/*.html', rootDir);
17+
return formatErrors(cleanup(errors), { files });
1118
}
1219

1320
describe('formatErrors', () => {
@@ -16,23 +23,34 @@ describe('formatErrors', () => {
1623
chalk.level = 0;
1724
});
1825

19-
it('prints a nice summery', async () => {
26+
it('prints a nice summary', async () => {
2027
const result = await executeAndFormat('fixtures/test-case');
2128
expect(result.trim().split('\n')).to.deep.equal([
2229
'1. missing id="my-teams" in fixtures/test-case/price/index.html',
2330
' from fixtures/test-case/history/index.html:1:9 via href="/price/#my-teams"',
2431
'',
32+
'Suggestion: did you mean test-node/fixtures/test-case/price/index.html instead?',
33+
'',
34+
'',
2535
'2. missing file fixtures/test-case/about/images/team.png',
2636
' from fixtures/test-case/about/index.html:3:10 via src="./images/team.png"',
2737
'',
38+
'Suggestion: did you mean test-node/fixtures/test-case/about/index.html instead?',
39+
'',
40+
'',
2841
'3. missing reference target fixtures/test-case/aboot',
2942
' from fixtures/test-case/about/index.html:6:11 via href="/aboot"',
3043
' from fixtures/test-case/history/index.html:4:11 via href="/aboot"',
3144
' from fixtures/test-case/index.html:4:11 via href="/aboot"',
3245
' ... 2 more references to this target',
3346
'',
47+
'Suggestion: did you mean test-node/fixtures/test-case/index.html instead?',
48+
'',
49+
'',
3450
'4. missing reference target fixtures/test-case/prce',
3551
' from fixtures/test-case/index.html:1:9 via href="./prce"',
52+
'',
53+
'Suggestion: did you mean test-node/fixtures/test-case/index.html instead?',
3654
]);
3755
});
3856
});

0 commit comments

Comments
 (0)