diff --git a/src/generators/api-links/__tests__/fixtures.test.mjs b/src/generators/api-links/__tests__/fixtures.test.mjs index d20fdfd5..647b0760 100644 --- a/src/generators/api-links/__tests__/fixtures.test.mjs +++ b/src/generators/api-links/__tests__/fixtures.test.mjs @@ -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); diff --git a/src/generators/web/ui/index.css b/src/generators/web/ui/index.css index 4c06e319..66472647 100644 --- a/src/generators/web/ui/index.css +++ b/src/generators/web/ui/index.css @@ -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; diff --git a/src/generators/web/utils/__tests__/css.test.mjs b/src/generators/web/utils/__tests__/css.test.mjs new file mode 100644 index 00000000..d048b418 --- /dev/null +++ b/src/generators/web/utils/__tests__/css.test.mjs @@ -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); + }); +}); diff --git a/src/generators/web/utils/css.mjs b/src/generators/web/utils/css.mjs index aa5e6ff7..d704f28a 100644 --- a/src/generators/web/utils/css.mjs +++ b/src/generators/web/utils/css.mjs @@ -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. @@ -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); @@ -47,19 +53,39 @@ 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(); @@ -67,19 +93,24 @@ export default () => { // 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', }; }, },