-
-
Notifications
You must be signed in to change notification settings - Fork 601
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: add getJSON option to output CSS modules mapping #1577
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -327,6 +327,17 @@ type modules = | |
| "dashes-only" | ||
| ((name: string) => string); | ||
exportOnlyLocals: boolean; | ||
getJSON: ({ | ||
resourcePath, | ||
imports, | ||
exports, | ||
replacements, | ||
}: { | ||
resourcePath: string; | ||
imports: object[]; | ||
exports: object[]; | ||
replacements: object[]; | ||
}) => any; | ||
}; | ||
``` | ||
|
||
|
@@ -604,6 +615,7 @@ module.exports = { | |
namedExport: true, | ||
exportLocalsConvention: "as-is", | ||
exportOnlyLocals: false, | ||
getJSON: ({ resourcePath, imports, exports, replacements }) => {}, | ||
}, | ||
}, | ||
}, | ||
|
@@ -1384,6 +1396,298 @@ module.exports = { | |
}; | ||
``` | ||
|
||
##### `getJSON` | ||
|
||
Type: | ||
|
||
```ts | ||
type getJSON = ({ | ||
resourcePath, | ||
imports, | ||
exports, | ||
replacements, | ||
}: { | ||
resourcePath: string; | ||
imports: object[]; | ||
exports: object[]; | ||
replacements: object[]; | ||
}) => any; | ||
``` | ||
|
||
Default: `undefined` | ||
|
||
Enables a callback to output the CSS modules mapping JSON. The callback is invoked with an object containing the following: | ||
|
||
- `resourcePath`: the absolute path of the original resource, e.g., `/foo/bar/baz.module.css` | ||
|
||
- `imports`: an array of import objects with data about import types and file paths, e.g., | ||
|
||
```json | ||
[ | ||
{ | ||
"type": "icss_import", | ||
"importName": "___CSS_LOADER_ICSS_IMPORT_0___", | ||
"url": "\"-!../../../../../node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[4].use[1]!../../../../../node_modules/postcss-loader/dist/cjs.js!../../../../../node_modules/sass-loader/dist/cjs.js!../../../../baz.module.css\"", | ||
"icss": true, | ||
"index": 0 | ||
} | ||
] | ||
``` | ||
|
||
(Note that this will include all imports, not just those relevant to CSS modules.) | ||
|
||
- `exports`: an array of export objects with exported names and values, e.g., | ||
|
||
```json | ||
[ | ||
{ | ||
"name": "main", | ||
"value": "D2Oy" | ||
} | ||
] | ||
``` | ||
|
||
- `replacements`: an array of import replacement objects used for linking `imports` and `exports`, e.g., | ||
|
||
```json | ||
{ | ||
"replacementName": "___CSS_LOADER_ICSS_IMPORT_0_REPLACEMENT_0___", | ||
"importName": "___CSS_LOADER_ICSS_IMPORT_0___", | ||
"localName": "main" | ||
} | ||
``` | ||
|
||
**webpack.config.js** | ||
|
||
```js | ||
// supports a synchronous callback | ||
module.exports = { | ||
module: { | ||
rules: [ | ||
{ | ||
test: /\.css$/i, | ||
loader: "css-loader", | ||
options: { | ||
modules: { | ||
getJSON: ({ resourcePath, exports }) => { | ||
// synchronously write a .json mapping file in the same directory as the resource | ||
const exportsJson = exports.reduce( | ||
(acc, { name, value }) => ({ ...acc, [name]: value }), | ||
{}, | ||
); | ||
|
||
const outputPath = path.resolve( | ||
path.dirname(resourcePath), | ||
`${path.basename(resourcePath)}.json`, | ||
); | ||
|
||
const fs = require("fs"); | ||
fs.writeFileSync(outputPath, JSON.stringify(json)); | ||
}, | ||
}, | ||
}, | ||
}, | ||
], | ||
}, | ||
}; | ||
|
||
// supports an asynchronous callback | ||
module.exports = { | ||
module: { | ||
rules: [ | ||
{ | ||
test: /\.css$/i, | ||
loader: "css-loader", | ||
options: { | ||
modules: { | ||
getJSON: async ({ resourcePath, exports }) => { | ||
const exportsJson = exports.reduce( | ||
(acc, { name, value }) => ({ ...acc, [name]: value }), | ||
{}, | ||
); | ||
|
||
const outputPath = path.resolve( | ||
path.dirname(resourcePath), | ||
`${path.basename(resourcePath)}.json`, | ||
); | ||
|
||
const fsp = require("fs/promises"); | ||
await fsp.writeFile(outputPath, JSON.stringify(json)); | ||
}, | ||
}, | ||
}, | ||
}, | ||
], | ||
}, | ||
}; | ||
``` | ||
|
||
Using `getJSON`, it's possible to output a files with all CSS module mappings. | ||
In the following example, we use `getJSON` to cache canonical mappings and | ||
add stand-ins for any composed values (through `composes`), and we use a custom plugin | ||
to consolidate the values and output them to a file: | ||
|
||
```js | ||
const CSS_LOADER_REPLACEMENT_REGEX = | ||
/(___CSS_LOADER_ICSS_IMPORT_\d+_REPLACEMENT_\d+___)/g; | ||
const REPLACEMENT_REGEX = /___REPLACEMENT\[(.*?)\]\[(.*?)\]___/g; | ||
const IDENTIFIER_REGEX = /\[(.*?)\]\[(.*?)\]/; | ||
const replacementsMap = {}; | ||
const canonicalValuesMap = {}; | ||
const allExportsJson = {}; | ||
|
||
function generateIdentifier(resourcePath, localName) { | ||
return `[${resourcePath}][${localName}]`; | ||
} | ||
|
||
function addReplacements(resourcePath, imports, exportsJson, replacements) { | ||
const importReplacementsMap = {}; | ||
|
||
// create a dict to quickly identify imports and get their absolute stand-in strings in the currently loaded file | ||
// e.g., { '___CSS_LOADER_ICSS_IMPORT_0_REPLACEMENT_0___': '___REPLACEMENT[/foo/bar/baz.css][main]___' } | ||
importReplacementsMap[resourcePath] = replacements.reduce( | ||
(acc, { replacementName, importName, localName }) => { | ||
const replacementImportUrl = imports.find( | ||
(importData) => importData.importName === importName, | ||
).url; | ||
const relativePathRe = /.*!(.*)"/; | ||
const [, relativePath] = replacementImportUrl.match(relativePathRe); | ||
const importPath = path.resolve(path.dirname(resourcePath), relativePath); | ||
const identifier = generateIdentifier(importPath, localName); | ||
return { ...acc, [replacementName]: `___REPLACEMENT${identifier}___` }; | ||
}, | ||
{}, | ||
); | ||
|
||
// iterate through the raw exports and add stand-in variables | ||
// ('___REPLACEMENT[<absolute_path>][<class_name>]___') | ||
// to be replaced in the plugin below | ||
for (const [localName, classNames] of Object.entries(exportsJson)) { | ||
const identifier = generateIdentifier(resourcePath, localName); | ||
|
||
if (CSS_LOADER_REPLACEMENT_REGEX.test(classNames)) { | ||
// if there are any replacements needed in the concatenated class names, | ||
// add them all to the replacements map to be replaced altogether later | ||
replacementsMap[identifier] = classNames.replaceAll( | ||
CSS_LOADER_REPLACEMENT_REGEX, | ||
(_, replacementName) => { | ||
return importReplacementsMap[resourcePath][replacementName]; | ||
}, | ||
); | ||
} else { | ||
// otherwise, no class names need replacements so we can add them to | ||
// canonical values map and all exports JSON verbatim | ||
canonicalValuesMap[identifier] = classNames; | ||
|
||
allExportsJson[resourcePath] = allExportsJson[resourcePath] || {}; | ||
allExportsJson[resourcePath][localName] = classNames; | ||
} | ||
} | ||
} | ||
|
||
function replaceReplacements(classNames) { | ||
const adjustedClassNames = classNames.replaceAll( | ||
REPLACEMENT_REGEX, | ||
(_, resourcePath, localName) => { | ||
const identifier = generateIdentifier(resourcePath, localName); | ||
if (identifier in canonicalValuesMap) { | ||
return canonicalValuesMap[identifier]; | ||
} | ||
|
||
// recurse through other stand-in that may be imports | ||
const canonicalValue = replaceReplacements(replacementsMap[identifier]); | ||
canonicalValuesMap[identifier] = canonicalValue; | ||
return canonicalValue; | ||
}, | ||
); | ||
|
||
return adjustedClassNames; | ||
} | ||
|
||
module.exports = { | ||
module: { | ||
rules: [ | ||
{ | ||
test: /\.css$/i, | ||
loader: "css-loader", | ||
options: { | ||
modules: { | ||
getJSON: ({ resourcePath, imports, exports, replacements }) => { | ||
const exportsJson = exports.reduce( | ||
(acc, { name, value }) => ({ ...acc, [name]: value }), | ||
{}, | ||
); | ||
|
||
if (replacements.length > 0) { | ||
// replacements present --> add stand-in values for absolute paths and local names, | ||
// which will be resolved to their canonical values in the plugin below | ||
addReplacements( | ||
resourcePath, | ||
imports, | ||
exportsJson, | ||
replacements, | ||
); | ||
} else { | ||
// no replacements present --> add to canonicalValuesMap verbatim | ||
// since all values here are canonical/don't need resolution | ||
for (const [key, value] of Object.entries(exportsJson)) { | ||
const id = `[${resourcePath}][${key}]`; | ||
|
||
canonicalValuesMap[id] = value; | ||
} | ||
|
||
allExportsJson[resourcePath] = exportsJson; | ||
} | ||
}, | ||
}, | ||
}, | ||
}, | ||
], | ||
}, | ||
plugins: [ | ||
{ | ||
apply(compiler) { | ||
compiler.hooks.done.tap("CssModulesJsonPlugin", () => { | ||
for (const [identifier, classNames] of Object.entries( | ||
replacementsMap, | ||
)) { | ||
const adjustedClassNames = replaceReplacements(classNames); | ||
replacementsMap[identifier] = adjustedClassNames; | ||
const [, resourcePath, localName] = | ||
identifier.match(IDENTIFIER_REGEX); | ||
allExportsJson[resourcePath] = allExportsJson[resourcePath] || {}; | ||
allExportsJson[resourcePath][localName] = adjustedClassNames; | ||
} | ||
|
||
fs.writeFileSync( | ||
"./output.css.json", | ||
JSON.stringify(allExportsJson, null, 2), | ||
"utf8", | ||
); | ||
}); | ||
}, | ||
}, | ||
], | ||
}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's keep only this, because other developers can use another solution and faced with the problem better avoid it for better DX There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And can you add a test case with your solution and put it in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @alexander-akait Sure, I can definitely add a test case! Regarding the README changes, which parts are you suggesting we keep versus remove? I'm happy to update whatever, but I'm not sure which part you're referring to. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mean keep only part where we replace There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, do you mean removing the other two examples that write to files on each loader call (here and here) and only keeping the third example in its entirety or omitting parts of the third example? The third example has a lot more logic involved than I would have liked, but I'm not sure it'd make sense without both the loader and plugin parts. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Let's keep it, composes is a popular things, so better developer will use it, maybe someone send a PR with some optimizations in future There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good to me! Thanks for your understanding 🙏 |
||
``` | ||
|
||
In the above, all import aliases are replaced with `___REPLACEMENT[<resourcePath>][<localName>]___` in `getJSON`, and they're resolved in the custom plugin. All CSS mappings are contained in `allExportsJson`: | ||
|
||
```json | ||
{ | ||
"/foo/bar/baz.module.css": { | ||
"main": "D2Oy", | ||
"header": "thNN" | ||
}, | ||
"/foot/bear/bath.module.css": { | ||
"logo": "sqiR", | ||
"info": "XMyI" | ||
} | ||
} | ||
``` | ||
|
||
This is saved to a local file named `output.css.json`. | ||
|
||
### `importLoaders` | ||
|
||
Type: | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -273,5 +273,16 @@ | |
isTemplateLiteralSupported, | ||
); | ||
|
||
const { getJSON } = options.modules; | ||
if (typeof getJSON === "function") { | ||
try { | ||
await getJSON({ resourcePath, imports, exports, replacements }); | ||
} catch (error) { | ||
callback(error); | ||
|
||
return; | ||
} | ||
} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please run it only when option is true |
||
|
||
callback(null, `${importCode}${moduleCode}${exportCode}`); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -173,6 +173,11 @@ | |
"description": "Export only locals.", | ||
"link": "https://github.com/webpack-contrib/css-loader#exportonlylocals", | ||
"type": "boolean" | ||
}, | ||
"getJSON": { | ||
"description": "Allows outputting of CSS modules mapping through a callback.", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Small update, just following conventions of existing language. |
||
"link": "https://github.com/webpack-contrib/css-loader#getJSON", | ||
"instanceof": "Function" | ||
} | ||
} | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's improve the example and show how to write it in the one file, because it is a prefered way instead a lot of fs read calls
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With structure like:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Note - additional example and keep this
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ooh, that definitely makes sense to me, but I'm not sure how to determine when we're "done" to write out the aggregated file. Like it's trivial to keep track of a Map/object in memory and add to it as
getJSON
is called, but it wouldn't know when to write out since each invocation of the loader function occurs in the context of an individual resource. Is there something like a lifecycle hook or a callback exposed to Webpack loaders like with plugins?(Apologies if this is a dumb question--this is the first time I've worked on a Webpack loader. Thanks for all your guidance in any case!)