Skip to content

Commit 694a342

Browse files
authored
feat(config): support shared configs with define.extends (#544)
1 parent 690c744 commit 694a342

11 files changed

Lines changed: 216 additions & 25 deletions

File tree

‎packages/rstack/src/config.ts‎

Lines changed: 34 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,37 +13,35 @@ import type { UserConfig, UserConfigAsyncFn } from '@rspress/core';
1313
import type { RstestConfigExport } from '@rstest/core';
1414
import type { FmtConfigDefinition } from './fmt/types.ts';
1515
import type { StagedConfig } from './staged.ts';
16-
import { composeConfigLayers } from './configLayers.ts';
16+
import { composeConfigLayers, flattenConfigLayers } from './configLayers.ts';
1717

18-
export type RslintConfigDefinition =
19-
RslintConfig | (() => Promise<RslintConfig>);
2018
export type RspressConfigDefinition = UserConfig | UserConfigAsyncFn;
2119

2220
type RslintConfigFactory = (
2321
lint: typeof import('@rslint/core'),
2422
) => RslintConfig | Promise<RslintConfig>;
2523

26-
type RslintConfigInput = RslintConfig | RslintConfigFactory;
24+
export type RslintConfigDefinition = RslintConfig | RslintConfigFactory;
2725

2826
export type Configs = {
2927
app?: RsbuildConfigDefinition;
3028
lib?: RslibConfigDefinition;
3129
doc?: RspressConfigDefinition;
3230
test?: RstestConfigExport;
33-
lint?: RslintConfigDefinition;
31+
lint?: RslintConfig | (() => Promise<RslintConfig>);
3432
fmt?: FmtConfigDefinition;
3533
staged?: StagedConfig;
3634
};
3735

3836
/** Shared configuration input; lint factories receive the tool exports. */
3937
export type RstackConfig = Omit<Configs, 'lint'> & {
4038
extends?: readonly RstackConfig[];
41-
lint?: RslintConfigInput;
39+
lint?: RslintConfigDefinition;
4240
};
4341

4442
const normalizeLintConfig = (
45-
config: RslintConfigInput,
46-
): RslintConfigDefinition =>
43+
config: RslintConfigDefinition,
44+
): NonNullable<Configs['lint']> =>
4745
typeof config === 'function'
4846
? async () => config(await import('@rslint/core'))
4947
: config;
@@ -82,6 +80,7 @@ export type LoadRstackConfigOptions = {
8280

8381
type ConfigSession = {
8482
configs: Configs;
83+
extends?: readonly RstackConfig[];
8584
active: boolean;
8685
};
8786

@@ -125,6 +124,13 @@ export const getConfigState = (): ConfigState => {
125124
};
126125

127126
type Define = {
127+
/**
128+
* Inherits shared configs from left to right, followed by the project's own
129+
* definitions regardless of call order. May only be called once per config load.
130+
*
131+
* @see {@link https://rstack.rs/config | Configuration guide}
132+
*/
133+
extends: (configs: readonly RstackConfig[]) => void;
128134
/**
129135
* Defines the Rsbuild config for the app.
130136
*
@@ -169,7 +175,7 @@ type Define = {
169175
*
170176
* @see {@link https://rstack.rs/config | Configuration guide}
171177
*/
172-
lint: (config: RslintConfigInput) => void;
178+
lint: (config: RslintConfigDefinition) => void;
173179
/**
174180
* Defines the Prettier config for formatting.
175181
*
@@ -207,6 +213,18 @@ const setConfig = <T extends keyof Configs>(
207213
};
208214

209215
export const define: Define = {
216+
extends: (configs) => {
217+
const session = getConfigSessionStorage().getStore();
218+
if (!session?.active) {
219+
throw new Error(
220+
'The "extends" config must be defined while loading an Rstack config.',
221+
);
222+
}
223+
if (session.extends !== undefined) {
224+
throw new Error('The "extends" config has already been defined.');
225+
}
226+
session.extends = configs;
227+
},
210228
app: ((config) => {
211229
setConfig('app', config);
212230
return config;
@@ -253,13 +271,19 @@ export const loadRstackConfig = async ({
253271
});
254272

255273
return {
256-
configs: await composeConfigLayers([session.configs]),
274+
configs: session.extends?.length
275+
? await composeConfigLayers([
276+
...flattenConfigLayers(session.extends),
277+
session.configs,
278+
])
279+
: session.configs,
257280
filePath,
258281
dependencies,
259282
};
260283
} finally {
261284
session.active = false;
262285
session.configs = {};
286+
session.extends = undefined;
263287
}
264288
});
265289
};

‎packages/rstack/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export { define } from './config.ts';
1+
export { define, type RstackConfig } from './config.ts';
22
export { runCLI } from './cli/index.ts';
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { define } from 'rstack';
2+
import { sharedConfig } from './shared.ts';
3+
4+
define.extends([
5+
sharedConfig,
6+
{
7+
fmt: { printWidth: 80 },
8+
},
9+
]);
10+
define.fmt({ singleQuote: false });
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import path from 'node:path';
2+
import { expect, test } from 'rstack/test';
3+
import { loadRstackConfig } from '../../../src/config.ts';
4+
import { resolveConfigLayers } from '../../../src/configLayers.ts';
5+
6+
const cwd = import.meta.dirname;
7+
const params = { command: 'build', env: 'production' } as const;
8+
9+
test('loads all seven tools from shared configs and tracks their dependencies', async () => {
10+
const { configs, filePath, dependencies } = await loadRstackConfig({ cwd });
11+
const layers = [configs];
12+
13+
expect({
14+
app: await resolveConfigLayers(layers, 'app', params),
15+
lib: await resolveConfigLayers(layers, 'lib', params),
16+
doc: await resolveConfigLayers(layers, 'doc'),
17+
test: await resolveConfigLayers(layers, 'test'),
18+
lint: await resolveConfigLayers(layers, 'lint'),
19+
fmt: await resolveConfigLayers(layers, 'fmt'),
20+
staged: await resolveConfigLayers(layers, 'staged'),
21+
}).toEqual({
22+
app: [
23+
{
24+
source: {
25+
define: { ENV: 'production' },
26+
},
27+
},
28+
],
29+
lib: [
30+
{
31+
lib: [{ format: 'esm' }],
32+
},
33+
],
34+
doc: [{ root: 'docs' }],
35+
test: [{ setupFiles: [path.join(cwd, 'setup.ts')] }],
36+
lint: [
37+
[
38+
{
39+
rules: { 'no-debugger': 'error' },
40+
},
41+
],
42+
],
43+
fmt: [{ semi: false, singleQuote: false, printWidth: 80 }],
44+
staged: [{ '*.ts': 'rs lint' }],
45+
});
46+
expect(filePath).toBe(path.join(cwd, 'rstack.config.ts'));
47+
expect(dependencies).toContain(path.join(cwd, 'shared.ts'));
48+
});
49+
50+
test('preserves project priority regardless of call order and across reloads', async () => {
51+
for (const configFilePath of [
52+
'rstack.config.ts',
53+
'after.config.ts',
54+
'rstack.config.ts',
55+
]) {
56+
const { configs } = await loadRstackConfig({ cwd, configFilePath });
57+
expect(await resolveConfigLayers([configs], 'fmt')).toEqual([
58+
{ semi: false, singleQuote: false, printWidth: 80 },
59+
]);
60+
}
61+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { define } from 'rstack';
2+
import { sharedConfig } from './shared.ts';
3+
4+
define.fmt({ singleQuote: false });
5+
define.extends([
6+
sharedConfig,
7+
{
8+
fmt: { printWidth: 80 },
9+
},
10+
]);
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { fileURLToPath } from 'node:url';
2+
import type { RstackConfig } from 'rstack';
3+
4+
export const sharedConfig: RstackConfig = {
5+
extends: [
6+
{
7+
fmt: { semi: false, printWidth: 100 },
8+
},
9+
],
10+
app: ({ env }) => ({
11+
source: {
12+
define: { ENV: env },
13+
},
14+
}),
15+
lib: {
16+
lib: [{ format: 'esm' }],
17+
},
18+
doc: { root: 'docs' },
19+
test: { setupFiles: [fileURLToPath(new URL('./setup.ts', import.meta.url))] },
20+
lint: ({ defineConfig }) =>
21+
defineConfig([
22+
{
23+
rules: { 'no-debugger': 'error' },
24+
},
25+
]),
26+
fmt: { singleQuote: true },
27+
staged: { '*.ts': 'rs lint' },
28+
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { define } from 'rstack';
2+
3+
define.extends([]);
4+
define.extends([]);

‎packages/rstack/tests/config/load-config/index.test.ts‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import path from 'node:path';
22
import { afterEach, expect, test } from 'rstack/test';
3-
import { getConfigState, loadRstackConfig } from '../../../src/config.ts';
3+
import {
4+
define,
5+
getConfigState,
6+
loadRstackConfig,
7+
} from '../../../src/config.ts';
48

59
type Deferred = ReturnType<typeof Promise.withResolvers<void>>;
610

@@ -37,7 +41,9 @@ test('should discard a config session after loading fails', async () => {
3741
await expect(loadRstackConfig()).rejects.toThrow('test config error');
3842

3943
const { configs } = await loadConfigFile('explicit.config.ts');
40-
expect(configs).toEqual({ app: {} });
44+
expect(configs).toEqual({
45+
app: {},
46+
});
4147
});
4248

4349
test('should prefer an explicit config path over the state config path', async () => {
@@ -111,3 +117,18 @@ test('should reject duplicate definitions within the same session', async () =>
111117
'The "app" config has already been defined.',
112118
);
113119
});
120+
121+
test('rejects repeated extends calls even with empty arrays', async () => {
122+
await expect(loadConfigFile('duplicate-extends.config.ts')).rejects.toThrow(
123+
'The "extends" config has already been defined.',
124+
);
125+
expect((await loadConfigFile('explicit.config.ts')).configs).toEqual({
126+
app: {},
127+
});
128+
});
129+
130+
test('rejects extends outside a config load', () => {
131+
expect(() => define.extends([])).toThrow(
132+
'The "extends" config must be defined while loading an Rstack config.',
133+
);
134+
});

‎packages/rstack/tests/types/resolution-bundler/index.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import 'rstack/test/globals';
33
import 'rstack/test/importMeta';
44
import 'rstack/types';
5-
import { define } from 'rstack';
5+
import { define, type RstackConfig } from 'rstack';
66
import { createRsbuild, defineConfig as defineAppConfig } from 'rstack/app';
77
import {
88
loadRstackConfig,
@@ -22,6 +22,11 @@ const loadOptions: LoadRstackConfigOptions = {
2222
};
2323
const loadedConfig: Promise<LoadedRstackConfig> = loadRstackConfig(loadOptions);
2424
const configs: Configs = {};
25+
const sharedConfig: RstackConfig = {
26+
app: appConfig,
27+
lint: lintConfig,
28+
};
29+
define.extends([{ extends: [sharedConfig] }] as const);
2530

2631
void loadedConfig;
2732
void configs;
@@ -50,11 +55,15 @@ define.lib(() => ({
5055
lib: [
5156
{
5257
format: 'esm',
53-
source: { entry: { index: './src/index.ts' } },
58+
source: {
59+
entry: { index: './src/index.ts' },
60+
},
5461
},
5562
{
5663
format: 'esm',
57-
source: { entry: { worker: './src/worker.ts' } },
64+
source: {
65+
entry: { worker: './src/worker.ts' },
66+
},
5867
},
5968
],
6069
}));

‎packages/rstack/tests/types/resolution-nodenext/index.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import 'rstack/test/globals';
33
import 'rstack/test/importMeta';
44
import 'rstack/types';
5-
import { define } from 'rstack';
5+
import { define, type RstackConfig } from 'rstack';
66
import { createRsbuild, defineConfig as defineAppConfig } from 'rstack/app';
77
import {
88
loadRstackConfig,
@@ -22,6 +22,11 @@ const loadOptions: LoadRstackConfigOptions = {
2222
};
2323
const loadedConfig: Promise<LoadedRstackConfig> = loadRstackConfig(loadOptions);
2424
const configs: Configs = {};
25+
const sharedConfig: RstackConfig = {
26+
app: appConfig,
27+
lint: lintConfig,
28+
};
29+
define.extends([{ extends: [sharedConfig] }] as const);
2530

2631
void loadedConfig;
2732
void configs;
@@ -50,11 +55,15 @@ define.lib(() => ({
5055
lib: [
5156
{
5257
format: 'esm',
53-
source: { entry: { index: './src/index.ts' } },
58+
source: {
59+
entry: { index: './src/index.ts' },
60+
},
5461
},
5562
{
5663
format: 'esm',
57-
source: { entry: { worker: './src/worker.ts' } },
64+
source: {
65+
entry: { worker: './src/worker.ts' },
66+
},
5867
},
5968
],
6069
}));

0 commit comments

Comments
 (0)