Merge pull request #32761 from backstage/rugvip/css-exports-support

cli: add support for CSS exports in package build
This commit is contained in:
Patrik Oldsberg
2026-02-10 13:23:02 +01:00
committed by GitHub
20 changed files with 344 additions and 9532 deletions
+1
View File
@@ -118,6 +118,7 @@
"p-queue": "^6.6.2",
"pirates": "^4.0.6",
"postcss": "^8.1.0",
"postcss-import": "^16.1.0",
"process": "^0.11.10",
"raw-loader": "^4.0.2",
"react-dev-utils": "^12.0.0-next.60",
+9 -2
View File
@@ -33,18 +33,25 @@ const defaultIndex = {
ext: '.ts',
};
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
function parseEntryPoint(mount: string, path: string): EntryPoint {
const ext = extname(path);
let name = mount;
if (name === '.') {
name = 'index';
} else if (name.startsWith('./')) {
name = name.slice(2);
}
if (name.includes('/')) {
// Script entry points can't have slashes because we create backward-compat
// directories for them. Non-script files (like CSS) can have nested paths.
if (name.includes('/') && SCRIPT_EXTS.includes(ext)) {
throw new Error(`Mount point '${mount}' may not contain multiple slashes`);
}
return { mount, path, name, ext: extname(path) };
return { mount, path, name, ext };
}
export function readEntryPoints(pkg: BackstagePackageJson): Array<EntryPoint> {
@@ -15,9 +15,14 @@
*/
import { OptionValues } from 'commander';
import fs from 'fs-extra';
import { buildPackage, Output } from '../../../lib/builder';
import { findRoleFromCommand } from '../../../../../lib/role';
import { PackageGraph, PackageRoles } from '@backstage/cli-node';
import {
BackstagePackageJson,
PackageGraph,
PackageRoles,
} from '@backstage/cli-node';
import { paths } from '../../../../../lib/paths';
import { buildFrontend } from '../../../lib/buildFrontend';
import { buildBackend } from '../../../lib/buildBackend';
@@ -85,8 +90,13 @@ export async function command(opts: OptionValues): Promise<void> {
outputs.add(Output.types);
}
const packageJson = (await fs.readJson(
paths.resolveTarget('package.json'),
)) as BackstagePackageJson;
return buildPackage({
outputs,
packageJson,
minify: Boolean(opts.minify),
workspacePackages: await PackageGraph.listTargetPackages(),
});
@@ -37,7 +37,7 @@ import {
OutputPlugin,
} from 'rollup';
import { forwardFileImports } from './plugins';
import { forwardFileImports, cssEntryPoints } from './plugins';
import { BuildOptions, Output } from './types';
import { paths } from '../../../../lib/paths';
import { BackstagePackageJson } from '@backstage/cli-node';
@@ -275,6 +275,7 @@ export async function makeRollupConfigs(
target: 'ES2023',
minify: options.minify,
}),
cssEntryPoints({ entryPoints, targetDir }),
],
});
}
@@ -107,7 +107,8 @@ export const buildPackage = async (options: BuildOptions) => {
const rollupConfigs = await makeRollupConfigs(options);
await fs.remove(resolvePath(options.targetDir ?? paths.targetDir, 'dist'));
const targetDir = options.targetDir ?? paths.targetDir;
await fs.remove(resolvePath(targetDir, 'dist'));
const buildTasks = rollupConfigs.map(rollupBuild);
@@ -22,9 +22,25 @@ import {
PluginContext,
} from 'rollup';
import { forwardFileImports } from './plugins';
import { forwardFileImports, cssEntryPoints } from './plugins';
import { createMockDirectory } from '@backstage/backend-test-utils';
// Helper to call generateBundle hook which can be a function or ObjectHook
async function callGenerateBundle(
plugin: ReturnType<typeof cssEntryPoints>,
ctx: PluginContext,
options: NormalizedOutputOptions,
bundle: Record<string, OutputChunk | OutputAsset>,
isWrite: boolean,
) {
const hook = plugin.generateBundle;
if (typeof hook === 'function') {
await hook.call(ctx, options, bundle, isWrite);
} else if (hook && typeof hook === 'object' && 'handler' in hook) {
await hook.handler.call(ctx, options, bundle, isWrite);
}
}
const context = {
meta: {
rollupVersion: '0.0.0',
@@ -194,3 +210,139 @@ describe('forwardFileImports', () => {
});
});
});
describe('cssEntryPoints', () => {
it('should be created with correct name', () => {
const plugin = cssEntryPoints({
entryPoints: [],
targetDir: '/dev',
});
expect(plugin.name).toBe('backstage-css-entry-points');
});
describe('with createMockDirectory', () => {
const mockDir = createMockDirectory();
const emittedFiles: Array<{ fileName: string; source: string }> = [];
const emitContext = {
...context,
emitFile: (file: { fileName: string; source: string }) => {
emittedFiles.push(file);
return 'asset-id';
},
} as unknown as PluginContext;
beforeEach(() => {
emittedFiles.length = 0;
mockDir.setContent({
dev: {
src: {
css: {
'styles.css': '@import "./base.css";\n.root { color: red; }',
'base.css': '.base { margin: 0; }',
},
},
},
});
});
it('should not emit when isWrite is false', async () => {
const plugin = cssEntryPoints({
entryPoints: [
{
mount: './css/styles.css',
path: './src/css/styles.css',
name: 'css/styles.css',
ext: '.css',
},
],
targetDir: mockDir.resolve('dev'),
});
await callGenerateBundle(
plugin,
emitContext,
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
{},
false,
);
expect(emittedFiles).toHaveLength(0);
});
it('should emit only CSS entry points with resolved imports', async () => {
const plugin = cssEntryPoints({
entryPoints: [
// Non-CSS entry should be ignored
{ mount: '.', path: './src/index.ts', name: 'index', ext: '.ts' },
{
mount: './css/styles.css',
path: './src/css/styles.css',
name: 'css/styles.css',
ext: '.css',
},
],
targetDir: mockDir.resolve('dev'),
});
await callGenerateBundle(
plugin,
emitContext,
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
{},
true,
);
// Only CSS file should be emitted, not the .ts entry
expect(emittedFiles).toHaveLength(1);
expect(emittedFiles[0].fileName).toBe('css/styles.css');
expect(emittedFiles[0].source).toContain('.base { margin: 0; }');
expect(emittedFiles[0].source).toContain('.root { color: red; }');
expect(emittedFiles[0].source).not.toContain('@import');
});
it('should only emit once per output directory', async () => {
const plugin = cssEntryPoints({
entryPoints: [
{
mount: './css/styles.css',
path: './src/css/styles.css',
name: 'css/styles.css',
ext: '.css',
},
],
targetDir: mockDir.resolve('dev'),
});
// First call should emit
await callGenerateBundle(
plugin,
emitContext,
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
{},
true,
);
expect(emittedFiles).toHaveLength(1);
// Second call to same dir should not emit again
await callGenerateBundle(
plugin,
emitContext,
{ dir: mockDir.resolve('dev/dist') } as NormalizedOutputOptions,
{},
true,
);
expect(emittedFiles).toHaveLength(1);
// Call to different dir should emit
await callGenerateBundle(
plugin,
emitContext,
{ dir: mockDir.resolve('dev/dist2') } as NormalizedOutputOptions,
{},
true,
);
expect(emittedFiles).toHaveLength(2);
});
});
});
@@ -15,6 +15,8 @@
*/
import fs from 'fs-extra';
import postcss from 'postcss';
import postcssImport from 'postcss-import';
import {
dirname,
resolve as resolvePath,
@@ -27,6 +29,7 @@ import {
OutputChunk,
HasModuleSideEffects,
} from 'rollup';
import { EntryPoint } from '../../../../lib/entryPoints';
type ForwardFileImportsOptions = {
include: Array<string | RegExp> | string | RegExp | null;
@@ -169,3 +172,58 @@ export function forwardFileImports(options: ForwardFileImportsOptions) {
},
} satisfies Plugin;
}
interface CssEntryPointsOptions {
entryPoints: EntryPoint[];
targetDir: string;
}
/**
* Rollup plugin that bundles CSS entry points using postcss-import.
* CSS files declared in package.json exports are processed and emitted
* as part of the Rollup bundle.
*/
export function cssEntryPoints(options: CssEntryPointsOptions): Plugin {
const cssEntries = options.entryPoints.filter(ep => ep.ext === '.css');
// Track output directories we've already emitted CSS to, to avoid duplicates
// when Rollup runs generateBundle multiple times (once per output format)
const generatedFor = new Set<string>();
return {
name: 'backstage-css-entry-points',
async generateBundle(outputOptions, _bundle, isWrite) {
if (!isWrite) {
return;
}
const dir = outputOptions.dir || dirname(outputOptions.file!);
if (generatedFor.has(dir)) {
return;
}
generatedFor.add(dir);
for (const entryPoint of cssEntries) {
const sourcePath = resolvePath(options.targetDir, entryPoint.path);
// Strip the src/ prefix to create an output filename relative to the Rollup output directory
const outputPath = entryPoint.path.replace(/^(\.\/)?src\//, '');
// Read source CSS
const source = await fs.readFile(sourcePath, 'utf8');
// Bundle @import statements using postcss-import
const result = await postcss([postcssImport()]).process(source, {
from: sourcePath,
});
// Emit the bundled CSS as an asset
this.emitFile({
type: 'asset',
fileName: outputPath,
source: result.css,
});
}
},
};
}
@@ -148,7 +148,11 @@ async function rewriteEntryPoints(
for (const entryPoint of entryPoints) {
if (!SCRIPT_EXTS.includes(entryPoint.ext)) {
outputExports[entryPoint.mount] = entryPoint.path;
// Non-script files (like CSS) get their paths rewritten from src/ to dist/
outputExports[entryPoint.mount] = entryPoint.path.replace(
/^(\.\/)?src\//,
'./dist/',
);
continue;
}
@@ -27,10 +27,13 @@ import {
resolve as resolvePath,
posix,
relative as relativePath,
extname,
} from 'node:path';
import { paths } from '../../../../lib/paths';
import { publishPreflightCheck } from '../../lib/publishing';
const SCRIPT_EXTS = ['.js', '.jsx', '.ts', '.tsx', '.json'];
/**
* A mutable object representing a package.json file with potential fixes.
*/
@@ -122,29 +125,46 @@ export function fixPackageExports(pkg: FixablePackage) {
}
const newPath = trimRelative(path);
// Only script files and package.json should be added to typesVersions
if (typeof value === 'string') {
typeEntries[newPath] = [trimRelative(value)];
if (SCRIPT_EXTS.includes(extname(value))) {
typeEntries[newPath] = [trimRelative(value)];
}
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
if (typeof value.types === 'string') {
typeEntries[newPath] = [trimRelative(value.types)];
} else if (typeof value.default === 'string') {
} else if (
typeof value.default === 'string' &&
SCRIPT_EXTS.includes(extname(value.default))
) {
typeEntries[newPath] = [trimRelative(value.default)];
}
}
}
const typesVersions = { '*': typeEntries };
if (existingTypesVersions !== JSON.stringify(typesVersions)) {
const newPkgEntries = Object.entries(pkg.packageJson).filter(
([name]) => name !== 'typesVersions',
);
newPkgEntries.splice(
newPkgEntries.findIndex(([name]) => name === 'exports') + 1,
0,
['typesVersions', typesVersions],
);
const hasTypeEntries = Object.keys(typeEntries).length > 0;
const typesVersions = hasTypeEntries ? { '*': typeEntries } : undefined;
pkg.packageJson = Object.fromEntries(newPkgEntries) as BackstagePackageJson;
if (existingTypesVersions !== JSON.stringify(typesVersions)) {
if (pkg.packageJson.typesVersions) {
// Update in place to preserve field order
if (typesVersions) {
pkg.packageJson.typesVersions = typesVersions;
} else {
delete pkg.packageJson.typesVersions;
}
} else if (typesVersions) {
// Insert after exports when adding for the first time
const newPkgEntries = Object.entries(pkg.packageJson);
newPkgEntries.splice(
newPkgEntries.findIndex(([name]) => name === 'exports') + 1,
0,
['typesVersions', typesVersions],
);
pkg.packageJson = Object.fromEntries(
newPkgEntries,
) as BackstagePackageJson;
}
pkg.changed = true;
}
+6
View File
@@ -259,3 +259,9 @@ declare module 'webpack-node-externals' {
}
declare module '@esbuild-kit/cjs-loader' {}
declare module 'postcss-import' {
import { Plugin } from 'postcss';
export default function postcssImport(): Plugin;
}