-
-
Notifications
You must be signed in to change notification settings - Fork 208
fix(theme/eject): allow treeshaking manually the theme/index file #2895
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,5 +1,6 @@ | ||||||
| import { createRequire } from 'node:module'; | ||||||
| import path from 'node:path'; | ||||||
| import path, { join } from 'node:path'; | ||||||
| import { cwd } from 'node:process'; | ||||||
| import { fileURLToPath } from 'node:url'; | ||||||
| import type { | ||||||
| RsbuildConfig, | ||||||
|
|
@@ -89,7 +90,9 @@ async function createInternalBuildConfig( | |||||
| routeService: RouteService, | ||||||
| pluginDriver: PluginDriver, | ||||||
| ): Promise<RsbuildConfig> { | ||||||
| const CUSTOM_THEME_DIR = config.themeDir!; | ||||||
| const CUSTOM_THEME_DIR = path.isAbsolute(config.themeDir!) | ||||||
| ? config.themeDir! | ||||||
| : path.join(cwd(), config.themeDir!); | ||||||
| const outDir = config?.outDir ?? OUTPUT_DIR; | ||||||
|
|
||||||
| const base = config?.base ?? ''; | ||||||
|
|
@@ -363,10 +366,22 @@ async function createInternalBuildConfig( | |||||
| chain.resolve.extensions.prepend('.md').prepend('.mdx').prepend('.mjs'); | ||||||
|
|
||||||
| chain.module | ||||||
| .rule('css-virtual-module') | ||||||
| .rule('rspress-css-virtual-module') | ||||||
| .test(/\.rspress[\\/]runtime[\\/]virtual-global-styles/) | ||||||
| .merge({ sideEffects: true }); | ||||||
|
|
||||||
| // Optimize the theme | ||||||
| const themeIndexPath = join(CUSTOM_THEME_DIR, 'index'); | ||||||
| const themeIndexRule = chain.module | ||||||
| .rule('rspress-theme-index') | ||||||
| .test(themeIndexPath); | ||||||
| themeIndexRule.merge({ | ||||||
| sideEffects: false, | ||||||
| }); | ||||||
| themeIndexRule | ||||||
| .use('EXPORT_STAR_OPTIMIZE') | ||||||
|
||||||
| .use('EXPORT_STAR_OPTIMIZE') | |
| .use('export-star-optimizer') |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { readFile } from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
| import { describe, expect, it } from '@rstest/core'; | ||
| import { exportStarOptimizerTransform } from './exportStarOptimizerTransform'; | ||
|
|
||
| const fixturesRoot = path.join(__dirname, 'fixtures'); | ||
|
|
||
| async function loadFixture(caseName: string) { | ||
| const filePath = path.join(fixturesRoot, caseName, 'index.ts'); | ||
| const code = await readFile(filePath, 'utf-8'); | ||
| return { code, filePath }; | ||
| } | ||
|
|
||
| describe('exportStarOptimizerTransform', () => { | ||
| it('replaces export * with named exports, excluding locals', async () => { | ||
| const { code, filePath } = await loadFixture('basic'); | ||
| const result = await exportStarOptimizerTransform(code, filePath); | ||
|
|
||
| expect(result).toMatchInlineSnapshot(` | ||
| "export const A = 1;\nexport { Button, Other, default } from './foo';\n" | ||
| `); | ||
| }); | ||
|
|
||
| it('removes export * when module exports are already local', async () => { | ||
| const { code, filePath } = await loadFixture('local-only'); | ||
| const result = await exportStarOptimizerTransform(code, filePath); | ||
|
|
||
| expect(result).toMatchInlineSnapshot(` | ||
| "export const A = 1; | ||
|
|
||
| " | ||
| `); | ||
| }); | ||
|
|
||
| it('resolves index fallback when no direct file match', async () => { | ||
| const { code, filePath } = await loadFixture('index-fallback'); | ||
| const result = await exportStarOptimizerTransform(code, filePath); | ||
|
|
||
| expect(result).toMatchInlineSnapshot(` | ||
| "export { B } from './foo';\n" | ||
| `); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,223 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { readFile } from 'node:fs/promises'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { dirname } from 'node:path'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { rspack } from '@rsbuild/core'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type ResolveAsync = ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| directory: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) => Promise<{ path?: string; error?: string }>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const resolver = new rspack.experiments.resolver.ResolverFactory({ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx', '.json'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mainFiles: ['index'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| mainFields: ['module', 'browser', 'main'], | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| alias: {}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Convert `export *` into named exports via regex for tree-shaking friendliness. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class ExportStarOptimizer { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| filepath: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _resolve: ResolveAsync | undefined; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| localExports: Set<string>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| reExports: Map<string, { source: string; imported: string }>; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| constructor(filepath: string, resolveAsync?: ResolveAsync) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.filepath = filepath; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.localExports = new Set(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.reExports = new Map(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this._resolve = resolveAsync; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| resolve( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| directory: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| request: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<{ path?: string; error?: string }> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (this._resolve) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return this._resolve(directory, request); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return resolver.async(directory, request); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| * Parse source code to collect local exports. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| parseLocalExports(code: string): void { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 移除注释 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // 移除注释 | |
| // Remove comments |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex /export\s+(?:const|let|var)\s+(\w+)/g only captures the first identifier in a multi-variable declaration. For example, 'export const a = 1, b = 2, c = 3;' would only capture 'a', missing 'b' and 'c'. Consider extending the regex or parsing logic to handle comma-separated variable declarations.
| const varExportRegex = /export\s+(?:const|let|var)\s+(\w+)/g; | |
| let match: RegExpExecArray | null; | |
| while ((match = varExportRegex.exec(cleanCode)) !== null) { | |
| this.localExports.add(match[1]); | |
| // Match the whole declaration after export const|let|var, up to semicolon or line break | |
| const varExportRegex = /export\s+(?:const|let|var)\s+([^;]+)/g; | |
| let match: RegExpExecArray | null; | |
| while ((match = varExportRegex.exec(cleanCode)) !== null) { | |
| // Split by commas, handle each variable declaration | |
| const declarations = match[1].split(','); | |
| for (const decl of declarations) { | |
| // Extract variable name (ignore destructuring for now) | |
| const varNameMatch = /^\s*([A-Za-z_$][\w$]*)/.exec(decl.trim()); | |
| if (varNameMatch) { | |
| this.localExports.add(varNameMatch[1]); | |
| } | |
| } |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex /export\s+(?:function|class)\s+(\w+)/g doesn't handle async functions ('export async function'), generator functions ('export function*'), or abstract classes ('export abstract class'). These TypeScript and ES2015+ constructs would be missed by this pattern. Consider extending the regex to handle these cases.
| const funcClassRegex = /export\s+(?:function|class)\s+(\w+)/g; | |
| // Handles: export function name, export async function name, export function* name, export async function* name, | |
| // export class Name, export abstract class Name | |
| const funcClassRegex = /export\s+(?:(?:async\s+)?function\s*\*?|(?:abstract\s+)?class)\s+(\w+)/g; |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex for parsing named exports doesn't account for 'export { name } from' statements, which would be incorrectly parsed as local exports. The regex should use a negative lookahead to exclude re-exports: /export\s*{([^}]+)}(?!\s*from)/g to avoid false positives.
| const namedExportRegex = /export\s*\{([^}]+)\}/g; | |
| const namedExportRegex = /export\s*\{([^}]+)\}(?!\s*from)/g; |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parsing logic for 'export { name as alias }' is incorrect. When splitting by 'as', parts[parts.length - 1] gets the alias (exported name), but for local exports in the parseLocalExports method, we should track the alias, not the original name. However, this logic seems backwards - it should store the exported name (after 'as'), which is correct. But the logic for adding names from lines 49 doesn't properly validate empty strings after trim(), which could add empty entries to localExports if there are trailing commas or extra spaces.
| }); | |
| }).filter(name => name.length > 0); |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The parseLocalExports method doesn't handle TypeScript-specific exports such as 'export type', 'export interface', 'export enum', 'export namespace', and 'export declare'. These are valid exports in TypeScript files and should be tracked to avoid conflicts when expanding 'export *' statements. Since the file extension list in getModuleExports includes '.ts' and '.tsx', TypeScript support should be comprehensive.
| } | |
| } | |
| // 6. TypeScript-specific exports | |
| // export type Name = ... | |
| const typeExportRegex = /export\s+type\s+(\w+)/g; | |
| while ((match = typeExportRegex.exec(cleanCode)) !== null) { | |
| this.localExports.add(match[1]); | |
| } | |
| // export interface Name { ... } | |
| const interfaceExportRegex = /export\s+interface\s+(\w+)/g; | |
| while ((match = interfaceExportRegex.exec(cleanCode)) !== null) { | |
| this.localExports.add(match[1]); | |
| } | |
| // export enum Name { ... } | |
| const enumExportRegex = /export\s+enum\s+(\w+)/g; | |
| while ((match = enumExportRegex.exec(cleanCode)) !== null) { | |
| this.localExports.add(match[1]); | |
| } | |
| // export namespace Name { ... } | |
| const namespaceExportRegex = /export\s+namespace\s+(\w+)/g; | |
| while ((match = namespaceExportRegex.exec(cleanCode)) !== null) { | |
| this.localExports.add(match[1]); | |
| } | |
| // export declare ... (type, interface, enum, namespace, function, class, const, let, var) | |
| const declareExportRegex = /export\s+declare\s+(?:type|interface|enum|namespace|function|class|const|let|var)\s+(\w+)/g; | |
| while ((match = declareExportRegex.exec(cleanCode)) !== null) { | |
| this.localExports.add(match[1]); | |
| } |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The same regex bug exists in getModuleExports: /export\s+(?:const|let|var)\s+(\w+)/g only captures the first variable in multi-variable declarations like 'export const a = 1, b = 2;'. This inconsistency with parseLocalExports should be fixed to handle comma-separated declarations.
| const varExportRegex = /export\s+(?:const|let|var)\s+(\w+)/g; | |
| let match: RegExpExecArray | null; | |
| while ((match = varExportRegex.exec(cleanCode)) !== null) { | |
| exports.add(match[1]); | |
| const varExportRegex = /export\s+(?:const|let|var)\s+([^;]+)/g; | |
| let match: RegExpExecArray | null; | |
| while ((match = varExportRegex.exec(cleanCode)) !== null) { | |
| // match[1] contains the full variable declaration list, e.g. "a = 1, b = 2" | |
| const varList = match[1]; | |
| // Split by commas not inside brackets (to avoid destructuring confusion) | |
| varList.split(',').forEach(decl => { | |
| // Remove any default value assignment and destructuring | |
| // Only match simple variable names (skip destructuring for now) | |
| const nameMatch = /^\s*([a-zA-Z_$][\w$]*)/.exec(decl.trim()); | |
| if (nameMatch) { | |
| exports.add(nameMatch[1]); | |
| } | |
| }); |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to parseLocalExports, getModuleExports doesn't handle TypeScript-specific exports like 'export type', 'export interface', 'export enum', etc. This could lead to incomplete export lists when processing TypeScript modules, potentially causing missing exports in the optimized output.
| // TypeScript-specific: export type Foo, export interface Bar, export enum Baz | |
| const typeExportRegex = /export\s+type\s+(\w+)/g; | |
| while ((match = typeExportRegex.exec(cleanCode)) !== null) { | |
| exports.add(match[1]); | |
| } | |
| const interfaceExportRegex = /export\s+interface\s+(\w+)/g; | |
| while ((match = interfaceExportRegex.exec(cleanCode)) !== null) { | |
| exports.add(match[1]); | |
| } | |
| const enumExportRegex = /export\s+enum\s+(\w+)/g; | |
| while ((match = enumExportRegex.exec(cleanCode)) !== null) { | |
| exports.add(match[1]); | |
| } |
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The logic for parsing export names from 'export { ... }' statements (lines 108-112) is duplicated with nearly identical logic at lines 117-121. The only difference is whether they filter for 'from' clauses. Consider combining these into a single parsing step with appropriate filtering to reduce duplication.
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The export parsing logic in getModuleExports (lines 95-126) is nearly identical to the logic in parseLocalExports (lines 28-68). This represents significant code duplication. Consider extracting this logic into a shared private method to improve maintainability and ensure consistency.
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When module resolution fails, the method returns an empty Set and only logs a warning. This silently causes the 'export *' statement to be replaced with nothing, which changes the semantic behavior of the code. If a module genuinely exists but fails to resolve, this could break the application. Consider either preserving the original 'export *' statement on resolution failure, or making the error more visible (e.g., throwing an error in non-production builds).
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex for removing single-line comments will incorrectly remove URLs and strings that contain '//', such as 'https://example.com' or strings like 'const url = "http://foo"'. This could corrupt valid code. Consider using a more robust comment removal approach that respects string literals and template literals, or use a proper AST-based parser.
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The regex for removing multi-line comments could incorrectly match '/' and '/' patterns that appear in strings or regular expressions. For example, code like 'const pattern = /*/' or 'const str = "/* not a comment */"' would be corrupted. Consider using an AST-based approach for more accurate comment removal.
Copilot
AI
Dec 15, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Each 'export *' statement triggers an async module resolution and file read operation in getModuleExports. If a file has multiple 'export *' statements referencing the same module, this module will be resolved and read multiple times. Consider caching the results of getModuleExports by module path to avoid redundant I/O operations.
| while ((match = exportStarRegex.exec(code)) !== null) { | |
| const fullMatch = match[0]; | |
| const source = match[1]; | |
| const startIndex = match.index; | |
| const endIndex = startIndex + fullMatch.length; | |
| // Get exports of the target module. | |
| const moduleExports = await this.getModuleExports(source); | |
| // Cache to avoid redundant module resolution and file reads. | |
| const moduleExportsCache = new Map<string, Set<string>>(); | |
| while ((match = exportStarRegex.exec(code)) !== null) { | |
| const fullMatch = match[0]; | |
| const source = match[1]; | |
| const startIndex = match.index; | |
| const endIndex = startIndex + fullMatch.length; | |
| // Get exports of the target module, using cache if available. | |
| let moduleExports: Set<string>; | |
| if (moduleExportsCache.has(source)) { | |
| moduleExports = moduleExportsCache.get(source)!; | |
| } else { | |
| moduleExports = await this.getModuleExports(source); | |
| moduleExportsCache.set(source, moduleExports); | |
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export const Button = 1; | ||
| export default 2; | ||
| export const Other = 3; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export const A = 1; | ||
| export * from './foo'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const B = 1; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export * from './foo'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export const A = 1; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export const A = 1; | ||
| export * from './foo'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The transform function is defined inline as an async function but is not explicitly marked as async in the api.transform call. This could lead to issues if the transform is expected to return a Promise. Ensure the transform callback properly handles the async nature of exportStarOptimizerTransform by either using async/await or returning the Promise.