Skip to content
This repository has been archived by the owner on May 15, 2024. It is now read-only.

Commit

Permalink
fix(load-styles): export css module classes as-is
Browse files Browse the repository at this point in the history
  • Loading branch information
zacowan committed Apr 22, 2024
1 parent a8f94c4 commit 0cdefc8
Show file tree
Hide file tree
Showing 2 changed files with 255 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -448,11 +448,11 @@ const css = \`._test-class_1o1cd_1 {
document.head.appendChild(el);
}
})();
export const testClass = '_test-class_1o1cd_1';
export const nestedClass = '_nested-class_1o1cd_5';
export default { testClass, nestedClass };
export default { 'test-class': '_test-class_1o1cd_1', 'nested-class': '_nested-class_1o1cd_5' };
export { css, digest };"
`);
`
);
});

it('should hash the css classes for .css files not in node_modules', async () => {
Expand Down Expand Up @@ -489,11 +489,11 @@ const css = \`
document.head.appendChild(el);
}
})();
export const testClass = '_test-class_ykkej_2';
export const nestedClass = '_nested-class_ykkej_5';
export default { testClass, nestedClass };
export default { 'test-class': '_test-class_ykkej_2', 'nested-class': '_nested-class_ykkej_5' };
export { css, digest };"
`);
`
);
});

it('should hash the css classes for .module.scss files in node_modules', async () => {
Expand Down Expand Up @@ -531,11 +531,11 @@ const css = \`._test-class_1o1cd_1 {
document.head.appendChild(el);
}
})();
export const testClass = '_test-class_1o1cd_1';
export const nestedClass = '_nested-class_1o1cd_5';
export default { testClass, nestedClass };
export default { 'test-class': '_test-class_1o1cd_1', 'nested-class': '_nested-class_1o1cd_5' };
export { css, digest };"
`);
`
);
});

it('should hash the css classes for .module.css files in node_modules', async () => {
Expand Down Expand Up @@ -572,11 +572,11 @@ const css = \`
document.head.appendChild(el);
}
})();
export const testClass = '_test-class_ykkej_2';
export const nestedClass = '_nested-class_ykkej_5';
export default { testClass, nestedClass };
export default { 'test-class': '_test-class_ykkej_2', 'nested-class': '_nested-class_ykkej_5' };
export { css, digest };"
`);
`
);
});

it('should not hash the css classes for .scss files in node_modules', async () => {
Expand Down Expand Up @@ -614,11 +614,11 @@ const css = \`.test-class {
document.head.appendChild(el);
}
})();
export const testClass = 'test-class';
export const nestedClass = 'nested-class';
export default { testClass, nestedClass };
export default { 'test-class': 'test-class', 'nested-class': 'nested-class' };
export { css, digest };"
`);
`
);
});

it('should not hash the css classes for .css files in node_modules', async () => {
Expand Down Expand Up @@ -655,9 +655,176 @@ const css = \`
document.head.appendChild(el);
}
})();
export const testClass = 'test-class';
export const nestedClass = 'nested-class';
export default { testClass, nestedClass };
export default { 'test-class': 'test-class', 'nested-class': 'nested-class' };
export { css, digest };"
`
);
});
});

