cli: Add support for CSS exports in package build

This adds support for CSS entry points in package exports. When a package
declares a CSS file in its exports field, the CLI will now automatically
bundle it during `backstage-cli package build` and rewrite the export
path from src/ to dist/ at publish time.

The CSS bundling uses lightningcss to resolve @import statements and
preserves @layer declarations that would otherwise be stripped during
bundling.

This allows packages like @backstage/ui to use the standard build command
instead of custom build scripts for CSS bundling.

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Patrik Oldsberg
2026-02-09 13:35:19 +01:00
parent 5a5e9bb6bd
commit 4f9d980943
10 changed files with 122 additions and 9369 deletions
+1
View File
@@ -110,6 +110,7 @@
"inquirer": "^8.2.0",
"jest-css-modules": "^2.1.0",
"json-schema": "^0.4.0",
"lightningcss": "^1.29.1",
"lodash": "^4.17.21",
"minimatch": "^9.0.0",
"node-stdlib-browser": "^1.3.1",
+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(),
});
@@ -0,0 +1,77 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'fs-extra';
import { bundle, transform } from 'lightningcss';
import { resolve as resolvePath, dirname } from 'node:path';
import { EntryPoint } from '../../../../lib/entryPoints';
/**
* Bundles a CSS entry point, resolving @import statements and preserving @layer declarations.
*/
export async function buildCSSEntryPoint(
entryPoint: EntryPoint,
targetDir: string,
): Promise<void> {
const sourcePath = resolvePath(targetDir, entryPoint.path);
// Convert src/ path to dist/ path
const outputPath = resolvePath(
targetDir,
entryPoint.path.replace(/^(\.\/)?src\//, 'dist/'),
);
// Read source to extract @layer declaration before bundling
const source = await fs.readFile(sourcePath, 'utf8');
const layerMatch = source.match(/@layer\s+[^;]+;/);
// Bundle @import statements using lightningcss
const { code: bundledCode } = bundle({
filename: sourcePath,
});
// Transform the bundled CSS
const { code } = transform({
filename: outputPath,
code: bundledCode,
minify: false,
});
// Restore @layer declaration if it was removed during bundling
let finalCode = code.toString();
if (layerMatch && !finalCode.includes(layerMatch[0])) {
finalCode = `${layerMatch[0]}\n\n${finalCode}`;
}
// Ensure output directory exists
await fs.mkdir(dirname(outputPath), { recursive: true });
// Write the bundled CSS
await fs.writeFile(outputPath, finalCode);
}
/**
* Builds all CSS entry points for a package.
*/
export async function buildCSSEntryPoints(
entryPoints: EntryPoint[],
targetDir: string,
): Promise<void> {
const cssEntryPoints = entryPoints.filter(ep => ep.ext === '.css');
for (const entryPoint of cssEntryPoints) {
await buildCSSEntryPoint(entryPoint, targetDir);
}
}
@@ -23,6 +23,8 @@ import { makeRollupConfigs } from './config';
import { BuildOptions, Output } from './types';
import { PackageRoles } from '@backstage/cli-node';
import { runParallelWorkers } from '../../../../lib/parallel';
import { buildCSSEntryPoints } from './css';
import { readEntryPoints } from '../../../../lib/entryPoints';
export function formatErrorMessage(error: any) {
let msg = '';
@@ -107,11 +109,18 @@ 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);
await Promise.all(buildTasks);
// Build CSS entry points after the main build
if (options.packageJson) {
const entryPoints = readEntryPoints(options.packageJson);
await buildCSSEntryPoints(entryPoints, targetDir);
}
};
export const buildPackages = async (options: BuildOptions[]) => {
@@ -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;
}