Skip to content

Commit ac4dcb6

Browse files
committed
Add ESLint with custom rule to catch broken internal links
Add a no-bad-internal-links ESLint rule that flags JSX href/to attributes with relative paths, .html extensions, missing trailing slashes, or whitespace in URLs. Includes a GitHub Actions workflow to run lint on every PR and push to master. Closes #156
1 parent b75366c commit ac4dcb6

File tree

6 files changed

+1382
-25
lines changed

6 files changed

+1382
-25
lines changed

.eslintignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules/
2+
build/
3+
.docusaurus/

.eslintrc.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module.exports = {
2+
parserOptions: {
3+
ecmaVersion: 2022,
4+
sourceType: "module",
5+
ecmaFeatures: { jsx: true },
6+
},
7+
plugins: ["react"],
8+
settings: {
9+
react: { version: "detect" },
10+
},
11+
rules: {
12+
"no-bad-internal-links": "error",
13+
},
14+
};

.github/workflows/lint.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Lint
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
pull_request:
8+
branches:
9+
- master
10+
11+
jobs:
12+
lint:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- name: Checkout
16+
uses: actions/checkout@v4
17+
18+
- name: Setup Node.js
19+
uses: actions/setup-node@v4
20+
with:
21+
node-version: 20
22+
cache: yarn
23+
24+
- name: Install dependencies
25+
run: yarn install --frozen-lockfile
26+
27+
- name: Lint
28+
run: yarn lint
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* ESLint rule: no-bad-internal-links
3+
*
4+
* Enforces correct internal link format in JSX:
5+
* - No leading/trailing whitespace in any URL
6+
* - Internal links must start with '/'
7+
* - Internal links must not use .html extension
8+
* - Internal links must end with '/'
9+
*/
10+
module.exports = {
11+
meta: {
12+
type: "problem",
13+
docs: {
14+
description:
15+
"Enforce correct internal link format in JSX (absolute paths, no .html, trailing slash)",
16+
},
17+
messages: {
18+
whitespace:
19+
"Link has leading or trailing whitespace: '{{value}}'. Remove the extra spaces.",
20+
noLeadingSlash:
21+
"Internal link '{{value}}' should start with '/'. Did you mean '/{{suggested}}'?",
22+
htmlExtension:
23+
"Internal link '{{value}}' should not use .html extension. Did you mean '{{suggested}}'?",
24+
noTrailingSlash:
25+
"Internal link '{{value}}' should end with '/'. Did you mean '{{value}}/'?",
26+
},
27+
},
28+
create(context) {
29+
return {
30+
JSXAttribute(node) {
31+
const attrName = node.name && node.name.name;
32+
if (attrName !== "href" && attrName !== "to") return;
33+
34+
// Only handle string literals, skip expressions like {`/path/${var}`}
35+
if (
36+
!node.value ||
37+
node.value.type !== "Literal" ||
38+
typeof node.value.value !== "string"
39+
) {
40+
return;
41+
}
42+
43+
const value = node.value.value;
44+
45+
// Check for leading/trailing whitespace (applies to all URLs)
46+
if (value !== value.trim()) {
47+
context.report({ node, messageId: "whitespace", data: { value } });
48+
return;
49+
}
50+
51+
// Skip external URLs, anchors, and protocol-relative URLs
52+
if (
53+
value.startsWith("http://") ||
54+
value.startsWith("https://") ||
55+
value.startsWith("mailto:") ||
56+
value.startsWith("tel:") ||
57+
value.startsWith("#") ||
58+
value.startsWith("//")
59+
) {
60+
return;
61+
}
62+
63+
// Skip empty strings
64+
if (value === "") return;
65+
66+
// --- Internal link checks ---
67+
68+
// Must start with /
69+
if (!value.startsWith("/")) {
70+
const stripped = value.replace(/\.html?$/, "");
71+
const suggested = stripped.endsWith("/") ? stripped : stripped + "/";
72+
context.report({
73+
node,
74+
messageId: "noLeadingSlash",
75+
data: { value, suggested },
76+
});
77+
return;
78+
}
79+
80+
// Must not end with .html
81+
if (/\.html?$/.test(value)) {
82+
const suggested = value.replace(/\.html?$/, "/");
83+
context.report({
84+
node,
85+
messageId: "htmlExtension",
86+
data: { value, suggested },
87+
});
88+
return;
89+
}
90+
91+
// Must end with / (except paths with anchors or query strings)
92+
if (
93+
!value.endsWith("/") &&
94+
!value.includes("#") &&
95+
!value.includes("?")
96+
) {
97+
context.report({
98+
node,
99+
messageId: "noTrailingSlash",
100+
data: { value },
101+
});
102+
}
103+
},
104+
};
105+
},
106+
};

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"clear": "docusaurus clear",
1212
"serve": "docusaurus serve",
1313
"write-translations": "docusaurus write-translations",
14-
"write-heading-ids": "docusaurus write-heading-ids"
14+
"write-heading-ids": "docusaurus write-heading-ids",
15+
"lint": "eslint --rulesdir eslint-rules src/"
1516
},
1617
"dependencies": {
1718
"@docusaurus/core": "^3.9.1",
@@ -39,5 +40,9 @@
3940
"last 1 firefox version",
4041
"last 1 safari version"
4142
]
43+
},
44+
"devDependencies": {
45+
"eslint": "^8.57.0",
46+
"eslint-plugin-react": "^7.37.0"
4247
}
4348
}

0 commit comments

Comments
 (0)