Skip to content

Commit af834b4

Browse files
authored
feat: added the getJSON option to output CSS modules mapping (#1577)
1 parent fd18587 commit af834b4

12 files changed

+549
-20
lines changed

README.md

+304
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,17 @@ type modules =
327327
| "dashes-only"
328328
| ((name: string) => string);
329329
exportOnlyLocals: boolean;
330+
getJSON: ({
331+
resourcePath,
332+
imports,
333+
exports,
334+
replacements,
335+
}: {
336+
resourcePath: string;
337+
imports: object[];
338+
exports: object[];
339+
replacements: object[];
340+
}) => any;
330341
};
331342
```
332343

@@ -604,6 +615,7 @@ module.exports = {
604615
namedExport: true,
605616
exportLocalsConvention: "as-is",
606617
exportOnlyLocals: false,
618+
getJSON: ({ resourcePath, imports, exports, replacements }) => {},
607619
},
608620
},
609621
},
@@ -1384,6 +1396,298 @@ module.exports = {
13841396
};
13851397
```
13861398

1399+
##### `getJSON`
1400+
1401+
Type:
1402+
1403+
```ts
1404+
type getJSON = ({
1405+
resourcePath,
1406+
imports,
1407+
exports,
1408+
replacements,
1409+
}: {
1410+
resourcePath: string;
1411+
imports: object[];
1412+
exports: object[];
1413+
replacements: object[];
1414+
}) => any;
1415+
```
1416+
1417+
Default: `undefined`
1418+
1419+
Enables a callback to output the CSS modules mapping JSON. The callback is invoked with an object containing the following:
1420+
1421+
- `resourcePath`: the absolute path of the original resource, e.g., `/foo/bar/baz.module.css`
1422+
1423+
- `imports`: an array of import objects with data about import types and file paths, e.g.,
1424+
1425+
```json
1426+
[
1427+
{
1428+
"type": "icss_import",
1429+
"importName": "___CSS_LOADER_ICSS_IMPORT_0___",
1430+
"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\"",
1431+
"icss": true,
1432+
"index": 0
1433+
}
1434+
]
1435+
```
1436+
1437+
(Note that this will include all imports, not just those relevant to CSS modules.)
1438+
1439+
- `exports`: an array of export objects with exported names and values, e.g.,
1440+
1441+
```json
1442+
[
1443+
{
1444+
"name": "main",
1445+
"value": "D2Oy"
1446+
}
1447+
]
1448+
```
1449+
1450+
- `replacements`: an array of import replacement objects used for linking `imports` and `exports`, e.g.,
1451+
1452+
```json
1453+
{
1454+
"replacementName": "___CSS_LOADER_ICSS_IMPORT_0_REPLACEMENT_0___",
1455+
"importName": "___CSS_LOADER_ICSS_IMPORT_0___",
1456+
"localName": "main"
1457+
}
1458+
```
1459+
1460+
**webpack.config.js**
1461+
1462+
```js
1463+
// supports a synchronous callback
1464+
module.exports = {
1465+
module: {
1466+
rules: [
1467+
{
1468+
test: /\.css$/i,
1469+
loader: "css-loader",
1470+
options: {
1471+
modules: {
1472+
getJSON: ({ resourcePath, exports }) => {
1473+
// synchronously write a .json mapping file in the same directory as the resource
1474+
const exportsJson = exports.reduce(
1475+
(acc, { name, value }) => ({ ...acc, [name]: value }),
1476+
{},
1477+
);
1478+
1479+
const outputPath = path.resolve(
1480+
path.dirname(resourcePath),
1481+
`${path.basename(resourcePath)}.json`,
1482+
);
1483+
1484+
const fs = require("fs");
1485+
fs.writeFileSync(outputPath, JSON.stringify(json));
1486+
},
1487+
},
1488+
},
1489+
},
1490+
],
1491+
},
1492+
};
1493+
1494+
// supports an asynchronous callback
1495+
module.exports = {
1496+
module: {
1497+
rules: [
1498+
{
1499+
test: /\.css$/i,
1500+
loader: "css-loader",
1501+
options: {
1502+
modules: {
1503+
getJSON: async ({ resourcePath, exports }) => {
1504+
const exportsJson = exports.reduce(
1505+
(acc, { name, value }) => ({ ...acc, [name]: value }),
1506+
{},
1507+
);
1508+
1509+
const outputPath = path.resolve(
1510+
path.dirname(resourcePath),
1511+
`${path.basename(resourcePath)}.json`,
1512+
);
1513+
1514+
const fsp = require("fs/promises");
1515+
await fsp.writeFile(outputPath, JSON.stringify(json));
1516+
},
1517+
},
1518+
},
1519+
},
1520+
],
1521+
},
1522+
};
1523+
```
1524+
1525+
Using `getJSON`, it's possible to output a files with all CSS module mappings.
1526+
In the following example, we use `getJSON` to cache canonical mappings and
1527+
add stand-ins for any composed values (through `composes`), and we use a custom plugin
1528+
to consolidate the values and output them to a file:
1529+
1530+
```js
1531+
const CSS_LOADER_REPLACEMENT_REGEX =
1532+
/(___CSS_LOADER_ICSS_IMPORT_\d+_REPLACEMENT_\d+___)/g;
1533+
const REPLACEMENT_REGEX = /___REPLACEMENT\[(.*?)\]\[(.*?)\]___/g;
1534+
const IDENTIFIER_REGEX = /\[(.*?)\]\[(.*?)\]/;
1535+
const replacementsMap = {};
1536+
const canonicalValuesMap = {};
1537+
const allExportsJson = {};
1538+
1539+
function generateIdentifier(resourcePath, localName) {
1540+
return `[${resourcePath}][${localName}]`;
1541+
}
1542+
1543+
function addReplacements(resourcePath, imports, exportsJson, replacements) {
1544+
const importReplacementsMap = {};
1545+
1546+
// create a dict to quickly identify imports and get their absolute stand-in strings in the currently loaded file
1547+
// e.g., { '___CSS_LOADER_ICSS_IMPORT_0_REPLACEMENT_0___': '___REPLACEMENT[/foo/bar/baz.css][main]___' }
1548+
importReplacementsMap[resourcePath] = replacements.reduce(
1549+
(acc, { replacementName, importName, localName }) => {
1550+
const replacementImportUrl = imports.find(
1551+
(importData) => importData.importName === importName,
1552+
).url;
1553+
const relativePathRe = /.*!(.*)"/;
1554+
const [, relativePath] = replacementImportUrl.match(relativePathRe);
1555+
const importPath = path.resolve(path.dirname(resourcePath), relativePath);
1556+
const identifier = generateIdentifier(importPath, localName);
1557+
return { ...acc, [replacementName]: `___REPLACEMENT${identifier}___` };
1558+
},
1559+
{},
1560+
);
1561+
1562+
// iterate through the raw exports and add stand-in variables
1563+
// ('___REPLACEMENT[<absolute_path>][<class_name>]___')
1564+
// to be replaced in the plugin below
1565+
for (const [localName, classNames] of Object.entries(exportsJson)) {
1566+
const identifier = generateIdentifier(resourcePath, localName);
1567+
1568+
if (CSS_LOADER_REPLACEMENT_REGEX.test(classNames)) {
1569+
// if there are any replacements needed in the concatenated class names,
1570+
// add them all to the replacements map to be replaced altogether later
1571+
replacementsMap[identifier] = classNames.replaceAll(
1572+
CSS_LOADER_REPLACEMENT_REGEX,
1573+
(_, replacementName) => {
1574+
return importReplacementsMap[resourcePath][replacementName];
1575+
},
1576+
);
1577+
} else {
1578+
// otherwise, no class names need replacements so we can add them to
1579+
// canonical values map and all exports JSON verbatim
1580+
canonicalValuesMap[identifier] = classNames;
1581+
1582+
allExportsJson[resourcePath] = allExportsJson[resourcePath] || {};
1583+
allExportsJson[resourcePath][localName] = classNames;
1584+
}
1585+
}
1586+
}
1587+
1588+
function replaceReplacements(classNames) {
1589+
const adjustedClassNames = classNames.replaceAll(
1590+
REPLACEMENT_REGEX,
1591+
(_, resourcePath, localName) => {
1592+
const identifier = generateIdentifier(resourcePath, localName);
1593+
if (identifier in canonicalValuesMap) {
1594+
return canonicalValuesMap[identifier];
1595+
}
1596+
1597+
// recurse through other stand-in that may be imports
1598+
const canonicalValue = replaceReplacements(replacementsMap[identifier]);
1599+
canonicalValuesMap[identifier] = canonicalValue;
1600+
return canonicalValue;
1601+
},
1602+
);
1603+
1604+
return adjustedClassNames;
1605+
}
1606+
1607+
module.exports = {
1608+
module: {
1609+
rules: [
1610+
{
1611+
test: /\.css$/i,
1612+
loader: "css-loader",
1613+
options: {
1614+
modules: {
1615+
getJSON: ({ resourcePath, imports, exports, replacements }) => {
1616+
const exportsJson = exports.reduce(
1617+
(acc, { name, value }) => ({ ...acc, [name]: value }),
1618+
{},
1619+
);
1620+
1621+
if (replacements.length > 0) {
1622+
// replacements present --> add stand-in values for absolute paths and local names,
1623+
// which will be resolved to their canonical values in the plugin below
1624+
addReplacements(
1625+
resourcePath,
1626+
imports,
1627+
exportsJson,
1628+
replacements,
1629+
);
1630+
} else {
1631+
// no replacements present --> add to canonicalValuesMap verbatim
1632+
// since all values here are canonical/don't need resolution
1633+
for (const [key, value] of Object.entries(exportsJson)) {
1634+
const id = `[${resourcePath}][${key}]`;
1635+
1636+
canonicalValuesMap[id] = value;
1637+
}
1638+
1639+
allExportsJson[resourcePath] = exportsJson;
1640+
}
1641+
},
1642+
},
1643+
},
1644+
},
1645+
],
1646+
},
1647+
plugins: [
1648+
{
1649+
apply(compiler) {
1650+
compiler.hooks.done.tap("CssModulesJsonPlugin", () => {
1651+
for (const [identifier, classNames] of Object.entries(
1652+
replacementsMap,
1653+
)) {
1654+
const adjustedClassNames = replaceReplacements(classNames);
1655+
replacementsMap[identifier] = adjustedClassNames;
1656+
const [, resourcePath, localName] =
1657+
identifier.match(IDENTIFIER_REGEX);
1658+
allExportsJson[resourcePath] = allExportsJson[resourcePath] || {};
1659+
allExportsJson[resourcePath][localName] = adjustedClassNames;
1660+
}
1661+
1662+
fs.writeFileSync(
1663+
"./output.css.json",
1664+
JSON.stringify(allExportsJson, null, 2),
1665+
"utf8",
1666+
);
1667+
});
1668+
},
1669+
},
1670+
],
1671+
};
1672+
```
1673+
1674+
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`:
1675+
1676+
```json
1677+
{
1678+
"/foo/bar/baz.module.css": {
1679+
"main": "D2Oy",
1680+
"header": "thNN"
1681+
},
1682+
"/foot/bear/bath.module.css": {
1683+
"logo": "sqiR",
1684+
"info": "XMyI"
1685+
}
1686+
}
1687+
```
1688+
1689+
This is saved to a local file named `output.css.json`.
1690+
13871691
### `importLoaders`
13881692

13891693
Type:

src/index.js

+11
Original file line numberDiff line numberDiff line change
@@ -273,5 +273,16 @@ export default async function loader(content, map, meta) {
273273
isTemplateLiteralSupported,
274274
);
275275

276+
const { getJSON } = options.modules;
277+
if (typeof getJSON === "function") {
278+
try {
279+
await getJSON({ resourcePath, imports, exports, replacements });
280+
} catch (error) {
281+
callback(error);
282+
283+
return;
284+
}
285+
}
286+
276287
callback(null, `${importCode}${moduleCode}${exportCode}`);
277288
}

src/options.json

+5
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,11 @@
173173
"description": "Export only locals.",
174174
"link": "https://github.com/webpack-contrib/css-loader#exportonlylocals",
175175
"type": "boolean"
176+
},
177+
"getJSON": {
178+
"description": "Allows outputting of CSS modules mapping through a callback.",
179+
"link": "https://github.com/webpack-contrib/css-loader#getJSON",
180+
"instanceof": "Function"
176181
}
177182
}
178183
}

0 commit comments

Comments
 (0)