@a11yfred/neighbor 0.3.0 → 1.0.3

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/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 visitorsthey do not take the `h` adapter.
7
- *
8
- * Rules:
9
- * no-announce-in-renderannounce() called in component body, not effect/handler
10
- * no-hash-router-in-remiximporting from siling-labuyo/hashRouter in a Remix project
11
- * no-use-page-title-in-remixusePageTitle() 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 safeAngular 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 callbacksafe (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 traversingmay 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 Remixuse 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/Angularonly the announce rule applies. */
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
+ }