zyzz 0.0.17 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/Style.d.ts.map +1 -1
  2. package/dist/Style.js +16 -3
  3. package/dist/Style.js.map +1 -1
  4. package/dist/oxlint/index.d.ts +6 -0
  5. package/dist/oxlint/index.d.ts.map +1 -0
  6. package/dist/oxlint/index.js +18 -0
  7. package/dist/oxlint/index.js.map +1 -0
  8. package/dist/oxlint/internal/Bindings.d.ts +20 -0
  9. package/dist/oxlint/internal/Bindings.d.ts.map +1 -0
  10. package/dist/oxlint/internal/Bindings.js +203 -0
  11. package/dist/oxlint/internal/Bindings.js.map +1 -0
  12. package/dist/oxlint/internal/Declarations.d.ts +19 -0
  13. package/dist/oxlint/internal/Declarations.d.ts.map +1 -0
  14. package/dist/oxlint/internal/Declarations.js +114 -0
  15. package/dist/oxlint/internal/Declarations.js.map +1 -0
  16. package/dist/oxlint/rules/noConflictingProps.d.ts +5 -0
  17. package/dist/oxlint/rules/noConflictingProps.d.ts.map +1 -0
  18. package/dist/oxlint/rules/noConflictingProps.js +45 -0
  19. package/dist/oxlint/rules/noConflictingProps.js.map +1 -0
  20. package/dist/oxlint/rules/noUnused.d.ts +5 -0
  21. package/dist/oxlint/rules/noUnused.d.ts.map +1 -0
  22. package/dist/oxlint/rules/noUnused.js +62 -0
  23. package/dist/oxlint/rules/noUnused.js.map +1 -0
  24. package/dist/oxlint/rules/restrictedProperties.d.ts +5 -0
  25. package/dist/oxlint/rules/restrictedProperties.d.ts.map +1 -0
  26. package/dist/oxlint/rules/restrictedProperties.js +82 -0
  27. package/dist/oxlint/rules/restrictedProperties.js.map +1 -0
  28. package/dist/oxlint/rules/useLogicalProperties.d.ts +5 -0
  29. package/dist/oxlint/rules/useLogicalProperties.d.ts.map +1 -0
  30. package/dist/oxlint/rules/useLogicalProperties.js +76 -0
  31. package/dist/oxlint/rules/useLogicalProperties.js.map +1 -0
  32. package/dist/oxlint/rules/validStyles.d.ts +5 -0
  33. package/dist/oxlint/rules/validStyles.d.ts.map +1 -0
  34. package/dist/oxlint/rules/validStyles.js +75 -0
  35. package/dist/oxlint/rules/validStyles.js.map +1 -0
  36. package/package.json +7 -1
  37. package/src/Style.ts +16 -3
  38. package/src/oxlint/index.ts +21 -0
  39. package/src/oxlint/internal/Bindings.ts +270 -0
  40. package/src/oxlint/internal/Declarations.ts +137 -0
  41. package/src/oxlint/rules/noConflictingProps.ts +52 -0
  42. package/src/oxlint/rules/noUnused.ts +67 -0
  43. package/src/oxlint/rules/restrictedProperties.ts +92 -0
  44. package/src/oxlint/rules/useLogicalProperties.ts +81 -0
  45. package/src/oxlint/rules/validStyles.ts +88 -0
