Skip to content

Commit 0241982

Browse files
fix: remove redundant prefixes for uplifted selectors (#26)
1 parent 374734b commit 0241982

File tree

9 files changed

+197
-10
lines changed

9 files changed

+197
-10
lines changed

src/transforms/globalCssToCssModule/__tests__/__snapshots__/globalCssToCssModule.test.ts.snap

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ exports[`globalCssToCssModule transforms correctly 1`] = `
116116
.kek-pek {
117117
color: red;
118118
}
119+
.logo {
120+
display: flex;
121+
}
119122
"
120123
`;
121124
@@ -136,7 +139,7 @@ export const Kek = () => {
136139
[classNames('d-flex mr-1', styles.kek)]: isActive,
137140
})}
138141
/>
139-
It's a component<p className={styles.kekWow}>wow</p>
142+
It's a component<p className={styles.logo}>wow</p>
140143
<div className={classNames('m-2 d-flex m-1', styles.repoHeader)}>Another one!</div>
141144
</div>
142145
)

src/transforms/globalCssToCssModule/__tests__/fixtures/Kek.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@
2828
border-width: 1px 0;
2929
}
3030

31+
&__logo {
32+
display: flex;
33+
}
34+
3135
// &__action-list-item {
3236
&__action-list-item {
3337
// Have a small gap between buttons so they are visually distinct when pressed

src/transforms/globalCssToCssModule/__tests__/fixtures/Kek.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const Kek = () => {
1111
'd-flex mr-1 kek': isActive,
1212
})}
1313
></div>
14-
It's a component<p className="kek--wow">wow</p>
14+
It's a component<p className="repo-header__logo">wow</p>
1515
<div className="m-2 repo-header d-flex m-1">Another one!</div>
1616
</div>
1717
)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { removePrefixFromExportNameIfNeeded, getPrefixesToRemove } from '../exportNameMapPrefixes'
2+
3+
describe('removePrefixFromExportNameIfNeeded', () => {
4+
it('removes matching prefix', () => {
5+
const exportName = removePrefixFromExportNameIfNeeded({
6+
className: 'menu__button',
7+
exportName: 'menuButton',
8+
prefixesToRemove: [{ prefix: 'menu__', exportName: 'menu' }],
9+
})
10+
11+
expect(exportName).toBe('button')
12+
})
13+
14+
it('skips exportName without matching prefix', () => {
15+
expect(
16+
removePrefixFromExportNameIfNeeded({
17+
className: 'menu__button',
18+
exportName: 'menuButton',
19+
prefixesToRemove: [{ prefix: 'repo__', exportName: 'repo' }],
20+
})
21+
).toBe('menuButton')
22+
23+
expect(
24+
removePrefixFromExportNameIfNeeded({
25+
className: 'menu',
26+
exportName: 'menu',
27+
prefixesToRemove: [{ prefix: 'menu__', exportName: 'menu' }],
28+
})
29+
).toBe('menu')
30+
})
31+
})
32+
33+
describe('getPrefixesToRemove', () => {
34+
it('finds all prefixes to remove', () => {
35+
const prefixesToRemove = getPrefixesToRemove({
36+
'repo-header': 'repoHeader',
37+
'repo-header__button': 'repoHeaderButton',
38+
'repo-header--alert': 'repoHeaderAlert',
39+
'navbar-nav': 'navbarNav',
40+
spacer: 'spacer',
41+
})
42+
43+
expect(prefixesToRemove).toEqual([{ prefix: 'repo-header__', exportName: 'repoHeader' }])
44+
})
45+
})

