Skip to content

Commit 5348913

Browse files
authored
feat(cli): introduce export-maps codemod (#7757)
1 parent 03dfd2a commit 5348913

File tree

8 files changed

+295
-8
lines changed

8 files changed

+295
-8
lines changed

.storybook/components/Footer.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js';
22
import PopoverPlacement from '@ui5/webcomponents/dist/types/PopoverPlacement.js';
33
import WrappingType from '@ui5/webcomponents/dist/types/WrappingType.js';
4+
import type { ButtonPropTypes, PopoverDomRef } from '@ui5/webcomponents-react';
45
import {
56
Button,
67
FlexBox,
@@ -12,22 +13,25 @@ import {
1213
Popover,
1314
Text,
1415
} from '@ui5/webcomponents-react';
16+
import type { CommonProps } from '@ui5/webcomponents-react-base';
17+
import { clsx } from 'clsx';
1518
import { useRef, useState } from 'react';
1619
import { createPortal } from 'react-dom';
1720
import BestRunLogo from '../../assets/SAP_Best_R_grad_blk_scrn.png';
1821
import classes from './Footer.module.css';
1922

20-
export const Footer = ({ style }) => {
21-
const popoverRef = useRef(null);
23+
export const Footer = (props: CommonProps) => {
24+
const { className } = props;
25+
const popoverRef = useRef<PopoverDomRef>(null);
2226
const footerRef = useRef(null);
2327
const [privacyPopoverOpen, setPPOpen] = useState(false);
24-
const showPrivacyPopover = (e) => {
28+
const showPrivacyPopover: ButtonPropTypes['onClick'] = (e) => {
2529
popoverRef.current.opener = e.target;
2630
setPPOpen((prev) => !prev);
2731
};
2832

2933
return createPortal(
30-
<footer className={classes.footer} style={style}>
34+
<footer {...props} className={clsx(classes.footer, className)}>
3135
<div ref={footerRef} className={classes.content}>
3236
<FlexBox
3337
justifyContent={FlexBoxJustifyContent.SpaceBetween}

.storybook/components/ProjectTemplate.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Text,
1313
ThemeProvider,
1414
} from '@ui5/webcomponents-react';
15+
// eslint-disable-next-line import/order
1516
import { addCustomCSSWithScoping } from '@ui5/webcomponents-react-base/internal/utils';
1617
import { clsx } from 'clsx';
1718
import type { ReactNode } from 'react';

docs/knowledge-base/FAQ.mdx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,4 +117,45 @@ import { Button } from '@ui5/webcomponents-react/Button';
117117
// ...
118118
```
119119

120+
## Why Use Direct Imports via Package Export Maps
121+
122+
Since [v2.14.0](https://github.com/UI5/webcomponents-react/releases/tag/v2.14.0), `ui5-webcomponents-react` supports **export maps** through the `exports` field in `package.json` ([documentation](https://nodejs.org/api/packages.html#exports)).
123+
124+
Export maps allow you to import components directly from their package entry points instead of relying on deep file paths or the package root (barrel) files.
125+
While most bundlers perform tree-shaking in production builds, so only the code you actually use is included, tree-shaking is often limited or disabled in development. If you import from the package root (a “barrel file”), all components and utilities exported from that root are included in the bundle, even those you don’t use.
126+
127+
```ts
128+
// Root import (less optimal without tree-shaking)
129+
import { Button, AnalyticalTable } from "@ui5/webcomponents-react";
130+
131+
// Direct import (recommended)
132+
import { Button } from "@ui5/webcomponents-react/Button";
133+
import { AnalyticalTable } from "@ui5/webcomponents-react/AnalyticalTable";
134+
```
135+
136+
### Codemod
137+
138+
To migrate from root imports to direct imports, you can use the `exports-map` codemod from the [cli](https://github.com/UI5/webcomponents-react/blob/main/packages/cli/README.md) package.
139+
140+
<MessageStrip
141+
hideCloseButton
142+
design="Critical"
143+
children={
144+
<>
145+
The codemod is a best-effort attempt to help you migrate to direct imports. Please review the generated code thoroughly!
146+
<br />
147+
<strong>
148+
Applying the codemod might break your code formatting, so please don't forget to run prettier and/or eslint
149+
after you've applied the codemod!
150+
</strong>
151+
</>
152+
}
153+
/>
154+
155+
```shell
156+
npx @ui5/webcomponents-react-cli codemod --transform export-maps \
157+
--src ./path/to/src \
158+
--typescript # only if you use TypeScript in your project, omit if you use JavaScript
159+
```
160+
120161
<Footer />

packages/base/src/internal/types/Ui5DomRef.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ type ChangeInfo = {
1212

1313
type InvalidationInfo = ChangeInfo & { target: Ui5DomRef };
1414

15+
/**
16+
* ⚠️ __INTERNAL__ use only! This interface is not part of the public API.
17+
*/
1518
export interface Ui5DomRef extends Omit<HTMLElement, 'focus'> {
1619
/**
1720
* Called every time before the component renders.

packages/base/src/internal/utils/addCustomCSSWithScoping.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { attachBoot } from '@ui5/webcomponents-base/dist/Boot.js';
22
import { addCustomCSS } from '@ui5/webcomponents-base/dist/Theming.js';
33
import { getUi5TagWithSuffix } from './index.js';
44

5+
/**
6+
* ⚠️ __INTERNAL__ use only! This function is not part of the public API.
7+
*/
58
export const addCustomCSSWithScoping = (baseTagName: string, customCSS: string) => {
69
attachBoot(() => {
710
const finalTag = getUi5TagWithSuffix(baseTagName);

packages/cli/README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @ui5/webcomponents-react-cli
22

3-
Wrapper generation and code-mod for ui5-webcomponents-react.
3+
Wrapper generation and code-mod for ui5/webcomponents-react.
44

55
## Usage
66

@@ -15,8 +15,19 @@ npm install @ui5/webcomponents-react-cli
1515
You can find an interactive documentation in our [Storybook](https://ui5.github.io/webcomponents-react/).
1616

1717
- [Wrapper generation](https://ui5.github.io/webcomponents-react/v2/?path=/docs/knowledge-base-bring-your-own-web-components--docs)
18-
- [Code-mod](https://ui5.github.io/webcomponents-react/v2/?path=/docs/migration-guide--docs#codemod)
19-
- ~~[Patch compatibility table](https://ui5.github.io/webcomponents-react/v2/?path=/docs/legacy-components-docs--docs#experimental-patch-script)~~ (deprecated in favor of [compatibility package scoping](https://ui5.github.io/webcomponents-react/v2/?path=/docs/legacy-components-docs--docs#using-the-compat-v1-table-together-with-the-v2-table-in-one-application))
18+
- Code-mods:
19+
- [v2](https://ui5.github.io/webcomponents-react/v2/?path=/docs/migration-guide--docs#codemod): Migrate your codebase from v1 to v2
20+
```shell
21+
npx @ui5/webcomponents-react-cli codemod --transform v2 \
22+
--src ./path/to/src \
23+
--typescript # only if you use TypeScript in your project, omit if you use JavaScript
24+
```
25+
- [export-maps](https://ui5.github.io/webcomponents-react/v2/?path=/docs/knowledge-base-faq--docs#why-use-direct-imports-via-package-export-maps): Migrate your codebase from root imports to direct imports using exports maps
26+
```shell
27+
npx @ui5/webcomponents-react-cli codemod --transform export-maps \
28+
--src ./path/to/src \
29+
--typescript # only if you use TypeScript in your project, omit if you use JavaScript
30+
```
2031

2132
## Contribute
2233

packages/cli/src/scripts/codemod/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import childProcess from 'node:child_process';
22
import path from 'node:path';
33
import { fileURLToPath } from 'node:url';
44

5-
const SUPPORTED_TRANSFORMERS = ['v2'];
5+
const SUPPORTED_TRANSFORMERS = ['v2', 'export-maps'];
66

77
const __filename = fileURLToPath(import.meta.url);
88
const __dirname = path.dirname(__filename);
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import type { API, FileInfo, JSCodeshift, Collection } from 'jscodeshift';
4+
5+
const mainPackageName = '@ui5/webcomponents-react';
6+
const basePackageName = '@ui5/webcomponents-react-base';
7+
const chartsPackageName = '@ui5/webcomponents-react-charts';
8+
const aiPackageName = '@ui5/webcomponents-ai-react';
9+
const compatPackageName = '@ui5/webcomponents-react-compat';
10+
const packageNames = [mainPackageName, basePackageName, chartsPackageName, aiPackageName, compatPackageName];
11+
12+
function getFileNames(dir: string) {
13+
let fileNames: string[] = [];
14+
try {
15+
fileNames = fs
16+
.readdirSync(dir)
17+
.filter(
18+
(file) =>
19+
(file.endsWith('.js') || file.endsWith('.ts')) && !file.endsWith('.d.ts') && !file.startsWith('index'),
20+
)
21+
.map((file) => path.basename(file, path.extname(file)));
22+
} catch (e) {
23+
console.warn(`⚠️ Could not read directory at ${dir}.`, e);
24+
}
25+
26+
return fileNames;
27+
}
28+
29+
function getExportNames(indexPath: string) {
30+
let exportNames: string[] = [];
31+
try {
32+
const indexSource = fs.readFileSync(indexPath, 'utf-8');
33+
const exportRegex = /export\s+(?:const|function|class|type|interface|{[^}]+})\s+([a-zA-Z0-9_]+)/g;
34+
let match;
35+
while ((match = exportRegex.exec(indexSource)) !== null) {
36+
exportNames.push(match[1]);
37+
}
38+
exportNames = Array.from(new Set(exportNames)); // Remove duplicates
39+
} catch (e) {
40+
console.warn(`⚠️ Could not read index at ${indexPath}.`, e);
41+
}
42+
43+
return exportNames;
44+
}
45+
46+
// Enums for main package
47+
const libraryPath = require.resolve('@ui5/webcomponents-react/package.json');
48+
const enumsDir = path.join(path.dirname(libraryPath), 'dist', 'enums');
49+
let enumNames: Set<string> = new Set();
50+
try {
51+
enumNames = new Set(
52+
fs
53+
.readdirSync(enumsDir)
54+
.filter(
55+
(file) =>
56+
(file.endsWith('.js') || file.endsWith('.ts')) && !file.endsWith('.d.ts') && !file.startsWith('index'),
57+
)
58+
.map((file) => path.basename(file, path.extname(file))),
59+
);
60+
} catch (e) {
61+
console.warn(`⚠️ Could not read enums directory at ${enumsDir}. Skipping enum detection.`, e);
62+
}
63+
64+
const hooksDir = path.join(
65+
path.dirname(require.resolve('@ui5/webcomponents-react-base/package.json')),
66+
'dist',
67+
'hooks',
68+
);
69+
const hookNames = getFileNames(hooksDir);
70+
71+
const internalHooksDir = path.join(
72+
path.dirname(require.resolve('@ui5/webcomponents-react-base/package.json')),
73+
'dist',
74+
'internal',
75+
'hooks',
76+
);
77+
const internalHookNames = getFileNames(internalHooksDir);
78+
79+
const internalUtilsDir = path.join(
80+
path.dirname(require.resolve('@ui5/webcomponents-react-base/package.json')),
81+
'dist',
82+
'internal',
83+
'utils',
84+
);
85+
const internalUtilNames: string[] = getFileNames(internalUtilsDir);
86+
const utilsIndexPath = path.join(internalUtilsDir, 'index.js');
87+
internalUtilNames.push(...getExportNames(utilsIndexPath));
88+
89+
const internalTypeDir = path.join(
90+
path.dirname(require.resolve('@ui5/webcomponents-react-base/package.json')),
91+
'dist',
92+
'internal',
93+
'types',
94+
);
95+
const internalTypeNames: string[] = getFileNames(internalTypeDir);
96+
const internalTypesIndexPath = path.join(internalTypeDir, 'index.js');
97+
internalTypeNames.push(...getExportNames(internalTypesIndexPath));
98+
99+
// Mapping functions
100+
function resolveBaseExport(importedName: string): string | undefined {
101+
const directMap: Record<string, string> = {
102+
VersionInfo: `${basePackageName}/VersionInfo`,
103+
I18nStore: `${basePackageName}/internal/stores/I18nStore`,
104+
StyleStore: `${basePackageName}/internal/stores/StyleStore`,
105+
CssSizeVariables: `${basePackageName}/internal/styling/CssSizeVariables`,
106+
ThemingParameters: `${basePackageName}/ThemingParameters`,
107+
withWebComponent: `${basePackageName}/internal/wrapper/withWebComponent`,
108+
utils: `${basePackageName}/internal/utils`,
109+
addCustomCSSWithScoping: `${basePackageName}/internal/utils`,
110+
UI5WCSlotsNode: `${basePackageName}/types`,
111+
};
112+
113+
if (directMap[importedName]) {
114+
return directMap[importedName];
115+
}
116+
if (hookNames.includes(importedName)) {
117+
return `${basePackageName}/hooks`;
118+
}
119+
if (internalHookNames.includes(importedName)) {
120+
return `${basePackageName}/internal/hooks`;
121+
}
122+
if (internalUtilNames.includes(importedName)) {
123+
return `${basePackageName}/internal/utils`;
124+
}
125+
if (internalTypeNames.includes(importedName)) {
126+
return `${basePackageName}/internal/types`;
127+
}
128+
return undefined;
129+
}
130+
131+
function resolveChartsExport(importedName: string): string | undefined {
132+
const directMap: Record<string, string> = {
133+
TimelineChartAnnotation: `${chartsPackageName}/TimelineChartAnnotation`,
134+
BarChartPlaceholder: `${chartsPackageName}/BarChartPlaceholder`,
135+
BulletChartPlaceholder: `${chartsPackageName}/BulletChartPlaceholder`,
136+
ColumnChartPlaceholder: `${chartsPackageName}/ColumnChartPlaceholder`,
137+
ColumnChartWithTrendPlaceholder: `${chartsPackageName}/ColumnChartWithTrendPlaceholder`,
138+
ComposedChartPlaceholder: `${chartsPackageName}/ComposedChartPlaceholder`,
139+
LineChartPlaceholder: `${chartsPackageName}/LineChartPlaceholder`,
140+
PieChartPlaceholder: `${chartsPackageName}/PieChartPlaceholder`,
141+
ScatterChartPlaceholder: `${chartsPackageName}/ScatterChartPlaceholder`,
142+
TimelineChartPlaceholder: `${chartsPackageName}/TimelineChartPlaceholder`,
143+
};
144+
if (directMap[importedName]) {
145+
return directMap[importedName];
146+
}
147+
return undefined;
148+
}
149+
150+
export default function transform(file: FileInfo, api: API): string | undefined {
151+
const j: JSCodeshift = api.jscodeshift;
152+
const root: Collection = j(file.source);
153+
154+
if (file.path.includes('node_modules')) {
155+
return undefined;
156+
}
157+
158+
let isDirty = false;
159+
160+
packageNames.forEach((pkg) => {
161+
root.find(j.ImportDeclaration, { source: { value: pkg } }).forEach((importPath) => {
162+
const specifiers = importPath.node.specifiers || [];
163+
specifiers.forEach((spec) => {
164+
if (spec.type !== 'ImportSpecifier') return;
165+
const importedName = spec.imported.name as string;
166+
let componentName = importedName;
167+
if (importedName.endsWith('PropTypes')) {
168+
componentName = importedName.replace(/PropTypes$/, '');
169+
} else if (importedName.endsWith('Props')) {
170+
componentName = componentName.replace(/Props$/, '');
171+
} else if (importedName.endsWith('DomRef')) {
172+
componentName = componentName.replace(/DomRef$/, '');
173+
}
174+
175+
let newSource: string;
176+
if (pkg === mainPackageName) {
177+
newSource =
178+
componentName !== importedName
179+
? `${mainPackageName}/${componentName}`
180+
: enumNames.has(importedName)
181+
? `${mainPackageName}/enums/${importedName}`
182+
: `${mainPackageName}/${importedName}`;
183+
} else if (pkg === basePackageName && importedName !== 'Device' && importedName !== 'hooks') {
184+
newSource = resolveBaseExport(importedName) || basePackageName;
185+
} else if (pkg === chartsPackageName) {
186+
newSource = resolveChartsExport(componentName) || `${chartsPackageName}/${componentName}`;
187+
} else {
188+
newSource = pkg;
189+
}
190+
191+
const newImport = j.importDeclaration(
192+
[
193+
j.importSpecifier(
194+
j.identifier(importedName),
195+
j.identifier(spec.local && typeof spec.local.name === 'string' ? spec.local.name : importedName),
196+
),
197+
],
198+
j.literal(newSource),
199+
);
200+
201+
// Delta: Namespace imports
202+
if (pkg === basePackageName && ['Device', 'hooks'].includes(importedName)) {
203+
const newImport = j.importDeclaration(
204+
[j.importNamespaceSpecifier(j.identifier((spec.local?.name as string) || importedName))],
205+
j.literal(`${basePackageName}/${importedName}`),
206+
);
207+
j(importPath).insertBefore(newImport);
208+
isDirty = true;
209+
return;
210+
}
211+
212+
if (('importKind' in spec && spec.importKind === 'type') || importPath.node.importKind === 'type') {
213+
newImport.importKind = 'type';
214+
}
215+
216+
j(importPath).insertBefore(newImport);
217+
isDirty = true;
218+
});
219+
j(importPath).remove();
220+
});
221+
});
222+
223+
return isDirty ? root.toSource() : undefined;
224+
}

0 commit comments

Comments
 (0)