Skip to content

Commit

Permalink
feat: import the check-help-links eslint rule (#117)
Browse files Browse the repository at this point in the history
  • Loading branch information
valerybugakov authored May 6, 2022
1 parent 82bf23f commit a72a1c3
Show file tree
Hide file tree
Showing 7 changed files with 240 additions and 2 deletions.
5 changes: 5 additions & 0 deletions .changeset/stale-eagles-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sourcegraph/eslint-plugin-sourcegraph': patch
---

Imported the `check-help-links` eslint-plugin rule.
8 changes: 7 additions & 1 deletion packages/eslint-plugin/src/configs/all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,10 @@
// DO NOT EDIT THIS CODE BY HAND
// YOU CAN REGENERATE IT USING yarn generate:configs

export = { extends: ['./configs/base'], rules: { '@sourcegraph/sourcegraph/use-button-component': 'error' } }
export = {
extends: ['./configs/base'],
rules: {
'@sourcegraph/sourcegraph/check-help-links': 'error',
'@sourcegraph/sourcegraph/use-button-component': 'error',
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Disabled some rules in the process of migration from JS to avoid bloating lint output with warnings.
// To fix these issues the `@typescript-eslint/no-explicit-any` warning in this file should be fixed.
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
import { RuleTester } from '../../../testing/RuleTester'
import { checkHelpLinks } from '../check-help-links'

const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 6,
ecmaFeatures: {
jsx: true,
},
},
parser: '@typescript-eslint/parser',
})

const invalidLinkError = (path: string) => {
return { message: 'Help link to non-existent page: ' + path, type: 'JSXOpeningElement' }
}
const options = [{ docsiteList: ['a.md', 'b/c.md', 'd/index.md'] }]

// Build up the test cases given the various combinations we need to support.
const cases: any = { valid: [], invalid: [] }

for (const [element, attribute] of [
['a', 'href'],
['Link', 'to'],
]) {
for (const anchor of ['', '#anchor', '#anchor#double']) {
for (const content of ['', 'link content']) {
const code = (target: string) => {
return content
? `<${element} ${attribute}="${target}${anchor}">${content}</${element}>`
: `<${element} ${attribute}="${target}${anchor}" />`
}

cases.valid.push(
...[
'/help/a',
'/help/b/c',
'/help/d',
'/help/d/',
'not-a-help-link',
'help/but-not-absolute',
'/help-but-not-a-directory',
].map(target => {
return {
code: code(target),
options,
}
})
)

cases.invalid.push(
...['/help/', '/help/b', '/help/does/not/exist'].map(target => {
return {
code: code(target),
errors: [invalidLinkError(target.slice(6))],
options,
}
})
)
}
}
}

// Every case should be valid if the options are empty.
cases.valid.push(
...[...cases.invalid, ...cases.valid].map(({ code }) => {
return { code }
})
)

// Actually run the tests.
ruleTester.run('check-help-links', checkHelpLinks, cases)
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Check help links for validity

## Rule details

This rule parses `Link` and `a` elements in JSX/TSX files. If a list of valid
docsite pages is provided, elements that point to a `/help/*` link are checked
against that list: if they don't exist, a linting error is raised.

The list of docsite pages is provided either via the `DOCSITE_LIST` environment
variable, which should be a newline separated list of pages as outputted by
`docsite ls`, or via the `docsiteList` rule option, which is the same data as
an array.

If neither of these are set, then the rule will silently succeed.

## How to Use

```jsonc
{
"@sourcegraph/check-help-links": "error"
}
```
127 changes: 127 additions & 0 deletions packages/eslint-plugin/src/rules/check-help-links/check-help-links.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'
import { Literal } from '@typescript-eslint/types/dist/ast-spec'

import { createRule } from '../../utils'

export const messages = {
invalidHelpLink: 'Help link to non-existent page: {{ destination }}',
}

export interface Option {
docsiteList: {
type: string
}[]
}

export const checkHelpLinks = createRule<Option[], keyof typeof messages>({
name: 'check-help-links',
meta: {
docs: {
description: 'Check that /help links point to real, non-redirected pages',
recommended: false,
},
messages,
schema: [
{
type: 'object',
properties: {
docsiteList: {
type: 'array',
items: {
type: 'string',
},
},
},
additionalProperties: false,
},
],
type: 'problem',
},
defaultOptions: [],
create(context) {
// Build the set of valid pages. In order, we'll try to get this from:
//
// 1. The DOCSITE_LIST environment variable, which should be a newline
// separated list of pages, as outputted by `docsite ls`.
// 2. The docsiteList rule option, which should be an array of pages.
//
// If neither of these are set, this rule will silently pass, so as not to
// require docsite to be run when a user wants to run eslint in general.
const pages = new Set()
if (process.env.DOCSITE_LIST) {
process.env.DOCSITE_LIST.split('\n').forEach(page => {
return pages.add(page)
})
} else if (context.options.length > 0) {
context.options[0].docsiteList.forEach(page => {
return pages.add(page)
})
}

// No pages were provided, so we'll return an empty object and do nothing.
if (pages.size === 0) {
return {}
}

// Return the object that will install the listeners we want. In this case,
// we only need to look at JSX opening elements.
//
// Note that we could use AST selectors below, but the structure of the AST
// makes that tricky: the identifer (Link or a) and attribute (to or href)
// we use to identify an element of interest are siblings, so we'd probably
// have to select on the identifier and have some ugly traversal code below
// to check the attribute. It feels cleaner to do it this way with the
// opening element as the context.
return {
JSXOpeningElement: node => {
// Figure out what kind of element we have and therefore what attribute
// we'd want to look for.
let attributeName: string

if (node.name.type === AST_NODE_TYPES.JSXIdentifier) {
if (node.name.name === 'Link') {
attributeName = 'to'
} else if (node.name.name === 'a') {
attributeName = 'href'
}
} else {
// Anything that's not a link is uninteresting.
return
}

// Go find the link target in the attribute array.
const target = node.attributes.reduce<Literal['value']>((target, attribute) => {
return (
target ||
(attribute.type === AST_NODE_TYPES.JSXAttribute &&
attribute.name &&
attribute.name.name === attributeName &&
attribute.value?.type === AST_NODE_TYPES.Literal
? attribute.value.value
: null)
)
}, null)

// Make sure the target points to a help link; if not, we don't need to
// go any further.
if (typeof target !== 'string' || !target.startsWith('/help/')) {
return
}

// Strip off the /help/ prefix, any anchor, and any trailing slash, then
// look up the resultant page in the pages set, bearing in mind that it
// might point to a directory and we also need to look for any index
// page that might exist.
const destination = target.slice(6).split('#')[0].replace(/\/+$/, '')

if (!pages.has(destination + '.md') && !pages.has(destination + '/index.md')) {
context.report({
node,
messageId: 'invalidHelpLink',
data: { destination },
})
}
},
}
},
})
1 change: 1 addition & 0 deletions packages/eslint-plugin/src/rules/check-help-links/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './check-help-links'
4 changes: 3 additions & 1 deletion packages/eslint-plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// This file is used by `scripts/generate-configs.ts` for rules extraction.
/* eslint-disable import/no-default-export */
import { checkHelpLinks } from './check-help-links'
import { useButtonComponent } from './use-button-component'

// eslint-disable-next-line import/no-default-export
export default {
'use-button-component': useButtonComponent,
'check-help-links': checkHelpLinks,
}

0 comments on commit a72a1c3

Please sign in to comment.