Skip to content

Commit 522b904

Browse files
v2: automatic asChild type injection (#341)
* remove unused axe dep * feat: automatic as-child type transform * feat: automatic as-child type transform * refactor: transform dts file * refactor: reusable utilities * fix: file extension for node runner * fix: update import statement to include file extension * fix: maieul feedback on dts transform * refactor: return render test is irrelevant right now
1 parent 484a0a1 commit 522b904

File tree

8 files changed

+714
-15
lines changed

8 files changed

+714
-15
lines changed

libs/components/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@
2323
"scripts": {
2424
"build": "pnpm run build.lib & pnpm run build.types",
2525
"build.lib": "rolldown -c rolldown.config.ts",
26-
"build.types": "tsc --emitDeclarationOnly --outDir ./lib-types",
26+
"build.types": "tsc --emitDeclarationOnly --outDir ./lib-types && pnpm run transform.types",
27+
"transform.types": "node ../tools/utils/transform-dts.ts ./src ./lib-types/src",
2728
"generate.styles": "node styles/tailwind/generate.ts"
2829
},
2930
"devDependencies": {
30-
"@axe-core/playwright": "^4.9.1",
3131
"@fluejs/noscroll": "^1.0.0",
3232
"@qds.dev/utils": "workspace:*",
3333
"@qwik.dev/core": "https://pkg.pr.new/QwikDev/qwik/@qwik.dev/core@d48c3d2",

libs/tools/utils/ast/core.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import type { Node } from "@oxc-project/types";
2+
3+
export function getNodeText(node: Node, source: string): string {
4+
return source.slice(node.start, node.end);
5+
}
6+
7+
export function isCallExpressionWithName(
8+
node: Node,
9+
source: string,
10+
name: string
11+
): boolean {
12+
if (node.type !== "CallExpression") return false;
13+
if (!("callee" in node)) return false;
14+
if (node.callee?.type !== "Identifier") return false;
15+
16+
const calleeName = getNodeText(node.callee, source);
17+
return calleeName === name;
18+
}
19+
20+
export function isIdentifierWithName(node: Node, source: string, name: string): boolean {
21+
if (node.type !== "Identifier") return false;
22+
23+
const identifierName = getNodeText(node, source);
24+
return identifierName === name;
25+
}

libs/tools/utils/ast/imports.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { ImportDeclaration, Node } from "@oxc-project/types";
2+
import type { parseSync } from "oxc-parser";
3+
4+
export function getImportSource(importNode: ImportDeclaration, source: string): string {
5+
return source.slice(importNode.source.start + 1, importNode.source.end - 1);
6+
}
7+
8+
export function findImportBySource(
9+
ast: ReturnType<typeof parseSync>,
10+
source: string,
11+
importSource: string
12+
): Node | null {
13+
for (const node of ast.program.body) {
14+
if (node.type !== "ImportDeclaration") continue;
15+
if (!("source" in node)) continue;
16+
17+
const nodeSource = getImportSource(node as ImportDeclaration, source);
18+
if (nodeSource === importSource) {
19+
return node;
20+
}
21+
}
22+
return null;
23+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { Node } from "@oxc-project/types";
2+
import { getNodeText } from "./core.ts";
3+
4+
export function getJSXElementName(node: Node, source: string): string | null {
5+
if (node.type !== "JSXElement") return null;
6+
if (!("openingElement" in node)) return null;
7+
if (node.openingElement.name.type !== "JSXIdentifier") return null;
8+
9+
return getNodeText(node.openingElement.name, source);
10+
}
11+
12+
export function isJSXElementWithName(node: Node, source: string, name: string): boolean {
13+
return getJSXElementName(node, source) === name;
14+
}

libs/tools/utils/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
// AST utilities
2+
export * from "./ast/core";
3+
export * from "./ast/imports";
4+
export * from "./ast/jsx-helpers";
5+
16
// Icon utilities
27
export * from "./icons/ast/expressions";
38
export * from "./icons/ast/jsx";
@@ -10,3 +15,4 @@ export * from "./icons/transform/tsx";
1015

1116
// Build utilities
1217
export * from "./package-json";
18+
export * from "./transform-dts";

libs/tools/utils/transform-dts.ts

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
#!/usr/bin/env node
2+
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
3+
import { join, relative } from "node:path";
4+
import type { ImportDeclaration, Node } from "@oxc-project/types";
5+
import MagicString from "magic-string";
6+
import { parseSync } from "oxc-parser";
7+
import { walk } from "oxc-walker";
8+
import { isCallExpressionWithName } from "./ast/core.ts";
9+
import { findImportBySource } from "./ast/imports.ts";
10+
import { isJSXElementWithName } from "./ast/jsx-helpers.ts";
11+
12+
async function* walkFiles(dir: string, extension: string): AsyncGenerator<string> {
13+
const entries = await readdir(dir, { withFileTypes: true });
14+
15+
for (const entry of entries) {
16+
const path = join(dir, entry.name);
17+
if (entry.isDirectory()) {
18+
yield* walkFiles(path, extension);
19+
continue;
20+
}
21+
if (entry.isFile() && entry.name.endsWith(extension)) {
22+
yield path;
23+
}
24+
}
25+
}
26+
27+
function isRenderElement(node: Node, code: string): boolean {
28+
return isJSXElementWithName(node, code, "Render");
29+
}
30+
31+
function returnsRenderComponent(callback: Node, code: string): boolean {
32+
let hasRenderReturn = false;
33+
34+
walk(callback, {
35+
enter(node: Node) {
36+
if (node.type === "ReturnStatement" && "argument" in node && node.argument) {
37+
if (isRenderElement(node.argument, code)) {
38+
hasRenderReturn = true;
39+
return;
40+
}
41+
}
42+
43+
if (isRenderElement(node, code)) {
44+
hasRenderReturn = true;
45+
}
46+
}
47+
});
48+
49+
return hasRenderReturn;
50+
}
51+
52+
function detectsRenderComponentUsage(sourceCode: string): boolean {
53+
try {
54+
const ast = parseSync("temp.tsx", sourceCode);
55+
let hasRenderComponent = false;
56+
57+
walk(ast.program, {
58+
enter(node: Node) {
59+
if (!isCallExpressionWithName(node, sourceCode, "component$")) return;
60+
if (!("arguments" in node)) return;
61+
62+
const callback = node.arguments[0];
63+
if (!callback) return;
64+
if (
65+
callback.type !== "ArrowFunctionExpression" &&
66+
callback.type !== "FunctionExpression"
67+
) {
68+
return;
69+
}
70+
71+
if (returnsRenderComponent(callback, sourceCode)) {
72+
hasRenderComponent = true;
73+
}
74+
}
75+
});
76+
77+
return hasRenderComponent;
78+
} catch {
79+
return false;
80+
}
81+
}
82+
83+
function injectAsChildTypesIntoComponent(
84+
node: Node,
85+
content: string,
86+
s: MagicString
87+
): boolean {
88+
if (node.type !== "ExportNamedDeclaration") return false;
89+
if (!("declaration" in node) || !node.declaration) return false;
90+
91+
const declaration = node.declaration;
92+
if (declaration.type !== "VariableDeclaration") return false;
93+
if (!("declarations" in declaration)) return false;
94+
95+
let hasChanges = false;
96+
97+
for (const declarator of declaration.declarations) {
98+
if (declarator.type !== "VariableDeclarator") continue;
99+
if (!("id" in declarator) || !declarator.id || declarator.id.type !== "Identifier")
100+
continue;
101+
102+
const id = declarator.id as Node & {
103+
typeAnnotation?: { typeAnnotation: Node };
104+
};
105+
if (!("typeAnnotation" in id) || !id.typeAnnotation) continue;
106+
107+
const typeAnnotation = id.typeAnnotation;
108+
if (!("typeAnnotation" in typeAnnotation)) continue;
109+
110+
const typeNode = typeAnnotation.typeAnnotation;
111+
if (!typeNode) continue;
112+
113+
const typeStr = content.slice(typeNode.start, typeNode.end);
114+
if (!typeStr.includes("Component<")) continue;
115+
116+
const match = typeStr.match(/Component<([^>]+)>/);
117+
if (!match) continue;
118+
119+
const propsType = match[1];
120+
if (propsType.includes("AsChildTypes")) continue;
121+
122+
const componentTypeEnd = typeNode.start + typeStr.lastIndexOf(">");
123+
s.appendLeft(componentTypeEnd, " & AsChildTypes");
124+
hasChanges = true;
125+
}
126+
127+
return hasChanges;
128+
}
129+
130+
function findToolsImport(
131+
ast: ReturnType<typeof parseSync>,
132+
content: string
133+
): Node | null {
134+
return findImportBySource(ast, content, "@qds.dev/tools");
135+
}
136+
137+
function injectAsChildTypesImport(
138+
ast: ReturnType<typeof parseSync>,
139+
content: string,
140+
s: MagicString,
141+
toolsImportNode: Node | null
142+
): void {
143+
if (toolsImportNode) {
144+
const importDecl = toolsImportNode as ImportDeclaration;
145+
if (importDecl.specifiers && importDecl.specifiers.length > 0) {
146+
const lastSpecifier = importDecl.specifiers[importDecl.specifiers.length - 1];
147+
s.appendLeft(lastSpecifier.end, ", type AsChildTypes");
148+
}
149+
return;
150+
}
151+
152+
const firstImport = ast.program.body.find(
153+
(node: Node) => node.type === "ImportDeclaration"
154+
);
155+
if (firstImport) {
156+
s.appendLeft(
157+
firstImport.start,
158+
'import type { AsChildTypes } from "@qds.dev/tools";\n'
159+
);
160+
}
161+
}
162+
163+
async function transformTypeFile(dtsPath: string, sourcePath: string): Promise<boolean> {
164+
const content = await readFile(dtsPath, "utf-8");
165+
if (content.includes("AsChildTypes")) return false;
166+
167+
try {
168+
const sourceCode = await readFile(sourcePath, "utf-8");
169+
if (!detectsRenderComponentUsage(sourceCode)) return false;
170+
} catch {
171+
return false;
172+
}
173+
174+
try {
175+
const ast = parseSync(dtsPath, content);
176+
const s = new MagicString(content);
177+
let hasChanges = false;
178+
179+
walk(ast.program, {
180+
enter(node: Node) {
181+
if (injectAsChildTypesIntoComponent(node, content, s)) {
182+
hasChanges = true;
183+
}
184+
}
185+
});
186+
187+
if (!hasChanges) return false;
188+
189+
const toolsImportNode = findToolsImport(ast, content);
190+
injectAsChildTypesImport(ast, content, s, toolsImportNode);
191+
192+
await writeFile(dtsPath, s.toString(), "utf-8");
193+
return true;
194+
} catch (error) {
195+
console.error(`Error processing ${dtsPath}:`, error);
196+
return false;
197+
}
198+
}
199+
200+
async function main() {
201+
const sourceDir = process.argv[2] || "./src";
202+
const declDir = process.argv[3] || "./lib-types";
203+
204+
console.log(`🔍 Scanning ${sourceDir} for source files...`);
205+
206+
let processedCount = 0;
207+
let changedCount = 0;
208+
209+
for await (const sourcePath of walkFiles(sourceDir, ".tsx")) {
210+
const relativePath = relative(sourceDir, sourcePath);
211+
const dtsPath = join(declDir, relativePath.replace(/\.tsx$/, ".d.ts"));
212+
213+
try {
214+
await stat(dtsPath);
215+
} catch {
216+
continue;
217+
}
218+
219+
processedCount++;
220+
const changed = await transformTypeFile(dtsPath, sourcePath);
221+
if (changed) {
222+
changedCount++;
223+
console.log(`✓ Transformed ${dtsPath}`);
224+
}
225+
}
226+
227+
console.log(
228+
`\n✨ Processed ${processedCount} files, transformed ${changedCount} files`
229+
);
230+
}
231+
232+
if (import.meta.url === `file://${process.argv[1]}`) {
233+
main().catch((error) => {
234+
console.error("Error:", error);
235+
process.exit(1);
236+
});
237+
}
238+
239+
export {
240+
detectsRenderComponentUsage,
241+
findToolsImport,
242+
injectAsChildTypesImport,
243+
injectAsChildTypesIntoComponent,
244+
isRenderElement,
245+
returnsRenderComponent,
246+
transformTypeFile,
247+
walkFiles
248+
};

0 commit comments

Comments
 (0)