@qds.dev/tools 0.11.2 → 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 +204 -1
- package/lib/linter/qds.d.ts +59 -0
- package/lib/linter/qds.unit.d.ts +1 -0
- package/lib/linter/rule-tester.d.ts +23 -1
- package/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 +6 -5
- package/lib/rolldown/as-child.qwik.mjs +52 -91
- package/lib/rolldown/index.d.ts +3 -2
- package/lib/rolldown/index.qwik.mjs +2 -3
- package/lib/rolldown/inject-component-types.qwik.mjs +1 -1
- package/lib/rolldown/inline-asset.qwik.mjs +6 -6
- 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/qds.unit.d.ts +1 -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/rolldown/ui.unit.d.ts +1 -0
- package/lib/utils/icons/transform/mdx.d.ts +3 -11
- package/lib/utils/icons/transform/mdx.qwik.mjs +14 -20
- package/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/utils/transform-dts.qwik.mjs +1 -1
- package/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/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 +8 -7
- package/lib/rolldown/icons.qwik.mjs +0 -107
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, it } from "vitest";
|
|
2
|
+
import qdsPlugin from "./qds";
|
|
3
|
+
import { createRuleTester } from "./rule-tester";
|
|
4
|
+
|
|
5
|
+
describe("qds-public/no-outside-getter-usage", () => {
|
|
6
|
+
const { valid, invalid } = createRuleTester({
|
|
7
|
+
name: "qds-public/no-outside-getter-usage",
|
|
8
|
+
rule: qdsPlugin.rules["no-outside-getter-usage"]
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("allows getter inside component$ with matching root", () => {
|
|
12
|
+
valid(`
|
|
13
|
+
import { select } from "@qds.dev/ui";
|
|
14
|
+
import { component$, useTask$ } from "@qwik.dev/core";
|
|
15
|
+
export default component$(() => {
|
|
16
|
+
useTask$(() => { console.log(select.getIsOpen.value); });
|
|
17
|
+
return <select.root><div /></select.root>;
|
|
18
|
+
});
|
|
19
|
+
`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("allows getter in nested function inside component$", () => {
|
|
23
|
+
valid(`
|
|
24
|
+
import { select } from "@qds.dev/ui";
|
|
25
|
+
import { component$ } from "@qwik.dev/core";
|
|
26
|
+
export default component$(() => {
|
|
27
|
+
function helper() { console.log(select.getIsOpen.value); }
|
|
28
|
+
return <select.root><div /></select.root>;
|
|
29
|
+
});
|
|
30
|
+
`);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("allows getter in JSX expression (plugin handles it)", () => {
|
|
34
|
+
valid(`
|
|
35
|
+
import { select } from "@qds.dev/ui";
|
|
36
|
+
import { component$ } from "@qwik.dev/core";
|
|
37
|
+
export default component$(() => (
|
|
38
|
+
<select.root><div>{select.getIsOpen.value}</div></select.root>
|
|
39
|
+
));
|
|
40
|
+
`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("allows destructured getter inside component$ with root", () => {
|
|
44
|
+
valid(`
|
|
45
|
+
import { select } from "@qds.dev/ui";
|
|
46
|
+
import { component$, useTask$ } from "@qwik.dev/core";
|
|
47
|
+
export default component$(() => {
|
|
48
|
+
const { getIsOpen } = select;
|
|
49
|
+
useTask$(() => { console.log(getIsOpen.value); });
|
|
50
|
+
return <select.root><div /></select.root>;
|
|
51
|
+
});
|
|
52
|
+
`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("errors on getter at module scope", () => {
|
|
56
|
+
invalid({
|
|
57
|
+
code: `
|
|
58
|
+
import { select } from "@qds.dev/ui";
|
|
59
|
+
const labels = select.getSelectedLabels;
|
|
60
|
+
`,
|
|
61
|
+
errors: ["moduleScope"]
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("errors on destructured getter at module scope", () => {
|
|
66
|
+
invalid({
|
|
67
|
+
code: `
|
|
68
|
+
import { select } from "@qds.dev/ui";
|
|
69
|
+
const { getSelectedLabels } = select;
|
|
70
|
+
console.log(getSelectedLabels.value);
|
|
71
|
+
`,
|
|
72
|
+
errors: ["moduleScope"]
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("errors on getter in function defined outside component$", () => {
|
|
77
|
+
invalid({
|
|
78
|
+
code: `
|
|
79
|
+
import { select } from "@qds.dev/ui";
|
|
80
|
+
import { component$ } from "@qwik.dev/core";
|
|
81
|
+
function myHelper() {
|
|
82
|
+
console.log(select.getIsOpen.value);
|
|
83
|
+
}
|
|
84
|
+
export default component$(() => {
|
|
85
|
+
myHelper();
|
|
86
|
+
return <select.root><div /></select.root>;
|
|
87
|
+
});
|
|
88
|
+
`,
|
|
89
|
+
errors: ["outsideComponent"]
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("errors on getter in component$ with no matching root", () => {
|
|
94
|
+
invalid({
|
|
95
|
+
code: `
|
|
96
|
+
import { select } from "@qds.dev/ui";
|
|
97
|
+
import { component$, useTask$ } from "@qwik.dev/core";
|
|
98
|
+
export default component$(() => {
|
|
99
|
+
useTask$(() => { console.log(select.getIsOpen.value); });
|
|
100
|
+
return <div>No root here</div>;
|
|
101
|
+
});
|
|
102
|
+
`,
|
|
103
|
+
errors: ["noMatchingRoot"]
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("allows getter in child component$ when parent component$ has root", () => {
|
|
108
|
+
valid(`
|
|
109
|
+
import { select } from "@qds.dev/ui";
|
|
110
|
+
import { component$ } from "@qwik.dev/core";
|
|
111
|
+
export default component$(() => {
|
|
112
|
+
return (
|
|
113
|
+
<select.root value="jim">
|
|
114
|
+
<Inner />
|
|
115
|
+
</select.root>
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
export const Inner = component$(() => {
|
|
119
|
+
const { getIsOpen, getSelectedLabels } = select;
|
|
120
|
+
return (
|
|
121
|
+
<>
|
|
122
|
+
<select.trigger>{getSelectedLabels.value}</select.trigger>
|
|
123
|
+
<p>Status: {getIsOpen.value ? "Open" : "Closed"}</p>
|
|
124
|
+
</>
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
`);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("allows getter via member expression in child component$ when parent has root", () => {
|
|
131
|
+
valid(`
|
|
132
|
+
import { select } from "@qds.dev/ui";
|
|
133
|
+
import { component$ } from "@qwik.dev/core";
|
|
134
|
+
export default component$(() => (
|
|
135
|
+
<select.root><Inner /></select.root>
|
|
136
|
+
));
|
|
137
|
+
const Inner = component$(() => (
|
|
138
|
+
<div>{select.getIsOpen.value ? "Open" : "Closed"}</div>
|
|
139
|
+
));
|
|
140
|
+
`);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("errors on getter in component$ when no sibling component$ has root", () => {
|
|
144
|
+
invalid({
|
|
145
|
+
code: `
|
|
146
|
+
import { select } from "@qds.dev/ui";
|
|
147
|
+
import { component$ } from "@qwik.dev/core";
|
|
148
|
+
export default component$(() => {
|
|
149
|
+
return <div>{select.getIsOpen.value}</div>;
|
|
150
|
+
});
|
|
151
|
+
export const Other = component$(() => {
|
|
152
|
+
return <div>No root here either</div>;
|
|
153
|
+
});
|
|
154
|
+
`,
|
|
155
|
+
errors: ["noMatchingRoot"]
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RuleTester for oxlint rules
|
|
3
|
+
*
|
|
4
|
+
* Provides a clean API similar to ESLint's RuleTester but designed for oxc/oxlint.
|
|
5
|
+
* Works seamlessly with Vitest for testing custom lint rules.
|
|
6
|
+
*/
|
|
7
|
+
import { parseSync } from "oxc-parser";
|
|
8
|
+
import { walk, type WalkerEnter } from "oxc-walker";
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
|
|
11
|
+
interface Ranged {
|
|
12
|
+
range: [number, number];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Node extends Ranged {
|
|
16
|
+
type: string;
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Context {
|
|
22
|
+
filename: string;
|
|
23
|
+
report(descriptor: {
|
|
24
|
+
node: Ranged;
|
|
25
|
+
messageId?: string;
|
|
26
|
+
message?: string;
|
|
27
|
+
data?: Record<string, string>;
|
|
28
|
+
}): void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type VisitorWithHooks = Record<string, unknown> & {
|
|
32
|
+
before?: () => boolean | void;
|
|
33
|
+
after?: () => void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type Rule =
|
|
37
|
+
| { meta: Record<string, unknown>; createOnce: (context: Context) => VisitorWithHooks }
|
|
38
|
+
| { meta: Record<string, unknown>; create: (context: Context) => VisitorWithHooks };
|
|
39
|
+
|
|
40
|
+
export interface ValidTestCase {
|
|
41
|
+
/** The code that should not trigger any errors */
|
|
42
|
+
code: string;
|
|
43
|
+
/** Optional filename (defaults to test.tsx) */
|
|
44
|
+
filename?: string;
|
|
45
|
+
/** Optional description for the test case */
|
|
46
|
+
description?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface InvalidTestCase {
|
|
50
|
+
/** The code that should trigger errors */
|
|
51
|
+
code: string;
|
|
52
|
+
/** Expected error messages or count */
|
|
53
|
+
errors:
|
|
54
|
+
| number // Just the count of errors
|
|
55
|
+
| string[] // Array of strings that should appear in error messages
|
|
56
|
+
| Array<
|
|
57
|
+
| string // Just the message
|
|
58
|
+
| { message: string; line?: number; column?: number } // Message with optional position
|
|
59
|
+
| { messageId: string; data?: Record<string, string> } // MessageId with data
|
|
60
|
+
>;
|
|
61
|
+
/** Optional filename (defaults to test.tsx) */
|
|
62
|
+
filename?: string;
|
|
63
|
+
/** Optional description for the test case */
|
|
64
|
+
description?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface RuleTesterConfig {
|
|
68
|
+
/** Default filename for test cases */
|
|
69
|
+
filename?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface ReportedError {
|
|
73
|
+
node: Ranged;
|
|
74
|
+
messageId?: string;
|
|
75
|
+
message?: string;
|
|
76
|
+
data?: Record<string, string>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* RuleTester - Test utility for oxlint rules
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* import { RuleTester } from './rule-tester';
|
|
85
|
+
* import myRule from './my-rule';
|
|
86
|
+
*
|
|
87
|
+
* const ruleTester = new RuleTester();
|
|
88
|
+
*
|
|
89
|
+
* ruleTester.run('my-rule', myRule, {
|
|
90
|
+
* valid: [
|
|
91
|
+
* 'const x = 1;',
|
|
92
|
+
* { code: 'const y = 2;', filename: 'test.ts' }
|
|
93
|
+
* ],
|
|
94
|
+
* invalid: [
|
|
95
|
+
* {
|
|
96
|
+
* code: 'var x = 1;',
|
|
97
|
+
* errors: ['Use const instead of var']
|
|
98
|
+
* }
|
|
99
|
+
* ]
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export class RuleTester {
|
|
104
|
+
private config: RuleTesterConfig;
|
|
105
|
+
|
|
106
|
+
constructor(config: RuleTesterConfig = {}) {
|
|
107
|
+
this.config = {
|
|
108
|
+
filename: "test.tsx",
|
|
109
|
+
...config
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
run(
|
|
114
|
+
ruleName: string,
|
|
115
|
+
rule: Rule,
|
|
116
|
+
tests: {
|
|
117
|
+
valid?: Array<string | ValidTestCase>;
|
|
118
|
+
invalid?: Array<string | InvalidTestCase>;
|
|
119
|
+
}
|
|
120
|
+
) {
|
|
121
|
+
describe(ruleName, () => {
|
|
122
|
+
const valid = tests.valid || [];
|
|
123
|
+
const invalid = tests.invalid || [];
|
|
124
|
+
|
|
125
|
+
if (valid.length > 0) {
|
|
126
|
+
describe("valid", () => {
|
|
127
|
+
for (const testCase of valid) {
|
|
128
|
+
const normalized = this.normalizeValidTestCase(testCase);
|
|
129
|
+
const title = normalized.description || normalized.code.trim().slice(0, 50);
|
|
130
|
+
|
|
131
|
+
it(title, () => {
|
|
132
|
+
const errors = this.runRule(rule, normalized.code, normalized.filename);
|
|
133
|
+
expect(errors).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (invalid.length > 0) {
|
|
140
|
+
describe("invalid", () => {
|
|
141
|
+
for (const testCase of invalid) {
|
|
142
|
+
const normalized: InvalidTestCase =
|
|
143
|
+
typeof testCase === "string" ? { code: testCase, errors: 1 } : testCase;
|
|
144
|
+
|
|
145
|
+
const title = normalized.description || normalized.code.trim().slice(0, 50);
|
|
146
|
+
|
|
147
|
+
it(title, () => {
|
|
148
|
+
const errors = this.runRule(rule, normalized.code, normalized.filename);
|
|
149
|
+
|
|
150
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
151
|
+
|
|
152
|
+
if (typeof normalized.errors === "number") {
|
|
153
|
+
expect(errors).toHaveLength(normalized.errors);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// If errors is a string array, check each string appears in error messages
|
|
158
|
+
if (
|
|
159
|
+
Array.isArray(normalized.errors) &&
|
|
160
|
+
normalized.errors.every((e) => typeof e === "string")
|
|
161
|
+
) {
|
|
162
|
+
this.validateStringArrayErrors(errors, normalized.errors as string[]);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
expect(errors).toHaveLength(normalized.errors.length);
|
|
167
|
+
|
|
168
|
+
for (let i = 0; i < normalized.errors.length; i++) {
|
|
169
|
+
const expectedError = normalized.errors[i];
|
|
170
|
+
const actualError = errors[i];
|
|
171
|
+
|
|
172
|
+
if (typeof expectedError === "string") {
|
|
173
|
+
const actualMessage = this.formatErrorMessage(actualError);
|
|
174
|
+
expect(actualMessage).toContain(expectedError);
|
|
175
|
+
} else if ("message" in expectedError) {
|
|
176
|
+
const actualMessage = this.formatErrorMessage(actualError);
|
|
177
|
+
expect(actualMessage).toContain(expectedError.message);
|
|
178
|
+
} else if ("messageId" in expectedError) {
|
|
179
|
+
expect(actualError.messageId).toBe(expectedError.messageId);
|
|
180
|
+
|
|
181
|
+
if (expectedError.data) {
|
|
182
|
+
expect(actualError.data).toMatchObject(expectedError.data);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private runRule(rule: Rule, code: string, filename?: string): ReportedError[] {
|
|
194
|
+
const errors: ReportedError[] = [];
|
|
195
|
+
const actualFilename = filename || this.config.filename!;
|
|
196
|
+
|
|
197
|
+
const ext = actualFilename.split(".").pop() || "tsx";
|
|
198
|
+
const lang = (["js", "jsx", "ts", "tsx", "dts"].includes(ext) ? ext : "tsx") as
|
|
199
|
+
| "js"
|
|
200
|
+
| "jsx"
|
|
201
|
+
| "ts"
|
|
202
|
+
| "tsx"
|
|
203
|
+
| "dts";
|
|
204
|
+
|
|
205
|
+
const parseResult = parseSync(actualFilename, code, { lang });
|
|
206
|
+
|
|
207
|
+
if (parseResult.errors.length > 0) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Parse error in test code:\n${parseResult.errors.map((e) => e.message).join("\n")}`
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const context: Context = this.createMockContext(code, actualFilename, errors);
|
|
214
|
+
|
|
215
|
+
const ruleInstance: VisitorWithHooks =
|
|
216
|
+
"createOnce" in rule ? rule.createOnce(context) : rule.create(context);
|
|
217
|
+
|
|
218
|
+
if (ruleInstance.before) {
|
|
219
|
+
const result = ruleInstance.before();
|
|
220
|
+
if (result === false) {
|
|
221
|
+
return errors;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const visitEnter: WalkerEnter = function (node) {
|
|
226
|
+
const visitor = ruleInstance[node.type as keyof VisitorWithHooks];
|
|
227
|
+
if (typeof visitor === "function") {
|
|
228
|
+
(visitor as (node: Node) => void)(node as unknown as Node);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
walk(parseResult.program, {
|
|
233
|
+
enter: visitEnter,
|
|
234
|
+
leave(node) {
|
|
235
|
+
const exitKey = `${node.type}:exit`;
|
|
236
|
+
const exitVisitor = ruleInstance[exitKey];
|
|
237
|
+
if (typeof exitVisitor === "function") {
|
|
238
|
+
(exitVisitor as (node: Node) => void)(node as unknown as Node);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (ruleInstance.after) {
|
|
244
|
+
ruleInstance.after();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return errors;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private createMockContext(
|
|
251
|
+
code: string,
|
|
252
|
+
filename: string,
|
|
253
|
+
errors: ReportedError[]
|
|
254
|
+
): Context {
|
|
255
|
+
return {
|
|
256
|
+
filename,
|
|
257
|
+
report(descriptor: {
|
|
258
|
+
node: Ranged;
|
|
259
|
+
messageId?: string;
|
|
260
|
+
message?: string;
|
|
261
|
+
data?: Record<string, string>;
|
|
262
|
+
}) {
|
|
263
|
+
errors.push({
|
|
264
|
+
node: descriptor.node,
|
|
265
|
+
messageId: descriptor.messageId,
|
|
266
|
+
message: descriptor.message,
|
|
267
|
+
data: descriptor.data
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private normalizeValidTestCase(testCase: string | ValidTestCase): ValidTestCase {
|
|
274
|
+
if (typeof testCase === "string") {
|
|
275
|
+
return { code: testCase };
|
|
276
|
+
}
|
|
277
|
+
return testCase;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private formatErrorMessage(error: ReportedError): string {
|
|
281
|
+
if (error.message) {
|
|
282
|
+
return error.message;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Include both messageId and data values for matching
|
|
286
|
+
const parts: string[] = [error.messageId || ""];
|
|
287
|
+
|
|
288
|
+
if (error.data) {
|
|
289
|
+
parts.push(...Object.values(error.data));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return parts.join(" ");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private validateStringArrayErrors(errors: ReportedError[], expected: string[]): void {
|
|
296
|
+
expect(errors).toHaveLength(expected.length);
|
|
297
|
+
for (let i = 0; i < expected.length; i++) {
|
|
298
|
+
const expectedFragment = expected[i];
|
|
299
|
+
const actualMessage = this.formatErrorMessage(errors[i]);
|
|
300
|
+
expect(actualMessage).toContain(expectedFragment);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Creates explicit test helpers for better DX
|
|
307
|
+
* Inspired by eslint-vitest-rule-tester but with our intuitive error format
|
|
308
|
+
*/
|
|
309
|
+
export function createRuleTester(options: {
|
|
310
|
+
name: string;
|
|
311
|
+
rule: Rule;
|
|
312
|
+
filename?: string;
|
|
313
|
+
}) {
|
|
314
|
+
const tester = new RuleTester({
|
|
315
|
+
filename: options.filename || "test.tsx"
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Test a valid code case
|
|
320
|
+
*/
|
|
321
|
+
function valid(code: string | ValidTestCase) {
|
|
322
|
+
const normalized = typeof code === "string" ? { code } : code;
|
|
323
|
+
const errors = tester["runRule"](options.rule, normalized.code, normalized.filename);
|
|
324
|
+
expect(errors).toHaveLength(0);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Test an invalid code case
|
|
329
|
+
* Returns errors for further assertions (e.g., snapshots)
|
|
330
|
+
*/
|
|
331
|
+
function invalid(
|
|
332
|
+
testCase:
|
|
333
|
+
| string
|
|
334
|
+
| {
|
|
335
|
+
code: string;
|
|
336
|
+
errors:
|
|
337
|
+
| number
|
|
338
|
+
| string[]
|
|
339
|
+
| Array<
|
|
340
|
+
| string
|
|
341
|
+
| { message: string; line?: number; column?: number }
|
|
342
|
+
| { messageId: string; data?: Record<string, string> }
|
|
343
|
+
>;
|
|
344
|
+
filename?: string;
|
|
345
|
+
output?: string;
|
|
346
|
+
}
|
|
347
|
+
) {
|
|
348
|
+
const normalized: InvalidTestCase =
|
|
349
|
+
typeof testCase === "string" ? { code: testCase, errors: 1 } : testCase;
|
|
350
|
+
|
|
351
|
+
const errors = tester["runRule"](options.rule, normalized.code, normalized.filename);
|
|
352
|
+
|
|
353
|
+
expect(errors.length).toBeGreaterThan(0);
|
|
354
|
+
|
|
355
|
+
// If errors is just a number, only check the count
|
|
356
|
+
if (typeof normalized.errors === "number") {
|
|
357
|
+
expect(errors).toHaveLength(normalized.errors);
|
|
358
|
+
return { errors };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// If errors is a string array, check each string appears in error messages
|
|
362
|
+
if (
|
|
363
|
+
Array.isArray(normalized.errors) &&
|
|
364
|
+
normalized.errors.every((e) => typeof e === "string")
|
|
365
|
+
) {
|
|
366
|
+
tester["validateStringArrayErrors"](errors, normalized.errors as string[]);
|
|
367
|
+
return { errors };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
expect(errors).toHaveLength(normalized.errors.length);
|
|
371
|
+
|
|
372
|
+
for (let i = 0; i < normalized.errors.length; i++) {
|
|
373
|
+
const expectedError = normalized.errors[i];
|
|
374
|
+
const actualError = errors[i];
|
|
375
|
+
|
|
376
|
+
if (typeof expectedError === "string") {
|
|
377
|
+
const actualMessage = tester["formatErrorMessage"](actualError);
|
|
378
|
+
expect(actualMessage).toContain(expectedError);
|
|
379
|
+
} else if ("message" in expectedError) {
|
|
380
|
+
const actualMessage = tester["formatErrorMessage"](actualError);
|
|
381
|
+
expect(actualMessage).toContain(expectedError.message);
|
|
382
|
+
} else if ("messageId" in expectedError) {
|
|
383
|
+
expect(actualError.messageId).toBe(expectedError.messageId);
|
|
384
|
+
|
|
385
|
+
if (expectedError.data) {
|
|
386
|
+
expect(actualError.data).toMatchObject(expectedError.data);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return { errors };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { valid, invalid };
|
|
395
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qds.dev/tools",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Tools and utilities for Qwik Design System",
|
|
6
6
|
"repository": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
"url": "https://github.com/kunai-consulting/qwik-design-system"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
-
"lib"
|
|
11
|
+
"lib",
|
|
12
|
+
"linter"
|
|
12
13
|
],
|
|
13
14
|
"type": "module",
|
|
14
15
|
"sideEffects": false,
|
|
@@ -45,12 +46,12 @@
|
|
|
45
46
|
"dependencies": {
|
|
46
47
|
"@iconify/json": "^2.2.382",
|
|
47
48
|
"@iconify/utils": "^3.0.1",
|
|
48
|
-
"@oxc-project/types": "^0.
|
|
49
|
+
"@oxc-project/types": "^0.115.0",
|
|
49
50
|
"magic-regexp": "^0.10.0",
|
|
50
|
-
"oxc-minify": "^0.
|
|
51
|
-
"oxc-parser": "^0.
|
|
52
|
-
"oxc-transform": "^0.
|
|
53
|
-
"oxc-walker": "^0.
|
|
51
|
+
"oxc-minify": "^0.115.0",
|
|
52
|
+
"oxc-parser": "^0.115.0",
|
|
53
|
+
"oxc-transform": "^0.115.0",
|
|
54
|
+
"oxc-walker": "^0.7.0",
|
|
54
55
|
"remark": "^15.0.1",
|
|
55
56
|
"remark-mdx": "^3.1.1"
|
|
56
57
|
},
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
import { CollectionLoader } from "../utils/icons/collections/loader.qwik.mjs";
|
|
2
|
-
import { resolveImportAliases } from "../utils/icons/import-resolver.qwik.mjs";
|
|
3
|
-
import { transformMDXFile } from "../utils/icons/transform/mdx.qwik.mjs";
|
|
4
|
-
import { transformTSXFile } from "../utils/icons/transform/tsx.qwik.mjs";
|
|
5
|
-
import { anyOf, createRegExp, exactly } from "magic-regexp";
|
|
6
|
-
import { parseSync } from "oxc-parser";
|
|
7
|
-
|
|
8
|
-
//#region rolldown/icons.ts
|
|
9
|
-
/**
|
|
10
|
-
* Rolldown plugin that transforms icon JSX elements to direct <svg /> calls
|
|
11
|
-
* @param options - Plugin configuration options
|
|
12
|
-
* @returns Rolldown-compatible plugin object
|
|
13
|
-
*/ const icons = (options = {}) => {
|
|
14
|
-
const importSources = options.importSources ?? ["@qds.dev/ui"];
|
|
15
|
-
const isDebugMode = !!options.debug;
|
|
16
|
-
const collectionNames = /* @__PURE__ */ new Map();
|
|
17
|
-
const debug = (message, ...data) => {
|
|
18
|
-
if (!isDebugMode) return;
|
|
19
|
-
console.log(`[icons] ${message}`, ...data);
|
|
20
|
-
};
|
|
21
|
-
const collectionLoader = new CollectionLoader(debug);
|
|
22
|
-
/**
|
|
23
|
-
* Parse and validate a file, returning the AST if valid
|
|
24
|
-
* @param code - Source code to parse
|
|
25
|
-
* @param id - File ID for debugging
|
|
26
|
-
* @returns AST program if valid, null if invalid
|
|
27
|
-
*/ function parseAndValidateFile(code, id) {
|
|
28
|
-
try {
|
|
29
|
-
const parsed = parseSync(id, code);
|
|
30
|
-
if (parsed.errors.length > 0) {
|
|
31
|
-
debug(`Parse errors in ${id}:`, parsed.errors.map((e) => e.message));
|
|
32
|
-
return null;
|
|
33
|
-
}
|
|
34
|
-
return parsed.program;
|
|
35
|
-
} catch (error) {
|
|
36
|
-
debug(`Error parsing ${id}:`, error);
|
|
37
|
-
return null;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
const isTSXJSXOrMDX = createRegExp(exactly(".").and(anyOf("tsx", "jsx", "mdx")).at.lineEnd());
|
|
41
|
-
const isVirtualIconsImport = createRegExp(exactly("virtual:icons/").at.lineStart());
|
|
42
|
-
const isVirtualIconsModule = createRegExp(exactly(String.fromCharCode(0)).and("virtual:icons/"));
|
|
43
|
-
return {
|
|
44
|
-
name: "vite-plugin-qds-icons",
|
|
45
|
-
enforce: "pre",
|
|
46
|
-
transform: {
|
|
47
|
-
filter: { id: isTSXJSXOrMDX },
|
|
48
|
-
handler(code, id) {
|
|
49
|
-
if (collectionLoader.getAvailableCollections().size === 0) collectionLoader.discoverCollections();
|
|
50
|
-
if (id.endsWith(".mdx")) return transformMDXFile(code, id, importSources, collectionLoader.getAvailableCollections(), collectionNames, options.packs, debug);
|
|
51
|
-
debug(`[TRANSFORM] Starting transformation for ${id}`);
|
|
52
|
-
try {
|
|
53
|
-
const ast = parseAndValidateFile(code, id);
|
|
54
|
-
if (!ast) {
|
|
55
|
-
debug(`[TRANSFORM] Failed to parse ${id}`);
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
const aliasToPack = resolveImportAliases(ast, importSources, collectionLoader.getAvailableCollections(), collectionNames, options.packs, debug);
|
|
59
|
-
if (aliasToPack.size === 0) {
|
|
60
|
-
debug(`[TRANSFORM] No icon imports found in ${id}`);
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
return transformTSXFile(code, id, ast, aliasToPack, collectionNames, collectionLoader.getAvailableCollections(), options.packs, debug);
|
|
64
|
-
} catch (error) {
|
|
65
|
-
debug(`[TRANSFORM] Error during transformation of ${id}:`, error);
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
},
|
|
70
|
-
resolveId: {
|
|
71
|
-
filter: { id: isVirtualIconsImport },
|
|
72
|
-
handler(source) {
|
|
73
|
-
if (source.startsWith("virtual:icons/")) return `\0${source}`;
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
load: {
|
|
78
|
-
filter: { id: isVirtualIconsModule },
|
|
79
|
-
async handler(id) {
|
|
80
|
-
const virtualPath = id.slice(1);
|
|
81
|
-
const parts = virtualPath.split("/");
|
|
82
|
-
const prefix = parts[1];
|
|
83
|
-
const name = parts[2];
|
|
84
|
-
if (!prefix || !name) {
|
|
85
|
-
debug(`Invalid virtual icon path: ${virtualPath}`);
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
try {
|
|
89
|
-
const iconData = name.includes("-") ? await collectionLoader.loadIconDataLazy(prefix, name) : await collectionLoader.loadIconDataByLowercase(prefix, name);
|
|
90
|
-
if (!iconData) {
|
|
91
|
-
debug(`Failed to load icon data for ${prefix}:${name}`);
|
|
92
|
-
return { code: `export default '<path d="M12 2L2 7l10 5 10-5z"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5"/>';\n` };
|
|
93
|
-
}
|
|
94
|
-
const code = `export default \`${iconData.body}\`;`;
|
|
95
|
-
debug(`Generated virtual module for ${prefix}:${name}`);
|
|
96
|
-
return { code };
|
|
97
|
-
} catch (error) {
|
|
98
|
-
debug(`Error loading virtual module ${virtualPath}:`, error);
|
|
99
|
-
return { code: `export default '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>';\n` };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
};
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
//#endregion
|
|
107
|
-
export { icons };
|