@ng-native/eslint-plugin 0.0.0-stage → 0.18.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +36 -2
  3. package/index.mjs +567 -0
  4. package/package.json +56 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ashley Hunter
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,37 @@
1
- # Temporary Holding Version
1
+ # @ng-native/eslint-plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ESLint rules for Angular Native: the DOM events, HTML controls and browser APIs that compile but do
4
+ nothing on a device. `(click)` on a `<pressable>`, a `<button>`, `routerLink` and a `document` read
5
+ all pass Angular's compiler; each rule reports one and names what to write instead.
6
+
7
+ Alpha: APIs may change before 1.0.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ npm install --save-dev eslint typescript-eslint angular-eslint @ng-native/eslint-plugin
13
+ ```
14
+
15
+ An app generated with `nx g @ng-native/nx:app --linter=eslint` or
16
+ `ng add @ng-native/schematics --linter=eslint` has it set up already.
17
+
18
+ ## Example
19
+
20
+ ```js
21
+ // eslint.config.mjs
22
+ import ngNative from '@ng-native/eslint-plugin';
23
+
24
+ export default [
25
+ { ignores: ['.expo/', 'ios/', 'android/', 'dist/'] },
26
+ ...ngNative.configs.recommended,
27
+ ];
28
+ ```
29
+
30
+ ```sh
31
+ npx eslint .
32
+ ```
33
+
34
+ ## Docs
35
+
36
+ - [ESLint](https://ng-native.com/packages/eslint-plugin) for setup in each kind of workspace, each
37
+ rule and what it reports.
package/index.mjs ADDED
@@ -0,0 +1,567 @@
1
+ /**
2
+ * ESLint rules for what compiles in an Angular Native app and does nothing on a device.
3
+ *
4
+ * Angular's compiler checks a template against the browser's DOM schema, so `(click)` on a
5
+ * `<pressable>`, a `<button>` or a `document` read all compile. Here there is no DOM: the event is
6
+ * never sent, the button draws as a plain view and `document` is undefined. Each rule names what
7
+ * to write instead.
8
+ */
9
+ import { readFileSync } from 'node:fs';
10
+ import angular from 'angular-eslint';
11
+ import tseslint from 'typescript-eslint';
12
+
13
+ const PRESS = '(press) on a <pressable>, or <text pressable (press)="...">';
14
+
15
+ /**
16
+ * DOM events no native view sends, with what to listen for instead. An event the renderer is
17
+ * given is the native one of the same name (`(scroll)` is `topScroll`), so only names no native
18
+ * view or Angular Native component uses are here: `change`, `load`, `error`, `focus` and `blur`
19
+ * are outputs or native events of some element and are left alone.
20
+ */
21
+ const DOM_EVENTS = new Map([
22
+ ['click', PRESS],
23
+ ['dblclick', PRESS],
24
+ ['contextmenu', '(longPress) on a <pressable>'],
25
+ ['mousedown', '(pressIn) on a <pressable>'],
26
+ ['mouseup', '(pressOut) on a <pressable>'],
27
+ ['input', '(changeText) or [(value)] on a <text-input>'],
28
+ ['beforeinput', '(changeText) on a <text-input>'],
29
+ ['keydown', '(keyPress) or (submitEditing) on a <text-input>'],
30
+ ['keyup', '(keyPress) or (submitEditing) on a <text-input>'],
31
+ ['keypress', '(keyPress) on a <text-input>'],
32
+ ['submit', '(submitEditing) on a <text-input>, or (press) on a submit <pressable>'],
33
+ ['wheel', '(scroll) on a <scroll-view>'],
34
+ ...['mousemove', 'mouseover', 'mouseout', 'mouseenter', 'mouseleave'].map((name) => [
35
+ name,
36
+ 'a [gesture] from @ng-native/components',
37
+ ]),
38
+ ...['drag', 'dragstart', 'dragend', 'dragenter', 'dragleave', 'dragover', 'drop'].map((name) => [
39
+ name,
40
+ 'a pan [gesture] from @ng-native/components',
41
+ ]),
42
+ ...['copy', 'cut', 'paste'].map((name) => [name, '(changeText) on a <text-input>']),
43
+ ]);
44
+
45
+ /** HTML elements that draw as a plain view or text, without the behaviour they have in a browser. */
46
+ const HTML_CONTROLS = new Map([
47
+ ['button', '<pressable>'],
48
+ [
49
+ 'a',
50
+ '<text pressable (press)="...">, opening the link with DeepLinks.open from @ng-native/device',
51
+ ],
52
+ ['input', '<text-input>, or <switch> for a checkbox'],
53
+ ['textarea', '<text-input multiline>'],
54
+ ['select', '<ui-picker> from @ng-native/expo'],
55
+ ['option', '<ui-picker> from @ng-native/expo'],
56
+ ['form', 'a <view>, with Signal Forms from @angular/forms/signals'],
57
+ ['img', '<image>'],
58
+ ['picture', '<image>'],
59
+ // No native view stands in for these.
60
+ ...['video', 'audio', 'iframe', 'canvas', 'embed', 'object'].map((name) => [name, null]),
61
+ ]);
62
+
63
+ /** Packages that need a browser, or that an app here never has. */
64
+ const BROWSER_PACKAGES = new Map([
65
+ ['zone.js', 'signals: an app here is zoneless'],
66
+ ['@angular/platform-browser', 'mount() from @ng-native/platform'],
67
+ ['@angular/platform-browser-dynamic', 'mount() from @ng-native/platform'],
68
+ ['@angular/animations', 'CSS transitions, or the animations in @ng-native/components'],
69
+ ]);
70
+
71
+ /** Names imported from a package that need a browser, where the rest of the package does not. */
72
+ const BROWSER_IMPORTS = new Map([
73
+ [
74
+ '@angular/common/http',
75
+ new Map([['provideHttpClient', 'provideNativeHttpClient() from @ng-native/platform/http']]),
76
+ ],
77
+ [
78
+ '@angular/core',
79
+ new Map([['provideZoneChangeDetection', 'nothing: an app here is zoneless already']]),
80
+ ],
81
+ ]);
82
+
83
+ /**
84
+ * Browser globals that are undefined on a device. `window` is left out: React Native defines it
85
+ * as the global object, so `window.setTimeout` works.
86
+ */
87
+ const BROWSER_GLOBALS = new Map([
88
+ ['document', 'the native elements, and a template reference for a node'],
89
+ ['localStorage', 'expo-sqlite/kv-store or another native store'],
90
+ ['sessionStorage', 'a signal or a service'],
91
+ ['indexedDB', 'expo-sqlite'],
92
+ ['location', 'the Router, or DeepLinks from @ng-native/device'],
93
+ ['history', 'the Router'],
94
+ ['matchMedia', '@media in a stylesheet, or Screen and ColorScheme from @ng-native/device'],
95
+ ['getComputedStyle', '(layout) on the element'],
96
+ ]);
97
+
98
+ /**
99
+ * Members of a browser's `window` that React Native's `window`, the global object, does not have.
100
+ * `window.document` and the rest of `BROWSER_GLOBALS` are read through it too.
101
+ */
102
+ const WINDOW_MEMBERS = new Map([
103
+ ...['addEventListener', 'removeEventListener'].map((name) => [
104
+ name,
105
+ 'an event binding, or the services in @ng-native/device',
106
+ ]),
107
+ ...['innerWidth', 'innerHeight', 'outerWidth', 'outerHeight'].map((name) => [
108
+ name,
109
+ 'Screen from @ng-native/device',
110
+ ]),
111
+ ...['scrollTo', 'scrollX', 'scrollY', 'pageXOffset', 'pageYOffset'].map((name) => [
112
+ name,
113
+ 'a <scroll-view> and its scrollTo() and (scroll)',
114
+ ]),
115
+ ['open', 'DeepLinks.open from @ng-native/device'],
116
+ ['print', 'a native print module'],
117
+ ]);
118
+
119
+ /**
120
+ * DOM members an element's `nativeElement` does not have here: it is the engine's node, not an
121
+ * `HTMLElement`. Each with the way to do the same.
122
+ */
123
+ const DOM_MEMBERS = new Map([
124
+ ...['focus', 'blur'].map((name) => [
125
+ name,
126
+ `${name}() on the TextInput, from viewChild(TextInput)`,
127
+ ]),
128
+ ...[
129
+ 'getBoundingClientRect',
130
+ 'getClientRects',
131
+ 'offsetWidth',
132
+ 'offsetHeight',
133
+ 'offsetTop',
134
+ 'offsetLeft',
135
+ 'clientWidth',
136
+ 'clientHeight',
137
+ 'scrollWidth',
138
+ 'scrollHeight',
139
+ ].map((name) => [name, '(layout) on the element, which carries its size and position']),
140
+ ...['scrollTo', 'scrollBy', 'scrollIntoView', 'scrollTop', 'scrollLeft'].map((name) => [
141
+ name,
142
+ 'scrollTo() on the ScrollView, from viewChild(ScrollView)',
143
+ ]),
144
+ ...['addEventListener', 'removeEventListener', 'dispatchEvent'].map((name) => [
145
+ name,
146
+ 'an event binding in the template',
147
+ ]),
148
+ ...[
149
+ 'classList',
150
+ 'className',
151
+ 'style',
152
+ 'setAttribute',
153
+ 'getAttribute',
154
+ 'removeAttribute',
155
+ 'innerHTML',
156
+ 'innerText',
157
+ 'textContent',
158
+ 'appendChild',
159
+ 'removeChild',
160
+ 'querySelector',
161
+ 'querySelectorAll',
162
+ 'closest',
163
+ ].map((name) => [name, 'a binding in the template: [class.name], [style.name] or an input']),
164
+ ]);
165
+
166
+ /**
167
+ * The elements from @ng-native/components whose names have a hyphen. An element without one is
168
+ * HTML's or a primitive's; any other hyphenated one is a component, which may declare an output
169
+ * called `drag` or `drop`, so its events are its own business.
170
+ */
171
+ const NATIVE_ELEMENTS = new Set([
172
+ 'activity-indicator',
173
+ 'gesture-root',
174
+ 'gradient-text',
175
+ 'image-background',
176
+ 'input-accessory-view',
177
+ 'keyboard-avoiding-view',
178
+ 'keyboard-controller-view',
179
+ 'keyboard-dock',
180
+ 'keyboard-gesture-area',
181
+ 'keyboard-motion-view',
182
+ 'refresh-control',
183
+ 'safe-area-provider',
184
+ 'safe-area-view',
185
+ 'scroll-view',
186
+ 'section-list',
187
+ 'text-input',
188
+ 'touchable-opacity',
189
+ 'virtual-list',
190
+ ]);
191
+
192
+ const DOCS = 'https://ng-native.com/packages/eslint-plugin';
193
+
194
+ /**
195
+ * Whether the file is an Expo DOM component, which starts with `'use dom'` and runs in a web
196
+ * view, where the DOM is real. An inline template is linted as a file of its own, so its
197
+ * component's file is read from disk.
198
+ */
199
+ function isDomComponent(context) {
200
+ const { physicalFilename, filename } = context;
201
+ let text = context.sourceCode.text;
202
+ if (physicalFilename !== filename) {
203
+ try {
204
+ text = readFileSync(physicalFilename, 'utf8');
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+ return /^(\s|\/\/.*\n|\/\*[\s\S]*?\*\/)*['"]use dom['"]/.test(text);
210
+ }
211
+
212
+ /** @type {import('eslint').Rule.RuleModule} */
213
+ const noDomEvents = {
214
+ meta: {
215
+ type: 'problem',
216
+ docs: { description: 'Disallow DOM events no native view sends', url: `${DOCS}#no-dom-events` },
217
+ fixable: 'code',
218
+ hasSuggestions: true,
219
+ messages: {
220
+ changeText: 'Listen for (changeText), which carries the text rather than an event.',
221
+ routerLink:
222
+ 'routerLink listens for a DOM click and never navigates on a device. Use nativeRouterLink from @ng-native/router, on a <pressable>.',
223
+ event: '({{name}}) is a DOM event and never fires on a device. Use {{instead}}.',
224
+ global:
225
+ '({{target}}:{{name}}) never fires: there is no {{target}} on a device. Use @ng-native/device for app-wide events.',
226
+ },
227
+ schema: [],
228
+ },
229
+ create(context) {
230
+ if (isDomComponent(context)) return {};
231
+ /**
232
+ * Report the event `name` on `target` (a global's name, or null for the element's own), with
233
+ * `fixes` naming the replacement of an event a fix or a suggestion can rename.
234
+ */
235
+ function check(where, target, name, fixes = {}) {
236
+ if (target) {
237
+ context.report({ ...where, messageId: 'global', data: { target, name } });
238
+ return;
239
+ }
240
+ // `(keydown.enter)` is the event `keydown` with a key filter.
241
+ const event = name.split('.')[0];
242
+ const instead = DOM_EVENTS.get(event);
243
+ if (!instead) return;
244
+ const report = { ...where, messageId: 'event', data: { name: event, instead } };
245
+ if (fixes.press) report.fix = fixes.press;
246
+ if (fixes.changeText) report.suggest = [{ messageId: 'changeText', fix: fixes.changeText }];
247
+ context.report(report);
248
+ }
249
+ return {
250
+ // Angular's RouterLink is a `(click)` listener and an `href`, so it is this rule's too.
251
+ ':matches(TextAttribute, BoundAttribute)[name="routerLink"]'(attribute) {
252
+ context.report({ loc: spanLoc(attribute), messageId: 'routerLink' });
253
+ },
254
+ BoundEvent(event) {
255
+ const element = event.parent?.type === 'Element' ? event.parent : null;
256
+ const native =
257
+ element && (!element.name.includes('-') || NATIVE_ELEMENTS.has(element.name));
258
+ if (event.target || native) {
259
+ const fixes = element ? renames(context, element, event) : {};
260
+ check({ loc: spanLoc(event) }, event.target, event.name, fixes);
261
+ }
262
+ },
263
+ // A listener in a decorator's `host` object, `'(click)': 'go()'`, is on the host's own view,
264
+ // which is a plain view whatever the selector.
265
+ 'Decorator > CallExpression > ObjectExpression > Property[key.name="host"] > ObjectExpression > Property'(
266
+ property,
267
+ ) {
268
+ const key = property.key.type === 'Literal' ? String(property.key.value) : '';
269
+ const listener = /^\((?:(window|document|body):)?([^)]+)\)$/.exec(key);
270
+ if (listener) check({ node: property.key }, listener[1] ?? null, listener[2]);
271
+ },
272
+ 'Decorator > CallExpression[callee.name="HostListener"] > Literal:first-child'(literal) {
273
+ const listener = /^(?:(window|document|body):)?(.+)$/.exec(String(literal.value));
274
+ if (listener) check({ node: literal }, listener[1] ?? null, listener[2]);
275
+ },
276
+ };
277
+ },
278
+ };
279
+
280
+ /** @type {import('eslint').Rule.RuleModule} */
281
+ const noHtmlControls = {
282
+ meta: {
283
+ type: 'problem',
284
+ docs: {
285
+ description: 'Disallow HTML elements that lose their behaviour on a device',
286
+ url: `${DOCS}#no-html-controls`,
287
+ },
288
+ messages: {
289
+ element: '<{{name}}> draws as a plain view on a device, with no behaviour. Use {{instead}}.',
290
+ none: '<{{name}}> draws as an empty view on a device: nothing in Angular Native draws it.',
291
+ },
292
+ schema: [],
293
+ },
294
+ create(context) {
295
+ if (isDomComponent(context)) return {};
296
+ return {
297
+ Element(element) {
298
+ if (!HTML_CONTROLS.has(element.name)) return;
299
+ const instead = HTML_CONTROLS.get(element.name);
300
+ const data = { name: element.name, instead };
301
+ context.report({ loc: spanLoc(element), messageId: instead ? 'element' : 'none', data });
302
+ },
303
+ };
304
+ },
305
+ };
306
+
307
+ /** @type {import('eslint').Rule.RuleModule} */
308
+ const noBrowserApis = {
309
+ meta: {
310
+ type: 'problem',
311
+ docs: {
312
+ description: 'Disallow browser APIs a device does not have',
313
+ url: `${DOCS}#no-browser-apis`,
314
+ },
315
+ messages: {
316
+ package: '{{name}} needs a browser. Use {{instead}}.',
317
+ import: '{{name}} from {{source}} needs a browser. Use {{instead}}.',
318
+ global: '{{name}} is undefined on a device. Use {{instead}}.',
319
+ member: '{{object}}.{{name}} is undefined on a device. Use {{instead}}.',
320
+ nativeHttp: 'Use provideNativeHttpClient from @ng-native/platform/http.',
321
+ },
322
+ hasSuggestions: true,
323
+ schema: [],
324
+ },
325
+ create(context) {
326
+ if (isDomComponent(context)) return {};
327
+ return {
328
+ ImportDeclaration(node) {
329
+ const source = String(node.source.value);
330
+ const pkg = [...BROWSER_PACKAGES.keys()].find(
331
+ (name) => source === name || source.startsWith(`${name}/`),
332
+ );
333
+ if (pkg) {
334
+ const data = { name: pkg, instead: BROWSER_PACKAGES.get(pkg) };
335
+ context.report({ node: node.source, messageId: 'package', data });
336
+ return;
337
+ }
338
+ const names = BROWSER_IMPORTS.get(source);
339
+ for (const specifier of node.specifiers) {
340
+ if (specifier.type !== 'ImportSpecifier') continue;
341
+ const name =
342
+ specifier.imported.type === 'Identifier'
343
+ ? specifier.imported.name
344
+ : specifier.imported.value;
345
+ const instead = names?.get(name);
346
+ if (!instead) continue;
347
+ const report = { node: specifier, messageId: 'import', data: { name, source, instead } };
348
+ if (name === 'provideHttpClient') {
349
+ report.suggest = [
350
+ {
351
+ messageId: 'nativeHttp',
352
+ fix: (fixer) => toNativeHttp(context, fixer, node, specifier),
353
+ },
354
+ ];
355
+ }
356
+ context.report(report);
357
+ }
358
+ },
359
+ 'Program:exit'(program) {
360
+ // A global read is a reference nothing in the file declares: one that resolves to
361
+ // nothing, or to a global the config declares (`globalThis`, or `globals.browser`).
362
+ const scope = context.sourceCode.getScope(program);
363
+ const configured = scope.variables.filter((variable) => variable.defs.length === 0);
364
+ const references = [...scope.through, ...configured.flatMap((one) => one.references)];
365
+ for (const reference of references) {
366
+ const { identifier } = reference;
367
+ const instead = BROWSER_GLOBALS.get(identifier.name);
368
+ if (instead) {
369
+ const data = { name: identifier.name, instead };
370
+ context.report({ node: identifier, messageId: 'global', data });
371
+ } else if (identifier.name === 'window' || identifier.name === 'globalThis') {
372
+ checkWindowMember(context, identifier);
373
+ }
374
+ }
375
+ },
376
+ };
377
+ },
378
+ };
379
+
380
+ /**
381
+ * The fixes that rename a DOM event to the native one, for an element that has it: `(click)` on a
382
+ * `<pressable>`, a `<touchable-opacity>` or a `<text pressable>` is `(press)`, fixed unless the
383
+ * handler reads `$event`, a mouse event there and a native one after; `(input)` on a
384
+ * `<text-input>` is `(changeText)`, only suggested, since its `$event` is the text.
385
+ */
386
+ function renames(context, element, event) {
387
+ const { keySpan, handlerSpan } = event;
388
+ if (!keySpan || event.name.includes('.')) return {};
389
+ const range = [keySpan.start.offset, keySpan.end.offset];
390
+ const handler = context.sourceCode.text.slice(handlerSpan.start.offset, handlerSpan.end.offset);
391
+ const pressable =
392
+ element.name === 'pressable' ||
393
+ element.name === 'touchable-opacity' ||
394
+ (element.name === 'text' &&
395
+ [...element.attributes, ...element.inputs].some((one) => one.name === 'pressable'));
396
+ if (event.name === 'click' && pressable && !handler.includes('$event')) {
397
+ return { press: (fixer) => fixer.replaceTextRange(range, 'press') };
398
+ }
399
+ if (event.name === 'input' && element.name === 'text-input') {
400
+ return { changeText: (fixer) => fixer.replaceTextRange(range, 'changeText') };
401
+ }
402
+ return {};
403
+ }
404
+
405
+ /** `ScrollView` as its element's name: `scroll-view`. */
406
+ const kebab = (name) => name.replace(/(?<!^)[A-Z]/g, (letter) => `-${letter}`).toLowerCase();
407
+
408
+ /** @type {import('eslint').Rule.RuleModule} */
409
+ const lowercaseElements = {
410
+ meta: {
411
+ type: 'problem',
412
+ docs: {
413
+ description: 'Require element names in lowercase, as the components are selected',
414
+ url: `${DOCS}#lowercase-elements`,
415
+ },
416
+ fixable: 'code',
417
+ messages: {
418
+ element: '<{{name}}> matches no component and fails the build. Write <{{lower}}>.',
419
+ },
420
+ schema: [],
421
+ },
422
+ create(context) {
423
+ return {
424
+ Element(element) {
425
+ if (!/^[A-Z]/.test(element.name)) return;
426
+ const lower = kebab(element.name);
427
+ const { startSourceSpan, endSourceSpan } = element;
428
+ // The name starts after `<` in the start tag, and after `</` in an end tag.
429
+ const tags = [startSourceSpan.start.offset + 1];
430
+ if (endSourceSpan && endSourceSpan.start.offset !== startSourceSpan.start.offset) {
431
+ tags.push(endSourceSpan.start.offset + 2);
432
+ }
433
+ context.report({
434
+ loc: spanLoc({ sourceSpan: startSourceSpan }),
435
+ messageId: 'element',
436
+ data: { name: element.name, lower },
437
+ fix: (fixer) =>
438
+ tags.map((at) => fixer.replaceTextRange([at, at + element.name.length], lower)),
439
+ });
440
+ },
441
+ };
442
+ },
443
+ };
444
+
445
+ /** @type {import('eslint').Rule.RuleModule} */
446
+ const noDomNodeAccess = {
447
+ meta: {
448
+ type: 'problem',
449
+ docs: {
450
+ description: "Disallow DOM members on an element's nativeElement, which is not a DOM node",
451
+ url: `${DOCS}#no-dom-node-access`,
452
+ },
453
+ messages: {
454
+ member:
455
+ 'nativeElement is a native node, not a DOM element: it has no {{name}}. Use {{instead}}.',
456
+ },
457
+ schema: [],
458
+ },
459
+ create(context) {
460
+ if (isDomComponent(context)) return {};
461
+ return {
462
+ // ponytail: reads `x.nativeElement.focus()` written out; a node kept in a variable first is
463
+ // not followed, which would need the type checker.
464
+ 'MemberExpression[object.type="MemberExpression"][object.property.name="nativeElement"]'(
465
+ node,
466
+ ) {
467
+ const name =
468
+ node.property.type === 'Identifier' && !node.computed ? node.property.name : '';
469
+ const instead = DOM_MEMBERS.get(name);
470
+ if (instead)
471
+ context.report({ node: node.property, messageId: 'member', data: { name, instead } });
472
+ },
473
+ };
474
+ },
475
+ };
476
+
477
+ /** Report `window.document`, `globalThis.localStorage`, `window.addEventListener` and the like. */
478
+ function checkWindowMember(context, identifier) {
479
+ const member = identifier.parent;
480
+ if (member?.type !== 'MemberExpression' || member.object !== identifier) return;
481
+ const name = member.property.name;
482
+ const instead = BROWSER_GLOBALS.get(name) ?? WINDOW_MEMBERS.get(name);
483
+ if (!instead) return;
484
+ const data = { object: identifier.name, name, instead };
485
+ context.report({ node: member, messageId: 'member', data });
486
+ }
487
+
488
+ /**
489
+ * Swap `provideHttpClient` for `provideNativeHttpClient`: its import from
490
+ * `@ng-native/platform/http`, and each call by the new name. The features it takes are the same.
491
+ */
492
+ function toNativeHttp(context, fixer, declaration, specifier) {
493
+ const { sourceCode } = context;
494
+ const local = specifier.local.name;
495
+ const variable = sourceCode.getDeclaredVariables(specifier).find((one) => one.name === local);
496
+ const fixes = (variable?.references ?? []).map((reference) =>
497
+ fixer.replaceText(reference.identifier, 'provideNativeHttpClient'),
498
+ );
499
+ const line = "import { provideNativeHttpClient } from '@ng-native/platform/http';";
500
+ if (declaration.specifiers.length === 1) {
501
+ fixes.push(fixer.replaceText(declaration, line));
502
+ return fixes;
503
+ }
504
+ // The name and the comma after it up to the next name, or the comma before it for the last.
505
+ const after = sourceCode.getTokenAfter(specifier);
506
+ const range =
507
+ after.value === ','
508
+ ? [specifier.range[0], sourceCode.getTokenAfter(after).range[0]]
509
+ : [sourceCode.getTokenBefore(specifier).range[0], specifier.range[1]];
510
+ fixes.push(fixer.removeRange(range));
511
+ fixes.push(fixer.insertTextAfter(declaration, `\n${line}`));
512
+ return fixes;
513
+ }
514
+
515
+ /** An Angular template node's place, as ESLint reads one from angular-eslint's parser. */
516
+ function spanLoc(node) {
517
+ const { start, end } = node.sourceSpan;
518
+ return {
519
+ start: { line: start.line + 1, column: start.col },
520
+ end: { line: end.line + 1, column: end.col },
521
+ };
522
+ }
523
+
524
+ const plugin = {
525
+ meta: { name: '@ng-native/eslint-plugin' },
526
+ rules: {
527
+ 'no-dom-events': noDomEvents,
528
+ 'no-html-controls': noHtmlControls,
529
+ 'no-browser-apis': noBrowserApis,
530
+ 'no-dom-node-access': noDomNodeAccess,
531
+ 'lowercase-elements': lowercaseElements,
532
+ },
533
+ };
534
+
535
+ /**
536
+ * The rules on, for an app's TypeScript and its templates, inline ones included. angular-eslint
537
+ * parses the templates and pulls inline ones out of the `.ts` files.
538
+ *
539
+ * @type {import('eslint').Linter.Config[]}
540
+ */
541
+ const recommended = [
542
+ {
543
+ name: '@ng-native/recommended/ts',
544
+ files: ['**/*.ts'],
545
+ languageOptions: { parser: tseslint.parser },
546
+ plugins: { '@ng-native': plugin },
547
+ processor: angular.processInlineTemplates,
548
+ rules: {
549
+ '@ng-native/no-browser-apis': 'error',
550
+ '@ng-native/no-dom-events': 'error',
551
+ '@ng-native/no-dom-node-access': 'error',
552
+ },
553
+ },
554
+ {
555
+ name: '@ng-native/recommended/html',
556
+ files: ['**/*.html'],
557
+ languageOptions: { parser: angular.templateParser },
558
+ plugins: { '@ng-native': plugin },
559
+ rules: {
560
+ '@ng-native/no-dom-events': 'error',
561
+ '@ng-native/no-html-controls': 'error',
562
+ '@ng-native/lowercase-elements': 'error',
563
+ },
564
+ },
565
+ ];
566
+
567
+ export default { ...plugin, configs: { recommended } };
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@ng-native/eslint-plugin",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.18.0",
4
+ "description": "ESLint rules for Angular Native: the DOM events, HTML controls and browser APIs that compile but do nothing on a device.",
5
+ "keywords": [
6
+ "angular",
7
+ "react-native",
8
+ "angular-native",
9
+ "eslint",
10
+ "eslint-plugin",
11
+ "angular-eslint"
12
+ ],
13
+ "type": "module",
14
+ "main": "index.mjs",
15
+ "exports": {
16
+ ".": "./index.mjs",
17
+ "./package.json": "./package.json"
18
+ },
19
+ "peerDependencies": {
20
+ "angular-eslint": "^22.0.0",
21
+ "eslint": "^9.0.0 || ^10.0.0",
22
+ "typescript-eslint": "^8.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "angular-eslint": "^22.6.0",
26
+ "eslint": "^10.11.0",
27
+ "typescript": "~6.0.3",
28
+ "typescript-eslint": "^8.70.1"
29
+ },
30
+ "nx": {
31
+ "tags": [
32
+ "scope:eslint-plugin",
33
+ "layer:build"
34
+ ],
35
+ "implicitDependencies": [
36
+ "@ng-native/components"
37
+ ]
38
+ },
39
+ "license": "MIT",
40
+ "homepage": "https://ng-native.com/packages/eslint-plugin",
41
+ "bugs": {
42
+ "url": "https://github.com/ng-native/ng-native/issues"
43
+ },
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "git+https://github.com/ng-native/ng-native.git",
47
+ "directory": "packages/eslint-plugin"
48
+ },
49
+ "files": [
50
+ "*.mjs",
51
+ "!*.test.*"
52
+ ],
53
+ "publishConfig": {
54
+ "access": "public"
55
+ },
56
+ "scripts": {
57
+ "test": "node --test \"*.test.ts\""
58
+ }
6
59
  }