@@ -0,0 +1,270 @@
1
+ /** Resolves immutable authoring bindings without loading or evaluating modules. @module */
2
+ import type { Context, ESTree, Variable } from '@oxlint/plugins'
3
+
4
+ /** A recognized helper or locally declared style. */
5
+ export type Binding = {
6
+ readonly kind:
7
+ | 'config'
8
+ | 'configFactory'
9
+ | 'configNamespace'
10
+ | 'cx'
11
+ | 'definition'
12
+ | 'module'
13
+ | 'style'
14
+ | 'variants'
15
+ readonly themed: boolean
16
+ }
17
+
18
+ /** Resolves helpers using lexical scope, configuration filenames, and explicit import sources. */
19
+ export function create(context: Context) {
20
+ const settings = context.settings.zyzz
21
+ const imports = new Set(['zyzz', 'zyzz/default'])
22
+ if (settings && typeof settings === 'object' && !Array.isArray(settings)) {
23
+ const sources = settings.imports
24
+ if (Array.isArray(sources))
25
+ for (const source of sources)
26
+ if (typeof source === 'string') imports.add(source)
27
+ }
28
+
29
+ function variable(
30
+ node: Extract<ESTree.Node, { type: 'Identifier' }>,
31
+ ): Variable | undefined {
32
+ let scope = context.sourceCode.getScope(node)
33
+ while (scope) {
34
+ const found = scope.set.get(node.name)
35
+ if (found) return found
36
+ if (!scope.upper) break
37
+ scope = scope.upper
38
+ }
39
+ return undefined
40
+ }
41
+
42
+ function initializer(
43
+ node: Extract<ESTree.Node, { type: 'Identifier' }>,
44
+ ): ESTree.Node | undefined {
45
+ const binding = variable(node)
46
+ if (!binding || binding.defs.length !== 1) return undefined
47
+ const definition = binding.defs[0]!
48
+ if (definition.node.type !== 'VariableDeclarator') return undefined
49
+ if (
50
+ definition.node.parent.type !== 'VariableDeclaration' ||
51
+ definition.node.parent.kind !== 'const'
52
+ )
53
+ return undefined
54
+ if (
55
+ binding.references.some(
56
+ (reference) => reference.isWrite() && !reference.init,
57
+ )
58
+ )
59
+ return undefined
60
+ if (definition.node.id.type !== 'Identifier') return undefined
61
+ const input = definition.node.init && unwrap(definition.node.init)
62
+ if (
63
+ input?.type === 'ArrayExpression' ||
64
+ input?.type === 'ObjectExpression'
65
+ ) {
66
+ const mutable = binding.references.some((reference) => {
67
+ let value: ESTree.Node = reference.identifier
68
+ while (
69
+ value.parent &&
70
+ ((value.parent.type === 'MemberExpression' &&
71
+ value.parent.object === value) ||
72
+ unwrap(value.parent) === value)
73
+ )
74
+ value = value.parent
75
+ const parent = value.parent
76
+ if (
77
+ parent?.type === 'ArrayExpression' ||
78
+ (parent?.type === 'Property' && parent.value === value)
79
+ )
80
+ return true
81
+ if (parent?.type === 'AssignmentExpression' && parent.left === value)
82
+ return true
83
+ if (
84
+ parent?.type === 'UpdateExpression' ||
85
+ (parent?.type === 'UnaryExpression' && parent.operator === 'delete')
86
+ )
87
+ return true
88
+ if (parent?.type === 'CallExpression') {
89
+ if (parent.callee === value) return true
90
+ const callee = resolve(parent.callee)
91
+ return callee?.kind !== 'style' && callee?.kind !== 'variants'
92
+ }
93
+ return parent?.type === 'VariableDeclarator' && parent.init === value
94
+ })
95
+ if (mutable) return undefined
96
+ }
97
+ return definition.node.init ?? undefined
98
+ }
99
+
100
+ function expression(
101
+ node: ESTree.Node,
102
+ seen = new Set<ESTree.Node>(),
103
+ ): ESTree.Node {
104
+ node = unwrap(node)
105
+ if (seen.has(node)) return node
106
+ seen.add(node)
107
+ if (node.type === 'Identifier') {
108
+ const init = initializer(node)
109
+ if (init) return expression(init, seen)
110
+ }
111
+ return node
112
+ }
113
+
114
+ function member(
115
+ binding: Binding | undefined,
116
+ name: string | undefined,
117
+ ): Binding | undefined {
118
+ if (!binding || !name) return undefined
119
+ if (binding.kind === 'module') {
120
+ if (!binding.themed && name === 'Config')
121
+ return { kind: 'configNamespace', themed: true }
122
+ if (
123
+ name === 'style' ||
124
+ name === 'variants' ||
125
+ (!binding.themed && name === 'cx')
126
+ )
127
+ return { kind: name, themed: binding.themed }
128
+ }
129
+ if (binding.kind === 'configNamespace' && name === 'create')
130
+ return { kind: 'configFactory', themed: true }
131
+ if (binding.kind === 'config' && (name === 'style' || name === 'variants'))
132
+ return { kind: name, themed: true }
133
+ return undefined
134
+ }
135
+
136
+ function resolve(
137
+ node: ESTree.Node,
138
+ seen = new Set<ESTree.Node>(),
139
+ ): Binding | undefined {
140
+ node = unwrap(node)
141
+ if (seen.has(node)) return undefined
142
+ seen.add(node)
143
+
144
+ if (node.type === 'Identifier') {
145
+ const binding = variable(node)
146
+ if (!binding || binding.defs.length !== 1) return undefined
147
+ const definition = binding.defs[0]!
148
+ const declaration = definition.node
149
+ if (definition.type === 'ImportBinding') {
150
+ const parent = declaration.parent
151
+ if (
152
+ parent?.type !== 'ImportDeclaration' ||
153
+ parent.importKind === 'type' ||
154
+ (!imports.has(parent.source.value) &&
155
+ !/(?:^|\/)zyzz\.config\.[^/]+$/.test(parent.source.value))
156
+ )
157
+ return undefined
158
+ const module: Binding = {
159
+ kind: 'module',
160
+ themed: parent.source.value !== 'zyzz',
161
+ }
162
+ if (declaration.type === 'ImportNamespaceSpecifier') return module
163
+ if (
164
+ declaration.type === 'ImportSpecifier' &&
165
+ declaration.importKind !== 'type'
166
+ )
167
+ return member(
168
+ module,
169
+ declaration.imported.type === 'Identifier'
170
+ ? declaration.imported.name
171
+ : declaration.imported.value,
172
+ )
173
+ return undefined
174
+ }
175
+ if (
176
+ declaration.type !== 'VariableDeclarator' ||
177
+ !declaration.init ||
178
+ declaration.parent.type !== 'VariableDeclaration' ||
179
+ declaration.parent.kind !== 'const'
180
+ )
181
+ return undefined
182
+ if (
183
+ binding.references.some(
184
+ (reference) => reference.isWrite() && !reference.init,
185
+ )
186
+ )
187
+ return undefined
188
+ if (declaration.id.type === 'Identifier')
189
+ return resolve(declaration.init, seen)
190
+ if (declaration.id.type === 'ObjectPattern') {
191
+ const property = declaration.id.properties.find(
192
+ (property) =>
193
+ property.type === 'Property' &&
194
+ property.value.type === 'Identifier' &&
195
+ property.value.name === node.name,
196
+ )
197
+ if (property?.type === 'Property')
198
+ return member(resolve(declaration.init, seen), key(property))
199
+ }
200
+ }
201
+
202
+ if (node.type === 'MemberExpression') {
203
+ const name = key(node)
204
+ const known = member(resolve(node.object, new Set(seen)), name)
205
+ if (known) return known
206
+ if (node.object.type !== 'Identifier' || !name) return undefined
207
+ const namespace = variable(node.object)
208
+ if (!namespace || namespace.defs.length !== 1) return undefined
209
+ const declaration = namespace.defs[0]!.node
210
+ if (
211
+ declaration.type !== 'TSModuleDeclaration' ||
212
+ declaration.body?.type !== 'TSModuleBlock'
213
+ )
214
+ return undefined
215
+ for (const statement of declaration.body.body) {
216
+ if (
217
+ statement.type !== 'ExportNamedDeclaration' ||
218
+ statement.declaration?.type !== 'VariableDeclaration' ||
219
+ statement.declaration.kind !== 'const'
220
+ )
221
+ continue
222
+ for (const entry of statement.declaration.declarations)
223
+ if (
224
+ entry.id.type === 'Identifier' &&
225
+ entry.id.name === name &&
226
+ entry.init
227
+ )
228
+ return resolve(entry.init, seen)
229
+ }
230
+ }
231
+
232
+ if (node.type === 'CallExpression') {
233
+ const callee = resolve(node.callee, seen)
234
+ if (callee?.kind === 'configFactory')
235
+ return { kind: 'config', themed: true }
236
+ if (callee?.kind === 'style' || callee?.kind === 'variants')
237
+ return { kind: 'definition', themed: callee.themed }
238
+ }
239
+ return undefined
240
+ }
241
+
242
+ return { expression, resolve, variable }
243
+ }
244
+
245
+ /** Resolves only literal property names, never computed identifiers. */
246
+ export function key(
247
+ node:
248
+ | ESTree.ObjectProperty
249
+ | ESTree.BindingProperty
250
+ | ESTree.MemberExpression,
251
+ ): string | undefined {
252
+ const property = node.type === 'Property' ? node.key : node.property
253
+ if (!node.computed && property.type === 'Identifier') return property.name
254
+ if (property.type === 'Literal' && typeof property.value === 'string')
255
+ return property.value
256
+ return undefined
257
+ }
258
+
259
+ /** Removes type-only wrappers while retaining the original source node. */
260
+ export function unwrap(node: ESTree.Node): ESTree.Node {
261
+ while (
262
+ node.type === 'TSAsExpression' ||
263
+ node.type === 'TSSatisfiesExpression' ||
264
+ node.type === 'TSNonNullExpression' ||
265
+ node.type === 'TSTypeAssertion' ||
266
+ node.type === 'ParenthesizedExpression'
267
+ )
268
+ node = node.expression
269
+ return node
270
+ }
@@ -0,0 +1,137 @@
1
+ /** Visits declaration positions in styles and recipes without evaluating expressions. @module */
2
+ import type { ESTree } from '@oxlint/plugins'
3
+ import * as Bindings from './Bindings.js'
4
+ import * as Condition from '../../internal/Condition.js'
5
+
6
+ /** Callbacks for known declaration blocks and malformed literal structures. */
7
+ export type Visitors = {
8
+ readonly declaration: (property: ESTree.ObjectProperty, name: string) => void
9
+ readonly invalid?: ((node: ESTree.Node, message: string) => void) | undefined
10
+ }
11
+
12
+ /** Traverses actual style positions, excluding variant selections and callback internals. */
13
+ export function visit(
14
+ node: ESTree.Node,
15
+ options: {
16
+ readonly bindings: ReturnType<typeof Bindings.create>
17
+ readonly recipe: boolean
18
+ readonly visitors: Visitors
19
+ },
20
+ ) {
21
+ const { bindings, visitors } = options
22
+ const seen = new Set<ESTree.Node>()
23
+
24
+ function object(
25
+ node: ESTree.Node,
26
+ callback: (node: ESTree.ObjectExpression) => void,
27
+ ) {
28
+ node = bindings.expression(node)
29
+ if (seen.has(node)) return
30
+ seen.add(node)
31
+ if (node.type === 'ObjectExpression') callback(node)
32
+ else if (node.type === 'Literal' || node.type === 'ArrayExpression')
33
+ visitors.invalid?.(node, 'Expected a style object.')
34
+ }
35
+
36
+ function entries(
37
+ node: ESTree.Node,
38
+ callback: (property: ESTree.ObjectProperty) => void,
39
+ ) {
40
+ object(node, (node) => {
41
+ for (const property of node.properties) {
42
+ if (property.type === 'SpreadElement') {
43
+ entries(property.argument, callback)
44
+ continue
45
+ }
46
+ if (property.kind !== 'init' || property.method) {
47
+ visitors.invalid?.(
48
+ property,
49
+ 'Style objects require data properties, not methods or accessors.',
50
+ )
51
+ continue
52
+ }
53
+ callback(property)
54
+ }
55
+ })
56
+ }
57
+
58
+ function style(node: ESTree.Node) {
59
+ node = bindings.expression(node)
60
+ if (node.type === 'ArrowFunctionExpression') {
61
+ if (seen.has(node)) return
62
+ seen.add(node)
63
+ if (node.body.type !== 'BlockStatement') style(node.body)
64
+ else
65
+ for (const statement of node.body.body)
66
+ if (statement.type === 'ReturnStatement' && statement.argument)
67
+ style(statement.argument)
68
+ return
69
+ }
70
+ entries(node, (property) => {
71
+ const name = Bindings.key(property)
72
+ if (!name) return
73
+ if (name === 'selectors')
74
+ entries(property.value, (selector) => style(selector.value))
75
+ else if (name === 'targets')
76
+ entries(property.value, (target) => {
77
+ if (Bindings.key(target) === 'web') style(target.value)
78
+ })
79
+ else if (Condition.is(name)) style(property.value)
80
+ else if (name !== 'vars' && name !== 'variables')
81
+ visitors.declaration(property, name)
82
+ })
83
+ }
84
+
85
+ if (!options.recipe) style(node)
86
+ else
87
+ entries(node, (property) => {
88
+ const name = Bindings.key(property)
89
+ if (name === 'base') style(property.value)
90
+ else if (name === 'variants')
91
+ entries(property.value, (axis) =>
92
+ entries(axis.value, (choice) => style(choice.value)),
93
+ )
94
+ else if (name === 'compoundVariants') {
95
+ const value = bindings.expression(property.value)
96
+ if (value.type === 'ArrayExpression')
97
+ for (const entry of value.elements)
98
+ if (entry && entry.type !== 'SpreadElement')
99
+ entries(entry, (property) => {
100
+ if (Bindings.key(property) === 'style') style(property.value)
101
+ })
102
+ }
103
+ })
104
+ }
105
+
106
+ /** Extracts known scalar values and fallback arrays without executing user code. */
107
+ export function literal(
108
+ node: ESTree.Node,
109
+ bindings: ReturnType<typeof Bindings.create>,
110
+ seen = new Set<ESTree.Node>(),
111
+ ): { readonly value: unknown } | undefined {
112
+ node = bindings.expression(node)
113
+ if (seen.has(node)) return undefined
114
+ seen = new Set(seen).add(node)
115
+ if (node.type === 'Literal') return { value: node.value }
116
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0)
117
+ return { value: node.quasis[0]?.value.cooked }
118
+ if (
119
+ node.type === 'UnaryExpression' &&
120
+ (node.operator === '-' || node.operator === '+')
121
+ ) {
122
+ const operand = literal(node.argument, bindings, seen)
123
+ if (typeof operand?.value === 'number')
124
+ return { value: node.operator === '-' ? -operand.value : operand.value }
125
+ }
126
+ if (node.type === 'ArrayExpression') {
127
+ const values: unknown[] = []
128
+ for (const entry of node.elements) {
129
+ if (!entry || entry.type === 'SpreadElement') return undefined
130
+ const result = literal(entry, bindings, seen)
131
+ if (!result) return undefined
132
+ values.push(result.value)
133
+ }
134
+ return { value: values }
135
+ }
136
+ return undefined
137
+ }
@@ -0,0 +1,52 @@
1
+ /** Finds JSX attributes that overwrite known Zyzz application props. @module */
2
+ import type { ESTree, Rule } from '@oxlint/plugins'
3
+ import * as Bindings from '../internal/Bindings.js'
4
+
5
+ /** Reports competing style spreads and explicit styling attributes without rewriting JSX. */
6
+ export const noConflictingProps: Rule = {
7
+ meta: {
8
+ messages: {
9
+ attribute:
10
+ "Pass '{{property}}' into the Zyzz style call to merge styling props.",
11
+ spread:
12
+ 'Compose Zyzz applications with cx() instead of overwriting them with multiple JSX spreads.',
13
+ },
14
+ schema: [],
15
+ type: 'problem',
16
+ },
17
+ create(context) {
18
+ const bindings = Bindings.create(context)
19
+ function application(node: ESTree.Node): boolean {
20
+ node = bindings.expression(node)
21
+ if (node.type !== 'CallExpression') return false
22
+ const binding = bindings.resolve(node.callee)
23
+ return binding?.kind === 'definition' || binding?.kind === 'cx'
24
+ }
25
+ return {
26
+ JSXOpeningElement(node) {
27
+ const spreads = node.attributes.filter(
28
+ (attribute) =>
29
+ attribute.type === 'JSXSpreadAttribute' &&
30
+ application(attribute.argument),
31
+ )
32
+ if (!spreads.length) return
33
+ for (const attribute of node.attributes) {
34
+ if (
35
+ attribute.type !== 'JSXAttribute' ||
36
+ attribute.name.type !== 'JSXIdentifier'
37
+ )
38
+ continue
39
+ const property = attribute.name.name
40
+ if (property === 'className' || property === 'style')
41
+ context.report({
42
+ data: { property },
43
+ messageId: 'attribute',
44
+ node: attribute,
45
+ })
46
+ }
47
+ for (const spread of spreads.slice(1))
48
+ context.report({ messageId: 'spread', node: spread })
49
+ },
50
+ }
51
+ },
52
+ }
@@ -0,0 +1,67 @@
1
+ /** Finds unused style members in local TypeScript namespaces. @module */
2
+ import type { ESTree, Rule } from '@oxlint/plugins'
3
+ import * as Bindings from '../internal/Bindings.js'
4
+
5
+ /** Leaves module exports and escaped namespaces available to unknown consumers. */
6
+ export const noUnused: Rule = {
7
+ meta: {
8
+ messages: { unused: "Style '{{name}}' is never used." },
9
+ schema: [],
10
+ type: 'suggestion',
11
+ },
12
+ create(context) {
13
+ const bindings = Bindings.create(context)
14
+ const declarations: ESTree.VariableDeclarator[] = []
15
+ return {
16
+ VariableDeclarator(node) {
17
+ if (
18
+ node.id.type === 'Identifier' &&
19
+ node.init &&
20
+ bindings.resolve(node.init)?.kind === 'definition'
21
+ )
22
+ declarations.push(node)
23
+ },
24
+ 'Program:exit'() {
25
+ for (const declaration of declarations) {
26
+ if (declaration.id.type !== 'Identifier') continue
27
+ let parent: ESTree.Node = declaration.parent
28
+ while (
29
+ parent.type === 'VariableDeclaration' ||
30
+ parent.type === 'ExportNamedDeclaration'
31
+ )
32
+ parent = parent.parent
33
+ if (
34
+ parent.type !== 'TSModuleBlock' ||
35
+ parent.parent.type !== 'TSModuleDeclaration'
36
+ )
37
+ continue
38
+ const namespace = parent.parent
39
+ if (namespace.id.type !== 'Identifier') continue
40
+ // Nested and exported namespaces may have consumers outside this local scope.
41
+ if (namespace.parent.type !== 'Program') continue
42
+ const variable = bindings.variable(declaration.id)
43
+ const owner = bindings.variable(namespace.id)
44
+ if (!variable || !owner || owner.defs.length !== 1) continue
45
+ if (variable.references.some((reference) => reference.isRead()))
46
+ continue
47
+ const used = owner.references.some((reference) => {
48
+ if (!reference.isRead()) return false
49
+ const member = reference.identifier.parent
50
+ return (
51
+ member.type !== 'MemberExpression' ||
52
+ member.object !== reference.identifier ||
53
+ Bindings.key(member) === undefined ||
54
+ Bindings.key(member) === variable.name
55
+ )
56
+ })
57
+ if (!used)
58
+ context.report({
59
+ data: { name: `${namespace.id.name}.${variable.name}` },
60
+ messageId: 'unused',
61
+ node: declaration.id,
62
+ })
63
+ }
64
+ },
65
+ }
66
+ },
67
+ }
@@ -0,0 +1,92 @@
1
+ /** Applies explicit project restrictions to known web declarations. @module */
2
+ import type { Rule } from '@oxlint/plugins'
3
+ import * as Bindings from '../internal/Bindings.js'
4
+ import * as Declarations from '../internal/Declarations.js'
5
+
6
+ /** Reports forbidden properties or literal values outside an explicit allowlist. */
7
+ export const restrictedProperties: Rule = {
8
+ meta: {
9
+ messages: {
10
+ restricted: "Property '{{property}}' is restricted. {{reason}}",
11
+ },
12
+ schema: [
13
+ {
14
+ additionalProperties: {
15
+ additionalProperties: false,
16
+ properties: {
17
+ reason: { type: 'string' },
18
+ values: { items: { type: ['string', 'number'] }, type: 'array' },
19
+ },
20
+ type: 'object',
21
+ },
22
+ type: 'object',
23
+ },
24
+ ],
25
+ type: 'suggestion',
26
+ },
27
+ create(context) {
28
+ const bindings = Bindings.create(context)
29
+ const restrictions = context.options[0]
30
+ const reported = new Set<number>()
31
+ return {
32
+ CallExpression(node) {
33
+ if (
34
+ !restrictions ||
35
+ typeof restrictions !== 'object' ||
36
+ Array.isArray(restrictions)
37
+ )
38
+ return
39
+ const binding = bindings.resolve(node.callee)
40
+ if (binding?.kind !== 'style' && binding?.kind !== 'variants') return
41
+ const input = node.arguments[0]
42
+ if (!input || input.type === 'SpreadElement') return
43
+ Declarations.visit(input, {
44
+ bindings,
45
+ recipe: binding.kind === 'variants',
46
+ visitors: {
47
+ declaration(property, name) {
48
+ if (
49
+ !Object.hasOwn(restrictions, name) ||
50
+ reported.has(property.start)
51
+ )
52
+ return
53
+ const restriction = restrictions[name]
54
+ if (
55
+ !restriction ||
56
+ typeof restriction !== 'object' ||
57
+ Array.isArray(restriction)
58
+ )
59
+ return
60
+ const allowed = restriction.values
61
+ if (Array.isArray(allowed)) {
62
+ const literal = Declarations.literal(property.value, bindings)
63
+ if (!literal) return
64
+ const values: unknown[] = Array.isArray(literal.value)
65
+ ? literal.value
66
+ : [literal.value]
67
+ if (
68
+ values.every((value) =>
69
+ allowed.some((allowed) => allowed === value),
70
+ )
71
+ )
72
+ return
73
+ }
74
+ reported.add(property.start)
75
+ context.report({
76
+ data: {
77
+ property: name,
78
+ reason:
79
+ typeof restriction.reason === 'string'
80
+ ? restriction.reason
81
+ : 'Use an approved property or value.',
82
+ },
83
+ messageId: 'restricted',
84
+ node: property.key,
85
+ })
86
+ },
87
+ },
88
+ })
89
+ },
90
+ }
91
+ },
92
+ }
@@ -0,0 +1,81 @@
1
+ /** Encourages logical CSS properties in web style declarations. @module */
2
+ import type { Rule } from '@oxlint/plugins'
3
+ import * as Bindings from '../internal/Bindings.js'
4
+ import * as Declarations from '../internal/Declarations.js'
5
+
6
+ const replacements: Readonly<Record<string, string>> = {
7
+ borderBottomLeftRadius: 'borderEndStartRadius',
8
+ borderBottomRightRadius: 'borderEndEndRadius',
9
+ borderLeft: 'borderInlineStart',
10
+ borderLeftColor: 'borderInlineStartColor',
11
+ borderLeftStyle: 'borderInlineStartStyle',
12
+ borderLeftWidth: 'borderInlineStartWidth',
13
+ borderRight: 'borderInlineEnd',
14
+ borderRightColor: 'borderInlineEndColor',
15
+ borderRightStyle: 'borderInlineEndStyle',
16
+ borderRightWidth: 'borderInlineEndWidth',
17
+ borderTopLeftRadius: 'borderStartStartRadius',
18
+ borderTopRightRadius: 'borderStartEndRadius',
19
+ insetLeft: 'insetInlineStart',
20
+ insetRight: 'insetInlineEnd',
21
+ left: 'insetInlineStart',
22
+ marginLeft: 'marginInlineStart',
23
+ marginRight: 'marginInlineEnd',
24
+ paddingLeft: 'paddingInlineStart',
25
+ paddingRight: 'paddingInlineEnd',
26
+ right: 'insetInlineEnd',
27
+ scrollMarginLeft: 'scrollMarginInlineStart',
28
+ scrollMarginRight: 'scrollMarginInlineEnd',
29
+ scrollPaddingLeft: 'scrollPaddingInlineStart',
30
+ scrollPaddingRight: 'scrollPaddingInlineEnd',
31
+ }
32
+
33
+ /** Reports physical properties without changing direction-dependent behavior. */
34
+ export const useLogicalProperties: Rule = {
35
+ meta: {
36
+ messages: {
37
+ logical:
38
+ "Use a CSS logical property instead of '{{property}}', such as '{{replacement}}' for horizontal LTR layouts.",
39
+ },
40
+ schema: [],
41
+ type: 'suggestion',
42
+ },
43
+ create(context) {
44
+ const bindings = Bindings.create(context)
45
+ const reported = new Set<number>()
46
+ return {
47
+ CallExpression(node) {
48
+ const binding = bindings.resolve(node.callee)
49
+ if (binding?.kind !== 'style' && binding?.kind !== 'variants') return
50
+ const input = node.arguments[0]
51
+ if (!input || input.type === 'SpreadElement') return
52
+ Declarations.visit(input, {
53
+ bindings,
54
+ recipe: binding.kind === 'variants',
55
+ visitors: {
56
+ declaration(property, name) {
57
+ const replacement = Object.hasOwn(replacements, name)
58
+ ? replacements[name]
59
+ : undefined
60
+ if (!replacement || reported.has(property.start)) return
61
+ if (
62
+ context.sourceCode
63
+ .getCommentsBefore(property)
64
+ .some((comment) =>
65
+ comment.value.includes('allow-physical-property'),
66
+ )
67
+ )
68
+ return
69
+ reported.add(property.start)
70
+ context.report({
71
+ data: { property: name, replacement },
72
+ messageId: 'logical',
73
+ node: property.key,
74
+ })
75
+ },
76
+ },
77
+ })
78
+ },
79
+ }
80
+ },
81
+ }