Skip to content

Commit 0f2e9c7

Browse files
feat: as child type imports (#342)
* 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 * feat: handle imports * fix: format
1 parent 522b904 commit 0f2e9c7

File tree

5 files changed

+304
-121
lines changed

5 files changed

+304
-121
lines changed

libs/tools/utils/ast/imports.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ImportDeclaration, Node } from "@oxc-project/types";
2+
import type MagicString from "magic-string";
23
import type { parseSync } from "oxc-parser";
34

45
export function getImportSource(importNode: ImportDeclaration, source: string): string {
@@ -21,3 +22,69 @@ export function findImportBySource(
2122
}
2223
return null;
2324
}
25+
26+
/**
27+
* Checks if a type import specifier exists in an import declaration
28+
*/
29+
export function hasImportSpecifier(
30+
importDecl: ImportDeclaration,
31+
specifierName: string
32+
): boolean {
33+
if (!importDecl.specifiers) return false;
34+
35+
return importDecl.specifiers.some((specifier) => {
36+
if (specifier.type === "ImportSpecifier" && "imported" in specifier) {
37+
const imported = specifier.imported;
38+
if (imported && "name" in imported) {
39+
return imported.name === specifierName;
40+
}
41+
}
42+
return false;
43+
});
44+
}
45+
46+
/**
47+
* Injects a type import specifier into an existing import or creates a new import statement
48+
*/
49+
export function injectTypeImport(options: {
50+
ast: ReturnType<typeof parseSync>;
51+
magicString: MagicString;
52+
importSource: string;
53+
specifierName: string;
54+
existingImportNode?: Node | null;
55+
}): void {
56+
const {
57+
ast,
58+
magicString: s,
59+
importSource,
60+
specifierName,
61+
existingImportNode = null
62+
} = options;
63+
64+
if (existingImportNode) {
65+
const importDecl = existingImportNode as ImportDeclaration;
66+
67+
if (importDecl.specifiers) {
68+
// Check if the specifier already exists
69+
if (hasImportSpecifier(importDecl, specifierName)) {
70+
return;
71+
}
72+
73+
const lastSpecifier = importDecl.specifiers[importDecl.specifiers.length - 1];
74+
s.appendLeft(lastSpecifier.end, `, type ${specifierName}`);
75+
}
76+
return;
77+
}
78+
79+
const firstImport = ast.program.body.find(
80+
(node: Node) => node.type === "ImportDeclaration"
81+
);
82+
83+
const importStatement = `import type { ${specifierName} } from "${importSource}";\n`;
84+
85+
if (firstImport) {
86+
s.appendLeft(firstImport.start, importStatement);
87+
} else {
88+
s.prepend(importStatement);
89+
}
90+
}

libs/tools/utils/ast/qwik.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { Node } from "@oxc-project/types";
2+
import { parseSync } from "oxc-parser";
3+
import { walk } from "oxc-walker";
4+
import { isCallExpressionWithName } from "./core.ts";
5+
import { isJSXElementWithName } from "./jsx-helpers.ts";
6+
7+
/**
8+
* Checks if a node is a Render JSX element
9+
*/
10+
export function isRenderElement(node: Node, code: string): boolean {
11+
return isJSXElementWithName(node, code, "Render");
12+
}
13+
14+
/**
15+
* Checks if a callback function returns a Render component
16+
*/
17+
export function returnsRenderComponent(callback: Node, code: string): boolean {
18+
let hasRenderReturn = false;
19+
20+
walk(callback, {
21+
enter(node: Node) {
22+
if (node.type === "ReturnStatement" && "argument" in node && node.argument) {
23+
if (isRenderElement(node.argument, code)) {
24+
hasRenderReturn = true;
25+
return;
26+
}
27+
}
28+
29+
if (isRenderElement(node, code)) {
30+
hasRenderReturn = true;
31+
}
32+
}
33+
});
34+
35+
return hasRenderReturn;
36+
}
37+
38+
/**
39+
* Detects if the source code uses the Render component pattern within a component$
40+
*/
41+
export function detectsRenderComponentUsage(sourceCode: string): boolean {
42+
try {
43+
const ast = parseSync("temp.tsx", sourceCode);
44+
let hasRenderComponent = false;
45+
46+
walk(ast.program, {
47+
enter(node: Node) {
48+
if (!isCallExpressionWithName(node, sourceCode, "component$")) return;
49+
if (!("arguments" in node)) return;
50+
51+
const callback = node.arguments[0];
52+
if (!callback) return;
53+
if (
54+
callback.type !== "ArrowFunctionExpression" &&
55+
callback.type !== "FunctionExpression"
56+
) {
57+
return;
58+
}
59+
60+
if (returnsRenderComponent(callback, sourceCode)) {
61+
hasRenderComponent = true;
62+
}
63+
}
64+
});
65+
66+
return hasRenderComponent;
67+
} catch {
68+
return false;
69+
}
70+
}

