Skip to content

Commit ba6771e

Browse files
committed
feat: 构建功能增强
1 parent a1a69db commit ba6771e

File tree

42 files changed

+1038
-19
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1038
-19
lines changed

TODO.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@
1919
### 🏗️ 构建功能增强
2020

2121
- [x] **清理功能** - 构建前自动清理输出目录 (`clean`)
22-
- [ ] **文件复制** - 静态资源复制功能 (`copy`)
23-
- [ ] **文件哈希** - 输出文件名哈希支持 (`hash`)
24-
- [ ] **固定扩展名** - 强制使用 `.cjs`/`.mjs` 扩展名 (`fixedExtension`)
25-
- [ ] **自定义扩展名** - 灵活的输出文件扩展名配置 (`outExtensions`)
26-
- [ ] **Banner/Footer** - 文件头尾注释添加
27-
- [ ] **Node.js 协议处理** - `node:` 前缀的添加/移除 (`nodeProtocol`)
22+
- [x] **文件复制** - 静态资源复制功能 (`copy`)
23+
- [x] **文件哈希** - 输出文件名哈希支持 (`hash`)
24+
- [x] **固定扩展名** - 强制使用 `.cjs`/`.mjs` 扩展名 (`fixedExtension`)
25+
- [x] **自定义扩展名** - 灵活的输出文件扩展名配置 (`outExtensions`)
26+
- [x] **Banner/Footer** - 文件头尾注释添加
27+
- [x] **Node.js 协议处理** - `node:` 前缀的添加/移除 (`nodeProtocol`)
2828

2929
### 🔍 代码质量和分析
3030

src/builders/bundle.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ import { rolldown } from 'rolldown'
2020

2121
import { dts } from 'rolldown-plugin-dts'
2222

23+
import { resolveChunkAddon } from '../features/banner'
24+
import { copyFiles } from '../features/copy'
25+
import { addHashToFilename, hasHash } from '../features/hash'
2326
import { distSize, fmtPath, sideEffectSize } from '../utils'
27+
import { nodeProtocolPlugin } from './plugins/node-protocol'
2428
import { makeExecutable, shebangPlugin } from './plugins/shebang'
2529

