Skip to content

Commit afff794

Browse files
authored
Merge pull request #95 from liferay/wincent/check-links
feat: add script to check for broken links
2 parents a900a5c + bbb71d0 commit afff794

File tree

6 files changed

+1335
-9
lines changed

6 files changed

+1335
-9
lines changed

.eslintrc.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module.exports = {
2+
env: {
3+
node: true
4+
},
5+
parserOptions: {
6+
ecmaVersion: 2018
7+
},
8+
extends: 'liferay',
9+
rules: {
10+
'no-for-of-loops/no-for-of-loops': 'off'
11+
}
12+
};

dxp/dev_dependencies.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,4 @@ The best way to get a developer dependency approved for Liferay DXP is to prove
2929

3030
## Related resources
3131

32-
[1][liferay dxp build process]()
32+
1. [Liferay DXP build process](https://github.com/liferay/liferay-npm-tools/tree/master/packages/liferay-npm-scripts)

general/testing/supported_libraries.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@
44

55
### Supported libraries inside Liferay DXP
66

7-
As described in [Developer Dependencies](../dxp/dev_dependencies.md), we use a very specific list of libraries within [Liferay DXP](https://github.com/liferay/liferay-portal). At the time of writing, for testing, we use:
7+
As described in [Developer Dependencies](../../dxp/dev_dependencies.md), we use a very specific list of libraries within [Liferay DXP](https://github.com/liferay/liferay-portal). At the time of writing, for testing, we use:
88

99
- [liferay-npm-scripts](https://github.com/liferay/liferay-npm-tools/tree/master/packages/liferay-npm-scripts) is our main entry point, and is called from the "test" script in each `package.json` file as `liferay-npm-scripts test`.
1010
- [Jest](https://jestjs.io/) is our test runner.
11-
- We bundle a number of libraries with liferay-npm-scripts that help us write user-centric tests; you don't need to specify these in your `devDependencies` (and indeed, [you shouldn't](../dxp/dev_dependencies.md)) because they are available in all Yarn workspaces:
11+
- We bundle a number of libraries with liferay-npm-scripts that help us write user-centric tests; you don't need to specify these in your `devDependencies` (and indeed, [you shouldn't](../../dxp/dev_dependencies.md)) because they are available in all Yarn workspaces:
1212
- [@testing-library/jest-dom](https://testing-library.com/docs/ecosystem-jest-dom)
1313
- [@testing-library/react](https://testing-library.com/docs/react-testing-library/intro)
1414
- [@testing-library/user-event](https://testing-library.com/docs/ecosystem-user-event)

package.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
{
22
"description": "Liferay Frontend Guidelines",
33
"devDependencies": {
4-
"prettier": "1.17.1"
4+
"eslint": "^6.4.0",
5+
"eslint-config-liferay": "^11.0.1",
6+
"prettier": "^1.18.2"
57
},
68
"name": "liferay-frontend-guidelines",
79
"private": true,
810
"repository": "https://github.com/liferay/liferay-frontend-guidelines.git",
911
"scripts": {
12+
"ci": "yarn format:check && yarn lint && yarn test",
1013
"format": "prettier --write '**/{.,}*.js{,on}' '**/*.md'",
1114
"format:changed": "git ls-files -mz -- '*.js' '*.json' '*.md' | xargs -0 prettier --write --",
12-
"format:check": "prettier --list-different '**/{.,}*.js{,on}' '**/*.md'"
15+
"format:check": "prettier --list-different '**/{.,}*.js{,on}' '**/*.md'",
16+
"lint": "eslint '**/*.js'",
17+
"lint:fix": "eslint --fix '**/*.js'",
18+
"test": "node support/checkLinks.js"
1319
},
1420
"version": "0.0.1"
1521
}

support/checkLinks.js

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
#!/usr/bin/env node
2+
3+
const fs = require('fs');
4+
const http = require('http');
5+
const path = require('path');
6+
const {promisify} = require('util');
7+
8+
const accessAsync = promisify(fs.access);
9+
const readFileAsync = promisify(fs.readFile);
10+
const readdirAsync = promisify(fs.readdir);
11+
const statAsync = promisify(fs.stat);
12+
13+
const FILTER_PATTERN = /\.md$/;
14+
15+
const IGNORE_PATTERN = /^(?:.git|node_modules)$/;
16+
17+
// Adapted from: https://stackoverflow.com/a/163684/2103996
18+
const URL_PATTERN = /\bhttps?:\/\/[-A-Za-z0-9+&@#/%?=~_|!:,.;]*[-A-Za-z0-9+&@#/%=~_|]/;
19+
20+
let errorCount = 0;
21+
22+
async function check(link, files) {
23+
if (URL_PATTERN.test(link)) {
24+
await checkRemote(link, files);
25+
} else if (/^#/.test(link)) {
26+
await checkInternal(link, files);
27+
} else {
28+
await checkLocal(link, files);
29+
}
30+
}
31+
32+
async function checkInternal(link, files) {
33+
for (const file of files) {
34+
const contents = await readFileAsync(file, 'utf8');
35+
36+
const headingPattern = link.slice(1).replace(/-/g, '[ -,]+');
37+
38+
const regExp = new RegExp(`^#+\\s+${headingPattern}\\s*$`, 'im');
39+
40+
if (!contents.match(regExp)) {
41+
report(file, `No heading found matching internal target: ${link}`);
42+
}
43+
}
44+
}
45+
46+
async function checkLocal(link, files) {
47+
for (const file of files) {
48+
const target = path.join(path.dirname(file), link);
49+
50+
try {
51+
await accessAsync(target);
52+
} catch (error) {
53+
report(file, `No file/directory found for local target: ${target}`);
54+
}
55+
}
56+
}
57+
58+
function checkRemote(link, files) {
59+
return new Promise(resolve => {
60+
const bail = problem => {
61+
report(files, problem);
62+
resolve();
63+
};
64+
65+
const {hostname, pathname, port} = new URL(link);
66+
67+
if (
68+
hostname === 'localhost' ||
69+
hostname === '127.0.0.1' ||
70+
hostname === '0.0.0.0' ||
71+
hostname === '::1'
72+
) {
73+
resolve();
74+
return;
75+
}
76+
77+
const request = http.get(
78+
{
79+
host: hostname,
80+
path: pathname,
81+
port
82+
},
83+
({statusCode}) => {
84+
if (statusCode >= 200 && statusCode < 400) {
85+
resolve();
86+
} else {
87+
bail(`Status code ${statusCode} for remote link: ${link}`);
88+
}
89+
}
90+
);
91+
92+
request.on('error', error => {
93+
// Trim stack trace.
94+
const text = error.toString().split(/\n/)[0];
95+
96+
bail(`Failed request (${text}) for remote link: ${link}`);
97+
});
98+
});
99+
}
100+
101+
async function enqueueFile(file, pending) {
102+
const contents = await readFileAsync(file, 'utf8');
103+
104+
const links = extractLinks(contents, file);
105+
106+
links.forEach(link => {
107+
if (!pending.has(link)) {
108+
pending.set(link, new Set());
109+
}
110+
pending.get(link).add(file);
111+
});
112+
}
113+
114+
/**
115+
* Look for links as described here:
116+
* https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet#links
117+
*/
118+
function extractLinks(contents, file) {
119+
const definitions = new Set();
120+
const links = new Set();
121+
const references = new Set();
122+
123+
contents
124+
// [reference text]: resolved-target optional-title
125+
.replace(
126+
/^\s*\[([^\]\n]+)\]\s*:\s*([^\s]+)(?:[ \t]+[^\n]+)?$/gm,
127+
(_, reference, target) => {
128+
definitions.add(reference);
129+
links.add(target);
130+
return ' ';
131+
}
132+
)
133+
// [link text](https://example.com a title)
134+
.replace(/\[[^\]\n]+\]\(([^\s)]+) [^)\n]+\)/g, (_, link) => {
135+
links.add(link);
136+
return ' ';
137+
})
138+
// [link text](https://example.com)
139+
.replace(/\[[^\]\n]+\]\(([^\s)]+)\)/g, (_, link) => {
140+
links.add(link);
141+
return ' ';
142+
})
143+
// [link text][reference]
144+
.replace(/\[[^\]\n]+\]\[([^\]\n]+)\]/g, (_, reference) => {
145+
references.add(reference);
146+
return ' ';
147+
})
148+
// [link text]
149+
.replace(/\[([^\]\n]+)\]g/, (_, reference) => {
150+
references.add(reference);
151+
return ' ';
152+
})
153+
// <http://www.example.com>
154+
.replace(new RegExp(`<(${URL_PATTERN.source})>`, 'gi'), (_, url) => {
155+
links.add(url);
156+
return ' ';
157+
})
158+
// http://www.example.com
159+
.replace(URL_PATTERN, url => {
160+
links.add(url);
161+
return ' ';
162+
});
163+
164+
for (const reference of references) {
165+
if (!definitions.has(reference)) {
166+
report(file, `Missing reference: ${reference}`);
167+
}
168+
}
169+
170+
return Array.from(links);
171+
}
172+
173+
async function main() {
174+
const pending = new Map();
175+
176+
for await (const file of walk('.')) {
177+
if (FILTER_PATTERN.test(file)) {
178+
await enqueueFile(file, pending);
179+
}
180+
}
181+
182+
await run(pending);
183+
}
184+
185+
const MAX_CONCURRENT_CHECKS = 10;
186+
187+
async function run(pending) {
188+
const active = new Set();
189+
190+
while (active.size > 0 || pending.size > 0) {
191+
for (let i = active.size; i < MAX_CONCURRENT_CHECKS; i++) {
192+
for (const [link, files] of pending.entries()) {
193+
const promise = new Promise((resolve, reject) => {
194+
check(link, files)
195+
.then(resolve)
196+
.catch(reject)
197+
.finally(() => active.delete(promise));
198+
});
199+
200+
active.add(promise);
201+
pending.delete(link);
202+
203+
break;
204+
}
205+
}
206+
207+
await Promise.race(active);
208+
}
209+
}
210+
211+
function report(bad, message) {
212+
const files = typeof bad === 'string' ? [bad] : [...bad];
213+
214+
files.forEach(file => {
215+
errorCount++;
216+
console.error(`${file}: ${message}`);
217+
});
218+
}
219+
220+
async function* walk(directory) {
221+
const entries = await readdirAsync(directory);
222+
223+
for (let i = 0; i < entries.length; i++) {
224+
const entry = path.join(directory, entries[i]);
225+
226+
if (IGNORE_PATTERN.test(entry)) {
227+
continue;
228+
}
229+
230+
const stat = await statAsync(entry);
231+
232+
if (stat.isDirectory()) {
233+
for await (const nested of walk(entry)) {
234+
yield nested;
235+
}
236+
} else {
237+
yield entry;
238+
}
239+
}
240+
}
241+
242+
main()
243+
.catch(error => {
244+
errorCount++;
245+
console.error(error);
246+
})
247+
.finally(() => {
248+
if (errorCount) {
249+
process.exit(1);
250+
}
251+
});

0 commit comments

Comments
 (0)