Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/decisions/0008-stylesheet-import-in-site-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ The build pipeline wraps each stylesheet in a CSS cascade layer based on the res
| `site` | stylesheets outside `node_modules` (the composing site's source) |
| `brand` | `@(open)?edx/brand*` packages |

The order declared at the top of `shell/style.scss` is `@layer paragon, shell, app, site, brand;` so the cascade resolves in that order: `brand` wins over `site`, `site` wins over `app`, `app` wins over `shell`, and `shell` wins over `paragon`.
The order is declared in `shell/layer-order.scss` as `@layer paragon, shell, app, site, brand;`, so the cascade resolves in that order: `brand` wins over `site`, `site` wins over `app`, `app` wins over `shell`, and `shell` wins over `paragon`.

`brand` is last because, in production, brand CSS is injected at runtime via `<link>` tags that bypass webpack entirely and therefore land **unlayered**. Unlayered rules beat every layered rule regardless of declared order, so runtime brand wins over the site's own CSS. Putting build-time brand imports (e.g. a dev harness that `@use`s a brand package directly) in the last layer keeps dev harness behavior consistent with production: brand overrides apply on top of everything the site declares.

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
".": "./dist/index.js",
"./tools": "./dist/tools/index.js",
"./tools/tsconfig.json": "./dist/tools/typescript/tsconfig.json",
"./shell/layer-order.scss": "./dist/shell/layer-order.scss",
"./shell/style": "./dist/shell/style.js",
"./shell/site": "./dist/shell/site.js"
},
Expand Down
1 change: 1 addition & 0 deletions shell/layer-order.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@layer paragon, shell, app, site, brand;
2 changes: 0 additions & 2 deletions shell/style.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
@layer paragon, shell, app, site, brand;

.flex-basis-0 {
flex-basis: 0 !important;
}
Expand Down
41 changes: 41 additions & 0 deletions tools/webpack/common-config/all/getStylesheetRule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
APP_RESOURCE,
BRAND_PACKAGE,
} from './getStylesheetRule';

describe('stylesheet classification', () => {
describe('BRAND_PACKAGE', () => {
const re = BRAND_PACKAGE.name as RegExp;

it('matches @openedx/brand-*', () => {
expect(re.test('@openedx/brand-openedx')).toBe(true);
});

it('matches @edx/brand-*', () => {
expect(re.test('@edx/brand-foo')).toBe(true);
});

it('matches bare @openedx/brand', () => {
expect(re.test('@openedx/brand')).toBe(true);
});

it('does not match unrelated scoped packages', () => {
expect(re.test('@openedx/paragon')).toBe(false);
expect(re.test('@openedx/branding')).toBe(false);
});
});

describe('APP_RESOURCE', () => {
it('matches a node_modules path', () => {
expect(APP_RESOURCE.test('/site/node_modules/some-pkg/index.js')).toBe(true);
});

it('matches an npm-workspace packages path', () => {
expect(APP_RESOURCE.test('/site/packages/some-pkg/index.js')).toBe(true);
});

it('does not match a site source path', () => {
expect(APP_RESOURCE.test('/site/src/component.scss')).toBe(false);
});
});
});
17 changes: 9 additions & 8 deletions tools/webpack/common-config/all/getStylesheetRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ import postcssWrapLayer from './postcssWrapLayer';
* precedence of runtime brand CSS (which is injected unlayered via
* <link> tags and thus beats every layered rule).
*/
const PARAGON_RESOURCE = /@openedx[\\/]paragon[\\/]/;
const SHELL_RESOURCE = /(@openedx[\\/]frontend-base|frontend-base[\\/]shell)[\\/]/;
const BRAND_RESOURCE = /@(open)?edx[\\/]brand(-[^\\/]+)?[\\/]/;
const NODE_MODULES = /[\\/]node_modules[\\/]/;
export const PARAGON_PACKAGE = { name: '@openedx/paragon' };
export const SHELL_PACKAGE = { name: '@openedx/frontend-base' };
export const BRAND_PACKAGE = { name: /^@(open)?edx\/brand(-.+)?$/ };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not blocking, and out-of-scope for this PR, but the more I think about it the more I feel using a regex to match brand packages is a bit odd.

With the npm aliasing method used by MFEs, there were no restrictions on what site operators could name their brand packages.

