Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
70f4fe90c5 feat(bundler): add CSS Module Scripts support
Implement CSS Module Scripts (https://web.dev/articles/css-module-scripts)
for Bun's bundler. When importing CSS with `{ type: 'css' }` attribute:

```javascript
import sheet from './styles.css' with { type: 'css' };
// or dynamically:
const module = await import('./styles.css', { with: { type: 'css' } });
```

The import now returns a CSSStyleSheet object that can be used with
`document.adoptedStyleSheets`, instead of the previous behavior of
returning an empty object or file path.

Changes:
- Add `__cssModuleScript` runtime helper that creates CSSStyleSheet
- Add `is_css_module_script` flag to ImportRecord
- Track CSS Module Script files in LinkerGraph
- Generate `__cssModuleScript(cssContent)` for CSS imports with type assertion
- Handle both static and dynamic imports correctly
- Preserve existing CSS Modules behavior (class name mappings) for
  imports without the type assertion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 20:45:19 +00:00
9 changed files with 440 additions and 202 deletions

View File

@@ -511,6 +511,10 @@ pub fn NewParser_(
p.import_records.items[import_record_index].tag = tag;
}
if (state.import_loader) |loader| {
p.import_records.items[import_record_index].loader = loader;
}
p.import_records.items[import_record_index].handles_import_errors = (state.is_await_target and p.fn_or_arrow_data_visit.try_body_count != 0) or state.is_then_catch_target;
p.import_records_for_current_part.append(p.allocator, import_record_index) catch unreachable;

View File

@@ -32,6 +32,10 @@ is_scb_bitset: BitSet = .{},
has_client_components: bool = false,
has_server_components: bool = false,
/// Tracks which CSS files are imported with `{ type: 'css' }` (CSS Module Scripts)
/// These files need to export a CSSStyleSheet instead of the normal CSS module exports
css_module_script_files: BitSet = .{},
/// This is for cross-module inlining of detected inlinable constants
// const_values: js_ast.Ast.ConstValuesMap = .{},
/// This is for cross-module inlining of TypeScript enum constants
@@ -41,6 +45,7 @@ pub fn init(allocator: std.mem.Allocator, file_count: usize) !LinkerGraph {
return LinkerGraph{
.allocator = allocator,
.files_live = try BitSet.initEmpty(allocator, file_count),
.css_module_script_files = try BitSet.initEmpty(allocator, file_count),
};
}
@@ -237,6 +242,10 @@ pub fn load(
this.allocator,
sources.len,
);
this.css_module_script_files = try BitSet.initEmpty(
this.allocator,
sources.len,
);
this.files.len = sources.len;
var files = this.files.slice();

View File

@@ -3423,9 +3423,21 @@ pub const BundleV2 = struct {
}
}
const import_record_loader = import_record.loader orelse path.loader(&transpiler.options.loaders) orelse .file;
// Check if the import has an explicit loader from import attributes (e.g., { type: 'css' })
// before falling back to the path-based loader
const explicit_loader = import_record.loader;
const import_record_loader = explicit_loader orelse path.loader(&transpiler.options.loaders) orelse .file;
import_record.loader = import_record_loader;
// Check if this is a CSS Module Script import (import ... with { type: 'css' })
// The import should have type: 'css' assertion AND target a CSS file
// IMPORTANT: Only set this if the loader was EXPLICITLY set via import attributes,
// not just because the file has a .css extension
const path_loader = path.loader(&transpiler.options.loaders) orelse .file;
if (explicit_loader != null and explicit_loader.? == .css and path_loader == .css) {
import_record.is_css_module_script = true;
}
const is_html_entrypoint = import_record_loader == .html and target.isServerSide() and this.transpiler.options.dev_server == null;
if (this.pathToSourceIndexMap(target).get(path.text)) |id| {

View File

@@ -17,247 +17,290 @@ pub fn generateCodeForLazyExport(this: *LinkerContext, source_index: Index.Int)
const module_ref = this.graph.ast.items(.module_ref)[source_index];
// Handle css modules
// Handle CSS Module Scripts (import ... with { type: 'css' })
// If this CSS file is imported with type: 'css', export a CSSStyleSheet
if (maybe_css_ast != null and this.graph.css_module_script_files.isSet(source_index)) {
const source = &all_sources[source_index];
const stmt: Stmt = part.stmts[0];
if (stmt.data != .s_lazy_export) {
@panic("Internal error: expected top-level lazy export statement");
}
// Generate: __cssModuleScript("css content")
const css_content_str = E.String.init(source.contents);
const css_content_expr = Expr.init(E.String, css_content_str, stmt.loc);
// Get the __cssModuleScript runtime function ref
const css_module_script_ref = this.graph.runtimeFunction("__cssModuleScript");
// Generate the call expression: __cssModuleScript("css content")
const call_args = try this.allocator().alloc(Expr, 1);
call_args[0] = css_content_expr;
const call_expr = Expr.init(E.Call, E.Call{
.target = Expr.initIdentifier(css_module_script_ref, stmt.loc),
.args = BabyList(Expr).fromOwnedSlice(call_args),
}, stmt.loc);
// Replace the lazy export with the call expression
part.stmts[0].data.s_lazy_export.* = call_expr.data;
// Mark that we need the __cssModuleScript runtime function
try this.graph.generateRuntimeSymbolImportAndUse(
source_index,
Index.part(1),
"__cssModuleScript",
1,
);
}
// Handle css modules (class name exports)
//
// --- original comment from esbuild ---
// If this JavaScript file is a stub from a CSS file, populate the exports of
// this JavaScript stub with the local names from that CSS file. This is done
// now instead of earlier because we need the whole bundle to be present.
// Skip this for CSS Module Scripts which already have their export set above.
const is_css_module_script_file = maybe_css_ast != null and this.graph.css_module_script_files.isSet(source_index);
if (maybe_css_ast) |css_ast| {
const stmt: Stmt = part.stmts[0];
if (stmt.data != .s_lazy_export) {
@panic("Internal error: expected top-level lazy export statement");
}
if (css_ast.local_scope.count() > 0) out: {
var exports = E.Object{};
if (is_css_module_script_file) {
// Skip - CSS Module Scripts already have their export set above
} else {
const stmt: Stmt = part.stmts[0];
if (stmt.data != .s_lazy_export) {
@panic("Internal error: expected top-level lazy export statement");
}
if (css_ast.local_scope.count() > 0) out: {
var exports = E.Object{};
const symbols: *const Symbol.List = &this.graph.ast.items(.symbols)[source_index];
const all_import_records: []const BabyList(bun.css.ImportRecord) = this.graph.ast.items(.import_records);
const symbols: *const Symbol.List = &this.graph.ast.items(.symbols)[source_index];
const all_import_records: []const BabyList(bun.css.ImportRecord) = this.graph.ast.items(.import_records);
const values = css_ast.local_scope.values();
if (values.len == 0) break :out;
const size = size: {
var size: u32 = 0;
for (values) |entry| {
size = @max(size, entry.ref.inner_index);
}
break :size size + 1;
};
const values = css_ast.local_scope.values();
if (values.len == 0) break :out;
const size = size: {
var size: u32 = 0;
for (values) |entry| {
size = @max(size, entry.ref.inner_index);
}
break :size size + 1;
};
var inner_visited = try BitSet.initEmpty(this.allocator(), size);
defer inner_visited.deinit(this.allocator());
var composes_visited = std.AutoArrayHashMap(bun.bundle_v2.Ref, void).init(this.allocator());
defer composes_visited.deinit();
var inner_visited = try BitSet.initEmpty(this.allocator(), size);
defer inner_visited.deinit(this.allocator());
var composes_visited = std.AutoArrayHashMap(bun.bundle_v2.Ref, void).init(this.allocator());
defer composes_visited.deinit();
const Visitor = struct {
inner_visited: *BitSet,
composes_visited: *std.AutoArrayHashMap(bun.bundle_v2.Ref, void),
parts: *std.array_list.Managed(E.TemplatePart),
all_import_records: []const BabyList(bun.css.ImportRecord),
all_css_asts: []?*bun.css.BundlerStyleSheet,
all_sources: []const Logger.Source,
all_symbols: []const Symbol.List,
source_index: Index.Int,
log: *Logger.Log,
loc: Loc,
allocator: std.mem.Allocator,
const Visitor = struct {
inner_visited: *BitSet,
composes_visited: *std.AutoArrayHashMap(bun.bundle_v2.Ref, void),
parts: *std.array_list.Managed(E.TemplatePart),
all_import_records: []const BabyList(bun.css.ImportRecord),
all_css_asts: []?*bun.css.BundlerStyleSheet,
all_sources: []const Logger.Source,
all_symbols: []const Symbol.List,
source_index: Index.Int,
log: *Logger.Log,
loc: Loc,
allocator: std.mem.Allocator,
fn clearAll(visitor: *@This()) void {
visitor.inner_visited.setAll(false);
visitor.composes_visited.clearRetainingCapacity();
}
fn visitName(visitor: *@This(), ast: *bun.css.BundlerStyleSheet, ref: bun.css.CssRef, idx: Index.Int) void {
bun.assert(ref.canBeComposed());
const from_this_file = ref.sourceIndex(idx) == visitor.source_index;
if ((from_this_file and visitor.inner_visited.isSet(ref.innerIndex())) or
(!from_this_file and visitor.composes_visited.contains(ref.toRealRef(idx))))
{
return;
fn clearAll(visitor: *@This()) void {
visitor.inner_visited.setAll(false);
visitor.composes_visited.clearRetainingCapacity();
}
visitor.visitComposes(ast, ref, idx);
visitor.parts.append(E.TemplatePart{
.value = Expr.init(
E.NameOfSymbol,
E.NameOfSymbol{
.ref = ref.toRealRef(idx),
fn visitName(visitor: *@This(), ast: *bun.css.BundlerStyleSheet, ref: bun.css.CssRef, idx: Index.Int) void {
bun.assert(ref.canBeComposed());
const from_this_file = ref.sourceIndex(idx) == visitor.source_index;
if ((from_this_file and visitor.inner_visited.isSet(ref.innerIndex())) or
(!from_this_file and visitor.composes_visited.contains(ref.toRealRef(idx))))
{
return;
}
visitor.visitComposes(ast, ref, idx);
visitor.parts.append(E.TemplatePart{
.value = Expr.init(
E.NameOfSymbol,
E.NameOfSymbol{
.ref = ref.toRealRef(idx),
},
visitor.loc,
),
.tail = .{
.cooked = E.String.init(" "),
},
visitor.loc,
),
.tail = .{
.cooked = E.String.init(" "),
},
.tail_loc = visitor.loc,
}) catch |err| bun.handleOom(err);
.tail_loc = visitor.loc,
}) catch |err| bun.handleOom(err);
if (from_this_file) {
visitor.inner_visited.set(ref.innerIndex());
} else {
visitor.composes_visited.put(ref.toRealRef(idx), {}) catch unreachable;
if (from_this_file) {
visitor.inner_visited.set(ref.innerIndex());
} else {
visitor.composes_visited.put(ref.toRealRef(idx), {}) catch unreachable;
}
}
}
fn warnNonSingleClassComposes(visitor: *@This(), ast: *bun.css.BundlerStyleSheet, css_ref: bun.css.CssRef, idx: Index.Int, compose_loc: Loc) void {
const ref = css_ref.toRealRef(idx);
_ = ref;
const syms: *const Symbol.List = &visitor.all_symbols[css_ref.sourceIndex(idx)];
const name = syms.at(css_ref.innerIndex()).original_name;
const loc = ast.local_scope.get(name).?.loc;
fn warnNonSingleClassComposes(visitor: *@This(), ast: *bun.css.BundlerStyleSheet, css_ref: bun.css.CssRef, idx: Index.Int, compose_loc: Loc) void {
const ref = css_ref.toRealRef(idx);
_ = ref;
const syms: *const Symbol.List = &visitor.all_symbols[css_ref.sourceIndex(idx)];
const name = syms.at(css_ref.innerIndex()).original_name;
const loc = ast.local_scope.get(name).?.loc;
visitor.log.addRangeErrorFmtWithNote(
&visitor.all_sources[idx],
.{ .loc = compose_loc },
visitor.allocator,
"The composes property cannot be used with {f}, because it is not a single class name.",
.{
bun.fmt.quote(name),
},
"The definition of {f} is here.",
.{
bun.fmt.quote(name),
},
visitor.log.addRangeErrorFmtWithNote(
&visitor.all_sources[idx],
.{ .loc = compose_loc },
visitor.allocator,
"The composes property cannot be used with {f}, because it is not a single class name.",
.{
bun.fmt.quote(name),
},
"The definition of {f} is here.",
.{
bun.fmt.quote(name),
},
.{
.loc = loc,
},
) catch |err| bun.handleOom(err);
}
.{
.loc = loc,
},
) catch |err| bun.handleOom(err);
}
fn visitComposes(visitor: *@This(), ast: *bun.css.BundlerStyleSheet, css_ref: bun.css.CssRef, idx: Index.Int) void {
const ref = css_ref.toRealRef(idx);
if (ast.composes.count() > 0) {
const composes = ast.composes.getPtr(ref) orelse return;
// while parsing we check that we only allow `composes` on single class selectors
bun.assert(css_ref.tag.class);
fn visitComposes(visitor: *@This(), ast: *bun.css.BundlerStyleSheet, css_ref: bun.css.CssRef, idx: Index.Int) void {
const ref = css_ref.toRealRef(idx);
if (ast.composes.count() > 0) {
const composes = ast.composes.getPtr(ref) orelse return;
// while parsing we check that we only allow `composes` on single class selectors
bun.assert(css_ref.tag.class);
for (composes.composes.slice()) |*compose| {
// it is imported
if (compose.from != null) {
if (compose.from.? == .import_record_index) {
const import_record_idx = compose.from.?.import_record_index;
const import_records: *const BabyList(bun.css.ImportRecord) = &visitor.all_import_records[idx];
const import_record = import_records.at(import_record_idx);
if (import_record.source_index.isValid()) {
const other_file = visitor.all_css_asts[import_record.source_index.get()] orelse {
for (composes.composes.slice()) |*compose| {
// it is imported
if (compose.from != null) {
if (compose.from.? == .import_record_index) {
const import_record_idx = compose.from.?.import_record_index;
const import_records: *const BabyList(bun.css.ImportRecord) = &visitor.all_import_records[idx];
const import_record = import_records.at(import_record_idx);
if (import_record.source_index.isValid()) {
const other_file = visitor.all_css_asts[import_record.source_index.get()] orelse {
visitor.log.addErrorFmt(
&visitor.all_sources[idx],
compose.loc,
visitor.allocator,
"Cannot use the \"composes\" property with the {f} file (it is not a CSS file)",
.{bun.fmt.quote(visitor.all_sources[import_record.source_index.get()].path.pretty)},
) catch |err| bun.handleOom(err);
continue;
};
for (compose.names.slice()) |name| {
const other_name_entry = other_file.local_scope.get(name.v) orelse continue;
const other_name_ref = other_name_entry.ref;
if (!other_name_ref.canBeComposed()) {
visitor.warnNonSingleClassComposes(other_file, other_name_ref, import_record.source_index.get(), compose.loc);
} else {
visitor.visitName(other_file, other_name_ref, import_record.source_index.get());
}
}
}
} else if (compose.from.? == .global) {
// E.g.: `composes: foo from global`
//
// In this example `foo` is global and won't be rewritten to a locally scoped
// name, so we can just add it as a string.
for (compose.names.slice()) |name| {
visitor.parts.append(
E.TemplatePart{
.value = Expr.init(
E.String,
E.String.init(name.v),
visitor.loc,
),
.tail = .{
.cooked = E.String.init(" "),
},
.tail_loc = visitor.loc,
},
) catch |err| bun.handleOom(err);
}
}
} else {
// it is from the current file
for (compose.names.slice()) |name| {
const name_entry = ast.local_scope.get(name.v) orelse {
visitor.log.addErrorFmt(
&visitor.all_sources[idx],
compose.loc,
visitor.allocator,
"Cannot use the \"composes\" property with the {f} file (it is not a CSS file)",
.{bun.fmt.quote(visitor.all_sources[import_record.source_index.get()].path.pretty)},
"The name {f} never appears in {f} as a CSS modules locally scoped class name. Note that \"composes\" only works with single class selectors.",
.{
bun.fmt.quote(name.v),
bun.fmt.quote(visitor.all_sources[idx].path.pretty),
},
) catch |err| bun.handleOom(err);
continue;
};
for (compose.names.slice()) |name| {
const other_name_entry = other_file.local_scope.get(name.v) orelse continue;
const other_name_ref = other_name_entry.ref;
if (!other_name_ref.canBeComposed()) {
visitor.warnNonSingleClassComposes(other_file, other_name_ref, import_record.source_index.get(), compose.loc);
} else {
visitor.visitName(other_file, other_name_ref, import_record.source_index.get());
}
const name_ref = name_entry.ref;
if (!name_ref.canBeComposed()) {
visitor.warnNonSingleClassComposes(ast, name_ref, idx, compose.loc);
} else {
visitor.visitName(ast, name_ref, idx);
}
}
} else if (compose.from.? == .global) {
// E.g.: `composes: foo from global`
//
// In this example `foo` is global and won't be rewritten to a locally scoped
// name, so we can just add it as a string.
for (compose.names.slice()) |name| {
visitor.parts.append(
E.TemplatePart{
.value = Expr.init(
E.String,
E.String.init(name.v),
visitor.loc,
),
.tail = .{
.cooked = E.String.init(" "),
},
.tail_loc = visitor.loc,
},
) catch |err| bun.handleOom(err);
}
}
} else {
// it is from the current file
for (compose.names.slice()) |name| {
const name_entry = ast.local_scope.get(name.v) orelse {
visitor.log.addErrorFmt(
&visitor.all_sources[idx],
compose.loc,
visitor.allocator,
"The name {f} never appears in {f} as a CSS modules locally scoped class name. Note that \"composes\" only works with single class selectors.",
.{
bun.fmt.quote(name.v),
bun.fmt.quote(visitor.all_sources[idx].path.pretty),
},
) catch |err| bun.handleOom(err);
continue;
};
const name_ref = name_entry.ref;
if (!name_ref.canBeComposed()) {
visitor.warnNonSingleClassComposes(ast, name_ref, idx, compose.loc);
} else {
visitor.visitName(ast, name_ref, idx);
}
}
}
}
}
}
};
};
var visitor = Visitor{
.inner_visited = &inner_visited,
.composes_visited = &composes_visited,
.source_index = source_index,
.parts = undefined,
.all_import_records = all_import_records,
.all_css_asts = all_css_asts,
.loc = stmt.loc,
.log = this.log,
.all_sources = all_sources,
.allocator = this.allocator(),
.all_symbols = this.graph.ast.items(.symbols),
};
var visitor = Visitor{
.inner_visited = &inner_visited,
.composes_visited = &composes_visited,
.source_index = source_index,
.parts = undefined,
.all_import_records = all_import_records,
.all_css_asts = all_css_asts,
.loc = stmt.loc,
.log = this.log,
.all_sources = all_sources,
.allocator = this.allocator(),
.all_symbols = this.graph.ast.items(.symbols),
};
for (values) |entry| {
const ref = entry.ref;
bun.assert(ref.inner_index < symbols.len);
for (values) |entry| {
const ref = entry.ref;
bun.assert(ref.inner_index < symbols.len);
var template_parts = std.array_list.Managed(E.TemplatePart).init(this.allocator());
var value = Expr.init(E.NameOfSymbol, E.NameOfSymbol{ .ref = ref.toRealRef(source_index) }, stmt.loc);
var template_parts = std.array_list.Managed(E.TemplatePart).init(this.allocator());
var value = Expr.init(E.NameOfSymbol, E.NameOfSymbol{ .ref = ref.toRealRef(source_index) }, stmt.loc);
visitor.parts = &template_parts;
visitor.clearAll();
visitor.inner_visited.set(ref.innerIndex());
if (ref.tag.class) visitor.visitComposes(css_ast, ref, source_index);
visitor.parts = &template_parts;
visitor.clearAll();
visitor.inner_visited.set(ref.innerIndex());
if (ref.tag.class) visitor.visitComposes(css_ast, ref, source_index);
if (template_parts.items.len > 0) {
template_parts.append(E.TemplatePart{
.value = value,
.tail_loc = stmt.loc,
.tail = .{ .cooked = E.String.init("") },
}) catch |err| bun.handleOom(err);
value = Expr.init(
E.Template,
E.Template{
.parts = template_parts.items,
.head = .{
.cooked = E.String.init(""),
if (template_parts.items.len > 0) {
template_parts.append(E.TemplatePart{
.value = value,
.tail_loc = stmt.loc,
.tail = .{ .cooked = E.String.init("") },
}) catch |err| bun.handleOom(err);
value = Expr.init(
E.Template,
E.Template{
.parts = template_parts.items,
.head = .{
.cooked = E.String.init(""),
},
},
},
stmt.loc,
);
stmt.loc,
);
}
const key = symbols.at(ref.innerIndex()).original_name;
try exports.put(this.allocator(), key, value);
}
const key = symbols.at(ref.innerIndex()).original_name;
try exports.put(this.allocator(), key, value);
part.stmts[0].data.s_lazy_export.* = Expr.init(E.Object, exports, stmt.loc).data;
}
part.stmts[0].data.s_lazy_export.* = Expr.init(E.Object, exports, stmt.loc).data;
}
}

View File

@@ -87,6 +87,11 @@ pub fn scanImportsAndExports(this: *LinkerContext) ScanImportsAndExportsError!vo
const other_file = record.source_index.get();
const other_flags = ast_flags_list[other_file];
// Track CSS Module Script imports (import ... with { type: 'css' })
if (record.is_css_module_script and css_asts[other_file] != null) {
this.graph.css_module_script_files.set(other_file);
}
// other file is empty
if (other_file >= exports_kind.len) continue;
const other_kind = exports_kind[other_file];
@@ -141,9 +146,14 @@ pub fn scanImportsAndExports(this: *LinkerContext) ScanImportsAndExportsError!vo
},
.dynamic => {
if (!this.graph.code_splitting) {
// If we're not splitting, then import() is just a require() that
// returns a promise, so the imported file must be a CommonJS module
if (exports_kind[other_file] == .esm) {
// CSS Module Script imports should not be wrapped as CommonJS
// They need to remain ESM to use __cssModuleScript
if (record.is_css_module_script) {
// Keep as ESM - will be handled by generateCodeForLazyExport
flags[other_file].wrap = .esm;
} else if (exports_kind[other_file] == .esm) {
// If we're not splitting, then import() is just a require() that
// returns a promise, so the imported file must be a CommonJS module
flags[other_file].wrap = .esm;
} else {
// TODO: introduce a NamedRequire for require("./foo").Bar AST nodes to support tree-shaking those.

View File

@@ -166,6 +166,9 @@ pub const ImportRecord = struct {
wrap_with_to_esm: bool = false,
wrap_with_to_commonjs: bool = false,
/// True if this import has `with { type: 'css' }` and should return a CSSStyleSheet
is_css_module_script: bool = false,
pub const List = bun.BabyList(ImportRecord);
pub const Tag = enum {

View File

@@ -177,3 +177,11 @@ export var $$typeof = /* @__PURE__ */ Symbol.for("react.element");
export var __jsonParse = /* @__PURE__ */ a => JSON.parse(a);
export var __promiseAll = args => Promise.all(args);
// CSS Module Scripts helper - creates a CSSStyleSheet from CSS text
// See: https://web.dev/articles/css-module-scripts
export var __cssModuleScript = /* @__PURE__ */ cssText => {
var sheet = new CSSStyleSheet();
sheet.replaceSync(cssText);
return sheet;
};

View File

@@ -318,6 +318,7 @@ pub const Runtime = struct {
__callDispose: ?Ref = null,
__jsonParse: ?Ref = null,
__promiseAll: ?Ref = null,
__cssModuleScript: ?Ref = null,
pub const all = [_][]const u8{
"__name",
@@ -335,6 +336,7 @@ pub const Runtime = struct {
"__callDispose",
"__jsonParse",
"__promiseAll",
"__cssModuleScript",
};
const all_sorted: [all.len]string = brk: {
@setEvalBranchQuota(1000000);

View File

@@ -0,0 +1,147 @@
import { itBundled } from "../expectBundled";
// Tests for CSS Module Scripts - https://web.dev/articles/css-module-scripts
// When importing CSS with `with { type: 'css' }`, the import should return a CSSStyleSheet object
describe("css-module-scripts", () => {
// Mock CSSStyleSheet for testing since we're running in Bun, not a browser
const env = {
...process.env,
// Inject a mock CSSStyleSheet constructor
BUN_DEBUG_QUIET_LOGS: "1",
};
itBundled("css-module-scripts/StaticImportWithTypeCSS", {
files: {
"/entry.js": /* js */ `
import sheet from './styles.css' with { type: 'css' };
console.log('sheet type:', typeof sheet);
console.log('sheet instanceof CSSStyleSheet:', sheet instanceof CSSStyleSheet);
console.log('cssRules length:', sheet.cssRules.length);
`,
"/styles.css": `.foo { color: red; }`,
},
entryPoints: ["/entry.js"],
outdir: "/out",
target: "browser",
format: "esm",
onAfterBundle(api) {
// Verify the output contains __cssModuleScript call
const content = api.readFile("/out/entry.js");
expect(content).toContain("__cssModuleScript");
expect(content).toContain(".foo { color: red; }");
},
});
itBundled("css-module-scripts/DynamicImportWithTypeCSS", {
files: {
"/entry.js": /* js */ `
const module = await import('./styles.css', { with: { type: 'css' } });
const sheet = module.default;
console.log('sheet type:', typeof sheet);
console.log('sheet instanceof CSSStyleSheet:', sheet instanceof CSSStyleSheet);
console.log('cssRules length:', sheet.cssRules.length);
`,
"/styles.css": `.bar { color: blue; }`,
},
entryPoints: ["/entry.js"],
outdir: "/out",
target: "browser",
format: "esm",
onAfterBundle(api) {
// Verify the output contains __cssModuleScript call with CSS content
const content = api.readFile("/out/entry.js");
expect(content).toContain("__cssModuleScript");
expect(content).toContain(".bar { color: blue; }");
},
});
itBundled("css-module-scripts/DynamicImportWithAssertTypeCSS", {
// Test the older `assert` syntax for backwards compatibility
files: {
"/entry.js": /* js */ `
const module = await import('./styles.css', { assert: { type: 'css' } });
const sheet = module.default;
console.log('sheet type:', typeof sheet);
`,
"/styles.css": `.baz { color: green; }`,
},
entryPoints: ["/entry.js"],
outdir: "/out",
target: "browser",
format: "esm",
onAfterBundle(api) {
// Verify the output contains __cssModuleScript call
const content = api.readFile("/out/entry.js");
expect(content).toContain("__cssModuleScript");
},
});
itBundled("css-module-scripts/CSSModuleWithTypeCSS", {
// CSS Modules (*.module.css) should still work with type: 'css'
// but return a CSSStyleSheet instead of the class name mapping
files: {
"/entry.js": /* js */ `
import sheet from './styles.module.css' with { type: 'css' };
console.log('sheet instanceof CSSStyleSheet:', sheet instanceof CSSStyleSheet);
`,
"/styles.module.css": `.myClass { color: purple; }`,
},
entryPoints: ["/entry.js"],
outdir: "/out",
target: "browser",
format: "esm",
onAfterBundle(api) {
const content = api.readFile("/out/entry.js");
expect(content).toContain("__cssModuleScript");
},
});
itBundled("css-module-scripts/PlainCSSImportWithoutType", {
// Plain CSS imports without type should NOT return CSSStyleSheet
// (existing behavior - either side-effect or object with class names)
files: {
"/entry.js": /* js */ `
import './styles.css';
console.log('CSS imported as side effect');
`,
"/styles.css": `.plain { color: black; }`,
},
entryPoints: ["/entry.js"],
outdir: "/out",
target: "browser",
format: "esm",
onAfterBundle(api) {
const content = api.readFile("/out/entry.js");
// Should NOT contain CSSStyleSheet for plain imports
expect(content).not.toContain("new CSSStyleSheet");
},
});
itBundled("css-module-scripts/MultipleRules", {
files: {
"/entry.js": /* js */ `
import sheet from './styles.css' with { type: 'css' };
console.log('rules:', sheet.cssRules.length);
`,
"/styles.css": /* css */ `
.a { color: red; }
.b { color: blue; }
.c { color: green; }
@media (min-width: 768px) {
.a { color: darkred; }
}
`,
},
entryPoints: ["/entry.js"],
outdir: "/out",
target: "browser",
format: "esm",
onAfterBundle(api) {
const content = api.readFile("/out/entry.js");
expect(content).toContain("__cssModuleScript");
// The CSS content should be included as a string
expect(content).toContain(".a");
expect(content).toContain("color");
},
});
});