Skip to content

Commit 3319f1d

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

File tree

5 files changed

+152
-7
lines changed

5 files changed

+152
-7
lines changed

.changeset/gentle-crabs-provide.md

Lines changed: 5 additions & 0 deletions
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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 27 additions & 2 deletions
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,29 @@ 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+
54+
files.forEach(file => {
55+
const filePathToCompare = file.replace(relativeFrom + '/', '');
56+
const score = levenshtein(filePathToCompare, filePath);
57+
console.info({ filePath, filePathToCompare, score });
58+
if (lowestScore === -1 || score < lowestScore) {
59+
lowestScore = score;
60+
suggestion = filePathToCompare;
61+
}
62+
});
63+
64+
if (suggestion) {
65+
output.push(
66+
chalk.italic(`Suggestion: did you mean ${chalk.magenta(suggestion)} instead?\n\n`),
67+
);
68+
}
4469
}
4570
return output.join('\n');
4671
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/* eslint-disable */
2+
// https://github.com/gustf/js-levenshtein/blob/master/index.js
3+
4+
function _min(d0, d1, d2, bx, ay) {
5+
return d0 < d1 || d2 < d1 ? (d0 > d2 ? d2 + 1 : d0 + 1) : bx === ay ? d1 : d1 + 1;
6+
}
7+
8+
export default function (a, b) {
9+
if (a === b) {
10+
return 0;
11+
}
12+
13+
if (a.length > b.length) {
14+
var tmp = a;
15+
a = b;
16+
b = tmp;
17+
}
18+
19+
var la = a.length;
20+
var lb = b.length;
21+
22+
while (la > 0 && a.charCodeAt(la - 1) === b.charCodeAt(lb - 1)) {
23+
la--;
24+
lb--;
25+
}
26+
27+
var offset = 0;
28+
29+
while (offset < la && a.charCodeAt(offset) === b.charCodeAt(offset)) {
30+
offset++;
31+
}
32+
33+
la -= offset;
34+
lb -= offset;
35+
36+
if (la === 0 || lb < 3) {
37+
return lb;
38+
}
39+
40+
var x = 0;
41+
var y;
42+
var d0;
43+
var d1;
44+
var d2;
45+
var d3;
46+
var dd;
47+
var dy;
48+
var ay;
49+
var bx0;
50+
var bx1;
51+
var bx2;
52+
var bx3;
53+
54+
var vector = [];
55+
56+
for (y = 0; y < la; y++) {
57+
vector.push(y + 1);
58+
vector.push(a.charCodeAt(offset + y));
59+
}
60+
61+
var len = vector.length - 1;
62+
63+
for (; x < lb - 3; ) {
64+
bx0 = b.charCodeAt(offset + (d0 = x));
65+
bx1 = b.charCodeAt(offset + (d1 = x + 1));
66+
bx2 = b.charCodeAt(offset + (d2 = x + 2));
67+
bx3 = b.charCodeAt(offset + (d3 = x + 3));
68+
dd = x += 4;
69+
for (y = 0; y < len; y += 2) {
70+
dy = vector[y];
71+
ay = vector[y + 1];
72+
d0 = _min(dy, d0, d1, bx0, ay);
73+
d1 = _min(d0, d1, d2, bx1, ay);
74+
d2 = _min(d1, d2, d3, bx2, ay);
75+
dd = _min(d2, d3, dd, bx3, ay);
76+
vector[y] = dd;
77+
d3 = d2;
78+
d2 = d1;
79+
d1 = d0;
80+
d0 = dy;
81+
}
82+
}
83+
84+
for (; x < lb; ) {
85+
bx0 = b.charCodeAt(offset + (d0 = x));
86+
dd = ++x;
87+
for (y = 0; y < len; y += 2) {
88+
dy = vector[y];
89+
vector[y] = dd = _min(dy, d0, dd, bx0, vector[y + 1]);
90+
d0 = dy;
91+
}
92+
}
93+
94+
return dd;
95+
}
Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,22 @@
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+
15+
const testDir = path.join(__dirname, inPath.split('/').join(path.sep));
16+
const rootDir = path.resolve(testDir);
17+
const files = await listFiles('**/*.html', rootDir);
18+
19+
return formatErrors(cleanup(errors), { files });
1120
}
1221

1322
describe('formatErrors', () => {
@@ -16,23 +25,34 @@ describe('formatErrors', () => {
1625
chalk.level = 0;
1726
});
1827

19-
it('prints a nice summery', async () => {
20-
const result = await executeAndFormat('fixtures/test-case');
28+
it('prints a nice summary', async () => {
29+
const result = await executeAndFormat('fixtures/test-case', { skipSuggestions: true });
2130
expect(result.trim().split('\n')).to.deep.equal([
2231
'1. missing id="my-teams" in fixtures/test-case/price/index.html',
2332
' from fixtures/test-case/history/index.html:1:9 via href="/price/#my-teams"',
2433
'',
34+
'Suggestion: did you mean test-node/fixtures/test-case/price/index.html instead?',
35+
'',
36+
'',
2537
'2. missing file fixtures/test-case/about/images/team.png',
2638
' from fixtures/test-case/about/index.html:3:10 via src="./images/team.png"',
2739
'',
40+
'Suggestion: did you mean test-node/fixtures/test-case/about/index.html instead?',
41+
'',
42+
'',
2843
'3. missing reference target fixtures/test-case/aboot',
2944
' from fixtures/test-case/about/index.html:6:11 via href="/aboot"',
3045
' from fixtures/test-case/history/index.html:4:11 via href="/aboot"',
3146
' from fixtures/test-case/index.html:4:11 via href="/aboot"',
3247
' ... 2 more references to this target',
3348
'',
49+
'Suggestion: did you mean test-node/fixtures/test-case/index.html instead?',
50+
'',
51+
'',
3452
'4. missing reference target fixtures/test-case/prce',
3553
' from fixtures/test-case/index.html:1:9 via href="./prce"',
54+
'',
55+
'Suggestion: did you mean test-node/fixtures/test-case/index.html instead?',
3656
]);
3757
});
3858
});

0 commit comments

Comments
 (0)