diff --git a/.changeset/solid-bees-agree.md b/.changeset/solid-bees-agree.md new file mode 100644 index 0000000000..8feecaf6d4 --- /dev/null +++ b/.changeset/solid-bees-agree.md @@ -0,0 +1,5 @@ +--- +'@backstage/eslint-plugin': minor +--- + +Added `@backstage/no-ui-css-imports-in-non-frontend` rule, which ensures that CSS from `@backstage/ui` is not imported outside of the frontend app. diff --git a/packages/dev-utils/src/devApp/BuiCss.tsx b/packages/dev-utils/src/devApp/BuiCss.tsx index 4a0a730a90..308c59266f 100644 --- a/packages/dev-utils/src/devApp/BuiCss.tsx +++ b/packages/dev-utils/src/devApp/BuiCss.tsx @@ -14,6 +14,8 @@ * limitations under the License. */ +// This ensures that dev apps always have the BUI CSS loaded. +// eslint-disable-next-line @backstage/no-ui-css-imports-in-non-frontend import '@backstage/ui/css/styles.css'; /** diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md index e30b9e5de3..9141770189 100644 --- a/packages/eslint-plugin/README.md +++ b/packages/eslint-plugin/README.md @@ -42,3 +42,4 @@ The following rules are provided by this plugin: | [@backstage/no-undeclared-imports](./docs/rules/no-undeclared-imports.md) | Forbid imports of external packages that have not been declared in the appropriate dependencies field in `package.json`. | | [@backstage/no-top-level-material-ui-4-imports](./docs/rules/no-top-level-material-ui-4-imports.md) | Forbid top level import from Material UI v4 packages. | | [@backstage/no-mixed-plugin-imports](./docs/rules/no-mixed-plugin-imports.md) | Disallow mixed plugin imports. | +| [@backstage/no-ui-css-imports-in-non-frontend](./docs/rules/no-ui-css-imports-in-non-frontend.md) | Ensure that only packages with the `frontend` role can import CSS files from `@backstage/ui`. | diff --git a/packages/eslint-plugin/docs/rules/no-ui-css-imports-in-non-frontend.md b/packages/eslint-plugin/docs/rules/no-ui-css-imports-in-non-frontend.md new file mode 100644 index 0000000000..dd207a43c8 --- /dev/null +++ b/packages/eslint-plugin/docs/rules/no-ui-css-imports-in-non-frontend.md @@ -0,0 +1,50 @@ +# no-ui-css-imports-in-non-frontend + +Ensures that only packages with `backstage.role` set to `"frontend"` can import CSS files from `@backstage/ui`. + +This rule prevents non-frontend packages from accidentally importing global CSS styles from `@backstage/ui`. These CSS files should only be imported by the app, never by plugins, modules or libraries. + +## Rule Details + +This rule checks imports from `@backstage/ui` that end with `.css` and verifies that the importing package has `backstage.role: "frontend"` in its `package.json`. + +If a package does not have a `backstage.role` field defined at all, the import is allowed (the check is skipped). + +Examples of **incorrect** code for this rule: + +```js +// In a package with "backstage.role": "frontend-plugin" +import '@backstage/ui/css/styles.css'; +``` + +```js +// In a package with "backstage.role": "web-library" +import '@backstage/ui/css/styles.css'; +``` + +```js +// In a package with "backstage.role": "backend" +require('@backstage/ui/css/styles.css'); +``` + +Examples of **correct** code for this rule: + +```js +// In a package with "backstage.role": "frontend" +import '@backstage/ui/css/styles.css'; +``` + +```js +// In a package without a "backstage.role" field +import '@backstage/ui/css/styles.css'; +``` + +```js +// Non-CSS imports are allowed in any package +import { Button, Text } from '@backstage/ui'; +``` + +```js +// In a package with "backstage.role": "backend" +import { Button } from '@backstage/ui'; +``` diff --git a/packages/eslint-plugin/index.js b/packages/eslint-plugin/index.js index 58b597a438..d911eee1ef 100644 --- a/packages/eslint-plugin/index.js +++ b/packages/eslint-plugin/index.js @@ -23,6 +23,7 @@ module.exports = { '@backstage/no-relative-monorepo-imports': 'error', '@backstage/no-undeclared-imports': 'error', '@backstage/no-mixed-plugin-imports': 'warn', + '@backstage/no-ui-css-imports-in-non-frontend': 'error', }, }, }, @@ -32,5 +33,6 @@ module.exports = { 'no-undeclared-imports': require('./rules/no-undeclared-imports'), 'no-top-level-material-ui-4-imports': require('./rules/no-top-level-material-ui-4-imports'), 'no-mixed-plugin-imports': require('./rules/no-mixed-plugin-imports'), + 'no-ui-css-imports-in-non-frontend': require('./rules/no-ui-css-imports-in-non-frontend'), }, }; diff --git a/packages/eslint-plugin/rules/no-ui-css-imports-in-non-frontend.js b/packages/eslint-plugin/rules/no-ui-css-imports-in-non-frontend.js new file mode 100644 index 0000000000..b6470bb1d3 --- /dev/null +++ b/packages/eslint-plugin/rules/no-ui-css-imports-in-non-frontend.js @@ -0,0 +1,77 @@ +/* + * Copyright 2023 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. + */ + +// @ts-check + +const visitImports = require('../lib/visitImports'); +const getPackages = require('../lib/getPackages'); + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + messages: { + noCssImport: + 'CSS imports from @backstage/ui are only allowed in packages with backstage.role set to "frontend". Current role: "{{role}}"', + }, + docs: { + description: + 'Ensure that only packages with backstage.role set to "frontend" can import CSS files from @backstage/ui.', + url: 'https://github.com/backstage/backstage/blob/master/packages/eslint-plugin/docs/rules/no-ui-css-imports-in-non-frontend.md', + }, + }, + create(context) { + const packages = getPackages(context.getCwd()); + if (!packages) { + return {}; + } + + const currentPackage = packages.byPath(context.filename); + if (!currentPackage) { + return {}; + } + + return visitImports(context, (node, imp) => { + const isBuiImport = + (imp.type === 'external' || imp.type === 'internal') && + imp.packageName === '@backstage/ui'; + if (!isBuiImport) { + return; + } + + const isCssImport = imp.path?.endsWith('.css'); + if (!isCssImport) { + return; + } + + const backstageRole = currentPackage.packageJson.backstage?.role; + if (!backstageRole) { + // Allow if no role is defined + return; + } + + if (backstageRole !== 'frontend') { + context.report({ + node: node, + messageId: 'noCssImport', + data: { + role: backstageRole, + }, + }); + } + }); + }, +}; diff --git a/packages/eslint-plugin/src/__fixtures__/monorepo/packages/frontend-pkg/package.json b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/frontend-pkg/package.json new file mode 100644 index 0000000000..9d3fe95884 --- /dev/null +++ b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/frontend-pkg/package.json @@ -0,0 +1,6 @@ +{ + "name": "@internal/frontend-pkg", + "backstage": { + "role": "frontend" + } +} diff --git a/packages/eslint-plugin/src/__fixtures__/monorepo/packages/frontend-plugin-pkg/package.json b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/frontend-plugin-pkg/package.json new file mode 100644 index 0000000000..c944f3dff6 --- /dev/null +++ b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/frontend-plugin-pkg/package.json @@ -0,0 +1,6 @@ +{ + "name": "@internal/frontend-plugin-pkg", + "backstage": { + "role": "frontend-plugin" + } +} diff --git a/packages/eslint-plugin/src/__fixtures__/monorepo/packages/no-role-pkg/package.json b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/no-role-pkg/package.json new file mode 100644 index 0000000000..bee386117c --- /dev/null +++ b/packages/eslint-plugin/src/__fixtures__/monorepo/packages/no-role-pkg/package.json @@ -0,0 +1,3 @@ +{ + "name": "@internal/no-role-pkg" +} diff --git a/packages/eslint-plugin/src/no-ui-css-imports-in-non-frontend.test.ts b/packages/eslint-plugin/src/no-ui-css-imports-in-non-frontend.test.ts new file mode 100644 index 0000000000..373659ae6e --- /dev/null +++ b/packages/eslint-plugin/src/no-ui-css-imports-in-non-frontend.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright 2023 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 { RuleTester } from 'eslint'; +import path from 'path'; +import rule from '../rules/no-ui-css-imports-in-non-frontend'; + +const RULE = 'no-ui-css-imports-in-non-frontend'; +const FIXTURE = path.resolve(__dirname, '__fixtures__/monorepo'); + +const ERR = (role: string) => ({ + message: `CSS imports from @backstage/ui are only allowed in packages with backstage.role set to "frontend". Current role: "${role}"`, +}); + +// cwd must be restored +const origDir = process.cwd(); +afterAll(() => { + process.chdir(origDir); +}); +process.chdir(FIXTURE); + +const ruleTester = new RuleTester({ + parserOptions: { + sourceType: 'module', + ecmaVersion: 2021, + }, +}); + +ruleTester.run(RULE, rule, { + valid: [ + // Frontend package can import CSS from @backstage/ui + { + code: `import '@backstage/ui/css/styles.css'`, + filename: path.join(FIXTURE, 'packages/frontend-pkg/index.ts'), + }, + { + code: `import '@backstage/ui/css/other.css'`, + filename: path.join(FIXTURE, 'packages/frontend-pkg/index.ts'), + }, + { + code: `require('@backstage/ui/css/styles.css')`, + filename: path.join(FIXTURE, 'packages/frontend-pkg/index.ts'), + }, + // Package without backstage.role can import CSS (skip check) + { + code: `import '@backstage/ui/css/styles.css'`, + filename: path.join(FIXTURE, 'packages/no-role-pkg/index.ts'), + }, + // Non-CSS imports are allowed in any package + { + code: `import { Button } from '@backstage/ui'`, + filename: path.join(FIXTURE, 'packages/frontend-plugin-pkg/index.ts'), + }, + { + code: `import { Text } from '@backstage/ui/components'`, + filename: path.join(FIXTURE, 'packages/frontend-plugin-pkg/index.ts'), + }, + // Imports from other packages are allowed + { + code: `import './styles.css'`, + filename: path.join(FIXTURE, 'packages/frontend-plugin-pkg/index.ts'), + }, + { + code: `import 'some-other-package/styles.css'`, + filename: path.join(FIXTURE, 'packages/frontend-plugin-pkg/index.ts'), + }, + ], + invalid: [ + // Backend package cannot import CSS from @backstage/ui + { + code: `import '@backstage/ui/css/styles.css'`, + filename: path.join(FIXTURE, 'packages/frontend-plugin-pkg/index.ts'), + errors: [ERR('frontend-plugin')], + }, + { + code: `import '@backstage/ui/css/other.css'`, + filename: path.join(FIXTURE, 'packages/frontend-plugin-pkg/index.ts'), + errors: [ERR('frontend-plugin')], + }, + { + code: `require('@backstage/ui/css/styles.css')`, + filename: path.join(FIXTURE, 'packages/frontend-plugin-pkg/index.ts'), + errors: [ERR('frontend-plugin')], + }, + ], +});