libs/tools/utils/fs.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { readdir } from "node:fs/promises";
2+
import { join } from "node:path";
3+
4+
/**
5+
* Recursively walks a directory and yields all files with the specified extension
6+
*/
7+
export async function* walkFiles(dir: string, extension: string): AsyncGenerator<string> {
8+
const entries = await readdir(dir, { withFileTypes: true });
9+
10+
for (const entry of entries) {
11+
const path = join(dir, entry.name);
12+
if (entry.isDirectory()) {
13+
yield* walkFiles(path, extension);
14+
continue;
15+
}
16+
if (entry.isFile() && entry.name.endsWith(extension)) {
17+
yield path;
18+
}
19+
}
20+
}

libs/tools/utils/transform-dts.ts

Lines changed: 48 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -1,84 +1,17 @@
11
#!/usr/bin/env node
2-
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
2+
import { readFile, stat, writeFile } from "node:fs/promises";
33
import { join, relative } from "node:path";
44
import type { ImportDeclaration, Node } from "@oxc-project/types";
55
import MagicString from "magic-string";
66
import { parseSync } from "oxc-parser";
77
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-
}
8+
import {
9+
findImportBySource,
10+
hasImportSpecifier,
11+
injectTypeImport
12+
} from "./ast/imports.ts";
13+
import { detectsRenderComponentUsage } from "./ast/qwik.ts";
14+
import { walkFiles } from "./fs.ts";
8215

8316
function injectAsChildTypesIntoComponent(
8417
node: Node,
@@ -127,42 +60,42 @@ function injectAsChildTypesIntoComponent(
12760
return hasChanges;
12861
}
12962

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-
}
63+
/**
64+
* Injects AsChildTypes import into the declaration file
65+
*/
66+
function injectAsChildTypesImport(options: {
67+
ast: ReturnType<typeof parseSync>;
68+
magicString: MagicString;
69+
toolsImportNode: Node | null;
70+
}): void {
71+
injectTypeImport({
72+
ast: options.ast,
73+
magicString: options.magicString,
74+
importSource: "@qds.dev/tools",
75+
specifierName: "AsChildTypes",
76+
existingImportNode: options.toolsImportNode
77+
});
16178
}
16279

16380
async function transformTypeFile(dtsPath: string, sourcePath: string): Promise<boolean> {
16481
const content = await readFile(dtsPath, "utf-8");
165-
if (content.includes("AsChildTypes")) return false;
82+
const hasAsChildTypesUsage = content.includes("AsChildTypes");
83+
84+
// Quick check if AsChildTypes is already used in the file
85+
if (hasAsChildTypesUsage) {
86+
try {
87+
const ast = parseSync(dtsPath, content);
88+
const toolsImportNode = findImportBySource(ast, content, "@qds.dev/tools");
89+
90+
if (toolsImportNode) {
91+
const importDecl = toolsImportNode as ImportDeclaration;
92+
// If both import and usage exist, nothing to do
93+
if (hasImportSpecifier(importDecl, "AsChildTypes")) return false;
94+
}
95+
} catch {
96+
return false;
97+
}
98+
}
16699

167100
try {
168101
const sourceCode = await readFile(sourcePath, "utf-8");
@@ -184,10 +117,14 @@ async function transformTypeFile(dtsPath: string, sourcePath: string): Promise<b
184117
}
185118
});
186119

187-
if (!hasChanges) return false;
120+
if (!hasChanges && !hasAsChildTypesUsage) return false;
188121

189-
const toolsImportNode = findToolsImport(ast, content);
190-
injectAsChildTypesImport(ast, content, s, toolsImportNode);
122+
const toolsImportNode = findImportBySource(ast, content, "@qds.dev/tools");
123+
injectAsChildTypesImport({
124+
ast,
125+
magicString: s,
126+
toolsImportNode
127+
});
191128

192129
await writeFile(dtsPath, s.toString(), "utf-8");
193130
return true;
@@ -236,13 +173,4 @@ if (import.meta.url === `file://${process.argv[1]}`) {
236173
});
237174
}
238175

239-
export {
240-
detectsRenderComponentUsage,
241-
findToolsImport,
242-
injectAsChildTypesImport,
243-
injectAsChildTypesIntoComponent,
244-
isRenderElement,
245-
returnsRenderComponent,
246-
transformTypeFile,
247-
walkFiles
248-
};
176+
export { injectAsChildTypesImport, injectAsChildTypesIntoComponent, transformTypeFile };

0 commit comments

Comments
 (0)