@tenphi/eslint-plugin-tasty 1.9.1 → 1.11.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,160 @@
1
+ import { DIMENSION_STYLES, POSITION_STYLES } from "./constants.js";
2
+ import { unwrapExpression, wildcardPattern } from "./utils.js";
3
+ import { resolveModuleExportOrigin } from "./module-components.js";
4
+
5
+ //#region src/protected-components.ts
6
+ const STYLE_PROP_GROUPS = new Map([["position", POSITION_STYLES], ["dimension", DIMENSION_STYLES]]);
7
+ const entriesByConfig = /* @__PURE__ */ new WeakMap();
8
+ function readEntries(config) {
9
+ let entries = entriesByConfig.get(config);
10
+ if (entries) return entries;
11
+ const list = config.protectedComponents ?? [];
12
+ if (!Array.isArray(list)) throw new Error("tasty: protectedComponents must be an array.");
13
+ entries = list.map((item, index) => {
14
+ const at = `tasty: protectedComponents[${index}]`;
15
+ if (!item || typeof item !== "object" || typeof item.source !== "string") throw new Error(`${at} must be an object with a string 'source'.`);
16
+ const entry = item;
17
+ const strings = (key) => {
18
+ const value = entry[key];
19
+ if (value === void 0) return key === "include" ? ["*"] : [];
20
+ if (!Array.isArray(value) || value.some((name) => typeof name !== "string")) throw new Error(`${at}.${key} must be an array of strings.`);
21
+ return value;
22
+ };
23
+ return {
24
+ source: wildcardPattern(entry.source),
25
+ include: strings("include").map(wildcardPattern),
26
+ exclude: strings("exclude").map(wildcardPattern),
27
+ allowedStyleProps: new Set(strings("allowedStyleProps").flatMap((name) => STYLE_PROP_GROUPS.get(name) ?? [name]))
28
+ };
29
+ });
30
+ entriesByConfig.set(config, entries);
31
+ return entries;
32
+ }
33
+ function importedName(specifier) {
34
+ return specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
35
+ }
36
+ /**
37
+ * Resolves JSX tags and `tasty()` bases to the `protectedComponents` entry they
38
+ * name, by the name their source exports them under. Aliases, namespace members
39
+ * (`UI.Button`, and `Text.Strong` as `Text`), `const` aliases, `tasty()`
40
+ * wrappers and relative re-exports are followed; relative modules need the
41
+ * `typescript` package, as for other cross-file recognition.
42
+ */
43
+ var ProtectedComponents = class {
44
+ entries;
45
+ bindings = /* @__PURE__ */ new WeakMap();
46
+ modules = /* @__PURE__ */ new Map();
47
+ constructor(context, ctx) {
48
+ this.context = context;
49
+ this.ctx = ctx;
50
+ this.entries = readEntries(ctx.config);
51
+ }
52
+ get enabled() {
53
+ return this.entries.length > 0;
54
+ }
55
+ /** `tasty(Base, ...)`: a Tasty wrapper of an existing component. */
56
+ isWrapperCall(node) {
57
+ const base = node.arguments[0];
58
+ return this.ctx.isTastyCall(node)?.importedName === "tasty" && base !== void 0 && base.type !== "SpreadElement" && unwrapExpression(base).type !== "ObjectExpression";
59
+ }
60
+ /** The protected component a JSX tag name refers to. */
61
+ resolveTag(name) {
62
+ if (name.type === "JSXIdentifier" && !/^[A-Z]/.test(name.name)) return null;
63
+ let root = name;
64
+ let member;
65
+ while (root.type === "JSXMemberExpression") {
66
+ member = root.property.name;
67
+ root = root.object;
68
+ }
69
+ if (root.type !== "JSXIdentifier") return null;
70
+ return this.match(this.resolveBinding(root, root.name, member, /* @__PURE__ */ new Set()));
71
+ }
72
+ /** The protected component an expression, such as a `tasty()` base, refers to. */
73
+ resolveExpression(node) {
74
+ return this.match(this.origin(node, void 0, /* @__PURE__ */ new Set()));
75
+ }
76
+ match(origin) {
77
+ if (!origin) return null;
78
+ const entry = this.entries.find((candidate) => candidate.source.test(origin.source) && candidate.include.some((pattern) => pattern.test(origin.name)) && !candidate.exclude.some((pattern) => pattern.test(origin.name)));
79
+ return entry ? {
80
+ ...origin,
81
+ allowedStyleProps: entry.allowedStyleProps
82
+ } : null;
83
+ }
84
+ origin(node, member, visited) {
85
+ const value = unwrapExpression(node);
86
+ if (value.type === "Identifier") return this.resolveBinding(value, value.name, member, visited);
87
+ if (value.type === "MemberExpression") {
88
+ let root = value;
89
+ let first = member;
90
+ while (root.type === "MemberExpression") {
91
+ if (root.computed || root.property.type !== "Identifier") return null;
92
+ first = root.property.name;
93
+ root = unwrapExpression(root.object);
94
+ }
95
+ return root.type === "Identifier" ? this.resolveBinding(root, root.name, first, visited) : null;
96
+ }
97
+ if (member === void 0 && value.type === "CallExpression" && this.isWrapperCall(value)) {
98
+ const origin = this.origin(value.arguments[0], void 0, visited);
99
+ return origin && {
100
+ ...origin,
101
+ wrapped: true
102
+ };
103
+ }
104
+ return null;
105
+ }
106
+ /** `member` is the first property read off the binding, as in `UI.Button`. */
107
+ resolveBinding(reference, localName, member, visited) {
108
+ let scope = this.context.sourceCode.getScope(reference);
109
+ while (scope && !scope.set.has(localName)) scope = scope.upper;
110
+ const variable = scope?.set.get(localName);
111
+ if (!variable || variable.defs.length !== 1) return null;
112
+ let cache = this.bindings.get(variable);
113
+ if (!cache) {
114
+ cache = /* @__PURE__ */ new Map();
115
+ this.bindings.set(variable, cache);
116
+ }
117
+ const key = member ?? "";
118
+ if (cache.has(key)) return cache.get(key);
119
+ const result = this.resolveDefinition(variable.defs[0], member, visited);
120
+ cache.set(key, result);
121
+ return result;
122
+ }
123
+ resolveDefinition(def, member, visited) {
124
+ if (def.type === "ImportBinding") {
125
+ if (def.parent.type !== "ImportDeclaration" || def.parent.importKind === "type") return null;
126
+ const specifier = def.node;
127
+ let name;
128
+ if (specifier.type === "ImportNamespaceSpecifier") name = member;
129
+ else if (specifier.type === "ImportDefaultSpecifier") name = "default";
130
+ else if (specifier.type === "ImportSpecifier") {
131
+ if (specifier.importKind === "type") return null;
132
+ name = importedName(specifier);
133
+ }
134
+ if (name === void 0) return null;
135
+ const source = def.parent.source.value;
136
+ if (!source.startsWith(".")) return {
137
+ source,
138
+ name,
139
+ wrapped: false
140
+ };
141
+ return this.moduleOrigin(source, name);
142
+ }
143
+ if (def.type !== "Variable" || def.parent.kind !== "const" || def.node.id.type !== "Identifier" || !def.node.init || visited.has(def.node)) return null;
144
+ visited.add(def.node);
145
+ return this.origin(def.node.init, member, visited);
146
+ }
147
+ moduleOrigin(source, exportName) {
148
+ const key = `${source}\0${exportName}`;
149
+ if (!this.modules.has(key)) this.modules.set(key, resolveModuleExportOrigin(this.context.filename, source, exportName, {
150
+ importSources: this.ctx.trackedSources,
151
+ isComponentFactory: (name) => name === "tasty",
152
+ isOriginSource: (candidate) => this.entries.some((entry) => entry.source.test(candidate))
153
+ }));
154
+ return this.modules.get(key);
155
+ }
156
+ };
157
+
158
+ //#endregion
159
+ export { ProtectedComponents };
160
+ //# 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} from './module-components.js';\nimport { unwrapExpression, wildcardPattern } from './utils.js';\n\n/** A component resolved to a `protectedComponents` entry. */\nexport interface ProtectedComponent {\n /** The name its source exports it under: `Button` for `<UI.Button>` or an alias. */\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 Entry {\n source: RegExp;\n include: RegExp[];\n exclude: RegExp[];\n allowedStyleProps: ReadonlySet<string>;\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(wildcardPattern),\n exclude: strings('exclude').map(wildcardPattern),\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. Aliases, namespace members\n * (`UI.Button`, and `Text.Strong` as `Text`), `const` aliases, `tasty()`\n * wrappers and relative re-exports are followed; relative modules need the\n * `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 let member: string | undefined;\n while (root.type === 'JSXMemberExpression') {\n member = root.property.name;\n root = root.object;\n }\n if (root.type !== 'JSXIdentifier') return null;\n return this.match(this.resolveBinding(root, root.name, member, 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, undefined, new Set()));\n }\n\n private match(origin: ModuleExportOrigin | null): ProtectedComponent | null {\n if (!origin) return null;\n const entry = this.entries.find(\n (candidate) =>\n candidate.source.test(origin.source) &&\n candidate.include.some((pattern) => pattern.test(origin.name)) &&\n !candidate.exclude.some((pattern) => pattern.test(origin.name)),\n );\n return entry\n ? { ...origin, allowedStyleProps: entry.allowedStyleProps }\n : null;\n }\n\n private origin(\n node: TSESTree.Node,\n member: string | undefined,\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, member, visited);\n }\n if (value.type === 'MemberExpression') {\n let root: TSESTree.Node = value;\n let first = member;\n while (root.type === 'MemberExpression') {\n if (root.computed || root.property.type !== 'Identifier') return null;\n first = root.property.name;\n root = unwrapExpression(root.object);\n }\n return root.type === 'Identifier'\n ? this.resolveBinding(root, root.name, first, visited)\n : null;\n }\n if (\n member === undefined &&\n value.type === 'CallExpression' &&\n this.isWrapperCall(value)\n ) {\n const origin = this.origin(value.arguments[0], undefined, visited);\n return origin && { ...origin, wrapped: true };\n }\n return null;\n }\n\n /** `member` is the first property read off the binding, as in `UI.Button`. */\n private resolveBinding(\n reference: TSESTree.Node,\n localName: string,\n member: string | undefined,\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 = member ?? '';\n if (cache.has(key)) return cache.get(key)!;\n\n const result = this.resolveDefinition(variable.defs[0], member, visited);\n cache.set(key, result);\n return result;\n }\n\n private resolveDefinition(\n def: Scope.Definition,\n member: string | undefined,\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 if (specifier.type === 'ImportNamespaceSpecifier') name = member;\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('.')) return { source, name, wrapped: false };\n return this.moduleOrigin(source, name);\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, member, 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":";;;;;;;;;AAcA,MAAM,kBAAkB,IAAI,IAAI;CAC9B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACD,CAAC;;AAQF,SAAS,kBAAkB,MAAuB;AAChD,QAAO,4BAA4B,KAAK,KAAK;;;AAI/C,SAAS,kBAAkB,MAA8B;CACvD,MAAM,QAAQ,iBAAiB,KAAK;AACpC,QACE,MAAM,SAAS,6BACf,MAAM,SAAS,wBACf,MAAM,SAAS,gBACf,MAAM,SAAS;;AAInB,mCAAe,WAA2B;CACxC,MAAM;CACN,MAAM;EACJ,MAAM;EACN,MAAM,EACJ,aACE,2GACH;EACD,UAAU;GACR,aACE;GACF,gBACE;GACH;EACD,QAAQ,EAAE;EACX;CACD,gBAAgB,EAAE;CAClB,OAAO,SAAS;EACd,MAAM,MAAM,IAAI,aAAa,QAAQ;EACrC,MAAM,aAAa,IAAI,oBAAoB,SAAS,IAAI;AACxD,MAAI,CAAC,WAAW,QAAS,QAAO,EAAE;EAElC,MAAM,aAAa,IAAI,IAAI,CACzB,GAAG,wBACH,GAAG,IAAI,OAAO,OACf,CAAC;;EAGF,SAAS,WAAW,OAA6B,QAAyB;GACxE,MAAM,SAAS,SAAS,iBAAiB,MAAM;AAC/C,UACE,QAAQ,SAAS,sBACjB,OAAO,WAAW,OAAO,SAAS;AAChC,QAAI,KAAK,SAAS,cAAc,KAAK,SAAU,QAAO;IACtD,MAAM,MAAM,WAAW,KAAK,IAAI;AAChC,WAAO,QAAQ,QAAQ,OAAO,kBAAkB,IAAI,IAAI;KACxD;;EAIN,SAAS,SACP,MACA,OACA,QACA,WACS;AACT,OAAI,SAAS,SAAU,QAAO,CAAC,WAAW,OAAO,OAAO;AACxD,OAAI,kBAAkB,KAAK,CAAE,QAAO;AACpC,OAAI,CAAC,WAAW,IAAI,KAAK,IAAI,OAAO,kBAAkB,IAAI,KAAK,CAC7D,QAAO;AAET,OAAI,SAAS,kBAAkB,MAAM,CAAE,QAAO;AAE9C,UAAO,EAAE,aAAa,IAAI,mBAAmB,UAAU;;EAGzD,SAAS,OACP,MACA,WACA,MACA,QACM;AACN,WAAQ,OAAO;IACb;IACA;IACA,MAAM;KACJ;KACA,WACE,OAAO,cAAc,OAAO,OACxB,IAAI,OAAO,KAAK,KAChB,IAAI,OAAO,UAAU,eAAe,OAAO,KAAK;KACtD,QAAQ,IAAI,OAAO,OAAO;KAC3B;IACF,CAAC;;EAGJ,SAAS,oBACP,SACA,QACM;AACN,QAAK,MAAM,QAAQ,QAAQ,YAAY;AACrC,QAAI,KAAK,SAAS,iBAAiB;KACjC,MAAM,SAAS,iBAAiB,KAAK,SAAS;AAC9C,SAAI,OAAO,SAAS,mBAClB,qBAAoB,QAAQ,OAAO;AAErC;;IAEF,MAAM,MAAM,KAAK,WAAW,iBAAiB,KAAK,IAAI,GAAG,KAAK;AAC9D,QAAI,KAAK,YAAY,IAAI,SAAS,UAAW;IAC7C,MAAM,OAAO,WAAW,IAAI;AAC5B,QAAI,SAAS,QAAQ,gBAAgB,IAAI,KAAK,CAAE;AAChD,QAAI,SAAS,cAAc,SAAS,MAAM,KAAK,OAAO,OAAO,CAC3D,QAAO,MAAM,kBAAkB,MAAM,OAAO;;;AA8BlD,SAAO;GACL,GA1BU,wBACV,MACC,SAAwB;IACvB,MAAM,SAAS,WAAW,WAAW,KAAK,KAAK;AAC/C,WACE,UAAU;KACR,GAAG;KACH,WAAW,QAAQ,WAAW,QAAQ,KAAK,KAAK;KACjD;OAGJ,MAAM,MAAM,WAAW;AAQtB,QAAI,SAAS,MANX,KAAK,SAAS,aACV,KAAK,QACL,KAAK,OAAO,SAAS,2BACnB,KAAK,MAAM,aACX,KAAK,OAEa,QADR,KAAK,SAAS,iBAAiB,OAAO,OACZ,CAC1C,QAAO,MAAM,eAAe,MAAM,OAAO;KAG9C;GAIC,iBAAiB;AACf,QAAI,mBAAmB;;GAEzB,eAAe,MAAM;IACnB,MAAM,CAAC,MAAM,WAAW,KAAK;AAC7B,QAAI,CAAC,WAAW,CAAC,WAAW,cAAc,KAAK,CAAE;IACjD,MAAM,YAAY,WAAW,kBAAkB,KAAK;IACpD,MAAM,SAAS,iBAAiB,QAAQ;AACxC,QAAI,CAAC,aAAa,OAAO,SAAS,mBAAoB;AACtD,wBAAoB,QAAQ;KAC1B,GAAG;KACH,WAAW,QAAQ,WAAW,QAAQ,iBAAiB,KAAK,CAAC;KAC9D,CAAC;;GAEL;;CAEJ,CAAC"}
@@ -1,22 +1,46 @@
1
1
  import { createRule } from "../create-rule.js";
