@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/ulam-rules.js
CHANGED
|
@@ -1,301 +1,301 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* neighbor/lib/ulam-rules.js
|
|
3
|
-
* Lint rules specific to @ulam framework patterns.
|
|
4
|
-
*
|
|
5
|
-
* These rules operate on JS/JSX call expressions and import declarations,
|
|
6
|
-
* not on JSX element visitors
|
|
7
|
-
*
|
|
8
|
-
* Rules:
|
|
9
|
-
* no-announce-in-render
|
|
10
|
-
* no-hash-router-in-remix
|
|
11
|
-
* no-use-page-title-in-remix
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
// ─── no-announce-in-render ───────────────────────────────────────────────────
|
|
15
|
-
//
|
|
16
|
-
// announce() writes to a live region. Calling it directly in a component body
|
|
17
|
-
// fires on every render, spamming screen readers with repeated announcements.
|
|
18
|
-
// It must only be called inside a lifecycle hook, effect, or event handler.
|
|
19
|
-
//
|
|
20
|
-
// React: useEffect / useLayoutEffect / event handlers (onClick={...} etc.)
|
|
21
|
-
// Vue: onMounted / onUpdated / watch / watchEffect / nextTick callbacks
|
|
22
|
-
// Angular: ngOnInit / ngAfterViewInit / ngOnChanges / event methods on the class
|
|
23
|
-
|
|
24
|
-
const ANNOUNCE_FNS = new Set(['announce', 'clearAnnouncements'])
|
|
25
|
-
|
|
26
|
-
const REACT_SAFE_CALLS = new Set([
|
|
27
|
-
'useEffect', 'useLayoutEffect', 'useInsertionEffect',
|
|
28
|
-
'useCallback', 'useMemo',
|
|
29
|
-
])
|
|
30
|
-
|
|
31
|
-
const VUE_SAFE_CALLS = new Set([
|
|
32
|
-
'onMounted', 'onUpdated', 'onBeforeMount', 'onBeforeUpdate',
|
|
33
|
-
'onActivated', 'onDeactivated', 'watch', 'watchEffect', 'watchPostEffect',
|
|
34
|
-
'watchSyncEffect', 'nextTick',
|
|
35
|
-
])
|
|
36
|
-
|
|
37
|
-
const ANGULAR_SAFE_METHODS = new Set([
|
|
38
|
-
'ngOnInit', 'ngAfterViewInit', 'ngAfterContentInit',
|
|
39
|
-
'ngOnChanges', 'ngDoCheck',
|
|
40
|
-
])
|
|
41
|
-
|
|
42
|
-
// Safe call names for each framework, merged for the combined check
|
|
43
|
-
function buildSafeCalls(framework) {
|
|
44
|
-
if (framework === 'vue') return new Set([...REACT_SAFE_CALLS, ...VUE_SAFE_CALLS])
|
|
45
|
-
if (framework === 'angular') return new Set([...REACT_SAFE_CALLS, ...ANGULAR_SAFE_METHODS])
|
|
46
|
-
return REACT_SAFE_CALLS
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function makeIsInsideSafeContext(safeCalls, framework) {
|
|
50
|
-
return function isInsideSafeContext(node) {
|
|
51
|
-
let cur = node.parent
|
|
52
|
-
while (cur) {
|
|
53
|
-
// Callback passed to useEffect / onMounted / watch etc.
|
|
54
|
-
if (
|
|
55
|
-
cur.type === 'CallExpression' &&
|
|
56
|
-
cur.callee?.name &&
|
|
57
|
-
safeCalls.has(cur.callee.name)
|
|
58
|
-
) return true
|
|
59
|
-
|
|
60
|
-
// React JSX event handler: onClick={...}, onKeyDown={...}, etc.
|
|
61
|
-
if (
|
|
62
|
-
framework !== 'angular' &&
|
|
63
|
-
cur.type === 'JSXExpressionContainer' &&
|
|
64
|
-
cur.parent?.type === 'JSXAttribute' &&
|
|
65
|
-
cur.parent?.name?.name?.startsWith('on')
|
|
66
|
-
) return true
|
|
67
|
-
|
|
68
|
-
// Angular: method defined directly on the class body (e.g. handleClick() { announce() })
|
|
69
|
-
// These are always safe
|
|
70
|
-
if (
|
|
71
|
-
framework === 'angular' &&
|
|
72
|
-
cur.type === 'MethodDefinition' &&
|
|
73
|
-
!ANGULAR_SAFE_METHODS.has(cur.key?.name) // lifecycle methods are caught above via CallExpression
|
|
74
|
-
) return true
|
|
75
|
-
|
|
76
|
-
// Regular function not passed as a callback
|
|
77
|
-
if (
|
|
78
|
-
cur.type === 'FunctionDeclaration' ||
|
|
79
|
-
cur.type === 'FunctionExpression' ||
|
|
80
|
-
cur.type === 'ArrowFunctionExpression'
|
|
81
|
-
) {
|
|
82
|
-
const parent = cur.parent
|
|
83
|
-
if (parent?.type !== 'CallExpression') return true
|
|
84
|
-
if (parent.callee?.name && safeCalls.has(parent.callee.name)) return true
|
|
85
|
-
// Event handler function passed as a prop / method call
|
|
86
|
-
if (parent.callee?.type === 'MemberExpression') return true
|
|
87
|
-
// Keep traversing
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
cur = cur.parent
|
|
91
|
-
}
|
|
92
|
-
return false
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function makeAnnounceMessage(framework) {
|
|
97
|
-
if (framework === 'vue') {
|
|
98
|
-
return (
|
|
99
|
-
'`{{fn}}()` called outside onMounted / watch / an event handler will fire on every ' +
|
|
100
|
-
'component setup, spamming screen readers. Move it into onMounted(() => { {{fn}}(...) }) ' +
|
|
101
|
-
'or call it from an event handler. (@ulam/taho)'
|
|
102
|
-
)
|
|
103
|
-
}
|
|
104
|
-
if (framework === 'angular') {
|
|
105
|
-
return (
|
|
106
|
-
'`{{fn}}()` called outside ngOnInit / ngAfterViewInit / an event method will fire on ' +
|
|
107
|
-
'every change-detection cycle, spamming screen readers. Move it into ngOnInit() or ' +
|
|
108
|
-
'call it from an event handler method. (@ulam/taho)'
|
|
109
|
-
)
|
|
110
|
-
}
|
|
111
|
-
return (
|
|
112
|
-
'`{{fn}}()` called outside a useEffect or event handler will fire on every render, ' +
|
|
113
|
-
'spamming screen readers. Move it into useEffect(() => { {{fn}}(...) }, [dep]) ' +
|
|
114
|
-
'or call it from an event handler. (@ulam/taho)'
|
|
115
|
-
)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function makeNoAnnounceInRender({ framework = 'react' } = {}) {
|
|
119
|
-
const safeCalls = buildSafeCalls(framework)
|
|
120
|
-
const isInsideSafeContext = makeIsInsideSafeContext(safeCalls, framework)
|
|
121
|
-
|
|
122
|
-
return {
|
|
123
|
-
meta: {
|
|
124
|
-
type: 'problem',
|
|
125
|
-
docs: { description: 'Disallow announce() called directly in a component render body or setup' },
|
|
126
|
-
messages: { inRender: makeAnnounceMessage(framework) },
|
|
127
|
-
schema: [],
|
|
128
|
-
},
|
|
129
|
-
create(context) {
|
|
130
|
-
return {
|
|
131
|
-
CallExpression(node) {
|
|
132
|
-
const name = node.callee?.name
|
|
133
|
-
if (!name || !ANNOUNCE_FNS.has(name)) return
|
|
134
|
-
if (isInsideSafeContext(node)) return
|
|
135
|
-
context.report({ node, messageId: 'inRender', data: { fn: name } })
|
|
136
|
-
},
|
|
137
|
-
}
|
|
138
|
-
},
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// ─── no-hash-router-in-remix ─────────────────────────────────────────────────
|
|
143
|
-
//
|
|
144
|
-
// The @ulam hash router (siling-labuyo/hashRouter, @ulam/sili/hashRouter) is a
|
|
145
|
-
// fallback for plain SPA builds. In Remix, file-based routing replaces it.
|
|
146
|
-
// Importing from the hash router in a file that also uses react-router means
|
|
147
|
-
// the migration to siling-mahaba is incomplete.
|
|
148
|
-
|
|
149
|
-
const HASH_ROUTER_PATHS = new Set([
|
|
150
|
-
'siling-labuyo/hashRouter',
|
|
151
|
-
'@ulam/sili/hashRouter',
|
|
152
|
-
'@ulam/siling-labuyo/hashRouter',
|
|
153
|
-
])
|
|
154
|
-
|
|
155
|
-
const REMIX_PATHS = new Set([
|
|
156
|
-
'react-router',
|
|
157
|
-
'@remix-run/react',
|
|
158
|
-
'react-router-dom',
|
|
159
|
-
])
|
|
160
|
-
|
|
161
|
-
export function makeNoHashRouterInRemix() {
|
|
162
|
-
return {
|
|
163
|
-
meta: {
|
|
164
|
-
type: 'suggestion',
|
|
165
|
-
docs: { description: 'Disallow @ulam hash router imports alongside react-router' },
|
|
166
|
-
messages: {
|
|
167
|
-
hashRouter:
|
|
168
|
-
'Importing from the @ulam hash router alongside react-router means the Remix migration ' +
|
|
169
|
-
'is incomplete. Replace hash router usage with siling-mahaba equivalents: ' +
|
|
170
|
-
'useRouter/useRouteMatch from @ulam/siling-mahaba. (@ulam/siling-mahaba)',
|
|
171
|
-
},
|
|
172
|
-
schema: [],
|
|
173
|
-
},
|
|
174
|
-
create(context) {
|
|
175
|
-
let hasRemixImport = false
|
|
176
|
-
const hashRouterNodes = []
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
ImportDeclaration(node) {
|
|
180
|
-
const src = node.source.value
|
|
181
|
-
if (REMIX_PATHS.has(src)) hasRemixImport = true
|
|
182
|
-
if (HASH_ROUTER_PATHS.has(src) || src.includes('/hashRouter')) {
|
|
183
|
-
hashRouterNodes.push(node)
|
|
184
|
-
}
|
|
185
|
-
},
|
|
186
|
-
'Program:exit'() {
|
|
187
|
-
if (!hasRemixImport) return
|
|
188
|
-
for (const node of hashRouterNodes) {
|
|
189
|
-
context.report({ node, messageId: 'hashRouter' })
|
|
190
|
-
}
|
|
191
|
-
},
|
|
192
|
-
}
|
|
193
|
-
},
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// ─── no-use-page-title-in-remix ──────────────────────────────────────────────
|
|
198
|
-
//
|
|
199
|
-
// usePageTitle() from siling-labuyo sets document.title imperatively.
|
|
200
|
-
// In Remix, page titles are set via the `meta` export on each route module.
|
|
201
|
-
// Using usePageTitle() alongside react-router imports means the migration shim
|
|
202
|
-
// has not been cleaned up.
|
|
203
|
-
|
|
204
|
-
const USE_PAGE_TITLE_SOURCES = new Set([
|
|
205
|
-
'siling-labuyo/hooks/usePageTitle',
|
|
206
|
-
'@ulam/sili',
|
|
207
|
-
'@ulam/siling-labuyo',
|
|
208
|
-
'@ulam/siling-mahaba',
|
|
209
|
-
])
|
|
210
|
-
|
|
211
|
-
export function makeNoUsePageTitleInRemix() {
|
|
212
|
-
return {
|
|
213
|
-
meta: {
|
|
214
|
-
type: 'suggestion',
|
|
215
|
-
docs: { description: 'Disallow usePageTitle() in Remix
|
|
216
|
-
messages: {
|
|
217
|
-
usePageTitle:
|
|
218
|
-
'`usePageTitle()` sets document.title imperatively, which conflicts with Remix\'s ' +
|
|
219
|
-
'declarative `meta` export. Export a `meta` function from each route module instead: ' +
|
|
220
|
-
'`export const meta = () => [{ title: "App | Page" }]`. ' +
|
|
221
|
-
'Then remove this import. (@ulam/siling-mahaba)',
|
|
222
|
-
},
|
|
223
|
-
schema: [],
|
|
224
|
-
},
|
|
225
|
-
create(context) {
|
|
226
|
-
let hasRemixImport = false
|
|
227
|
-
const usePageTitleNodes = []
|
|
228
|
-
|
|
229
|
-
return {
|
|
230
|
-
ImportDeclaration(node) {
|
|
231
|
-
const src = node.source.value
|
|
232
|
-
if (REMIX_PATHS.has(src)) hasRemixImport = true
|
|
233
|
-
const importsUsePageTitle = node.specifiers.some(
|
|
234
|
-
s => s.type === 'ImportSpecifier' && s.imported?.name === 'usePageTitle'
|
|
235
|
-
)
|
|
236
|
-
if (importsUsePageTitle && USE_PAGE_TITLE_SOURCES.has(src)) {
|
|
237
|
-
usePageTitleNodes.push(node)
|
|
238
|
-
}
|
|
239
|
-
// Also catch wildcard re-exports like @ulam/siling-mahaba (which re-exports it)
|
|
240
|
-
if (importsUsePageTitle) usePageTitleNodes.push(node)
|
|
241
|
-
},
|
|
242
|
-
'Program:exit'() {
|
|
243
|
-
if (!hasRemixImport) return
|
|
244
|
-
// Deduplicate (wildcard catch above may double-push)
|
|
245
|
-
const seen = new Set()
|
|
246
|
-
for (const node of usePageTitleNodes) {
|
|
247
|
-
if (seen.has(node)) continue
|
|
248
|
-
seen.add(node)
|
|
249
|
-
context.report({ node, messageId: 'usePageTitle' })
|
|
250
|
-
}
|
|
251
|
-
},
|
|
252
|
-
}
|
|
253
|
-
},
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// ─── All ulam rule factories ──────────────────────────────────────────────────
|
|
258
|
-
|
|
259
|
-
export const ULAM_RULE_FACTORIES = {
|
|
260
|
-
'no-announce-in-render': makeNoAnnounceInRender,
|
|
261
|
-
'no-hash-router-in-remix': makeNoHashRouterInRemix,
|
|
262
|
-
'no-use-page-title-in-remix': makeNoUsePageTitleInRemix,
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** React plugin: all three ulam rules. */
|
|
266
|
-
export function buildUlamRules() {
|
|
267
|
-
return {
|
|
268
|
-
'no-announce-in-render': makeNoAnnounceInRender({ framework: 'react' }),
|
|
269
|
-
'no-hash-router-in-remix': makeNoHashRouterInRemix(),
|
|
270
|
-
'no-use-page-title-in-remix': makeNoUsePageTitleInRemix(),
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Vue plugin: only the announce rule, tuned for Vue lifecycle hooks. */
|
|
275
|
-
export function buildUlamRulesVue() {
|
|
276
|
-
return {
|
|
277
|
-
'no-announce-in-render': makeNoAnnounceInRender({ framework: 'vue' }),
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/** Angular plugin: only the announce rule, tuned for Angular lifecycle methods. */
|
|
282
|
-
export function buildUlamRulesAngular() {
|
|
283
|
-
return {
|
|
284
|
-
'no-announce-in-render': makeNoAnnounceInRender({ framework: 'angular' }),
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
export function buildUlamRecommendedRules(ns) {
|
|
289
|
-
return {
|
|
290
|
-
[`${ns}/no-announce-in-render`]: 'error',
|
|
291
|
-
[`${ns}/no-hash-router-in-remix`]: 'warn',
|
|
292
|
-
[`${ns}/no-use-page-title-in-remix`]: 'warn',
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/** Recommended rules for Vue/Angular
|
|
297
|
-
export function buildUlamRecommendedRulesFramework(ns) {
|
|
298
|
-
return {
|
|
299
|
-
[`${ns}/no-announce-in-render`]: 'error',
|
|
300
|
-
}
|
|
301
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* neighbor/lib/ulam-rules.js
|
|
3
|
+
* Lint rules specific to @ulam framework patterns.
|
|
4
|
+
*
|
|
5
|
+
* These rules operate on JS/JSX call expressions and import declarations,
|
|
6
|
+
* not on JSX element visitors - they do not take the `h` adapter.
|
|
7
|
+
*
|
|
8
|
+
* Rules:
|
|
9
|
+
* no-announce-in-render - announce() called in component body, not effect/handler
|
|
10
|
+
* no-hash-router-in-remix - importing from siling-labuyo/hashRouter in a Remix project
|
|
11
|
+
* no-use-page-title-in-remix - usePageTitle() used alongside react-router imports
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// ─── no-announce-in-render ───────────────────────────────────────────────────
|
|
15
|
+
//
|
|
16
|
+
// announce() writes to a live region. Calling it directly in a component body
|
|
17
|
+
// fires on every render, spamming screen readers with repeated announcements.
|
|
18
|
+
// It must only be called inside a lifecycle hook, effect, or event handler.
|
|
19
|
+
//
|
|
20
|
+
// React: useEffect / useLayoutEffect / event handlers (onClick={...} etc.)
|
|
21
|
+
// Vue: onMounted / onUpdated / watch / watchEffect / nextTick callbacks
|
|
22
|
+
// Angular: ngOnInit / ngAfterViewInit / ngOnChanges / event methods on the class
|
|
23
|
+
|
|
24
|
+
const ANNOUNCE_FNS = new Set(['announce', 'clearAnnouncements'])
|
|
25
|
+
|
|
26
|
+
const REACT_SAFE_CALLS = new Set([
|
|
27
|
+
'useEffect', 'useLayoutEffect', 'useInsertionEffect',
|
|
28
|
+
'useCallback', 'useMemo',
|
|
29
|
+
])
|
|
30
|
+
|
|
31
|
+
const VUE_SAFE_CALLS = new Set([
|
|
32
|
+
'onMounted', 'onUpdated', 'onBeforeMount', 'onBeforeUpdate',
|
|
33
|
+
'onActivated', 'onDeactivated', 'watch', 'watchEffect', 'watchPostEffect',
|
|
34
|
+
'watchSyncEffect', 'nextTick',
|
|
35
|
+
])
|
|
36
|
+
|
|
37
|
+
const ANGULAR_SAFE_METHODS = new Set([
|
|
38
|
+
'ngOnInit', 'ngAfterViewInit', 'ngAfterContentInit',
|
|
39
|
+
'ngOnChanges', 'ngDoCheck',
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
// Safe call names for each framework, merged for the combined check
|
|
43
|
+
function buildSafeCalls(framework) {
|
|
44
|
+
if (framework === 'vue') return new Set([...REACT_SAFE_CALLS, ...VUE_SAFE_CALLS])
|
|
45
|
+
if (framework === 'angular') return new Set([...REACT_SAFE_CALLS, ...ANGULAR_SAFE_METHODS])
|
|
46
|
+
return REACT_SAFE_CALLS
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function makeIsInsideSafeContext(safeCalls, framework) {
|
|
50
|
+
return function isInsideSafeContext(node) {
|
|
51
|
+
let cur = node.parent
|
|
52
|
+
while (cur) {
|
|
53
|
+
// Callback passed to useEffect / onMounted / watch etc.
|
|
54
|
+
if (
|
|
55
|
+
cur.type === 'CallExpression' &&
|
|
56
|
+
cur.callee?.name &&
|
|
57
|
+
safeCalls.has(cur.callee.name)
|
|
58
|
+
) return true
|
|
59
|
+
|
|
60
|
+
// React JSX event handler: onClick={...}, onKeyDown={...}, etc.
|
|
61
|
+
if (
|
|
62
|
+
framework !== 'angular' &&
|
|
63
|
+
cur.type === 'JSXExpressionContainer' &&
|
|
64
|
+
cur.parent?.type === 'JSXAttribute' &&
|
|
65
|
+
cur.parent?.name?.name?.startsWith('on')
|
|
66
|
+
) return true
|
|
67
|
+
|
|
68
|
+
// Angular: method defined directly on the class body (e.g. handleClick() { announce() })
|
|
69
|
+
// These are always safe - Angular calls them only in response to events or lifecycle.
|
|
70
|
+
if (
|
|
71
|
+
framework === 'angular' &&
|
|
72
|
+
cur.type === 'MethodDefinition' &&
|
|
73
|
+
!ANGULAR_SAFE_METHODS.has(cur.key?.name) // lifecycle methods are caught above via CallExpression
|
|
74
|
+
) return true
|
|
75
|
+
|
|
76
|
+
// Regular function not passed as a callback - safe (event listener, async handler, etc.)
|
|
77
|
+
if (
|
|
78
|
+
cur.type === 'FunctionDeclaration' ||
|
|
79
|
+
cur.type === 'FunctionExpression' ||
|
|
80
|
+
cur.type === 'ArrowFunctionExpression'
|
|
81
|
+
) {
|
|
82
|
+
const parent = cur.parent
|
|
83
|
+
if (parent?.type !== 'CallExpression') return true
|
|
84
|
+
if (parent.callee?.name && safeCalls.has(parent.callee.name)) return true
|
|
85
|
+
// Event handler function passed as a prop / method call
|
|
86
|
+
if (parent.callee?.type === 'MemberExpression') return true
|
|
87
|
+
// Keep traversing - may be a nested callback inside an onClick
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
cur = cur.parent
|
|
91
|
+
}
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function makeAnnounceMessage(framework) {
|
|
97
|
+
if (framework === 'vue') {
|
|
98
|
+
return (
|
|
99
|
+
'`{{fn}}()` called outside onMounted / watch / an event handler will fire on every ' +
|
|
100
|
+
'component setup, spamming screen readers. Move it into onMounted(() => { {{fn}}(...) }) ' +
|
|
101
|
+
'or call it from an event handler. (@ulam/taho)'
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
if (framework === 'angular') {
|
|
105
|
+
return (
|
|
106
|
+
'`{{fn}}()` called outside ngOnInit / ngAfterViewInit / an event method will fire on ' +
|
|
107
|
+
'every change-detection cycle, spamming screen readers. Move it into ngOnInit() or ' +
|
|
108
|
+
'call it from an event handler method. (@ulam/taho)'
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
return (
|
|
112
|
+
'`{{fn}}()` called outside a useEffect or event handler will fire on every render, ' +
|
|
113
|
+
'spamming screen readers. Move it into useEffect(() => { {{fn}}(...) }, [dep]) ' +
|
|
114
|
+
'or call it from an event handler. (@ulam/taho)'
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function makeNoAnnounceInRender({ framework = 'react' } = {}) {
|
|
119
|
+
const safeCalls = buildSafeCalls(framework)
|
|
120
|
+
const isInsideSafeContext = makeIsInsideSafeContext(safeCalls, framework)
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
meta: {
|
|
124
|
+
type: 'problem',
|
|
125
|
+
docs: { description: 'Disallow announce() called directly in a component render body or setup' },
|
|
126
|
+
messages: { inRender: makeAnnounceMessage(framework) },
|
|
127
|
+
schema: [],
|
|
128
|
+
},
|
|
129
|
+
create(context) {
|
|
130
|
+
return {
|
|
131
|
+
CallExpression(node) {
|
|
132
|
+
const name = node.callee?.name
|
|
133
|
+
if (!name || !ANNOUNCE_FNS.has(name)) return
|
|
134
|
+
if (isInsideSafeContext(node)) return
|
|
135
|
+
context.report({ node, messageId: 'inRender', data: { fn: name } })
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── no-hash-router-in-remix ─────────────────────────────────────────────────
|
|
143
|
+
//
|
|
144
|
+
// The @ulam hash router (siling-labuyo/hashRouter, @ulam/sili/hashRouter) is a
|
|
145
|
+
// fallback for plain SPA builds. In Remix, file-based routing replaces it.
|
|
146
|
+
// Importing from the hash router in a file that also uses react-router means
|
|
147
|
+
// the migration to siling-mahaba is incomplete.
|
|
148
|
+
|
|
149
|
+
const HASH_ROUTER_PATHS = new Set([
|
|
150
|
+
'siling-labuyo/hashRouter',
|
|
151
|
+
'@ulam/sili/hashRouter',
|
|
152
|
+
'@ulam/siling-labuyo/hashRouter',
|
|
153
|
+
])
|
|
154
|
+
|
|
155
|
+
const REMIX_PATHS = new Set([
|
|
156
|
+
'react-router',
|
|
157
|
+
'@remix-run/react',
|
|
158
|
+
'react-router-dom',
|
|
159
|
+
])
|
|
160
|
+
|
|
161
|
+
export function makeNoHashRouterInRemix() {
|
|
162
|
+
return {
|
|
163
|
+
meta: {
|
|
164
|
+
type: 'suggestion',
|
|
165
|
+
docs: { description: 'Disallow @ulam hash router imports alongside react-router' },
|
|
166
|
+
messages: {
|
|
167
|
+
hashRouter:
|
|
168
|
+
'Importing from the @ulam hash router alongside react-router means the Remix migration ' +
|
|
169
|
+
'is incomplete. Replace hash router usage with siling-mahaba equivalents: ' +
|
|
170
|
+
'useRouter/useRouteMatch from @ulam/siling-mahaba. (@ulam/siling-mahaba)',
|
|
171
|
+
},
|
|
172
|
+
schema: [],
|
|
173
|
+
},
|
|
174
|
+
create(context) {
|
|
175
|
+
let hasRemixImport = false
|
|
176
|
+
const hashRouterNodes = []
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
ImportDeclaration(node) {
|
|
180
|
+
const src = node.source.value
|
|
181
|
+
if (REMIX_PATHS.has(src)) hasRemixImport = true
|
|
182
|
+
if (HASH_ROUTER_PATHS.has(src) || src.includes('/hashRouter')) {
|
|
183
|
+
hashRouterNodes.push(node)
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
'Program:exit'() {
|
|
187
|
+
if (!hasRemixImport) return
|
|
188
|
+
for (const node of hashRouterNodes) {
|
|
189
|
+
context.report({ node, messageId: 'hashRouter' })
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── no-use-page-title-in-remix ──────────────────────────────────────────────
|
|
198
|
+
//
|
|
199
|
+
// usePageTitle() from siling-labuyo sets document.title imperatively.
|
|
200
|
+
// In Remix, page titles are set via the `meta` export on each route module.
|
|
201
|
+
// Using usePageTitle() alongside react-router imports means the migration shim
|
|
202
|
+
// has not been cleaned up.
|
|
203
|
+
|
|
204
|
+
const USE_PAGE_TITLE_SOURCES = new Set([
|
|
205
|
+
'siling-labuyo/hooks/usePageTitle',
|
|
206
|
+
'@ulam/sili',
|
|
207
|
+
'@ulam/siling-labuyo',
|
|
208
|
+
'@ulam/siling-mahaba',
|
|
209
|
+
])
|
|
210
|
+
|
|
211
|
+
export function makeNoUsePageTitleInRemix() {
|
|
212
|
+
return {
|
|
213
|
+
meta: {
|
|
214
|
+
type: 'suggestion',
|
|
215
|
+
docs: { description: 'Disallow usePageTitle() in Remix - use the meta export instead' },
|
|
216
|
+
messages: {
|
|
217
|
+
usePageTitle:
|
|
218
|
+
'`usePageTitle()` sets document.title imperatively, which conflicts with Remix\'s ' +
|
|
219
|
+
'declarative `meta` export. Export a `meta` function from each route module instead: ' +
|
|
220
|
+
'`export const meta = () => [{ title: "App | Page" }]`. ' +
|
|
221
|
+
'Then remove this import. (@ulam/siling-mahaba)',
|
|
222
|
+
},
|
|
223
|
+
schema: [],
|
|
224
|
+
},
|
|
225
|
+
create(context) {
|
|
226
|
+
let hasRemixImport = false
|
|
227
|
+
const usePageTitleNodes = []
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
ImportDeclaration(node) {
|
|
231
|
+
const src = node.source.value
|
|
232
|
+
if (REMIX_PATHS.has(src)) hasRemixImport = true
|
|
233
|
+
const importsUsePageTitle = node.specifiers.some(
|
|
234
|
+
s => s.type === 'ImportSpecifier' && s.imported?.name === 'usePageTitle'
|
|
235
|
+
)
|
|
236
|
+
if (importsUsePageTitle && USE_PAGE_TITLE_SOURCES.has(src)) {
|
|
237
|
+
usePageTitleNodes.push(node)
|
|
238
|
+
}
|
|
239
|
+
// Also catch wildcard re-exports like @ulam/siling-mahaba (which re-exports it)
|
|
240
|
+
if (importsUsePageTitle) usePageTitleNodes.push(node)
|
|
241
|
+
},
|
|
242
|
+
'Program:exit'() {
|
|
243
|
+
if (!hasRemixImport) return
|
|
244
|
+
// Deduplicate (wildcard catch above may double-push)
|
|
245
|
+
const seen = new Set()
|
|
246
|
+
for (const node of usePageTitleNodes) {
|
|
247
|
+
if (seen.has(node)) continue
|
|
248
|
+
seen.add(node)
|
|
249
|
+
context.report({ node, messageId: 'usePageTitle' })
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ─── All ulam rule factories ──────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
export const ULAM_RULE_FACTORIES = {
|
|
260
|
+
'no-announce-in-render': makeNoAnnounceInRender,
|
|
261
|
+
'no-hash-router-in-remix': makeNoHashRouterInRemix,
|
|
262
|
+
'no-use-page-title-in-remix': makeNoUsePageTitleInRemix,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** React plugin: all three ulam rules. */
|
|
266
|
+
export function buildUlamRules() {
|
|
267
|
+
return {
|
|
268
|
+
'no-announce-in-render': makeNoAnnounceInRender({ framework: 'react' }),
|
|
269
|
+
'no-hash-router-in-remix': makeNoHashRouterInRemix(),
|
|
270
|
+
'no-use-page-title-in-remix': makeNoUsePageTitleInRemix(),
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Vue plugin: only the announce rule, tuned for Vue lifecycle hooks. */
|
|
275
|
+
export function buildUlamRulesVue() {
|
|
276
|
+
return {
|
|
277
|
+
'no-announce-in-render': makeNoAnnounceInRender({ framework: 'vue' }),
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Angular plugin: only the announce rule, tuned for Angular lifecycle methods. */
|
|
282
|
+
export function buildUlamRulesAngular() {
|
|
283
|
+
return {
|
|
284
|
+
'no-announce-in-render': makeNoAnnounceInRender({ framework: 'angular' }),
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function buildUlamRecommendedRules(ns) {
|
|
289
|
+
return {
|
|
290
|
+
[`${ns}/no-announce-in-render`]: 'error',
|
|
291
|
+
[`${ns}/no-hash-router-in-remix`]: 'warn',
|
|
292
|
+
[`${ns}/no-use-page-title-in-remix`]: 'warn',
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Recommended rules for Vue/Angular - only the announce rule applies. */
|
|
297
|
+
export function buildUlamRecommendedRulesFramework(ns) {
|
|
298
|
+
return {
|
|
299
|
+
[`${ns}/no-announce-in-render`]: 'error',
|
|
300
|
+
}
|
|
301
|
+
}
|