@planningcenter/eslint-plugin-tapestry 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CODEOWNERS +1 -0
- package/.github/workflows/linting.yml +26 -0
- package/.github/workflows/pco-release-create-pr.yml +23 -0
- package/.github/workflows/pco-release-create-prerelease.yml +27 -0
- package/.github/workflows/pco-release-create-release-on-merge.yml +19 -0
- package/.github/workflows/pco-release-require-changelog-update.yml +22 -0
- package/.github/workflows/pco-release-sync-release-by-label.yml +19 -0
- package/.github/workflows/revert.yml +21 -0
- package/.github/workflows/tests.yml +22 -0
- package/README.md +110 -0
- package/docs/rules/valid-href.md +82 -0
- package/index.js +27 -0
- package/jest.config.js +8 -0
- package/package.json +34 -0
- package/rules/valid-href.js +117 -0
- package/tests/valid-href.test.js +208 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
* @planningcenter/design-engineering
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
name: Linting
|
|
2
|
+
|
|
3
|
+
on: [pull_request]
|
|
4
|
+
|
|
5
|
+
jobs:
|
|
6
|
+
linting:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
checks: write
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-node@v4
|
|
14
|
+
with:
|
|
15
|
+
node-version: "20"
|
|
16
|
+
cache: "yarn"
|
|
17
|
+
- run: yarn install
|
|
18
|
+
- uses: planningcenter/balto-typescript@v1.2.0
|
|
19
|
+
env:
|
|
20
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
21
|
+
with:
|
|
22
|
+
conclusionLevel: "failure"
|
|
23
|
+
- uses: reviewdog/action-eslint@v1
|
|
24
|
+
with:
|
|
25
|
+
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
26
|
+
reporter: github-pr-check
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: PCO-Release - Release Automation
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
pull-requests: write
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
release-automation:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: "20"
|
|
20
|
+
cache: "yarn"
|
|
21
|
+
- uses: planningcenter/pco-release-action/release-by-pr@v1
|
|
22
|
+
with:
|
|
23
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Create Pre-Release
|
|
2
|
+
on:
|
|
3
|
+
issue_comment:
|
|
4
|
+
types: [created]
|
|
5
|
+
|
|
6
|
+
jobs:
|
|
7
|
+
create-rc-release:
|
|
8
|
+
if: github.event.issue.pull_request && contains(github.event.comment.body, '@pco-release rc')
|
|
9
|
+
uses: planningcenter/pco-release-action/.github/workflows/release-candidate.yml@v1
|
|
10
|
+
secrets: inherit
|
|
11
|
+
permissions:
|
|
12
|
+
contents: write
|
|
13
|
+
pull-requests: write
|
|
14
|
+
packages: write
|
|
15
|
+
with:
|
|
16
|
+
only: accounts,calendar,check-ins,giving,groups,home,people,registrations,services
|
|
17
|
+
|
|
18
|
+
create-qa-release:
|
|
19
|
+
if: github.event.issue.pull_request && contains(github.event.comment.body, '@pco-release qa')
|
|
20
|
+
permissions:
|
|
21
|
+
contents: write
|
|
22
|
+
pull-requests: write
|
|
23
|
+
packages: write
|
|
24
|
+
uses: planningcenter/pco-release-action/.github/workflows/qa-release.yml@v1
|
|
25
|
+
secrets: inherit
|
|
26
|
+
with:
|
|
27
|
+
only: accounts,calendar,check-ins,giving,groups,home,people,registrations,services
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: PCO-Release - Create Release on Merge
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [closed]
|
|
6
|
+
branches:
|
|
7
|
+
- main
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
create-release:
|
|
11
|
+
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'pco-release-pending')
|
|
12
|
+
permissions:
|
|
13
|
+
contents: write
|
|
14
|
+
pull-requests: write
|
|
15
|
+
packages: write
|
|
16
|
+
uses: planningcenter/pco-release-action/.github/workflows/release.yml@v1
|
|
17
|
+
secrets: inherit
|
|
18
|
+
with:
|
|
19
|
+
pr-number: ${{ github.event.pull_request.number }}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: PCO-Release - Require Changelog Update
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
require-changelog-update:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
pull-requests: read
|
|
12
|
+
contents: read
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- id: changed-files
|
|
16
|
+
uses: tj-actions/changed-files@v46
|
|
17
|
+
with:
|
|
18
|
+
files: CHANGELOG.md
|
|
19
|
+
- if: steps.changed-files.outputs.any_changed == 'false'
|
|
20
|
+
run: |
|
|
21
|
+
echo "Pull Requests require an update to the CHANGELOG.md file."
|
|
22
|
+
exit 1
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
name: PCO-Release - Sync With Labels
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
types: [labeled]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: write
|
|
9
|
+
pull-requests: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
sync-with-labels:
|
|
13
|
+
if: ${{ github.event.pull_request.head.ref == 'pco-release--internal' && (github.event.label.name == 'pco-release-patch' || github.event.label.name == 'pco-release-minor' || github.event.label.name == 'pco-release-major') }}
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: planningcenter/pco-release-action/sync-with-labels@v1
|
|
18
|
+
with:
|
|
19
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Revert
|
|
2
|
+
on:
|
|
3
|
+
workflow_dispatch:
|
|
4
|
+
inputs:
|
|
5
|
+
release-tag:
|
|
6
|
+
description: "Version to deploy"
|
|
7
|
+
required: true
|
|
8
|
+
pr-number:
|
|
9
|
+
description: "Pull Request number to post results of results"
|
|
10
|
+
required: true
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
revert:
|
|
14
|
+
permissions:
|
|
15
|
+
contents: write
|
|
16
|
+
pull-requests: write
|
|
17
|
+
secrets: inherit
|
|
18
|
+
uses: planningcenter/pco-release-action/.github/workflows/revert.yml@v1
|
|
19
|
+
with:
|
|
20
|
+
release-tag: ${{ inputs.release-tag }}
|
|
21
|
+
pr-number: ${{ inputs.pr-number }}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- "**"
|
|
7
|
+
- "!main"
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
run-tests:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- name: Checkout
|
|
14
|
+
uses: actions/checkout@v4
|
|
15
|
+
- name: Setup Node
|
|
16
|
+
uses: actions/setup-node@v4
|
|
17
|
+
with:
|
|
18
|
+
node-version: "20"
|
|
19
|
+
cache: "yarn"
|
|
20
|
+
- run: yarn install
|
|
21
|
+
- name: Run eslint
|
|
22
|
+
run: yarn lint
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# eslint-plugin-tapestry
|
|
2
|
+
|
|
3
|
+
ESLint plugin for Tapestry components to encourage best practices and accessibility.
|
|
4
|
+
|
|
5
|
+
> This plugin only tests components provided by Tapestry; it will ignore native HTML elements, Tapestry-React, etc.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
yarn add @planningcenter/eslint-plugin-tapestry --dev
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
### Recommended Configuration
|
|
16
|
+
|
|
17
|
+
#### Flat Config
|
|
18
|
+
```javascript
|
|
19
|
+
import tapestry from '@planningcenter/eslint-plugin-tapestry';
|
|
20
|
+
|
|
21
|
+
export default [
|
|
22
|
+
tapestry.configs.recommended,
|
|
23
|
+
];
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
#### Legacy Config
|
|
27
|
+
```json
|
|
28
|
+
"plugins": [
|
|
29
|
+
"tapestry"
|
|
30
|
+
],
|
|
31
|
+
...
|
|
32
|
+
{
|
|
33
|
+
"extends": ["plugin:tapestry/recommended"]
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### ESLint Flat Config (eslint.config.js)
|
|
38
|
+
|
|
39
|
+
```javascript
|
|
40
|
+
import tapestry from '@planningcenter/eslint-plugin-tapestry';
|
|
41
|
+
|
|
42
|
+
export default [
|
|
43
|
+
{
|
|
44
|
+
plugins: {
|
|
45
|
+
tapestry,
|
|
46
|
+
},
|
|
47
|
+
rules: {
|
|
48
|
+
'tapestry/valid-href': 'error',
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Legacy Config (.eslintrc.json)
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"plugins": ["tapestry"],
|
|
59
|
+
"rules": {
|
|
60
|
+
"tapestry/valid-href": "error"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Rules
|
|
66
|
+
|
|
67
|
+
| Rule | Category | Description |
|
|
68
|
+
| ---- | -------- | ----------- |
|
|
69
|
+
| [valid-href](docs/rules/valid-href.md) | Accessibility | Ensures that `BaseLink`, `Link`, and `IconLink` components have valid `href` values |
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
yarn test # Run tests
|
|
75
|
+
yarn test --watch # Run tests in watch mode
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Setup for initial release testing
|
|
79
|
+
|
|
80
|
+
### Tapestry
|
|
81
|
+
1. Open a terminal to this repo and run `yarn && yalc publish`
|
|
82
|
+
2. Open a new terminal to Tapestry and create a temp test branch from `main`
|
|
83
|
+
3. Run `yalc add @planningcenter/eslint-plugin-tapestry@1.0.0 --dev`
|
|
84
|
+
5. In `eslint.config.mjs`, add or modify
|
|
85
|
+
* `import tapestry from "@planningcenter/eslint-plugin-tapestry"`
|
|
86
|
+
* `plugins: {tapestry}`
|
|
87
|
+
* `rules: {"tapestry/valid-href": "error"}`
|
|
88
|
+
6. Restart the ESLint server (`cmd` + `shift` + `P` > `ESLint: Restart Eslint Server`)
|
|
89
|
+
7. Open `packages/tapestry/src/components/link/index.stories.tsx` and notice that the `tapestry-react` `Link` isn't reporting any lint errors (no red squiggle lines)
|
|
90
|
+
8. Comment out the `Link` import and add a line to `import { Link } from "@planningcenter/tapestry"`
|
|
91
|
+
* You should get red squiggle lines on the `Link`
|
|
92
|
+
* Test out different variations of `href` to see the lint errors
|
|
93
|
+
9. In your terminal, run `yarn lint`, notice you'll see `tapestry/valid-href` errors
|
|
94
|
+
|
|
95
|
+
### Groups
|
|
96
|
+
|
|
97
|
+
1. Open a terminal to this repo and run `yarn && yalc publish`
|
|
98
|
+
2. Open a new terminal to Groups and create a temp test branch from `main`
|
|
99
|
+
3. Run `yalc add @planningcenter/eslint-plugin-tapestry@1.0.0 --dev`
|
|
100
|
+
5. In `eslint.config.mjs`, add or modify
|
|
101
|
+
* `import tapestry from "@planningcenter/eslint-plugin-tapestry"`
|
|
102
|
+
* `plugins: {tapestry}`
|
|
103
|
+
* `rules: {"tapestry/valid-href": "error"}`
|
|
104
|
+
* `files: ["**/*.ts", "**/*.tsx", "**/*.jsx", "**/*.js"],`
|
|
105
|
+
6. Restart the ESLint server (`cmd` + `shift` + `P` > `ESLint: Restart Eslint Server`)
|
|
106
|
+
7. Open `app/javascript/staff_only/staff_only_nav_links.tsx` and notice that the `tapestry-react` `Link` isn't reporting any lint errors (no red squiggle lines)
|
|
107
|
+
8. Comment out the `Link` import and add a line to `import { Link } from "@planningcenter/tapestry"`
|
|
108
|
+
* You should get red squiggle lines on the `Link`
|
|
109
|
+
* Test out different variations of `href` to see the lint errors
|
|
110
|
+
9. In your terminal, run `yarn lint`, notice you'll see `tapestry/valid-href` errors
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# valid-href
|
|
2
|
+
|
|
3
|
+
Ensures that `BaseLink`, `Link`, `IconLink` components have valid `href` values.
|
|
4
|
+
|
|
5
|
+
## Rule Details
|
|
6
|
+
|
|
7
|
+
This rule enforces that Tapestry link components (`BaseLink`, `Link`, and `IconLink`) have valid `href` values to ensure proper accessibility support for keyboard navigation and screen readers.
|
|
8
|
+
|
|
9
|
+
### Requirements
|
|
10
|
+
|
|
11
|
+
- ✅ `href` must be specified as a direct prop (not in spread props)
|
|
12
|
+
- ✅ `href` cannot be empty string `""`
|
|
13
|
+
- ✅ `href` cannot be just a hash `"#"`
|
|
14
|
+
- ✅ `href` cannot contain `javascript`
|
|
15
|
+
|
|
16
|
+
## Examples
|
|
17
|
+
|
|
18
|
+
### ❌ Invalid
|
|
19
|
+
|
|
20
|
+
```jsx
|
|
21
|
+
// Missing href
|
|
22
|
+
<BaseLink>Click me</BaseLink>
|
|
23
|
+
|
|
24
|
+
// Invalid href values
|
|
25
|
+
<Link href="">Empty</Link>
|
|
26
|
+
<Link href="#">Hash only</Link>
|
|
27
|
+
<Link href="javascript:void(0)">JS void</Link>
|
|
28
|
+
|
|
29
|
+
// href in spread props
|
|
30
|
+
<IconLink {...props}>Icon</IconLink>
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### ✅ Valid
|
|
34
|
+
|
|
35
|
+
```jsx
|
|
36
|
+
<BaseLink href="/page">Click me</BaseLink>
|
|
37
|
+
<Link href="https://example.com">External</Link>
|
|
38
|
+
<IconLink href="/dashboard">Dashboard</IconLink>
|
|
39
|
+
<Link href="mailto:test@example.com">Email</Link>
|
|
40
|
+
<IconLink href="tel:555-1234">Call</IconLink>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
This rule is enabled by default in the recommended configuration.
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"rules": {
|
|
50
|
+
"tapestry/valid-href": "error"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Background
|
|
56
|
+
|
|
57
|
+
In HTML and React, the `href` attribute in anchor tags is defined as either a `string` or `undefined` by default. This flexibility, while convenient, can lead to accessibility and user experience issues when improper values are used. This RFC addresses why enforcing proper `href` values is crucial.
|
|
58
|
+
|
|
59
|
+
The `href` attribute is a critical component of HTML anchor tags for several reasons:
|
|
60
|
+
|
|
61
|
+
### Accessibility Considerations
|
|
62
|
+
|
|
63
|
+
- **Screen Reader Navigation:** Screen readers announce links and their destinations to users. Without a valid `href`, screen readers might announce it as "link to nowhere" or skip it entirely.
|
|
64
|
+
- **Keyboard Navigation:** The `href` attribute makes links naturally focusable and interactive with keyboard navigation. Links without proper hrefs break this expected behavior.
|
|
65
|
+
- **Semantic Clarity:** A proper `href` communicates the purpose of the link to assistive technologies, helping users understand where they'll go when activating it.
|
|
66
|
+
|
|
67
|
+
### User Experience & Performance
|
|
68
|
+
|
|
69
|
+
- **Expected Behavior:** Users expect clicking a link to navigate somewhere. Empty or JavaScript-based pseudo-links (`href="#"` or `href="javascript:void(0)"`) frustrate this expectation.
|
|
70
|
+
- **Browser Optimizations:** Browsers can pre-fetch content from valid URLs, improving page transition speed and responsiveness.
|
|
71
|
+
- **Default Link Behaviors:** Proper `href`s enable right-click context menus, "open in new tab," and other browser features users rely on.
|
|
72
|
+
|
|
73
|
+
### Technical Benefits
|
|
74
|
+
|
|
75
|
+
- **Analytics:** Most analytics tools track link clicks based on href values, helping understand user navigation patterns.
|
|
76
|
+
- **History Management:** Real URLs properly update browser history, enabling back/forward navigation and bookmarking.
|
|
77
|
+
- **Testing:** Valid `href`s make automated testing more reliable, as tools can verify navigation paths.
|
|
78
|
+
|
|
79
|
+
## Further Reading
|
|
80
|
+
|
|
81
|
+
- [Web Content Accessibility Guidelines (WCAG) on Links](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html)
|
|
82
|
+
- [MDN: HTMLAnchorElement.href](https://developer.mozilla.org/en-US/docs/Web/API/HTMLAnchorElement/href)
|
package/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const validHrefRule = require('./rules/valid-href');
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
rules: {
|
|
5
|
+
'valid-href': validHrefRule,
|
|
6
|
+
},
|
|
7
|
+
configs: {
|
|
8
|
+
recommended: {
|
|
9
|
+
plugins: ['tapestry'],
|
|
10
|
+
rules: {
|
|
11
|
+
'tapestry/valid-href': 'error',
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
// Legacy configuration for .eslintrc.json
|
|
15
|
+
legacy: {
|
|
16
|
+
plugins: ['tapestry'],
|
|
17
|
+
rules: {
|
|
18
|
+
'tapestry/valid-href': 'error',
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
// Flat config support for eslint.config.js
|
|
23
|
+
meta: {
|
|
24
|
+
name: 'eslint-plugin-tapestry',
|
|
25
|
+
version: '1.0.0',
|
|
26
|
+
},
|
|
27
|
+
};
|
package/jest.config.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@planningcenter/eslint-plugin-tapestry",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "ESLint plugin for Tapestry React components",
|
|
5
|
+
"repository": "https://github.com/planningcenter/eslint-plugin-tapestry.git",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "jest",
|
|
9
|
+
"test:watch": "jest --watch"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"eslint",
|
|
13
|
+
"eslintplugin",
|
|
14
|
+
"eslint-plugin",
|
|
15
|
+
"tapestry",
|
|
16
|
+
"react",
|
|
17
|
+
"accessibility"
|
|
18
|
+
],
|
|
19
|
+
"author": "Planning Center",
|
|
20
|
+
"license": "UNLICENSED",
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@babel/core": "^7.23.0",
|
|
23
|
+
"@babel/preset-env": "^7.23.0",
|
|
24
|
+
"@babel/preset-react": "^7.22.0",
|
|
25
|
+
"eslint": "^8.50.0",
|
|
26
|
+
"jest": "^29.7.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"eslint": ">=8.0.0"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
meta: {
|
|
3
|
+
type: 'problem',
|
|
4
|
+
docs: {
|
|
5
|
+
description: 'Ensure BaseLink, Link, IconLink components have valid href values',
|
|
6
|
+
category: 'Accessibility',
|
|
7
|
+
recommended: true,
|
|
8
|
+
url: 'https://github.com/planningcenter/eslint-plugin-tapestry/blob/main/docs/rules/valid-href.md',
|
|
9
|
+
},
|
|
10
|
+
fixable: null,
|
|
11
|
+
schema: [],
|
|
12
|
+
messages: {
|
|
13
|
+
missingHref: 'Tapestry {{componentName}} component must have an href prop',
|
|
14
|
+
invalidHref: 'Tapestry {{componentName}} component href cannot be "{{value}}" - use a valid URL, path, or Button component instead.',
|
|
15
|
+
hrefInSpread: 'Tapestry {{componentName}} component href must be specified as a direct prop, not in spread props',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
create(context) {
|
|
20
|
+
const targetComponents = new Set(['BaseLink', 'Link', 'IconLink']);
|
|
21
|
+
const invalidHrefValues = new Set(['', '#']);
|
|
22
|
+
const tapestryImports = new Set();
|
|
23
|
+
|
|
24
|
+
function isJavaScriptUrl(value) {
|
|
25
|
+
if (typeof value !== 'string') return false;
|
|
26
|
+
return value.toLowerCase().includes('javascript');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isTapestryComponent(componentName) {
|
|
30
|
+
// Only check Tapestry components if imported from current Tapestry
|
|
31
|
+
return tapestryImports.has(componentName);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function checkHrefProp(node, componentName) {
|
|
35
|
+
let hasDirectHref = false;
|
|
36
|
+
let hasSpreadProps = false;
|
|
37
|
+
let hrefValue = null;
|
|
38
|
+
|
|
39
|
+
// Check for direct href prop
|
|
40
|
+
for (const prop of node.attributes) {
|
|
41
|
+
if (prop.type === 'JSXAttribute' && prop.name.name === 'href') {
|
|
42
|
+
hasDirectHref = true;
|
|
43
|
+
|
|
44
|
+
// Extract href value for validation
|
|
45
|
+
if (prop.value) {
|
|
46
|
+
if (prop.value.type === 'Literal') {
|
|
47
|
+
hrefValue = prop.value.value;
|
|
48
|
+
} else if (prop.value.type === 'JSXExpressionContainer') {
|
|
49
|
+
if (prop.value.expression.type === 'Literal') {
|
|
50
|
+
hrefValue = prop.value.expression.value;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
} else if (prop.type === 'JSXSpreadAttribute') {
|
|
55
|
+
hasSpreadProps = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Report missing href
|
|
60
|
+
if (!hasDirectHref && !hasSpreadProps) {
|
|
61
|
+
context.report({
|
|
62
|
+
node,
|
|
63
|
+
messageId: 'missingHref',
|
|
64
|
+
data: { componentName },
|
|
65
|
+
});
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Report href in spread props
|
|
70
|
+
if (!hasDirectHref && hasSpreadProps) {
|
|
71
|
+
context.report({
|
|
72
|
+
node,
|
|
73
|
+
messageId: 'hrefInSpread',
|
|
74
|
+
data: { componentName },
|
|
75
|
+
});
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Validate href value if we have one
|
|
80
|
+
if (hasDirectHref && hrefValue !== null) {
|
|
81
|
+
if (invalidHrefValues.has(hrefValue) || isJavaScriptUrl(hrefValue)) {
|
|
82
|
+
context.report({
|
|
83
|
+
node,
|
|
84
|
+
messageId: 'invalidHref',
|
|
85
|
+
data: {
|
|
86
|
+
componentName,
|
|
87
|
+
value: hrefValue
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
ImportDeclaration(node) {
|
|
96
|
+
// Track imports from @planningcenter/tapestry
|
|
97
|
+
if (node.source.value === '@planningcenter/tapestry') {
|
|
98
|
+
node.specifiers.forEach(spec => {
|
|
99
|
+
if (spec.type === 'ImportSpecifier') {
|
|
100
|
+
tapestryImports.add(spec.imported.name);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
|
|
106
|
+
JSXOpeningElement(node) {
|
|
107
|
+
if (node.name.type === 'JSXIdentifier') {
|
|
108
|
+
const componentName = node.name.name;
|
|
109
|
+
|
|
110
|
+
if (targetComponents.has(componentName) && isTapestryComponent(componentName)) {
|
|
111
|
+
checkHrefProp(node, componentName);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
},
|
|
117
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
const { RuleTester } = require('eslint');
|
|
2
|
+
const rule = require('../rules/valid-href');
|
|
3
|
+
|
|
4
|
+
const ruleTester = new RuleTester({
|
|
5
|
+
parserOptions: {
|
|
6
|
+
ecmaVersion: 2018,
|
|
7
|
+
sourceType: 'module',
|
|
8
|
+
ecmaFeatures: {
|
|
9
|
+
jsx: true,
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
ruleTester.run('valid-href', rule, {
|
|
15
|
+
valid: [
|
|
16
|
+
// Valid href values - Tapestry components with imports
|
|
17
|
+
{
|
|
18
|
+
code: `
|
|
19
|
+
import { BaseLink } from '@planningcenter/tapestry';
|
|
20
|
+
<BaseLink href="/page">Click me</BaseLink>
|
|
21
|
+
`,
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
code: `
|
|
25
|
+
import { Link } from '@planningcenter/tapestry';
|
|
26
|
+
<Link href="https://example.com">External</Link>
|
|
27
|
+
`,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
code: `
|
|
31
|
+
import { IconLink } from '@planningcenter/tapestry';
|
|
32
|
+
<IconLink href="/dashboard">Dashboard</IconLink>
|
|
33
|
+
`,
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
// Legacy components should be ignored (no @planningcenter/tapestry import)
|
|
37
|
+
{
|
|
38
|
+
code: `
|
|
39
|
+
import { Link } from 'tapestry-react';
|
|
40
|
+
<Link href="#">Legacy component ignored</Link>
|
|
41
|
+
`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
code: `
|
|
45
|
+
import { Link } from '@planningcenter/tapestry-react';
|
|
46
|
+
<Link href="#">Legacy component ignored</Link>
|
|
47
|
+
`,
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// Components that aren't target components should be ignored
|
|
51
|
+
'<Button>Click me</Button>',
|
|
52
|
+
'<CustomLink href="#">Custom component</CustomLink>',
|
|
53
|
+
|
|
54
|
+
// Dynamic href values (we can't validate these statically)
|
|
55
|
+
{
|
|
56
|
+
code: `
|
|
57
|
+
import { BaseLink, Link } from '@planningcenter/tapestry';
|
|
58
|
+
<div>
|
|
59
|
+
<BaseLink href={dynamicHref}>Dynamic</BaseLink>
|
|
60
|
+
<Link href={\`/user/\${userId}\`}>Template literal</Link>
|
|
61
|
+
</div>
|
|
62
|
+
`,
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
|
|
66
|
+
invalid: [
|
|
67
|
+
// Missing href - Tapestry components (with imports)
|
|
68
|
+
{
|
|
69
|
+
code: `
|
|
70
|
+
import { BaseLink } from '@planningcenter/tapestry';
|
|
71
|
+
<BaseLink>Click me</BaseLink>
|
|
72
|
+
`,
|
|
73
|
+
errors: [{
|
|
74
|
+
messageId: 'missingHref',
|
|
75
|
+
data: { componentName: 'BaseLink' },
|
|
76
|
+
}],
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
code: `
|
|
80
|
+
import { Link } from '@planningcenter/tapestry';
|
|
81
|
+
<Link>External</Link>
|
|
82
|
+
`,
|
|
83
|
+
errors: [{
|
|
84
|
+
messageId: 'missingHref',
|
|
85
|
+
data: { componentName: 'Link' },
|
|
86
|
+
}],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
code: `
|
|
90
|
+
import { IconLink } from '@planningcenter/tapestry';
|
|
91
|
+
<IconLink>Dashboard</IconLink>
|
|
92
|
+
`,
|
|
93
|
+
errors: [{
|
|
94
|
+
messageId: 'missingHref',
|
|
95
|
+
data: { componentName: 'IconLink' },
|
|
96
|
+
}],
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
// Empty href - Tapestry components
|
|
100
|
+
{
|
|
101
|
+
code: `
|
|
102
|
+
import { BaseLink } from '@planningcenter/tapestry';
|
|
103
|
+
<BaseLink href="">Empty</BaseLink>
|
|
104
|
+
`,
|
|
105
|
+
errors: [{
|
|
106
|
+
messageId: 'invalidHref',
|
|
107
|
+
data: { componentName: 'BaseLink', value: '' },
|
|
108
|
+
}],
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
code: `
|
|
112
|
+
import { Link } from '@planningcenter/tapestry';
|
|
113
|
+
<Link href={""}>Empty expression</Link>
|
|
114
|
+
`,
|
|
115
|
+
errors: [{
|
|
116
|
+
messageId: 'invalidHref',
|
|
117
|
+
data: { componentName: 'Link', value: '' },
|
|
118
|
+
}],
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
// Hash only - Tapestry components
|
|
122
|
+
{
|
|
123
|
+
code: `
|
|
124
|
+
import { BaseLink } from '@planningcenter/tapestry';
|
|
125
|
+
<BaseLink href="#">Hash only</BaseLink>
|
|
126
|
+
`,
|
|
127
|
+
errors: [{
|
|
128
|
+
messageId: 'invalidHref',
|
|
129
|
+
data: { componentName: 'BaseLink', value: '#' },
|
|
130
|
+
}],
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
code: `
|
|
134
|
+
import { IconLink } from '@planningcenter/tapestry';
|
|
135
|
+
<IconLink href={"#"}>Hash expression</IconLink>
|
|
136
|
+
`,
|
|
137
|
+
errors: [{
|
|
138
|
+
messageId: 'invalidHref',
|
|
139
|
+
data: { componentName: 'IconLink', value: '#' },
|
|
140
|
+
}],
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// JavaScript URLs - Tapestry components
|
|
144
|
+
{
|
|
145
|
+
code: `
|
|
146
|
+
import { BaseLink } from '@planningcenter/tapestry';
|
|
147
|
+
<BaseLink href="javascript:void(0)">JS void</BaseLink>
|
|
148
|
+
`,
|
|
149
|
+
errors: [{
|
|
150
|
+
messageId: 'invalidHref',
|
|
151
|
+
data: { componentName: 'BaseLink', value: 'javascript:void(0)' },
|
|
152
|
+
}],
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
code: `
|
|
156
|
+
import { Link } from '@planningcenter/tapestry';
|
|
157
|
+
<Link href="javascript:alert('hi')">JS alert</Link>
|
|
158
|
+
`,
|
|
159
|
+
errors: [{
|
|
160
|
+
messageId: 'invalidHref',
|
|
161
|
+
data: { componentName: 'Link', value: 'javascript:alert(\'hi\')' },
|
|
162
|
+
}],
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
code: `
|
|
166
|
+
import { IconLink } from '@planningcenter/tapestry';
|
|
167
|
+
<IconLink href="JavaScript:void(0)">Uppercase JS</IconLink>
|
|
168
|
+
`,
|
|
169
|
+
errors: [{
|
|
170
|
+
messageId: 'invalidHref',
|
|
171
|
+
data: { componentName: 'IconLink', value: 'JavaScript:void(0)' },
|
|
172
|
+
}],
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
// href in spread props - Tapestry components
|
|
176
|
+
{
|
|
177
|
+
code: `
|
|
178
|
+
import { BaseLink } from '@planningcenter/tapestry';
|
|
179
|
+
<BaseLink {...props}>Spread props</BaseLink>
|
|
180
|
+
`,
|
|
181
|
+
errors: [{
|
|
182
|
+
messageId: 'hrefInSpread',
|
|
183
|
+
data: { componentName: 'BaseLink' },
|
|
184
|
+
}],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
code: `
|
|
188
|
+
import { Link } from '@planningcenter/tapestry';
|
|
189
|
+
<Link {...linkProps} className="link">Spread with other props</Link>
|
|
190
|
+
`,
|
|
191
|
+
errors: [{
|
|
192
|
+
messageId: 'hrefInSpread',
|
|
193
|
+
data: { componentName: 'Link' },
|
|
194
|
+
}],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
code: `
|
|
198
|
+
import { IconLink } from '@planningcenter/tapestry';
|
|
199
|
+
<IconLink {...rest} aria-label="Icon">Icon with spread</IconLink>
|
|
200
|
+
`,
|
|
201
|
+
errors: [{
|
|
202
|
+
messageId: 'hrefInSpread',
|
|
203
|
+
data: { componentName: 'IconLink' },
|
|
204
|
+
}],
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
],
|
|
208
|
+
});
|