diff --git a/packages/rstack/src/config.ts b/packages/rstack/src/config.ts index cf03b0b9..9f1ec1e4 100644 --- a/packages/rstack/src/config.ts +++ b/packages/rstack/src/config.ts @@ -22,6 +22,8 @@ type RslintConfigFactory = ( lint: typeof import('@rslint/core'), ) => RslintConfig | Promise; +type RslintConfigInput = RslintConfig | RslintConfigFactory; + export type Configs = { app?: RsbuildConfigDefinition; lib?: RslibConfigDefinition; @@ -32,6 +34,29 @@ export type Configs = { staged?: StagedConfig; }; +/** Shared configuration input; lint factories receive the tool exports. */ +export type RstackConfig = Omit & { + extends?: readonly RstackConfig[]; + lint?: RslintConfigInput; +}; + +const normalizeLintConfig = ( + config: RslintConfigInput, +): RslintConfigDefinition => + typeof config === 'function' + ? async () => config(await import('@rslint/core')) + : config; + +/** Normalize one shared configuration without resolving factories or inheritance. */ +export const normalizeRstackConfig = ({ + extends: _extends, + lint, + ...configs +}: RstackConfig): Configs => + lint === undefined + ? configs + : { ...configs, lint: normalizeLintConfig(lint) }; + export type LoadedRstackConfig = { configs: Configs; filePath: string | null; @@ -142,7 +167,7 @@ type Define = { * * @see {@link https://rstack.rs/config | Configuration guide} */ - lint: (config: RslintConfig | RslintConfigFactory) => void; + lint: (config: RslintConfigInput) => void; /** * Defines the Prettier config for formatting. * @@ -190,13 +215,7 @@ export const define: Define = { }, doc: (config) => setConfig('doc', config), test: (config) => setConfig('test', config), - lint: (config) => - setConfig( - 'lint', - typeof config === 'function' - ? async () => config(await import('@rslint/core')) - : config, - ), + lint: (config) => setConfig('lint', normalizeLintConfig(config)), fmt: (config) => setConfig('fmt', config), staged: (config) => setConfig('staged', config), }; diff --git a/packages/rstack/tests/config/normalize.test.ts b/packages/rstack/tests/config/normalize.test.ts new file mode 100644 index 00000000..50ab40c6 --- /dev/null +++ b/packages/rstack/tests/config/normalize.test.ts @@ -0,0 +1,46 @@ +import { expect, rs, test } from 'rstack/test'; +import { normalizeRstackConfig, type RstackConfig } from '../../src/config.ts'; +import { resolveConfigLayers } from '../../src/configLayers.ts'; + +test('preserves tool definitions without resolving factories or inheritance', () => { + const app = rs.fn(() => ({})); + const staged = rs.fn(() => ['rs lint']); + const shared: RstackConfig = { + extends: [{ fmt: { singleQuote: true } }], + app, + staged, + }; + + expect(normalizeRstackConfig(shared)).toEqual({ app, staged }); + expect(shared.extends).toEqual([{ fmt: { singleQuote: true } }]); + expect(app).not.toHaveBeenCalled(); + expect(staged).not.toHaveBeenCalled(); +}); + +test('resolves sync and async lint factories lazily with tool exports', async () => { + const syncLint = rs.fn((lint: typeof import('@rslint/core')) => [ + lint.js.configs.recommended, + ]); + const asyncLint = rs.fn((lint: typeof import('@rslint/core')) => + Promise.resolve([lint.ts.configs.recommended]), + ); + const shared: RstackConfig[] = [ + { lint: [] }, + { lint: syncLint }, + { lint: asyncLint }, + ]; + const configs = shared.map(normalizeRstackConfig); + + expect(syncLint).not.toHaveBeenCalled(); + expect(asyncLint).not.toHaveBeenCalled(); + expect(shared[1].lint).toBe(syncLint); + + const resolved = await resolveConfigLayers(configs, 'lint'); + const { js, ts } = await import('@rslint/core'); + + expect(resolved).toEqual([ + [], + [js.configs.recommended], + [ts.configs.recommended], + ]); +}); diff --git a/packages/rstack/tests/types/shared-config/index.ts b/packages/rstack/tests/types/shared-config/index.ts new file mode 100644 index 00000000..fa9e5535 --- /dev/null +++ b/packages/rstack/tests/types/shared-config/index.ts @@ -0,0 +1,51 @@ +import type { RstackConfig } from '../../../src/config.ts'; + +export const baseConfig: RstackConfig = { + app: { source: { entry: { index: './src/index.ts' } } }, + lib: { lib: [{ format: 'esm' }] }, + doc: { title: 'Docs' }, + test: { retry: 2 }, + lint: [], + fmt: { singleQuote: true }, + staged: { '*.ts': 'rs lint' }, +}; + +export const syncConfig: RstackConfig = { + extends: [baseConfig] as const, + app: ({ command }) => ({ + source: { define: { COMMAND: JSON.stringify(command) } }, + }), + lib: ({ env }) => ({ + lib: [{ format: 'esm' }], + mode: env === 'production' ? 'production' : 'development', + }), + test: () => ({ retry: 1 }), + lint: ({ js, ts }) => [js.configs.recommended, ts.configs.recommended], + fmt: () => ({ singleQuote: true }), + staged: (files) => (files.length ? ['rs lint'] : []), +}; + +export const asyncConfig: RstackConfig = { + app: ({ env }) => + Promise.resolve({ + source: { define: { ENV: JSON.stringify(env) } }, + }), + lib: () => Promise.resolve({ lib: [{ format: 'esm' }] }), + doc: () => Promise.resolve({ title: 'Docs' }), + test: () => Promise.resolve({ retry: 2 }), + lint: ({ js }) => Promise.resolve([js.configs.recommended]), + fmt: () => Promise.resolve({ singleQuote: true }), + staged: (files) => Promise.resolve(files.length ? ['rs fmt'] : []), +}; + +export function sharedConfig(options: { retry?: number } = {}): RstackConfig { + return { + extends: [syncConfig, asyncConfig], + test: { retry: options.retry ?? 2 }, + }; +} + +export const invalidConfig: RstackConfig = { + // @ts-expect-error Lint factories receive tool exports, not build parameters. + lint: (_params: { env: string }) => [], +}; diff --git a/packages/rstack/tests/types/shared-config/tsconfig.json b/packages/rstack/tests/types/shared-config/tsconfig.json new file mode 100644 index 00000000..430aa357 --- /dev/null +++ b/packages/rstack/tests/types/shared-config/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "allowImportingTsExtensions": true + }, + "include": ["index.ts"] +}