@ethlete/eslint-plugin 1.0.0-next.18 → 1.0.0-next.20

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 CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-next.20
4
+
5
+ ### Patch Changes
6
+
7
+ - [`085c95e`](https://github.com/ethlete-io/ethdk/commit/085c95ebe1ecb8489b00b042066997a7a9dc8e5e) Thanks [@TomTomB](https://github.com/TomTomB)! - `require-form-submit` accepts `[etForm]` and `[formRoot]` as a form's submit handling.
8
+
9
+ - [`4350d8e`](https://github.com/ethlete-io/ethdk/commit/4350d8e1eca00447ba130ec5a0ce43a64cfc4496) Thanks [@TomTomB](https://github.com/TomTomB)! - `no-trivial-wrapper-method` no longer flags `focus`, `blur` or `reset` on a component or directive - Angular resolves those by name on the instance.
10
+
11
+ ## 1.0.0-next.19
12
+
13
+ ### Minor Changes
14
+
15
+ - [#3055](https://github.com/ethlete-io/ethdk/pull/3055) [`2fcb8e3`](https://github.com/ethlete-io/ethdk/commit/2fcb8e3799169956571534b75fdc152acc983877) Thanks [@github-actions](https://github.com/apps/github-actions)! - Add `require-form-submit` (a form handles its own submission, a submit control reaches a form) plus the opt-in `no-cdk-import` and `no-legacy-query-import`, which name each legacy symbol's successor in the message.
16
+
3
17
  ## 1.0.0-next.18
4
18
 
5
19
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ethlete/eslint-plugin",
3
- "version": "1.0.0-next.18",
3
+ "version": "1.0.0-next.20",
4
4
  "license": "MIT",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -422,6 +422,8 @@ const recommendedTemplate = {
422
422
  // (warn + suggestion-only: the rewrite is only safe when the input has a booleanAttribute transform,
423
423
  // which a template rule cannot verify)
424
424
  'ethlete/prefer-static-boolean-properties': 'warn',
425
+ // A form must handle its own submission, and a type="submit" control must reach a form
426
+ 'ethlete/require-form-submit': 'error',
425
427
  },
426
428
  };
427
429
 
package/src/index.js CHANGED
@@ -55,6 +55,9 @@ const preferPresentTenseOutput = require('./rules/prefer-present-tense-output');
55
55
  const preferStaticBooleanProperties = require('./rules/prefer-static-boolean-properties');
56
56
  const noImpureTopLevelProvider = require('./rules/no-impure-top-level-provider');
57
57
  const noLegacyPrepareWithoutInjector = require('./rules/no-legacy-prepare-without-injector');
58
+ const requireFormSubmit = require('./rules/require-form-submit');
59
+ const noCdkImport = require('./rules/no-cdk-import');
60
+ const noLegacyQueryImport = require('./rules/no-legacy-query-import');
58
61
  const { recommendedTs, recommendedTemplate, recommendedSpec } = require('./configs/recommended');
59
62
 
60
63
  /** @type {import('eslint').ESLint.Plugin} */
@@ -118,6 +121,9 @@ const plugin = {
118
121
  'prefer-present-tense-output': preferPresentTenseOutput,
119
122
  'prefer-static-boolean-properties': preferStaticBooleanProperties,
120
123
  'no-impure-top-level-provider': noImpureTopLevelProvider,
124
+ 'require-form-submit': requireFormSubmit,
125
+ 'no-cdk-import': noCdkImport,
126
+ 'no-legacy-query-import': noLegacyQueryImport,
121
127
  },
122
128
  };
123
129
 
@@ -0,0 +1,154 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ const { createRequire } = require('node:module');
5
+ const path = require('node:path');
6
+ const fs = require('node:fs');
7
+
8
+ /**
9
+ * Disallows importing from `@ethlete/cdk`, the maintenance-mode predecessor of `@ethlete/components`,
10
+ * and names the successor of each imported symbol in the message.
11
+ *
12
+ * import { ButtonComponent } from '@ethlete/cdk';
13
+ * → `ButtonComponent` is legacy @ethlete/cdk. Use `ButtonComponent` from @ethlete/components
14
+ * (https://…/components/button): a real button system - variant, size and color inputs plus
15
+ * theming instead of CSS-only classes.
16
+ *
17
+ * The successors come from `migration-map.json`, which ships inside the `@ethlete/cdk` package - so
18
+ * the advice is always the installed version's, and this rule holds no copy of it. Without the map on
19
+ * disk the rule still reports, pointing at the migration guide instead of a specific symbol.
20
+ *
21
+ * Off by default: it is only useful once an app has decided to leave the cdk behind.
22
+ */
23
+
24
+ const CDK_PACKAGE = '@ethlete/cdk';
25
+ const MIGRATION_MAP = '@ethlete/cdk/migration-map.json';
26
+ const DEFAULT_DOCS_BASE_URL = 'https://ethlete-sdk-docs.web.app';
27
+
28
+ /** @type {Map<string, Record<string, { kind: string, to?: string, package?: string, docs?: string, note?: string }> | null>} */
29
+ const mapCache = new Map();
30
+
31
+ /**
32
+ * @param {string | undefined} mapPath
33
+ * @param {string} cwd
34
+ */
35
+ const loadMigrationMap = (mapPath, cwd) => {
36
+ const cacheKey = mapPath ? path.resolve(cwd, mapPath) : MIGRATION_MAP;
37
+
38
+ if (mapCache.has(cacheKey)) return mapCache.get(cacheKey) ?? null;
39
+
40
+ let map;
41
+
42
+ try {
43
+ map = mapPath
44
+ ? JSON.parse(fs.readFileSync(cacheKey, 'utf8'))
45
+ : createRequire(path.join(cwd, 'noop.js'))(MIGRATION_MAP);
46
+ } catch {
47
+ map = null;
48
+ }
49
+
50
+ mapCache.set(cacheKey, map);
51
+
52
+ return map;
53
+ };
54
+
55
+ /** @param {any} specifier */
56
+ const importedName = (specifier) => {
57
+ if (specifier.type !== 'ImportSpecifier') return null;
58
+
59
+ return specifier.imported.name ?? specifier.imported.value ?? null;
60
+ };
61
+
62
+ /** @type {import('eslint').Rule.RuleModule} */
63
+ const noCdkImport = {
64
+ meta: {
65
+ type: 'suggestion',
66
+ docs: {
67
+ description: 'Disallow importing from the maintenance-mode @ethlete/cdk; name the @ethlete/components successor.',
68
+ },
69
+ schema: [
70
+ {
71
+ type: 'object',
72
+ properties: {
73
+ /** Where to read the migration map from, when `@ethlete/cdk` is not resolvable from the linted project. */
74
+ migrationMapPath: { type: 'string' },
75
+ /** Base URL the migration map's doc paths are appended to. */
76
+ docsBaseUrl: { type: 'string' },
77
+ },
78
+ additionalProperties: false,
79
+ },
80
+ ],
81
+ messages: {
82
+ successor: '`{{ name }}` is legacy {{ cdk }}. Use `{{ to }}` from {{ package }} instead ({{ docs }}).{{ note }}',
83
+ noSuccessor: '`{{ name }}` is legacy {{ cdk }} and has no successor{{ note }} - see {{ docs }}.',
84
+ unmapped: '`{{ name }}` is legacy {{ cdk }}. Move it to @ethlete/components - see {{ docs }}.',
85
+ module: 'Do not depend on the legacy {{ cdk }} - move to @ethlete/components, see {{ docs }}.',
86
+ },
87
+ },
88
+ create(context) {
89
+ const options = context.options[0] ?? {};
90
+ const docsBaseUrl = (options.docsBaseUrl ?? DEFAULT_DOCS_BASE_URL).replace(/\/$/, '');
91
+ const migrationDocs = `${docsBaseUrl}/cdk/migration`;
92
+ const map = loadMigrationMap(options.migrationMapPath, context.cwd);
93
+
94
+ /** @param {string} name */
95
+ const entryFor = (name) => map?.[name] ?? null;
96
+
97
+ return {
98
+ ImportDeclaration(node) {
99
+ const declaration = /** @type {any} */ (node);
100
+ const source = declaration.source.value;
101
+
102
+ if (typeof source !== 'string' || (source !== CDK_PACKAGE && !source.startsWith(`${CDK_PACKAGE}/`))) return;
103
+
104
+ const named = declaration.specifiers.filter(/** @param {any} s */ (s) => importedName(s));
105
+
106
+ if (!named.length) {
107
+ context.report({ node, messageId: 'module', data: { cdk: CDK_PACKAGE, docs: migrationDocs } });
108
+
109
+ return;
110
+ }
111
+
112
+ for (const specifier of named) {
113
+ const name = /** @type {string} */ (importedName(specifier));
114
+ const entry = entryFor(name);
115
+
116
+ if (!entry) {
117
+ context.report({
118
+ node: specifier,
119
+ messageId: 'unmapped',
120
+ data: { name, cdk: CDK_PACKAGE, docs: migrationDocs },
121
+ });
122
+
123
+ continue;
124
+ }
125
+
126
+ if (!entry.to) {
127
+ context.report({
128
+ node: specifier,
129
+ messageId: 'noSuccessor',
130
+ data: { name, cdk: CDK_PACKAGE, note: entry.note ? ` (${entry.note})` : '', docs: migrationDocs },
131
+ });
132
+
133
+ continue;
134
+ }
135
+
136
+ context.report({
137
+ node: specifier,
138
+ messageId: 'successor',
139
+ data: {
140
+ name,
141
+ cdk: CDK_PACKAGE,
142
+ to: entry.to,
143
+ package: entry.package ?? '@ethlete/components',
144
+ docs: entry.docs ? `${docsBaseUrl}${entry.docs}` : migrationDocs,
145
+ note: entry.note ? ` ${entry.note}.` : '',
146
+ },
147
+ });
148
+ }
149
+ },
150
+ };
151
+ },
152
+ };
153
+
154
+ module.exports = noCdkImport;
@@ -0,0 +1,136 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Disallows importing the legacy (v2) query system from `@ethlete/query`, and names the current-system
6
+ * API in the message.
7
+ *
8
+ * import { V2QueryClient, filterSuccess } from '@ethlete/query';
9
+ * → `V2QueryClient` is the legacy (v2) query system. Use `createQueryClient` instead (…/query/queries).
10
+ * → `filterSuccess` … Use `query.response()` instead - the query is already signals (…/query/queries).
11
+ *
12
+ * Two things are matched: every `V2`/`AnyV2`-prefixed export - the prefix the library gives the legacy
13
+ * system's half of a colliding name - and the legacy APIs that never collided, which carry their
14
+ * successor from the migration guide. `createLegacyQueryCreator` is deliberately **not** matched: it is
15
+ * the sanctioned interop seam a migration leans on until its call sites are converted.
16
+ *
17
+ * Off by default, and deliberately not type-aware: it names successors rather than repeating the
18
+ * `@deprecated` tag every legacy export already carries. For the whole deprecated surface (including
19
+ * the types this rule leaves alone), enable `@typescript-eslint/no-deprecated` alongside it.
20
+ */
21
+
22
+ const QUERY_PACKAGE = '@ethlete/query';
23
+ const DEFAULT_DOCS_BASE_URL = 'https://ethlete-sdk-docs.web.app';
24
+
25
+ /**
26
+ * The legacy APIs whose names never collided with the current system, and what the migration guide
27
+ * (`/query/legacy#migrating-to-the-current-system`) maps each one to.
28
+ *
29
+ * @type {Record<string, { to: string, docs: string }>}
30
+ */
31
+ const LEGACY_SYMBOLS = {
32
+ def: { to: 'the type parameter of a current creator - `createGetQuery(client)<TArgs>(route)`', docs: '/query/http' },
33
+ BasicAuthProvider: { to: 'no equivalent - the current system authenticates through `createBearerAuthProvider`', docs: '/query/auth' }, // prettier-ignore
34
+ CustomHeaderAuthProvider: { to: '`headers` on `createQueryClient`, which re-reads a function form per request', docs: '/query/queries#the-query-client' }, // prettier-ignore
35
+ EntityStore: { to: 'nothing directly - caching dedupes by request, and shared state derives from signals', docs: '/query/caching' }, // prettier-ignore
36
+ InfinityQuery: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
37
+ InfinityQueryDirective: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
38
+ InfinityQueryTriggerDirective: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
39
+ createInfinityQueryConfig: { to: '`createPagedQueryStack`', docs: '/query/stacks#paged-queries' },
40
+ QueryDirective: { to: "the query's own signals, read directly in the template", docs: '/query/migrating-from-v2#templates-read-signals-not-directives' }, // prettier-ignore
41
+ filterSuccess: { to: '`query.response()`, or `query.response.asObservable()` where a stream is needed', docs: '/query/queries#the-query-object' }, // prettier-ignore
42
+ filterFailure: { to: '`query.error()`, or `query.error.asObservable()` where a stream is needed', docs: '/query/queries#the-query-object' }, // prettier-ignore
43
+ switchQueryState: { to: "the query's own signals - each one is an `ObservableSignal`", docs: '/query/queries#the-query-object' }, // prettier-ignore
44
+ takeUntilResponse: { to: "the query's own signals - each one is an `ObservableSignal`", docs: '/query/queries#the-query-object' }, // prettier-ignore
45
+ toQuerySignal: { to: 'the query object itself - it is already signals', docs: '/query/queries#the-query-object' },
46
+ queryStateSignal: { to: 'the query object itself - it is already signals', docs: '/query/queries#the-query-object' }, // prettier-ignore
47
+ queryStateResponseSignal: { to: '`query.response()`', docs: '/query/queries#the-query-object' },
48
+ queryStateErrorSignal: { to: '`query.error()`', docs: '/query/queries#the-query-object' },
49
+ queryStateLoadingSignal: { to: '`query.loading()`', docs: '/query/queries#the-query-object' },
50
+ validateWithV2Query: { to: '`validateWithQuery`', docs: '/query/errors#validating-against-the-server-as-the-user-types' }, // prettier-ignore
51
+ provideQueryClientForDevtools: { to: '`provideQueryDevtools()` from `@ethlete/query-devtools`, which registers every client at once', docs: '/query-devtools/' }, // prettier-ignore
52
+ };
53
+
54
+ /** The current-system counterparts of the names the legacy system had to give up its half of. */
55
+ const V2_SUCCESSORS = {
56
+ V2QueryClient: { to: '`createQueryClient`', docs: '/query/queries#the-query-client' },
57
+ V2QueryClientConfig: { to: 'the config of `createQueryClient`', docs: '/query/queries#the-query-client' },
58
+ V2QueryCreator: { to: '`createGetQuery` and its siblings', docs: '/query/http' },
59
+ AnyV2QueryCreator: { to: '`createGetQuery` and its siblings', docs: '/query/http' },
60
+ V2BearerAuthProvider: { to: '`createBearerAuthProvider` plus the secure creator templates', docs: '/query/auth' },
61
+ };
62
+
63
+ /** @param {any} specifier */
64
+ const importedName = (specifier) => {
65
+ if (specifier.type !== 'ImportSpecifier') return null;
66
+
67
+ return specifier.imported.name ?? specifier.imported.value ?? null;
68
+ };
69
+
70
+ /** @param {string} name */
71
+ const isV2Symbol = (name) => /^(V2|AnyV2)[A-Z]/.test(name);
72
+
73
+ /** @type {import('eslint').Rule.RuleModule} */
74
+ const noLegacyQueryImport = {
75
+ meta: {
76
+ type: 'suggestion',
77
+ docs: {
78
+ description: 'Disallow importing the legacy (v2) query system; name the current-system API instead.',
79
+ },
80
+ schema: [
81
+ {
82
+ type: 'object',
83
+ properties: {
84
+ /** Base URL the guide paths in the messages are appended to. */
85
+ docsBaseUrl: { type: 'string' },
86
+ },
87
+ additionalProperties: false,
88
+ },
89
+ ],
90
+ messages: {
91
+ successor: '`{{ name }}` is the legacy (v2) query system. Use {{ to }} instead ({{ docs }}).',
92
+ legacySystem:
93
+ '`{{ name }}` is the legacy (v2) query system. Migrate to the current one - see {{ docs }}, and run `nx g @ethlete/query:migrate-to-query-v3` for the mechanical parts.',
94
+ },
95
+ },
96
+ create(context) {
97
+ const options = context.options[0] ?? {};
98
+ const docsBaseUrl = (options.docsBaseUrl ?? DEFAULT_DOCS_BASE_URL).replace(/\/$/, '');
99
+
100
+ return {
101
+ ImportDeclaration(node) {
102
+ const declaration = /** @type {any} */ (node);
103
+
104
+ if (declaration.source.value !== QUERY_PACKAGE) return;
105
+
106
+ for (const specifier of declaration.specifiers) {
107
+ const name = importedName(specifier);
108
+
109
+ if (!name) continue;
110
+
111
+ const successor = V2_SUCCESSORS[name] ?? LEGACY_SYMBOLS[name];
112
+
113
+ if (successor) {
114
+ context.report({
115
+ node: specifier,
116
+ messageId: 'successor',
117
+ data: { name, to: successor.to, docs: `${docsBaseUrl}${successor.docs}` },
118
+ });
119
+
120
+ continue;
121
+ }
122
+
123
+ if (!isV2Symbol(name)) continue;
124
+
125
+ context.report({
126
+ node: specifier,
127
+ messageId: 'legacySystem',
128
+ data: { name, docs: `${docsBaseUrl}/query/migrating-from-v2` },
129
+ });
130
+ }
131
+ },
132
+ };
133
+ },
134
+ };
135
+
136
+ module.exports = noLegacyQueryImport;
@@ -16,14 +16,41 @@
16
16
  * GOOD:
17
17
  * // Call this.value.set(val) directly at the call sites.
18
18
  * // If the method wraps something non-trivially (transforms args, adds logic), it is fine.
19
+ * // `focus`/`blur`/`reset` on a @Component/@Directive are exempt - see CONTRACT_METHOD_NAMES.
19
20
  */
20
21
 
22
+ /**
23
+ * Imperative members Angular and the DOM look up **by name on the instance**. A component that
24
+ * wraps a control in a host directive has to re-expose them itself: signal forms resolves a custom
25
+ * control to the wrapper component, not to its host directives, so a `focus()` that only forwards
26
+ * is the whole contract - there is no call site to inline it into.
27
+ */
28
+ const CONTRACT_METHOD_NAMES = new Set(['focus', 'blur', 'reset']);
29
+
21
30
  /**
22
31
  * Returns true when the param is a plain identifier (no default, no destructuring, no rest).
23
32
  * @param {import('@typescript-eslint/types').TSESTree.Parameter} param
24
33
  */
25
34
  const isSimpleParam = (param) => param.type === 'Identifier';
26
35
 
36
+ /**
37
+ * Whether the class holding this method is an Angular component or directive.
38
+ * @param {import('eslint').Rule.Node} methodNode MethodDefinition node
39
+ */
40
+ const isInAngularClass = (methodNode) => {
41
+ const classNode = methodNode.parent?.parent;
42
+ const decorators = classNode && 'decorators' in classNode ? classNode.decorators : null;
43
+
44
+ if (!decorators) return false;
45
+
46
+ return decorators.some((decorator) => {
47
+ const expression = decorator.expression;
48
+ const callee = expression.type === 'CallExpression' ? expression.callee : expression;
49
+
50
+ return callee.type === 'Identifier' && (callee.name === 'Component' || callee.name === 'Directive');
51
+ });
52
+ };
53
+
27
54
  /**
28
55
  * Returns the single CallExpression in the method body when the method is a
29
56
  * trivial all-args-forwarding wrapper, or null otherwise.
@@ -97,6 +124,8 @@ const noTrivialWrapperMethod = {
97
124
  const methodName = node.key.type === 'Identifier' ? node.key.name : null;
98
125
  if (!methodName) return;
99
126
 
127
+ if (CONTRACT_METHOD_NAMES.has(methodName) && isInAngularClass(node)) return;
128
+
100
129
  const callee = callExpr.callee;
101
130
  const targetName =
102
131
  callee.type === 'MemberExpression'
@@ -0,0 +1,95 @@
1
+ // @ts-check
2
+ 'use strict';
3
+
4
+ /**
5
+ * Checks the two ends of the same wire: that a `<form>` handles its own submission, and that a
6
+ * submit control reaches a form at all.
7
+ *
8
+ * <form (ngSubmit)="save()"> ✔
9
+ * <form [etForm]="form"> ✔ signal forms - the directive submits the field tree
10
+ * <form> ✘ pressing Enter in a field does nothing, or reloads the page
11
+ *
12
+ * <form …><button type="submit"> ✔
13
+ * <button type="submit" form="edit"> ✔ associated by id, outside the form's subtree
14
+ * <button type="submit"> ✘ submits nothing
15
+ *
16
+ * A form declaring native submission (`action`, `ngNoForm`, `method="dialog"`) is left alone - it is
17
+ * handled by the platform rather than by a handler.
18
+ */
19
+
20
+ /** @param {any} node @param {string} name */
21
+ const attribute = (node, name) => node.attributes?.find(/** @param {any} a */ (a) => a.name === name);
22
+
23
+ /** @param {any} node @param {string} name */
24
+ const hasBinding = (node, name) =>
25
+ node.inputs?.some(/** @param {any} i */ (i) => i.name === name) ||
26
+ node.attributes?.some(/** @param {any} a */ (a) => a.name === name);
27
+
28
+ /** @param {any} node */
29
+ const handlesSubmit = (node) =>
30
+ node.outputs?.some(/** @param {any} o */ (o) => o.name === 'submit' || o.name === 'ngSubmit') ||
31
+ hasBinding(node, 'etForm') ||
32
+ hasBinding(node, 'formRoot');
33
+
34
+ /** @param {any} node */
35
+ const submitsNatively = (node) =>
36
+ hasBinding(node, 'action') || hasBinding(node, 'ngNoForm') || attribute(node, 'method')?.value === 'dialog';
37
+
38
+ /** @param {any} node */
39
+ const isSubmitControl = (node) =>
40
+ (node.name === 'button' || node.name === 'input') && attribute(node, 'type')?.value === 'submit';
41
+
42
+ /** @type {import('eslint').Rule.RuleModule} */
43
+ const requireFormSubmit = {
44
+ meta: {
45
+ type: 'problem',
46
+ docs: {
47
+ description: 'Require a `<form>` to handle its own submission, and a submit control to reach a form.',
48
+ },
49
+ schema: [],
50
+ messages: {
51
+ missingSubmitHandler:
52
+ 'This `<form>` handles no submission - pressing Enter in a field either does nothing or reloads the page. Bind `[etForm]` (signal forms), `(ngSubmit)` (reactive forms) or `(submit)`, or use a plain element if this is not a form.',
53
+ submitOutsideForm:
54
+ 'A `type="submit"` control outside a `<form>` submits nothing. Put it inside the form, or associate it with one by id: `form="the-form-id"`.',
55
+ },
56
+ },
57
+ create(context) {
58
+ const parserServices = /** @type {any} */ (context.sourceCode.parserServices);
59
+
60
+ if (!parserServices?.convertNodeSourceSpanToLoc) return {};
61
+
62
+ let formDepth = 0;
63
+
64
+ /** @param {any} node */
65
+ const report = (node, messageId) =>
66
+ context.report({
67
+ loc: parserServices.convertNodeSourceSpanToLoc(node.startSourceSpan ?? node.sourceSpan),
68
+ messageId,
69
+ });
70
+
71
+ return {
72
+ /** @param {any} node */
73
+ Element(node) {
74
+ if (node.name === 'form') {
75
+ formDepth++;
76
+
77
+ if (!handlesSubmit(node) && !submitsNatively(node)) report(node, 'missingSubmitHandler');
78
+
79
+ return;
80
+ }
81
+
82
+ if (formDepth > 0 || !isSubmitControl(node) || hasBinding(node, 'form')) return;
83
+
84
+ report(node, 'submitOutsideForm');
85
+ },
86
+
87
+ /** @param {any} node */
88
+ 'Element:exit'(node) {
89
+ if (node.name === 'form') formDepth--;
90
+ },
91
+ };
92
+ },
93
+ };
94
+
95
+ module.exports = requireFormSubmit;