@qds.dev/tools 0.11.1 → 0.13.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/lib/linter/qds-internal.d.ts +210 -0
- package/lib/linter/qds.d.ts +59 -0
- package/{lib-types/tools → lib}/linter/rule-tester.d.ts +23 -1
- package/{lib-types/tools → lib}/playground/prop-extraction.d.ts +6 -1
- package/lib/playground/prop-extraction.qwik.mjs +68 -9
- package/lib/playground/scenario-injection.qwik.mjs +41 -8
- package/lib/rolldown/as-child.d.ts +16 -0
- package/lib/rolldown/as-child.qwik.mjs +52 -91
- package/lib/rolldown/icons.d.ts +21 -0
- package/{lib-types/tools → lib}/rolldown/index.d.ts +4 -2
- package/lib/rolldown/index.qwik.mjs +3 -3
- package/lib/rolldown/inject-component-types.d.ts +2 -0
- package/lib/rolldown/inject-component-types.qwik.mjs +138 -0
- package/lib/rolldown/inline-asset.qwik.mjs +6 -6
- package/lib/rolldown/inline-css.d.ts +2 -0
- package/lib/rolldown/inline-css.qwik.mjs +1 -1
- package/lib/rolldown/qds-types.d.ts +41 -0
- package/lib/rolldown/qds.d.ts +5 -0
- package/lib/rolldown/qds.qwik.mjs +147 -0
- package/lib/rolldown/qwik-rolldown.d.ts +6 -0
- package/lib/rolldown/ui-types.d.ts +42 -0
- package/lib/rolldown/ui.d.ts +12 -0
- package/lib/rolldown/ui.qwik.mjs +445 -0
- package/lib/utils/icons/naming.unit.d.ts +1 -0
- package/{lib-types/tools → lib}/utils/icons/transform/mdx.d.ts +3 -11
- package/lib/utils/icons/transform/mdx.qwik.mjs +14 -20
- package/{lib-types/tools → lib}/utils/icons/transform/tsx.d.ts +3 -12
- package/lib/utils/icons/transform/tsx.qwik.mjs +28 -37
- package/lib/utils/index.qwik.mjs +5 -5
- package/{lib-types/tools → lib}/utils/transform-dts.d.ts +4 -3
- package/lib/utils/transform-dts.qwik.mjs +18 -23
- package/lib/utils/transform-dts.unit.d.ts +1 -0
- package/{lib-types/tools → lib}/vite/index.d.ts +2 -2
- package/lib/vite/index.qwik.mjs +2 -3
- package/lib/vite/minify-content.qwik.mjs +1 -1
- package/lib/vite/minify-content.unit.d.ts +1 -0
- package/linter/qds-internal.ts +707 -0
- package/linter/qds-internal.unit.ts +399 -0
- package/linter/qds.ts +300 -0
- package/linter/qds.unit.ts +158 -0
- package/linter/rule-tester.ts +395 -0
- package/package.json +17 -18
- package/lib/rolldown/icons.qwik.mjs +0 -112
- package/lib-types/components/src/icons-runtime.d.ts +0 -223
- package/lib-types/tools/linter/qds-internal.d.ts +0 -7
- package/lib-types/tools/rolldown/as-child.d.ts +0 -24
- package/lib-types/tools/rolldown/icons.d.ts +0 -45
- package/lib-types/tools/rolldown/inline-css.d.ts +0 -26
- package/lib-types/tools/rolldown/qwik-rolldown.d.ts +0 -9
- /package/{lib-types/tools → lib}/linter/qds-internal.unit.d.ts +0 -0
- /package/{lib-types/tools/playground/generate-metadata.d.ts → lib/linter/qds.unit.d.ts} +0 -0
- /package/{lib-types/tools/playground/generate-metadata.unit.d.ts → lib/playground/generate-metadata.d.ts} +0 -0
- /package/{lib-types/tools/playground/prop-extraction.unit.d.ts → lib/playground/generate-metadata.unit.d.ts} +0 -0
- /package/{lib-types/tools → lib}/playground/index.d.ts +0 -0
- /package/{lib-types/tools/playground/scenario-injection.unit.d.ts → lib/playground/prop-extraction.unit.d.ts} +0 -0
- /package/{lib-types/tools → lib}/playground/scenario-injection.d.ts +0 -0
- /package/{lib-types/tools/rolldown/as-child.unit.d.ts → lib/playground/scenario-injection.unit.d.ts} +0 -0
- /package/{lib-types/tools/rolldown/icons.unit.d.ts → lib/rolldown/as-child.unit.d.ts} +0 -0
- /package/{lib-types/tools/rolldown/inline-asset.unit.d.ts → lib/rolldown/icons.unit.d.ts} +0 -0
- /package/{lib-types/tools/src/generate/icon-types.unit.d.ts → lib/rolldown/inject-component-types.unit.d.ts} +0 -0
- /package/{lib-types/tools → lib}/rolldown/inline-asset.d.ts +0 -0
- /package/{lib-types/tools/utils/icons/ast/expressions.unit.d.ts → lib/rolldown/inline-asset.unit.d.ts} +0 -0
- /package/{lib-types/tools/utils/icons/ast/jsx.unit.d.ts → lib/rolldown/qds.unit.d.ts} +0 -0
- /package/{lib-types/tools/utils/icons/naming.unit.d.ts → lib/rolldown/ui.unit.d.ts} +0 -0
- /package/{lib-types/tools → lib}/src/generate/icon-types.d.ts +0 -0
- /package/{lib-types/tools → lib}/src/index.d.ts +0 -0
- /package/{lib-types/tools → lib}/src/vite.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/ast/core.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/ast/imports.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/ast/jsx-helpers.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/ast/qwik.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/fs-mock.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/fs.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/icons/ast/expressions.d.ts +0 -0
- /package/{lib-types/tools/utils/transform-dts.unit.d.ts → lib/utils/icons/ast/expressions.unit.d.ts} +0 -0
- /package/{lib-types/tools → lib}/utils/icons/ast/jsx.d.ts +0 -0
- /package/{lib-types/tools/vite/minify-content.unit.d.ts → lib/utils/icons/ast/jsx.unit.d.ts} +0 -0
- /package/{lib-types/tools → lib}/utils/icons/collections/loader.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/icons/import-resolver.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/icons/naming.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/icons/transform/shared.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/icons/types/mdx-ast.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/index.d.ts +0 -0
- /package/{lib-types/tools → lib}/utils/package-json.d.ts +0 -0
- /package/{lib-types/tools → lib}/vite/minify-content.d.ts +0 -0
|
@@ -0,0 +1,707 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Oxlint plugin for Qwik Design System
|
|
3
|
+
*
|
|
4
|
+
* Custom linting rules specific to Qwik patterns and conventions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface Ranged {
|
|
8
|
+
range: [number, number];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface Node extends Ranged {
|
|
12
|
+
type: string;
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Context {
|
|
18
|
+
filename: string;
|
|
19
|
+
report(descriptor: {
|
|
20
|
+
node: Ranged;
|
|
21
|
+
messageId?: string;
|
|
22
|
+
message?: string;
|
|
23
|
+
data?: Record<string, string>;
|
|
24
|
+
}): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
namespace ESTree {
|
|
28
|
+
interface BaseNode extends Ranged {
|
|
29
|
+
type: string;
|
|
30
|
+
[key: string]: unknown;
|
|
31
|
+
}
|
|
32
|
+
export interface Identifier extends BaseNode {
|
|
33
|
+
type: "Identifier";
|
|
34
|
+
name: string;
|
|
35
|
+
}
|
|
36
|
+
export interface TSPropertySignature extends BaseNode {
|
|
37
|
+
type: "TSPropertySignature";
|
|
38
|
+
key: Identifier | BaseNode;
|
|
39
|
+
computed: boolean;
|
|
40
|
+
optional: boolean;
|
|
41
|
+
readonly: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface JSXIdentifier extends BaseNode {
|
|
44
|
+
type: "JSXIdentifier";
|
|
45
|
+
name: string;
|
|
46
|
+
}
|
|
47
|
+
export interface JSXExpressionContainer extends BaseNode {
|
|
48
|
+
type: "JSXExpressionContainer";
|
|
49
|
+
expression: Expression;
|
|
50
|
+
}
|
|
51
|
+
export interface JSXAttribute extends BaseNode {
|
|
52
|
+
type: "JSXAttribute";
|
|
53
|
+
name: JSXIdentifier | BaseNode;
|
|
54
|
+
value: JSXExpressionContainer | BaseNode | null;
|
|
55
|
+
}
|
|
56
|
+
export interface CallExpression extends BaseNode {
|
|
57
|
+
type: "CallExpression";
|
|
58
|
+
callee: Expression;
|
|
59
|
+
arguments: BaseNode[];
|
|
60
|
+
}
|
|
61
|
+
export interface VariableDeclarator extends BaseNode {
|
|
62
|
+
type: "VariableDeclarator";
|
|
63
|
+
id: Identifier | BaseNode;
|
|
64
|
+
init: Expression | null;
|
|
65
|
+
}
|
|
66
|
+
export interface ReturnStatement extends BaseNode {
|
|
67
|
+
type: "ReturnStatement";
|
|
68
|
+
argument: Expression | null;
|
|
69
|
+
}
|
|
70
|
+
export interface VariableDeclaration extends BaseNode {
|
|
71
|
+
type: "VariableDeclaration";
|
|
72
|
+
declarations: VariableDeclarator[];
|
|
73
|
+
}
|
|
74
|
+
export type Expression = BaseNode;
|
|
75
|
+
export type JSXElement = BaseNode;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function defineRule<const T extends Record<string, unknown>>(rule: T): T {
|
|
79
|
+
return rule;
|
|
80
|
+
}
|
|
81
|
+
function definePlugin<const T extends Record<string, unknown>>(plugin: T): T {
|
|
82
|
+
return plugin;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const noDefaultXNamingRule = defineRule({
|
|
86
|
+
meta: {
|
|
87
|
+
type: "problem",
|
|
88
|
+
docs: {
|
|
89
|
+
description:
|
|
90
|
+
"Disallow 'defaultX' naming pattern in component props - use Qwik's signal/value-based patterns instead",
|
|
91
|
+
category: "Best Practices",
|
|
92
|
+
recommended: true,
|
|
93
|
+
url: "https://qds.dev/contributing/state/"
|
|
94
|
+
},
|
|
95
|
+
messages: {
|
|
96
|
+
defaultXPattern:
|
|
97
|
+
"'{{name}}' uses React's defaultX pattern. Qwik uses signal-based (two-way binding) or value-based (one-way binding) patterns instead. See: https://qds.dev/contributing/state/"
|
|
98
|
+
},
|
|
99
|
+
schema: []
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
createOnce(context: Context) {
|
|
103
|
+
const defaultPattern = /^default[A-Z][a-zA-Z0-9]*$/;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
TSPropertySignature(node: Node) {
|
|
107
|
+
const tsNode = node as unknown as ESTree.TSPropertySignature;
|
|
108
|
+
if (tsNode.key.type !== "Identifier") return;
|
|
109
|
+
const key = tsNode.key as ESTree.Identifier;
|
|
110
|
+
if (!key.name) return;
|
|
111
|
+
|
|
112
|
+
const name = key.name;
|
|
113
|
+
if (!defaultPattern.test(name)) return;
|
|
114
|
+
|
|
115
|
+
context.report({
|
|
116
|
+
node: key,
|
|
117
|
+
messageId: "defaultXPattern",
|
|
118
|
+
data: { name }
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
const eventHandlersArrayPatternRule = defineRule({
|
|
126
|
+
meta: {
|
|
127
|
+
type: "problem",
|
|
128
|
+
docs: {
|
|
129
|
+
description:
|
|
130
|
+
"Event handlers should use array pattern to combine local handlers with props",
|
|
131
|
+
category: "Best Practices",
|
|
132
|
+
recommended: true,
|
|
133
|
+
url: "https://qds.dev/contributing/"
|
|
134
|
+
},
|
|
135
|
+
messages: {
|
|
136
|
+
requireArrayPattern:
|
|
137
|
+
"Event handler '{{name}}' should use array pattern: [localHandler$, props.{{name}}]."
|
|
138
|
+
},
|
|
139
|
+
schema: []
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
createOnce(context: Context) {
|
|
143
|
+
return {
|
|
144
|
+
JSXAttribute(node: Node) {
|
|
145
|
+
const jsxAttr = node as unknown as ESTree.JSXAttribute;
|
|
146
|
+
|
|
147
|
+
if (jsxAttr.name.type !== "JSXIdentifier") return;
|
|
148
|
+
|
|
149
|
+
const attrName = (jsxAttr.name as ESTree.JSXIdentifier).name;
|
|
150
|
+
if (!attrName.startsWith("on") || !attrName.endsWith("$")) return;
|
|
151
|
+
|
|
152
|
+
const value = jsxAttr.value;
|
|
153
|
+
if (!value || value.type !== "JSXExpressionContainer") return;
|
|
154
|
+
|
|
155
|
+
const expr = (value as ESTree.JSXExpressionContainer).expression;
|
|
156
|
+
|
|
157
|
+
// Allow array expressions: onXxx$={[handler$, props.onXxx$]}
|
|
158
|
+
if (expr.type === "ArrayExpression") return;
|
|
159
|
+
|
|
160
|
+
// Allow ternary expressions (single-line ternaries are valid)
|
|
161
|
+
if (expr.type === "ConditionalExpression") return;
|
|
162
|
+
|
|
163
|
+
context.report({
|
|
164
|
+
node: jsxAttr.name,
|
|
165
|
+
messageId: "requireArrayPattern",
|
|
166
|
+
data: { name: attrName }
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
function collectHTMLElements(node: ESTree.Expression): string[] {
|
|
174
|
+
const elements: string[] = [];
|
|
175
|
+
|
|
176
|
+
const walk = (n: unknown): void => {
|
|
177
|
+
if (!n || typeof n !== "object") return;
|
|
178
|
+
|
|
179
|
+
const record = n as Record<string, unknown>;
|
|
180
|
+
if (record.type === "JSXElement") {
|
|
181
|
+
const openingElement = record.openingElement as Record<string, unknown> | undefined;
|
|
182
|
+
if (openingElement?.name) {
|
|
183
|
+
const name = openingElement.name as Record<string, unknown>;
|
|
184
|
+
if (name.type === "JSXIdentifier") {
|
|
185
|
+
const tagName = name.name as string;
|
|
186
|
+
// Only collect lowercase tags (HTML elements, not components)
|
|
187
|
+
if (tagName && tagName[0] === tagName[0].toLowerCase()) {
|
|
188
|
+
elements.push(tagName);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Recursively walk children
|
|
195
|
+
for (const key in record) {
|
|
196
|
+
const value = record[key];
|
|
197
|
+
if (key === "children" && Array.isArray(value)) {
|
|
198
|
+
value.forEach(walk);
|
|
199
|
+
} else if (value && typeof value === "object") {
|
|
200
|
+
walk(value);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
walk(node);
|
|
206
|
+
return elements;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const oneElementCompositionRule = defineRule({
|
|
210
|
+
meta: {
|
|
211
|
+
type: "problem",
|
|
212
|
+
docs: {
|
|
213
|
+
description:
|
|
214
|
+
"Components should follow 'One Component, One Markup Element' principle",
|
|
215
|
+
category: "Best Practices",
|
|
216
|
+
recommended: true,
|
|
217
|
+
url: "https://qds.dev/contributing/composition/"
|
|
218
|
+
},
|
|
219
|
+
messages: {
|
|
220
|
+
multipleElements:
|
|
221
|
+
"Component returns multiple HTML element types: {{elements}}. Each component should correspond to ONE type of markup element. Add '// no-composition-check' to exempt."
|
|
222
|
+
},
|
|
223
|
+
schema: []
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
createOnce(context: Context) {
|
|
227
|
+
let hasCompositionCheckDisable = false;
|
|
228
|
+
const componentReturnElements = new Map<string, Set<string>>();
|
|
229
|
+
let currentComponentName: string | null = null;
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
Program() {
|
|
233
|
+
// Check for no-composition-check comment
|
|
234
|
+
// Note: In a real implementation, we'd check context.sourceCode.getAllComments()
|
|
235
|
+
// For now, we'll skip this feature in the linter
|
|
236
|
+
hasCompositionCheckDisable = false;
|
|
237
|
+
},
|
|
238
|
+
|
|
239
|
+
VariableDeclarator(node: Node) {
|
|
240
|
+
const decl = node as unknown as ESTree.VariableDeclarator;
|
|
241
|
+
if (decl.init?.type !== "CallExpression") return;
|
|
242
|
+
|
|
243
|
+
const callee = (decl.init as ESTree.CallExpression).callee as ESTree.Identifier;
|
|
244
|
+
if (callee.type !== "Identifier") return;
|
|
245
|
+
if (callee.name !== "component$") return;
|
|
246
|
+
if (decl.id.type !== "Identifier") return;
|
|
247
|
+
|
|
248
|
+
currentComponentName = (decl.id as ESTree.Identifier).name;
|
|
249
|
+
componentReturnElements.set(currentComponentName, new Set());
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
ReturnStatement(node: Node) {
|
|
253
|
+
if (!currentComponentName || hasCompositionCheckDisable) return;
|
|
254
|
+
|
|
255
|
+
const returnStmt = node as unknown as ESTree.ReturnStatement;
|
|
256
|
+
if (!returnStmt.argument) return;
|
|
257
|
+
|
|
258
|
+
// Collect HTML elements from the return statement
|
|
259
|
+
const elements = collectHTMLElements(returnStmt.argument);
|
|
260
|
+
const elementSet = componentReturnElements.get(currentComponentName);
|
|
261
|
+
|
|
262
|
+
if (elementSet) {
|
|
263
|
+
elements.forEach((el) => elementSet.add(el));
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
"VariableDeclarator:exit"(node: Node) {
|
|
268
|
+
const decl = node as unknown as ESTree.VariableDeclarator;
|
|
269
|
+
if (decl.init?.type !== "CallExpression") return;
|
|
270
|
+
|
|
271
|
+
const callee = (decl.init as ESTree.CallExpression).callee as ESTree.Identifier;
|
|
272
|
+
if (callee.type !== "Identifier") return;
|
|
273
|
+
if (callee.name !== "component$") return;
|
|
274
|
+
if (!currentComponentName) return;
|
|
275
|
+
if (decl.id.type !== "Identifier") return;
|
|
276
|
+
|
|
277
|
+
const elementSet = componentReturnElements.get(currentComponentName);
|
|
278
|
+
|
|
279
|
+
if (elementSet && elementSet.size > 1) {
|
|
280
|
+
const elements = Array.from(elementSet).toSorted().join(", ");
|
|
281
|
+
context.report({
|
|
282
|
+
node: decl.id,
|
|
283
|
+
messageId: "multipleElements",
|
|
284
|
+
data: { elements }
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
currentComponentName = null;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
const requireUseBindingsRule = defineRule({
|
|
295
|
+
meta: {
|
|
296
|
+
type: "problem",
|
|
297
|
+
docs: {
|
|
298
|
+
description:
|
|
299
|
+
"Root components (*-root.tsx) should use useBindings or include '// no-bindings' comment",
|
|
300
|
+
category: "Best Practices",
|
|
301
|
+
recommended: true,
|
|
302
|
+
url: "https://qds.dev/contributing/state/#useBindings"
|
|
303
|
+
},
|
|
304
|
+
messages: {
|
|
305
|
+
missingBindings:
|
|
306
|
+
"Root component is missing useBindings. Add useBindings or include '// no-bindings' comment if not needed."
|
|
307
|
+
},
|
|
308
|
+
schema: []
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
createOnce(context: Context) {
|
|
312
|
+
let hasUseBindings = false;
|
|
313
|
+
let hasNoBindingsComment = false;
|
|
314
|
+
let hasComponent = false;
|
|
315
|
+
let componentNode: Ranged | null = null;
|
|
316
|
+
let isRootComponent = false;
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
Program() {
|
|
320
|
+
// Check if this is a root component file
|
|
321
|
+
const filename = context.filename;
|
|
322
|
+
isRootComponent =
|
|
323
|
+
filename.endsWith("-root.tsx") || filename.endsWith("-root.jsx");
|
|
324
|
+
|
|
325
|
+
// Note: In a real implementation, we'd check for comments here
|
|
326
|
+
hasNoBindingsComment = false;
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
CallExpression(node: Node) {
|
|
330
|
+
if (!isRootComponent) return;
|
|
331
|
+
|
|
332
|
+
const call = node as unknown as ESTree.CallExpression;
|
|
333
|
+
const callee = call.callee;
|
|
334
|
+
if (callee.type !== "Identifier") return;
|
|
335
|
+
|
|
336
|
+
// Check for component$
|
|
337
|
+
if (callee.name === "component$") {
|
|
338
|
+
hasComponent = true;
|
|
339
|
+
componentNode = node;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Check for useBindings
|
|
343
|
+
if (callee.name === "useBindings") {
|
|
344
|
+
hasUseBindings = true;
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
"Program:exit"() {
|
|
349
|
+
if (!isRootComponent) return;
|
|
350
|
+
if (!hasComponent) return;
|
|
351
|
+
if (hasUseBindings) return;
|
|
352
|
+
if (hasNoBindingsComment) return;
|
|
353
|
+
if (!componentNode) return;
|
|
354
|
+
|
|
355
|
+
context.report({
|
|
356
|
+
node: componentNode,
|
|
357
|
+
messageId: "missingBindings"
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
const requireResearchFileRule = defineRule({
|
|
365
|
+
meta: {
|
|
366
|
+
type: "problem",
|
|
367
|
+
docs: {
|
|
368
|
+
description:
|
|
369
|
+
"Root components (*-root.tsx) require a research file (research.md or research.mdx)",
|
|
370
|
+
category: "Best Practices",
|
|
371
|
+
recommended: true,
|
|
372
|
+
url: "https://qwik.design/contributing/research/"
|
|
373
|
+
},
|
|
374
|
+
messages: {
|
|
375
|
+
missingResearch:
|
|
376
|
+
"Root component requires a research file. Create research.md or research.mdx in this directory documenting component research, accessibility, design decisions, and usage guidelines."
|
|
377
|
+
},
|
|
378
|
+
schema: []
|
|
379
|
+
},
|
|
380
|
+
|
|
381
|
+
createOnce(context: Context) {
|
|
382
|
+
let isRootComponent = false;
|
|
383
|
+
let hasComponent = false;
|
|
384
|
+
let componentNode: Ranged | null = null;
|
|
385
|
+
|
|
386
|
+
return {
|
|
387
|
+
Program() {
|
|
388
|
+
const filename = context.filename;
|
|
389
|
+
isRootComponent =
|
|
390
|
+
filename.endsWith("-root.tsx") || filename.endsWith("-root.jsx");
|
|
391
|
+
},
|
|
392
|
+
|
|
393
|
+
CallExpression(node: Node) {
|
|
394
|
+
if (!isRootComponent) return;
|
|
395
|
+
|
|
396
|
+
const call = node as unknown as ESTree.CallExpression;
|
|
397
|
+
const callee = call.callee;
|
|
398
|
+
if (callee.type !== "Identifier") return;
|
|
399
|
+
|
|
400
|
+
if (callee.name === "component$") {
|
|
401
|
+
hasComponent = true;
|
|
402
|
+
componentNode = node;
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
"Program:exit"() {
|
|
407
|
+
if (!isRootComponent) return;
|
|
408
|
+
if (!hasComponent) return;
|
|
409
|
+
if (!componentNode) return;
|
|
410
|
+
|
|
411
|
+
// Note: We can't actually check the filesystem from oxlint
|
|
412
|
+
// This rule serves as a reminder/documentation
|
|
413
|
+
// The actual enforcement is done by the GitHub Actions workflow
|
|
414
|
+
context.report({
|
|
415
|
+
node: componentNode,
|
|
416
|
+
messageId: "missingResearch"
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
const requireTestFileRule = defineRule({
|
|
424
|
+
meta: {
|
|
425
|
+
type: "problem",
|
|
426
|
+
docs: {
|
|
427
|
+
description: "Root components (*-root.tsx) require a test file (*.browser.tsx)",
|
|
428
|
+
category: "Best Practices",
|
|
429
|
+
recommended: true,
|
|
430
|
+
url: "https://qwik.design/contributing/testing/"
|
|
431
|
+
},
|
|
432
|
+
messages: {
|
|
433
|
+
missingTest:
|
|
434
|
+
"Root component requires a test file. Create a *.browser.tsx file in this directory with component tests."
|
|
435
|
+
},
|
|
436
|
+
schema: []
|
|
437
|
+
},
|
|
438
|
+
|
|
439
|
+
createOnce(context: Context) {
|
|
440
|
+
let isRootComponent = false;
|
|
441
|
+
let hasComponent = false;
|
|
442
|
+
let componentNode: Ranged | null = null;
|
|
443
|
+
|
|
444
|
+
return {
|
|
445
|
+
Program() {
|
|
446
|
+
const filename = context.filename;
|
|
447
|
+
isRootComponent =
|
|
448
|
+
filename.endsWith("-root.tsx") || filename.endsWith("-root.jsx");
|
|
449
|
+
},
|
|
450
|
+
|
|
451
|
+
CallExpression(node: Node) {
|
|
452
|
+
if (!isRootComponent) return;
|
|
453
|
+
|
|
454
|
+
const call = node as unknown as ESTree.CallExpression;
|
|
455
|
+
const callee = call.callee;
|
|
456
|
+
if (callee.type !== "Identifier") return;
|
|
457
|
+
|
|
458
|
+
if (callee.name === "component$") {
|
|
459
|
+
hasComponent = true;
|
|
460
|
+
componentNode = node;
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
"Program:exit"() {
|
|
465
|
+
if (!isRootComponent) return;
|
|
466
|
+
if (!hasComponent) return;
|
|
467
|
+
if (!componentNode) return;
|
|
468
|
+
|
|
469
|
+
// Note: We can't actually check the filesystem from oxlint
|
|
470
|
+
// This rule serves as a reminder/documentation
|
|
471
|
+
// The actual enforcement is done by the GitHub Actions workflow
|
|
472
|
+
context.report({
|
|
473
|
+
node: componentNode,
|
|
474
|
+
messageId: "missingTest"
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
const requireDestructureBindingsRule = defineRule({
|
|
482
|
+
meta: {
|
|
483
|
+
type: "problem",
|
|
484
|
+
docs: {
|
|
485
|
+
description:
|
|
486
|
+
"If useBindings is used, destructureBindings must also be used in the same file to ensure proper prop handling",
|
|
487
|
+
category: "Best Practices",
|
|
488
|
+
recommended: true,
|
|
489
|
+
url: "https://qds.dev/contributing/state/#destructureBindings"
|
|
490
|
+
},
|
|
491
|
+
messages: {
|
|
492
|
+
missingDestructureBindings:
|
|
493
|
+
"File uses useBindings but is missing destructureBindings. Both are required for proper QDS component state management. Example: const { ... } = destructureBindings(props, initialValues);"
|
|
494
|
+
},
|
|
495
|
+
schema: []
|
|
496
|
+
},
|
|
497
|
+
|
|
498
|
+
createOnce(context: Context) {
|
|
499
|
+
let hasUseBindings = false;
|
|
500
|
+
let hasDestructureBindings = false;
|
|
501
|
+
let useBindingsNode: Ranged | null = null;
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
CallExpression(node: Node) {
|
|
505
|
+
const call = node as unknown as ESTree.CallExpression;
|
|
506
|
+
const callee = call.callee;
|
|
507
|
+
if (callee.type !== "Identifier") return;
|
|
508
|
+
|
|
509
|
+
if (callee.name === "useBindings") {
|
|
510
|
+
hasUseBindings = true;
|
|
511
|
+
useBindingsNode = node;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (callee.name === "destructureBindings") {
|
|
515
|
+
hasDestructureBindings = true;
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
|
|
519
|
+
"Program:exit"() {
|
|
520
|
+
if (hasUseBindings && !hasDestructureBindings && useBindingsNode) {
|
|
521
|
+
context.report({
|
|
522
|
+
node: useBindingsNode,
|
|
523
|
+
messageId: "missingDestructureBindings"
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
export const requireContextProxyRule = defineRule({
|
|
532
|
+
meta: {
|
|
533
|
+
type: "problem",
|
|
534
|
+
docs: {
|
|
535
|
+
description: "Enforce createContextProxy pattern for getter exports",
|
|
536
|
+
recommended: true
|
|
537
|
+
},
|
|
538
|
+
messages: {
|
|
539
|
+
nonStringArg:
|
|
540
|
+
"Getter export '{{name}}' calls context() with a non-string argument. Use a string literal: context('fieldName').",
|
|
541
|
+
notContextProxy:
|
|
542
|
+
"Getter export '{{name}}' is not created via createContextProxy. Use: const context = createContextProxy<T>(); export const {{name}} = context('fieldName');",
|
|
543
|
+
missingArg:
|
|
544
|
+
"Getter export '{{name}}' calls context() without arguments. Provide the context field name: context('fieldName').",
|
|
545
|
+
missingGetPrefix:
|
|
546
|
+
"Export '{{name}}' uses createContextProxy but is missing the 'get' prefix. Rename to 'get{{suggested}}' so the UI plugin can detect it.",
|
|
547
|
+
getterNameMismatch:
|
|
548
|
+
"Getter '{{name}}' does not match context field '{{field}}'. Expected export name: 'get{{expected}}' (derived: '{{derived}}', actual field: '{{field}}'). The UI plugin uses deriveContextField which strips 'get' and lowercases the next char."
|
|
549
|
+
},
|
|
550
|
+
schema: []
|
|
551
|
+
},
|
|
552
|
+
|
|
553
|
+
create(context: Context) {
|
|
554
|
+
let createContextProxyLocalName: string | null = null;
|
|
555
|
+
const proxyVariableNames = new Set<string>();
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
ImportDeclaration(node: Node) {
|
|
559
|
+
const importNode = node as unknown as Record<string, unknown>;
|
|
560
|
+
const source = importNode.source as Record<string, unknown> | undefined;
|
|
561
|
+
if (!source || typeof source.value !== "string") return;
|
|
562
|
+
if (source.value !== "@qds.dev/base") return;
|
|
563
|
+
|
|
564
|
+
const specifiers = importNode.specifiers as
|
|
565
|
+
| Array<Record<string, unknown>>
|
|
566
|
+
| undefined;
|
|
567
|
+
if (!Array.isArray(specifiers)) return;
|
|
568
|
+
|
|
569
|
+
for (const specifier of specifiers) {
|
|
570
|
+
if (specifier.type !== "ImportSpecifier") continue;
|
|
571
|
+
const imported = specifier.imported as Record<string, unknown> | undefined;
|
|
572
|
+
const local = specifier.local as Record<string, unknown> | undefined;
|
|
573
|
+
if (!imported || !local) continue;
|
|
574
|
+
if (typeof imported.name !== "string") continue;
|
|
575
|
+
if (imported.name !== "createContextProxy") continue;
|
|
576
|
+
if (typeof local.name === "string") {
|
|
577
|
+
createContextProxyLocalName = local.name;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
VariableDeclarator(node: Node) {
|
|
583
|
+
if (!createContextProxyLocalName) return;
|
|
584
|
+
|
|
585
|
+
const decl = node as unknown as ESTree.VariableDeclarator;
|
|
586
|
+
if (!decl.init) return;
|
|
587
|
+
if (decl.init.type !== "CallExpression") return;
|
|
588
|
+
|
|
589
|
+
const callExpr = decl.init as unknown as ESTree.CallExpression;
|
|
590
|
+
if (callExpr.callee.type !== "Identifier") return;
|
|
591
|
+
|
|
592
|
+
const calleeName = (callExpr.callee as unknown as ESTree.Identifier).name;
|
|
593
|
+
if (calleeName !== createContextProxyLocalName) return;
|
|
594
|
+
if (decl.id.type !== "Identifier") return;
|
|
595
|
+
|
|
596
|
+
const varName = (decl.id as unknown as ESTree.Identifier).name;
|
|
597
|
+
proxyVariableNames.add(varName);
|
|
598
|
+
},
|
|
599
|
+
|
|
600
|
+
ExportNamedDeclaration(node: Node) {
|
|
601
|
+
if (!createContextProxyLocalName) return;
|
|
602
|
+
|
|
603
|
+
const exportNode = node as unknown as Record<string, unknown>;
|
|
604
|
+
const declaration = exportNode.declaration as Record<string, unknown> | undefined;
|
|
605
|
+
if (!declaration) return;
|
|
606
|
+
if (declaration.type !== "VariableDeclaration") return;
|
|
607
|
+
|
|
608
|
+
const declarations = declaration.declarations as
|
|
609
|
+
| Array<Record<string, unknown>>
|
|
610
|
+
| undefined;
|
|
611
|
+
if (!Array.isArray(declarations)) return;
|
|
612
|
+
|
|
613
|
+
for (const declarator of declarations) {
|
|
614
|
+
const id = declarator.id as Record<string, unknown> | undefined;
|
|
615
|
+
if (!id || id.type !== "Identifier") continue;
|
|
616
|
+
|
|
617
|
+
const exportName = id.name as string;
|
|
618
|
+
const init = declarator.init as Record<string, unknown> | undefined;
|
|
619
|
+
if (!init) continue;
|
|
620
|
+
if (init.type !== "CallExpression") continue;
|
|
621
|
+
|
|
622
|
+
const callee = init.callee as Record<string, unknown> | undefined;
|
|
623
|
+
if (!callee || callee.type !== "Identifier") continue;
|
|
624
|
+
|
|
625
|
+
const calleeName = callee.name as string;
|
|
626
|
+
const hasGetPrefix = exportName.startsWith("get") && exportName.length > 3;
|
|
627
|
+
|
|
628
|
+
if (!hasGetPrefix && proxyVariableNames.has(calleeName)) {
|
|
629
|
+
const suggested = exportName.charAt(0).toUpperCase() + exportName.slice(1);
|
|
630
|
+
context.report({
|
|
631
|
+
node: node as unknown as Ranged,
|
|
632
|
+
messageId: "missingGetPrefix",
|
|
633
|
+
data: { name: exportName, suggested }
|
|
634
|
+
});
|
|
635
|
+
continue;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (!hasGetPrefix) continue;
|
|
639
|
+
|
|
640
|
+
if (!proxyVariableNames.has(calleeName)) {
|
|
641
|
+
context.report({
|
|
642
|
+
node: node as unknown as Ranged,
|
|
643
|
+
messageId: "notContextProxy",
|
|
644
|
+
data: { name: exportName }
|
|
645
|
+
});
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const args = init.arguments as Array<Record<string, unknown>> | undefined;
|
|
650
|
+
if (!Array.isArray(args) || args.length === 0) {
|
|
651
|
+
context.report({
|
|
652
|
+
node: node as unknown as Ranged,
|
|
653
|
+
messageId: "missingArg",
|
|
654
|
+
data: { name: exportName }
|
|
655
|
+
});
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const firstArg = args[0];
|
|
660
|
+
const isStringLiteral =
|
|
661
|
+
firstArg.type === "Literal" && typeof firstArg.value === "string";
|
|
662
|
+
if (!isStringLiteral) {
|
|
663
|
+
context.report({
|
|
664
|
+
node: node as unknown as Ranged,
|
|
665
|
+
messageId: "nonStringArg",
|
|
666
|
+
data: { name: exportName }
|
|
667
|
+
});
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const contextField = firstArg.value as string;
|
|
672
|
+
const derived = exportName.slice(3, 4).toLowerCase() + exportName.slice(4);
|
|
673
|
+
if (derived !== contextField) {
|
|
674
|
+
context.report({
|
|
675
|
+
node: node as unknown as Ranged,
|
|
676
|
+
messageId: "getterNameMismatch",
|
|
677
|
+
data: {
|
|
678
|
+
name: exportName,
|
|
679
|
+
field: contextField,
|
|
680
|
+
expected: contextField.charAt(0).toUpperCase() + contextField.slice(1),
|
|
681
|
+
derived
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const qdsPlugin = definePlugin({
|
|
692
|
+
meta: {
|
|
693
|
+
name: "qds-internal"
|
|
694
|
+
},
|
|
695
|
+
rules: {
|
|
696
|
+
"no-default-name": noDefaultXNamingRule,
|
|
697
|
+
"event-handlers-array-pattern": eventHandlersArrayPatternRule,
|
|
698
|
+
"one-element-composition": oneElementCompositionRule,
|
|
699
|
+
"require-use-bindings": requireUseBindingsRule,
|
|
700
|
+
"require-destructure-bindings": requireDestructureBindingsRule,
|
|
701
|
+
"require-research-file": requireResearchFileRule,
|
|
702
|
+
"require-test-file": requireTestFileRule,
|
|
703
|
+
"require-context-proxy": requireContextProxyRule
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
export default qdsPlugin;
|