I'm not sure how many site operators are using brand packages that don't match the regex, so it's possible this is a non-issue, but I figured it's worth flagging to at least think about.

Copy link
Copy Markdown
Contributor Author

@arbrandes arbrandes Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The alternative is to do what Atlas does, which is to require metadata in the package.json. I don't dislike that idea, not only for brand but for apps: that would give us a way to have an app layer literally just for apps, not as a catch-all. (And to put the node_modules stuff in its own layer before anything else).

But we can do this in a subsequent series of PRs.

// The site/app split is positional (my project vs. a dep), so it is path-based.
export const APP_RESOURCE = /[\\/](node_modules|packages)[\\/]/;

/*
* There are a few things we need to do here.
Expand All @@ -40,7 +41,7 @@ export default function getStylesheetRule(mode: 'dev' | 'production'): RuleSetRu
test: /(.scss|.css)$/,
oneOf: [
{
resource: PARAGON_RESOURCE,
descriptionData: PARAGON_PACKAGE,
// We need Paragon to not elide CSS: we have to be able to import it
// directly from shell/style.ts
sideEffects: true,
Expand All @@ -50,21 +51,21 @@ export default function getStylesheetRule(mode: 'dev' | 'production'): RuleSetRu
],
},
{
resource: SHELL_RESOURCE,
descriptionData: SHELL_PACKAGE,
use: [
MiniCssExtractPlugin.loader,
...getStyleUseConfig(mode, 'shell'),
],
},
{
resource: BRAND_RESOURCE,
descriptionData: BRAND_PACKAGE,
use: [
MiniCssExtractPlugin.loader,
...getStyleUseConfig(mode, 'brand'),
],
},
{
resource: { not: [NODE_MODULES] },
resource: { not: [APP_RESOURCE] },
use: [
getFirstLoader(mode),
...getStyleUseConfig(mode, 'site'),
Expand Down
14 changes: 11 additions & 3 deletions tools/webpack/common-config/site/getHtmlWebpackPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,19 @@
import HtmlWebpackPlugin from 'html-webpack-plugin';
import path from 'path';

// Generates an HTML file in the output directory.
/*
* Generates an HTML file in the output directory.
*
* `cssLayerOrder` is listed first so its <link> is injected before any other
* stylesheet. That file contains the `@layer paragon, shell, app, site, brand;`
* statement, which locks in cascade-layer priority before any layer name is
* mentioned by the rest of the CSS the browser parses.
*/
export default function getHtmlWebpackPlugin() {
return new HtmlWebpackPlugin({
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
inject: true,
template: path.resolve(process.cwd(), 'public/index.html'),
chunks: ['app'],
chunks: ['cssLayerOrder', 'app'],
chunksSortMode: 'manual',
});
}
1 change: 1 addition & 0 deletions tools/webpack/webpack.config.build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const config: Configuration = {
mode: 'production',
devtool: 'source-map',
entry: {
cssLayerOrder: '@openedx/frontend-base/shell/layer-order.scss',
app: '@openedx/frontend-base/shell/site',
},
output: {
Expand Down
6 changes: 4 additions & 2 deletions tools/webpack/webpack.config.dev.shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const resolvedSiteI18nPath = getResolvedSiteI18nPath('test-site/src/i18n');

const config: Configuration = {
entry: {
cssLayerOrder: path.resolve(process.cwd(), 'shell/layer-order.scss'),
app: path.resolve(process.cwd(), 'shell/site'),
},
output: {
Expand Down Expand Up @@ -90,9 +91,10 @@ const config: Configuration = {
filename: '[name].css',
}),
new HtmlWebpackPlugin({
inject: true, // Appends script tags linking to the webpack bundles at the end of the body
inject: true,
template: path.resolve(process.cwd(), 'shell/public/index.html'),
chunks: ['app'],
chunks: ['cssLayerOrder', 'app'],
chunksSortMode: 'manual',
}),
new ReactRefreshWebpackPlugin(),
new ForkTsCheckerWebpackPlugin(),
Expand Down
1 change: 1 addition & 0 deletions tools/webpack/webpack.config.dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const resolvedSiteI18nPath = getResolvedSiteI18nPath('src/i18n');

const config: Configuration = {
entry: {
cssLayerOrder: '@openedx/frontend-base/shell/layer-order.scss',
app: '@openedx/frontend-base/shell/site',
},
output: {
Expand Down