@tenphi/eslint-plugin-tasty 1.10.0 → 1.12.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.
@@ -0,0 +1,188 @@
1
+ import { DIMENSION_STYLES, POSITION_STYLES } from "./constants.js";
2
+ import { unwrapExpression, wildcardPattern } from "./utils.js";
3
+ import { resolveModuleExportOrigin, withMembers } from "./module-components.js";
4
+
5
+ //#region src/protected-components.ts
6
+ function namePattern(pattern) {
7
+ return {
8
+ regex: wildcardPattern(pattern),
9
+ member: pattern.includes(".")
10
+ };
11
+ }
12
+ const STYLE_PROP_GROUPS = new Map([["position", POSITION_STYLES], ["dimension", DIMENSION_STYLES]]);
13
+ const entriesByConfig = /* @__PURE__ */ new WeakMap();
14
+ function readEntries(config) {
15
+ let entries = entriesByConfig.get(config);
16
+ if (entries) return entries;
17
+ const list = config.protectedComponents ?? [];
18
+ if (!Array.isArray(list)) throw new Error("tasty: protectedComponents must be an array.");
19
+ entries = list.map((item, index) => {
20
+ const at = `tasty: protectedComponents[${index}]`;
21
+ if (!item || typeof item !== "object" || typeof item.source !== "string") throw new Error(`${at} must be an object with a string 'source'.`);
22
+ const entry = item;
23
+ const strings = (key) => {
24
+ const value = entry[key];
25
+ if (value === void 0) return key === "include" ? ["*"] : [];
26
+ if (!Array.isArray(value) || value.some((name) => typeof name !== "string")) throw new Error(`${at}.${key} must be an array of strings.`);
27
+ return value;
28
+ };
29
+ return {
30
+ source: wildcardPattern(entry.source),
31
+ include: strings("include").map(namePattern),
32
+ exclude: strings("exclude").map(namePattern),
33
+ allowedStyleProps: new Set(strings("allowedStyleProps").flatMap((name) => STYLE_PROP_GROUPS.get(name) ?? [name]))
34
+ };
35
+ });
36
+ entriesByConfig.set(config, entries);
37
+ return entries;
38
+ }
39
+ function importedName(specifier) {
40
+ return specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
41
+ }
42
+ /**
43
+ * Resolves JSX tags and `tasty()` bases to the `protectedComponents` entry they
44
+ * name, by the name their source exports them under plus any members read off
45
+ * it (`Button.Group`). Aliases, namespace members (`UI.Button`), `const`
46
+ * aliases, `tasty()` wrappers and relative re-exports are followed; relative
47
+ * modules need the `typescript` package, as for other cross-file recognition.
48
+ */
49
+ var ProtectedComponents = class {
50
+ entries;
51
+ bindings = /* @__PURE__ */ new WeakMap();
52
+ modules = /* @__PURE__ */ new Map();
53
+ constructor(context, ctx) {
54
+ this.context = context;
55
+ this.ctx = ctx;
56
+ this.entries = readEntries(ctx.config);
57
+ }
58
+ get enabled() {
59
+ return this.entries.length > 0;
60
+ }
61
+ /** `tasty(Base, ...)`: a Tasty wrapper of an existing component. */
62
+ isWrapperCall(node) {
63
+ const base = node.arguments[0];
64
+ return this.ctx.isTastyCall(node)?.importedName === "tasty" && base !== void 0 && base.type !== "SpreadElement" && unwrapExpression(base).type !== "ObjectExpression";
65
+ }
66
+ /** The protected component a JSX tag name refers to. */
67
+ resolveTag(name) {
68
+ if (name.type === "JSXIdentifier" && !/^[A-Z]/.test(name.name)) return null;
69
+ let root = name;
70
+ const members = [];
71
+ while (root.type === "JSXMemberExpression") {
72
+ members.unshift(root.property.name);
73
+ root = root.object;
74
+ }
75
+ if (root.type !== "JSXIdentifier") return null;
76
+ return this.match(this.resolveBinding(root, root.name, members, /* @__PURE__ */ new Set()));
77
+ }
78
+ /** The protected component an expression, such as a `tasty()` base, refers to. */
79
+ resolveExpression(node) {
80
+ return this.match(this.origin(node, [], /* @__PURE__ */ new Set()));
81
+ }
82
+ /**
83
+ * The most specific name decides: an entry that names a member path
84
+ * (`Button.Group`, `Button.*`) in `include` or `exclude` wins over one that
85
+ * matches only the export, so a sub-component that is a separate component
86
+ * can follow its own entry. Longer paths are tried first.
87
+ */
88
+ match(origin) {
89
+ if (!origin) return null;
90
+ const entries = this.entries.filter((entry) => entry.source.test(origin.source));
91
+ const protect = (name, entry) => ({
92
+ name,
93
+ source: origin.source,
94
+ wrapped: origin.wrapped,
95
+ allowedStyleProps: entry.allowedStyleProps
96
+ });
97
+ const parts = [origin.name, ...origin.members];
98
+ for (let length = parts.length; length > 1; length--) {
99
+ const path = parts.slice(0, length).join(".");
100
+ const names = (patterns) => patterns.some((pattern) => pattern.member && pattern.regex.test(path));
101
+ for (const entry of entries) {
102
+ if (names(entry.exclude)) return null;
103
+ if (names(entry.include)) return protect(path, entry);
104
+ }
105
+ }
106
+ const matches = (patterns) => patterns.some((pattern) => !pattern.member && pattern.regex.test(origin.name));
107
+ const entry = entries.find((candidate) => matches(candidate.include) && !matches(candidate.exclude));
108
+ return entry ? protect(origin.name, entry) : null;
109
+ }
110
+ origin(node, members, visited) {
111
+ const value = unwrapExpression(node);
112
+ if (value.type === "Identifier") return this.resolveBinding(value, value.name, members, visited);
113
+ if (value.type === "MemberExpression") {
114
+ let root = value;
115
+ const path = [...members];
116
+ while (root.type === "MemberExpression") {
117
+ if (root.computed || root.property.type !== "Identifier") return null;
118
+ path.unshift(root.property.name);
119
+ root = unwrapExpression(root.object);
120
+ }
121
+ return root.type === "Identifier" ? this.resolveBinding(root, root.name, path, visited) : null;
122
+ }
123
+ if (members.length === 0 && value.type === "CallExpression" && this.isWrapperCall(value)) {
124
+ const origin = this.origin(value.arguments[0], [], visited);
125
+ return origin && {
126
+ ...origin,
127
+ wrapped: true
128
+ };
129
+ }
130
+ return null;
131
+ }
132
+ /** `members` are the properties read off the binding, as in `UI.Button.Group`. */
133
+ resolveBinding(reference, localName, members, visited) {
134
+ let scope = this.context.sourceCode.getScope(reference);
135
+ while (scope && !scope.set.has(localName)) scope = scope.upper;
136
+ const variable = scope?.set.get(localName);
137
+ if (!variable || variable.defs.length !== 1) return null;
138
+ let cache = this.bindings.get(variable);
139
+ if (!cache) {
140
+ cache = /* @__PURE__ */ new Map();
141
+ this.bindings.set(variable, cache);
142
+ }
143
+ const key = members.join(".");
144
+ if (cache.has(key)) return cache.get(key);
145
+ const result = this.resolveDefinition(variable.defs[0], members, visited);
146
+ cache.set(key, result);
147
+ return result;
148
+ }
149
+ resolveDefinition(def, members, visited) {
150
+ if (def.type === "ImportBinding") {
151
+ if (def.parent.type !== "ImportDeclaration" || def.parent.importKind === "type") return null;
152
+ const specifier = def.node;
153
+ let name;
154
+ let rest = members;
155
+ if (specifier.type === "ImportNamespaceSpecifier") [name, ...rest] = members;
156
+ else if (specifier.type === "ImportDefaultSpecifier") name = "default";
157
+ else if (specifier.type === "ImportSpecifier") {
158
+ if (specifier.importKind === "type") return null;
159
+ name = importedName(specifier);
160
+ }
161
+ if (name === void 0) return null;
162
+ const source = def.parent.source.value;
163
+ if (!source.startsWith(".")) return {
164
+ source,
165
+ name,
166
+ members: rest,
167
+ wrapped: false
168
+ };
169
+ return withMembers(this.moduleOrigin(source, name), rest);
170
+ }
171
+ if (def.type !== "Variable" || def.parent.kind !== "const" || def.node.id.type !== "Identifier" || !def.node.init || visited.has(def.node)) return null;
172
+ visited.add(def.node);
173
+ return this.origin(def.node.init, members, visited);
174
+ }
175
+ moduleOrigin(source, exportName) {
176
+ const key = `${source}\0${exportName}`;
177
+ if (!this.modules.has(key)) this.modules.set(key, resolveModuleExportOrigin(this.context.filename, source, exportName, {
178
+ importSources: this.ctx.trackedSources,
179
+ isComponentFactory: (name) => name === "tasty",
180
+ isOriginSource: (candidate) => this.entries.some((entry) => entry.source.test(candidate))
181
+ }));
182
+ return this.modules.get(key);
183
+ }
184
+ };
185
+
186
+ //#endregion
187
+ export { ProtectedComponents };
188
+ //# sourceMappingURL=protected-components.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"protected-components.js","names":[],"sources":["../src/protected-components.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport type { RuleContext, Scope } from '@typescript-eslint/utils/ts-eslint';\nimport type { TastyContext } from './context.js';\nimport type { ResolvedConfig } from './types.js';\nimport { DIMENSION_STYLES, POSITION_STYLES } from './constants.js';\nimport {\n type ModuleExportOrigin,\n resolveModuleExportOrigin,\n withMembers,\n} from './module-components.js';\nimport { unwrapExpression, wildcardPattern } from './utils.js';\n\n/** A component resolved to a `protectedComponents` entry. */\nexport interface ProtectedComponent {\n /**\n * The name it is protected under: the export name (`Button` for `<UI.Button>`,\n * an alias or `<Button.Split>`), or the member path an entry names, such as\n * `Button.Group`.\n */\n name: string;\n source: string;\n /** Whether a `tasty()` wrapper sits between the reference and the export. */\n wrapped: boolean;\n allowedStyleProps: ReadonlySet<string>;\n}\n\ninterface Pattern {\n regex: RegExp;\n /** Names a member path, such as `Button.Group` or `Button.*`. */\n member: boolean;\n}\n\ninterface Entry {\n source: RegExp;\n include: Pattern[];\n exclude: Pattern[];\n allowedStyleProps: ReadonlySet<string>;\n}\n\nfunction namePattern(pattern: string): Pattern {\n return { regex: wildcardPattern(pattern), member: pattern.includes('.') };\n}\n\nconst STYLE_PROP_GROUPS = new Map<string, readonly string[]>([\n ['position', POSITION_STYLES],\n ['dimension', DIMENSION_STYLES],\n]);\n\nconst entriesByConfig = new WeakMap<ResolvedConfig, Entry[]>();\n\nfunction readEntries(config: ResolvedConfig): Entry[] {\n let entries = entriesByConfig.get(config);\n if (entries) return entries;\n\n const list: unknown = config.protectedComponents ?? [];\n if (!Array.isArray(list)) {\n throw new Error('tasty: protectedComponents must be an array.');\n }\n entries = list.map((item: unknown, index): Entry => {\n const at = `tasty: protectedComponents[${index}]`;\n if (\n !item ||\n typeof item !== 'object' ||\n typeof (item as { source?: unknown }).source !== 'string'\n ) {\n throw new Error(`${at} must be an object with a string 'source'.`);\n }\n const entry = item as Record<string, unknown>;\n const strings = (key: string): string[] => {\n const value = entry[key];\n if (value === undefined) return key === 'include' ? ['*'] : [];\n if (\n !Array.isArray(value) ||\n value.some((name) => typeof name !== 'string')\n ) {\n throw new Error(`${at}.${key} must be an array of strings.`);\n }\n return value as string[];\n };\n return {\n source: wildcardPattern(entry.source as string),\n include: strings('include').map(namePattern),\n exclude: strings('exclude').map(namePattern),\n allowedStyleProps: new Set(\n strings('allowedStyleProps').flatMap(\n (name) => STYLE_PROP_GROUPS.get(name) ?? [name],\n ),\n ),\n };\n });\n entriesByConfig.set(config, entries);\n return entries;\n}\n\nfunction importedName(specifier: TSESTree.ImportSpecifier): string {\n return specifier.imported.type === 'Identifier'\n ? specifier.imported.name\n : specifier.imported.value;\n}\n\n/**\n * Resolves JSX tags and `tasty()` bases to the `protectedComponents` entry they\n * name, by the name their source exports them under plus any members read off\n * it (`Button.Group`). Aliases, namespace members (`UI.Button`), `const`\n * aliases, `tasty()` wrappers and relative re-exports are followed; relative\n * modules need the `typescript` package, as for other cross-file recognition.\n */\nexport class ProtectedComponents {\n private entries: Entry[];\n private bindings = new WeakMap<\n Scope.Variable,\n Map<string, ModuleExportOrigin | null>\n >();\n private modules = new Map<string, ModuleExportOrigin | null>();\n\n constructor(\n private context: RuleContext<string, unknown[]>,\n private ctx: TastyContext,\n ) {\n this.entries = readEntries(ctx.config);\n }\n\n get enabled(): boolean {\n return this.entries.length > 0;\n }\n\n /** `tasty(Base, ...)`: a Tasty wrapper of an existing component. */\n isWrapperCall(node: TSESTree.CallExpression): boolean {\n const base = node.arguments[0];\n return (\n this.ctx.isTastyCall(node)?.importedName === 'tasty' &&\n base !== undefined &&\n base.type !== 'SpreadElement' &&\n unwrapExpression(base).type !== 'ObjectExpression'\n );\n }\n\n /** The protected component a JSX tag name refers to. */\n resolveTag(name: TSESTree.JSXTagNameExpression): ProtectedComponent | null {\n // `<button>` is a native element whatever a `button` binding holds.\n if (name.type === 'JSXIdentifier' && !/^[A-Z]/.test(name.name)) return null;\n let root = name;\n const members: string[] = [];\n while (root.type === 'JSXMemberExpression') {\n members.unshift(root.property.name);\n root = root.object;\n }\n if (root.type !== 'JSXIdentifier') return null;\n return this.match(this.resolveBinding(root, root.name, members, new Set()));\n }\n\n /** The protected component an expression, such as a `tasty()` base, refers to. */\n resolveExpression(node: TSESTree.Node): ProtectedComponent | null {\n return this.match(this.origin(node, [], new Set()));\n }\n\n /**\n * The most specific name decides: an entry that names a member path\n * (`Button.Group`, `Button.*`) in `include` or `exclude` wins over one that\n * matches only the export, so a sub-component that is a separate component\n * can follow its own entry. Longer paths are tried first.\n */\n private match(origin: ModuleExportOrigin | null): ProtectedComponent | null {\n if (!origin) return null;\n const entries = this.entries.filter((entry) =>\n entry.source.test(origin.source),\n );\n const protect = (name: string, entry: Entry): ProtectedComponent => ({\n name,\n source: origin.source,\n wrapped: origin.wrapped,\n allowedStyleProps: entry.allowedStyleProps,\n });\n\n const parts = [origin.name, ...origin.members];\n for (let length = parts.length; length > 1; length--) {\n const path = parts.slice(0, length).join('.');\n const names = (patterns: Pattern[]) =>\n patterns.some((pattern) => pattern.member && pattern.regex.test(path));\n for (const entry of entries) {\n if (names(entry.exclude)) return null;\n if (names(entry.include)) return protect(path, entry);\n }\n }\n\n const matches = (patterns: Pattern[]) =>\n patterns.some(\n (pattern) => !pattern.member && pattern.regex.test(origin.name),\n );\n const entry = entries.find(\n (candidate) => matches(candidate.include) && !matches(candidate.exclude),\n );\n return entry ? protect(origin.name, entry) : null;\n }\n\n private origin(\n node: TSESTree.Node,\n members: string[],\n visited: Set<TSESTree.Node>,\n ): ModuleExportOrigin | null {\n const value = unwrapExpression(node);\n if (value.type === 'Identifier') {\n return this.resolveBinding(value, value.name, members, visited);\n }\n if (value.type === 'MemberExpression') {\n let root: TSESTree.Node = value;\n const path = [...members];\n while (root.type === 'MemberExpression') {\n if (root.computed || root.property.type !== 'Identifier') return null;\n path.unshift(root.property.name);\n root = unwrapExpression(root.object);\n }\n return root.type === 'Identifier'\n ? this.resolveBinding(root, root.name, path, visited)\n : null;\n }\n if (\n members.length === 0 &&\n value.type === 'CallExpression' &&\n this.isWrapperCall(value)\n ) {\n const origin = this.origin(value.arguments[0], [], visited);\n return origin && { ...origin, wrapped: true };\n }\n return null;\n }\n\n /** `members` are the properties read off the binding, as in `UI.Button.Group`. */\n private resolveBinding(\n reference: TSESTree.Node,\n localName: string,\n members: string[],\n visited: Set<TSESTree.Node>,\n ): ModuleExportOrigin | null {\n let scope: Scope.Scope | null = this.context.sourceCode.getScope(reference);\n while (scope && !scope.set.has(localName)) scope = scope.upper;\n const variable = scope?.set.get(localName);\n if (!variable || variable.defs.length !== 1) return null;\n\n let cache = this.bindings.get(variable);\n if (!cache) {\n cache = new Map();\n this.bindings.set(variable, cache);\n }\n const key = members.join('.');\n if (cache.has(key)) return cache.get(key)!;\n\n const result = this.resolveDefinition(variable.defs[0], members, visited);\n cache.set(key, result);\n return result;\n }\n\n private resolveDefinition(\n def: Scope.Definition,\n members: string[],\n visited: Set<TSESTree.Node>,\n ): ModuleExportOrigin | null {\n if (def.type === 'ImportBinding') {\n if (\n def.parent.type !== 'ImportDeclaration' ||\n def.parent.importKind === 'type'\n ) {\n return null;\n }\n const specifier = def.node;\n let name: string | undefined;\n let rest = members;\n if (specifier.type === 'ImportNamespaceSpecifier') {\n [name, ...rest] = members;\n } else if (specifier.type === 'ImportDefaultSpecifier') name = 'default';\n else if (specifier.type === 'ImportSpecifier') {\n if (specifier.importKind === 'type') return null;\n name = importedName(specifier);\n }\n if (name === undefined) return null;\n\n const source = def.parent.source.value;\n if (!source.startsWith('.')) {\n return { source, name, members: rest, wrapped: false };\n }\n return withMembers(this.moduleOrigin(source, name), rest);\n }\n\n if (\n def.type !== 'Variable' ||\n def.parent.kind !== 'const' ||\n def.node.id.type !== 'Identifier' ||\n !def.node.init ||\n visited.has(def.node)\n ) {\n return null;\n }\n visited.add(def.node);\n return this.origin(def.node.init, members, visited);\n }\n\n private moduleOrigin(\n source: string,\n exportName: string,\n ): ModuleExportOrigin | null {\n const key = `${source}\\0${exportName}`;\n if (!this.modules.has(key)) {\n this.modules.set(\n key,\n resolveModuleExportOrigin(this.context.filename, source, exportName, {\n importSources: this.ctx.trackedSources,\n isComponentFactory: (name) => name === 'tasty',\n isOriginSource: (candidate) =>\n this.entries.some((entry) => entry.source.test(candidate)),\n }),\n );\n }\n return this.modules.get(key)!;\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,120 @@
1
+ import { createRule } from "../create-rule.js";
2
+ import { KNOWN_TASTY_PROPERTIES } from "../constants.js";
3
+ import { getKeyName, unwrapExpression } from "../utils.js";
4
+ import { TastyContext } from "../context.js";
5
+ import { jsxElementPropListeners } from "./tasty-component-props.js";
6
+ import { ProtectedComponents } from "../protected-components.js";
7
+
8
+ //#region src/rules/no-protected-restyle.ts
9
+ /** `tasty(Base, options)` keys that configure the wrapper instead of setting a default prop. */
10
+ const WRAPPER_OPTIONS = new Set([
11
+ "as",
12
+ "element",
13
+ "elements",
14
+ "mods",
15
+ "modProps",
16
+ "qa",
17
+ "qaVal",
18
+ "styleProps",
19
+ "tokenProps",
20
+ "tokens",
21
+ "variant"
22
+ ]);
23
+ /** `inputStyles`, `triggerStyles`, ...: styles for one of the component's inner elements. */
24
+ function isInnerStylesProp(name) {
25
+ return /^[a-z][A-Za-z0-9]*Styles$/.test(name);
26
+ }
27
+ /** A value that cannot be a style value: a callback or an element. */
28
+ function isNeverStyleValue(node) {
29
+ const value = unwrapExpression(node);
30
+ return value.type === "ArrowFunctionExpression" || value.type === "FunctionExpression" || value.type === "JSXElement" || value.type === "JSXFragment";
31
+ }
32
+ var no_protected_restyle_default = createRule({
33
+ name: "no-protected-restyle",
34
+ meta: {
35
+ type: "suggestion",
36
+ docs: { description: "Warn when a design system's protected components are restyled instead of customized through their props" },
37
+ messages: {
38
+ restyleProp: "'{{prop}}' restyles {{component}}, a protected {{source}} component. Customize it through its own props, 'tokens' or 'mods'. If this override is intended, explicitly disable tasty/no-protected-restyle for this usage with an ESLint comment and a reason.",
39
+ restyleWrapper: "This tasty() wrapper restyles {{component}}, a protected {{source}} component, through '{{prop}}'. Set its own props, 'tokens' or 'mods' instead. If this override is intended, explicitly disable tasty/no-protected-restyle for this usage with an ESLint comment and a reason."
40
+ },
41
+ schema: []
42
+ },
43
+ defaultOptions: [],
44
+ create(context) {
45
+ const ctx = new TastyContext(context);
46
+ const components = new ProtectedComponents(context, ctx);
47
+ if (!components.enabled) return {};
48
+ const styleProps = new Set([...KNOWN_TASTY_PROPERTIES, ...ctx.config.styles]);
49
+ /** Root `styles` that only places or sizes the component, through allowed keys. */
50
+ function placesOnly(value, target) {
51
+ const object = value && unwrapExpression(value);
52
+ return object?.type === "ObjectExpression" && object.properties.every((prop) => {
53
+ if (prop.type !== "Property" || prop.computed) return false;
54
+ const key = getKeyName(prop.key);
55
+ return key !== null && target.allowedStyleProps.has(key);
56
+ });
57
+ }
58
+ function restyles(name, value, target, attribute) {
59
+ if (name === "styles") return !placesOnly(value, target);
60
+ if (isInnerStylesProp(name)) return true;
61
+ if (!styleProps.has(name) || target.allowedStyleProps.has(name)) return false;
62
+ if (value && isNeverStyleValue(value)) return false;
63
+ return !(attribute && ctx.isComponentJSXProp(attribute));
64
+ }
65
+ function report(node, messageId, prop, target) {
66
+ context.report({
67
+ node,
68
+ messageId,
69
+ data: {
70
+ prop,
71
+ component: target.reference === target.name ? `'${target.name}'` : `'${target.reference}' (based on '${target.name}')`,
72
+ source: `'${target.source}'`
73
+ }
74
+ });
75
+ }
76
+ function checkWrapperOptions(options, target) {
77
+ for (const prop of options.properties) {
78
+ if (prop.type === "SpreadElement") {
79
+ const spread = unwrapExpression(prop.argument);
80
+ if (spread.type === "ObjectExpression") checkWrapperOptions(spread, target);
81
+ continue;
82
+ }
83
+ const key = prop.computed ? unwrapExpression(prop.key) : prop.key;
84
+ if (prop.computed && key.type !== "Literal") continue;
85
+ const name = getKeyName(key);
86
+ if (name === null || WRAPPER_OPTIONS.has(name)) continue;
87
+ if (name === "variants" || restyles(name, prop.value, target)) report(prop, "restyleWrapper", name, target);
88
+ }
89
+ }
90
+ return {
91
+ ...jsxElementPropListeners(ctx, (node) => {
92
+ const target = components.resolveTag(node.name);
93
+ return target && {
94
+ ...target,
95
+ reference: context.sourceCode.getText(node.name)
96
+ };
97
+ }, (name, node, target) => {
98
+ if (restyles(name, node.type === "Property" ? node.value : node.value?.type === "JSXExpressionContainer" ? node.value.expression : node.value, target, node.type === "JSXAttribute" ? node : void 0)) report(node, "restyleProp", name, target);
99
+ }),
100
+ "Program:exit"() {
101
+ ctx.finishJSXAnalysis();
102
+ },
103
+ CallExpression(node) {
104
+ const [base, options] = node.arguments;
105
+ if (!options || !components.isWrapperCall(node)) return;
106
+ const component = components.resolveExpression(base);
107
+ const object = unwrapExpression(options);
108
+ if (!component || object.type !== "ObjectExpression") return;
109
+ checkWrapperOptions(object, {
110
+ ...component,
111
+ reference: context.sourceCode.getText(unwrapExpression(base))
112
+ });
113
+ }
114
+ };
115
+ }
116
+ });
117
+
118
+ //#endregion
119
+ export { no_protected_restyle_default as default };
120
+ //# sourceMappingURL=no-protected-restyle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"no-protected-restyle.js","names":[],"sources":["../../src/rules/no-protected-restyle.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport { createRule } from '../create-rule.js';\nimport { TastyContext } from '../context.js';\nimport { KNOWN_TASTY_PROPERTIES } from '../constants.js';\nimport {\n type ProtectedComponent,\n ProtectedComponents,\n} from '../protected-components.js';\nimport { getKeyName, unwrapExpression } from '../utils.js';\nimport { jsxElementPropListeners } from './tasty-component-props.js';\n\ntype MessageIds = 'restyleProp' | 'restyleWrapper';\n\n/** `tasty(Base, options)` keys that configure the wrapper instead of setting a default prop. */\nconst WRAPPER_OPTIONS = new Set([\n 'as',\n 'element',\n 'elements',\n 'mods',\n 'modProps',\n 'qa',\n 'qaVal',\n 'styleProps',\n 'tokenProps',\n 'tokens',\n 'variant',\n]);\n\ninterface Target extends ProtectedComponent {\n /** The tag or base as written: `UI.Button`, `CopyButton`. */\n reference: string;\n}\n\n/** `inputStyles`, `triggerStyles`, ...: styles for one of the component's inner elements. */\nfunction isInnerStylesProp(name: string): boolean {\n return /^[a-z][A-Za-z0-9]*Styles$/.test(name);\n}\n\n/** A value that cannot be a style value: a callback or an element. */\nfunction isNeverStyleValue(node: TSESTree.Node): boolean {\n const value = unwrapExpression(node);\n return (\n value.type === 'ArrowFunctionExpression' ||\n value.type === 'FunctionExpression' ||\n value.type === 'JSXElement' ||\n value.type === 'JSXFragment'\n );\n}\n\nexport default createRule<[], MessageIds>({\n name: 'no-protected-restyle',\n meta: {\n type: 'suggestion',\n docs: {\n description:\n \"Warn when a design system's protected components are restyled instead of customized through their props\",\n },\n messages: {\n restyleProp:\n \"'{{prop}}' restyles {{component}}, a protected {{source}} component. Customize it through its own props, 'tokens' or 'mods'. If this override is intended, explicitly disable tasty/no-protected-restyle for this usage with an ESLint comment and a reason.\",\n restyleWrapper:\n \"This tasty() wrapper restyles {{component}}, a protected {{source}} component, through '{{prop}}'. Set its own props, 'tokens' or 'mods' instead. If this override is intended, explicitly disable tasty/no-protected-restyle for this usage with an ESLint comment and a reason.\",\n },\n schema: [],\n },\n defaultOptions: [],\n create(context) {\n const ctx = new TastyContext(context);\n const components = new ProtectedComponents(context, ctx);\n if (!components.enabled) return {};\n\n const styleProps = new Set([\n ...KNOWN_TASTY_PROPERTIES,\n ...ctx.config.styles,\n ]);\n\n /** Root `styles` that only places or sizes the component, through allowed keys. */\n function placesOnly(value: TSESTree.Node | null, target: Target): boolean {\n const object = value && unwrapExpression(value);\n return (\n object?.type === 'ObjectExpression' &&\n object.properties.every((prop) => {\n if (prop.type !== 'Property' || prop.computed) return false;\n const key = getKeyName(prop.key);\n return key !== null && target.allowedStyleProps.has(key);\n })\n );\n }\n\n function restyles(\n name: string,\n value: TSESTree.Node | null,\n target: Target,\n attribute?: TSESTree.JSXAttribute,\n ): boolean {\n if (name === 'styles') return !placesOnly(value, target);\n if (isInnerStylesProp(name)) return true;\n if (!styleProps.has(name) || target.allowedStyleProps.has(name)) {\n return false;\n }\n if (value && isNeverStyleValue(value)) return false;\n // With typeAwareJSX, a prop the component declares itself is not a style prop.\n return !(attribute && ctx.isComponentJSXProp(attribute));\n }\n\n function report(\n node: TSESTree.Node,\n messageId: MessageIds,\n prop: string,\n target: Target,\n ): void {\n context.report({\n node,\n messageId,\n data: {\n prop,\n component:\n target.reference === target.name\n ? `'${target.name}'`\n : `'${target.reference}' (based on '${target.name}')`,\n source: `'${target.source}'`,\n },\n });\n }\n\n function checkWrapperOptions(\n options: TSESTree.ObjectExpression,\n target: Target,\n ): void {\n for (const prop of options.properties) {\n if (prop.type === 'SpreadElement') {\n const spread = unwrapExpression(prop.argument);\n if (spread.type === 'ObjectExpression') {\n checkWrapperOptions(spread, target);\n }\n continue;\n }\n const key = prop.computed ? unwrapExpression(prop.key) : prop.key;\n if (prop.computed && key.type !== 'Literal') continue;\n const name = getKeyName(key);\n if (name === null || WRAPPER_OPTIONS.has(name)) continue;\n if (name === 'variants' || restyles(name, prop.value, target)) {\n report(prop, 'restyleWrapper', name, target);\n }\n }\n }\n\n const jsx = jsxElementPropListeners(\n ctx,\n (node): Target | null => {\n const target = components.resolveTag(node.name);\n return (\n target && {\n ...target,\n reference: context.sourceCode.getText(node.name),\n }\n );\n },\n (name, node, target) => {\n const value =\n node.type === 'Property'\n ? node.value\n : node.value?.type === 'JSXExpressionContainer'\n ? node.value.expression\n : node.value;\n const attribute = node.type === 'JSXAttribute' ? node : undefined;\n if (restyles(name, value, target, attribute)) {\n report(node, 'restyleProp', name, target);\n }\n },\n );\n\n return {\n ...jsx,\n 'Program:exit'() {\n ctx.finishJSXAnalysis();\n },\n CallExpression(node) {\n const [base, options] = node.arguments;\n if (!options || !components.isWrapperCall(node)) return;\n const component = components.resolveExpression(base);\n const object = unwrapExpression(options);\n if (!component || object.type !== 'ObjectExpression') return;\n checkWrapperOptions(object, {\n ...component,\n reference: context.sourceCode.getText(unwrapExpression(base)),\n });\n },\n };\n },\n});\n"],"mappings":"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"}
@@ -2,39 +2,47 @@ import { getKeyName, unwrapExpression } from "../utils.js";
2
2
 
