diff --git a/README.md b/README.md index 57c86b33..a9042465 100644 --- a/README.md +++ b/README.md @@ -327,6 +327,9 @@ type modules = | "dashesOnly" | ((name: string) => string); exportOnlyLocals: boolean; + getJSON: + | string + | ((resourcePath: string, json: object, outputPath: string) => any); }; ``` @@ -592,6 +595,7 @@ module.exports = { namedExport: true, exportLocalsConvention: "camelCase", exportOnlyLocals: false, + getJSON: false, }, }, }, @@ -1372,6 +1376,75 @@ module.exports = { }; ``` +##### `getJSON` + +Type: + +```ts +type getJSON = + | boolean + | ((resourcePath: string, json: object, outputPath: string) => any); +``` + +Default: `undefined` + +Enables the outputting of the CSS modules mapping JSON. This can be omitted or set to a falsy value to disable any output. + +###### `boolean` + +Possible values: + +- `true` - writes a JSON file next located in the same directory as the loaded resource file. For example, given a resource file located at /foo/bar/baz.css, this would write the CSS modules mapping JSON to /foo/bar/baz.css.json +- `false` - disables CSS modules mapping JSON output + +**webpack.config.js** + +```js +module.exports = { + module: { + rules: [ + { + test: /\.css$/i, + loader: "css-loader", + options: { + modules: { + getJSON: true, + }, + }, + }, + ], + }, +}; +``` + +###### `function` + +Enables custom handling of the CSS modules mapping JSON output. The return value of the function is not used for anything internally and is only intended to customize output. + +**webpack.config.js** + +```js +module.exports = { + module: { + rules: [ + { + test: /\.css$/i, + loader: "css-loader", + options: { + modules: { + getJSON: (resourcePath, json, outputPath) => { + // `resourcePath` is the original resource file path, e.g., /foo/bar/baz.css + // `json` is the CSS modules map + // `outputPath` is the expected output file path, e.g., /foo/bar/baz.css.json + }, + }, + }, + }, + ], + }, +}; +``` + ### `importLoaders` Type: diff --git a/src/index.js b/src/index.js index f31a93c3..db85acf2 100644 --- a/src/index.js +++ b/src/index.js @@ -27,6 +27,7 @@ import { stringifyRequest, warningFactory, syntaxErrorFactory, + writeModulesMap, } from "./utils"; export default async function loader(content, map, meta) { @@ -274,5 +275,16 @@ export default async function loader(content, map, meta) { isTemplateLiteralSupported ); + try { + const { getJSON } = options.modules; + if (getJSON) { + await writeModulesMap(getJSON, resourcePath, exports); + } + } catch (error) { + callback(error); + + return; + } + callback(null, `${importCode}${moduleCode}${exportCode}`); } diff --git a/src/options.json b/src/options.json index bb61fb12..0d605989 100644 --- a/src/options.json +++ b/src/options.json @@ -169,6 +169,18 @@ "description": "Export only locals.", "link": "https://github.com/webpack-contrib/css-loader#exportonlylocals", "type": "boolean" + }, + "getJSON": { + "description": "Output CSS modules mapping to a JSON file or through a callback.", + "link": "https://github.com/webpack-contrib/css-loader#getJSON", + "anyOf": [ + { + "type": "boolean" + }, + { + "instanceof": "Function" + } + ] } } } diff --git a/src/utils.js b/src/utils.js index 9d68a598..b7fa8c5f 100644 --- a/src/utils.js +++ b/src/utils.js @@ -4,6 +4,7 @@ */ import { fileURLToPath } from "url"; import path from "path"; +import fsp from "fs/promises"; import modulesValues from "postcss-modules-values"; import localByDefault from "postcss-modules-local-by-default"; @@ -1412,6 +1413,26 @@ function syntaxErrorFactory(error) { return obj; } +async function writeModulesMap(getJSON, resourcePath, exports) { + const json = exports.reduce((acc, { name, value }) => { + return { ...acc, [name]: value }; + }, {}); + + const outputPath = path.resolve( + path.dirname(resourcePath), + `${path.basename(resourcePath)}.json` + ); + + if (getJSON === true) { + // If true, output a JSON CSS modules mapping file in the same directory as the resource + await fsp.writeFile(outputPath, JSON.stringify(json)); + } else if (typeof getJSON === "function") { + // If function, call function with call getJSON with similar args as postcss-modules#getJSON + // https://github.com/madyankin/postcss-modules/tree/master?tab=readme-ov-file#saving-exported-classes + getJSON(resourcePath, json, outputPath); + } +} + export { normalizeOptions, shouldUseModulesPlugins, @@ -1439,4 +1460,5 @@ export { defaultGetLocalIdent, warningFactory, syntaxErrorFactory, + writeModulesMap, }; diff --git a/test/__snapshots__/modules-option.test.js.snap b/test/__snapshots__/modules-option.test.js.snap index eba3389d..640bdbcc 100644 --- a/test/__snapshots__/modules-option.test.js.snap +++ b/test/__snapshots__/modules-option.test.js.snap @@ -1122,6 +1122,75 @@ exports[`"modules" option should emit warning when localIdentName is emoji: erro exports[`"modules" option should emit warning when localIdentName is emoji: warnings 1`] = `Array []`; +exports[`"modules" option should invoke the custom getJSON function with getJSON as a function: errors 1`] = `Array []`; + +exports[`"modules" option should invoke the custom getJSON function with getJSON as a function: mapping 1`] = ` +Object { + "a": "RT7ktT7mB7tfBR25sJDZ", + "b": "IZmhTnK9CIeu6ww6Zjbv", + "c": "PV11nPFlF7mzEgCXkQw4", +} +`; + +exports[`"modules" option should invoke the custom getJSON function with getJSON as a function: module 1`] = ` +"// Imports +import ___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___ from \\"../../../../src/runtime/noSourceMaps.js\\"; +import ___CSS_LOADER_API_IMPORT___ from \\"../../../../src/runtime/api.js\\"; +var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___); +// Module +___CSS_LOADER_EXPORT___.push([module.id, \`.RT7ktT7mB7tfBR25sJDZ { + background-color: aliceblue; +} + +.IZmhTnK9CIeu6ww6Zjbv { + background-color: burlywood; +} + +.PV11nPFlF7mzEgCXkQw4 { + background-color: chartreuse; +} + +.d { + background-color: darkgoldenrod +} +\`, \\"\\"]); +// Exports +___CSS_LOADER_EXPORT___.locals = { + \\"a\\": \`RT7ktT7mB7tfBR25sJDZ\`, + \\"b\\": \`IZmhTnK9CIeu6ww6Zjbv\`, + \\"c\\": \`PV11nPFlF7mzEgCXkQw4\` +}; +export default ___CSS_LOADER_EXPORT___; +" +`; + +exports[`"modules" option should invoke the custom getJSON function with getJSON as a function: result 1`] = ` +Array [ + Array [ + "./modules/getJSON/source.css", + ".RT7ktT7mB7tfBR25sJDZ { + background-color: aliceblue; +} + +.IZmhTnK9CIeu6ww6Zjbv { + background-color: burlywood; +} + +.PV11nPFlF7mzEgCXkQw4 { + background-color: chartreuse; +} + +.d { + background-color: darkgoldenrod +} +", + "", + ], +] +`; + +exports[`"modules" option should invoke the custom getJSON function with getJSON as a function: warnings 1`] = `Array []`; + exports[`"modules" option should keep order: errors 1`] = `Array []`; exports[`"modules" option should keep order: module 1`] = ` @@ -1194,6 +1263,69 @@ Array [ exports[`"modules" option should keep order: warnings 1`] = `Array []`; +exports[`"modules" option should output a co-located CSS modules map file with getJSON as true: errors 1`] = `Array []`; + +exports[`"modules" option should output a co-located CSS modules map file with getJSON as true: mapping 1`] = `"{\\"a\\":\\"RT7ktT7mB7tfBR25sJDZ\\",\\"b\\":\\"IZmhTnK9CIeu6ww6Zjbv\\",\\"c\\":\\"PV11nPFlF7mzEgCXkQw4\\"}"`; + +exports[`"modules" option should output a co-located CSS modules map file with getJSON as true: module 1`] = ` +"// Imports +import ___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___ from \\"../../../../src/runtime/noSourceMaps.js\\"; +import ___CSS_LOADER_API_IMPORT___ from \\"../../../../src/runtime/api.js\\"; +var ___CSS_LOADER_EXPORT___ = ___CSS_LOADER_API_IMPORT___(___CSS_LOADER_API_NO_SOURCEMAP_IMPORT___); +// Module +___CSS_LOADER_EXPORT___.push([module.id, \`.RT7ktT7mB7tfBR25sJDZ { + background-color: aliceblue; +} + +.IZmhTnK9CIeu6ww6Zjbv { + background-color: burlywood; +} + +.PV11nPFlF7mzEgCXkQw4 { + background-color: chartreuse; +} + +.d { + background-color: darkgoldenrod +} +\`, \\"\\"]); +// Exports +___CSS_LOADER_EXPORT___.locals = { + \\"a\\": \`RT7ktT7mB7tfBR25sJDZ\`, + \\"b\\": \`IZmhTnK9CIeu6ww6Zjbv\`, + \\"c\\": \`PV11nPFlF7mzEgCXkQw4\` +}; +export default ___CSS_LOADER_EXPORT___; +" +`; + +exports[`"modules" option should output a co-located CSS modules map file with getJSON as true: result 1`] = ` +Array [ + Array [ + "./modules/getJSON/source.css", + ".RT7ktT7mB7tfBR25sJDZ { + background-color: aliceblue; +} + +.IZmhTnK9CIeu6ww6Zjbv { + background-color: burlywood; +} + +.PV11nPFlF7mzEgCXkQw4 { + background-color: chartreuse; +} + +.d { + background-color: darkgoldenrod +} +", + "", + ], +] +`; + +exports[`"modules" option should output a co-located CSS modules map file with getJSON as true: warnings 1`] = `Array []`; + exports[`"modules" option should resolve absolute path in composes: errors 1`] = `Array []`; exports[`"modules" option should resolve absolute path in composes: module 1`] = ` diff --git a/test/fixtures/modules/getJSON/source.css b/test/fixtures/modules/getJSON/source.css new file mode 100644 index 00000000..4b124dbf --- /dev/null +++ b/test/fixtures/modules/getJSON/source.css @@ -0,0 +1,15 @@ +.a { + background-color: aliceblue; +} + +.b { + background-color: burlywood; +} + +.c { + background-color: chartreuse; +} + +:global(.d) { + background-color: darkgoldenrod +} diff --git a/test/fixtures/modules/getJSON/source.js b/test/fixtures/modules/getJSON/source.js new file mode 100644 index 00000000..1996779e --- /dev/null +++ b/test/fixtures/modules/getJSON/source.js @@ -0,0 +1,5 @@ +import css from './source.css'; + +__export__ = css; + +export default css; diff --git a/test/modules-option.test.js b/test/modules-option.test.js index ed4b9329..4b164ca2 100644 --- a/test/modules-option.test.js +++ b/test/modules-option.test.js @@ -13,7 +13,8 @@ import { readAsset, } from "./helpers/index"; -const testCasesPath = path.join(__dirname, "fixtures/modules/tests-cases"); +const modulesFixturesPath = path.join(__dirname, "fixtures/modules"); +const testCasesPath = path.join(modulesFixturesPath, "tests-cases"); const testCases = fs.readdirSync(testCasesPath); jest.setTimeout(60000); @@ -2417,4 +2418,65 @@ describe('"modules" option', () => { expect(getWarnings(stats)).toMatchSnapshot("warnings"); expect(getErrors(stats)).toMatchSnapshot("errors"); }); + + it("should output a co-located CSS modules map file with getJSON as true", async () => { + const outputPath = path.resolve( + modulesFixturesPath, + "getJSON", + "source.css.json" + ); + if (fs.existsSync(outputPath)) { + fs.unlinkSync(outputPath); + } + + const compiler = getCompiler("./modules/getJSON/source.js", { + modules: { + getJSON: true, + }, + }); + + const stats = await compile(compiler); + + const json = fs.readFileSync(outputPath).toString(); + fs.unlinkSync(outputPath); + expect(json).toMatchSnapshot("mapping"); + + expect( + getModuleSource("./modules/getJSON/source.css", stats) + ).toMatchSnapshot("module"); + expect(getExecutedCode("main.bundle.js", compiler, stats)).toMatchSnapshot( + "result" + ); + expect(getWarnings(stats)).toMatchSnapshot("warnings"); + expect(getErrors(stats)).toMatchSnapshot("errors"); + }); + + it("should invoke the custom getJSON function with getJSON as a function", async () => { + const getJSONSpy = jest.fn(); + const compiler = getCompiler("./modules/getJSON/source.js", { + modules: { + // need to wrap Jest spy since it doesn't pass ajv validation on its own + getJSON: (...args) => getJSONSpy(...args), + }, + }); + const stats = await compile(compiler); + + const [[resourcePath, mapping, outputPath]] = getJSONSpy.mock.calls; + expect(resourcePath).toEqual( + path.resolve(modulesFixturesPath, "getJSON", "source.css") + ); + expect(mapping).toMatchSnapshot("mapping"); + expect(outputPath).toEqual( + path.resolve(modulesFixturesPath, "getJSON", "source.css.json") + ); + + expect( + getModuleSource("./modules/getJSON/source.css", stats) + ).toMatchSnapshot("module"); + expect(getExecutedCode("main.bundle.js", compiler, stats)).toMatchSnapshot( + "result" + ); + expect(getWarnings(stats)).toMatchSnapshot("warnings"); + expect(getErrors(stats)).toMatchSnapshot("errors"); + }); });