Skip to content
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

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
304 changes: 304 additions & 0 deletions README.md
Expand Up @@ -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;
};
```

Expand Down Expand Up @@ -604,6 +615,7 @@ module.exports = {
namedExport: true,
exportLocalsConvention: "as-is",
exportOnlyLocals: false,
getJSON: ({ resourcePath, imports, exports, replacements }) => {},
},
},
},
Expand Down Expand Up @@ -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));
Copy link
Member

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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With structure like:

{
  "resource-path.css": {
    "foo": "abcdef0987654321"
  }
}

Copy link
Member

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

Copy link
Contributor Author

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!)

},
},
},
},
],
},
};

// 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",
);
});
},
},
],
};
Copy link
Member

Choose a reason for hiding this comment

The 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

Copy link
Member

Choose a reason for hiding this comment

The 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 helpers directory

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean keep only part where we replace ___REPLACEMENT

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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:
Expand Down
11 changes: 11 additions & 0 deletions src/index.js
Expand Up @@ -273,5 +273,16 @@
isTemplateLiteralSupported,
);

const { getJSON } = options.modules;
if (typeof getJSON === "function") {
try {
await getJSON({ resourcePath, imports, exports, replacements });
} catch (error) {
callback(error);

Check warning on line 281 in src/index.js

View check run for this annotation

Codecov / codecov/patch

src/index.js#L281

Added line #L281 was not covered by tests

return;

Check warning on line 283 in src/index.js

View check run for this annotation

Codecov / codecov/patch

src/index.js#L283

Added line #L283 was not covered by tests
}
}
Copy link
Member

Choose a reason for hiding this comment

The 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}`);
}
5 changes: 5 additions & 0 deletions src/options.json
Expand Up @@ -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.",
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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"
}
}
}
Expand Down