Skip to content

Commit ff5471e

Browse files
Shrink bundle size (#1653)
* shrink bundle size: - use `oxc-parser` for AST parsing - remove previous AST related packages - remove unused packages - update some packages * add tests for `compound-components` transformer * use oxc-parser types * infer types * move `removeReactImport` to it's own file, add tests * dedupe tests * enhance `addToConfig` to inline/multiline detection and indentation * infer types
1 parent e4e2b06 commit ff5471e

16 files changed

Lines changed: 2994 additions & 737 deletions

bun.lock

Lines changed: 422 additions & 148 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ui/package.json

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -267,17 +267,17 @@
267267
"@floating-ui/core": "1.7.3",
268268
"@floating-ui/react": "0.27.16",
269269
"@iarna/toml": "2.2.5",
270-
"@typescript-eslint/typescript-estree": "8.46.2",
271270
"chokidar": "4.0.3",
272271
"classnames": "2.5.1",
273272
"comment-json": "4.4.1",
274273
"debounce": "2.2.0",
275274
"deepmerge-ts": "7.1.5",
276275
"klona": "2.0.6",
276+
"magic-string": "0.30.21",
277+
"oxc-parser": "0.112.0",
277278
"package-manager-detector": "1.5.0",
278-
"recast": "0.23.11",
279-
"tailwind-merge-v2": "npm:[email protected]",
280-
"tailwind-merge-v3": "npm:[email protected]"
279+
"tailwind-merge@2": "npm:[email protected]",
280+
"tailwind-merge@3": "npm:[email protected]"
281281
},
282282
"devDependencies": {
283283
"@farmfe/core": "1.7.11",
@@ -295,19 +295,15 @@
295295
"@typescript-eslint/parser": "8.46.2",
296296
"@vitejs/plugin-react": "4.3.4",
297297
"@vitest/coverage-v8": "2.1.8",
298-
"acorn": "8.15.0",
299-
"ast-types": "0.14.2",
300298
"autoprefixer": "10.4.24",
301299
"esbuild": "0.25.11",
302300
"eslint-plugin-react": "7.37.5",
303301
"eslint-plugin-vitest": "0.5.4",
304-
"estree-walker": "3.0.3",
305302
"jsdom": "25.0.1",
306-
"minimatch": "10.0.3",
307303
"next": "16.0.7",
308304
"react-icons": "5.5.0",
309-
"rolldown": "1.0.0-beta.45",
310-
"rollup": "4.52.5",
305+
"rolldown": "1.0.0-rc.3",
306+
"rollup": "4.57.1",
311307
"rollup-plugin-esbuild": "6.2.1",
312308
"rollup-plugin-use-client": "1.4.0",
313309
"typescript": "5.6.3",

packages/ui/rollup.config.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ entries = entries
1212
.sort();
1313

1414
const external = [
15-
"ast-types",
1615
"child_process",
1716
"fs/promises",
1817
"klona/json",

packages/ui/scripts/generate-metadata.test.ts

Lines changed: 151 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,101 @@ describe("extractClassList", () => {
6161
const result = await extractClassList(content);
6262
expect(result).toEqual(["btn-primary", "card-default"]);
6363
});
64+
65+
it("should handle TypeScript type annotations", async () => {
66+
const content = `
67+
const theme: ButtonTheme = createTheme({
68+
base: "flex items-center",
69+
color: {
70+
primary: "bg-blue-500 text-white"
71+
}
72+
});
73+
`;
74+
75+
const result = await extractClassList(content);
76+
expect(result).toEqual(["bg-blue-500", "flex", "items-center", "text-white"]);
77+
});
78+
79+
it("should handle TypeScript as const assertion", async () => {
80+
const content = `
81+
const theme = createTheme({
82+
button: "rounded-lg px-4 py-2"
83+
} as const);
84+
`;
85+
86+
const result = await extractClassList(content);
87+
expect(result).toEqual(["px-4", "py-2", "rounded-lg"]);
88+
});
89+
90+
it("should handle TypeScript satisfies operator", async () => {
91+
const content = `
92+
const theme = createTheme({
93+
card: "shadow-lg border rounded-xl"
94+
}) satisfies CardTheme;
95+
`;
96+
97+
const result = await extractClassList(content);
98+
expect(result).toEqual(["border", "rounded-xl", "shadow-lg"]);
99+
});
100+
101+
it("should handle TypeScript generic type parameter", async () => {
102+
const content = `
103+
const theme = createTheme<ButtonTheme>({
104+
base: "inline-flex justify-center",
105+
size: {
106+
sm: "text-sm px-3",
107+
lg: "text-lg px-6"
108+
}
109+
});
110+
`;
111+
112+
const result = await extractClassList(content);
113+
expect(result).toEqual(["inline-flex", "justify-center", "px-3", "px-6", "text-lg", "text-sm"]);
114+
});
115+
116+
it("should handle nested TypeScript objects with type annotations", async () => {
117+
const content = `
118+
interface Theme {
119+
root: { base: string };
120+
}
121+
122+
const buttonTheme: Theme = createTheme({
123+
root: {
124+
base: "font-medium focus:ring-4"
125+
}
126+
});
127+
`;
128+
129+
const result = await extractClassList(content);
130+
expect(result).toEqual(["focus:ring-4", "font-medium"]);
131+
});
132+
133+
it("should handle export with type annotation", async () => {
134+
const content = `
135+
export const accordionTheme: AccordionTheme = createTheme({
136+
root: "divide-y divide-gray-200",
137+
content: "p-5"
138+
});
139+
`;
140+
141+
const result = await extractClassList(content);
142+
expect(result).toEqual(["divide-gray-200", "divide-y", "p-5"]);
143+
});
64144
});
65145

