Skip to content

Commit 97d5fb2

Browse files
jorgecasardaKmoR
authored andcommitted
feat(check-html-links): add external link validation
1 parent 0ca2bc6 commit 97d5fb2

File tree

13 files changed

+178
-21
lines changed

13 files changed

+178
-21
lines changed
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+
add external links validation using the flag `--validate-externals`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
"husky": "^4.3.7",
8282
"lint-staged": "^10.5.3",
8383
"mocha": "^9.1.3",
84-
"node-fetch": "^2.6.7",
84+
"node-fetch": "^3.0.0",
8585
"npm-run-all": "^4.1.5",
8686
"onchange": "^7.1.0",
8787
"prettier": "^2.5.1",

packages/check-html-links/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ npm i -D check-html-links
1414
npx check-html-links _site
1515
```
1616

17-
For docs please see our homepage [https://rocket.modern-web.dev/docs/tools/check-html-links/](https://rocket.modern-web.dev/docs/tools/check-html-links/).
17+
For docs please see our homepage [https://rocket.modern-web.dev/tools/check-html-links/overview/](https://rocket.modern-web.dev/tools/check-html-links/overview/).
1818

1919
## Comparison
2020

packages/check-html-links/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"command-line-args": "^5.1.1",
3838
"glob": "^7.0.0",
3939
"minimatch": "^3.0.4",
40+
"node-fetch": "^3.0.0",
4041
"sax-wasm": "^2.0.0",
4142
"slash": "^4.0.0"
4243
},

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ export class CheckHtmlLinksCli {
1818
const mainDefinitions = [
1919
{ name: 'ignore-link-pattern', type: String, multiple: true },
2020
{ name: 'root-dir', type: String, defaultOption: true },
21-
{ name: 'continue-on-error', type: Boolean, defaultOption: false },
21+
{ name: 'continue-on-error', type: Boolean },
22+
{ name: 'validate-externals', type: Boolean },
2223
];
2324
const options = commandLineArgs(mainDefinitions, {
2425
stopAtFirstUnknown: true,
@@ -29,6 +30,7 @@ export class CheckHtmlLinksCli {
2930
continueOnError: options['continue-on-error'],
3031
rootDir: options['root-dir'],
3132
ignoreLinkPatterns: options['ignore-link-pattern'],
33+
validateExternals: options['validate-externals'],
3234
};
3335
}
3436

@@ -43,7 +45,7 @@ export class CheckHtmlLinksCli {
4345
}
4446

4547
async run() {
46-
const { ignoreLinkPatterns, rootDir: userRootDir } = this.options;
48+
const { ignoreLinkPatterns, rootDir: userRootDir, validateExternals } = this.options;
4749
const rootDir = userRootDir ? path.resolve(userRootDir) : process.cwd();
4850
const performanceStart = process.hrtime();
4951

@@ -56,7 +58,10 @@ export class CheckHtmlLinksCli {
5658
: `🔥 Found a total of ${chalk.green.bold(files.length)} files to check!`;
5759
console.log(filesOutput);
5860

59-
const { errors, numberLinks } = await validateFiles(files, rootDir, { ignoreLinkPatterns });
61+
const { errors, numberLinks } = await validateFiles(files, rootDir, {
62+
ignoreLinkPatterns,
63+
validateExternals,
64+
});
6065

6166
console.log(`🔗 Found a total of ${chalk.green.bold(numberLinks)} links to validate!\n`);
6267

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import fetch from 'node-fetch';
2+
3+
/**
4+
* @type {Map<string,boolean>}
5+
*/
6+
const resultsMap = new Map();
7+
8+
/**
9+
*
10+
* @param {string} url
11+
* @param {boolean} result
12+
* @returns {boolean}
13+
*/
14+
const memorizeCheckup = (url, result) => {
15+
resultsMap.set(url, result);
16+
return result;
17+
};
18+
19+
/**
20+
*
21+
* @param {string} url
22+
* @param {string} method
23+
* @returns
24+
*/
25+
const fetchWrap = async (url, method = 'GET') =>
26+
fetch(url, { method })
27+
.then(response => response.ok)
28+
.catch(() => false);
29+
30+
/**
31+
*
32+
* @param {string} url
33+
* @returns {Promise<boolean>}
34+
*/
35+
const fetchHead = async url => fetchWrap(url, 'HEAD');
36+
37+
/**
38+
*
39+
* @param {string} url - URL object to check
40+
* @returns {Promise<boolean>} true if url is alive or false if not
41+
*/
42+
const checkUrl = async url =>
43+
(fetchHead(url) || fetchWrap(url)).then(result => memorizeCheckup(url, result));
44+
45+
/**
46+
*
47+
* @param {string} link - link string to check
48+
* @returns {Promise<boolean>}
49+
*/
50+
export const checkLink = async link => {
51+
const url = link.startsWith('//') ? `https:${link}` : link;
52+
return resultsMap.get(url) ?? checkUrl(url);
53+
};
54+
/**
55+
* Check an array of links and return an object with
56+
*
57+
* @param {string[]} links Links to check
58+
*/
59+
export const checkLinks = async links => Promise.all(links.map(checkLink));

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

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import fs from 'fs';
33
import saxWasm from 'sax-wasm';
44
import minimatch from 'minimatch';
55
import { createRequire } from 'module';
6-
7-
import { listFiles } from './listFiles.js';
86
import path from 'path';
97
import slash from 'slash';
8+
import { listFiles } from './listFiles.js';
9+
import { checkLinks } from './checkLinks.js';
1010

1111
/** @typedef {import('../types/main').Link} Link */
1212
/** @typedef {import('../types/main').LocalFile} LocalFile */
@@ -28,6 +28,9 @@ const parserIds = new SAXParser(SaxEventType.Attribute, streamOptions);
2828
/** @type {Error[]} */
2929
let checkLocalFiles = [];
3030

31+
/** @type {Error[]} */
32+
let checkExternalLinks = [];
33+
3134
/** @type {Error[]} */
3235
let errors = [];
3336

@@ -151,6 +154,26 @@ function addLocalFile(filePath, anchor, usageObj) {
151154
}
152155
}
153156

157+
/**
158+
* @param {string} filePath
159+
* @param {Usage} usageObj
160+
*/
161+
function addExternalLink(filePath, usageObj) {
162+
const foundIndex = checkExternalLinks.findIndex(item => {
163+
return item.filePath === filePath;
164+
});
165+
166+
if (foundIndex === -1) {
167+
checkExternalLinks.push({
168+
filePath,
169+
onlyAnchorMissing: false,
170+
usage: [usageObj],
171+
});
172+
} else {
173+
checkExternalLinks[foundIndex].usage.push(usageObj);
174+
}
175+
}
176+
154177
/**
155178
* @param {string} inValue
156179
*/
@@ -231,6 +254,7 @@ async function resolveLinks(links, { htmlFilePath, rootDir, ignoreUsage }) {
231254
} else if (value.startsWith('//') || value.startsWith('http')) {
232255
// TODO: handle external urls
233256
// external url - we do not handle that (yet)
257+
addExternalLink(htmlFilePath, usageObj);
234258
} else if (value.startsWith('/')) {
235259
const filePath = path.join(rootDir, valueFile);
236260
addLocalFile(filePath, anchor, usageObj);
@@ -244,7 +268,7 @@ async function resolveLinks(links, { htmlFilePath, rootDir, ignoreUsage }) {
244268
}
245269
}
246270

247-
return { checkLocalFiles: [...checkLocalFiles] };
271+
return { checkLocalFiles: [...checkLocalFiles], checkExternalLinks: [...checkExternalLinks] };
248272
}
249273

250274
/**
@@ -283,6 +307,22 @@ async function validateLocalFiles(checkLocalFiles) {
283307
return errors;
284308
}
285309

310+
/**
311+
*
312+
* @param {Error[]} checkExternalLinks
313+
*/
314+
async function validateExternalLinks(checkExternalLinks) {
315+
for await (const localFileObj of checkExternalLinks) {
316+
const links = localFileObj.usage.map(usage => usage.value);
317+
const results = await checkLinks(links);
318+
localFileObj.usage = localFileObj.usage.filter((link, index) => !results[index]);
319+
if (localFileObj.usage.length > 0) {
320+
errors.push(localFileObj);
321+
}
322+
}
323+
return errors;
324+
}
325+
286326
/**
287327
* @param {string[]} files
288328
* @param {string} rootDir
@@ -294,6 +334,7 @@ export async function validateFiles(files, rootDir, opts) {
294334

295335
errors = [];
296336
checkLocalFiles = [];
337+
checkExternalLinks = [];
297338
idCache = new Map();
298339
let numberLinks = 0;
299340

@@ -312,6 +353,9 @@ export async function validateFiles(files, rootDir, opts) {
312353
await resolveLinks(links, { htmlFilePath, rootDir, ignoreUsage });
313354
}
314355
await validateLocalFiles(checkLocalFiles);
356+
if (opts?.validateExternals) {
357+
await validateExternalLinks(checkExternalLinks);
358+
}
315359

316360
return { errors: errors, numberLinks: numberLinks };
317361
}
Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
<!-- ignore known subsystems -->
2-
<a href="/docs/"></a>
3-
<a href="/developer/getting-started.html#js"></a>
4-
<a href="/developer/language-guides/"></a>
5-
<a href="/developer/javascript.html"></a>
1+
<!-- valid -->
2+
<a href="//rocket.modern-web.dev/"></a>
3+
<a href="http://rocket.modern-web.dev/"></a>
4+
<a href="https://rocket.modern-web.dev/"></a>
5+
6+
<!-- invalid -->
7+
<a href="//rocket.modern-web.dev/unexists-page/"></a>
8+
<a href="http://rocket.modern-web.dev/unexists-page/"></a>
9+
<a href="https://rocket.modern-web.dev/unexists-page/"></a>

packages/check-html-links/test-node/fixtures/internal-link/index.html

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,5 @@
55
<a href="./page.html"></a>
66
<a href=" ./page.html "></a>
77
<a href=" /page.html "></a>
8-
<a href="//domain.com/something/"></a>
9-
<a href="http://domain.com/something/"></a>
10-
<a href="https://domain.com/something/"></a>
118
<a href=""></a>
129
<a href=":~:text=put%20your%20labels%20above%20your%20inputs">Sign-in form best practices</a>

packages/check-html-links/test-node/validateFolder.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,44 @@ describe('validateFolder', () => {
6666
]);
6767
});
6868

69+
it('validates external links', async () => {
70+
const { errors, cleanup } = await execute('fixtures/external-link', {
71+
validateExternals: true,
72+
});
73+
expect(cleanup(errors)).to.deep.equal([
74+
{
75+
filePath: 'fixtures/external-link/index.html',
76+
onlyAnchorMissing: false,
77+
usage: [
78+
{
79+
attribute: 'href',
80+
value: '//rocket.modern-web.dev/unexists-page/',
81+
file: 'fixtures/external-link/index.html',
82+
line: 6,
83+
character: 9,
84+
anchor: '',
85+
},
86+
{
87+
attribute: 'href',
88+
value: 'http://rocket.modern-web.dev/unexists-page/',
89+
file: 'fixtures/external-link/index.html',
90+
line: 7,
91+
character: 9,
92+
anchor: '',
93+
},
94+
{
95+
attribute: 'href',
96+
value: 'https://rocket.modern-web.dev/unexists-page/',
97+
file: 'fixtures/external-link/index.html',
98+
line: 8,
99+
character: 9,
100+
anchor: '',
101+
},
102+
],
103+
},
104+
]);
105+
});
106+
69107
it('groups multiple usage of the same missing file', async () => {
70108
const { errors, cleanup } = await execute('fixtures/internal-links-to-same-file');
71109
expect(cleanup(errors)).to.deep.equal([

0 commit comments

Comments
 (0)