diff --git a/node/index.d.ts b/node/index.d.ts index e1a43102..a9f2a7f2 100644 --- a/node/index.d.ts +++ b/node/index.d.ts @@ -197,3 +197,18 @@ export declare function browserslistToTargets(browserslist: string[]): Targets; * Bundles a CSS file and its dependencies, inlining @import rules. */ export declare function bundle(options: BundleOptions): TransformResult; + +export interface AsyncBundleOptions extends BundleOptions { + resolver?: Resolver; +} + +export interface Resolver { + read?: (file: string) => string | Promise; + resolve?: (specifier: string, originatingFile: string) => string | Promise; +} + +/** + * Bundles a CSS file and its dependencies asynchronously, inlining @import rules. + * Also supports custom resolvers. + */ +export declare function bundleAsync(options: AsyncBundleOptions): Promise; diff --git a/node/index.js b/node/index.js index ba7fe4ee..508de4e9 100644 --- a/node/index.js +++ b/node/index.js @@ -23,3 +23,48 @@ if (process.env.CSS_TRANSFORMER_WASM) { } module.exports.browserslistToTargets = require('./browserslistToTargets'); + +// Include a small JS-shim for `bundleAsync()` to convert make `resolver` more ergonomic. +const {bundleAsync} = module.exports; +module.exports.bundleAsync = (opts, ...rest) => { + return bundleAsync({ + ...opts, + resolver: opts.resolver && { + ...opts.resolver, + read: opts.resolver.read && normalizeJsCallback(opts.resolver.read.bind(opts.resolver)), + resolve: opts.resolver.resolve && normalizeJsCallback(opts.resolver.resolve.bind(opts.resolver)), + }, + }, ...rest); +}; + +// `napi-rs` ignores JS function return values, so any results must be passed back to +// the Rust side via a callback rather than a returned value or `Promise`. This +// callback also follows NodeJS conventions (`callback(err, result)`). Managing the +// error and callback are annoying for users, so this converts a typical JS function +// which returns its result in a `Promise` into a N-API-compatible function which +// accepts and propagates its results to a callback. +function normalizeJsCallback(func) { + return (...args) => { + // Splice out `[...args, callback]`. + const funcArgs = args.slice(0, -1); + const callback = args[args.length - 1]; + + // Invoke the inner function, normalize to a `Promise`, and then invoke the callback + // function with Node conventions of `callback(err, result)`. + toPromise(() => func(...funcArgs)).then( + (result) => callback(null, result), + (err) => callback(err, null), + ); + }; +} + +// Converts the given function execution to return a `Promise` instead of returning or +// erroring synchronously. This is different from `Promise.resolve(func())` in that a +// synchronous `throw` statement is converted to a `Promise` rejection. +function toPromise(func) { + try { + return Promise.resolve(func()); + } catch (err) { + return Promise.reject(err); + } +} diff --git a/test-bundle.mjs b/test-bundle.mjs new file mode 100644 index 00000000..eb1ad310 --- /dev/null +++ b/test-bundle.mjs @@ -0,0 +1,262 @@ +import path from 'path'; +import css from './node/index.js'; + +(async function testResolver() { + const inMemoryFs = new Map(Object.entries({ + 'foo.css': ` +@import 'root:bar.css'; + +.foo { color: red; } + `.trim(), + + 'bar.css': ` +@import 'root:hello/world.css'; + +.bar { color: green; } + `.trim(), + + 'hello/world.css': ` +.baz { color: blue; } + `.trim(), + })); + + const { code: buffer } = await css.bundleAsync({ + filename: 'foo.css', + resolver: { + read(file) { + const result = inMemoryFs.get(path.normalize(file)); + if (!result) throw new Error(`Could not find ${file} in ${ + Array.from(inMemoryFs.keys()).join(', ')}.`); + return result; + }, + + resolve(specifier) { + return specifier.slice('root:'.length); + }, + }, + }); + const code = buffer.toString('utf-8').trim(); + + const expected = ` +.baz { + color: #00f; +} + +.bar { + color: green; +} + +.foo { + color: red; +} + `.trim(); + if (code !== expected) throw new Error(`\`testResolver()\` failed. Expected:\n${expected}\n\nGot:\n${code}`); +})(); + +(async function testOnlyCustomRead() { + const inMemoryFs = new Map(Object.entries({ + 'foo.css': ` +@import 'hello/world.css'; + +.foo { color: red; } + `.trim(), + + 'hello/world.css': ` +@import '../bar.css'; + +.bar { color: green; } + `.trim(), + + 'bar.css': ` +.baz { color: blue; } + `.trim(), + })); + + const { code: buffer } = await css.bundleAsync({ + filename: 'foo.css', + resolver: { + read(file) { + const result = inMemoryFs.get(path.normalize(file)); + if (!result) throw new Error(`Could not find ${file} in ${ + Array.from(inMemoryFs.keys()).join(', ')}.`); + return result; + }, + }, + }); + const code = buffer.toString('utf-8').trim(); + + const expected = ` +.baz { + color: #00f; +} + +.bar { + color: green; +} + +.foo { + color: red; +} + `.trim(); + if (code !== expected) throw new Error(`\`testOnlyCustomRead()\` failed. Expected:\n${expected}\n\nGot:\n${code}`); +})(); + +(async function testOnlyCustomResolve() { + const root = path.join('tests', 'testdata', 'css'); + const { code: buffer } = await css.bundleAsync({ + filename: path.join(root, 'foo.css'), + resolver: { + resolve(specifier) { + // Strip `root:` prefix off specifier and resolve it as an absolute path + // in the test data root. + return path.join(root, specifier.slice('root:'.length)); + }, + }, + }); + const code = buffer.toString('utf-8').trim(); + + const expected = ` +.baz { + color: #00f; +} + +.bar { + color: green; +} + +.foo { + color: red; +} + `.trim(); + if (code !== expected) throw new Error(`\`testOnlyCustomResolve()\` failed. Expected:\n${expected}\n\nGot:\n${code}`); +})(); + +(async function testReadThrow() { + let error = undefined; + try { + await css.bundleAsync({ + filename: 'foo.css', + resolver: { + read(file) { + throw new Error(`Oh noes! Failed to read \`${file}\`.`); + } + }, + }); + } catch (err) { + error = err; + } + + if (!error) throw new Error(`\`testReadThrow()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); + if (!error.message.includes(`\`read()\` threw error:`) || !error.message.includes(`Oh noes! Failed to read \`foo.css\`.`)) { + throw new Error(`\`testReadThrow()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`); + } +})(); + +(async function testResolveThrow() { + let error = undefined; + try { + await css.bundleAsync({ + filename: 'tests/testdata/css/foo.css', + resolver: { + resolve(specifier, originatingFile) { + throw new Error(`Oh noes! Failed to resolve \`${specifier}\` from \`${ + originatingFile}\`.`); + } + }, + }); + } catch (err) { + error = err; + } + + if (!error) throw new Error(`\`testResolveThrow()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); + if (!error.message.includes(`\`resolve()\` threw error:`) || !error.message.includes(`Oh noes! Failed to resolve \`root:hello/world.css\` from \`tests/testdata/css/foo.css\`.`)) { + throw new Error(`\`testResolveThrow()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`); + } +})(); + +(async function testReadReturnNonString() { + let error = undefined; + try { + await css.bundleAsync({ + filename: 'foo.css', + resolver: { + read() { + return 1234; // Returns a non-string value. + } + }, + }); + } catch (err) { + error = err; + } + + if (!error) throw new Error(`\`testReadReturnNonString()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); + if (!error.message.includes(`Expected \`read()\` to return a value of type \`String\`, but it returned a value of type \`Number\` instead.`)) { + throw new Error(`\`testReadReturnNonString()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`); + } +})(); + +(async function testResolveReturnNonString() { + let error = undefined; + try { + await css.bundleAsync({ + filename: 'tests/testdata/css/foo.css', + resolver: { + resolve() { + return 1234; // Returns a non-string value. + } + }, + }); + } catch (err) { + error = err; + } + + if (!error) throw new Error(`\`testResolveReturnNonString()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); + if (!error.message.includes(`Expected \`resolve()\` to return a value of type \`String\`, but it returned a value of type \`Number\` instead.`)) { + throw new Error(`\`testResolveReturnNonString()\` failed. Expected \`bundleAsync()\` to throw a specific error message, but it threw a different error:\n${error.message}`); + } +})(); + +(async function testThis() { + const inMemoryFs = new Map(Object.entries({ + 'foo.css': ` +@import './bar.css'; + +.foo { color: red; } + `.trim(), + + 'bar.css': ` +@import './hello/world.css'; + +.bar { color: green; } + `.trim(), + + 'hello/world.css': ` +.baz { color: blue; } + `.trim(), + })); + + let readThis = undefined; + let resolveThis = undefined; + const resolver = { + read: function (file) { + readThis = this; + const result = inMemoryFs.get(path.normalize(file)); + if (!result) throw new Error(`Could not find ${file} in ${ + Array.from(inMemoryFs.keys()).join(', ')}.`); + return result; + }, + resolve: function (specifier, originatingFile) { + resolveThis = this; + return path.join(originatingFile, '..', specifier); + }, + }; + await css.bundleAsync({ + filename: 'foo.css', + resolver, + }); + + if (readThis !== resolver) throw new Error(`\`testThis()\` failed. Expected \`read()\` to be called with the \`resolver\` as \`this\`, but instead it was called with:\n${readThis}`); + if (resolveThis !== resolver) throw new Error(`\`testThis()\` failed. Expected \`resolve()\` to be called with the \`resolver\` as \`this\`, but instead it was called with:\n${resolveThis}`); +})(); + +console.log('PASSED!'); diff --git a/tests/testdata/css/baz.css b/tests/testdata/css/baz.css new file mode 100644 index 00000000..ccb3274e --- /dev/null +++ b/tests/testdata/css/baz.css @@ -0,0 +1 @@ +.baz { color: blue; } diff --git a/tests/testdata/css/foo.css b/tests/testdata/css/foo.css new file mode 100644 index 00000000..95099612 --- /dev/null +++ b/tests/testdata/css/foo.css @@ -0,0 +1,3 @@ +@import 'root:hello/world.css'; + +.foo { color: red; } diff --git a/tests/testdata/css/hello/world.css b/tests/testdata/css/hello/world.css new file mode 100644 index 00000000..bc1763bb --- /dev/null +++ b/tests/testdata/css/hello/world.css @@ -0,0 +1,3 @@ +@import 'root:baz.css'; + +.bar { color: green; }