ai-deslop 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -0
- package/package.json +53 -0
- package/src/index.ts +87 -0
- package/src/rules/no-array-filter-map.ts +28 -0
- package/src/rules/no-call-only-assertions.ts +94 -0
- package/src/rules/no-chained-type-assertions.ts +77 -0
- package/src/rules/no-conditional-empty-object-spread.ts +49 -0
- package/src/rules/no-known-value-widening.ts +415 -0
- package/src/rules/no-module-mocking.ts +80 -0
- package/src/rules/no-object-parameters.ts +83 -0
- package/src/rules/no-pass-through-type-alias.ts +52 -0
- package/src/rules/no-reduce-accumulator-copy.ts +109 -0
- package/src/rules/no-reflect-apply.ts +28 -0
- package/src/rules/no-reflect-get.ts +28 -0
- package/src/rules/no-runtime-typeof.ts +77 -0
- package/src/rules/no-shape-in-symbol-names.ts +46 -0
- package/src/rules/no-unknown-parameters.ts +69 -0
- package/src/rules/no-unknown-returns.ts +82 -0
- package/src/rules/no-unknown-type-aliases.ts +54 -0
- package/src/rules/no-unsafe-dictionary-type.ts +154 -0
- package/src/rules/no-widen-then-assert.ts +366 -0
- package/src/rules/require-safety-comment-for-type-assertion.ts +131 -0
- package/src/shared/array-method.ts +94 -0
- package/src/shared/dictionary-types.ts +515 -0
- package/src/shared/function-parameters.ts +49 -0
- package/src/shared/lexical-type-parameters.ts +61 -0
- package/src/shared/reflect-method.ts +24 -0
- package/src/shared/scope.ts +15 -0
- package/src/shared/type-alias-resolution.ts +250 -0
- package/src/tailwind/candidates.ts +38 -0
- package/src/tailwind/design-system.ts +59 -0
- package/src/tailwind/rule.ts +126 -0
- package/src/tailwind/worker.ts +38 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { ESTree } from "@oxlint/plugins";
|
|
2
|
+
|
|
3
|
+
import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts";
|
|
4
|
+
|
|
5
|
+
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
|
|
6
|
+
type TypeScope = ESTree.Node;
|
|
7
|
+
|
|
8
|
+
type TypeBinding = {
|
|
9
|
+
readonly alias: ESTree.TSTypeAliasDeclaration | null;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly scope: TypeScope;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type Substitution = {
|
|
15
|
+
readonly substitutions: Substitutions;
|
|
16
|
+
readonly type: ESTree.TSType;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type Substitutions = ReadonlyMap<string, Substitution>;
|
|
20
|
+
|
|
21
|
+
export type TypeAliasEnvironment = {
|
|
22
|
+
readonly aliases: readonly ESTree.TSTypeAliasDeclaration[];
|
|
23
|
+
readonly bindingsByName: ReadonlyMap<string, readonly TypeBinding[]>;
|
|
24
|
+
readonly visitorKeys: VisitorKeys;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ResolvedTypeMatcher = (
|
|
28
|
+
type: ESTree.TSType,
|
|
29
|
+
matches: (child: ESTree.TSType) => boolean,
|
|
30
|
+
) => boolean;
|
|
31
|
+
|
|
32
|
+
const environmentsByProgram = new WeakMap<ESTree.Program, TypeAliasEnvironment>();
|
|
33
|
+
|
|
34
|
+
function isNode(value: unknown): value is ESTree.Node {
|
|
35
|
+
return (
|
|
36
|
+
typeof value === "object" &&
|
|
37
|
+
value !== null &&
|
|
38
|
+
"type" in value &&
|
|
39
|
+
typeof value.type === "string"
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function enclosingTypeScope(node: ESTree.Node): TypeScope {
|
|
44
|
+
let current: ESTree.Node | null = node.parent;
|
|
45
|
+
while (current !== null) {
|
|
46
|
+
if (
|
|
47
|
+
current.type === "Program" ||
|
|
48
|
+
current.type === "BlockStatement" ||
|
|
49
|
+
current.type === "TSModuleBlock" ||
|
|
50
|
+
current.type === "StaticBlock" ||
|
|
51
|
+
current.type === "SwitchStatement"
|
|
52
|
+
) {
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
current = current.parent;
|
|
56
|
+
}
|
|
57
|
+
return node;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function declaredTypeBinding(node: ESTree.Node): {
|
|
61
|
+
readonly alias: ESTree.TSTypeAliasDeclaration | null;
|
|
62
|
+
readonly name: string;
|
|
63
|
+
} | null {
|
|
64
|
+
if (node.type === "TSTypeAliasDeclaration") {
|
|
65
|
+
return { alias: node, name: node.id.name };
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
node.type === "TSInterfaceDeclaration" ||
|
|
69
|
+
node.type === "TSEnumDeclaration" ||
|
|
70
|
+
node.type === "ClassDeclaration" ||
|
|
71
|
+
node.type === "ClassExpression"
|
|
72
|
+
) {
|
|
73
|
+
return node.id === null ? null : { alias: null, name: node.id.name };
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
node.type === "ImportSpecifier" ||
|
|
77
|
+
node.type === "ImportDefaultSpecifier" ||
|
|
78
|
+
node.type === "ImportNamespaceSpecifier"
|
|
79
|
+
) {
|
|
80
|
+
return { alias: null, name: node.local.name };
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function collectTypeBindings(
|
|
86
|
+
node: ESTree.Node,
|
|
87
|
+
visitorKeys: VisitorKeys,
|
|
88
|
+
bindingsByName: Map<string, TypeBinding[]>,
|
|
89
|
+
aliases: ESTree.TSTypeAliasDeclaration[],
|
|
90
|
+
): void {
|
|
91
|
+
const declared = declaredTypeBinding(node);
|
|
92
|
+
if (declared !== null) {
|
|
93
|
+
const bindings = bindingsByName.get(declared.name) ?? [];
|
|
94
|
+
bindings.push({ ...declared, scope: enclosingTypeScope(node) });
|
|
95
|
+
bindingsByName.set(declared.name, bindings);
|
|
96
|
+
if (declared.alias !== null) aliases.push(declared.alias);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// SAFETY: Oxlint's visitor keys identify only ESTree child-node properties.
|
|
100
|
+
const fields = node as unknown as Readonly<Record<string, unknown>>;
|
|
101
|
+
for (const key of visitorKeys[node.type] ?? []) {
|
|
102
|
+
const value = fields[key];
|
|
103
|
+
if (isNode(value)) {
|
|
104
|
+
collectTypeBindings(value, visitorKeys, bindingsByName, aliases);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(value)) continue;
|
|
108
|
+
for (const child of value) {
|
|
109
|
+
if (isNode(child)) {
|
|
110
|
+
collectTypeBindings(child, visitorKeys, bindingsByName, aliases);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Collect every lexical type alias and competing type binding in a program. */
|
|
117
|
+
export function createTypeAliasEnvironment(
|
|
118
|
+
program: ESTree.Program,
|
|
119
|
+
visitorKeys: VisitorKeys,
|
|
120
|
+
): TypeAliasEnvironment {
|
|
121
|
+
const cached = environmentsByProgram.get(program);
|
|
122
|
+
if (cached !== undefined) return cached;
|
|
123
|
+
const bindingsByName = new Map<string, TypeBinding[]>();
|
|
124
|
+
const aliases: ESTree.TSTypeAliasDeclaration[] = [];
|
|
125
|
+
collectTypeBindings(program, visitorKeys, bindingsByName, aliases);
|
|
126
|
+
const environment = { aliases, bindingsByName, visitorKeys };
|
|
127
|
+
environmentsByProgram.set(program, environment);
|
|
128
|
+
return environment;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null {
|
|
132
|
+
let current: ESTree.Node | null = node;
|
|
133
|
+
let distance = 0;
|
|
134
|
+
while (current !== null) {
|
|
135
|
+
if (current === ancestor) return distance;
|
|
136
|
+
current = current.parent;
|
|
137
|
+
distance += 1;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function nearestTypeBindings(
|
|
143
|
+
name: string,
|
|
144
|
+
use: ESTree.Node,
|
|
145
|
+
environment: TypeAliasEnvironment,
|
|
146
|
+
): readonly TypeBinding[] {
|
|
147
|
+
const candidates = environment.bindingsByName.get(name) ?? [];
|
|
148
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
149
|
+
let nearest: TypeBinding[] = [];
|
|
150
|
+
for (const candidate of candidates) {
|
|
151
|
+
const distance = ancestorDistance(candidate.scope, use);
|
|
152
|
+
if (distance === null || distance > nearestDistance) continue;
|
|
153
|
+
if (distance === nearestDistance) {
|
|
154
|
+
nearest.push(candidate);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
nearestDistance = distance;
|
|
158
|
+
nearest = [candidate];
|
|
159
|
+
}
|
|
160
|
+
return nearest;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */
|
|
164
|
+
export function visibleTypeAlias(
|
|
165
|
+
name: string,
|
|
166
|
+
use: ESTree.Node,
|
|
167
|
+
environment: TypeAliasEnvironment,
|
|
168
|
+
): ESTree.TSTypeAliasDeclaration | null {
|
|
169
|
+
if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null;
|
|
170
|
+
const bindings = nearestTypeBindings(name, use, environment);
|
|
171
|
+
return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Return whether a local declaration shadows a built-in type at this use. */
|
|
175
|
+
export function hasVisibleTypeBinding(
|
|
176
|
+
name: string,
|
|
177
|
+
use: ESTree.Node,
|
|
178
|
+
environment: TypeAliasEnvironment,
|
|
179
|
+
): boolean {
|
|
180
|
+
return (
|
|
181
|
+
lexicalTypeParameterNames(use, environment.visitorKeys).has(name) ||
|
|
182
|
+
nearestTypeBindings(name, use, environment).length > 0
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
|
187
|
+
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function aliasSubstitutions(
|
|
191
|
+
alias: ESTree.TSTypeAliasDeclaration,
|
|
192
|
+
reference: ESTree.TSTypeReference,
|
|
193
|
+
base: Substitutions,
|
|
194
|
+
): Substitutions | null {
|
|
195
|
+
const parameters = alias.typeParameters?.params ?? [];
|
|
196
|
+
const arguments_ = reference.typeArguments?.params ?? [];
|
|
197
|
+
const next = new Map(base);
|
|
198
|
+
for (const [index, parameter] of parameters.entries()) {
|
|
199
|
+
const explicitArgument = arguments_[index];
|
|
200
|
+
const argument = explicitArgument ?? parameter.default;
|
|
201
|
+
if (argument === null || argument === undefined) return null;
|
|
202
|
+
const argumentSubstitutions = explicitArgument === undefined ? next : base;
|
|
203
|
+
next.set(parameter.name.name, {
|
|
204
|
+
type: argument,
|
|
205
|
+
substitutions: new Map(argumentSubstitutions),
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
return next;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Match a type after resolving visible aliases and substituting their type parameters. */
|
|
212
|
+
export function resolvedTypeMatches(
|
|
213
|
+
type: ESTree.TSType,
|
|
214
|
+
environment: TypeAliasEnvironment,
|
|
215
|
+
matcher: ResolvedTypeMatcher,
|
|
216
|
+
): boolean {
|
|
217
|
+
const evaluate = (
|
|
218
|
+
current: ESTree.TSType,
|
|
219
|
+
substitutions: Substitutions,
|
|
220
|
+
resolvingAliases: ReadonlySet<ESTree.TSTypeAliasDeclaration>,
|
|
221
|
+
): boolean => {
|
|
222
|
+
if (current.type === "TSTypeReference") {
|
|
223
|
+
const name = typeReferenceName(current);
|
|
224
|
+
if (name !== null) {
|
|
225
|
+
const substitution = substitutions.get(name);
|
|
226
|
+
if (substitution !== undefined && !current.typeArguments?.params.length) {
|
|
227
|
+
return evaluate(
|
|
228
|
+
substitution.type,
|
|
229
|
+
substitution.substitutions,
|
|
230
|
+
resolvingAliases,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const alias = visibleTypeAlias(name, current, environment);
|
|
234
|
+
if (alias !== null && !resolvingAliases.has(alias)) {
|
|
235
|
+
const nextSubstitutions = aliasSubstitutions(alias, current, substitutions);
|
|
236
|
+
if (nextSubstitutions !== null) {
|
|
237
|
+
const nextResolving = new Set(resolvingAliases);
|
|
238
|
+
nextResolving.add(alias);
|
|
239
|
+
return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return matcher(current, (child) =>
|
|
245
|
+
evaluate(child, substitutions, resolvingAliases),
|
|
246
|
+
);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
return evaluate(type, new Map(), new Set());
|
|
250
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface SpellingChange {
|
|
2
|
+
original: string;
|
|
3
|
+
canonical: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function rewriteClassValue(
|
|
7
|
+
value: string,
|
|
8
|
+
canonicalOf: ReadonlyMap<string, string>,
|
|
9
|
+
): { fixed: string; changes: Array<SpellingChange> } {
|
|
10
|
+
const changes: Array<SpellingChange> = [];
|
|
11
|
+
const fixed = value.replace(/\S+/g, (token) => {
|
|
12
|
+
const canonical = canonicalOf.get(token);
|
|
13
|
+
if (canonical !== undefined && canonical !== token) {
|
|
14
|
+
changes.push({ original: token, canonical });
|
|
15
|
+
return canonical;
|
|
16
|
+
}
|
|
17
|
+
return token;
|
|
18
|
+
});
|
|
19
|
+
return { fixed, changes };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function uniqueTokens(value: string): Array<string> {
|
|
23
|
+
const tokens = value.match(/\S+/g) ?? [];
|
|
24
|
+
return [...new Set(tokens)];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function summarizeChanges(changes: ReadonlyArray<SpellingChange>): {
|
|
28
|
+
original: string;
|
|
29
|
+
canonical: string;
|
|
30
|
+
suffix: string;
|
|
31
|
+
} {
|
|
32
|
+
const first = changes[0];
|
|
33
|
+
return {
|
|
34
|
+
original: first.original,
|
|
35
|
+
canonical: first.canonical,
|
|
36
|
+
suffix: changes.length > 1 ? ` (+${changes.length - 1} more in this string)` : "",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { createSyncFn } from "synckit";
|
|
5
|
+
|
|
6
|
+
interface LoadJob {
|
|
7
|
+
type: "load";
|
|
8
|
+
key: string;
|
|
9
|
+
cssContent: string;
|
|
10
|
+
base: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CanonicalizeJob {
|
|
14
|
+
type: "canonicalize";
|
|
15
|
+
key: string;
|
|
16
|
+
candidates: Array<string>;
|
|
17
|
+
rem: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type WorkerJob = LoadJob | CanonicalizeJob;
|
|
21
|
+
type WorkerResult = { ok: true } | Array<string>;
|
|
22
|
+
type WorkerFunction = (job: WorkerJob) => Promise<WorkerResult>;
|
|
23
|
+
|
|
24
|
+
const workerPath = fileURLToPath(new URL("./worker.ts", import.meta.url));
|
|
25
|
+
let syncCall: ReturnType<typeof createSyncFn<WorkerFunction>> | null = null;
|
|
26
|
+
const loadedSystems = new Map<string, number>();
|
|
27
|
+
|
|
28
|
+
function getSyncCall(): ReturnType<typeof createSyncFn<WorkerFunction>> {
|
|
29
|
+
if (syncCall === null) {
|
|
30
|
+
syncCall = createSyncFn<WorkerFunction>(workerPath);
|
|
31
|
+
}
|
|
32
|
+
return syncCall;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resolveCssPath(cssPath: string | undefined, cwd = process.cwd()): string {
|
|
36
|
+
if (!cssPath) throw new Error("cssPath is required");
|
|
37
|
+
return path.isAbsolute(cssPath) ? cssPath : path.resolve(cwd, cssPath);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ensureDesignSystem(cssFile: string): string {
|
|
41
|
+
const stat = fs.statSync(cssFile);
|
|
42
|
+
if (loadedSystems.get(cssFile) === stat.mtimeMs) return cssFile;
|
|
43
|
+
|
|
44
|
+
const cssContent = fs.readFileSync(cssFile, "utf8");
|
|
45
|
+
getSyncCall()({ type: "load", key: cssFile, cssContent, base: path.dirname(cssFile) });
|
|
46
|
+
loadedSystems.set(cssFile, stat.mtimeMs);
|
|
47
|
+
return cssFile;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function canonicalizeTokens(key: string, tokens: Array<string>, rem: number): Map<string, string> {
|
|
51
|
+
if (tokens.length === 0) return new Map();
|
|
52
|
+
|
|
53
|
+
const canonical = getSyncCall()({ type: "canonicalize", key, candidates: tokens, rem });
|
|
54
|
+
if (!Array.isArray(canonical)) throw new Error("Tailwind canonicalization returned an invalid result");
|
|
55
|
+
|
|
56
|
+
const result = new Map<string, string>();
|
|
57
|
+
for (let i = 0; i < tokens.length; i++) result.set(tokens[i], canonical[i] ?? tokens[i]);
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { defineRule } from "@oxlint/plugins";
|
|
2
|
+
import type { ESTree } from "@oxlint/plugins";
|
|
3
|
+
import { rewriteClassValue, summarizeChanges, uniqueTokens } from "./candidates.ts";
|
|
4
|
+
import { canonicalizeTokens, ensureDesignSystem, resolveCssPath } from "./design-system.ts";
|
|
5
|
+
|
|
6
|
+
export const PLUGIN_NAME = "deslop";
|
|
7
|
+
export const RULE_ID = "canonical-class-names";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_ATTRIBUTES = ["class", "className"];
|
|
10
|
+
const DEFAULT_CALLEE_FUNCTIONS = ["cn", "clsx", "cva", "twMerge", "tw", "classNames", "cx"];
|
|
11
|
+
const DEFAULT_ROOT_FONT_SIZE = 16;
|
|
12
|
+
|
|
13
|
+
interface RuleOptions {
|
|
14
|
+
cssPath: string;
|
|
15
|
+
rootFontSize?: number;
|
|
16
|
+
attributes?: Array<string>;
|
|
17
|
+
calleeFunctions?: Array<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const OPTIONS_SCHEMA = {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
cssPath: { type: "string", description: "Path to the Tailwind v4 entry CSS." },
|
|
24
|
+
rootFontSize: { type: "number", description: "Root font size in px used for rem normalization." },
|
|
25
|
+
attributes: { type: "array", items: { type: "string" }, description: "JSX class-list attributes." },
|
|
26
|
+
calleeFunctions: { type: "array", items: { type: "string" }, description: "Class-list helper functions." },
|
|
27
|
+
},
|
|
28
|
+
required: ["cssPath"],
|
|
29
|
+
additionalProperties: false,
|
|
30
|
+
} as const;
|
|
31
|
+
|
|
32
|
+
type StringNode = ESTree.StringLiteral | ESTree.TemplateLiteral;
|
|
33
|
+
|
|
34
|
+
export const canonicalClassNames = defineRule({
|
|
35
|
+
meta: {
|
|
36
|
+
type: "suggestion",
|
|
37
|
+
docs: { description: "Enforce canonical Tailwind CSS class spellings." },
|
|
38
|
+
fixable: "code",
|
|
39
|
+
messages: {
|
|
40
|
+
nonCanonical: "Tailwind class '{{original}}' can be written as '{{canonical}}'.{{suffix}}",
|
|
41
|
+
cssNotFound: "Could not load Tailwind CSS entry file: {{path}}",
|
|
42
|
+
},
|
|
43
|
+
schema: [OPTIONS_SCHEMA],
|
|
44
|
+
},
|
|
45
|
+
createOnce(context) {
|
|
46
|
+
let attributes = new Set(DEFAULT_ATTRIBUTES);
|
|
47
|
+
let callees = new Set(DEFAULT_CALLEE_FUNCTIONS);
|
|
48
|
+
let rem = DEFAULT_ROOT_FONT_SIZE;
|
|
49
|
+
let cssFile = "";
|
|
50
|
+
let designKey: string | false | null = null;
|
|
51
|
+
|
|
52
|
+
function initFile() {
|
|
53
|
+
const options = (context.options?.[0] ?? {}) as Partial<RuleOptions>;
|
|
54
|
+
attributes = new Set(options.attributes ?? DEFAULT_ATTRIBUTES);
|
|
55
|
+
callees = new Set(options.calleeFunctions ?? DEFAULT_CALLEE_FUNCTIONS);
|
|
56
|
+
rem = options.rootFontSize ?? DEFAULT_ROOT_FONT_SIZE;
|
|
57
|
+
try {
|
|
58
|
+
cssFile = resolveCssPath(options.cssPath);
|
|
59
|
+
} catch {
|
|
60
|
+
cssFile = options.cssPath ?? "";
|
|
61
|
+
}
|
|
62
|
+
designKey = null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getDesignKey(): string | null {
|
|
66
|
+
if (designKey === null) {
|
|
67
|
+
try { designKey = ensureDesignSystem(cssFile); } catch { designKey = false; }
|
|
68
|
+
}
|
|
69
|
+
return designKey === false ? null : designKey;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function checkStringNode(node: StringNode, value: string) {
|
|
73
|
+
const tokens = uniqueTokens(value);
|
|
74
|
+
if (tokens.length === 0) return;
|
|
75
|
+
const key = getDesignKey();
|
|
76
|
+
if (key === null) {
|
|
77
|
+
context.report({ node, messageId: "cssNotFound", data: { path: cssFile } });
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
let canonicalOf: Map<string, string>;
|
|
81
|
+
try { canonicalOf = canonicalizeTokens(key, tokens, rem); } catch { return; }
|
|
82
|
+
const { fixed, changes } = rewriteClassValue(value, canonicalOf);
|
|
83
|
+
if (changes.length === 0) return;
|
|
84
|
+
const summary = summarizeChanges(changes);
|
|
85
|
+
context.report({
|
|
86
|
+
node,
|
|
87
|
+
messageId: "nonCanonical",
|
|
88
|
+
data: summary,
|
|
89
|
+
fix(fixer) {
|
|
90
|
+
const first = context.sourceCode.getText(node)[0];
|
|
91
|
+
const quote = first === "'" || first === '"' || first === "`" ? first : '"';
|
|
92
|
+
return fixer.replaceText(node, `${quote}${fixed}${quote}`);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function checkExpression(node: ESTree.Expression | ESTree.JSXEmptyExpression) {
|
|
98
|
+
if (node.type === "Literal" && typeof node.value === "string") checkStringNode(node, node.value);
|
|
99
|
+
else if (node.type === "TemplateLiteral" && node.expressions.length === 0 && node.quasis.length === 1) {
|
|
100
|
+
checkStringNode(node, node.quasis[0].value.raw);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
before() {
|
|
106
|
+
initFile();
|
|
107
|
+
const text = context.sourceCode.text;
|
|
108
|
+
for (const name of attributes) if (name !== "" && text.includes(name)) return;
|
|
109
|
+
for (const name of callees) if (name !== "" && text.includes(name)) return;
|
|
110
|
+
return false;
|
|
111
|
+
},
|
|
112
|
+
JSXAttribute(node) {
|
|
113
|
+
if (node.name.type !== "JSXIdentifier" || !attributes.has(node.name.name) || !node.value) return;
|
|
114
|
+
if (node.value.type === "Literal" && typeof node.value.value === "string") checkStringNode(node.value, node.value.value);
|
|
115
|
+
else if (node.value.type === "JSXExpressionContainer") checkExpression(node.value.expression);
|
|
116
|
+
},
|
|
117
|
+
CallExpression(node) {
|
|
118
|
+
let name: string | null = null;
|
|
119
|
+
if (node.callee.type === "Identifier") name = node.callee.name;
|
|
120
|
+
else if (node.callee.type === "MemberExpression" && !node.callee.computed && node.callee.property.type === "Identifier") name = node.callee.property.name;
|
|
121
|
+
if (name === null || !callees.has(name)) return;
|
|
122
|
+
for (const arg of node.arguments) if (arg.type === "Literal" || arg.type === "TemplateLiteral") checkExpression(arg);
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { __unstable__loadDesignSystem } from "@tailwindcss/node";
|
|
2
|
+
import { runAsWorker } from "synckit";
|
|
3
|
+
|
|
4
|
+
interface LoadJob {
|
|
5
|
+
type: "load";
|
|
6
|
+
key: string;
|
|
7
|
+
cssContent: string;
|
|
8
|
+
base: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface CanonicalizeJob {
|
|
12
|
+
type: "canonicalize";
|
|
13
|
+
key: string;
|
|
14
|
+
candidates: Array<string>;
|
|
15
|
+
rem: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type WorkerJob = LoadJob | CanonicalizeJob;
|
|
19
|
+
|
|
20
|
+
interface DesignSystem {
|
|
21
|
+
canonicalizeCandidates(candidates: Array<string>, options: { rem: number }): Array<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const designSystems = new Map<string, DesignSystem>();
|
|
25
|
+
|
|
26
|
+
runAsWorker(async (job: WorkerJob) => {
|
|
27
|
+
if (job.type === "load") {
|
|
28
|
+
designSystems.set(
|
|
29
|
+
job.key,
|
|
30
|
+
await __unstable__loadDesignSystem(job.cssContent, { base: job.base }) as DesignSystem,
|
|
31
|
+
);
|
|
32
|
+
return { ok: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const designSystem = designSystems.get(job.key);
|
|
36
|
+
if (!designSystem) throw new Error(`Tailwind design system not loaded for "${job.key}"`);
|
|
37
|
+
return designSystem.canonicalizeCandidates(job.candidates, { rem: job.rem });
|
|
38
|
+
});
|