@a11yfred/neighbor 0.3.0 → 1.0.1
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/CHANGELOG.md +59 -7
- package/CONTRIBUTING.md +10 -10
- package/README.md +101 -31
- package/RULES-CONTENT.md +296 -0
- package/RULES-CSS.md +61 -0
- package/RULES-MARKUP.md +156 -0
- package/RULES.md +55 -0
- package/lib/content-rules.js +858 -0
- package/lib/helpers-angular.js +146 -146
- package/lib/helpers-jsx.js +193 -193
- package/lib/helpers-vue.js +151 -151
- package/lib/helpers.js +37 -37
- package/lib/rules.js +2413 -2413
- package/lib/ulam-rules.js +301 -301
- package/neighbor-content.mjs +80 -0
- package/neighbor-eslint-angular.mjs +68 -68
- package/neighbor-eslint-vue.mjs +48 -48
- package/neighbor-eslint.mjs +56 -56
- package/neighbor-stylelint.mjs +257 -256
- package/package.json +18 -5
package/lib/helpers-jsx.js
CHANGED
|
@@ -1,193 +1,193 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* neighbor/lib/helpers-jsx.js
|
|
3
|
-
* Helpers for React/JSX AST (espree + @babel/eslint-parser).
|
|
4
|
-
* Nodes: JSXOpeningElement, JSXAttribute, JSXElement, JSXText, JSXExpressionContainer
|
|
5
|
-
*
|
|
6
|
-
* All helpers conform to the standard interface expected by lib/rules.js:
|
|
7
|
-
* getAttr(node, name) → attribute node or null
|
|
8
|
-
* getAttrStringValue(attr) → string | null
|
|
9
|
-
* getElementName(node) → lowercase string | null
|
|
10
|
-
* hasAttr(node, name) → boolean
|
|
11
|
-
* getRoleValue(node) → string | null
|
|
12
|
-
* hasAccessibleName(node) → boolean
|
|
13
|
-
* isInteractiveElement(node) → boolean
|
|
14
|
-
* hasOnlyHiddenChildren(jsxOrVNode) → boolean (for link-hidden check)
|
|
15
|
-
* getParent(node) → parent opening-element-like node | null
|
|
16
|
-
* getAncestors(node) → iterable of opening-element-like nodes, root-ward
|
|
17
|
-
* getChildOpeningElements(node) → iterable of opening-element-like nodes (direct children)
|
|
18
|
-
* getClassName(node) → string | null
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import {
|
|
22
|
-
INTERACTIVE_ELEMENTS,
|
|
23
|
-
INTERACTIVE_ROLES,
|
|
24
|
-
} from './helpers.js'
|
|
25
|
-
|
|
26
|
-
export function getAttrStringValue(attr) {
|
|
27
|
-
if (!attr) return null
|
|
28
|
-
const val = attr.value
|
|
29
|
-
if (!val) return null
|
|
30
|
-
if (val.type === 'Literal') return String(val.value)
|
|
31
|
-
if (val.type === 'JSXExpressionContainer' && val.expression.type === 'Literal')
|
|
32
|
-
return String(val.expression.value)
|
|
33
|
-
return null
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function getAttr(openingElement, name) {
|
|
37
|
-
return openingElement.attributes.find(
|
|
38
|
-
a => a.type === 'JSXAttribute' && a.name?.name === name
|
|
39
|
-
) ?? null
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function getElementName(openingElement) {
|
|
43
|
-
const n = openingElement.name
|
|
44
|
-
if (typeof n.name === 'string') {
|
|
45
|
-
// Custom React components start with uppercase
|
|
46
|
-
// that check element names don't treat them as native HTML elements.
|
|
47
|
-
if (n.name[0] !== n.name[0].toLowerCase()) return null
|
|
48
|
-
return n.name.toLowerCase()
|
|
49
|
-
}
|
|
50
|
-
return null
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function hasAttr(openingElement, name) {
|
|
54
|
-
return getAttr(openingElement, name) !== null
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function getRoleValue(openingElement) {
|
|
58
|
-
return getAttrStringValue(getAttr(openingElement, 'role'))
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function hasAccessibleName(openingElement) {
|
|
62
|
-
return hasAttr(openingElement, 'aria-label') || hasAttr(openingElement, 'aria-labelledby')
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function isInteractiveElement(openingElement) {
|
|
66
|
-
const el = getElementName(openingElement)
|
|
67
|
-
if (el && INTERACTIVE_ELEMENTS.has(el)) return true
|
|
68
|
-
const role = getRoleValue(openingElement)
|
|
69
|
-
return !!(role && INTERACTIVE_ROLES.has(role))
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Returns true if every child of the JSXElement is aria-hidden or empty text. */
|
|
73
|
-
export function hasOnlyHiddenChildren(jsxElement) {
|
|
74
|
-
const children = jsxElement.children ?? []
|
|
75
|
-
if (children.length === 0) return false
|
|
76
|
-
return children.every(child => {
|
|
77
|
-
if (child.type === 'JSXText') return child.value.trim() === ''
|
|
78
|
-
if (child.type === 'JSXExpressionContainer')
|
|
79
|
-
return child.expression.type === 'Literal' && String(child.expression.value).trim() === ''
|
|
80
|
-
if (child.type === 'JSXElement') {
|
|
81
|
-
return child.openingElement.attributes.some(
|
|
82
|
-
a => a.type === 'JSXAttribute' && a.name?.name === 'aria-hidden'
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
|
-
return false
|
|
86
|
-
})
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Returns the parent opening element (JSXOpeningElement), or null. */
|
|
90
|
-
export function getParent(openingElement) {
|
|
91
|
-
// openingElement → JSXElement → parent JSXElement → openingElement
|
|
92
|
-
const parent = openingElement.parent?.parent
|
|
93
|
-
if (parent?.type === 'JSXElement') return parent.openingElement
|
|
94
|
-
return null
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Returns an iterable of ancestor opening elements, root-ward. */
|
|
98
|
-
export function* getAncestors(openingElement) {
|
|
99
|
-
let cur = getParent(openingElement)
|
|
100
|
-
while (cur) {
|
|
101
|
-
yield cur
|
|
102
|
-
cur = getParent(cur)
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Returns an iterable of direct-child opening elements. */
|
|
107
|
-
export function* getChildOpeningElements(openingElement) {
|
|
108
|
-
const jsxElement = openingElement.parent
|
|
109
|
-
if (jsxElement?.type !== 'JSXElement') return
|
|
110
|
-
for (const child of jsxElement.children ?? []) {
|
|
111
|
-
if (child.type === 'JSXElement') yield child.openingElement
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function getClassName(openingElement) {
|
|
116
|
-
const classAttr = getAttr(openingElement, 'className') ?? getAttr(openingElement, 'class')
|
|
117
|
-
return getAttrStringValue(classAttr)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const NEW_TAB_PATTERN = /new.tab|new.window|opens in/i
|
|
121
|
-
|
|
122
|
-
/** Recursively collect all text content from a JSX subtree. */
|
|
123
|
-
function collectText(node) {
|
|
124
|
-
if (!node) return ''
|
|
125
|
-
if (node.type === 'JSXText') return node.value
|
|
126
|
-
if (node.type === 'JSXElement') {
|
|
127
|
-
return (node.children ?? []).map(collectText).join('')
|
|
128
|
-
}
|
|
129
|
-
if (node.type === 'JSXExpressionContainer') {
|
|
130
|
-
const ex = node.expression
|
|
131
|
-
if (ex.type === 'Literal') return String(ex.value)
|
|
132
|
-
if (ex.type === 'TemplateLiteral') return ex.quasis.map(q => q.value.raw).join('')
|
|
133
|
-
}
|
|
134
|
-
return ''
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* Returns true if the link communicates "opens in new tab" via:
|
|
139
|
-
* - aria-label containing the phrase
|
|
140
|
-
* - any descendant text content containing the phrase (e.g. sr-only span)
|
|
141
|
-
*/
|
|
142
|
-
export function hasNewTabWarning(openingElement) {
|
|
143
|
-
const labelVal = (getAttrStringValue(getAttr(openingElement, 'aria-label')) ?? '').toLowerCase()
|
|
144
|
-
if (NEW_TAB_PATTERN.test(labelVal)) return true
|
|
145
|
-
const jsxElement = openingElement.parent
|
|
146
|
-
if (jsxElement?.type !== 'JSXElement') return false
|
|
147
|
-
const childText = collectText(jsxElement)
|
|
148
|
-
return NEW_TAB_PATTERN.test(childText)
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Returns the dangerouslySetInnerHTML attribute node if present, else null. */
|
|
152
|
-
export function getInnerHtmlAttr(openingElement) {
|
|
153
|
-
return getAttr(openingElement, 'dangerouslySetInnerHTML')
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** Returns the attribute name string for the inject-HTML attribute. */
|
|
157
|
-
export function getInnerHtmlAttrName(_openingElement) {
|
|
158
|
-
return 'dangerouslySetInnerHTML'
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Wrap all JSX helpers into the standard `h` interface expected by rules. */
|
|
162
|
-
export const h = {
|
|
163
|
-
getAttr,
|
|
164
|
-
getAttrStringValue,
|
|
165
|
-
getElementName,
|
|
166
|
-
hasAttr,
|
|
167
|
-
getRoleValue,
|
|
168
|
-
hasAccessibleName,
|
|
169
|
-
isInteractiveElement,
|
|
170
|
-
hasOnlyHiddenChildren: (openingElement) => {
|
|
171
|
-
const el = openingElement.parent
|
|
172
|
-
return el?.type === 'JSXElement' ? hasOnlyHiddenChildren(el) : false
|
|
173
|
-
},
|
|
174
|
-
hasNewTabWarning,
|
|
175
|
-
getParent,
|
|
176
|
-
getAncestors,
|
|
177
|
-
getChildOpeningElements,
|
|
178
|
-
getClassName,
|
|
179
|
-
getInnerHtmlAttr,
|
|
180
|
-
getInnerHtmlAttrName,
|
|
181
|
-
// Node visitor key for ESLint
|
|
182
|
-
elementVisitor: 'JSXOpeningElement',
|
|
183
|
-
// For rules that need to visit element+children (group-without-name, etc.)
|
|
184
|
-
elementWithChildrenVisitor: 'JSXElement',
|
|
185
|
-
/** For elementWithChildrenVisitor: extract the opening element from the wrapper node */
|
|
186
|
-
getOpeningElement: (node) => node.openingElement,
|
|
187
|
-
/** For elementWithChildrenVisitor: get direct child opening elements */
|
|
188
|
-
getChildOpeningElementsFromWrapper: (node) => {
|
|
189
|
-
return (node.children ?? [])
|
|
190
|
-
.filter(c => c.type === 'JSXElement')
|
|
191
|
-
.map(c => c.openingElement)
|
|
192
|
-
},
|
|
193
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* neighbor/lib/helpers-jsx.js
|
|
3
|
+
* Helpers for React/JSX AST (espree + @babel/eslint-parser).
|
|
4
|
+
* Nodes: JSXOpeningElement, JSXAttribute, JSXElement, JSXText, JSXExpressionContainer
|
|
5
|
+
*
|
|
6
|
+
* All helpers conform to the standard interface expected by lib/rules.js:
|
|
7
|
+
* getAttr(node, name) → attribute node or null
|
|
8
|
+
* getAttrStringValue(attr) → string | null
|
|
9
|
+
* getElementName(node) → lowercase string | null
|
|
10
|
+
* hasAttr(node, name) → boolean
|
|
11
|
+
* getRoleValue(node) → string | null
|
|
12
|
+
* hasAccessibleName(node) → boolean
|
|
13
|
+
* isInteractiveElement(node) → boolean
|
|
14
|
+
* hasOnlyHiddenChildren(jsxOrVNode) → boolean (for link-hidden check)
|
|
15
|
+
* getParent(node) → parent opening-element-like node | null
|
|
16
|
+
* getAncestors(node) → iterable of opening-element-like nodes, root-ward
|
|
17
|
+
* getChildOpeningElements(node) → iterable of opening-element-like nodes (direct children)
|
|
18
|
+
* getClassName(node) → string | null
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
INTERACTIVE_ELEMENTS,
|
|
23
|
+
INTERACTIVE_ROLES,
|
|
24
|
+
} from './helpers.js'
|
|
25
|
+
|
|
26
|
+
export function getAttrStringValue(attr) {
|
|
27
|
+
if (!attr) return null
|
|
28
|
+
const val = attr.value
|
|
29
|
+
if (!val) return null
|
|
30
|
+
if (val.type === 'Literal') return String(val.value)
|
|
31
|
+
if (val.type === 'JSXExpressionContainer' && val.expression.type === 'Literal')
|
|
32
|
+
return String(val.expression.value)
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getAttr(openingElement, name) {
|
|
37
|
+
return openingElement.attributes.find(
|
|
38
|
+
a => a.type === 'JSXAttribute' && a.name?.name === name
|
|
39
|
+
) ?? null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getElementName(openingElement) {
|
|
43
|
+
const n = openingElement.name
|
|
44
|
+
if (typeof n.name === 'string') {
|
|
45
|
+
// Custom React components start with uppercase - return null so rules
|
|
46
|
+
// that check element names don't treat them as native HTML elements.
|
|
47
|
+
if (n.name[0] !== n.name[0].toLowerCase()) return null
|
|
48
|
+
return n.name.toLowerCase()
|
|
49
|
+
}
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function hasAttr(openingElement, name) {
|
|
54
|
+
return getAttr(openingElement, name) !== null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getRoleValue(openingElement) {
|
|
58
|
+
return getAttrStringValue(getAttr(openingElement, 'role'))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function hasAccessibleName(openingElement) {
|
|
62
|
+
return hasAttr(openingElement, 'aria-label') || hasAttr(openingElement, 'aria-labelledby')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function isInteractiveElement(openingElement) {
|
|
66
|
+
const el = getElementName(openingElement)
|
|
67
|
+
if (el && INTERACTIVE_ELEMENTS.has(el)) return true
|
|
68
|
+
const role = getRoleValue(openingElement)
|
|
69
|
+
return !!(role && INTERACTIVE_ROLES.has(role))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Returns true if every child of the JSXElement is aria-hidden or empty text. */
|
|
73
|
+
export function hasOnlyHiddenChildren(jsxElement) {
|
|
74
|
+
const children = jsxElement.children ?? []
|
|
75
|
+
if (children.length === 0) return false
|
|
76
|
+
return children.every(child => {
|
|
77
|
+
if (child.type === 'JSXText') return child.value.trim() === ''
|
|
78
|
+
if (child.type === 'JSXExpressionContainer')
|
|
79
|
+
return child.expression.type === 'Literal' && String(child.expression.value).trim() === ''
|
|
80
|
+
if (child.type === 'JSXElement') {
|
|
81
|
+
return child.openingElement.attributes.some(
|
|
82
|
+
a => a.type === 'JSXAttribute' && a.name?.name === 'aria-hidden'
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
return false
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Returns the parent opening element (JSXOpeningElement), or null. */
|
|
90
|
+
export function getParent(openingElement) {
|
|
91
|
+
// openingElement → JSXElement → parent JSXElement → openingElement
|
|
92
|
+
const parent = openingElement.parent?.parent
|
|
93
|
+
if (parent?.type === 'JSXElement') return parent.openingElement
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Returns an iterable of ancestor opening elements, root-ward. */
|
|
98
|
+
export function* getAncestors(openingElement) {
|
|
99
|
+
let cur = getParent(openingElement)
|
|
100
|
+
while (cur) {
|
|
101
|
+
yield cur
|
|
102
|
+
cur = getParent(cur)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Returns an iterable of direct-child opening elements. */
|
|
107
|
+
export function* getChildOpeningElements(openingElement) {
|
|
108
|
+
const jsxElement = openingElement.parent
|
|
109
|
+
if (jsxElement?.type !== 'JSXElement') return
|
|
110
|
+
for (const child of jsxElement.children ?? []) {
|
|
111
|
+
if (child.type === 'JSXElement') yield child.openingElement
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function getClassName(openingElement) {
|
|
116
|
+
const classAttr = getAttr(openingElement, 'className') ?? getAttr(openingElement, 'class')
|
|
117
|
+
return getAttrStringValue(classAttr)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const NEW_TAB_PATTERN = /new.tab|new.window|opens in/i
|
|
121
|
+
|
|
122
|
+
/** Recursively collect all text content from a JSX subtree. */
|
|
123
|
+
function collectText(node) {
|
|
124
|
+
if (!node) return ''
|
|
125
|
+
if (node.type === 'JSXText') return node.value
|
|
126
|
+
if (node.type === 'JSXElement') {
|
|
127
|
+
return (node.children ?? []).map(collectText).join('')
|
|
128
|
+
}
|
|
129
|
+
if (node.type === 'JSXExpressionContainer') {
|
|
130
|
+
const ex = node.expression
|
|
131
|
+
if (ex.type === 'Literal') return String(ex.value)
|
|
132
|
+
if (ex.type === 'TemplateLiteral') return ex.quasis.map(q => q.value.raw).join('')
|
|
133
|
+
}
|
|
134
|
+
return ''
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns true if the link communicates "opens in new tab" via:
|
|
139
|
+
* - aria-label containing the phrase
|
|
140
|
+
* - any descendant text content containing the phrase (e.g. sr-only span)
|
|
141
|
+
*/
|
|
142
|
+
export function hasNewTabWarning(openingElement) {
|
|
143
|
+
const labelVal = (getAttrStringValue(getAttr(openingElement, 'aria-label')) ?? '').toLowerCase()
|
|
144
|
+
if (NEW_TAB_PATTERN.test(labelVal)) return true
|
|
145
|
+
const jsxElement = openingElement.parent
|
|
146
|
+
if (jsxElement?.type !== 'JSXElement') return false
|
|
147
|
+
const childText = collectText(jsxElement)
|
|
148
|
+
return NEW_TAB_PATTERN.test(childText)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Returns the dangerouslySetInnerHTML attribute node if present, else null. */
|
|
152
|
+
export function getInnerHtmlAttr(openingElement) {
|
|
153
|
+
return getAttr(openingElement, 'dangerouslySetInnerHTML')
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Returns the attribute name string for the inject-HTML attribute. */
|
|
157
|
+
export function getInnerHtmlAttrName(_openingElement) {
|
|
158
|
+
return 'dangerouslySetInnerHTML'
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Wrap all JSX helpers into the standard `h` interface expected by rules. */
|
|
162
|
+
export const h = {
|
|
163
|
+
getAttr,
|
|
164
|
+
getAttrStringValue,
|
|
165
|
+
getElementName,
|
|
166
|
+
hasAttr,
|
|
167
|
+
getRoleValue,
|
|
168
|
+
hasAccessibleName,
|
|
169
|
+
isInteractiveElement,
|
|
170
|
+
hasOnlyHiddenChildren: (openingElement) => {
|
|
171
|
+
const el = openingElement.parent
|
|
172
|
+
return el?.type === 'JSXElement' ? hasOnlyHiddenChildren(el) : false
|
|
173
|
+
},
|
|
174
|
+
hasNewTabWarning,
|
|
175
|
+
getParent,
|
|
176
|
+
getAncestors,
|
|
177
|
+
getChildOpeningElements,
|
|
178
|
+
getClassName,
|
|
179
|
+
getInnerHtmlAttr,
|
|
180
|
+
getInnerHtmlAttrName,
|
|
181
|
+
// Node visitor key for ESLint - what AST node type fires the rule
|
|
182
|
+
elementVisitor: 'JSXOpeningElement',
|
|
183
|
+
// For rules that need to visit element+children (group-without-name, etc.)
|
|
184
|
+
elementWithChildrenVisitor: 'JSXElement',
|
|
185
|
+
/** For elementWithChildrenVisitor: extract the opening element from the wrapper node */
|
|
186
|
+
getOpeningElement: (node) => node.openingElement,
|
|
187
|
+
/** For elementWithChildrenVisitor: get direct child opening elements */
|
|
188
|
+
getChildOpeningElementsFromWrapper: (node) => {
|
|
189
|
+
return (node.children ?? [])
|
|
190
|
+
.filter(c => c.type === 'JSXElement')
|
|
191
|
+
.map(c => c.openingElement)
|
|
192
|
+
},
|
|
193
|
+
}
|