2630
/**
@@ -44,7 +48,16 @@ function formatToRolldownFormat(format: OutputFormat): ModuleFormat {
4448
/**
4549
* Get file extension for format
4650
*/
47-
function getFormatExtension(format: OutputFormat, platform: Platform): string {
51+
function getFormatExtension(
52+
format: OutputFormat,
53+
platform: Platform,
54+
fixedExtension = false,
55+
): string {
56+
if (fixedExtension) {
57+
// Force .cjs/.mjs extensions
58+
return format === 'cjs' ? '.cjs' : '.mjs'
59+
}
60+
4861
switch (format) {
4962
case 'esm':
5063
return '.mjs' // Always use .mjs for ESM to be explicit about module type
@@ -182,7 +195,10 @@ export async function rolldownBuild(
182195
const baseRolldownConfig = defu(entry.rolldown, {
183196
cwd: ctx.pkgDir,
184197
input: inputs,
185-
plugins: [shebangPlugin()] as Plugin[],
198+
plugins: [
199+
shebangPlugin(),
200+
nodeProtocolPlugin(entry.nodeProtocol || false),
201+
] as Plugin[],
186202
platform: platform === 'node' ? 'node' : 'neutral',
187203
external: typeof entry.external === 'function'
188204
? entry.external
@@ -213,7 +229,7 @@ export async function rolldownBuild(
213229

214230
for (const format of formats) {
215231
const rolldownFormat = formatToRolldownFormat(format)
216-
const extension = getFormatExtension(format, platform)
232+
const extension = getFormatExtension(format, platform, entry.fixedExtension)
217233

218234
// Create config for this format
219235
const formatConfig = { ...baseRolldownConfig }
@@ -256,6 +272,8 @@ export async function rolldownBuild(
256272
chunkFileNames: `_chunks/[name]-[hash]${extension}`,
257273
minify: entry.minify,
258274
name: entry.globalName, // For IIFE/UMD formats
275+
banner: resolveChunkAddon(entry.banner, format),
276+
footer: resolveChunkAddon(entry.footer, format),
259277
}
260278

261279
await hooks.rolldownOutput?.(outConfig, res, ctx)
@@ -295,20 +313,42 @@ export async function rolldownBuild(
295313
if (chunk.fileName.endsWith('ts'))
296314
continue
297315

316+
let finalFileName = chunk.fileName
317+
let finalFilePath = join(formatOutDir, chunk.fileName)
318+
319+
// Add hash to filename if requested
320+
if (entry.hash && !hasHash(chunk.fileName)) {
321+
const content = chunk.code
322+
const hashedFileName = addHashToFilename(chunk.fileName, content)
323+
const hashedFilePath = join(formatOutDir, hashedFileName)
324+
325+
// Rename the file to include hash
326+
const { rename } = await import('node:fs/promises')
327+
await rename(finalFilePath, hashedFilePath)
328+
329+
finalFileName = hashedFileName
330+
finalFilePath = hashedFilePath
331+
}
332+
298333
// Store full path for logging
299-
filePathMap.set(chunk.fileName, join(formatOutDir, chunk.fileName))
334+
filePathMap.set(finalFileName, finalFilePath)
300335

301336
allOutputEntries.push({
302337
format,
303-
name: chunk.fileName,
338+
name: finalFileName,
304339
exports: chunk.exports,
305340
deps: resolveDeps(chunk),
306-
...(await distSize(formatOutDir, chunk.fileName)),
307-
sideEffectSize: await sideEffectSize(formatOutDir, chunk.fileName),
341+
...(await distSize(formatOutDir, finalFileName)),
342+
sideEffectSize: await sideEffectSize(formatOutDir, finalFileName),
308343
})
309344
}
310345
}
311346

347+
// Copy files if specified
348+
if (entry.copy) {
349+
await copyFiles(ctx.pkgDir, fullOutDir, entry.copy)
350+
}
351+
312352
// Display build results
313353
consola.log(
314354
`\n${allOutputEntries
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Plugin } from 'rolldown'
2+
import { transformNodeProtocol } from '../../features/node-protocol'
3+
4+
/**
5+
* Rolldown plugin for Node.js protocol handling
6+
*/
7+
export function nodeProtocolPlugin(nodeProtocol: 'strip' | boolean): Plugin {
8+
if (!nodeProtocol) {
9+
return {
10+
name: 'node-protocol-noop',
11+
}
12+
}
13+
14+
return {
15+
name: 'node-protocol',
16+
renderChunk(code) {
17+
return {
18+
code: transformNodeProtocol(code, nodeProtocol),
19+
map: null,
20+
}
21+
},
22+
}
23+
}

src/builders/transform.ts

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import { minify } from 'oxc-minify'
1212
import { parseSync } from 'oxc-parser'
1313
import { transform } from 'oxc-transform'
1414
import { glob } from 'tinyglobby'
15+
import { addBannerFooter, resolveChunkAddon } from '../features/banner'
16+
import { copyFiles } from '../features/copy'
17+
import { createFilename } from '../features/extensions'
18+
import { addHashToFilename, hasHash } from '../features/hash'
19+
import { transformNodeProtocol } from '../features/node-protocol'
1520
import { fmtPath } from '../utils'
1621
import { makeExecutable, SHEBANG_RE } from './plugins/shebang'
1722

@@ -74,20 +79,40 @@ export async function transformDir(
7479
case '.ts': {
7580
{
7681
const transformed = await transformModule(entryPath, entry)
77-
const entryDistPath = join(
78-
entry.outDir!,
79-
entryName.replace(/\.ts$/, '.mjs'),
80-
)
82+
83+
// Determine output filename with proper extension
84+
const baseName = entryName.replace(/\.ts$/, '')
85+
const outputFileName = createFilename(baseName, 'esm', false, {
86+
platform: entry.platform,
87+
fixedExtension: entry.fixedExtension,
88+
outExtensions: entry.outExtensions,
89+
})
90+
91+
let entryDistPath = join(entry.outDir!, outputFileName)
8192
await mkdir(dirname(entryDistPath), { recursive: true })
8293
await writeFile(entryDistPath, transformed.code, 'utf8')
8394

95+
// Add hash to filename if requested
96+
if (entry.hash && !hasHash(entryDistPath)) {
97+
const hashedPath = addHashToFilename(entryDistPath, transformed.code)
98+
const { rename } = await import('node:fs/promises')
99+
await rename(entryDistPath, hashedPath)
100+
entryDistPath = hashedPath
101+
}
102+
84103
if (SHEBANG_RE.test(transformed.code)) {
85104
await makeExecutable(entryDistPath)
86105
}
87106

88107
if (transformed.declaration) {
108+
const dtsFileName = createFilename(baseName, 'esm', true, {
109+
platform: entry.platform,
110+
fixedExtension: entry.fixedExtension,
111+
outExtensions: entry.outExtensions,
112+
})
113+
const dtsPath = join(entry.outDir!, dtsFileName)
89114
await writeFile(
90-
entryDistPath.replace(/\.mjs$/, '.d.mts'),
115+
dtsPath,
91116
transformed.declaration,
92117
'utf8',
93118
)
@@ -116,6 +141,11 @@ export async function transformDir(
116141

117142
const writtenFiles = await Promise.all(promises)
118143

144+
// Copy files if specified
145+
if (entry.copy) {
146+
await copyFiles(ctx.pkgDir, fullOutDir, entry.copy)
147+
}
148+
119149
consola.log(
120150
`\n${c.magenta('[transform] ')}${c.underline(`${fmtPath(entry.outDir!)}/`)}\n${writtenFiles
121151
.map(f => c.dim(fmtPath(f)))
@@ -172,7 +202,7 @@ async function transformModule(entryPath: string, entry: TransformEntry) {
172202
// Handle aliases first
173203
if (entry.alias) {
174204
for (const [alias, target] of Object.entries(entry.alias)) {
175-
if (moduleId === alias || moduleId.startsWith(alias + '/')) {
205+
if (moduleId === alias || moduleId.startsWith(`${alias}/`)) {
176206
moduleId = moduleId.replace(alias, target)
177207
wasAliasResolved = true
178208
break
@@ -256,5 +286,15 @@ async function transformModule(entryPath: string, entry: TransformEntry) {
256286
transformed.map = res.map
257287
}
258288

289+
// Apply banner/footer
290+
const banner = resolveChunkAddon(entry.banner, 'esm') // Transform mode uses ESM
291+
const footer = resolveChunkAddon(entry.footer, 'esm')
292+
transformed.code = addBannerFooter(transformed.code, banner, footer)
293+
294+
// Apply Node.js protocol handling
295+
if (entry.nodeProtocol) {
296+
transformed.code = transformNodeProtocol(transformed.code, entry.nodeProtocol)
297+
}
298+
259299
return transformed
260300
}

src/features/banner.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { ChunkAddon, OutputFormat } from '../types'
2+
3+
/**
4+
* Resolve banner/footer addon for specific format
5+
*/
6+
export function resolveChunkAddon(
7+
addon: string | ChunkAddon | undefined,
8+
format: OutputFormat,
9+
): string | undefined {
10+
if (!addon) {
11+
return undefined
12+
}
13+
14+
if (typeof addon === 'string') {
15+
return addon
16+
}
17+
18+
// Handle format-specific addons
19+
const formatKey = format === 'esm' ? 'js' : format
20+
return addon[formatKey] || addon.js
21+
}
22+
23+
/**
24+
* Add banner to content
25+
*/
26+
export function addBanner(content: string, banner?: string): string {
27+
if (!banner) {
28+
return content
29+
}
30+
return `${banner}\n${content}`
31+
}
32+
33+
/**
34+
* Add footer to content
35+
*/
36+
export function addFooter(content: string, footer?: string): string {
37+
if (!footer) {
38+
return content
39+
}
40+
return `${content}\n${footer}`
41+
}
42+
43+
/**
44+
* Add both banner and footer to content
45+
*/
46+
export function addBannerFooter(
47+
content: string,
48+
banner?: string,
49+
footer?: string,
50+
): string {
51+
let result = content
52+
result = addBanner(result, banner)
53+
result = addFooter(result, footer)
54+
return result
55+
}

src/features/copy.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { CopyEntry, CopyOptions } from '../types'
2+
import { cp } from 'node:fs/promises'
3+
import { basename, resolve } from 'node:path'
4+
import { consola } from 'consola'
5+
6+
/**
7+
* Copy files to output directory
8+
*/
9+
export async function copyFiles(
10+
cwd: string,
11+
outDir: string,
12+
copyOptions: CopyOptions,
13+
): Promise<void> {
14+
if (!copyOptions || copyOptions.length === 0) {
15+
return
16+
}
17+
18+
consola.debug('📁 Copying files...')
19+
20+
await Promise.all(
21+
copyOptions.map(async (entry) => {
22+
const from = typeof entry === 'string' ? entry : entry.from
23+
const to = typeof entry === 'string'
24+
? resolve(outDir, basename(from))
25+
: resolve(cwd, entry.to)
26+
27+
const fromPath = resolve(cwd, from)
28+
29+
try {
30+
await cp(fromPath, to, {
31+
recursive: true,
32+
force: true,
33+
})
34+
consola.debug(` ${from}${to}`)
35+
}
36+
catch (error) {
37+
consola.warn(`Failed to copy ${from} to ${to}:`, error)
38+
}
39+
}),
40+
)
41+
42+
consola.debug('✅ Files copied successfully')
43+
}

0 commit comments

Comments
 (0)