describe('css module class name mapping', () => {
it('should map camelCase class names to camelCase export', async () => {
expect.assertions(1);
const mockFileContentCamelCase = `
.testClass {
background: white;
}`;

const mockFileName = 'index.module.css';
const plugin = stylesLoader({}, {
bundleType: BUNDLE_TYPES.BROWSER,
});
const onLoadHook = runSetupAndGetLifeHooks(plugin).onLoad[0].hookFunction;

const { contents } = await runOnLoadHook(
onLoadHook,
{ mockFileName, mockFileContent: mockFileContentCamelCase }
);

expect(contents).toMatchInlineSnapshot(`
"const digest = '226b4f2da43972a4fc06e45959f141575ff54d5112c560f4e9317565d5f7f7e3';
const css = \`
._testClass_nd9j1_2 {
background: white;
}\`;
(function() {
if ( global.BROWSER && !document.getElementById(digest)) {
var el = document.createElement('style');
el.id = digest;
el.textContent = css;
document.head.appendChild(el);
}
})();
export const testClass = '_testClass_nd9j1_2';
export default { testClass };
export { css, digest };"
`);
});

it('should map kebab-case class names to kebab-case export', async () => {
expect.assertions(1);
const mockFileContentKebabCase = `
.test-class {
background: white;
}`;

const mockFileName = 'index.module.css';
const plugin = stylesLoader({}, {
bundleType: BUNDLE_TYPES.BROWSER,
});
const onLoadHook = runSetupAndGetLifeHooks(plugin).onLoad[0].hookFunction;

const { contents } = await runOnLoadHook(
onLoadHook,
{ mockFileName, mockFileContent: mockFileContentKebabCase }
);

expect(contents).toMatchInlineSnapshot(`
"const digest = '4e6b6b5fb2aba1e71d4f619563aa5dd3196dc39177fec2591ba5985b7fce1c2a';
const css = \`
._test-class_jogu8_2 {
background: white;
}\`;
(function() {
if ( global.BROWSER && !document.getElementById(digest)) {
var el = document.createElement('style');
el.id = digest;
el.textContent = css;
document.head.appendChild(el);
}
})();
export default { 'test-class': '_test-class_jogu8_2' };
export { css, digest };"
`);
});

it('should map PascalCase class names to PascalCase export', async () => {
expect.assertions(1);
const mockFileContentPascalCase = `
.TestClass {
background: white;
}`;

const mockFileName = 'index.module.css';
const plugin = stylesLoader({}, {
bundleType: BUNDLE_TYPES.BROWSER,
});
const onLoadHook = runSetupAndGetLifeHooks(plugin).onLoad[0].hookFunction;

const { contents } = await runOnLoadHook(
onLoadHook,
{ mockFileName, mockFileContent: mockFileContentPascalCase }
);

expect(contents).toMatchInlineSnapshot(`
"const digest = '86d0ab75f61f32582cec3b0195d0ecfbde103f660afbc7426663ada518f1f0a5';
const css = \`
._TestClass_ndabk_2 {
background: white;
}\`;
(function() {
if ( global.BROWSER && !document.getElementById(digest)) {
var el = document.createElement('style');
el.id = digest;
el.textContent = css;
document.head.appendChild(el);
}
})();
export const TestClass = '_TestClass_ndabk_2';
export default { TestClass };
export { css, digest };"
`);
});

it('should map a combination of class names to the correct export', async () => {
expect.assertions(1);
const mockFileContent = `
.testClass {
background: white;
}
.test-class {
font-color: black;
}
.TestClass {
font-size: 16px;
}`;

const mockFileName = 'index.module.css';
const plugin = stylesLoader({}, {
bundleType: BUNDLE_TYPES.BROWSER,
});
const onLoadHook = runSetupAndGetLifeHooks(plugin).onLoad[0].hookFunction;

const { contents } = await runOnLoadHook(
onLoadHook,
{ mockFileName, mockFileContent }
);

expect(contents).toMatchInlineSnapshot(`
"const digest = 'ed48420423f1f7e20e2928760486f1e4601840fc4eede99b965397aa5f739bb8';
const css = \`
._testClass_1mj1y_2 {
background: white;
}
._test-class_1mj1y_5 {
font-color: black;
}
._TestClass_1mj1y_8 {
font-size: 16px;
}\`;
(function() {
if ( global.BROWSER && !document.getElementById(digest)) {
var el = document.createElement('style');
el.id = digest;
el.textContent = css;
document.head.appendChild(el);
}
})();
export const testClass = '_testClass_1mj1y_2';
export const TestClass = '_TestClass_1mj1y_8';
export default { testClass, TestClass, 'test-class': '_test-class_1mj1y_5' };
export { css, digest };"
`);
});
Expand Down
96 changes: 65 additions & 31 deletions packages/one-app-dev-bundler/esbuild/utils/load-styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ import getModulesBundlerConfig from './get-modules-bundler-config.js';
import { BUNDLE_TYPES } from '../constants/enums.js';
import { addStyle } from './server-style-aggregator.js';

// eslint-disable-next-line unicorn/better-regex -- kept for readability
const VALID_JS_VARIABLE_REGEX = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;

const getGenerateScopedNameOption = (path) => {
if (!path.includes('node_modules') || path.endsWith('.module.css') || path.endsWith('.module.scss')) {
// use the default option (scoped) for non-node_module files or css modules within node_modules
Expand All @@ -33,6 +36,64 @@ const getGenerateScopedNameOption = (path) => {
return '[local]';
};

/**
* Assumes that the class names should be exported 'as-is'. If characters are included
* in the class name that cannot be used in a variable name, they will not be added
* to the list of named exports.
*/
const generateCssModuleExports = (cssModulesJSON) => {
const entries = Object.entries(cssModulesJSON);
const unsupportedCharacterEntries = entries.filter(
([exportName]) => VALID_JS_VARIABLE_REGEX.test(exportName) === false
);
const otherEntries = entries.filter(
([exportName]) => !unsupportedCharacterEntries.reduce(
(acc, [unsupportedExportName]) => [...acc, unsupportedExportName],
[]
)
.includes(exportName)
);

const namedExportsString = otherEntries.map(([exportName, className]) => `export const ${exportName} = '${className}';`).join('\n');
const defaultExportString = `export default { \
${otherEntries.map(([exportName]) => exportName).join(', ')}\
${otherEntries.length > 0 && unsupportedCharacterEntries.length > 0 ? ', ' : ''}\
${unsupportedCharacterEntries.map(([exportName, className]) => `'${exportName}': '${className}'`).join(', ')} \
};`;
return `${namedExportsString}\n${defaultExportString}`;
};

const generateJsContent = ({
css, cssModulesJSON, digest, bundleType, path,
}) => {
let injectedCode = '';
if (bundleType === BUNDLE_TYPES.BROWSER) {
// For browsers generate code to inject this style into the head at runtime
injectedCode = `\
(function() {
if ( global.BROWSER && !document.getElementById(digest)) {
var el = document.createElement('style');
el.id = digest;
el.textContent = css;
document.head.appendChild(el);
}
})();`;
} else {
// For SSR, aggregate all styles, then inject them once at the end
const isDependencyFile = path.indexOf('/node_modules/') >= 0;
addStyle(digest, css, isDependencyFile);
}

// provide useful values to the importer of this file, most importantly, the classnames
const jsContent = `\
const digest = '${digest}';
const css = \`${css}\`;
${injectedCode}
${generateCssModuleExports(cssModulesJSON)}
export { css, digest };`;
return jsContent;
};

// This function can generically take css or scss content,
// and 'load it', turning it into js. Meaning it can be called
// from either esbuild or webpack based bundlers.
Expand All @@ -42,7 +103,7 @@ const loadStyles = async ({
bundleType,
}) => {
const {
localsConvention = 'camelCaseOnly',
localsConvention = null, // null for `localsConvention` defaults to mapping class names 'as-is'
generateScopedName = getGenerateScopedNameOption(path),
} = cssModulesOptions;

Expand Down Expand Up @@ -88,36 +149,9 @@ const loadStyles = async ({
const digest = hash.copy()
.digest('hex');

let injectedCode = '';
if (bundleType === BUNDLE_TYPES.BROWSER) {
// For browsers generate code to inject this style into the head at runtime
injectedCode = `\
(function() {
if ( global.BROWSER && !document.getElementById(digest)) {
var el = document.createElement('style');
el.id = digest;
el.textContent = css;
document.head.appendChild(el);
}
})();`;
} else {
// For SSR, aggregate all styles, then inject them once at the end
const isDependencyFile = path.indexOf('/node_modules/') >= 0;
addStyle(digest, result.css, isDependencyFile);
}

// provide useful values to the importer of this file, most importantly, the classnames
const jsContent = `\
const digest = '${digest}';
const css = \`${result.css}\`;
${injectedCode}
${Object.entries(cssModulesJSON)
.map(([exportName, className]) => `export const ${exportName} = '${className}';`)
.join('\n')}
export default { ${Object.keys(cssModulesJSON)
.join(', ')} };
export { css, digest };`;
return jsContent;
return generateJsContent({
css: result.css, cssModulesJSON, digest, bundleType, path,
});
};

export default loadStyles;

0 comments on commit 0cdefc8

Please sign in to comment.