src/transforms/globalCssToCssModule/postcss/__tests__/getCssModuleExportNameMap.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('getCssModuleExportNameMap', () => {
3030

3131
expect(exportNameMap).toEqual({
3232
'repo-header': 'repoHeader',
33-
'repo-header__button': 'repoHeaderButton',
33+
'repo-header__button': 'button',
3434
'repo-header--alert': 'repoHeaderAlert',
3535
'navbar-nav': 'navbarNav',
3636
spacer: 'spacer',
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { decapitalize, isDefined } from '../../../utils'
2+
3+
interface RemovedPrefix {
4+
prefix: string
5+
exportName: string
6+
}
7+
8+
interface RemovePrefixFromExportNameIfNeededOptions {
9+
className: string
10+
exportName: string
11+
prefixesToRemove: RemovedPrefix[]
12+
}
13+
14+
/**
15+
* If `className` starts with `prefix__` and `exportName` starts with `prefix` -> remove `prefix` from the export name.
16+
*
17+
* ```ts
18+
* const exportName = removePrefixFromExportNameIfNeeded({
19+
* className: 'menu__button',
20+
* exportName: 'menuButton',
21+
* prefixesToRemove: [{ prefix: 'menu__', exportName: 'menu' }]
22+
* })
23+
*
24+
* exportName === 'button'
25+
* ```
26+
*/
27+
export function removePrefixFromExportNameIfNeeded(options: RemovePrefixFromExportNameIfNeededOptions): string {
28+
const { className, exportName, prefixesToRemove } = options
29+
30+
const removedPrefix = prefixesToRemove.find(
31+
removedPrefix => exportName.startsWith(removedPrefix.exportName) && className.startsWith(removedPrefix.prefix)
32+
)
33+
34+
if (removedPrefix) {
35+
return decapitalize(exportName.replace(removedPrefix.exportName, ''))
36+
}
37+
38+
return exportName
39+
}
40+
41+
/**
42+
* Upon conversion to the CSS module, we lift `&__` nested selectors to the root level:
43+
*
44+
* ```scss
45+
* .menu {
46+
* &__button { ... }
47+
* }
48+
*
49+
* .menu {}
50+
* .button {}
51+
* ```
52+
*
53+
* Here `menu__` is a removed prefix because className changed:
54+
* .menu__button -> .button
55+
*
56+
*/
57+
export function getPrefixesToRemove(exportNameMap: Record<string, string>): RemovedPrefix[] {
58+
const prefixesToRemove = Object.keys(exportNameMap)
59+
.map(key => {
60+
const matches = key.match(/(.+)__/)
61+
62+
if (matches) {
63+
return {
64+
prefix: matches[0],
65+
exportName: exportNameMap[matches[1]],
66+
}
67+
}
68+
69+
return undefined
70+
})
71+
.filter(isDefined)
72+
73+
return [...new Set(prefixesToRemove)]
74+
}

src/transforms/globalCssToCssModule/postcss/getCssModuleExportNameMap.ts

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import CssModuleLoaderCore, { Source } from 'css-modules-loader-core'
33
import postcssNested from 'postcss-nested'
44

55
import { createCssProcessor } from './createCssProcessor'
6+
import { getPrefixesToRemove, removePrefixFromExportNameIfNeeded } from './exportNameMapPrefixes'
67

78
const EXPORT_NAME_PREFIX = 'prefix'
89

@@ -14,14 +15,71 @@ const sourceCssToClassNames = (source: Source): Promise<Core.Result> =>
1415

1516
const removeCssNestingProcessor = createCssProcessor(postcssNested)
1617

17-
// Get a mapping between export names that will be used in the TS file and CSS classes.
18+
/**
19+
* Get a mapping between export names that will be used in the TS file and CSS classes.
20+
*/
1821
export async function getCssModuleExportNameMap(sourceCss: string): Promise<Record<string, string>> {
1922
const flattenedResult = await removeCssNestingProcessor(sourceCss)
2023
const classNames = await sourceCssToClassNames(flattenedResult.css)
2124

2225
const exportNameClassNamePairs: [string, string][] = Object.entries(classNames.exportTokens).map(
23-
([property, className]) => [className.replace(`_${EXPORT_NAME_PREFIX}__`, ''), camelcase(property)]
26+
([exportName, className]) => {
27+
const classNameWithoutExportPrefix = className.replace(`_${EXPORT_NAME_PREFIX}__`, '')
28+
29+
return [classNameWithoutExportPrefix, camelcase(exportName)]
30+
}
31+
)
32+
33+
/**
34+
* Initial export name map without removed nesting of the selectors:
35+
*
36+
* ```scss
37+
* .menu {
38+
* &__button {}
39+
* }
40+
* ```
41+
*
42+
* Export name map:
43+
*
44+
* ```ts
45+
* {
46+
* menu: 'menu',
47+
* menu__button: 'menuButton'
48+
* }
49+
* ```
50+
*/
51+
const initialExportNameMap = Object.fromEntries<string>(exportNameClassNamePairs)
52+
const prefixesToRemove = getPrefixesToRemove(initialExportNameMap)
53+
54+
const exportNameMapPairs: [string, string][] = Object.entries(initialExportNameMap).map(
55+
([className, exportName]) => {
56+
const exportNameWithoutPrefix = removePrefixFromExportNameIfNeeded({
57+
className,
58+
exportName,
59+
prefixesToRemove,
60+
})
61+
62+
return [className, exportNameWithoutPrefix]
63+
}
2464
)
2565

26-
return Object.fromEntries<string>(exportNameClassNamePairs)
66+
/**
67+
* Export name map _with_ removed nesting of the selectors:
68+
*
69+
* ```scss
70+
* .menu {
71+
* &__button {}
72+
* }
73+
* ```
74+
*
75+
* Export name map:
76+
*
77+
* ```ts
78+
* {
79+
* menu: 'menu',
80+
* menu__button: 'button'
81+
* }
82+
* ```
83+
*/
84+
return Object.fromEntries<string>(exportNameMapPairs)
2785
}

src/transforms/globalCssToCssModule/ts/getClassNameNodeReplacement.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,26 +42,27 @@ export function getClassNameNodeReplacement(
4242
options: GetClassNameNodeReplacementOptions
4343
): PropertyAccessExpression | JsxExpression | ComputedPropertyName | CallExpression {
4444
const { parentNode, exportNameReferences } = options
45+
const parentKind = parentNode.getKind()
4546

4647
if (exportNameReferences.length === 0) {
4748
throw new Error('`exportNameReferences` should not be empty!')
4849
}
4950

5051
const replacementWithoutBraces = getClassNameNodeReplacementWithoutBraces(options)
5152

52-
if (parentNode.getKind() === SyntaxKind.JsxAttribute) {
53+
if (parentKind === SyntaxKind.JsxAttribute) {
5354
return ts.factory.createJsxExpression(undefined, replacementWithoutBraces)
5455
}
5556

56-
if (parentNode.getKind() === SyntaxKind.PropertyAssignment) {
57+
if (parentKind === SyntaxKind.PropertyAssignment) {
5758
return ts.factory.createComputedPropertyName(replacementWithoutBraces)
5859
}
5960

60-
if (parentNode.getKind() === SyntaxKind.ConditionalExpression) {
61+
if (parentKind === SyntaxKind.ConditionalExpression || parentKind === SyntaxKind.CallExpression) {
6162
// Replace one class string inside of `ConditionalExpression` with the `exportName`.
6263
// className={classNames('d-flex', isActive ? 'kek' : 'pek')} -> className={classNames('d-flex', isActive ? styles.kek : 'pek')}
6364
return replacementWithoutBraces
6465
}
6566

66-
throw new Error(`Unsupported 'parentNode' type: ${parentNode.compilerNode.kind}`)
67+
throw new Error(`Unsupported 'parentNode' type: ${parentNode.getKindName()}`)
6768
}

src/utils/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,7 @@ export function isDefined<T>(argument: T | undefined): argument is T {
22
return argument !== undefined
33
}
44

5+
export const decapitalize = ([first, ...rest]: string): string => first.toLowerCase() + rest.join('')
6+
57
export * from './formatWithPrettierEslint'
68
export * from './formatWithStylelint'

0 commit comments

Comments
 (0)