3
3
  //#region src/rules/tasty-component-props.ts
4
4
  /**
5
- * Inspect explicit props and inline object spreads on every JSX element without
6
- * evaluating spread bindings. `kinds` limits which elements are classified and
7
- * checked, so a Tasty-only rule never runs component recognition elsewhere.
5
+ * Inspect explicit props and inline object spreads on JSX elements without
6
+ * evaluating spread bindings. `classify` picks the elements to check and what
7
+ * the check is told about each; `null` skips the element.
8
8
  */
9
- function jsxPropListeners(ctx, kinds, check) {
10
- function visitSpread(node, kind) {
9
+ function jsxElementPropListeners(ctx, classify, check) {
10
+ function visitSpread(node, target) {
11
11
  node = unwrapExpression(node);
12
12
  if (node.type !== "ObjectExpression") return;
13
- for (const prop of node.properties) if (prop.type === "SpreadElement") visitSpread(prop.argument, kind);
13
+ for (const prop of node.properties) if (prop.type === "SpreadElement") visitSpread(prop.argument, target);
14
14
  else {
15
15
  const name = prop.computed ? getKeyName(unwrapExpression(prop.key)) : getKeyName(prop.key);
16
- if (name !== null && (!prop.computed || unwrapExpression(prop.key).type === "Literal")) check(name, prop, kind);
16
+ if (name !== null && (!prop.computed || unwrapExpression(prop.key).type === "Literal")) check(name, prop, target);
17
17
  }
18
18
  }
19
- function classify(node) {
20
- let name = node.name;
21
- while (name.type === "JSXMemberExpression") name = name.object;
22
- if (name.type !== "JSXIdentifier" || node.name.type === "JSXIdentifier" && !/^[A-Z]/.test(name.name)) return kinds.has("element") ? "element" : null;
23
- if (kinds.has("tasty") && ctx.isTastyJSXComponent(node, name.name)) return "tasty";
24
- return kinds.has("component") ? "component" : null;
25
- }
26
19
  return {
27
20
  ImportDeclaration(node) {
28
21
  ctx.trackImport(node);
29
22
  },
30
23
  JSXOpeningElement(node) {
31
- const kind = classify(node);
32
- if (!kind) return;
33
- for (const attribute of node.attributes) if (attribute.type === "JSXSpreadAttribute") visitSpread(attribute.argument, kind);
34
- else if (attribute.name.type === "JSXIdentifier") check(attribute.name.name, attribute, kind);
24
+ const target = classify(node);
25
+ if (target === null) return;
26
+ for (const attribute of node.attributes) if (attribute.type === "JSXSpreadAttribute") visitSpread(attribute.argument, target);
27
+ else if (attribute.name.type === "JSXIdentifier") check(attribute.name.name, attribute, target);
35
28
  }
36
29
  };
37
30
  }
31
+ /**
32
+ * Inspect explicit props and inline object spreads on every JSX element without
33
+ * evaluating spread bindings. `kinds` limits which elements are classified and
34
+ * checked, so a Tasty-only rule never runs component recognition elsewhere.
35
+ */
36
+ function jsxPropListeners(ctx, kinds, check) {
37
+ function classify(node) {
38
+ let name = node.name;
39
+ while (name.type === "JSXMemberExpression") name = name.object;
40
+ if (name.type !== "JSXIdentifier" || node.name.type === "JSXIdentifier" && !/^[A-Z]/.test(name.name)) return kinds.has("element") ? "element" : null;
41
+ if (kinds.has("tasty") && ctx.isTastyJSXComponent(node, name.name)) return "tasty";
42
+ return kinds.has("component") ? "component" : null;
43
+ }
44
+ return jsxElementPropListeners(ctx, classify, check);
45
+ }
38
46
  const TASTY_ONLY = new Set(["tasty"]);
39
47
  /** Inspect explicit props and inline object spreads on recognized Tasty components. */
40
48
  function tastyComponentPropListeners(ctx, check) {
@@ -42,5 +50,5 @@ function tastyComponentPropListeners(ctx, check) {
42
50
  }
43
51
 
44
52
  //#endregion
45
- export { jsxPropListeners, tastyComponentPropListeners };
53
+ export { jsxElementPropListeners, jsxPropListeners, tastyComponentPropListeners };
46
54
  //# sourceMappingURL=tasty-component-props.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"tasty-component-props.js","names":[],"sources":["../../src/rules/tasty-component-props.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport type { TastyContext } from '../context.js';\nimport { getKeyName, unwrapExpression } from '../utils.js';\n\n/**\n * What a JSX element is, for prop rules: a recognized Tasty component, any\n * other component (capitalized or member tag), or a native element.\n */\nexport type JSXElementKind = 'tasty' | 'component' | 'element';\n\n/**\n * Inspect explicit props and inline object spreads on every JSX element without\n * evaluating spread bindings. `kinds` limits which elements are classified and\n * checked, so a Tasty-only rule never runs component recognition elsewhere.\n */\nexport function jsxPropListeners(\n ctx: TastyContext,\n kinds: ReadonlySet<JSXElementKind>,\n check: (name: string, node: TSESTree.Node, kind: JSXElementKind) => void,\n) {\n function visitSpread(node: TSESTree.Node, kind: JSXElementKind): void {\n node = unwrapExpression(node);\n if (node.type !== 'ObjectExpression') return;\n for (const prop of node.properties) {\n if (prop.type === 'SpreadElement') {\n visitSpread(prop.argument, kind);\n } else {\n const name = prop.computed\n ? getKeyName(unwrapExpression(prop.key))\n : getKeyName(prop.key);\n // A computed identifier is dynamic; a computed string literal is visible.\n if (\n name !== null &&\n (!prop.computed || unwrapExpression(prop.key).type === 'Literal')\n ) {\n check(name, prop, kind);\n }\n }\n }\n }\n\n function classify(node: TSESTree.JSXOpeningElement): JSXElementKind | null {\n let name = node.name;\n while (name.type === 'JSXMemberExpression') name = name.object;\n // `<svg:rect>` and lowercase tags are native; `<motion.div>` is a component.\n if (\n name.type !== 'JSXIdentifier' ||\n (node.name.type === 'JSXIdentifier' && !/^[A-Z]/.test(name.name))\n ) {\n return kinds.has('element') ? 'element' : null;\n }\n if (kinds.has('tasty') && ctx.isTastyJSXComponent(node, name.name))\n return 'tasty';\n return kinds.has('component') ? 'component' : null;\n }\n\n return {\n ImportDeclaration(node: TSESTree.ImportDeclaration) {\n ctx.trackImport(node);\n },\n JSXOpeningElement(node: TSESTree.JSXOpeningElement) {\n const kind = classify(node);\n if (!kind) return;\n for (const attribute of node.attributes) {\n if (attribute.type === 'JSXSpreadAttribute')\n visitSpread(attribute.argument, kind);\n else if (attribute.name.type === 'JSXIdentifier')\n check(attribute.name.name, attribute, kind);\n }\n },\n };\n}\n\nconst TASTY_ONLY = new Set<JSXElementKind>(['tasty']);\n\n/** Inspect explicit props and inline object spreads on recognized Tasty components. */\nexport function tastyComponentPropListeners(\n ctx: TastyContext,\n check: (name: string, node: TSESTree.Node) => void,\n) {\n return jsxPropListeners(ctx, TASTY_ONLY, (name, node) => check(name, node));\n}\n"],"mappings":";;;;;;;;AAeA,SAAgB,iBACd,KACA,OACA,OACA;CACA,SAAS,YAAY,MAAqB,MAA4B;AACpE,SAAO,iBAAiB,KAAK;AAC7B,MAAI,KAAK,SAAS,mBAAoB;AACtC,OAAK,MAAM,QAAQ,KAAK,WACtB,KAAI,KAAK,SAAS,gBAChB,aAAY,KAAK,UAAU,KAAK;OAC3B;GACL,MAAM,OAAO,KAAK,WACd,WAAW,iBAAiB,KAAK,IAAI,CAAC,GACtC,WAAW,KAAK,IAAI;AAExB,OACE,SAAS,SACR,CAAC,KAAK,YAAY,iBAAiB,KAAK,IAAI,CAAC,SAAS,WAEvD,OAAM,MAAM,MAAM,KAAK;;;CAM/B,SAAS,SAAS,MAAyD;EACzE,IAAI,OAAO,KAAK;AAChB,SAAO,KAAK,SAAS,sBAAuB,QAAO,KAAK;AAExD,MACE,KAAK,SAAS,mBACb,KAAK,KAAK,SAAS,mBAAmB,CAAC,SAAS,KAAK,KAAK,KAAK,CAEhE,QAAO,MAAM,IAAI,UAAU,GAAG,YAAY;AAE5C,MAAI,MAAM,IAAI,QAAQ,IAAI,IAAI,oBAAoB,MAAM,KAAK,KAAK,CAChE,QAAO;AACT,SAAO,MAAM,IAAI,YAAY,GAAG,cAAc;;AAGhD,QAAO;EACL,kBAAkB,MAAkC;AAClD,OAAI,YAAY,KAAK;;EAEvB,kBAAkB,MAAkC;GAClD,MAAM,OAAO,SAAS,KAAK;AAC3B,OAAI,CAAC,KAAM;AACX,QAAK,MAAM,aAAa,KAAK,WAC3B,KAAI,UAAU,SAAS,qBACrB,aAAY,UAAU,UAAU,KAAK;YAC9B,UAAU,KAAK,SAAS,gBAC/B,OAAM,UAAU,KAAK,MAAM,WAAW,KAAK;;EAGlD;;AAGH,MAAM,aAAa,IAAI,IAAoB,CAAC,QAAQ,CAAC;;AAGrD,SAAgB,4BACd,KACA,OACA;AACA,QAAO,iBAAiB,KAAK,aAAa,MAAM,SAAS,MAAM,MAAM,KAAK,CAAC"}
1
+ {"version":3,"file":"tasty-component-props.js","names":[],"sources":["../../src/rules/tasty-component-props.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport type { TastyContext } from '../context.js';\nimport { getKeyName, unwrapExpression } from '../utils.js';\n\n/**\n * What a JSX element is, for prop rules: a recognized Tasty component, any\n * other component (capitalized or member tag), or a native element.\n */\nexport type JSXElementKind = 'tasty' | 'component' | 'element';\n\n/** An explicit JSX attribute, or a key of an inline object spread. */\nexport type JSXPropNode = TSESTree.JSXAttribute | TSESTree.Property;\n\n/**\n * Inspect explicit props and inline object spreads on JSX elements without\n * evaluating spread bindings. `classify` picks the elements to check and what\n * the check is told about each; `null` skips the element.\n */\nexport function jsxElementPropListeners<T>(\n ctx: TastyContext,\n classify: (node: TSESTree.JSXOpeningElement) => T | null,\n check: (name: string, node: JSXPropNode, target: T) => void,\n) {\n function visitSpread(node: TSESTree.Node, target: T): void {\n node = unwrapExpression(node);\n if (node.type !== 'ObjectExpression') return;\n for (const prop of node.properties) {\n if (prop.type === 'SpreadElement') {\n visitSpread(prop.argument, target);\n } else {\n const name = prop.computed\n ? getKeyName(unwrapExpression(prop.key))\n : getKeyName(prop.key);\n // A computed identifier is dynamic; a computed string literal is visible.\n if (\n name !== null &&\n (!prop.computed || unwrapExpression(prop.key).type === 'Literal')\n ) {\n check(name, prop, target);\n }\n }\n }\n }\n\n return {\n ImportDeclaration(node: TSESTree.ImportDeclaration) {\n ctx.trackImport(node);\n },\n JSXOpeningElement(node: TSESTree.JSXOpeningElement) {\n const target = classify(node);\n if (target === null) return;\n for (const attribute of node.attributes) {\n if (attribute.type === 'JSXSpreadAttribute')\n visitSpread(attribute.argument, target);\n else if (attribute.name.type === 'JSXIdentifier')\n check(attribute.name.name, attribute, target);\n }\n },\n };\n}\n\n/**\n * Inspect explicit props and inline object spreads on every JSX element without\n * evaluating spread bindings. `kinds` limits which elements are classified and\n * checked, so a Tasty-only rule never runs component recognition elsewhere.\n */\nexport function jsxPropListeners(\n ctx: TastyContext,\n kinds: ReadonlySet<JSXElementKind>,\n check: (name: string, node: JSXPropNode, kind: JSXElementKind) => void,\n) {\n function classify(node: TSESTree.JSXOpeningElement): JSXElementKind | null {\n let name = node.name;\n while (name.type === 'JSXMemberExpression') name = name.object;\n // `<svg:rect>` and lowercase tags are native; `<motion.div>` is a component.\n if (\n name.type !== 'JSXIdentifier' ||\n (node.name.type === 'JSXIdentifier' && !/^[A-Z]/.test(name.name))\n ) {\n return kinds.has('element') ? 'element' : null;\n }\n if (kinds.has('tasty') && ctx.isTastyJSXComponent(node, name.name))\n return 'tasty';\n return kinds.has('component') ? 'component' : null;\n }\n\n return jsxElementPropListeners(ctx, classify, check);\n}\n\nconst TASTY_ONLY = new Set<JSXElementKind>(['tasty']);\n\n/** Inspect explicit props and inline object spreads on recognized Tasty components. */\nexport function tastyComponentPropListeners(\n ctx: TastyContext,\n check: (name: string, node: JSXPropNode) => void,\n) {\n return jsxPropListeners(ctx, TASTY_ONLY, (name, node) => check(name, node));\n}\n"],"mappings":";;;;;;;;AAkBA,SAAgB,wBACd,KACA,UACA,OACA;CACA,SAAS,YAAY,MAAqB,QAAiB;AACzD,SAAO,iBAAiB,KAAK;AAC7B,MAAI,KAAK,SAAS,mBAAoB;AACtC,OAAK,MAAM,QAAQ,KAAK,WACtB,KAAI,KAAK,SAAS,gBAChB,aAAY,KAAK,UAAU,OAAO;OAC7B;GACL,MAAM,OAAO,KAAK,WACd,WAAW,iBAAiB,KAAK,IAAI,CAAC,GACtC,WAAW,KAAK,IAAI;AAExB,OACE,SAAS,SACR,CAAC,KAAK,YAAY,iBAAiB,KAAK,IAAI,CAAC,SAAS,WAEvD,OAAM,MAAM,MAAM,OAAO;;;AAMjC,QAAO;EACL,kBAAkB,MAAkC;AAClD,OAAI,YAAY,KAAK;;EAEvB,kBAAkB,MAAkC;GAClD,MAAM,SAAS,SAAS,KAAK;AAC7B,OAAI,WAAW,KAAM;AACrB,QAAK,MAAM,aAAa,KAAK,WAC3B,KAAI,UAAU,SAAS,qBACrB,aAAY,UAAU,UAAU,OAAO;YAChC,UAAU,KAAK,SAAS,gBAC/B,OAAM,UAAU,KAAK,MAAM,WAAW,OAAO;;EAGpD;;;;;;;AAQH,SAAgB,iBACd,KACA,OACA,OACA;CACA,SAAS,SAAS,MAAyD;EACzE,IAAI,OAAO,KAAK;AAChB,SAAO,KAAK,SAAS,sBAAuB,QAAO,KAAK;AAExD,MACE,KAAK,SAAS,mBACb,KAAK,KAAK,SAAS,mBAAmB,CAAC,SAAS,KAAK,KAAK,KAAK,CAEhE,QAAO,MAAM,IAAI,UAAU,GAAG,YAAY;AAE5C,MAAI,MAAM,IAAI,QAAQ,IAAI,IAAI,oBAAoB,MAAM,KAAK,KAAK,CAChE,QAAO;AACT,SAAO,MAAM,IAAI,YAAY,GAAG,cAAc;;AAGhD,QAAO,wBAAwB,KAAK,UAAU,MAAM;;AAGtD,MAAM,aAAa,IAAI,IAAoB,CAAC,QAAQ,CAAC;;AAGrD,SAAgB,4BACd,KACA,OACA;AACA,QAAO,iBAAiB,KAAK,aAAa,MAAM,SAAS,MAAM,MAAM,KAAK,CAAC"}
package/dist/types.d.ts CHANGED
@@ -8,6 +8,28 @@ interface StyleFunctionConfig {
8
8
  /** Treat styles as a partial override of existing styles. Defaults to false. */
9
9
  partial?: boolean;
10
10
  }
11
+ /** Design-system components whose look is a contract, for `no-protected-restyle`. */
12
+ interface ProtectedComponentConfig {
13
+ /** Package the components are imported from. `*` matches any run of characters. */
14
+ source: string;
15
+ /**
16
+ * Exported names to protect, `*` matching any run of characters. Defaults to
17
+ * every export. A name with a dot is a member path, such as `Button.Split` or
18
+ * `Menu.*`, and wins over an entry that matches only the export.
19
+ */
20
+ include?: string[];
21
+ /**
22
+ * Exported names or member paths to leave out, such as layout and typography
23
+ * primitives, or a separate component attached as `Button.Group`.
24
+ */
25
+ exclude?: string[];
26
+ /**
27
+ * Style props that place or size the root and stay allowed: `'position'` and
28
+ * `'dimension'` stand for Tasty's `POSITION_STYLES` and `DIMENSION_STYLES`,
29
+ * anything else is a property name.
30
+ */
31
+ allowedStyleProps?: string[];
32
+ }
11
33
  interface TastyValidationConfig {
12
34
  extends?: string;
13
35
  tokens?: false | string[];
@@ -39,6 +61,11 @@ interface TastyValidationConfig {
39
61
  * of characters, so `@my-org/*` covers every package under the scope.
40
62
  */
41
63
  ownedSources?: string[];
64
+ /**
65
+ * Components a consumer should customize through their props rather than
66
+ * restyle. Entries from an extended config come first; `false` drops them.
67
+ */
68
+ protectedComponents?: false | ProtectedComponentConfig[];
42
69
  }
43
70
  interface ResolvedConfig {
44
71
  tokens: false | string[];
@@ -59,7 +86,9 @@ interface ResolvedConfig {
59
86
  ownedSources?: string[];
60
87
  /** Optional for compatibility with existing consumer-built configs. */
61
88
  styleFunctions?: Record<string, StyleFunctionConfig>;
89
+ /** Optional for compatibility with existing consumer-built configs. */
90
+ protectedComponents?: ProtectedComponentConfig[];
62
91
  }
63
92
  //#endregion
64
- export { ResolvedConfig, StyleFunctionConfig, TastyValidationConfig };
93
+ export { ProtectedComponentConfig, ResolvedConfig, StyleFunctionConfig, TastyValidationConfig };
65
94
  //# sourceMappingURL=types.d.ts.map
package/dist/utils.js CHANGED
@@ -7,6 +7,11 @@ function unwrapExpression(node) {
7
7
  while (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSNonNullExpression" || current.type === "TSTypeAssertion" || current.type === "TSInstantiationExpression") current = current.expression;
8
8
  return current;
9
9
  }
10
+ /** A whole-string pattern in which `*` matches any run of characters. */
11
+ function wildcardPattern(pattern) {
12
+ const escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13
+ return new RegExp(`^${escaped.replace(/\\\*/g, ".*")}$`);
14
+ }
10
15
  /**
11
16
  * Gets the string value of a property key node.
12
17
  */
@@ -123,5 +128,5 @@ function collectLocalStateAliases(node) {
123
128
  }
124
129
 
125
130
  //#endregion
126
- export { collectLocalStateAliases, getKeyName, getStringValue, isKnownStateAlias, isRawHexColor, isStaticValue, isValidSelector, isValidUnit, unwrapExpression, validateColorTokenSyntax };
131
+ export { collectLocalStateAliases, getKeyName, getStringValue, isKnownStateAlias, isRawHexColor, isStaticValue, isValidSelector, isValidUnit, unwrapExpression, validateColorTokenSyntax, wildcardPattern };
127
132
  //# sourceMappingURL=utils.js.map
package/dist/utils.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","names":[],"sources":["../src/utils.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport {\n BUILT_IN_UNITS,\n CSS_UNITS,\n BUILT_IN_STATE_PREFIXES,\n} from './constants.js';\nimport type { ResolvedConfig } from './types.js';\n\n/** Strip TypeScript wrappers that do not change an expression's runtime value. */\nexport function unwrapExpression(node: TSESTree.Node): TSESTree.Node {\n let current = node;\n\n while (\n current.type === 'TSAsExpression' ||\n current.type === 'TSSatisfiesExpression' ||\n current.type === 'TSNonNullExpression' ||\n current.type === 'TSTypeAssertion' ||\n current.type === 'TSInstantiationExpression'\n ) {\n current = current.expression;\n }\n\n return current;\n}\n\n/**\n * Gets the string value of a property key node.\n */\nexport function getKeyName(key: TSESTree.Node): string | null {\n if (key.type === 'Identifier') return key.name;\n if (key.type === 'Literal' && typeof key.value === 'string') return key.value;\n if (key.type === 'Literal' && typeof key.value === 'number')\n return String(key.value);\n return null;\n}\n\n/**\n * Gets the string value of a node if it is a string literal.\n */\nexport function getStringValue(node: TSESTree.Node): string | null {\n if (node.type === 'Literal' && typeof node.value === 'string') {\n return node.value;\n }\n if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {\n return node.quasis[0].value.cooked ?? null;\n }\n return null;\n}\n\n/**\n * Checks if a value node is a static literal.\n */\nexport function isStaticValue(node: TSESTree.Node): boolean {\n node = unwrapExpression(node);\n if (node.type === 'Literal') return true;\n if (\n node.type === 'UnaryExpression' &&\n node.operator === '-' &&\n node.argument.type === 'Literal'\n ) {\n return true;\n }\n if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {\n return true;\n }\n if (node.type === 'ArrayExpression') {\n return node.elements.every((el) => el !== null && isStaticValue(el));\n }\n if (node.type === 'ObjectExpression') {\n return node.properties.every(\n (prop) =>\n prop.type === 'Property' && !prop.computed && isStaticValue(prop.value),\n );\n }\n return false;\n}\n\n/**\n * Validates color token syntax.\n * Returns null if valid, or an error message if invalid.\n */\nexport function validateColorTokenSyntax(token: string): string | null {\n // Strip leading # or ##\n let name = token;\n if (name.startsWith('##')) {\n name = name.slice(2);\n } else if (name.startsWith('#')) {\n name = name.slice(1);\n } else {\n return 'Color token must start with #';\n }\n\n if (name.length === 0) return 'Empty color token name';\n\n // Check for opacity suffix\n const dotIndex = name.indexOf('.');\n if (dotIndex !== -1) {\n const tokenName = name.slice(0, dotIndex);\n const opacitySuffix = name.slice(dotIndex + 1);\n\n if (tokenName.length === 0) return 'Empty color token name before opacity';\n\n if (opacitySuffix.startsWith('$')) {\n // Dynamic opacity from CSS custom property — always valid\n return null;\n }\n\n if (opacitySuffix.length === 0) return 'Trailing dot with no opacity value';\n\n // Tasty reads the digits as a decimal fraction (`.5` and `.50` are 50%,\n // `.150` is 15%), so any digit string is in range.\n if (!/^\\d+$/.test(opacitySuffix)) {\n return /^-\\d+$/.test(opacitySuffix)\n ? 'Opacity cannot be negative'\n : `Invalid opacity value '${opacitySuffix}'`;\n }\n }\n\n return null;\n}\n\n/**\n * Checks if a string looks like a raw hex color (not a token).\n * Hex colors: #fff, #ffff, #ffffff, #ffffffff (3, 4, 6, or 8 hex chars).\n */\nexport function isRawHexColor(value: string): boolean {\n if (!value.startsWith('#')) return false;\n const hex = value.slice(1).split('.')[0];\n if (![3, 4, 6, 8].includes(hex.length)) return false;\n return /^[0-9a-fA-F]+$/.test(hex);\n}\n\n/**\n * Extracts custom unit from a value token like \"2x\", \"1.5r\", \"3cols\".\n * Returns the unit name, or null if not a custom-unit value.\n */\nexport function extractCustomUnit(token: string): string | null {\n const match = token.match(/^-?[\\d.]+([a-zA-Z]+)$/);\n if (!match) return null;\n return match[1];\n}\n\n/**\n * Checks if a unit is valid (built-in, CSS, or in config).\n */\nexport function isValidUnit(unit: string, config: ResolvedConfig): boolean {\n if (config.units === false) return true;\n if (BUILT_IN_UNITS.has(unit)) return true;\n if (CSS_UNITS.has(unit)) return true;\n if (Array.isArray(config.units) && config.units.includes(unit)) return true;\n return false;\n}\n\n/**\n * Checks if a state alias key (starting with @) is known.\n */\nexport function isKnownStateAlias(\n key: string,\n config: ResolvedConfig,\n): boolean {\n // Built-in prefixes\n for (const prefix of BUILT_IN_STATE_PREFIXES) {\n if (key === prefix || key.startsWith(prefix + '(')) return true;\n }\n // Container query shorthand\n if (key.startsWith('@(')) return true;\n // Config aliases\n return config.states.includes(key);\n}\n\n/**\n * Checks if a CSS selector string is basically valid.\n */\nexport function isValidSelector(selector: string): string | null {\n if (selector.length === 0) return 'Selector cannot be empty';\n\n // Check balanced brackets\n let depth = 0;\n for (const char of selector) {\n if (char === '(' || char === '[') depth++;\n if (char === ')' || char === ']') depth--;\n if (depth < 0) return 'Unbalanced brackets in selector';\n }\n if (depth !== 0) return 'Unbalanced brackets in selector';\n\n return null;\n}\n\n/**\n * Finds a property by key name in an object expression.\n */\nexport function findProperty(\n obj: TSESTree.ObjectExpression,\n name: string,\n): TSESTree.Property | undefined {\n for (const prop of obj.properties) {\n if (prop.type === 'Property' && !prop.computed) {\n const keyName = getKeyName(prop.key);\n if (keyName === name) return prop;\n }\n }\n return undefined;\n}\n\n/**\n * Collects local predefined state alias names from a styles ObjectExpression.\n * Local states are top-level `@name` keys with string literal values,\n * mirroring the runtime's `extractLocalPredefinedStates()`.\n */\nexport function collectLocalStateAliases(\n node: TSESTree.ObjectExpression,\n): string[] {\n const aliases: string[] = [];\n\n for (const prop of node.properties) {\n if (prop.type !== 'Property' || prop.computed) continue;\n\n const key = getKeyName(prop.key);\n if (key === null) continue;\n if (!/^@[A-Za-z][A-Za-z0-9-]*$/.test(key)) continue;\n\n // Skip built-in prefixes — they are not local state definitions\n if (BUILT_IN_STATE_PREFIXES.has(key)) continue;\n\n // Must have a string value to be a state definition\n if (getStringValue(prop.value) !== null) {\n aliases.push(key);\n }\n }\n\n return aliases;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"utils.js","names":[],"sources":["../src/utils.ts"],"sourcesContent":["import type { TSESTree } from '@typescript-eslint/utils';\nimport {\n BUILT_IN_UNITS,\n CSS_UNITS,\n BUILT_IN_STATE_PREFIXES,\n} from './constants.js';\nimport type { ResolvedConfig } from './types.js';\n\n/** Strip TypeScript wrappers that do not change an expression's runtime value. */\nexport function unwrapExpression(node: TSESTree.Node): TSESTree.Node {\n let current = node;\n\n while (\n current.type === 'TSAsExpression' ||\n current.type === 'TSSatisfiesExpression' ||\n current.type === 'TSNonNullExpression' ||\n current.type === 'TSTypeAssertion' ||\n current.type === 'TSInstantiationExpression'\n ) {\n current = current.expression;\n }\n\n return current;\n}\n\n/** A whole-string pattern in which `*` matches any run of characters. */\nexport function wildcardPattern(pattern: string): RegExp {\n const escaped = pattern.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\n return new RegExp(`^${escaped.replace(/\\\\\\*/g, '.*')}$`);\n}\n\n/**\n * Gets the string value of a property key node.\n */\nexport function getKeyName(key: TSESTree.Node): string | null {\n if (key.type === 'Identifier') return key.name;\n if (key.type === 'Literal' && typeof key.value === 'string') return key.value;\n if (key.type === 'Literal' && typeof key.value === 'number')\n return String(key.value);\n return null;\n}\n\n/**\n * Gets the string value of a node if it is a string literal.\n */\nexport function getStringValue(node: TSESTree.Node): string | null {\n if (node.type === 'Literal' && typeof node.value === 'string') {\n return node.value;\n }\n if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {\n return node.quasis[0].value.cooked ?? null;\n }\n return null;\n}\n\n/**\n * Checks if a value node is a static literal.\n */\nexport function isStaticValue(node: TSESTree.Node): boolean {\n node = unwrapExpression(node);\n if (node.type === 'Literal') return true;\n if (\n node.type === 'UnaryExpression' &&\n node.operator === '-' &&\n node.argument.type === 'Literal'\n ) {\n return true;\n }\n if (node.type === 'TemplateLiteral' && node.expressions.length === 0) {\n return true;\n }\n if (node.type === 'ArrayExpression') {\n return node.elements.every((el) => el !== null && isStaticValue(el));\n }\n if (node.type === 'ObjectExpression') {\n return node.properties.every(\n (prop) =>\n prop.type === 'Property' && !prop.computed && isStaticValue(prop.value),\n );\n }\n return false;\n}\n\n/**\n * Validates color token syntax.\n * Returns null if valid, or an error message if invalid.\n */\nexport function validateColorTokenSyntax(token: string): string | null {\n // Strip leading # or ##\n let name = token;\n if (name.startsWith('##')) {\n name = name.slice(2);\n } else if (name.startsWith('#')) {\n name = name.slice(1);\n } else {\n return 'Color token must start with #';\n }\n\n if (name.length === 0) return 'Empty color token name';\n\n // Check for opacity suffix\n const dotIndex = name.indexOf('.');\n if (dotIndex !== -1) {\n const tokenName = name.slice(0, dotIndex);\n const opacitySuffix = name.slice(dotIndex + 1);\n\n if (tokenName.length === 0) return 'Empty color token name before opacity';\n\n if (opacitySuffix.startsWith('$')) {\n // Dynamic opacity from CSS custom property — always valid\n return null;\n }\n\n if (opacitySuffix.length === 0) return 'Trailing dot with no opacity value';\n\n // Tasty reads the digits as a decimal fraction (`.5` and `.50` are 50%,\n // `.150` is 15%), so any digit string is in range.\n if (!/^\\d+$/.test(opacitySuffix)) {\n return /^-\\d+$/.test(opacitySuffix)\n ? 'Opacity cannot be negative'\n : `Invalid opacity value '${opacitySuffix}'`;\n }\n }\n\n return null;\n}\n\n/**\n * Checks if a string looks like a raw hex color (not a token).\n * Hex colors: #fff, #ffff, #ffffff, #ffffffff (3, 4, 6, or 8 hex chars).\n */\nexport function isRawHexColor(value: string): boolean {\n if (!value.startsWith('#')) return false;\n const hex = value.slice(1).split('.')[0];\n if (![3, 4, 6, 8].includes(hex.length)) return false;\n return /^[0-9a-fA-F]+$/.test(hex);\n}\n\n/**\n * Extracts custom unit from a value token like \"2x\", \"1.5r\", \"3cols\".\n * Returns the unit name, or null if not a custom-unit value.\n */\nexport function extractCustomUnit(token: string): string | null {\n const match = token.match(/^-?[\\d.]+([a-zA-Z]+)$/);\n if (!match) return null;\n return match[1];\n}\n\n/**\n * Checks if a unit is valid (built-in, CSS, or in config).\n */\nexport function isValidUnit(unit: string, config: ResolvedConfig): boolean {\n if (config.units === false) return true;\n if (BUILT_IN_UNITS.has(unit)) return true;\n if (CSS_UNITS.has(unit)) return true;\n if (Array.isArray(config.units) && config.units.includes(unit)) return true;\n return false;\n}\n\n/**\n * Checks if a state alias key (starting with @) is known.\n */\nexport function isKnownStateAlias(\n key: string,\n config: ResolvedConfig,\n): boolean {\n // Built-in prefixes\n for (const prefix of BUILT_IN_STATE_PREFIXES) {\n if (key === prefix || key.startsWith(prefix + '(')) return true;\n }\n // Container query shorthand\n if (key.startsWith('@(')) return true;\n // Config aliases\n return config.states.includes(key);\n}\n\n/**\n * Checks if a CSS selector string is basically valid.\n */\nexport function isValidSelector(selector: string): string | null {\n if (selector.length === 0) return 'Selector cannot be empty';\n\n // Check balanced brackets\n let depth = 0;\n for (const char of selector) {\n if (char === '(' || char === '[') depth++;\n if (char === ')' || char === ']') depth--;\n if (depth < 0) return 'Unbalanced brackets in selector';\n }\n if (depth !== 0) return 'Unbalanced brackets in selector';\n\n return null;\n}\n\n/**\n * Finds a property by key name in an object expression.\n */\nexport function findProperty(\n obj: TSESTree.ObjectExpression,\n name: string,\n): TSESTree.Property | undefined {\n for (const prop of obj.properties) {\n if (prop.type === 'Property' && !prop.computed) {\n const keyName = getKeyName(prop.key);\n if (keyName === name) return prop;\n }\n }\n return undefined;\n}\n\n/**\n * Collects local predefined state alias names from a styles ObjectExpression.\n * Local states are top-level `@name` keys with string literal values,\n * mirroring the runtime's `extractLocalPredefinedStates()`.\n */\nexport function collectLocalStateAliases(\n node: TSESTree.ObjectExpression,\n): string[] {\n const aliases: string[] = [];\n\n for (const prop of node.properties) {\n if (prop.type !== 'Property' || prop.computed) continue;\n\n const key = getKeyName(prop.key);\n if (key === null) continue;\n if (!/^@[A-Za-z][A-Za-z0-9-]*$/.test(key)) continue;\n\n // Skip built-in prefixes — they are not local state definitions\n if (BUILT_IN_STATE_PREFIXES.has(key)) continue;\n\n // Must have a string value to be a state definition\n if (getStringValue(prop.value) !== null) {\n aliases.push(key);\n }\n }\n\n return aliases;\n}\n"],"mappings":";;;;AASA,SAAgB,iBAAiB,MAAoC;CACnE,IAAI,UAAU;AAEd,QACE,QAAQ,SAAS,oBACjB,QAAQ,SAAS,2BACjB,QAAQ,SAAS,yBACjB,QAAQ,SAAS,qBACjB,QAAQ,SAAS,4BAEjB,WAAU,QAAQ;AAGpB,QAAO;;;AAIT,SAAgB,gBAAgB,SAAyB;CACvD,MAAM,UAAU,QAAQ,QAAQ,uBAAuB,OAAO;AAE9D,QAAO,IAAI,OAAO,IAAI,QAAQ,QAAQ,SAAS,KAAK,CAAC,GAAG;;;;;AAM1D,SAAgB,WAAW,KAAmC;AAC5D,KAAI,IAAI,SAAS,aAAc,QAAO,IAAI;AAC1C,KAAI,IAAI,SAAS,aAAa,OAAO,IAAI,UAAU,SAAU,QAAO,IAAI;AACxE,KAAI,IAAI,SAAS,aAAa,OAAO,IAAI,UAAU,SACjD,QAAO,OAAO,IAAI,MAAM;AAC1B,QAAO;;;;;AAMT,SAAgB,eAAe,MAAoC;AACjE,KAAI,KAAK,SAAS,aAAa,OAAO,KAAK,UAAU,SACnD,QAAO,KAAK;AAEd,KAAI,KAAK,SAAS,qBAAqB,KAAK,YAAY,WAAW,EACjE,QAAO,KAAK,OAAO,GAAG,MAAM,UAAU;AAExC,QAAO;;;;;AAMT,SAAgB,cAAc,MAA8B;AAC1D,QAAO,iBAAiB,KAAK;AAC7B,KAAI,KAAK,SAAS,UAAW,QAAO;AACpC,KACE,KAAK,SAAS,qBACd,KAAK,aAAa,OAClB,KAAK,SAAS,SAAS,UAEvB,QAAO;AAET,KAAI,KAAK,SAAS,qBAAqB,KAAK,YAAY,WAAW,EACjE,QAAO;AAET,KAAI,KAAK,SAAS,kBAChB,QAAO,KAAK,SAAS,OAAO,OAAO,OAAO,QAAQ,cAAc,GAAG,CAAC;AAEtE,KAAI,KAAK,SAAS,mBAChB,QAAO,KAAK,WAAW,OACpB,SACC,KAAK,SAAS,cAAc,CAAC,KAAK,YAAY,cAAc,KAAK,MAAM,CAC1E;AAEH,QAAO;;;;;;AAOT,SAAgB,yBAAyB,OAA8B;CAErE,IAAI,OAAO;AACX,KAAI,KAAK,WAAW,KAAK,CACvB,QAAO,KAAK,MAAM,EAAE;UACX,KAAK,WAAW,IAAI,CAC7B,QAAO,KAAK,MAAM,EAAE;KAEpB,QAAO;AAGT,KAAI,KAAK,WAAW,EAAG,QAAO;CAG9B,MAAM,WAAW,KAAK,QAAQ,IAAI;AAClC,KAAI,aAAa,IAAI;EACnB,MAAM,YAAY,KAAK,MAAM,GAAG,SAAS;EACzC,MAAM,gBAAgB,KAAK,MAAM,WAAW,EAAE;AAE9C,MAAI,UAAU,WAAW,EAAG,QAAO;AAEnC,MAAI,cAAc,WAAW,IAAI,CAE/B,QAAO;AAGT,MAAI,cAAc,WAAW,EAAG,QAAO;AAIvC,MAAI,CAAC,QAAQ,KAAK,cAAc,CAC9B,QAAO,SAAS,KAAK,cAAc,GAC/B,+BACA,0BAA0B,cAAc;;AAIhD,QAAO;;;;;;AAOT,SAAgB,cAAc,OAAwB;AACpD,KAAI,CAAC,MAAM,WAAW,IAAI,CAAE,QAAO;CACnC,MAAM,MAAM,MAAM,MAAM,EAAE,CAAC,MAAM,IAAI,CAAC;AACtC,KAAI,CAAC;EAAC;EAAG;EAAG;EAAG;EAAE,CAAC,SAAS,IAAI,OAAO,CAAE,QAAO;AAC/C,QAAO,iBAAiB,KAAK,IAAI;;;;;AAgBnC,SAAgB,YAAY,MAAc,QAAiC;AACzE,KAAI,OAAO,UAAU,MAAO,QAAO;AACnC,KAAI,eAAe,IAAI,KAAK,CAAE,QAAO;AACrC,KAAI,UAAU,IAAI,KAAK,CAAE,QAAO;AAChC,KAAI,MAAM,QAAQ,OAAO,MAAM,IAAI,OAAO,MAAM,SAAS,KAAK,CAAE,QAAO;AACvE,QAAO;;;;;AAMT,SAAgB,kBACd,KACA,QACS;AAET,MAAK,MAAM,UAAU,wBACnB,KAAI,QAAQ,UAAU,IAAI,WAAW,SAAS,IAAI,CAAE,QAAO;AAG7D,KAAI,IAAI,WAAW,KAAK,CAAE,QAAO;AAEjC,QAAO,OAAO,OAAO,SAAS,IAAI;;;;;AAMpC,SAAgB,gBAAgB,UAAiC;AAC/D,KAAI,SAAS,WAAW,EAAG,QAAO;CAGlC,IAAI,QAAQ;AACZ,MAAK,MAAM,QAAQ,UAAU;AAC3B,MAAI,SAAS,OAAO,SAAS,IAAK;AAClC,MAAI,SAAS,OAAO,SAAS,IAAK;AAClC,MAAI,QAAQ,EAAG,QAAO;;AAExB,KAAI,UAAU,EAAG,QAAO;AAExB,QAAO;;;;;;;AAwBT,SAAgB,yBACd,MACU;CACV,MAAM,UAAoB,EAAE;AAE5B,MAAK,MAAM,QAAQ,KAAK,YAAY;AAClC,MAAI,KAAK,SAAS,cAAc,KAAK,SAAU;EAE/C,MAAM,MAAM,WAAW,KAAK,IAAI;AAChC,MAAI,QAAQ,KAAM;AAClB,MAAI,CAAC,2BAA2B,KAAK,IAAI,CAAE;AAG3C,MAAI,wBAAwB,IAAI,IAAI,CAAE;AAGtC,MAAI,eAAe,KAAK,MAAM,KAAK,KACjC,SAAQ,KAAK,IAAI;;AAIrB,QAAO"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/eslint-plugin-tasty",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "ESLint plugin for validating tasty() and tastyStatic() style objects",
5
5
  "type": "module",
6
6
  "exports": {