@@ -1,147 +1,254 @@
import fs from "fs" ;
import path from "path" ;
// 1. 引入 tinyglobby
import { globSync } from "tinyglobby" ;
import * as fs from "fs" ;
import * as path from "path" ;
interface Config {
/**
* 默认测试 / story / example / fixture / type 文件匹配规则
* 仅用于“识别”,不直接决定是否排除
*/
const DEFAULT_TEST_FILE_PATTERNS : RegExp [ ] = [
// =========================
// Test / Spec
// =========================
/\.(test|spec)\.(ts|tsx|js|jsx)$/ ,
/\.(test|spec)\.(ts|tsx|js|jsx)\?.*$/ , // query param safe
// =========================
// E2E / Playwright / Cypress
// =========================
/\.(cy|playwright|e2e)\.(ts|tsx|js|jsx)$/ ,
// =========================
// Storybook
// =========================
/\.(story|stories)\.(ts|tsx|js|jsx|mdx)$/ ,
// =========================
// Snapshot / Mock / Fixture
// =========================
/\.snap$/ ,
/\.mock\.(ts|tsx|js|jsx)$/ ,
/\.fixture\.(ts|tsx|js|jsx)$/ ,
// =========================
// Type declarations
// =========================
/\.d\.ts$/ ,
// =========================
// Examples / Demos / Docs
// =========================
/\.(example|demo|docs)\.(ts|tsx|js|jsx)$/ ,
] ;
/**
* 测试 / 辅助目录(目录级排除)
*/
const DEFAULT_TEST_DIR_PATTERNS : RegExp [ ] = [
/[/\\](__tests__|tests|test|spec|__mocks__|__fixtures__|__snapshots__)[\/\\]/ ,
/[/\\](cypress|playwright|e2e|__e2e__|__playwright__)[\/\\]/ ,
/[/\\](story|stories|\.storybook)[\/\\]/ ,
/[/\\](types|type|typings|interfaces)[\/\\]/ ,
/[/\\](examples|example|demo|demos|docs)[\/\\]/ ,
] ;
type Config = {
outputDir : string ;
outputFilenameWithExt : string ;
outputFile : string ;
scanDirs : string [ ] ;
importPrefix : string ;
predefineStatements : string [ ] ;
includeExtensions : string [ ] ;
excludeDirs : string [ ] ;
excludeFileExtensions : string [ ] ;
excludePatterns : RegExp [ ] ;
}
const cssConfig : Config = {
outputDir : "src" ,
outputFilenameWithExt : "index.css" ,
scanDirs : [ "src" ] ,
importPrefix : "@import" ,
predefineStatements : [ ] ,
includeExtensions : [ ".css" ] ,
excludeDirs : [ "__tests__" , "tests" , "story" , "stories" , "types" ] ,
excludeFileExtensions : [ ] ,
excludePatterns : [ /^index\.(css)$/ , /\.(test|spec)\./ , /\.(story|stories)\./ ] ,
preamble? : string [ ] ;
importPrefix? : string ;
entryFilePatterns? : RegExp [ ] ;
barrelFirstMode? : boolean ;
includeFilePatterns? : RegExp [ ] ;
excludeFilePatterns? : RegExp [ ] ;
excludeDirPatterns? : RegExp [ ] ;
} ;
const tsConfig : Config = {
outputDir : "src" ,
outputFilenameWithExt : "index.ts" ,
outputFile : "index.ts" ,
scanDirs : [ "src" ] ,
importPrefix : "export * from" ,
predefineStatements : [ "import './index.css'" ] ,
includeExtensions : [ ".ts" , "tsx" , "js" , "jsx "] ,
excludeDirs : [ "__tests__" , "tests" , "story" , "stories" , "types" ] ,
excludeFileExtensions : [ ".d.ts" ] ,
excludePatterns : [
/^index\.(ts|tsx|js|jsx)$/ ,
/\.(test|spec)\./ ,
/\.(story|stories)\./ ,
] ,
barrelFirstMode : true ,
preamble : [ "import './index.css'; " ] ,
entryFilePatterns : [ /^index\.(js|ts|jsx|tsx)$/ ] ,
includeFilePatterns : [ /\.(js|ts|jsx|tsx)$/ ] ,
excludeFilePatterns : DEFAULT_TEST_FILE_PATTERNS ,
excludeDirPatterns : DEFAULT_TEST_DIR_PATTERNS ,
} ;
const normalizePath = ( p : string ) = > p . replace ( /\\/g , "/" ) ;
const isExcludeDir = ( filePath : string , excludeDirs : string [ ] ) = > {
const normalized = normalizePath ( filePath ) ;
return excludeDirs . some ( ( dir ) = > normalized . includes ( ` / ${ dir } / ` ) ) ;
const cssConfig : Config = {
outputDir : "src" ,
outputFile : "index.css" ,
scanDirs : [ "src" ] ,
importPrefix : "@import" ,
barrelFirstMode : false ,
entryFilePatterns : [ /^index\.(css)$/ ] ,
includeFilePatterns : [ /\.(css)$/ ] ,
excludeFilePatterns : DEFAULT_TEST_FILE_PATTERNS ,
excludeDirPatterns : DEFAULT_TEST_DIR_PATTERNS ,
} ;
const isExcludeFileExtensions = (
filePath : string ,
excludeFileExtensions : string [ ] ,
) = > excludeFileExtensions . some ( ( ext ) = > filePath . endsWith ( ext ) ) ;
function isEntryFile ( fileName : string , config : Config ) : boolean {
const regExps = config . entryFilePatterns ;
const isExcludePattern = ( fileName : string , excludePatterns : RegExp [ ] ) = >
excludePatterns . some ( ( pattern ) = > pattern . test ( fileName ) ) ;
if ( ! regExps || regExps . length === 0 ) return false ;
// ----------------------------------------
function isValidFile ( filePath : string , config : Config ) : boolean {
const fileName = filePath . split ( /[\\/]/ ) . pop ( ) ! ;
if ( isExcludeDir ( filePath , config . excludeDirs ) ) return false ;
if ( isExcludeFileExtensions ( filePath , config . excludeFileExtensions ) )
return false ;
if ( isExcludePattern ( fileName , config . excludePatterns ) ) return false ;
const ext = path . extname ( filePath ) ;
return config . includeExtensions . includes ( ext ) ;
return regExps . some ( ( regExp ) = > regExp . test ( fileName ) ) ;
}
// -----------------------------------------
function generateIndexFile ( config : Config ) {
const currentPath = process . cwd ( ) ;
const outputPath = path . resolve ( currentPath , config . outputDir ) ;
let exportStatements : string [ ] = [ ] ;
// ------ scanDirs forEach start ------------------------
config . scanDirs . forEach ( ( dir ) = > {
// 2. 路径模式保持不变,tinyglobby 能够正确处理
const scanPattern = path . resolve ( currentPath , dir , "**" , "*.*" ) ;
function isIncludeFile ( fileName : string , config : Config ) : boolean {
const regExps = config . includeFilePatterns ;
const allFilePath = globSync ( scanPattern , {
absolute : true ,
// 3. 移除了 windowsPathsNoEscape, tinyglobby 默认处理路径更智能
if ( ! regExps || regExps . length === 0 ) return false ;
return regExps . some ( ( regExp ) = > regExp . test ( fileName ) ) ;
}
function isExcludeFile ( fileName : string , config : Config ) : boolean {
const regExps = config . excludeFilePatterns ;
if ( ! regExps || regExps . length === 0 ) return false ;
return regExps . some ( ( regExp ) = > regExp . test ( fileName ) ) ;
}
function isExcludeDir ( filePath : string , config : Config ) : boolean {
const regExps = config . excludeDirPatterns ;
if ( ! regExps || regExps . length === 0 ) return false ;
return regExps . some ( ( regExp ) = > regExp . test ( filePath ) ) ;
}
function isValidDir ( filePath : string , config : Config ) : boolean {
if ( isExcludeDir ( filePath , config ) ) {
return false ;
}
return true ;
}
function isValidFile ( fileName : string , config : Config ) : boolean {
if ( isIncludeFile ( fileName , config ) && ! isExcludeFile ( fileName , config ) ) {
return true ;
}
return false ;
}
const buildExportStatement = ( filePath : string , config : Config ) = > {
// 文件夹(不是文件)与目标文件的相对路径
let exportFilePath = path . relative ( config . outputDir , filePath ) ;
// 去除扩展名,并且转化成正斜杠
const exportFilePathWithoutExt = path
. join (
path . dirname ( exportFilePath ) ,
path . basename ( exportFilePath , path . extname ( exportFilePath ) ) ,
)
. replace ( /\\/g , "/" ) ;
// 加上 ./ 前缀
return ` ${ config . importPrefix } './ ${ exportFilePathWithoutExt } '; ` ;
} ;
function isOutputEntry ( filePath : string , config : Config ) {
const outputFilePath = path . resolve ( config . outputDir , config . outputFile ) ;
const targetFilePath = path . resolve ( filePath ) ;
return outputFilePath === targetFilePath ;
}
function generateExports ( dirPath : string , config : Config ) : string [ ] {
const fileNames = fs . readdirSync ( dirPath ) ;
const exports : string [ ] = [ ] ;
// =========================
// 逻辑分支:Barrel First Mode
// =========================
if ( config . barrelFirstMode ) {
// 查找当前目录有没有 index.ts
const entryFileName = fileNames . find ( ( fileName ) = > {
const filePath = path . join ( dirPath , fileName ) ;
return fs . statSync ( filePath ) . isFile ( ) && isEntryFile ( fileName , config ) ;
} ) ;
// 如果有
if ( entryFileName ) {
const entryFilePath = path . join ( dirPath , entryFileName ) ;
const validFiles = allFilePath . filter ( ( filePath ) = > {
return isValidFile ( filePath , config ) ;
} ) ;
if ( validFiles . length === 0 ) {
console . log (
` ⚠️ 未找到符合条件的文件,跳过生成 ${ config . outputFilenameWithExt } ` ,
) ;
return ;
}
validFiles . sort ( ) ;
validFiles . forEach ( ( file ) = > {
const relativePath = path . relative ( outputPath , file ) ;
const importPath = ` ./ ${ relativePath . replace ( /\\/g , "/" ) } ` ;
exportStatements . push ( ` ${ config . importPrefix } ' ${ importPath } '; ` ) ;
} ) ;
} ) ;
// --------- scanDirs forEach end ----------------
const indexFileContent = `
${ config . predefineStatements . join ( "\n" ) }
${ exportStatements . join ( "\n" ) }
` . trim ( ) ;
const indexFilePath = path . resolve (
currentPath ,
config . outputDir ,
config . outputFilenameWithExt ,
) ;
// ✅ 内容比对,避免重复写入
if ( fs . existsSync ( indexFilePath ) ) {
const old = fs . readFileSync ( indexFilePath , "utf8" ) ;
if ( old === indexFileContent ) {
console . log (
` ✅ ${ config . outputFilenameWithExt } 内容无变化,无需重新生成 ` ,
) ;
return ;
// 如果是 outputFile 自身,则跳过
if ( isOutputEntry ( entryFilePath , config ) ) {
console . log ( ` ⏭️ 跳过自身入口文件: ${ entryFilePath } ` ) ;
} else {
exports . push ( buildExportStatement ( entryFilePath , config ) ) ;
}
}
}
fs . writeFileSync ( indexFilePath , indexFileContent , "utf8" ) ;
console . log ( ` ✅ 成功生成 ${ config . outputFilenameWithExt } : ${ indexFilePath } ` ) ;
// =========================
// 逻辑分支:index.ts 不存在,则继续正常遍历与递归
// =========================
fileNames . forEach ( ( fileName ) = > {
const filePath = path . join ( dirPath , fileName ) ;
const stat = fs . statSync ( filePath ) ;
// 情况1:是文件,且通过了校验
if (
stat . isFile ( ) &&
isValidFile ( fileName , config ) &&
! isEntryFile ( fileName , config )
) {
exports . push ( buildExportStatement ( filePath , config ) ) ;
}
// 情况2:是文件夹,且通过了校验,递归扫描子文件夹
else if ( stat . isDirectory ( ) && isValidDir ( filePath , config ) ) {
const subExports = generateExports ( filePath , config ) ;
exports . push ( . . . subExports ) ;
}
} ) ;
return exports ;
}
// --------------------------------------------------
function genIndexFile ( config : Config ) {
// 确保输出目录存在,如果不存在就递归创建
if ( ! fs . existsSync ( config . outputDir ) ) {
fs . mkdirSync ( config . outputDir , { recursive : true } ) ;
}
const allExports : string [ ] = [ ] ;
// 遍历所有需要扫描的根目录
config . scanDirs . forEach ( ( scanDir ) = > {
// 只有当目录真实存在时才进行扫描
if ( fs . existsSync ( scanDir ) ) {
const exports = generateExports ( scanDir , config ) ;
allExports . push ( . . . exports ) ;
} else {
console . warn ( ` ⚠️ 警告:扫描目录不存在,已跳过 -> ${ scanDir } ` ) ;
}
} ) ;
// 拼接最终的文件内容:前言 + 导出语句(使用 Set 自动去重)
const fileContent = [
. . . ( config . preamble ? ? [ ] ) ,
. . . Array . from ( new Set ( allExports ) ) ,
] . join ( "\n" ) ;
// 将内容写入到最终的 index.ts 文件中
const outputFilePath = path . join ( config . outputDir , config . outputFile ) ;
fs . writeFileSync ( outputFilePath , fileContent , "utf-8" ) ;
console . log ( ` ✨ 成功生成入口文件: ${ outputFilePath } ` ) ;
}
// ================= 脚本执行入口 =================
try {
console . log ( ` 🚀 [gen-index] 开始扫描 ` ) ;
generateIndexFile ( cssConfig ) ;
generateIndexFile ( tsConfig ) ;
} catch ( err ) {
const msg = err instanceof Error ? err.message : String ( err ) ;
console . error ( ` ❌ [gen-index] 执行失败: ${ msg } ` ) ;
process . exit ( 1 ) ;
console . log ( "🚀 开始扫描并生成入口文件..." ) ;
genIndexFile ( tsConfig ) ;
genIndexFile ( cssConfig ) ;
console . log ( "✅ 脚本执行完毕!" ) ;
} catch ( error ) {
console . error ( "❌ 脚本执行失败:" , error ) ;
process . exit ( 1 ) ; // 如果报错,让进程以非 0 状态码退出
}