Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion tools/workspace-plugin/src/executors/build/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { NormalizedOptions, normalizeOptions, processAsyncQueue, runInParallel,

import { measureEnd, measureStart } from '../../utils';
import generateApiExecutor from '../generate-api/executor';
import { type GenerateApiExecutorSchema } from '../generate-api/schema';

import { type BuildExecutorSchema } from './schema';

Expand All @@ -22,7 +23,14 @@ const runExecutor: PromiseExecutor<BuildExecutorSchema> = async (schema, context
() =>
runInParallel(
() => runBuild(options, context),
() => (options.generateApi ? generateApiExecutor({}, context).then(res => res.success) : Promise.resolve(true)),
() => {
if (!options.generateApi) {
return Promise.resolve(true);
}
const generateApiSchema: GenerateApiExecutorSchema =
typeof options.generateApi === 'object' ? options.generateApi : {};
return generateApiExecutor(generateApiSchema, context).then(res => res.success);
},
),
() => copyAssets(assetFiles),
);
Expand Down
2 changes: 1 addition & 1 deletion tools/workspace-plugin/src/executors/build/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export interface BuildExecutorSchema {
/**
* Generate rolluped 'd.ts' bundle including 'api.md' that provides project public API
*/
generateApi?: boolean;
generateApi?: boolean | { exportSubpaths?: boolean | { apiReport?: boolean } };
/**
* Enable Griffel raw styles output.
* This will generate additional files with '.styles.raw.js' extension that contain Griffel raw styles
Expand Down
28 changes: 26 additions & 2 deletions tools/workspace-plugin/src/executors/build/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,32 @@
}
},
"generateApi": {
"type": "boolean",
"description": "Generate rolluped 'd.ts' bundle including 'api.md' that provides project public API",
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"properties": {
"exportSubpaths": {
"oneOf": [
{ "type": "boolean" },
{
"type": "object",
"properties": {
"apiReport": {
"type": "boolean",
"description": "Whether to generate api.md reports for each resolved sub-path entry."
}
},
"additionalProperties": false
}
],
"description": "Whether to read non-root export map entries from package.json and run api-extractor for each resolved sub-path."
}
},
"additionalProperties": false
}
],
"description": "Generate rolluped 'd.ts' bundle including 'api.md' that provides project public API. Pass an object to configure generate-api executor options.",
"default": true
},
"enableGriffelRawStyles": {
Expand Down
261 changes: 261 additions & 0 deletions tools/workspace-plugin/src/executors/generate-api/executor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,3 +232,264 @@ describe('GenerateApi Executor', () => {
expect(output.success).toBe(true);
});
});

// ─────────────────────────────────────────────────────────────────────────────
// Export subpath resolution
// ─────────────────────────────────────────────────────────────────────────────

describe('GenerateApi Executor – export subpath resolution', () => {
afterEach(() => {
cleanup();
});

/**
* Creates a fixture with configurable export map entries.
* The primary api-extractor.json uses a relative path from config/ to dts/src/.
*/
function prepareExportFixture(config: { wildcardSubDirs?: string[]; namedExports?: string[] }) {
const { wildcardSubDirs = [], namedExports = [] } = config;
const { paths, context } = prepareFixture('valid', {});
const { projRoot } = paths;

const exports: Record<string, unknown> = {
'.': { types: './dist/index.d.ts', import: './lib/index.js' },
};
if (namedExports.length > 0) {
for (const name of namedExports) {
exports[`./${name}`] = { types: `./dist/${name}/index.d.ts`, import: `./lib/${name}/index.js` };
}
}
if (wildcardSubDirs.length > 0) {
exports['./*'] = { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' };
}
exports['./package.json'] = './package.json';

writeFileSync(
join(projRoot, 'package.json'),
serializeJson({ name: '@proj/proj', types: 'dist/index.d.ts', exports }),
'utf-8',
);

writeFileSync(
join(projRoot, 'config', 'api-extractor.json'),
serializeJson({
mainEntryPointFilePath: '../dts/src/index.d.ts',
apiReport: { enabled: false },
docModel: { enabled: false },
dtsRollup: { enabled: true },
tsdocMetadata: { enabled: false },
}),
'utf-8',
);

execSyncMock.mockImplementation(() => {
mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true });
writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const root: 1;', 'utf-8');
for (const name of namedExports) {
mkdirSync(join(projRoot, 'dts', 'src', name), { recursive: true });
writeFileSync(join(projRoot, 'dts', 'src', name, 'index.d.ts'), `export const ${name}: string;`, 'utf-8');
}
for (const name of wildcardSubDirs) {
mkdirSync(join(projRoot, 'dts', 'src', 'items', name), { recursive: true });
writeFileSync(
join(projRoot, 'dts', 'src', 'items', name, 'index.d.ts'),
`export const value: string;`,
'utf-8',
);
}
});

return { paths, context };
}

// ── Wildcard exports ──────────────────────────────────────────────────────

it('generates correct configs for each wildcard sub-directory', async () => {
const subDirs = ['alpha', 'beta', 'gamma'];
const { paths, context } = prepareExportFixture({ wildcardSubDirs: subDirs });

const capturedConfigs: ExtractorConfig[] = [];
jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => {
capturedConfigs.push(cfg);
return { succeeded: true } as ExtractorResult;
});

const output = await executor({ ...options, exportSubpaths: true }, context);

// primary (1) + one per sub-directory
expect(capturedConfigs).toHaveLength(1 + subDirs.length);
expect(output.success).toBe(true);

const wildcardConfigs = capturedConfigs.slice(1);
for (const name of subDirs) {
const cfg = wildcardConfigs.find(c => c.mainEntryPointFilePath.includes(`items/${name}/`))!;
expect(cfg.mainEntryPointFilePath).toContain(`items/${name}/index.d.ts`);
expect(cfg.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'items', name, 'index.d.ts'));
expect(cfg.apiReportEnabled).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-deprecated
expect(cfg.reportFilePath).toBe(join(paths.projRoot, 'etc', `${name}.api.md`));
// eslint-disable-next-line @typescript-eslint/no-deprecated
expect(cfg.reportTempFilePath).toBe(join(paths.projRoot, 'temp', `${name}.api.md`));
}
});

