Skip to content

Commit 9754d37

Browse files
shulandmimiErKeLost
andauthoredApr 30, 2024··
feat: merge configuration policies (#1264)
* feat: merge configuration policies * feat: merge more fields from params * chore: update types * chore: remove unless code * chore: add api example * chore: update config resolve * chore: remove example --------- Co-authored-by: ADNY <66500121+ErKeLost@users.noreply.github.com> Co-authored-by: erkelost <1256029807@qq.com>
1 parent 0ecfeef commit 9754d37

34 files changed

+345
-1035
lines changed
 

‎.changeset/famous-llamas-suffer.md

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@farmfe/core': patch
3+
'@farmfe/cli': patch
4+
---
5+
6+
merge configuration policies

‎packages/cli/src/config.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { FarmCLIOptions, UserConfig } from '@farmfe/core';
2+
import { FarmCLIBuildOptions, GlobalFarmCLIOptions } from './types.js';
3+
4+
export function getOptionFromBuildOption(
5+
options: FarmCLIBuildOptions & GlobalFarmCLIOptions
6+
): FarmCLIOptions & UserConfig {
7+
const {
8+
input,
9+
outDir,
10+
target,
11+
format,
12+
watch,
13+
minify,
14+
sourcemap,
15+
treeShaking,
16+
mode
17+
} = options;
18+
19+
const output: UserConfig['compilation']['output'] = {
20+
...(outDir && { path: outDir }),
21+
...(target && { targetEnv: target }),
22+
...(format && { format })
23+
};
24+
25+
const compilation: UserConfig['compilation'] = {
26+
input: { ...(input && { index: input }) },
27+
output,
28+
...(watch && { watch }),
29+
...(minify && { minify }),
30+
...(sourcemap && { sourcemap }),
31+
...(treeShaking && { treeShaking })
32+
};
33+
34+
const defaultOptions: FarmCLIOptions & UserConfig = {
35+
compilation,
36+
...(mode && { mode })
37+
};
38+
39+
return defaultOptions;
40+
}

‎packages/cli/src/index.ts

+5-52
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,12 @@ import { readFileSync } from 'node:fs';
33
import { cac } from 'cac';
44
import {
55
resolveCore,
6-
getConfigPath,
76
resolveCommandOptions,
87
handleAsyncOperationErrors,
98
preventExperimentalWarning,
10-
resolveRootPath,
119
resolveCliConfig
1210
} from './utils.js';
13-
import { COMMANDS } from './plugin/index.js';
11+
import { getOptionFromBuildOption } from './config.js';
1412

1513
import type {
1614
FarmCLIBuildOptions,
@@ -97,22 +95,8 @@ cli
9795

9896
const defaultOptions = {
9997
root,
100-
compilation: {
101-
watch: options.watch,
102-
output: {
103-
path: options?.outDir,
104-
targetEnv: options?.target,
105-
format: options?.format
106-
},
107-
input: {
108-
index: options?.input
109-
},
110-
sourcemap: options.sourcemap,
111-
minify: options.minify,
112-
treeShaking: options.treeShaking
113-
},
114-
mode: options.mode,
115-
configPath
98+
configPath,
99+
...getOptionFromBuildOption(options)
116100
};
117101

118102
const { build } = await resolveCore();
@@ -138,21 +122,8 @@ cli
138122

139123
const defaultOptions = {
140124
root,
141-
compilation: {
142-
output: {
143-
path: options?.outDir,
144-
targetEnv: options?.target,
145-
format: options?.format
146-
},
147-
input: {
148-
index: options?.input
149-
},
150-
sourcemap: options.sourcemap,
151-
minify: options.minify,
152-
treeShaking: options.treeShaking
153-
},
154-
mode: options.mode,
155-
configPath
125+
configPath,
126+
...getOptionFromBuildOption(options)
156127
};
157128

158129
const { watch } = await resolveCore();
@@ -211,24 +182,6 @@ cli
211182
}
212183
});
213184

