Skip to content
Closed
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 src/generators/api-links/__tests__/fixtures.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe('api links', () => {
const astJsResults = [];

for await (const chunk of astJs.generate(undefined, {
input: [sourceFile],
input: [sourceFile.replaceAll('\\', '/')],
worker,
})) {
astJsResults.push(...chunk);
Expand Down
14 changes: 14 additions & 0 deletions src/generators/web/ui/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,27 @@ main {
}
}

/* Navigation Bar */
nav [class*='navItems']:empty {
display: none !important;
}

@media (min-width: 64rem) {
nav [class*='actionsWrapper'] {
margin-left: auto;
}
}

#modalContent li > div > a {
max-width: 42rem;

> svg {
flex-shrink: 0;
}

> div {
min-width: 0;

> p {
overflow: hidden;
text-overflow: ellipsis;
Expand Down
98 changes: 98 additions & 0 deletions src/generators/web/utils/__tests__/css.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
'use strict';

import assert from 'node:assert/strict';
import { describe, it, mock } from 'node:test';

let readFileCalls = 0;
let bundleAsyncCalls = 0;

mock.module('node:fs/promises', {
namedExports: {
readFile: async () => {
readFileCalls += 1;
return 'body { color: red; }';
},
},
});

mock.module('lightningcss', {
namedExports: {
bundleAsync: async ({ cssModules }) => {
bundleAsyncCalls += 1;

return {
code: Buffer.from(cssModules ? '.module{}' : '.global{}'),
exports: cssModules ? { foo: { name: '_foo_hash' } } : {},
};
},
},
});

const createCssLoader = (await import('../css.mjs')).default;

describe('css loader', () => {
it('returns empty JS for global CSS but still emits collected CSS', async () => {
readFileCalls = 0;
bundleAsyncCalls = 0;

const plugin = createCssLoader();

const result = await plugin.load.handler('C:/tmp/styles.css');
assert.deepStrictEqual(result, {
code: '',
moduleType: 'js',
moduleSideEffects: 'no-treeshake',
});

let emitted;
plugin.buildEnd.call({
emitFile(file) {
emitted = file;
return 'ref';
},
});

assert.deepStrictEqual(emitted, {
type: 'asset',
name: 'styles.css',
source: '.global{}',
});

assert.equal(readFileCalls, 1);
assert.equal(bundleAsyncCalls, 1);
});

it('exports class mapping for CSS modules and caches results', async () => {
readFileCalls = 0;
bundleAsyncCalls = 0;

const plugin = createCssLoader();

const first = await plugin.load.handler('C:/tmp/index.module.css');
const second = await plugin.load.handler('C:/tmp/index.module.css');

assert.deepStrictEqual(first, {
code: 'export default {"foo":"_foo_hash"};',
moduleType: 'js',
moduleSideEffects: 'no-treeshake',
});
assert.deepStrictEqual(second, first);

let emitted;
plugin.buildEnd.call({
emitFile(file) {
emitted = file;
return 'ref';
},
});

assert.deepStrictEqual(emitted, {
type: 'asset',
name: 'styles.css',
source: '.module{}',
});

assert.equal(readFileCalls, 1);
assert.equal(bundleAsyncCalls, 1);
});
});
55 changes: 43 additions & 12 deletions src/generators/web/utils/css.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { resolve, dirname } from 'node:path';

import { bundleAsync } from 'lightningcss';

const requireFn = createRequire(import.meta.url);

// Since we use rolldown to bundle multiple times,
// we re-use a lot of CSS files, so there is no
// need to re-transpile.
Expand All @@ -26,19 +30,21 @@ export default () => {

// Hook into the module loading phase of Rolldown
load: {
// Match only files ending with `.module.css`
// Match only files ending with `.css`
filter: {
id: {
include: /\.module\.css$/,
include: /\.css$/,
},
},

/**
* Load handler to process matched `.module.css` files
* Load handler to process matched `.css` files
*
* @param {string} id - Absolute file path to the CSS file
*/
async handler(id) {
const isModule = /\.module\.css$/.test(id);

// Return from cache if already processed
if (fileCache.has(id)) {
const cached = fileCache.get(id);
Expand All @@ -47,39 +53,64 @@ export default () => {
cssChunks.add(cached.code);

return {
code: `export default ${JSON.stringify(cached.exports)};`,
code: isModule
? `export default ${JSON.stringify(cached.exports)};`
: '',
moduleType: 'js',
moduleSideEffects: 'no-treeshake',
};
}

// Read the raw CSS file from disk
const source = await readFile(id, 'utf8');

// Use Lightning CSS to compile the file with CSS Modules enabled
// Use Lightning CSS to compile the file
const { code, exports } = await bundleAsync({
filename: id,
code: Buffer.from(source),
cssModules: true,
cssModules: isModule,
resolver: {
/**
* @param {string} specifier
* @param {string} from
*/
resolve(specifier, from) {
if (specifier.startsWith('./') || specifier.startsWith('../')) {
return resolve(dirname(from), specifier);
}

try {
return requireFn.resolve(specifier, { paths: [dirname(from)] });
} catch {
return specifier;
}
},
},
});

const css = code.toString();

// Add the compiled CSS to our in-memory collection
cssChunks.add(css);

// Map exported class names to their scoped identifiers
// Map exported class names to their scoped identifiers if it's a module
// e.g., { button: '_button_abc123' }
const mappedExports = Object.fromEntries(
Object.entries(exports).map(([key, value]) => [key, value.name])
);
const mappedExports = isModule
? Object.fromEntries(
Object.entries(exports).map(([key, value]) => [key, value.name])
)
: {};

// Cache result
fileCache.set(id, { code: css, exports: mappedExports });

// Return a JS module that exports the scoped class names
// Return a JS module that exports the scoped class names (or nothing for global CSS)
return {
code: `export default ${JSON.stringify(mappedExports)};`,
code: isModule
? `export default ${JSON.stringify(mappedExports)};`
: '',
moduleType: 'js',
moduleSideEffects: 'no-treeshake',
};
},
},
Expand Down