it('skips wildcard exports with no types field', async () => {
const { paths, context } = prepareFixture('valid', {});
const { projRoot } = paths;

writeFileSync(
join(projRoot, 'package.json'),
serializeJson({
name: '@proj/proj',
types: 'dist/index.d.ts',
exports: {
'.': { import: './lib/index.js' },
'./*': { import: './lib/items/*/index.js' }, // no types field
'./package.json': './package.json',
},
}),
'utf-8',
);

execSyncMock.mockImplementation(() => {
mkdirSync(join(projRoot, 'dts'));
writeFileSync(join(projRoot, 'dts', 'index.d.ts'), 'export const x: 1;', 'utf-8');
});

const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation(
() =>
({
succeeded: true,
} as ExtractorResult),
);

await executor(options, context);

expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); // primary only
});

it('skips wildcard expansion when the resolved declaration directory does not exist', async () => {
const { paths, context } = prepareFixture('valid', {});
const { projRoot } = paths;

writeFileSync(
join(projRoot, 'package.json'),
serializeJson({
name: '@proj/proj',
types: 'dist/index.d.ts',
exports: {
'.': { types: './dist/index.d.ts', import: './lib/index.js' },
'./*': { types: './dist/items/*/index.d.ts', import: './lib/items/*/index.js' },
},
}),
'utf-8',
);

writeFileSync(
join(projRoot, 'config', 'api-extractor.json'),
serializeJson({
mainEntryPointFilePath: '../dts/src/index.d.ts',
apiReport: { enabled: false },
docModel: { enabled: false },
dtsRollup: { enabled: true },
tsdocMetadata: { enabled: false },
}),
'utf-8',
);

execSyncMock.mockImplementation(() => {
mkdirSync(join(projRoot, 'dts', 'src'), { recursive: true });
writeFileSync(join(projRoot, 'dts', 'src', 'index.d.ts'), 'export const x: 1;', 'utf-8');
// dts/src/items/ intentionally NOT created
});

const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation(
() =>
({
succeeded: true,
} as ExtractorResult),
);

const output = await executor(options, context);

expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1); // primary only
expect(output.success).toBe(true);
});

it.each([{ exportSubpaths: false } as const, {} as const])(
'skips export subpath expansion when exportSubpaths=%j',
async overrides => {
const subDirs = ['alpha', 'beta'];
const { context } = prepareExportFixture({ wildcardSubDirs: subDirs });

const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation(
() =>
({
succeeded: true,
} as ExtractorResult),
);

const output = await executor({ ...options, ...overrides }, context);

expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1);
expect(output.success).toBe(true);
},
);

// ── Named exports ────────────────────────────────────────────────────────

it('generates correct config for named export ./utils', async () => {
const { paths, context } = prepareExportFixture({ namedExports: ['utils'] });

const capturedConfigs: ExtractorConfig[] = [];
jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => {
capturedConfigs.push(cfg);
return { succeeded: true } as ExtractorResult;
});

await executor({ ...options, exportSubpaths: true }, context);

// primary + utils — "." and "./package.json" are skipped
expect(capturedConfigs).toHaveLength(2);

const utilsConfig = capturedConfigs[1];
expect(utilsConfig.mainEntryPointFilePath).toContain('utils/index.d.ts');
expect(utilsConfig.untrimmedFilePath).toBe(join(paths.projRoot, 'dist', 'utils', 'index.d.ts'));
expect(utilsConfig.apiReportEnabled).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-deprecated
expect(utilsConfig.reportFilePath).toContain('utils.api.md');
});

it('disables apiReport for named exports when exportSubpaths: { apiReport: false }', async () => {
const { context } = prepareExportFixture({ namedExports: ['utils'] });

const capturedConfigs: ExtractorConfig[] = [];
jest.spyOn(Extractor, 'invoke').mockImplementation(cfg => {
capturedConfigs.push(cfg);
return { succeeded: true } as ExtractorResult;
});

await executor({ ...options, exportSubpaths: { apiReport: false } }, context);

const utilsConfig = capturedConfigs[1];
expect(utilsConfig.apiReportEnabled).toBe(false);
});

it('processes both named and wildcard exports in a single package', async () => {
const subDirs = ['alpha', 'beta'];
const { context } = prepareExportFixture({ wildcardSubDirs: subDirs, namedExports: ['utils'] });

const ExtractorInvokeSpy = jest.spyOn(Extractor, 'invoke').mockImplementation(
() =>
({
succeeded: true,
} as ExtractorResult),
);

const output = await executor({ ...options, exportSubpaths: true }, context);

// primary (1) + utils (1) + wildcard sub-dirs (2)
expect(ExtractorInvokeSpy).toHaveBeenCalledTimes(1 + 1 + subDirs.length);
expect(output.success).toBe(true);
});
});
Loading
Loading