214-
// create plugins command
215-
cli
216-
.command('plugin [command]', 'Commands for manage plugins', {
217-
allowUnknownOptions: true
218-
})
219-
.action(async (command: keyof typeof COMMANDS, args: unknown) => {
220-
try {
221-
COMMANDS[command](args);
222-
} catch (e) {
223-
const { Logger } = await import('@farmfe/core');
224-
const logger = new Logger();
225-
logger.error(
226-
`The command arg parameter is incorrect. If you want to create a plugin in farm. such as "farm plugin create"\n${e.stack}`
227-
);
228-
process.exit(1);
229-
}
230-
});
231-
232185
// Listening for unknown command
233186
cli.on('command:*', async () => {
234187
const { Logger } = await import('@farmfe/core');

‎packages/cli/src/plugin/build.ts

-92
This file was deleted.

‎packages/cli/src/plugin/create.ts

-124
This file was deleted.

‎packages/cli/src/plugin/index.ts

-31
This file was deleted.

‎packages/cli/src/plugin/prepublish.ts

-48
This file was deleted.

‎packages/cli/templates/js-plugin/farm.config.mjs

-34
This file was deleted.

‎packages/cli/templates/js-plugin/package.json

-41
This file was deleted.

‎packages/cli/templates/js-plugin/src/index.ts

-39
This file was deleted.

‎packages/cli/templates/js-plugin/tsconfig.build.json

-10
This file was deleted.

‎packages/cli/templates/js-plugin/tsconfig.json

-21
This file was deleted.

‎packages/cli/templates/rust-plugin/.eslintrc.json

-7
This file was deleted.

‎packages/cli/templates/rust-plugin/.gitignore

-197
This file was deleted.

‎packages/cli/templates/rust-plugin/Cargo.toml

-11
This file was deleted.

‎packages/cli/templates/rust-plugin/index.d.ts

-2
This file was deleted.

‎packages/cli/templates/rust-plugin/index.js

-69
This file was deleted.

‎packages/cli/templates/rust-plugin/npm/darwin-arm64/README.md

-3
This file was deleted.

‎packages/cli/templates/rust-plugin/npm/darwin-arm64/package.json

-18
This file was deleted.

‎packages/cli/templates/rust-plugin/npm/darwin-x64/README.md

-3
This file was deleted.

‎packages/cli/templates/rust-plugin/npm/darwin-x64/package.json

-18
This file was deleted.

‎packages/cli/templates/rust-plugin/npm/linux-x64-gnu/README.md

-3
This file was deleted.

‎packages/cli/templates/rust-plugin/npm/linux-x64-gnu/package.json

-21
This file was deleted.

‎packages/cli/templates/rust-plugin/npm/win32-x64-msvc/README.md

-3
This file was deleted.

‎packages/cli/templates/rust-plugin/npm/win32-x64-msvc/package.json

-18
This file was deleted.

‎packages/cli/templates/rust-plugin/package.json

-26
This file was deleted.

‎packages/cli/templates/rust-plugin/rustfmt.toml

-2
This file was deleted.

‎packages/cli/templates/rust-plugin/src/lib.rs

-20
This file was deleted.

‎packages/core/src/config/index.ts

+33-116
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import { traceDependencies } from '../utils/trace-dependencies.js';
4545
import type {
4646
Alias,
4747
FarmCLIOptions,
48-
FarmCLIServerOptions,
4948
NormalizedServerConfig,
5049
ResolvedUserConfig,
5150
UserConfig,
@@ -57,6 +56,7 @@ import type {
5756
import { normalizeExternal } from './normalize-config/normalize-external.js';
5857
import { DEFAULT_CONFIG_NAMES, FARM_DEFAULT_NAMESPACE } from './constants.js';
5958
import merge from '../utils/merge.js';
59+
import { mergeConfig, mergeFarmCliConfig } from './mergeConfig.js';
6060

6161
export * from './types.js';
6262

@@ -73,23 +73,18 @@ export function defineFarmConfig(config: UserConfigExport): UserConfigExport {
7373
}
7474

7575
async function getDefaultConfig(
76+
config: UserConfig,
7677
inlineOptions: FarmCLIOptions,
7778
logger: Logger,
78-
mode?: CompilationMode,
79-
isHandleServerPortConflict = true
79+
mode?: CompilationMode
8080
) {
81-
const mergedUserConfig = mergeInlineCliOptions({}, inlineOptions);
82-
8381
const resolvedUserConfig = await resolveMergedUserConfig(
84-
mergedUserConfig,
82+
config,
8583
undefined,
8684
inlineOptions.mode ?? mode
8785
);
88-
resolvedUserConfig.server = normalizeDevServerOptions({}, mode);
8986

90-
if (isHandleServerPortConflict) {
91-
await handleServerPortConflict(resolvedUserConfig, logger, mode);
92-
}
87+
resolvedUserConfig.server = normalizeDevServerOptions({}, mode);
9388

9489
resolvedUserConfig.compilation = await normalizeUserCompilationConfig(
9590
resolvedUserConfig,
@@ -122,7 +117,7 @@ async function handleServerPortConflict(
122117
* @param configPath
123118
*/
124119
export async function resolveConfig(
125-
inlineOptions: FarmCLIOptions,
120+
inlineOptions: FarmCLIOptions & UserConfig,
126121
logger: Logger,
127122
mode?: CompilationMode,
128123
isHandleServerPortConflict = true
@@ -132,38 +127,36 @@ export async function resolveConfig(
132127
inlineOptions.mode = inlineOptions.mode ?? mode;
133128

134129
// configPath may be file or directory
135-
const { configPath } = inlineOptions;
136-
// if the config file can not found, just merge cli options and return default
137-
if (!configPath) {
138-
return getDefaultConfig(
139-
inlineOptions,
140-
logger,
141-
mode,
142-
isHandleServerPortConflict
143-
);
144-
}
145-
146-
if (!path.isAbsolute(configPath)) {
147-
throw new Error('configPath must be an absolute path');
148-
}
130+
let { configPath } = inlineOptions;
131+
let rawConfig: UserConfig = mergeFarmCliConfig(inlineOptions, {});
149132

150-
const loadedUserConfig = await loadConfigFile(
151-
configPath,
152-
inlineOptions,
153-
logger,
154-
mode
155-
);
133+
// if the config file can not found, just merge cli options and return default
134+
if (configPath) {
135+
if (!path.isAbsolute(configPath)) {
136+
throw new Error('configPath must be an absolute path');
137+
}
156138

157-
if (!loadedUserConfig) {
158-
return getDefaultConfig(
139+
const loadedUserConfig = await loadConfigFile(
140+
configPath,
159141
inlineOptions,
160142
logger,
161-
mode,
162-
isHandleServerPortConflict
143+
mode
144+
);
145+
if (loadedUserConfig) {
146+
configPath = loadedUserConfig.configFilePath;
147+
rawConfig = mergeConfig(rawConfig, loadedUserConfig.config);
148+
}
149+
} else {
150+
mergeConfig(
151+
rawConfig,
152+
await getDefaultConfig(rawConfig, inlineOptions, logger, mode)
163153
);
164154
}
165155

166-
const { config: userConfig, configFilePath } = loadedUserConfig;
156+
const { config: userConfig, configFilePath } = {
157+
configFilePath: configPath,
158+
config: rawConfig
159+
};
167160

168161
const { jsPlugins, rustPlugins } = await resolveFarmPlugins(userConfig);
169162

@@ -190,7 +183,7 @@ export async function resolveConfig(
190183

191184
const config = await resolveConfigHook(userConfig, sortFarmJsPlugins);
192185

193-
const mergedUserConfig = mergeInlineCliOptions(config, inlineOptions);
186+
const mergedUserConfig = mergeFarmCliConfig(inlineOptions, config);
194187

195188
const resolvedUserConfig = await resolveMergedUserConfig(
196189
mergedUserConfig,
@@ -619,10 +612,10 @@ async function readConfigFile(
619612
'.farm'
620613
);
621614

622-
const fileName = `farm.config.bundle-{${Date.now()}-${Math.random()
615+
const fileName = `farm.config.bundle-${Date.now()}-${Math.random()
623616
.toString(16)
624617
.split('.')
625-
.join('')}}.mjs`;
618+
.join('')}.mjs`;
626619

627620
const normalizedConfig = await normalizeUserCompilationConfig(
628621
{
@@ -770,83 +763,7 @@ function checkClearScreen(inlineConfig: FarmCLIOptions) {
770763
}
771764
}
772765

773-
function mergeInlineCliOptions(
774-
userConfig: UserConfig,
775-
inlineOptions: FarmCLIOptions
776-
): UserConfig {
777-
const configRootPath = userConfig.root;
778-
if (inlineOptions.root) {
779-
const cliRoot = inlineOptions.root;
780-
781-
if (!isAbsolute(cliRoot)) {
782-
userConfig.root = path.resolve(process.cwd(), cliRoot);
783-
} else {
784-
userConfig.root = cliRoot;
785-
}
786-
}
787-
788-
if (configRootPath) {
789-
userConfig.root = configRootPath;
790-
}
791-
792-
if (userConfig.root && !isAbsolute(userConfig.root)) {
793-
const resolvedRoot = path.resolve(
794-
inlineOptions.configPath,
795-
userConfig.root
796-
);
797-
userConfig.root = resolvedRoot;
798-
}
799-
800-
// set compiler options
801-
['minify', 'sourcemap'].forEach((option: keyof FarmCLIOptions) => {
802-
if (inlineOptions[option] !== undefined) {
803-
userConfig.compilation = {
804-
...(userConfig.compilation ?? {}),
805-
[option]: inlineOptions[option]
806-
};
807-
}
808-
});
809-
810-
const outputOptions = inlineOptions.compilation?.output;
811-
812-
if (outputOptions?.path) {
813-
userConfig.compilation = {
814-
...(userConfig.compilation ?? {})
815-
};
816-
817-
userConfig.compilation.output = {
818-
...(userConfig.compilation.output ?? {}),
819-
path: outputOptions?.path
820-
};
821-
}
822-
823-
if (outputOptions?.targetEnv) {
824-
userConfig.compilation = {
825-
...(userConfig.compilation ?? {})
826-
};
827-
828-
userConfig.compilation.output = {
829-
...(userConfig.compilation.output ?? {}),
830-
targetEnv: outputOptions?.targetEnv
831-
};
832-
}
833-
834-
// set server options
835-
['port', 'open', 'https', 'hmr', 'host', 'strictPort'].forEach(
836-
(option: keyof FarmCLIServerOptions) => {
837-
if (inlineOptions.server?.[option]) {
838-
userConfig.server = {
839-
...(userConfig.server ?? {}),
840-
[option]: inlineOptions.server[option]
841-
};
842-
}
843-
}
844-
);
845-
846-
return userConfig;
847-
}
848-
849-
async function resolveMergedUserConfig(
766+
export async function resolveMergedUserConfig(
850767
mergedUserConfig: UserConfig,
851768
configFilePath: string | undefined,
852769
mode: 'development' | 'production' | string
+137
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import path, { isAbsolute } from 'node:path';
2+
import { isString } from '../plugin/js/utils.js';
3+
import { isArray, isObject } from '../utils/share.js';
4+
import { FarmCLIOptions, UserConfig } from './types.js';
5+
6+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7+
export function mergeConfig<T extends Record<string, any>>(
8+
userConfig: T,
9+
target: T
10+
): T {
11+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
12+
const result: Record<string, any> = { ...userConfig };
13+
for (const key of Object.keys(target)) {
14+
const left = result[key];
15+
const right = target[key];
16+
17+
if (right === null || right === undefined) {
18+
continue;
19+
}
20+
21+
if (left === null || right === undefined) {
22+
result[key] = right;
23+
continue;
24+
}
25+
26+
if (isArray(left) || isArray(right)) {
27+
result[key] = [
28+
...(isArray(left) ? left : []),
29+
...(isArray(right) ? right : [])
30+
];
31+
continue;
32+
}
33+
34+
if (isObject(left) && isObject(right)) {
35+
result[key] = mergeConfig(left, right);
36+
continue;
37+
}
38+
39+
result[key] = right;
40+
}
41+
42+
return result as T;
43+
}
44+
45+
export function mergeFarmCliConfig(
46+
cliOption: FarmCLIOptions & UserConfig,
47+
target: UserConfig
48+
): UserConfig {
49+
let left: UserConfig = {};
50+
51+
(
52+
[
53+
'clearScreen',
54+
'compilation',
55+
'envDir',
56+
'envPrefix',
57+
'plugins',
58+
'publicDir',
59+
'server',
60+
'vitePlugins'
61+
] satisfies (keyof UserConfig)[]
62+
).forEach((key) => {
63+
const value = cliOption[key];
64+
if (value || typeof value === 'boolean') {
65+
left = mergeConfig(left, { [key]: cliOption[key] });
66+
}
67+
});
68+
69+
{
70+
// root
71+
const configRootPath = target.root;
72+
73+
if (cliOption.root) {
74+
const cliRoot = cliOption.root;
75+
76+
if (!isAbsolute(cliRoot)) {
77+
target.root = path.resolve(process.cwd(), cliRoot);
78+
} else {
79+
target.root = cliRoot;
80+
}
81+
}
82+
if (configRootPath) {
83+
target.root = configRootPath;
84+
}
85+
86+
if (target.root && !isAbsolute(target.root)) {
87+
const resolvedRoot = path.resolve(cliOption.configPath, target.root);
88+
target.root = resolvedRoot;
89+
}
90+
}
91+
92+
if (isString(cliOption.host) || typeof cliOption.host === 'boolean') {
93+
left = mergeConfig(left, { server: { host: cliOption.host } });
94+
}
95+
96+
if (typeof cliOption.minify === 'boolean') {
97+
left = mergeConfig(left, { compilation: { minify: cliOption.minify } });
98+
}
99+
100+
if (cliOption.outDir) {
101+
left = mergeConfig(left, {
102+
compilation: { output: { path: cliOption.outDir } }
103+
});
104+
}
105+
106+
if (cliOption.port) {
107+
left = mergeConfig(left, {
108+
server: {
109+
port: cliOption.port
110+
}
111+
});
112+
}
113+
114+
if (cliOption.mode) {
115+
left = mergeConfig(left, {
116+
compilation: {
117+
mode: cliOption.mode as UserConfig['compilation']['mode']
118+
}
119+
});
120+
}
121+
122+
if (cliOption.https) {
123+
left = mergeConfig(left, {
124+
server: {
125+
https: cliOption.https
126+
}
127+
});
128+
}
129+
130+
if (cliOption.sourcemap) {
131+
left = mergeConfig(left, {
132+
compilation: { sourcemap: cliOption.sourcemap }
133+
});
134+
}
135+
136+
return mergeConfig(left, target);
137+
}

‎packages/core/src/index.ts

+11-6
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ import { lazyCompilation } from './server/middlewares/lazy-compilation.js';
3737
import { resolveHostname } from './utils/http.js';
3838

3939
export async function start(
40-
inlineConfig: FarmCLIOptions & UserConfig
40+
inlineConfig?: FarmCLIOptions & UserConfig
4141
): Promise<void> {
42+
inlineConfig = inlineConfig ?? {};
4243
const logger = inlineConfig.logger ?? new Logger();
4344
setProcessEnv('development');
4445

@@ -76,15 +77,17 @@ export async function start(
7677
}
7778

7879
export async function build(
79-
inlineConfig: FarmCLIOptions & UserConfig
80+
inlineConfig?: FarmCLIOptions & UserConfig
8081
): Promise<void> {
82+
inlineConfig = inlineConfig ?? {};
8183
const logger = inlineConfig.logger ?? new Logger();
8284
setProcessEnv('production');
8385

8486
const resolvedUserConfig = await resolveConfig(
8587
inlineConfig,
8688
logger,
87-
'production'
89+
'production',
90+
false
8891
);
8992

9093
try {
@@ -96,7 +99,8 @@ export async function build(
9699
}
97100
}
98101

99-
export async function preview(inlineConfig: FarmCLIOptions): Promise<void> {
102+
export async function preview(inlineConfig?: FarmCLIOptions): Promise<void> {
103+
inlineConfig = inlineConfig ?? {};
100104
const logger = inlineConfig.logger ?? new Logger();
101105
const resolvedUserConfig = await resolveConfig(
102106
inlineConfig,
@@ -141,8 +145,9 @@ export async function preview(inlineConfig: FarmCLIOptions): Promise<void> {
141145
}
142146

143147
export async function watch(
144-
inlineConfig: FarmCLIOptions & UserConfig
148+
inlineConfig?: FarmCLIOptions & UserConfig
145149
): Promise<void> {
150+
inlineConfig = inlineConfig ?? {};
146151
const logger = inlineConfig.logger ?? new Logger();
147152
setProcessEnv('development');
148153

@@ -206,7 +211,7 @@ export async function watch(
206211

207212
export async function clean(
208213
rootPath: string,
209-
recursive: boolean | undefined
214+
recursive?: boolean | undefined
210215
): Promise<void> {
211216
// TODO After optimizing the reading of config, put the clean method into compiler
212217
const logger = new Logger();
+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { mergeFarmCliConfig } from '../../src/config/mergeConfig.js';
3+
import path from 'path';
4+
5+
describe('mergeFarmCliConfig', () => {
6+
test('inlineOption.root not empty', () => {
7+
const result = mergeFarmCliConfig({}, { root: '/path/to/' });
8+
9+
expect(result).toEqual({ root: '/path/to/' });
10+
});
11+
12+
test('userConfig.root not empty', () => {
13+
const result = mergeFarmCliConfig({ root: '/path/to/' }, {});
14+
15+
expect(result).toEqual({ root: '/path/to/' });
16+
});
17+
18+
test('userConfig.root both inlineOption not empty', () => {
19+
const result = mergeFarmCliConfig(
20+
{ root: '/path/to/inlineOption' },
21+
{ root: '/path/to/userConfig' }
22+
);
23+
24+
expect(result).toEqual({ root: '/path/to/userConfig' });
25+
});
26+
27+
test('userConfig.root relative, should have configPath', () => {
28+
expect(() => {
29+
mergeFarmCliConfig({ root: './path/to/' }, { root: './path/userConfig' });
30+
}).toThrow();
31+
32+
const result = mergeFarmCliConfig(
33+
{ root: './path/to/', configPath: process.cwd() },
34+
{ root: './path/userConfig' }
35+
);
36+
37+
expect(result).toEqual({
38+
root: path.resolve(process.cwd(), './path/userConfig')
39+
});
40+
});
41+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { mergeConfig } from '../../src/config/mergeConfig.js';
3+
import { UserConfig } from '../../src/index.js';
4+
5+
describe('mergeConfig', () => {
6+
test('merge object', () => {
7+
const fileConfig: UserConfig = {
8+
compilation: {
9+
input: {
10+
index: 'src/index.ts'
11+
}
12+
}
13+
};
14+
15+
const inputConfig: UserConfig = {
16+
compilation: {
17+
input: {
18+
index2: 'src/index.ts'
19+
}
20+
}
21+
};
22+
const result: UserConfig = mergeConfig(fileConfig, inputConfig);
23+
24+
expect(result).toEqual({
25+
compilation: {
26+
input: {
27+
index: 'src/index.ts',
28+
index2: 'src/index.ts'
29+
}
30+
}
31+
});
32+
});
33+
34+
test('merge arr', () => {
35+
const fileConfig: UserConfig = {
36+
plugins: ['a']
37+
};
38+
39+
const inputConfig: UserConfig = {
40+
plugins: ['b'],
41+
vitePlugins: [{ name: 'test' }]
42+
};
43+
const result: UserConfig = mergeConfig(fileConfig, inputConfig);
44+
45+
expect(result).toEqual({
46+
plugins: ['a', 'b'],
47+
vitePlugins: [{ name: 'test' }]
48+
});
49+
});
50+
51+
test('merge right to left', () => {
52+
const fileConfig: UserConfig = {};
53+
54+
const inputConfig: UserConfig = {
55+
plugins: ['b']
56+
};
57+
const result: UserConfig = mergeConfig(fileConfig, inputConfig);
58+
59+
expect(result).toEqual({
60+
plugins: ['b']
61+
});
62+
});
63+
});

‎pnpm-lock.yaml

+9
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)
Please sign in to comment.