66146
describe("extractDependencyList", () => {
67147
it("should extract named imports", async () => {
68148
const content = `
69-
import { Button, Card } from '@flowbite/react';
149+
import { Button, Card } from 'flowbite-react';
70150
`;
71151
const result = await extractDependencyList(content);
72152
expect(result).toEqual(["Button", "Card"]);
73153
});
74154

75155
it("should handle multiple import statements", async () => {
76156
const content = `
77-
import { Button } from '@flowbite/react';
78-
import { Modal, Table } from '@flowbite/react';
157+
import { Button } from 'flowbite-react';
158+
import { Modal, Table } from 'flowbite-react';
79159
`;
80160
const result = await extractDependencyList(content);
81161
expect(result).toEqual(["Button", "Modal", "Table"]);
@@ -88,17 +168,83 @@ describe("extractDependencyList", () => {
88168

89169
it("should ignore default imports", async () => {
90170
const content = `
91-
import Default, { Named } from '@flowbite/react';
171+
import Default, { Named } from 'flowbite-react';
92172
`;
93173
const result = await extractDependencyList(content);
94174
expect(result).toEqual(["Named"]);
95175
});
96176

97177
it("should sort imports alphabetically", async () => {
98178
const content = `
99-
import { Zebra, Beta, Alpha } from '@flowbite/react';
179+
import { Zebra, Beta, Alpha } from 'flowbite-react';
100180
`;
101181
const result = await extractDependencyList(content);
102182
expect(result).toEqual(["Alpha", "Beta", "Zebra"]);
103183
});
184+
185+
it("should omit type-only import declarations", async () => {
186+
const content = `
187+
import { Button } from 'flowbite-react';
188+
import type { CardTheme } from 'flowbite-react';
189+
`;
190+
const result = await extractDependencyList(content);
191+
expect(result).toEqual(["Button"]);
192+
});
193+
194+
it("should omit inline type imports", async () => {
195+
const content = `
196+
import { type ModalProps, Modal, type ModalTheme } from 'flowbite-react';
197+
`;
198+
const result = await extractDependencyList(content);
199+
expect(result).toEqual(["Modal"]);
200+
});
201+
202+
it("should omit mixed type imports", async () => {
203+
const content = `
204+
import { Button, type ButtonProps } from 'flowbite-react';
205+
import type { CardTheme } from 'flowbite-react';
206+
`;
207+
const result = await extractDependencyList(content);
208+
expect(result).toEqual(["Button"]);
209+
});
210+
211+
it("should handle aliased imports", async () => {
212+
const content = `
213+
import { Button as FlowbiteButton, Card as FlowbiteCard } from 'flowbite-react';
214+
`;
215+
const result = await extractDependencyList(content);
216+
expect(result).toEqual(["Button", "Card"]);
217+
});
218+
219+
it("should handle mixed default, named, and type imports", async () => {
220+
const content = `
221+
import React, { useState, type FC } from 'react';
222+
import { Accordion, AccordionPanel } from 'flowbite-react';
223+
`;
224+
const result = await extractDependencyList(content);
225+
expect(result).toEqual(["Accordion", "AccordionPanel", "useState"]);
226+
});
227+
228+
it("should handle namespace imports", async () => {
229+
const content = `
230+
import * as Flowbite from 'flowbite-react';
231+
import { Table } from 'flowbite-react';
232+
`;
233+
const result = await extractDependencyList(content);
234+
expect(result).toEqual(["Table"]);
235+
});
236+
237+
it("should handle TSX component file with imports", async () => {
238+
const content = `
239+
import type { FC, ReactNode } from 'react';
240+
import { Button, Card, Modal } from 'flowbite-react';
241+
import { theme } from './theme';
242+
243+
export const MyComponent: FC<{ children: ReactNode }> = ({ children }) => {
244+
return <Card><Button>{children}</Button></Card>;
245+
};
246+
`;
247+
const result = await extractDependencyList(content);
248+
expect(result).toEqual(["Button", "Card", "Modal", "theme"]);
249+
});
104250
});

packages/ui/scripts/generate-metadata.ts

Lines changed: 44 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { parse } from "acorn";
21
import { Glob } from "bun";
3-
import { walk, type Node } from "estree-walker";
2+
import { parseSync, Visitor } from "oxc-parser";
43
import prettier from "prettier";
54

65
const outputDir = "src/metadata";
@@ -86,39 +85,35 @@ async function generateClassList(): Promise<{
8685
*/
8786
export async function extractClassList(content: string): Promise<string[]> {
8887
const classList = new Set<string>();
89-
const transpiler = new Bun.Transpiler({
90-
loader: "ts",
91-
});
92-
const transpiledContent = transpiler.transformSync(content);
93-
const ast = parse(transpiledContent, {
94-
ecmaVersion: "latest",
95-
sourceType: "module",
96-
});
88+
const result = parseSync("theme.ts", content);
89+
90+
if (result.errors.length > 0) {
91+
return [];
92+
}
93+
9794
let isInsideCreateTheme = false;
9895

99-
walk(ast as Node, {
100-
enter(node) {
101-
if (isCreateThemeNode(node)) {
96+
const visitor = new Visitor({
97+
CallExpression(node) {
98+
if (node.callee.type === "Identifier" && node.callee.name === "createTheme") {
10299
isInsideCreateTheme = true;
103100
}
104-
if (isInsideCreateTheme) {
105-
if (node.type === "Literal" && typeof node.value === "string") {
106-
for (const className of node.value.split(/\s+/)) {
107-
if (className) classList.add(className);
108-
}
109-
}
110-
}
111101
},
112-
leave(node) {
113-
if (isCreateThemeNode(node)) {
102+
"CallExpression:exit"(node) {
103+
if (node.callee.type === "Identifier" && node.callee.name === "createTheme") {
114104
isInsideCreateTheme = false;
115105
}
116106
},
107+
Literal(node) {
108+
if (isInsideCreateTheme && node.type === "Literal" && typeof node.value === "string") {
109+
for (const className of node.value.split(/\s+/)) {
110+
if (className) classList.add(className);
111+
}
112+
}
113+
},
117114
});
118115

119-
function isCreateThemeNode(node: Node) {
120-
return node.type === "CallExpression" && "name" in node.callee && node.callee.name === "createTheme";
121-
}
116+
visitor.visit(result.program);
122117

123118
return [...classList].sort();
124119
}
@@ -162,36 +157,40 @@ async function generateDependencyList(): Promise<void> {
162157

163158
/**
164159
* Extracts and sorts a list of imported component names from TypeScript/TSX content.
165-
* The function transpiles the content, parses it into an AST, and walks through import declarations
166-
* to collect component names.
160+
* The function parses the content into an AST and walks through import declarations
161+
* to collect component names. Type-only imports are excluded.
167162
*
168163
* @param content - The TypeScript/TSX source code content to analyze
169164
* @returns {Promise<string[]>} A sorted array of unique component names that are imported in the source code
170165
* @example
171166
* const content = `
172167
* import { Button, Card } from 'some-library';
173-
* import { Table } from 'other-library';
168+
* import type { ButtonProps } from 'some-library';
174169
* `;
175170
* const dependencies = await extractDependencyList(content);
176-
* // returns ['Button', 'Card', 'Table']
171+
* // returns ['Button', 'Card'] (type imports are excluded)
177172
*/
178173
export async function extractDependencyList(content: string): Promise<string[]> {
179174
const componentImports = new Set<string>();
180-
const transpiler = new Bun.Transpiler({
181-
loader: "tsx",
182-
});
183-
const transpiledContent = transpiler.transformSync(content);
184-
const ast = parse(transpiledContent, {
185-
ecmaVersion: "latest",
186-
sourceType: "module",
187-
});
175+
const result = parseSync("component.tsx", content);
176+
177+
if (result.errors.length > 0) {
178+
return [];
179+
}
180+
181+
const visitor = new Visitor({
182+
ImportDeclaration(node) {
183+
if (node.importKind === "type") {
184+
return;
185+
}
188186

189-
walk(ast as Node, {
190-
enter(node) {
191-
if (node.type === "ImportDeclaration") {
192-
if ("specifiers" in node && Array.isArray(node.specifiers)) {
193-
for (const specifier of node.specifiers) {
194-
if ("imported" in specifier && "name" in specifier.imported) {
187+
if (Array.isArray(node.specifiers)) {
188+
for (const specifier of node.specifiers) {
189+
if (specifier.type === "ImportSpecifier") {
190+
if (specifier.importKind === "type") {
191+
continue;
192+
}
193+
if (specifier.imported.type === "Identifier") {
195194
componentImports.add(specifier.imported.name);
196195
}
197196
}
@@ -200,6 +199,8 @@ export async function extractDependencyList(content: string): Promise<string[]>
200199
},
201200
});
202201

202+
visitor.visit(result.program);
203+
203204
return [...componentImports].sort();
204205
}
205206

0 commit comments

Comments
 (0)