Skip to content

Commit a72a1c3

Browse files
feat: import the check-help-links eslint rule (#117)
1 parent 82bf23f commit a72a1c3

File tree

7 files changed

+240
-2
lines changed

7 files changed

+240
-2
lines changed

.changeset/stale-eagles-relate.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sourcegraph/eslint-plugin-sourcegraph': patch
3+
---
4+
5+
Imported the `check-help-links` eslint-plugin rule.

packages/eslint-plugin/src/configs/all.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,10 @@
22
// DO NOT EDIT THIS CODE BY HAND
33
// YOU CAN REGENERATE IT USING yarn generate:configs
44

5-
export = { extends: ['./configs/base'], rules: { '@sourcegraph/sourcegraph/use-button-component': 'error' } }
5+
export = {
6+
extends: ['./configs/base'],
7+
rules: {
8+
'@sourcegraph/sourcegraph/check-help-links': 'error',
9+
'@sourcegraph/sourcegraph/use-button-component': 'error',
10+
},
11+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Disabled some rules in the process of migration from JS to avoid bloating lint output with warnings.
2+
// To fix these issues the `@typescript-eslint/no-explicit-any` warning in this file should be fixed.
3+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
4+
import { RuleTester } from '../../../testing/RuleTester'
5+
import { checkHelpLinks } from '../check-help-links'
6+
7+
const ruleTester = new RuleTester({
8+
parserOptions: {
9+
ecmaVersion: 6,
10+
ecmaFeatures: {
11+
jsx: true,
12+
},
13+
},
14+
parser: '@typescript-eslint/parser',
15+
})
16+
17+
const invalidLinkError = (path: string) => {
18+
return { message: 'Help link to non-existent page: ' + path, type: 'JSXOpeningElement' }
19+
}
20+
const options = [{ docsiteList: ['a.md', 'b/c.md', 'd/index.md'] }]
21+
22+
// Build up the test cases given the various combinations we need to support.
23+
const cases: any = { valid: [], invalid: [] }
24+
25+
for (const [element, attribute] of [
26+
['a', 'href'],
27+
['Link', 'to'],
28+
]) {
29+
for (const anchor of ['', '#anchor', '#anchor#double']) {
30+
for (const content of ['', 'link content']) {
31+
const code = (target: string) => {
32+
return content
33+
? `<${element} ${attribute}="${target}${anchor}">${content}</${element}>`
34+
: `<${element} ${attribute}="${target}${anchor}" />`
35+
}
36+
37+
cases.valid.push(
38+
...[
39+
'/help/a',
40+
'/help/b/c',
41+
'/help/d',
42+
'/help/d/',
43+
'not-a-help-link',
44+
'help/but-not-absolute',
45+
'/help-but-not-a-directory',
46+
].map(target => {
47+
return {
48+
code: code(target),
49+
options,
50+
}
51+
})
52+
)
53+
54+
cases.invalid.push(
55+
...['/help/', '/help/b', '/help/does/not/exist'].map(target => {
56+
return {
57+
code: code(target),
58+
errors: [invalidLinkError(target.slice(6))],
59+
options,
60+
}
61+
})
62+
)
63+
}
64+
}
65+
}
66+
67+
// Every case should be valid if the options are empty.
68+
cases.valid.push(
69+
...[...cases.invalid, ...cases.valid].map(({ code }) => {
70+
return { code }
71+
})
72+
)
73+
74+
// Actually run the tests.
75+
ruleTester.run('check-help-links', checkHelpLinks, cases)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Check help links for validity
2+
3+
## Rule details
4+
5+
This rule parses `Link` and `a` elements in JSX/TSX files. If a list of valid
6+
docsite pages is provided, elements that point to a `/help/*` link are checked
7+
against that list: if they don't exist, a linting error is raised.
8+
9+
The list of docsite pages is provided either via the `DOCSITE_LIST` environment
10+
variable, which should be a newline separated list of pages as outputted by
11+
`docsite ls`, or via the `docsiteList` rule option, which is the same data as
12+
an array.
13+
14+
If neither of these are set, then the rule will silently succeed.
15+
16+
## How to Use
17+
18+
```jsonc
19+
{
20+
"@sourcegraph/check-help-links": "error"
21+
}
22+
```
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'
2+
import { Literal } from '@typescript-eslint/types/dist/ast-spec'
3+
4+
import { createRule } from '../../utils'
5+
6+
export const messages = {
7+
invalidHelpLink: 'Help link to non-existent page: {{ destination }}',
8+
}
9+
10+
export interface Option {
11+
docsiteList: {
12+
type: string
13+
}[]
14+
}
15+
16+
export const checkHelpLinks = createRule<Option[], keyof typeof messages>({
17+
name: 'check-help-links',
18+
meta: {
19+
docs: {
20+
description: 'Check that /help links point to real, non-redirected pages',
21+
recommended: false,
22+
},
23+
messages,
24+
schema: [
25+
{
26+
type: 'object',
27+
properties: {
28+
docsiteList: {
29+
type: 'array',
30+
items: {
31+
type: 'string',
32+
},
33+
},
34+
},
35+
additionalProperties: false,
36+
},
37+
],
38+
type: 'problem',
39+
},
40+
defaultOptions: [],
41+
create(context) {
42+
// Build the set of valid pages. In order, we'll try to get this from:
43+
//
44+
// 1. The DOCSITE_LIST environment variable, which should be a newline
45+
// separated list of pages, as outputted by `docsite ls`.
46+
// 2. The docsiteList rule option, which should be an array of pages.
47+
//
48+
// If neither of these are set, this rule will silently pass, so as not to
49+
// require docsite to be run when a user wants to run eslint in general.
50+
const pages = new Set()
51+
if (process.env.DOCSITE_LIST) {
52+
process.env.DOCSITE_LIST.split('\n').forEach(page => {
53+
return pages.add(page)
54+
})
55+
} else if (context.options.length > 0) {
56+
context.options[0].docsiteList.forEach(page => {
57+
return pages.add(page)
58+
})
59+
}
60+
61+
// No pages were provided, so we'll return an empty object and do nothing.
62+
if (pages.size === 0) {
63+
return {}
64+
}
65+
66+
// Return the object that will install the listeners we want. In this case,
67+
// we only need to look at JSX opening elements.
68+
//
69+
// Note that we could use AST selectors below, but the structure of the AST
70+
// makes that tricky: the identifer (Link or a) and attribute (to or href)
71+
// we use to identify an element of interest are siblings, so we'd probably
72+
// have to select on the identifier and have some ugly traversal code below
73+
// to check the attribute. It feels cleaner to do it this way with the
74+
// opening element as the context.
75+
return {
76+
JSXOpeningElement: node => {
77+
// Figure out what kind of element we have and therefore what attribute
78+
// we'd want to look for.
79+
let attributeName: string
80+
81+
if (node.name.type === AST_NODE_TYPES.JSXIdentifier) {
82+
if (node.name.name === 'Link') {
83+
attributeName = 'to'
84+
} else if (node.name.name === 'a') {
85+
attributeName = 'href'
86+
}
87+
} else {
88+
// Anything that's not a link is uninteresting.
89+
return
90+
}
91+
92+
// Go find the link target in the attribute array.
93+
const target = node.attributes.reduce<Literal['value']>((target, attribute) => {
94+
return (
95+
target ||
96+
(attribute.type === AST_NODE_TYPES.JSXAttribute &&
97+
attribute.name &&
98+
attribute.name.name === attributeName &&
99+
attribute.value?.type === AST_NODE_TYPES.Literal
100+
? attribute.value.value
101+
: null)
102+
)
103+
}, null)
104+
105+
// Make sure the target points to a help link; if not, we don't need to
106+
// go any further.
107+
if (typeof target !== 'string' || !target.startsWith('/help/')) {
108+
return
109+
}
110+
111+
// Strip off the /help/ prefix, any anchor, and any trailing slash, then
112+
// look up the resultant page in the pages set, bearing in mind that it
113+
// might point to a directory and we also need to look for any index
114+
// page that might exist.
115+
const destination = target.slice(6).split('#')[0].replace(/\/+$/, '')
116+
117+
if (!pages.has(destination + '.md') && !pages.has(destination + '/index.md')) {
118+
context.report({
119+
node,
120+
messageId: 'invalidHelpLink',
121+
data: { destination },
122+
})
123+
}
124+
},
125+
}
126+
},
127+
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './check-help-links'
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
// This file is used by `scripts/generate-configs.ts` for rules extraction.
2-
/* eslint-disable import/no-default-export */
2+
import { checkHelpLinks } from './check-help-links'
33
import { useButtonComponent } from './use-button-component'
44

5+
// eslint-disable-next-line import/no-default-export
56
export default {
67
'use-button-component': useButtonComponent,
8+
'check-help-links': checkHelpLinks,
79
}

0 commit comments

Comments
 (0)