2
2
  import { TastyContext } from "../context.js";
3
- import { tastyComponentPropListeners } from "./tasty-component-props.js";
3
+ import { jsxPropListeners } from "./tasty-component-props.js";
4
4
 
5
5
  //#region src/rules/no-style-prop.ts
6
+ const KINDS = {
7
+ tasty: new Set(["tasty"]),
8
+ components: new Set(["tasty", "component"]),
9
+ all: new Set([
10
+ "tasty",
11
+ "component",
12
+ "element"
13
+ ])
14
+ };
6
15
  var no_style_prop_default = createRule({
7
16
  name: "no-style-prop",
8
17
  meta: {
9
18
  type: "suggestion",
10
19
  docs: { description: "Prefer tokens over React inline style on Tasty components" },
11
- messages: { noStyleProp: "Avoid the 'style' prop on Tasty components. Define styles with token references and pass dynamic values through 'tokens' (e.g. tokens={{ $size: value }}). If a third-party library requires inline styles, explicitly disable tasty/no-style-prop for this usage with an ESLint comment and a reason." },
12
- schema: []
20
+ messages: {
21
+ noStyleProp: "Avoid the 'style' prop on Tasty components. Define styles with token references and pass dynamic values through 'tokens' (e.g. tokens={{ $size: value }}). If a third-party library requires inline styles, explicitly disable tasty/no-style-prop for this usage with an ESLint comment and a reason.",
22
+ noInlineStyle: "Avoid the 'style' prop. Move these styles into Tasty: render a tasty() element, or wrap the component with tasty(Component, { styles }), using token references and passing dynamic values through 'tokens'. If a third-party library requires inline styles, explicitly disable tasty/no-style-prop for this usage with an ESLint comment and a reason."
23
+ },
24
+ schema: [{
25
+ type: "object",
26
+ properties: { elements: {
27
+ type: "string",
28
+ enum: [
29
+ "tasty",
30
+ "components",
31
+ "all"
32
+ ]
33
+ } },
34
+ additionalProperties: false
35
+ }]
13
36
  },
14
- defaultOptions: [],
15
- create(context) {
16
- return tastyComponentPropListeners(new TastyContext(context), (name, node) => {
17
- if (name === "style") context.report({
37
+ defaultOptions: [{ elements: "tasty" }],
38
+ create(context, [{ elements = "tasty" }]) {
39
+ return jsxPropListeners(new TastyContext(context), KINDS[elements], (name, node, kind) => {
40
+ if (name !== "style") return;
41
+ context.report({
18
42
  node,
19
- messageId: "noStyleProp"
43
+ messageId: kind === "tasty" ? "noStyleProp" : "noInlineStyle"
20
44
  });
21
45
  });
22
46
  }
@@ -1 +1 @@
1
- {"version":3,"file":"no-style-prop.js","names":[],"sources":["../../src/rules/no-style-prop.ts"],"sourcesContent":["import { createRule } from '../create-rule.js';\nimport { TastyContext } from '../context.js';\nimport { tastyComponentPropListeners } from './tasty-component-props.js';\n\nexport default createRule<[], 'noStyleProp'>({\n name: 'no-style-prop',\n meta: {\n type: 'suggestion',\n docs: {\n description: 'Prefer tokens over React inline style on Tasty components',\n },\n messages: {\n noStyleProp:\n \"Avoid the 'style' prop on Tasty components. Define styles with token references and pass dynamic values through 'tokens' (e.g. tokens={{ $size: value }}). If a third-party library requires inline styles, explicitly disable tasty/no-style-prop 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 return tastyComponentPropListeners(ctx, (name, node) => {\n if (name === 'style') context.report({ node, messageId: 'noStyleProp' });\n });\n },\n});\n"],"mappings":";;;;;AAIA,4BAAe,WAA8B;CAC3C,MAAM;CACN,MAAM;EACJ,MAAM;EACN,MAAM,EACJ,aAAa,6DACd;EACD,UAAU,EACR,aACE,0SACH;EACD,QAAQ,EAAE;EACX;CACD,gBAAgB,EAAE;CAClB,OAAO,SAAS;AAEd,SAAO,4BADK,IAAI,aAAa,QAAQ,GACI,MAAM,SAAS;AACtD,OAAI,SAAS,QAAS,SAAQ,OAAO;IAAE;IAAM,WAAW;IAAe,CAAC;IACxE;;CAEL,CAAC"}
1
+ {"version":3,"file":"no-style-prop.js","names":[],"sources":["../../src/rules/no-style-prop.ts"],"sourcesContent":["import { createRule } from '../create-rule.js';\nimport { TastyContext } from '../context.js';\nimport {\n type JSXElementKind,\n jsxPropListeners,\n} from './tasty-component-props.js';\n\ntype Elements = 'tasty' | 'components' | 'all';\n\nconst KINDS: Record<Elements, ReadonlySet<JSXElementKind>> = {\n tasty: new Set(['tasty']),\n components: new Set(['tasty', 'component']),\n all: new Set(['tasty', 'component', 'element']),\n};\n\nexport default createRule<\n [{ elements?: Elements }],\n 'noStyleProp' | 'noInlineStyle'\n>({\n name: 'no-style-prop',\n meta: {\n type: 'suggestion',\n docs: {\n description: 'Prefer tokens over React inline style on Tasty components',\n },\n messages: {\n noStyleProp:\n \"Avoid the 'style' prop on Tasty components. Define styles with token references and pass dynamic values through 'tokens' (e.g. tokens={{ $size: value }}). If a third-party library requires inline styles, explicitly disable tasty/no-style-prop for this usage with an ESLint comment and a reason.\",\n noInlineStyle:\n \"Avoid the 'style' prop. Move these styles into Tasty: render a tasty() element, or wrap the component with tasty(Component, { styles }), using token references and passing dynamic values through 'tokens'. If a third-party library requires inline styles, explicitly disable tasty/no-style-prop for this usage with an ESLint comment and a reason.\",\n },\n schema: [\n {\n type: 'object',\n properties: {\n elements: { type: 'string', enum: ['tasty', 'components', 'all'] },\n },\n additionalProperties: false,\n },\n ],\n },\n defaultOptions: [{ elements: 'tasty' }],\n create(context, [{ elements = 'tasty' }]) {\n const ctx = new TastyContext(context);\n return jsxPropListeners(ctx, KINDS[elements], (name, node, kind) => {\n if (name !== 'style') return;\n context.report({\n node,\n messageId: kind === 'tasty' ? 'noStyleProp' : 'noInlineStyle',\n });\n });\n },\n});\n"],"mappings":";;;;;AASA,MAAM,QAAuD;CAC3D,OAAO,IAAI,IAAI,CAAC,QAAQ,CAAC;CACzB,YAAY,IAAI,IAAI,CAAC,SAAS,YAAY,CAAC;CAC3C,KAAK,IAAI,IAAI;EAAC;EAAS;EAAa;EAAU,CAAC;CAChD;AAED,4BAAe,WAGb;CACA,MAAM;CACN,MAAM;EACJ,MAAM;EACN,MAAM,EACJ,aAAa,6DACd;EACD,UAAU;GACR,aACE;GACF,eACE;GACH;EACD,QAAQ,CACN;GACE,MAAM;GACN,YAAY,EACV,UAAU;IAAE,MAAM;IAAU,MAAM;KAAC;KAAS;KAAc;KAAM;IAAE,EACnE;GACD,sBAAsB;GACvB,CACF;EACF;CACD,gBAAgB,CAAC,EAAE,UAAU,SAAS,CAAC;CACvC,OAAO,SAAS,CAAC,EAAE,WAAW,YAAY;AAExC,SAAO,iBADK,IAAI,aAAa,QAAQ,EACR,MAAM,YAAY,MAAM,MAAM,SAAS;AAClE,OAAI,SAAS,QAAS;AACtB,WAAQ,OAAO;IACb;IACA,WAAW,SAAS,UAAU,gBAAgB;IAC/C,CAAC;IACF;;CAEL,CAAC"}
@@ -1,15 +1,19 @@
1
1
  import { getKeyName, unwrapExpression } from "../utils.js";
2
2
 
3
3
  //#region src/rules/tasty-component-props.ts
4
- /** Inspect explicit props and inline object spreads without evaluating spread bindings. */
5
- function tastyComponentPropListeners(ctx, check) {
6
- function visitSpread(node) {
4
+ /**
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
+ */
9
+ function jsxElementPropListeners(ctx, classify, check) {
10
+ function visitSpread(node, target) {
7
11
  node = unwrapExpression(node);
8
12
  if (node.type !== "ObjectExpression") return;
9
- for (const prop of node.properties) if (prop.type === "SpreadElement") visitSpread(prop.argument);
13
+ for (const prop of node.properties) if (prop.type === "SpreadElement") visitSpread(prop.argument, target);
10
14
  else {
11
15
  const name = prop.computed ? getKeyName(unwrapExpression(prop.key)) : getKeyName(prop.key);
12
- if (name !== null && (!prop.computed || unwrapExpression(prop.key).type === "Literal")) check(name, prop);
16
+ if (name !== null && (!prop.computed || unwrapExpression(prop.key).type === "Literal")) check(name, prop, target);
13
17
  }
14
18
  }
15
19
  return {
@@ -17,17 +21,34 @@ function tastyComponentPropListeners(ctx, check) {
17
21
  ctx.trackImport(node);
18
22
  },
19
23
  JSXOpeningElement(node) {
20
- let name = node.name;
21
- while (name.type === "JSXMemberExpression") name = name.object;
22
- if (name.type !== "JSXIdentifier") return;
23
- if (node.name.type === "JSXIdentifier" && !/^[A-Z]/.test(name.name)) return;
24
- if (!ctx.isTastyJSXComponent(node, name.name)) return;
25
- for (const attribute of node.attributes) if (attribute.type === "JSXSpreadAttribute") visitSpread(attribute.argument);
26
- else if (attribute.name.type === "JSXIdentifier") check(attribute.name.name, attribute);
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);
27
28
  }
28
29
  };
29
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
+ }
46
+ const TASTY_ONLY = new Set(["tasty"]);
47
+ /** Inspect explicit props and inline object spreads on recognized Tasty components. */
48
+ function tastyComponentPropListeners(ctx, check) {
49
+ return jsxPropListeners(ctx, TASTY_ONLY, (name, node) => check(name, node));
50
+ }
30
51
 
31
52
  //#endregion
32
- export { tastyComponentPropListeners };
53
+ export { jsxElementPropListeners, jsxPropListeners, tastyComponentPropListeners };
33
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/** Inspect explicit props and inline object spreads without evaluating spread bindings. */\nexport function tastyComponentPropListeners(\n ctx: TastyContext,\n check: (name: string, node: TSESTree.Node) => void,\n) {\n function visitSpread(node: TSESTree.Node): 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);\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);\n }\n }\n }\n }\n\n return {\n ImportDeclaration(node: TSESTree.ImportDeclaration) {\n ctx.trackImport(node);\n },\n JSXOpeningElement(node: TSESTree.JSXOpeningElement) {\n let name = node.name;\n while (name.type === 'JSXMemberExpression') name = name.object;\n if (name.type !== 'JSXIdentifier') return;\n if (node.name.type === 'JSXIdentifier' && !/^[A-Z]/.test(name.name))\n return;\n if (!ctx.isTastyJSXComponent(node, name.name)) return;\n for (const attribute of node.attributes) {\n if (attribute.type === 'JSXSpreadAttribute')\n visitSpread(attribute.argument);\n else if (attribute.name.type === 'JSXIdentifier')\n check(attribute.name.name, attribute);\n }\n },\n };\n}\n"],"mappings":";;;;AAKA,SAAgB,4BACd,KACA,OACA;CACA,SAAS,YAAY,MAA2B;AAC9C,SAAO,iBAAiB,KAAK;AAC7B,MAAI,KAAK,SAAS,mBAAoB;AACtC,OAAK,MAAM,QAAQ,KAAK,WACtB,KAAI,KAAK,SAAS,gBAChB,aAAY,KAAK,SAAS;OACrB;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,KAAK;;;AAMzB,QAAO;EACL,kBAAkB,MAAkC;AAClD,OAAI,YAAY,KAAK;;EAEvB,kBAAkB,MAAkC;GAClD,IAAI,OAAO,KAAK;AAChB,UAAO,KAAK,SAAS,sBAAuB,QAAO,KAAK;AACxD,OAAI,KAAK,SAAS,gBAAiB;AACnC,OAAI,KAAK,KAAK,SAAS,mBAAmB,CAAC,SAAS,KAAK,KAAK,KAAK,CACjE;AACF,OAAI,CAAC,IAAI,oBAAoB,MAAM,KAAK,KAAK,CAAE;AAC/C,QAAK,MAAM,aAAa,KAAK,WAC3B,KAAI,UAAU,SAAS,qBACrB,aAAY,UAAU,SAAS;YACxB,UAAU,KAAK,SAAS,gBAC/B,OAAM,UAAU,KAAK,MAAM,UAAU;;EAG5C"}
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,21 @@ 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
+ /** Exported names to protect, `*` matching any run of characters. Defaults to every export. */
16
+ include?: string[];
17
+ /** Exported names to leave out, such as layout and typography primitives. */
18
+ exclude?: string[];
19
+ /**
20
+ * Style props that place or size the root and stay allowed: `'position'` and
21
+ * `'dimension'` stand for Tasty's `POSITION_STYLES` and `DIMENSION_STYLES`,
22
+ * anything else is a property name.
23
+ */
24
+ allowedStyleProps?: string[];
25
+ }
11
26
  interface TastyValidationConfig {
12
27
  extends?: string;
13
28
  tokens?: false | string[];
@@ -39,6 +54,11 @@ interface TastyValidationConfig {
39
54
  * of characters, so `@my-org/*` covers every package under the scope.
40
55
  */
41
56
  ownedSources?: string[];
57
+ /**
58
+ * Components a consumer should customize through their props rather than
59
+ * restyle. Entries from an extended config come first; `false` drops them.
60
+ */
61
+ protectedComponents?: false | ProtectedComponentConfig[];
42
62
  }
43
63
  interface ResolvedConfig {
44
64
  tokens: false | string[];
@@ -59,7 +79,9 @@ interface ResolvedConfig {
59
79
  ownedSources?: string[];
60
80
  /** Optional for compatibility with existing consumer-built configs. */
61
81
  styleFunctions?: Record<string, StyleFunctionConfig>;
82
+ /** Optional for compatibility with existing consumer-built configs. */
83
+ protectedComponents?: ProtectedComponentConfig[];
62
84
  }
63
85
  //#endregion
64
- export { ResolvedConfig, StyleFunctionConfig, TastyValidationConfig };
86
+ export { ProtectedComponentConfig, ResolvedConfig, StyleFunctionConfig, TastyValidationConfig };
65
87
  //# 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":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenphi/eslint-plugin-tasty",
3
- "version": "1.9.1",
3
+ "version": "1.11.0",
4
4
  "description": "ESLint plugin for validating tasty() and tastyStatic() style objects",
5
5
  "type": "module",
6
6
  "exports": {