Skip to content

Commit 4fd0b95

Browse files
committed
feat(fs/fileOutput)!: support merge for more file types, more powerful hooks
1 parent aa7b27c commit 4fd0b95

File tree

6 files changed

+227
-67
lines changed

6 files changed

+227
-67
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@
7272
"jiti": "^2.4.2",
7373
"pathe": "^2.0.3",
7474
"std-env": "^3.9.0",
75-
"tinyglobby": "^0.2.13"
75+
"tinyglobby": "^0.2.13",
76+
"yaml": "^2.8.0"
7677
},
7778
"devDependencies": {
7879
"@antfu/eslint-config": "^4.12.0",

pnpm-lock.yaml

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

src/helpers/fs.ts

Lines changed: 78 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,38 @@ import type { ReactiveArgs } from '~/types'
33
import { Buffer } from 'node:buffer'
44
import { access, mkdir, readFile, writeFile } from 'node:fs/promises'
55
import { defu } from 'defu'
6-
import { dirname } from 'pathe'
6+
import { dirname, extname } from 'pathe'
7+
import { parse as yamlParse, stringify as yamlStringify } from 'yaml'
78
import { logger } from './logger'
89

910
export type FileOutputState = ReactiveArgs<{
1011
filePath: string
1112
data: string
12-
mergeResult: undefined | string
13-
mergeType: 'json' | 'concat'
13+
/**
14+
* This property is auto-populated for deep merge-able files (json, yaml) in hooks like `onFileOutputDeepMerge`, the user can also set it manually on prior hooks.
15+
*/
16+
parsedData?: Record<string, unknown> | undefined
17+
mergeResult?: string | undefined
18+
mergeType: 'json' | 'yaml' | 'concat' | 'custom'
1419
isValidFileToMerge: boolean
20+
/**
21+
* A custom id for the file, used for advanced hook usecase like `custom` merge
22+
*/
23+
fid?: string
1524
}>
1625

1726
export interface FileOutputHooks extends Hooks {
1827
onFileOutput: (state: FileOutputState) => void | Promise<void>
1928

20-
onFileOutputJsonMerge: (state: FileOutputState) => void | Promise<void>
29+
onFileOutputCustomMerge: (state: FileOutputState) => void | Promise<void>
2130

2231
onFileOutputConcatMerge: (state: FileOutputState) => void | Promise<void>
32+
33+
onFileOutputDeepMerge: (state: FileOutputState) => void | Promise<void>
34+
35+
onFileOutputJsonMerge: (state: FileOutputState) => void | Promise<void>
36+
37+
onFileOutputYamlMerge: (state: FileOutputState) => void | Promise<void>
2338
}
2439

2540
export interface FileOutputOptions {
@@ -32,11 +47,11 @@ export interface FileOutputOptions {
3247
* Control existing file content merging policy.
3348
*
3449
* Options:
35-
* + `'json'`: perform deep merge for json files only.
50+
* + `'deep'`: perform deep merge supported files only (json, yaml).
3651
* + `true`: concatenate data of all files (json are still deep-merged).
3752
*
3853
*/
39-
mergeContent?: boolean | 'json'
54+
mergeContent?: boolean | 'deep'
4055
}
4156

4257
/**
@@ -52,12 +67,12 @@ export async function fileOutput(filePath: string, data: string, options?: FileO
5267
mergeContent,
5368
} = options ?? {}
5469

70+
const mergeType = _fileMergeType(filePath)
5571
const state: FileOutputState = {
5672
filePath,
5773
data,
58-
mergeResult: undefined as undefined | string,
59-
mergeType: filePath.endsWith('.json') ? 'json' : 'concat',
60-
isValidFileToMerge: mergeContent === true || (mergeContent === 'json' && filePath.endsWith('.json')),
74+
mergeType,
75+
isValidFileToMerge: mergeContent === true || (mergeContent === 'deep' && mergeType !== 'concat'),
6176
}
6277

6378
if (hookable)
@@ -71,25 +86,59 @@ export async function fileOutput(filePath: string, data: string, options?: FileO
7186
logger.info(`Merging file "${state.filePath}"...`)
7287

7388
switch (state.mergeType) {
89+
case 'custom': {
90+
if (!hookable)
91+
throw new Error('Expect `hookable` to be provided when mergeType is "custom"')
92+
93+
await hookable.callHook('onFileOutputCustomMerge', state)
94+
95+
if (!state.mergeResult)
96+
throw new Error('Expect `mergeResult` to be provided when mergeType is "custom"')
97+
98+
state.data = state.mergeResult
99+
break
100+
}
101+
case 'concat': {
102+
if (hookable)
103+
await hookable.callHook('onFileOutputConcatMerge', state)
104+
105+
state.data = state.mergeResult ?? Buffer.concat([await readFile(state.filePath), Buffer.from(state.data)]).toString()
106+
break
107+
}
74108
case 'json': {
75109
if (typeof state.data !== 'string')
76110
throw new Error('Please provide `data` as a JSON stringified object')
77111

78-
if (hookable)
112+
state.parsedData = JSON.parse(state.data)
113+
114+
if (hookable) {
115+
await hookable.callHook('onFileOutputDeepMerge', state)
79116
await hookable.callHook('onFileOutputJsonMerge', state)
117+
}
80118

81119
state.data = state.mergeResult ?? JSON.stringify(
82-
defu(JSON.parse(state.data), JSON.parse(await readFile(state.filePath, 'utf-8'))),
120+
defu(state.parsedData, JSON.parse(await readFile(state.filePath, 'utf-8'))),
83121
undefined,
84122
2,
85123
)
86124
break
87125
}
88-
case 'concat': {
89-
if (hookable)
90-
await hookable.callHook('onFileOutputConcatMerge', state)
126+
case 'yaml': {
127+
if (typeof state.data !== 'string')
128+
throw new Error('Please provide `data` as a YAML stringified object')
91129

92-
state.data = state.mergeResult ?? Buffer.concat([await readFile(state.filePath), Buffer.from(state.data)]).toString()
130+
state.parsedData = yamlParse(state.data)
131+
132+
if (hookable) {
133+
await hookable.callHook('onFileOutputDeepMerge', state)
134+
await hookable.callHook('onFileOutputYamlMerge', state)
135+
}
136+
137+
state.data = state.mergeResult ?? yamlStringify(
138+
defu(state.parsedData, yamlParse(await readFile(state.filePath, 'utf-8'))),
139+
undefined,
140+
2,
141+
)
93142
break
94143
}
95144
default:
@@ -100,3 +149,17 @@ export async function fileOutput(filePath: string, data: string, options?: FileO
100149
// Write the file
101150
return await writeFile(state.filePath, state.data)
102151
}
152+
153+
function _fileMergeType(filePath: string) {
154+
const extension = extname(filePath)
155+
156+
switch (extension) {
157+
case '.json':
158+
return 'json'
159+
case '.yaml':
160+
case '.yml':
161+
return 'yaml'
162+
default:
163+
return 'concat'
164+
}
165+
}

src/rocket/assemble.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export async function simpleRocketAssemble(options: SimpleRocketAssembleOptions)
114114
fileContentFinal = replaceMap(fileContentMirror, variables)
115115
} while (fileContentFinal !== fileContentMirror)
116116

117-
await fileOutput(resolve(outDir, filePath), fileContentFinal, { hookable, mergeContent: 'json' })
117+
await fileOutput(resolve(outDir, filePath), fileContentFinal, { hookable, mergeContent: 'deep' })
118118
}
119119
}
120120

test/cli/unpack.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ describe('unpackFromUrl', () => {
8181
delete globalThis.fetch
8282
})
8383

84-
it('should download, extract, and assemble a valid rocket config pack', async () => {
84+
it('should extract, and assemble a valid rocket config pack', async () => {
8585
// Arrange
8686
const mockZipData = await createMockZip({
8787
'rocket.config.json5': '{ "name": "test-pack" }',
@@ -113,9 +113,9 @@ describe('unpackFromUrl', () => {
113113
expect(vi.mocked(await import('node:fs/promises')).mkdtemp).toHaveBeenCalled()
114114
expect(mockUnzip).toHaveBeenCalled()
115115
expect(fileOutput).toHaveBeenCalledTimes(3)
116-
expect(fileOutput).toHaveBeenCalledWith(`${expectedTmpDir}/rocket.config.json5`, '{ "name": "test-pack" }', { hookable: undefined })
117-
expect(fileOutput).toHaveBeenCalledWith(`${expectedTmpDir}/frame/file1.txt`, 'frame content', { hookable: undefined })
118-
expect(fileOutput).toHaveBeenCalledWith(`${expectedTmpDir}/fuel/file2.txt`, 'fuel content', { hookable: undefined })
116+
expect(fileOutput).toHaveBeenCalledWith(`${expectedTmpDir}/rocket.config.json5`, '{ "name": "test-pack" }', undefined)
117+
expect(fileOutput).toHaveBeenCalledWith(`${expectedTmpDir}/frame/file1.txt`, 'frame content', undefined)
118+
expect(fileOutput).toHaveBeenCalledWith(`${expectedTmpDir}/fuel/file2.txt`, 'fuel content', undefined)
119119
expect(logger.success).toHaveBeenCalledWith('Extracted successfully.')
120120
expect(logger.start).toHaveBeenCalledWith('Assembling the config according to `rocketConfig`...')
121121
expect(rocketAssemble).toHaveBeenCalledWith({

0 commit comments

Comments
 (0)