@md-code/react-component-uniqueness 0.1.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.
- package/README.md +125 -0
- package/bin/react-component-uniqueness.js +10 -0
- package/config.js +290 -0
- package/debt.js +105 -0
- package/dom.js +158 -0
- package/errorTypes/duplicate-component.js +105 -0
- package/errorTypes/layout-primitive.js +212 -0
- package/errorTypes/raw-html.js +97 -0
- package/errorTypes/styled-in-app.js +93 -0
- package/index.js +283 -0
- package/loaders.js +123 -0
- package/package.json +59 -0
- package/plugin.js +22 -0
- package/report.js +339 -0
- package/resolve.js +374 -0
- package/scanner/attribute.js +79 -0
- package/scanner/clusters.js +96 -0
- package/scanner/component.js +346 -0
- package/scanner/extract.js +256 -0
- package/scanner/filters/drop-child-element-matches.js +16 -0
- package/scanner/filters/drop-usages.js +14 -0
- package/scanner/filters/index.js +8 -0
- package/scanner/filters/registry.js +30 -0
- package/scanner/generate.js +58 -0
- package/scanner/load-config.js +109 -0
- package/scanner/main.js +439 -0
- package/scanner/matching.js +269 -0
- package/scanner/output.js +42 -0
- package/scanner/walk.js +37 -0
- package/signature.js +663 -0
- package/snapshots/README.md +69 -0
- package/snapshots/build-playground.mjs +132 -0
- package/snapshots/run.mjs +43 -0
- package/snapshots/snapshot.mjs +204 -0
- package/thresholds.js +35 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* errorTypes/duplicate-component.js
|
|
5
|
+
*
|
|
6
|
+
* Error type 1 — DUPLICATE DECLARATION (messageId: duplicateComponent).
|
|
7
|
+
*
|
|
8
|
+
* The component registry (reports/component-registry.json) maps every
|
|
9
|
+
* canonical component name to the list of directories where that name is
|
|
10
|
+
* allowed to be declared. Declaring a registry name outside its canonical
|
|
11
|
+
* directories — const Button = ..., function Form() {...}, class Card,
|
|
12
|
+
* export default function Badge — is a duplicate.
|
|
13
|
+
*
|
|
14
|
+
* Only EXPORTED component bindings count: a private styled sub-part
|
|
15
|
+
* (const Title = styled.h3 inside Card) that happens to share a name with a
|
|
16
|
+
* canonical component is an internal detail, not a duplicate — private
|
|
17
|
+
* bindings cannot be imported elsewhere.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { inCanonicalDir } = require("../config");
|
|
21
|
+
|
|
22
|
+
/** Is this identifier a component binding (not a type)? */
|
|
23
|
+
function isComponentBinding(node) {
|
|
24
|
+
if (!node || node.type !== "Identifier") return false;
|
|
25
|
+
if (!/^[A-Z]/.test(node.name)) return false;
|
|
26
|
+
// Skip type-only declarations: export type Foo / interface Foo are
|
|
27
|
+
// not components.
|
|
28
|
+
let parent = node.parent;
|
|
29
|
+
while (parent) {
|
|
30
|
+
if (parent.type === "TSInterfaceDeclaration") return false;
|
|
31
|
+
if (parent.type === "TSTypeAliasDeclaration") return false;
|
|
32
|
+
if (parent.type === "TSModuleDeclaration") return false;
|
|
33
|
+
if (parent.type === "ExportNamedDeclaration" || parent.type === "ExportDefaultDeclaration") break;
|
|
34
|
+
parent = parent.parent;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Is this VariableDeclarator part of an exported declaration?
|
|
41
|
+
*
|
|
42
|
+
* Only exported components are "the component" — a private styled sub-part
|
|
43
|
+
* (const Title = styled.h3 inside Card, const Overlay inside HamburgerMenu)
|
|
44
|
+
* that happens to share a name with a canonical component is an internal
|
|
45
|
+
* detail, not a duplicate. Private bindings cannot be imported elsewhere,
|
|
46
|
+
* so they are never the thing the user is trying to prevent.
|
|
47
|
+
*/
|
|
48
|
+
function isExportedDeclarator(node) {
|
|
49
|
+
let p = node.parent;
|
|
50
|
+
while (p && p.type === "VariableDeclaration") p = p.parent;
|
|
51
|
+
return p != null && p.type === "ExportNamedDeclaration";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create the error type-1 visitors.
|
|
56
|
+
*
|
|
57
|
+
* @param {object} env { context, names, relPath }
|
|
58
|
+
* @returns {object} visitors: VariableDeclarator, ExportNamedDeclaration,
|
|
59
|
+
* ExportDefaultDeclaration
|
|
60
|
+
*/
|
|
61
|
+
function createHandler(env) {
|
|
62
|
+
const { context, names, relPath } = env;
|
|
63
|
+
|
|
64
|
+
/** Report a duplicate declaration of a registry name. */
|
|
65
|
+
function checkName(name, node) {
|
|
66
|
+
const dirs = names[name];
|
|
67
|
+
if (!dirs || dirs.length === 0) return;
|
|
68
|
+
if (inCanonicalDir(relPath, dirs)) return;
|
|
69
|
+
context.report({
|
|
70
|
+
node,
|
|
71
|
+
messageId: "duplicateComponent",
|
|
72
|
+
data: { name, dirs: dirs.join(" or ") },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
VariableDeclarator(node) {
|
|
78
|
+
if (isComponentBinding(node.id) && isExportedDeclarator(node)) checkName(node.id.name, node.id);
|
|
79
|
+
},
|
|
80
|
+
ExportNamedDeclaration(node) {
|
|
81
|
+
const decl = node.declaration;
|
|
82
|
+
if (!decl) return;
|
|
83
|
+
// VariableDeclaration is handled by the VariableDeclarator
|
|
84
|
+
// visitor (avoids double-reporting).
|
|
85
|
+
if (decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration") {
|
|
86
|
+
if (isComponentBinding(decl.id)) checkName(decl.id.name, decl.id);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
ExportDefaultDeclaration(node) {
|
|
90
|
+
const decl = node.declaration;
|
|
91
|
+
if (!decl) return;
|
|
92
|
+
// export default function Foo / class Foo
|
|
93
|
+
if ((decl.type === "FunctionDeclaration" || decl.type === "ClassDeclaration") && decl.id) {
|
|
94
|
+
if (isComponentBinding(decl.id)) checkName(decl.id.name, decl.id);
|
|
95
|
+
} else if (decl.type === "Identifier") {
|
|
96
|
+
// export default SomeBinding — the binding must be declared
|
|
97
|
+
// in this file; its name is the folder name only, so we
|
|
98
|
+
// cannot attribute a registry name to it reliably. Skip.
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { createHandler, isComponentBinding, isExportedDeclarator };
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* errorTypes/layout-primitive.js
|
|
5
|
+
*
|
|
6
|
+
* Error type 3 — HAND-ROLLED COMPONENTS BY BEHAVIOR (messageId: layoutPrimitive)
|
|
7
|
+
* and Error type 3b — CATALOG MATCH (messageId: catalogDuplicate,
|
|
8
|
+
* similarComponent).
|
|
9
|
+
*
|
|
10
|
+
* A plain div is building material and is NOT flagged on its own. But an
|
|
11
|
+
* element that BEHAVES like a canonical component — a div with
|
|
12
|
+
* flex/grid/col/row/justify/gap classes (a hand-rolled container), a span
|
|
13
|
+
* with onClick (a hand-rolled button), role=dialog / aria-modal (a
|
|
14
|
+
* hand-rolled modal), role=menu (a hand-rolled dropdown), input type=range
|
|
15
|
+
* (a hand-rolled slider), role=tooltip (a hand-rolled tooltip), a box with
|
|
16
|
+
* rounded corners + shadow (a hand-rolled Card / Paper), a spinner, tabs,
|
|
17
|
+
* accordion, progress, alert, avatar, badge — is a duplicate of a canonical
|
|
18
|
+
* component and is reported EVERYWHERE, including inside the component
|
|
19
|
+
* folders. That is what makes the rule total: a container hand-rolled in
|
|
20
|
+
* packages/components is still a duplicate of Container.
|
|
21
|
+
*
|
|
22
|
+
* The taxonomy mirrors the component categories of shadcn / MUI / antd.
|
|
23
|
+
* Values are resolved dynamically: className from a local variable,
|
|
24
|
+
* ternaries, cn()/clsx() calls, template literals, and identifiers imported
|
|
25
|
+
* from a sibling file (the imported file is read from disk and the exported
|
|
26
|
+
* constant's string literals are collected).
|
|
27
|
+
*
|
|
28
|
+
* Error type 3b matches the element's Signature against the component catalog
|
|
29
|
+
* (reports/component-catalog.json) using the decision matrix
|
|
30
|
+
* (signature.js). An error-tier verdict supersedes the other error types; a
|
|
31
|
+
* warning-tier verdict is a report-only hint (never fails the gate).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const sig = require("../signature");
|
|
35
|
+
const {
|
|
36
|
+
SPINNER_CLASS_RE,
|
|
37
|
+
MODAL_CLASS_RE,
|
|
38
|
+
CARD_CLASS_RE,
|
|
39
|
+
CARD_SECOND_RE,
|
|
40
|
+
CONTAINER_CLASS_RE,
|
|
41
|
+
ROLE_WHAT,
|
|
42
|
+
} = require("../config");
|
|
43
|
+
const { attrOf, attrValueOf, classNameCandidates, stylePropsOf, buildSignature } = require("../dom");
|
|
44
|
+
const { forBehavior } = require("../suggestions");
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Library style props that signal "this wrapper applies its OWN styling to a
|
|
48
|
+
* library primitive" — the static equivalent of a style={} object. MUI `sx`,
|
|
49
|
+
* antd `style`/`css`, styled-components `className` overrides, etc. A bare
|
|
50
|
+
* <MuiButton> has none of these (plain import); <MuiPaper sx={{...}}> does
|
|
51
|
+
* (a hand-rolled card). buildSignature() does not read these, so includeDynamic
|
|
52
|
+
* needs an explicit check to tell the two apart.
|
|
53
|
+
*/
|
|
54
|
+
const LIBRARY_STYLE_PROPS = new Set(["sx", "css", "styleObject", "stl"]);
|
|
55
|
+
function hasLibraryStyleProp(opening) {
|
|
56
|
+
return (opening.attributes || []).some(
|
|
57
|
+
(a) => a.type === "JSXAttribute" && a.name && LIBRARY_STYLE_PROPS.has(a.name.name)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Error type 3: an element that behaves like a canonical component.
|
|
63
|
+
* Returns a human "what" string, or null if the element is an ordinary
|
|
64
|
+
* building block.
|
|
65
|
+
*/
|
|
66
|
+
function behaviorOf(tag, opening, scope, fileDir) {
|
|
67
|
+
const role = attrValueOf(opening, "role");
|
|
68
|
+
if (typeof role === "string" && ROLE_WHAT[role]) return ROLE_WHAT[role];
|
|
69
|
+
if (attrValueOf(opening, "aria-modal") === "true") return "modal / dialog";
|
|
70
|
+
const hasPopup = attrValueOf(opening, "aria-haspopup");
|
|
71
|
+
if (typeof hasPopup === "string" && hasPopup !== "false") return "dropdown / menu trigger";
|
|
72
|
+
if (attrValueOf(opening, "contentEditable") != null) return "contentEditable element (a hand-rolled control)";
|
|
73
|
+
if (attrValueOf(opening, "onClick") != null) return "clickable element (a hand-rolled button)";
|
|
74
|
+
if (attrOf(opening, "aria-expanded")) return "accordion / collapsible";
|
|
75
|
+
|
|
76
|
+
const cls = classNameCandidates(opening, scope, fileDir).join(" ");
|
|
77
|
+
// Merged styles: tailwind className (incl. arbitrary values like z-[1000])
|
|
78
|
+
// + inline style={}. The modal check below runs on this merged map, so a
|
|
79
|
+
// "fixed inset-0 z-[1000]" Tailwind overlay is a modal, not only an inline
|
|
80
|
+
// style={{position:"fixed",zIndex:9999}} one.
|
|
81
|
+
const style = {};
|
|
82
|
+
for (const c of classNameCandidates(opening, scope, fileDir)) {
|
|
83
|
+
Object.assign(style, sig.twToStyles(c));
|
|
84
|
+
}
|
|
85
|
+
for (const [prop, values] of Object.entries(stylePropsOf(opening, scope, fileDir))) {
|
|
86
|
+
const key = sig.camelToKebab(prop);
|
|
87
|
+
style[key] = (style[key] || []).concat(values);
|
|
88
|
+
}
|
|
89
|
+
const styleHas = (prop) => Object.prototype.hasOwnProperty.call(style, prop);
|
|
90
|
+
const styleAny = (prop, re) => (style[prop] || []).some((v) => re.test(String(v)));
|
|
91
|
+
|
|
92
|
+
if (SPINNER_CLASS_RE.test(cls) || styleAny("animation", /spin|rotate/i)) return "spinner / skeleton";
|
|
93
|
+
if (MODAL_CLASS_RE.test(cls)) return "modal / dialog";
|
|
94
|
+
if (
|
|
95
|
+
styleHas("position") &&
|
|
96
|
+
(style.position || []).some((v) => /fixed/.test(String(v))) &&
|
|
97
|
+
(style["z-index"] || []).some((v) => Number(String(v).replace(/[^0-9-]/g, "")) >= 1000)
|
|
98
|
+
) {
|
|
99
|
+
return "modal / dialog";
|
|
100
|
+
}
|
|
101
|
+
if (CARD_CLASS_RE.test(cls) && (CARD_SECOND_RE.test(cls) || cls.split(/\s+/).filter(Boolean).length >= 3)) {
|
|
102
|
+
return "card / paper";
|
|
103
|
+
}
|
|
104
|
+
// Kebab-case keys: the merged map normalizes inline camelCase to kebab.
|
|
105
|
+
if (styleHas("border-radius") && (styleHas("box-shadow") || styleHas("border"))) return "card / paper";
|
|
106
|
+
// NOTE: there is deliberately NO "layout container" branch. A div with
|
|
107
|
+
// display:flex/grid is the normal way to build a layout — it is not a
|
|
108
|
+
// duplicate of any canonical component, and the canonical Container is a
|
|
109
|
+
// CSS-GRID component, so suggesting it for a flexbox div is actively
|
|
110
|
+
// wrong. Only strong behavior signals (button/modal/card/spinner/...) are
|
|
111
|
+
// reported here.
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create the error type-3 handler.
|
|
117
|
+
*
|
|
118
|
+
* @param {object} env { context, behaviorTags, catalogComponents, relPath, fileDir }
|
|
119
|
+
* @returns {object} { matchCatalog, handleElement }
|
|
120
|
+
*/
|
|
121
|
+
function createHandler(env) {
|
|
122
|
+
const { context, behaviorTags, catalogComponents, relPath, fileDir, registry, inComponentsFolder, componentsFolder } = env;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Error type 3b: match the element signature against the component catalog
|
|
126
|
+
* (decision matrix). Returns {name, path, level, reason, sim} or null.
|
|
127
|
+
* Skips the entry whose own file is the current file.
|
|
128
|
+
*
|
|
129
|
+
* @param {string} tag the (possibly resolved) element tag
|
|
130
|
+
* @param {object} opening the JSXOpeningElement node
|
|
131
|
+
* @param {object} scope the eslint-scope scope
|
|
132
|
+
* @param {object|null} dynEntry optional rendered-DOM signature (includeDynamic):
|
|
133
|
+
* when present, the candidate is re-tagged to the rendered DOM tag and
|
|
134
|
+
* enriched with the styles/actions/a11y that the library applies at
|
|
135
|
+
* runtime (MUI emotion/sx, antd cssinjs) — invisible to static analysis.
|
|
136
|
+
*/
|
|
137
|
+
function matchCatalog(tag, opening, scope, dynEntry) {
|
|
138
|
+
if (!catalogComponents.length) return null;
|
|
139
|
+
const candidate = buildSignature(tag, opening, scope, fileDir);
|
|
140
|
+
if (dynEntry) {
|
|
141
|
+
if (dynEntry.tag) candidate.tag = dynEntry.tag;
|
|
142
|
+
// A plain library use (<MuiButton> with no style/onClick/a11y of its
|
|
143
|
+
// own) is an IMPORT, not a duplicate: the rendered DOM is the
|
|
144
|
+
// library's, so we must not merge its rendered styles into the
|
|
145
|
+
// candidate — that would let a bare <MuiButton> "look like" a
|
|
146
|
+
// hand-rolled Card/Container and produce a false similarComponent.
|
|
147
|
+
// Only a WRAPPER that carries its own static signal (its own styles
|
|
148
|
+
// or an onClick / a11y marker) is a candidate for a duplicate, and
|
|
149
|
+
// for it we merge the rendered styles the library applies.
|
|
150
|
+
const hasOwnSignal =
|
|
151
|
+
Object.keys(candidate.styles).length > 0 ||
|
|
152
|
+
candidate.actions.length > 0 ||
|
|
153
|
+
candidate.a11y.length > 0 ||
|
|
154
|
+
hasLibraryStyleProp(opening);
|
|
155
|
+
if (hasOwnSignal && dynEntry.styles && Object.keys(dynEntry.styles).length && Object.keys(candidate.styles).length < sig.MIN_SHARED_STYLE_KEYS) {
|
|
156
|
+
candidate.styles = sig.mergeStyles(candidate.styles, dynEntry.styles);
|
|
157
|
+
}
|
|
158
|
+
// NOTE: actions/a11y are deliberately NOT merged from the dynamic
|
|
159
|
+
// entry. Behavior (onClick, type, role) is a per-USE static signal:
|
|
160
|
+
// a plain <MuiButton> without onClick must stay a plain import, not
|
|
161
|
+
// become a "duplicate" just because MUI renders <button type=button>.
|
|
162
|
+
// The dynamic catalog contributes only what static analysis cannot
|
|
163
|
+
// see: the real DOM tag + runtime styles.
|
|
164
|
+
}
|
|
165
|
+
// Exclude the current file's OWN catalog entries from the pool BEFORE
|
|
166
|
+
// best-selection. includeDynamic adds the file's own rendered wrappers
|
|
167
|
+
// (e.g. <MuiButton> in _keystone.tsx) to the catalog; a candidate that
|
|
168
|
+
// merges its own rendered styles would match its own entry at sim 1.0,
|
|
169
|
+
// and a post-hoc "own file" drop would then discard the real match
|
|
170
|
+
// against the canonical component. Filtering first lets the canonical
|
|
171
|
+
// entry win the best-selection.
|
|
172
|
+
// When the CURRENT file is a canonical component (inside the component
|
|
173
|
+
// folders), also drop the dynamic app-wrapper entries: a canonical
|
|
174
|
+
// component is the source of truth and must not be reported as a
|
|
175
|
+
// "duplicate" of a hand-rolled app wrapper (e.g. forms Button must not
|
|
176
|
+
// match an app's <MuiButton> plain import).
|
|
177
|
+
const pool = catalogComponents.filter((c) => c.path !== relPath && !(inComponentsFolder && c.__dyn));
|
|
178
|
+
const res = sig.matchSignature(candidate, { components: pool });
|
|
179
|
+
return res;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Handle a non-raw element (div, span, ul, a, ...).
|
|
184
|
+
* Returns true when a finding was reported.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} tag
|
|
187
|
+
* @param {object} opening the JSXOpeningElement node
|
|
188
|
+
* @param {object} scope the eslint-scope scope of the element
|
|
189
|
+
* @returns {boolean}
|
|
190
|
+
*/
|
|
191
|
+
function handleElement(tag, opening, scope) {
|
|
192
|
+
if (!behaviorTags.has(tag)) return false;
|
|
193
|
+
// Error type 3: behavior — reported EVERYWHERE, including inside the
|
|
194
|
+
// component folders. A container hand-rolled in packages/components is
|
|
195
|
+
// still a duplicate of Container: that is what makes the rule total.
|
|
196
|
+
const what = behaviorOf(tag, opening, scope, fileDir);
|
|
197
|
+
if (what) {
|
|
198
|
+
const sug = forBehavior(what, registry, catalogComponents, componentsFolder);
|
|
199
|
+
context.report({
|
|
200
|
+
node: opening,
|
|
201
|
+
messageId: "layoutPrimitive",
|
|
202
|
+
data: { tag, what, component: sug.component, path: sug.path },
|
|
203
|
+
});
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { matchCatalog, handleElement };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { createHandler, behaviorOf };
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* errorTypes/raw-html.js
|
|
5
|
+
*
|
|
6
|
+
* Error type 2 — RAW HTML (messageId: rawHtml).
|
|
7
|
+
*
|
|
8
|
+
* Raw interactive / form elements (button, label, select, textarea, input,
|
|
9
|
+
* form, table, dialog) are only legal inside the canonical packages
|
|
10
|
+
* (@md/components / @md/sections) — that is where the canonical components
|
|
11
|
+
* are built from them. Outside those packages every raw element is a
|
|
12
|
+
* report: use the canonical component instead.
|
|
13
|
+
*
|
|
14
|
+
* Error type 3 also applies to raw elements: a raw input type=range / select /
|
|
15
|
+
* dialog is a hand-rolled slider / select / modal, and that behavior check
|
|
16
|
+
* runs EVERYWHERE — including inside the component folders. The rawHtml
|
|
17
|
+
* report (error type 2) stays silent there, because the raw element is the
|
|
18
|
+
* building material the canonical component is made of.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { ROLE_WHAT, RAW_BEHAVIOR_TAGS } = require("../config");
|
|
22
|
+
const { attrValueOf } = require("../dom");
|
|
23
|
+
const { forBehavior, forRaw } = require("../suggestions");
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Behavior of a raw element (input type=range, select, dialog,
|
|
27
|
+
* button with onClick, role=...). Returns a "what" string or null.
|
|
28
|
+
*/
|
|
29
|
+
function behaviorForRaw(tag, opening) {
|
|
30
|
+
const role = attrValueOf(opening, "role");
|
|
31
|
+
if (typeof role === "string" && ROLE_WHAT[role]) return ROLE_WHAT[role];
|
|
32
|
+
if (tag === "input") {
|
|
33
|
+
const type = attrValueOf(opening, "type");
|
|
34
|
+
if (type === "range") return "slider";
|
|
35
|
+
if (type === "checkbox") return "checkbox (a hand-rolled control)";
|
|
36
|
+
if (type === "radio") return "radio (a hand-rolled control)";
|
|
37
|
+
}
|
|
38
|
+
if (tag === "dialog") return "modal / dialog";
|
|
39
|
+
if (tag === "select") return "select (a hand-rolled control)";
|
|
40
|
+
if (tag === "button" && attrValueOf(opening, "onClick") != null) return "clickable element (a hand-rolled button)";
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Create the error type-2 handler.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} env { context, rawElements, inComponentsFolder }
|
|
48
|
+
* @returns {function} handleRaw(tag, opening) -> boolean (was a finding reported?)
|
|
49
|
+
*/
|
|
50
|
+
function createHandler(env) {
|
|
51
|
+
const { context, rawElements, inComponentsFolder, registry, catalogComponents, componentsFolder } = env;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Handle a raw element (button, input, select, ...).
|
|
55
|
+
* Returns true when a finding was reported.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} tag
|
|
58
|
+
* @param {object} opening the JSXOpeningElement node
|
|
59
|
+
* @returns {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function handleRaw(tag, opening) {
|
|
62
|
+
if (!rawElements.has(tag)) return false;
|
|
63
|
+
let reported = false;
|
|
64
|
+
// Error type 2: raw HTML is only legal inside the component folders
|
|
65
|
+
// (it is the building material the canonical components are made of).
|
|
66
|
+
if (!inComponentsFolder) {
|
|
67
|
+
const sug = forRaw(tag, attrValueOf(opening, "type"), registry, catalogComponents, componentsFolder);
|
|
68
|
+
context.report({
|
|
69
|
+
node: opening,
|
|
70
|
+
messageId: "rawHtml",
|
|
71
|
+
data: { tag, component: sug.component, path: sug.path },
|
|
72
|
+
});
|
|
73
|
+
reported = true;
|
|
74
|
+
}
|
|
75
|
+
// Error type 3 also applies to raw elements: a raw input type=range /
|
|
76
|
+
// select / dialog is a hand-rolled slider / select / modal. Runs
|
|
77
|
+
// EVERYWHERE — a hand-rolled slider in packages/components is still a
|
|
78
|
+
// duplicate of Slider.
|
|
79
|
+
if (RAW_BEHAVIOR_TAGS.has(tag)) {
|
|
80
|
+
const what = behaviorForRaw(tag, opening);
|
|
81
|
+
if (what) {
|
|
82
|
+
const sug = forBehavior(what, registry, catalogComponents, componentsFolder);
|
|
83
|
+
context.report({
|
|
84
|
+
node: opening,
|
|
85
|
+
messageId: "layoutPrimitive",
|
|
86
|
+
data: { tag, what, component: sug.component, path: sug.path },
|
|
87
|
+
});
|
|
88
|
+
reported = true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return reported;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { handleRaw };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { createHandler, behaviorForRaw };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* errorTypes/styled-in-app.js
|
|
5
|
+
*
|
|
6
|
+
* Error type 4 — STYLED IN APP (messageId: styledInApp).
|
|
7
|
+
*
|
|
8
|
+
* styled.div / styled.button / ... created outside the component folders is
|
|
9
|
+
* UI built in the app — reported, whatever the tag. Inside the component
|
|
10
|
+
* folders styled.* is the building material the canonical components are
|
|
11
|
+
* made of and is legal.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create the error type-4 visitors.
|
|
16
|
+
*
|
|
17
|
+
* @param {object} env { context, inComponentsFolder }
|
|
18
|
+
* @returns {object} visitors: VariableDeclarator
|
|
19
|
+
*/
|
|
20
|
+
const sig = require("../signature");
|
|
21
|
+
|
|
22
|
+
function createHandler(env) {
|
|
23
|
+
const { context, inComponentsFolder, catalogComponents, componentsFolder } = env;
|
|
24
|
+
|
|
25
|
+
/** Fallback path: the first canonical folder, or the generic phrase. */
|
|
26
|
+
const defaultPath = componentsFolder && componentsFolder.length ? componentsFolder[0] : "the canonical component packages";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Collect the CSS text of a styled template (no-substitution literal, or a
|
|
30
|
+
* template expression with interpolations — only the literal parts).
|
|
31
|
+
*/
|
|
32
|
+
function cssTextOf(tpl) {
|
|
33
|
+
if (!tpl) return "";
|
|
34
|
+
if (tpl.type === "NoSubstitutionTemplate") return tpl.value.cooked || tpl.value.raw || "";
|
|
35
|
+
if (tpl.type === "TemplateLiteral") {
|
|
36
|
+
return (tpl.quasis || []).map((q) => q.value.cooked || q.value.raw || "").join("");
|
|
37
|
+
}
|
|
38
|
+
return "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Error type 4: styled.<htmlTag> created outside the component folders is
|
|
43
|
+
* UI built in the app — reported, whatever the tag. The suggestion is the
|
|
44
|
+
* catalog component whose signature matches the styled template, if any.
|
|
45
|
+
*/
|
|
46
|
+
function checkStyledInApp(node) {
|
|
47
|
+
if (inComponentsFolder) return;
|
|
48
|
+
const init = node.init;
|
|
49
|
+
if (!init || init.type !== "TaggedTemplateExpression") return;
|
|
50
|
+
const tagExpr = init.tag;
|
|
51
|
+
// styled.div`...` (MemberExpression) and styled(MuiButton)`...`
|
|
52
|
+
// (CallExpression with an identifier argument — a wrapper built on a
|
|
53
|
+
// LIBRARY component, e.g. MUI/antd). Both are app-built UI outside the
|
|
54
|
+
// component folders.
|
|
55
|
+
let tag = null;
|
|
56
|
+
if (tagExpr.type === "MemberExpression" && tagExpr.property && tagExpr.property.name) {
|
|
57
|
+
tag = tagExpr.property.name;
|
|
58
|
+
} else if (tagExpr.type === "CallExpression" && tagExpr.arguments.length) {
|
|
59
|
+
const arg0 = tagExpr.arguments[0];
|
|
60
|
+
if (arg0.type === "Identifier") tag = arg0.name;
|
|
61
|
+
else if (arg0.type === "Literal" && typeof arg0.value === "string") tag = arg0.value;
|
|
62
|
+
}
|
|
63
|
+
if (!tag) return;
|
|
64
|
+
|
|
65
|
+
let component = "CanonicalComponent";
|
|
66
|
+
let path = defaultPath;
|
|
67
|
+
const cssText = cssTextOf(init.template);
|
|
68
|
+
const styles = sig.cssTextToStyles(cssText);
|
|
69
|
+
if (Object.keys(styles).length && catalogComponents.length) {
|
|
70
|
+
const hit = sig.matchSignature(
|
|
71
|
+
{ tag, styles, actions: [], a11y: [], data: [] },
|
|
72
|
+
{ components: catalogComponents },
|
|
73
|
+
);
|
|
74
|
+
if (hit) {
|
|
75
|
+
component = hit.name;
|
|
76
|
+
path = hit.path;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
context.report({
|
|
80
|
+
node: node.id,
|
|
81
|
+
messageId: "styledInApp",
|
|
82
|
+
data: { tag, component, path },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
VariableDeclarator(node) {
|
|
88
|
+
checkStyledInApp(node);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { createHandler };
|