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;
}
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -22,17 +22,18 @@
"sideEffects": [
"*.css"
],
"exports": {
".": "./src/index.ts",
"./css/styles.css": "./src/css/styles.css",
"./package.json": "./package.json"
},
"main": "src/index.ts",
"types": "src/index.ts",
"files": [
"dist",
"css"
"dist"
],
"scripts": {
"build": "yarn build:app && yarn build:css",
"build:app": "backstage-cli package build",
"build:css": "node scripts/build-css.mjs",
"build:css:watch": "node scripts/build-css.mjs --watch",
"build": "backstage-cli package build",
"clean": "backstage-cli package clean",
"lint": "backstage-cli package lint",
"prepack": "backstage-cli package prepack",
@@ -50,12 +51,9 @@
"@backstage/cli": "workspace:^",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"chalk": "^5.4.1",
"eslint-plugin-storybook": "^10.3.0-alpha.1",
"glob": "^11.0.1",
"globals": "^15.11.0",
"lightningcss": "^1.29.1",
"mini-css-extract-plugin": "^2.9.2",
"react": "^18.0.2",
"react-dom": "^18.0.2",
"react-router-dom": "^6.30.2",
-79
View File
@@ -1,79 +0,0 @@
/*
* 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.
*/
/* eslint-disable no-restricted-imports */
import { transform, bundle } from 'lightningcss';
import fs from 'node:fs';
import chalk from 'chalk';
/* eslint-enable no-restricted-imports */
const srcFile = 'src/css/styles.css';
const distDir = 'css';
const distFile = `${distDir}/styles.css`;
// Check if styles.css exists
if (!fs.existsSync(srcFile)) {
console.error(`${srcFile} does not exist`);
process.exit(1);
}
// Ensure the dist/css directory exists
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true });
}
const args = process.argv.slice(2);
const watchMode = args.includes('--watch');
async function buildCSS(logs = true) {
const css = fs.readFileSync(srcFile);
let { code: bundleCode } = bundle({
filename: srcFile,
});
const { code } = transform({
filename: distFile,
code: bundleCode,
minify: false,
});
// Prepend the layer order declaration that lightningcss removes during bundling
// This is crucial to maintain the correct layer cascade order
const layerDeclaration = '@layer tokens, base, components, utilities;\n\n';
const finalCode = layerDeclaration + code;
fs.writeFileSync(distFile, finalCode);
if (logs) {
console.log(chalk.blue('CSS transformed and minified: ') + 'styles.css');
console.log(chalk.green('CSS file built successfully!'));
}
}
if (watchMode) {
fs.watch('src/css', { recursive: true }, (eventType, filename) => {
if (filename === 'styles.css') {
console.log(
chalk.yellow(`Changes detected in ${filename}, rebuilding...`),
);
buildCSS(false).catch(console.error);
}
});
buildCSS().catch(console.error);
console.log(chalk.yellow('Watching for CSS changes...'));
} else {
buildCSS().catch(console.error);
}
+2 -4
View File
@@ -3377,6 +3377,7 @@ __metadata:
jest-css-modules: "npm:^2.1.0"
jsdom: "npm:^27.1.0"
json-schema: "npm:^0.4.0"
lightningcss: "npm:^1.29.1"
lodash: "npm:^4.17.21"
mini-css-extract-plugin: "npm:^2.4.2"
minimatch: "npm:^9.0.0"
@@ -8023,13 +8024,10 @@ __metadata:
"@tanstack/react-table": "npm:^8.21.3"
"@types/react": "npm:^18.0.0"
"@types/react-dom": "npm:^18.0.0"
chalk: "npm:^5.4.1"
clsx: "npm:^2.1.1"
eslint-plugin-storybook: "npm:^10.3.0-alpha.1"
glob: "npm:^11.0.1"
globals: "npm:^15.11.0"
lightningcss: "npm:^1.29.1"
mini-css-extract-plugin: "npm:^2.9.2"
react: "npm:^18.0.2"
react-aria-components: "npm:^1.14.0"
react-dom: "npm:^18.0.2"
@@ -39285,7 +39283,7 @@ __metadata:
languageName: node
linkType: hard
"mini-css-extract-plugin@npm:^2.4.2, mini-css-extract-plugin@npm:^2.9.2":
"mini-css-extract-plugin@npm:^2.4.2":
version: 2.10.0
resolution: "mini-css-extract-plugin@npm:2.10.0"
dependencies: