Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use lightningcss to implement CSS packager #8492

Merged
merged 12 commits into from
Jan 8, 2024
17 changes: 16 additions & 1 deletion packages/core/integration-tests/test/css.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,9 @@ describe('css', () => {
let css = await outputFS.readFile(path.join(distDir, '/index.css'), 'utf8');
assert(css.includes('.local'));
assert(css.includes('.other'));
assert(/@media print {\s*.other/.test(css));
assert(
/@media print {\s*\.local(.|\n)*\.other(.|\n)*}(.|\n)*\.index/.test(css),
);
assert(css.includes('.index'));
});

Expand Down Expand Up @@ -510,4 +512,17 @@ describe('css', () => {
let res = await outputFS.readFile(b.getBundles()[0].filePath, 'utf8');
assert(res.includes('.foo.bar'));
});

it('should support @layer', async function () {
let b = await bundle(path.join(__dirname, '/integration/css-layer/a.css'), {
mode: 'production',
});

let res = await outputFS.readFile(b.getBundles()[0].filePath, 'utf8');
assert(
res.includes(
'@layer b.c{.c{color:#ff0}}@layer b{.b{color:#00f}}.a{color:red}',
),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import "b.css" layer(b);

.a {
color: red;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
@import "c.css" layer(c);

.b {
color: blue;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.c {
color: yellow;
}
3 changes: 1 addition & 2 deletions packages/core/integration-tests/test/sourcemaps.js
Original file line number Diff line number Diff line change
Expand Up @@ -830,12 +830,11 @@ describe('sourcemaps', function () {
// This should actually just be `./integration/scss-sourcemap-imports/with_url.scss`
// but this is a small bug in the extend utility of the source-map library
assert.deepEqual(mapData.sources, [
'integration/scss-sourcemap-imports/style.scss',
'integration/scss-sourcemap-imports/with_url.scss',
]);

let input = await inputFS.readFile(
path.join(path.dirname(filename), map.sourceRoot, map.sources[1]),
path.join(path.dirname(filename), map.sourceRoot, map.sources[0]),
'utf-8',
);

Expand Down
1 change: 1 addition & 0 deletions packages/packagers/css/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@parcel/plugin": "2.11.0",
"@parcel/source-map": "^2.1.1",
"@parcel/utils": "2.11.0",
"lightningcss": "^1.22.1",
"nullthrows": "^1.1.1"
},
"devDependencies": {
Expand Down
198 changes: 128 additions & 70 deletions packages/packagers/css/src/CSSPackager.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,19 @@
import type {Root} from 'postcss';
import type {Asset, Dependency} from '@parcel/types';
import typeof PostCSS from 'postcss';
import {bundleAsync} from 'lightningcss';

import path from 'path';
import invariant from 'assert';
import nullthrows from 'nullthrows';
import SourceMap from '@parcel/source-map';
import {Packager} from '@parcel/plugin';
import {convertSourceLocationToHighlight} from '@parcel/diagnostic';
import {
PromiseQueue,
countLines,
replaceInlineReferences,
replaceURLReferences,
} from '@parcel/utils';

import nullthrows from 'nullthrows';

export default (new Packager({
async package({
bundle,
Expand All @@ -26,38 +25,68 @@ export default (new Packager({
logger,
options,
}) {
// Inline style attributes are parsed differently from full CSS files.
if (bundle.bundleBehavior === 'inline') {
let entry = bundle.getMainEntry();
if (entry?.meta.type === 'attr') {
return replaceReferences(
bundle,
bundleGraph,
await entry.getCode(),
await entry.getMap(),
getInlineBundleContents,
);
}
}

let queue = new PromiseQueue({
maxConcurrent: 32,
});
let hoistedImports = [];
let assetsByPlaceholder = new Map();
let entry = null;
let entryContents = '';

bundle.traverse({
enter: (node, context) => {
if (node.type === 'asset' && !context) {
// If there is only one entry, we'll use it directly.
// Otherwise, we'll create a fake bundle entry with @import rules for each root asset.
if (entry == null) {
entry = node.value.id;
} else {
entry = bundle.id;
}

assetsByPlaceholder.set(node.value.id, node.value);
entryContents += `@import "${node.value.id}";\n`;
}
return true;
},
exit: node => {
if (node.type === 'dependency') {
let resolved = bundleGraph.getResolvedAsset(node.value, bundle);

// Hoist unresolved external dependencies (i.e. http: imports)
if (
node.value.priority === 'sync' &&
!bundleGraph.isDependencySkipped(node.value) &&
!bundleGraph.getResolvedAsset(node.value, bundle)
!resolved
) {
hoistedImports.push(node.value.specifier);
}
return;
}

let asset = node.value;

// Figure out which media types this asset was imported with.
// We only want to import the asset once, so group them all together.
let media = [];
for (let dep of bundleGraph.getIncomingDependencies(asset)) {
if (!dep.meta.media) {
// Asset was imported without a media type. Don't wrap in @media.
media.length = 0;
break;
if (resolved && bundle.hasAsset(resolved)) {
assetsByPlaceholder.set(
node.value.meta.placeholder ?? node.value.specifier,
resolved,
);
}
media.push(dep.meta.media);

return;
}

let asset = node.value;
queue.add(() => {
if (
!asset.symbols.isCleared &&
Expand All @@ -71,7 +100,6 @@ export default (new Packager({
bundleGraph,
bundle,
asset,
media,
);
} else {
return Promise.all([
Expand Down Expand Up @@ -106,77 +134,112 @@ export default (new Packager({
}
}

if (media.length) {
return `@media ${media.join(', ')} {\n${css}\n}\n`;
}

return css;
}),
bundle.env.sourceMap && asset.getMapBuffer(),
bundle.env.sourceMap ? asset.getMap() : null,
]);
}
});
},
});

let outputs = await queue.run();
let contents = '';
let outputs = new Map(
(await queue.run()).map(([asset, code, map]) => [asset, [code, map]]),
);
let map = new SourceMap(options.projectRoot);
let lineOffset = 0;

for (let url of hoistedImports) {
contents += `@import "${url}";\n`;
lineOffset++;
}
let res = await bundleAsync({
Copy link
Member

Choose a reason for hiding this comment

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

What can we do for the REPL where bundleAsync doesn't exist?

Copy link
Member Author

Choose a reason for hiding this comment

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

It does exist now (as of a few months ago)! Uses emscripten's asyncify transform to turn the rust function into a coroutine.

Copy link
Member

Choose a reason for hiding this comment

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

Ah, perfect!

Copy link
Member

@mischnic mischnic Jan 7, 2024

Choose a reason for hiding this comment

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

We will need to usual alias (and devdep) to the wasm package though like in the CSS packager and optimizer:

"browser": {
"lightningcss": "lightningcss-wasm"
}

Copy link
Member Author

Choose a reason for hiding this comment

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

added the dep. are we just relying on the transformer to initialize the wasm module? looks like CSSOptimizer doesn't do it, so I just followed that.

Copy link
Member

Choose a reason for hiding this comment

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

Good point, I apparently forgot that in the optimizer. It's not really safe to assume that every worker running the packager also has also at least once invoked the CSS transformer.

Copy link
Member Author

Choose a reason for hiding this comment

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

ok added to both

filename: nullthrows(entry),
sourceMap: !!bundle.env.sourceMap,
resolver: {
resolve(specifier) {
return specifier;
},
async read(file) {
if (file === bundle.id) {
return entryContents;
}

for (let [asset, code, mapBuffer] of outputs) {
contents += code + '\n';
if (bundle.env.sourceMap) {
if (mapBuffer) {
map.addBuffer(mapBuffer, lineOffset);
} else {
map.addEmptyMap(
path
.relative(options.projectRoot, asset.filePath)
.replace(/\\+/g, '/'),
code,
lineOffset,
);
}
let asset = assetsByPlaceholder.get(file);
if (!asset) {
return '';
}
let [code, map] = nullthrows(outputs.get(asset));
if (map) {
let sm = await map.stringify({format: 'inline'});
invariant(typeof sm === 'string');
code += `\n/*# sourceMappingURL=${sm} */`;
}
return code;
mischnic marked this conversation as resolved.
Show resolved Hide resolved
},
},
});

lineOffset += countLines(code);
}
}
let contents = res.code.toString();

if (bundle.env.sourceMap) {
if (res.map) {
let vlqMap = JSON.parse(res.map.toString());
map.addVLQMap(vlqMap);
let reference = await getSourceMapReference(map);
if (reference != null) {
contents += '/*# sourceMappingURL=' + reference + ' */\n';
}
}

({contents, map} = replaceURLReferences({
bundle,
bundleGraph,
contents,
map,
getReplacement: escapeString,
}));
// Prepend hoisted external imports.
if (hoistedImports.length > 0) {
let lineOffset = 0;
let hoistedCode = '';
for (let url of hoistedImports) {
hoistedCode += `@import "${url}";\n`;
lineOffset++;
}

return replaceInlineReferences({
if (bundle.env.sourceMap) {
map.offsetLines(1, lineOffset);
}

contents = hoistedCode + contents;
}

return replaceReferences(
bundle,
bundleGraph,
contents,
getInlineBundleContents,
getInlineReplacement: (dep, inlineType, contents) => ({
from: getSpecifier(dep),
to: escapeString(contents),
}),
map,
});
getInlineBundleContents,
);
},
}): Packager);

function replaceReferences(
bundle,
bundleGraph,
contents,
map,
getInlineBundleContents,
) {
({contents, map} = replaceURLReferences({
bundle,
bundleGraph,
contents,
map,
getReplacement: escapeString,
}));

return replaceInlineReferences({
bundle,
bundleGraph,
contents,
getInlineBundleContents,
getInlineReplacement: (dep, inlineType, contents) => ({
from: getSpecifier(dep),
to: escapeString(contents),
}),
map,
});
}

export function getSpecifier(dep: Dependency): string {
if (typeof dep.meta.placeholder === 'string') {
return dep.meta.placeholder;
Expand All @@ -195,8 +258,7 @@ async function processCSSModule(
bundleGraph,
bundle,
asset,
media,
): Promise<[Asset, string, ?Buffer]> {
): Promise<[Asset, string, ?SourceMap]> {
let postcss: PostCSS = await options.packageManager.require(
'postcss',
options.projectRoot + '/index',
Expand Down Expand Up @@ -276,11 +338,7 @@ async function processCSSModule(
sourceMap.addVLQMap(map.toJSON());
}

if (media.length) {
content = `@media ${media.join(', ')} {\n${content}\n}\n`;
}

return [asset, content, sourceMap?.toBuffer()];
return [asset, content, sourceMap];
}

function escapeDashedIdent(name) {
Expand Down