Skip to content

Commit 4a44877

Browse files
JohnVickeJosh-CenaViktor Malmedalslorber
authored
feat: add eslint plugin no-html-links (#8156)
Co-authored-by: Joshua Chen <[email protected]> Co-authored-by: Viktor Malmedal <[email protected]> Co-authored-by: sebastienlorber <[email protected]> Co-authored-by: Sébastien Lorber <[email protected]>
1 parent 81f30dd commit 4a44877

File tree

23 files changed

+291
-67
lines changed

23 files changed

+291
-67
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ module.exports = {
374374
// locals must be justified with a disable comment.
375375
'@typescript-eslint/no-unused-vars': [ERROR, {ignoreRestSiblings: true}],
376376
'@typescript-eslint/prefer-optional-chain': ERROR,
377+
'@docusaurus/no-html-links': ERROR,
377378
'@docusaurus/no-untranslated-text': [
378379
WARNING,
379380
{

packages/docusaurus-theme-classic/src/theme/EditThisPage/index.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,19 @@
88
import React from 'react';
99
import Translate from '@docusaurus/Translate';
1010
import {ThemeClassNames} from '@docusaurus/theme-common';
11+
import Link from '@docusaurus/Link';
1112
import IconEdit from '@theme/Icon/Edit';
1213
import type {Props} from '@theme/EditThisPage';
1314

1415
export default function EditThisPage({editUrl}: Props): JSX.Element {
1516
return (
16-
<a
17-
href={editUrl}
18-
target="_blank"
19-
rel="noreferrer noopener"
20-
className={ThemeClassNames.common.editThisPage}>
17+
<Link to={editUrl} className={ThemeClassNames.common.editThisPage}>
2118
<IconEdit />
2219
<Translate
2320
id="theme.common.editThisPage"
2421
description="The link label to edit the current page">
2522
Edit this page
2623
</Translate>
27-
</a>
24+
</Link>
2825
);
2926
}

packages/docusaurus-theme-classic/src/theme/Heading/index.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import React from 'react';
99
import clsx from 'clsx';
1010
import {translate} from '@docusaurus/Translate';
1111
import {useThemeConfig} from '@docusaurus/theme-common';
12+
import Link from '@docusaurus/Link';
1213
import type {Props} from '@theme/Heading';
1314

1415
import styles from './styles.module.css';
@@ -34,16 +35,16 @@ export default function Heading({as: As, id, ...props}: Props): JSX.Element {
3435
)}
3536
id={id}>
3637
{props.children}
37-
<a
38+
<Link
3839
className="hash-link"
39-
href={`#${id}`}
40+
to={`#${id}`}
4041
title={translate({
4142
id: 'theme.common.headingLinkTitle',
4243
message: 'Direct link to heading',
4344
description: 'Title for link to heading',
4445
})}>
4546
&#8203;
46-
</a>
47+
</Link>
4748
</As>
4849
);
4950
}

packages/docusaurus-theme-classic/src/theme/SkipToContent/index.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
import React from 'react';
99
import {SkipToContentLink} from '@docusaurus/theme-common';
10-
1110
import styles from './styles.module.css';
1211

1312
export default function SkipToContent(): JSX.Element {

packages/docusaurus-theme-classic/src/theme/TOCItems/Tree.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import React from 'react';
9+
import Link from '@docusaurus/Link';
910
import type {Props} from '@theme/TOCItems/Tree';
1011

1112
// Recursive component rendering the toc tree
@@ -22,12 +23,10 @@ function TOCItemTree({
2223
<ul className={isChild ? undefined : className}>
2324
{toc.map((heading) => (
2425
<li key={heading.id}>
25-
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
26-
<a
27-
href={`#${heading.id}`}
26+
<Link
27+
to={`#${heading.id}`}
2828
className={linkClassName ?? undefined}
2929
// Developer provided the HTML, so assume it's safe.
30-
// eslint-disable-next-line react/no-danger
3130
dangerouslySetInnerHTML={{__html: heading.value}}
3231
/>
3332
<TOCItemTree

packages/docusaurus-theme-common/src/utils/skipToContentUtils.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ export function SkipToContentLink(props: SkipToContentLinkProps): JSX.Element {
9090
ref={containerRef}
9191
role="region"
9292
aria-label={DefaultSkipToContentLabel}>
93+
{/* eslint-disable-next-line @docusaurus/no-html-links */}
9394
<a
9495
{...props}
9596
// Note this is a fallback href in case JS is disabled

packages/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -426,10 +426,8 @@ function SearchPageContent(): JSX.Element {
426426
'text--right',
427427
styles.searchLogoColumn,
428428
)}>
429-
<a
430-
target="_blank"
431-
rel="noopener noreferrer"
432-
href="https://www.algolia.com/"
429+
<Link
430+
to="https://www.algolia.com/"
433431
aria-label={translate({
434432
id: 'theme.SearchPage.algoliaLabel',
435433
message: 'Search by Algolia',
@@ -451,7 +449,7 @@ function SearchPageContent(): JSX.Element {
451449
/>
452450
</g>
453451
</svg>
454-
</a>
452+
</Link>
455453
</div>
456454
</div>
457455

packages/docusaurus/src/client/exports/Link.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ function Link(
148148
}
149149

150150
return isRegularHtmlLink ? (
151-
// eslint-disable-next-line jsx-a11y/anchor-has-content
151+
// eslint-disable-next-line jsx-a11y/anchor-has-content, @docusaurus/no-html-links
152152
<a
153153
ref={innerRef}
154154
href={targetLink}

packages/eslint-plugin/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@ export = {
1414
plugins: ['@docusaurus'],
1515
rules: {
1616
'@docusaurus/string-literal-i18n-messages': 'error',
17+
'@docusaurus/no-html-links': 'warn',
1718
},
1819
},
1920
all: {
2021
plugins: ['@docusaurus'],
2122
rules: {
2223
'@docusaurus/string-literal-i18n-messages': 'error',
2324
'@docusaurus/no-untranslated-text': 'warn',
25+
'@docusaurus/no-html-links': 'warn',
2426
},
2527
},
2628
},
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import rule from '../no-html-links';
9+
import {RuleTester} from './testUtils';
10+
11+
const errorsJSX = [{messageId: 'link'}] as const;
12+
13+
const ruleTester = new RuleTester({
14+
parser: '@typescript-eslint/parser',
15+
parserOptions: {
16+
ecmaFeatures: {
17+
jsx: true,
18+
},
19+
},
20+
});
21+
22+
ruleTester.run('prefer-docusaurus-link', rule, {
23+
valid: [
24+
{
25+
code: '<Link to="/test">test</Link>',
26+
},
27+
{
28+
code: '<Link to="https://twitter.com/docusaurus">Twitter</Link>',
29+
},
30+
{
31+
code: '<a href="https://twitter.com/docusaurus">Twitter</a>',
32+
options: [{ignoreFullyResolved: true}],
33+
},
34+
{
35+
code: '<a href={`https://twitter.com/docusaurus`}>Twitter</a>',
36+
options: [{ignoreFullyResolved: true}],
37+
},
38+
{
39+
code: '<a href="mailto:[email protected]">Contact</a> ',
40+
options: [{ignoreFullyResolved: true}],
41+
},
42+
{
43+
code: '<a href="tel:123456789">Call</a>',
44+
options: [{ignoreFullyResolved: true}],
45+
},
46+
],
47+
invalid: [
48+
{
49+
code: '<a href="/test">test</a>',
50+
errors: errorsJSX,
51+
},
52+
{
53+
code: '<a href="https://twitter.com/docusaurus" target="_blank">test</a>',
54+
errors: errorsJSX,
55+
},
56+
{
57+
code: '<a href="https://twitter.com/docusaurus" target="_blank" rel="noopener noreferrer">test</a>',
58+
errors: errorsJSX,
59+
},
60+
{
61+
code: '<a href="mailto:[email protected]">Contact</a> ',
62+
errors: errorsJSX,
63+
},
64+
{
65+
code: '<a href="tel:123456789">Call</a>',
66+
errors: errorsJSX,
67+
},
68+
{
69+
code: '<a href={``}>Twitter</a>',
70+
errors: errorsJSX,
71+
},
72+
{
73+
code: '<a href={`https://www.twitter.com/docusaurus`}>Twitter</a>',
74+
errors: errorsJSX,
75+
},
76+
{
77+
code: '<a href="www.twitter.com/docusaurus">Twitter</a>',
78+
options: [{ignoreFullyResolved: true}],
79+
errors: errorsJSX,
80+
},
81+
{
82+
// TODO we might want to make this test pass
83+
// Can template literals be statically pre-evaluated? (Babel can do it)
84+
// eslint-disable-next-line no-template-curly-in-string
85+
code: '<a href={`https://twitter.com/${"docu" + "saurus"} ${"rex"}`}>Twitter</a>',
86+
options: [{ignoreFullyResolved: true}],
87+
errors: errorsJSX,
88+
},
89+
],
90+
});

packages/eslint-plugin/src/rules/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8+
import noHtmlLinks from './no-html-links';
89
import noUntranslatedText from './no-untranslated-text';
910
import stringLiteralI18nMessages from './string-literal-i18n-messages';
1011

1112
export default {
1213
'no-untranslated-text': noUntranslatedText,
1314
'string-literal-i18n-messages': stringLiteralI18nMessages,
15+
'no-html-links': noHtmlLinks,
1416
};
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {createRule} from '../util';
9+
import type {TSESTree} from '@typescript-eslint/types/dist/ts-estree';
10+
11+
const docsUrl = 'https://docusaurus.io/docs/docusaurus-core#link';
12+
13+
type Options = [
14+
{
15+
ignoreFullyResolved: boolean;
16+
},
17+
];
18+
19+
type MessageIds = 'link';
20+
21+
function isFullyResolvedUrl(urlString: string): boolean {
22+
try {
23+
// href gets coerced to a string when it gets rendered anyway
24+
const url = new URL(String(urlString));
25+
if (url.protocol) {
26+
return true;
27+
}
28+
} catch (e) {}
29+
return false;
30+
}
31+
32+
export default createRule<Options, MessageIds>({
33+
name: 'no-html-links',
34+
meta: {
35+
type: 'problem',
36+
docs: {
37+
description: 'enforce using Docusaurus Link component instead of <a> tag',
38+
recommended: false,
39+
},
40+
schema: [
41+
{
42+
type: 'object',
43+
properties: {
44+
ignoreFullyResolved: {
45+
type: 'boolean',
46+
},
47+
},
48+
additionalProperties: false,
49+
},
50+
],
51+
messages: {
52+
link: `Do not use an \`<a>\` element to navigate. Use the \`<Link />\` component from \`@docusaurus/Link\` instead. See: ${docsUrl}`,
53+
},
54+
},
55+
defaultOptions: [
56+
{
57+
ignoreFullyResolved: false,
58+
},
59+
],
60+
61+
create(context, [options]) {
62+
const {ignoreFullyResolved} = options;
63+
64+
return {
65+
JSXOpeningElement(node) {
66+
if ((node.name as TSESTree.JSXIdentifier).name !== 'a') {
67+
return;
68+
}
69+
70+
if (ignoreFullyResolved) {
71+
const hrefAttr = node.attributes.find(
72+
(attr): attr is TSESTree.JSXAttribute =>
73+
attr.type === 'JSXAttribute' && attr.name.name === 'href',
74+
);
75+
76+
if (hrefAttr?.value?.type === 'Literal') {
77+
if (isFullyResolvedUrl(String(hrefAttr.value.value))) {
78+
return;
79+
}
80+
}
81+
if (hrefAttr?.value?.type === 'JSXExpressionContainer') {
82+
const container: TSESTree.JSXExpressionContainer = hrefAttr.value;
83+
const {expression} = container;
84+
if (expression.type === 'TemplateLiteral') {
85+
// Simple static string template literals
86+
if (
87+
expression.expressions.length === 0 &&
88+
expression.quasis.length === 1 &&
89+
expression.quasis[0]?.type === 'TemplateElement' &&
90+
isFullyResolvedUrl(String(expression.quasis[0].value.raw))
91+
) {
92+
return;
93+
}
94+
// TODO add more complex TemplateLiteral cases here
95+
}
96+
}
97+
}
98+
99+
context.report({node, messageId: 'link'});
100+
},
101+
};
102+
},
103+
});

website/_dogfooding/_pages tests/hydration-tests.tsx

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,17 @@
66
*/
77

88
import React from 'react';
9+
import Link from '@docusaurus/Link';
910
import Layout from '@theme/Layout';
1011

1112
// Repro for hydration issue https://github.com/facebook/docusaurus/issues/5617
1213
function BuggyText() {
1314
return (
1415
<span>
15-
Built using the{' '}
16-
<a href="https://www.electronjs.org/" target="_blank" rel="noreferrer">
17-
Electron
18-
</a>{' '}
19-
, based on{' '}
20-
<a href="https://www.chromium.org/" target="_blank" rel="noreferrer">
21-
Chromium
22-
</a>
23-
, and written using{' '}
24-
<a
25-
href="https://www.typescriptlang.org/"
26-
target="_blank"
27-
rel="noreferrer">
28-
TypeScript
29-
</a>{' '}
30-
, Xplorer promises you an unprecedented experience.
16+
Built using the <Link to="https://www.electronjs.org/">Electron</Link> ,
17+
based on <Link to="https://www.chromium.org/">Chromium</Link>, and written
18+
using <Link to="https://www.typescriptlang.org/">TypeScript</Link> ,
19+
Xplorer promises you an unprecedented experience.
3120
</span>
3221
);
3322
}

website/docs/api/misc/eslint-plugin/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ For more fine-grained control, you can also enable the plugin manually and confi
5252
| --- | --- | --- |
5353
| [`@docusaurus/no-untranslated-text`](./no-untranslated-text.md) | Enforce text labels in JSX to be wrapped by translate calls | |
5454
| [`@docusaurus/string-literal-i18n-messages`](./string-literal-i18n-messages.md) | Enforce translate APIs to be called on plain text labels ||
55+
| [`@docusaurus/no-html-links`](./no-html-links.md) | Ensures @docusaurus/Link is used instead of `<a>` tags ||
5556

5657
✅ = recommended
5758

0 commit comments

Comments
 (0)