rtl-migrate-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/bin/rtlmigrate.js +2 -0
- package/package.json +58 -0
- package/src/cli.js +73 -0
- package/src/fileProcessor.js +30 -0
- package/src/index.js +5 -0
- package/src/rules/heuristics.js +17 -0
- package/src/rules/propertyMap.js +37 -0
- package/src/transformers/cssTransform.js +78 -0
- package/src/transformers/jsTransform.js +233 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 jatinsh1011
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# rtl-migrate-cli
|
|
2
|
+
|
|
3
|
+
CLI + codemod that converts physical LTR CSS / CSS-in-JS properties to
|
|
4
|
+
[CSS logical properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_logical_properties_and_values),
|
|
5
|
+
so styles work correctly in both LTR and RTL layouts without direction-based branching.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
marginLeft -> marginInlineStart
|
|
9
|
+
marginRight -> marginInlineEnd
|
|
10
|
+
paddingLeft -> paddingInlineStart
|
|
11
|
+
paddingRight -> paddingInlineEnd
|
|
12
|
+
borderLeft -> borderInlineStart
|
|
13
|
+
borderRight -> borderInlineEnd
|
|
14
|
+
borderTopLeftRadius -> borderStartStartRadius
|
|
15
|
+
borderTopRightRadius -> borderStartEndRadius
|
|
16
|
+
borderBottomLeftRadius -> borderEndStartRadius
|
|
17
|
+
borderBottomRightRadius-> borderEndEndRadius
|
|
18
|
+
left / right (position)-> insetInlineStart / insetInlineEnd
|
|
19
|
+
textAlign: 'left'|'right' (in a direction conditional) -> 'start' | 'end'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
It understands two kinds of files:
|
|
23
|
+
|
|
24
|
+
- **JS/TS style objects** (`makeStyles`, `sx`, inline `style={{ ... }}`, plain style objects) —
|
|
25
|
+
parsed and rewritten with Babel + [recast](https://github.com/benjamn/recast), so untouched code
|
|
26
|
+
keeps its original formatting.
|
|
27
|
+
- **CSS / SCSS files** — rewritten with PostCSS.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install -g rtl-migrate-cli
|
|
33
|
+
# or run without installing:
|
|
34
|
+
npx rtl-migrate-cli "src/**/*.styles.js"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The CLI command is `rtlmigrate`.
|
|
38
|
+
|
|
39
|
+
## Usage
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Dry run (default): prints a diff, does not touch files
|
|
43
|
+
rtlmigrate "src/**/*.styles.js" "src/**/*.css"
|
|
44
|
+
|
|
45
|
+
# Apply changes
|
|
46
|
+
rtlmigrate "src/**/*.styles.js" --write
|
|
47
|
+
|
|
48
|
+
# Keep existing [dir="rtl"] override blocks instead of removing them
|
|
49
|
+
rtlmigrate "src/**/*.css" --write --keep-rtl-overrides
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Options
|
|
53
|
+
|
|
54
|
+
| Flag | Description |
|
|
55
|
+
|--------------------------|----------------------------------------------------------------------|
|
|
56
|
+
| `-w, --write` | Write changes to disk. Without it, `rtlmigrate` only prints a diff. |
|
|
57
|
+
| `--keep-rtl-overrides` | Don't remove `[dir="rtl"]` override blocks/rules. |
|
|
58
|
+
| `-V, --version` | Print the installed version. |
|
|
59
|
+
|
|
60
|
+
## Conversion rules
|
|
61
|
+
|
|
62
|
+
- **Ternary with a direction condition** (`direction === 'ltr' ? a : b`) — the LTR branch is kept,
|
|
63
|
+
the condition is dropped.
|
|
64
|
+
- **Shorthand ternary** (`margin`/`padding` with a 4-value string) — split into
|
|
65
|
+
`*InlineStart`/`*InlineEnd` (plus `*Top`/`*Bottom` if non-zero).
|
|
66
|
+
- **Static values** — the property is renamed only, value untouched.
|
|
67
|
+
- **`left`/`right` positioning** is only converted when it looks directional. It is skipped for:
|
|
68
|
+
- centering (`left: '50%'`)
|
|
69
|
+
- full overlays (`top/right/bottom/left` all `0` on the same rule/object)
|
|
70
|
+
- off-screen hiding (e.g. `left: '-9999px'`)
|
|
71
|
+
- **`[dir="rtl"]` override blocks** are removed once they become redundant (opt out with
|
|
72
|
+
`--keep-rtl-overrides`).
|
|
73
|
+
- `box-shadow`, `transform`, and `inset(...)` values are never touched. `shouldForwardProp` arrays,
|
|
74
|
+
`PropTypes`, and commented-out code are never touched (the transform only targets real style
|
|
75
|
+
object properties and CSS declarations).
|
|
76
|
+
|
|
77
|
+
## Programmatic API
|
|
78
|
+
|
|
79
|
+
```js
|
|
80
|
+
const { transformJs, transformCss, processFile } = require('rtl-migrate-cli');
|
|
81
|
+
|
|
82
|
+
const { code, report } = transformJs(sourceCode, 'Component.styles.js');
|
|
83
|
+
// report.converted / report.skipped / report.removed
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Limitations / roadmap
|
|
87
|
+
|
|
88
|
+
- Template-literal CSS-in-JS (`styled.div\`...\``, emotion `css\`...\``) is not yet parsed — only
|
|
89
|
+
JS object-based styles and `.css`/`.scss` files are supported today.
|
|
90
|
+
- `margin`/`padding` shorthand splitting only supports 1-value and 4-value forms; 2/3-value
|
|
91
|
+
shorthands are left untouched and reported as skipped.
|
|
92
|
+
- Direction-check detection is heuristic (`x === 'ltr'|'rtl'`); unusual condition shapes are
|
|
93
|
+
reported as skipped rather than guessed at.
|
|
94
|
+
|
|
95
|
+
Contributions welcome — please open an issue/PR for additional CSS-in-JS flavors or shorthand
|
|
96
|
+
patterns.
|
|
97
|
+
|
|
98
|
+
## Publishing (maintainer notes)
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
npm login
|
|
102
|
+
npm publish --access public
|
|
103
|
+
git init && git add -A && git commit -m "Initial release"
|
|
104
|
+
git remote add origin git@github.com:jatinsh1011/rtlmigrate.git
|
|
105
|
+
git push -u origin main
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rtl-migrate-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI + codemod that migrates physical LTR CSS / CSS-in-JS properties to RTL-ready logical properties (marginLeft -> marginInlineStart, etc.)",
|
|
5
|
+
"bin": {
|
|
6
|
+
"rtlmigrate": "bin/rtlmigrate.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "src/index.js",
|
|
9
|
+
"type": "commonjs",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=16"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"rtl",
|
|
25
|
+
"ltr",
|
|
26
|
+
"css",
|
|
27
|
+
"logical-properties",
|
|
28
|
+
"i18n",
|
|
29
|
+
"codemod",
|
|
30
|
+
"css-in-js",
|
|
31
|
+
"cli"
|
|
32
|
+
],
|
|
33
|
+
"author": "jatinsh1011",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/jatinsh1011/rtlmigrate.git"
|
|
38
|
+
},
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/jatinsh1011/rtlmigrate/issues"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/jatinsh1011/rtlmigrate#readme",
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@babel/parser": "^7.24.0",
|
|
45
|
+
"@babel/traverse": "^7.24.0",
|
|
46
|
+
"@babel/types": "^7.24.0",
|
|
47
|
+
"commander": "^12.0.0",
|
|
48
|
+
"diff": "^5.2.0",
|
|
49
|
+
"fast-glob": "^3.3.2",
|
|
50
|
+
"picocolors": "^1.0.0",
|
|
51
|
+
"postcss": "^8.4.38",
|
|
52
|
+
"postcss-scss": "^4.0.9",
|
|
53
|
+
"recast": "^0.23.9"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"vitest": "^1.4.0"
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const { Command } = require('commander');
|
|
3
|
+
const fg = require('fast-glob');
|
|
4
|
+
const { createPatch } = require('diff');
|
|
5
|
+
const pc = require('picocolors');
|
|
6
|
+
const { processFile } = require('./fileProcessor');
|
|
7
|
+
const pkg = require('../package.json');
|
|
8
|
+
|
|
9
|
+
function run(argv) {
|
|
10
|
+
const program = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('rtlmigrate')
|
|
14
|
+
.description('Convert LTR physical CSS / CSS-in-JS properties to RTL-ready logical properties')
|
|
15
|
+
.argument('<patterns...>', 'file(s) or glob pattern(s) to process')
|
|
16
|
+
.option('-w, --write', 'write changes to disk (default: dry-run, prints a diff only)', false)
|
|
17
|
+
.option('--keep-rtl-overrides', 'do not remove [dir="rtl"] override blocks', false)
|
|
18
|
+
.version(pkg.version)
|
|
19
|
+
.parse(argv);
|
|
20
|
+
|
|
21
|
+
const opts = program.opts();
|
|
22
|
+
const patterns = program.args;
|
|
23
|
+
const files = fg.sync(patterns, { onlyFiles: true, absolute: true, dot: false });
|
|
24
|
+
|
|
25
|
+
if (!files.length) {
|
|
26
|
+
console.error(pc.yellow('No files matched the given pattern(s).'));
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let anyChanged = false;
|
|
32
|
+
let errorCount = 0;
|
|
33
|
+
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
try {
|
|
36
|
+
const result = processFile(file, { write: opts.write, keepRtlOverrides: opts.keepRtlOverrides });
|
|
37
|
+
if (result.skippedFile) continue;
|
|
38
|
+
|
|
39
|
+
const relPath = path.relative(process.cwd(), file);
|
|
40
|
+
if (!result.changed) {
|
|
41
|
+
console.log(pc.gray(`No changes: ${relPath}`));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
anyChanged = true;
|
|
46
|
+
console.log(pc.bold(pc.cyan(`\n${relPath}`)));
|
|
47
|
+
result.report.converted.forEach((c) => console.log(pc.green(` + ${c.from} \u2192 ${c.to}`)));
|
|
48
|
+
result.report.removed.forEach((r) =>
|
|
49
|
+
console.log(pc.yellow(` - removed: ${r.selector || r.property} (${r.reason})`))
|
|
50
|
+
);
|
|
51
|
+
result.report.skipped.forEach((s) => console.log(pc.gray(` ~ skipped: ${s.property} (${s.reason})`)));
|
|
52
|
+
|
|
53
|
+
if (opts.write) {
|
|
54
|
+
console.log(pc.green(' \u2714 written'));
|
|
55
|
+
} else {
|
|
56
|
+
console.log(createPatch(file, result.original, result.output, '', ''));
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
errorCount += 1;
|
|
60
|
+
console.error(pc.red(`Error processing ${file}: ${err.message}`));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!anyChanged) {
|
|
65
|
+
console.log(pc.gray('\nNo properties needed conversion.'));
|
|
66
|
+
} else if (!opts.write) {
|
|
67
|
+
console.log(pc.yellow('\nDry run only \u2014 re-run with --write to apply changes.'));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
process.exitCode = errorCount ? 1 : 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { run };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { transformJs } = require('./transformers/jsTransform');
|
|
4
|
+
const { transformCss } = require('./transformers/cssTransform');
|
|
5
|
+
|
|
6
|
+
const JS_EXTS = new Set(['.js', '.jsx', '.ts', '.tsx']);
|
|
7
|
+
const CSS_EXTS = new Set(['.css', '.scss']);
|
|
8
|
+
|
|
9
|
+
function processFile(filePath, opts = {}) {
|
|
10
|
+
const original = fs.readFileSync(filePath, 'utf8');
|
|
11
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
12
|
+
|
|
13
|
+
let result;
|
|
14
|
+
if (JS_EXTS.has(ext)) {
|
|
15
|
+
result = transformJs(original, filePath, opts);
|
|
16
|
+
} else if (CSS_EXTS.has(ext)) {
|
|
17
|
+
result = transformCss(original, filePath, opts);
|
|
18
|
+
} else {
|
|
19
|
+
return { skippedFile: true, reason: `unsupported extension ${ext}` };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const changed = result.code !== original;
|
|
23
|
+
if (changed && opts.write) {
|
|
24
|
+
fs.writeFileSync(filePath, result.code, 'utf8');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return { filePath, original, output: result.code, changed, report: result.report };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
module.exports = { processFile };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Value-based heuristics used to avoid converting left/right that aren't directional.
|
|
2
|
+
|
|
3
|
+
function isCentering(value) {
|
|
4
|
+
return /^-?50%$/.test(String(value).trim());
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function isOffScreen(value) {
|
|
8
|
+
const match = String(value).trim().match(/^-(\d+(?:\.\d+)?)(px|rem|em)$/);
|
|
9
|
+
if (!match) return false;
|
|
10
|
+
return parseFloat(match[1]) >= 999;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function isZero(value) {
|
|
14
|
+
return /^0(px|em|rem|%)?$/.test(String(value).trim());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { isCentering, isOffScreen, isZero };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Physical -> logical property maps for JS style objects (camelCase) and CSS (kebab-case).
|
|
2
|
+
|
|
3
|
+
const DIRECT_RENAME_MAP = {
|
|
4
|
+
marginLeft: 'marginInlineStart',
|
|
5
|
+
marginRight: 'marginInlineEnd',
|
|
6
|
+
paddingLeft: 'paddingInlineStart',
|
|
7
|
+
paddingRight: 'paddingInlineEnd',
|
|
8
|
+
borderLeft: 'borderInlineStart',
|
|
9
|
+
borderRight: 'borderInlineEnd',
|
|
10
|
+
borderTopLeftRadius: 'borderStartStartRadius',
|
|
11
|
+
borderTopRightRadius: 'borderStartEndRadius',
|
|
12
|
+
borderBottomLeftRadius: 'borderEndStartRadius',
|
|
13
|
+
borderBottomRightRadius: 'borderEndEndRadius',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const CSS_DIRECT_RENAME_MAP = {
|
|
17
|
+
'margin-left': 'margin-inline-start',
|
|
18
|
+
'margin-right': 'margin-inline-end',
|
|
19
|
+
'padding-left': 'padding-inline-start',
|
|
20
|
+
'padding-right': 'padding-inline-end',
|
|
21
|
+
'border-left': 'border-inline-start',
|
|
22
|
+
'border-right': 'border-inline-end',
|
|
23
|
+
'border-top-left-radius': 'border-start-start-radius',
|
|
24
|
+
'border-top-right-radius': 'border-start-end-radius',
|
|
25
|
+
'border-bottom-left-radius': 'border-end-start-radius',
|
|
26
|
+
'border-bottom-right-radius': 'border-end-end-radius',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const POSITION_MAP = { left: 'insetInlineStart', right: 'insetInlineEnd' };
|
|
30
|
+
const CSS_POSITION_MAP = { left: 'inset-inline-start', right: 'inset-inline-end' };
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
DIRECT_RENAME_MAP,
|
|
34
|
+
CSS_DIRECT_RENAME_MAP,
|
|
35
|
+
POSITION_MAP,
|
|
36
|
+
CSS_POSITION_MAP,
|
|
37
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const postcss = require('postcss');
|
|
2
|
+
const scss = require('postcss-scss');
|
|
3
|
+
const { CSS_DIRECT_RENAME_MAP, CSS_POSITION_MAP } = require('../rules/propertyMap');
|
|
4
|
+
const { isCentering, isOffScreen, isZero } = require('../rules/heuristics');
|
|
5
|
+
|
|
6
|
+
function transformCss(code, filePath, opts = {}) {
|
|
7
|
+
const report = { converted: [], skipped: [], removed: [] };
|
|
8
|
+
const syntax = filePath.endsWith('.scss') ? scss : undefined;
|
|
9
|
+
const root = postcss.parse(code, { syntax });
|
|
10
|
+
|
|
11
|
+
root.walkRules((rule) => {
|
|
12
|
+
if (/\[dir=["']rtl["']\]/.test(rule.selector)) {
|
|
13
|
+
if (!opts.keepRtlOverrides) {
|
|
14
|
+
report.removed.push({ file: filePath, selector: rule.selector, reason: 'redundant [dir="rtl"] override block' });
|
|
15
|
+
rule.remove();
|
|
16
|
+
}
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const values = {};
|
|
21
|
+
rule.walkDecls((d) => {
|
|
22
|
+
values[d.prop.toLowerCase()] = d.value;
|
|
23
|
+
});
|
|
24
|
+
const isFullOverlay = ['top', 'left', 'right', 'bottom'].every(
|
|
25
|
+
(k) => values[k] !== undefined && isZero(values[k])
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
rule.walkDecls((decl) => {
|
|
29
|
+
const prop = decl.prop.toLowerCase();
|
|
30
|
+
|
|
31
|
+
if (CSS_DIRECT_RENAME_MAP[prop]) {
|
|
32
|
+
report.converted.push({ file: filePath, from: prop, to: CSS_DIRECT_RENAME_MAP[prop] });
|
|
33
|
+
decl.prop = CSS_DIRECT_RENAME_MAP[prop];
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (prop === 'left' || prop === 'right') {
|
|
38
|
+
if (isFullOverlay) {
|
|
39
|
+
report.skipped.push({ file: filePath, property: prop, reason: 'full-overlay pattern (top/right/bottom/left all 0)' });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (isCentering(decl.value) || isOffScreen(decl.value)) {
|
|
43
|
+
report.skipped.push({ file: filePath, property: prop, reason: 'centering or off-screen value' });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
report.converted.push({ file: filePath, from: prop, to: CSS_POSITION_MAP[prop] });
|
|
47
|
+
decl.prop = CSS_POSITION_MAP[prop];
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (prop === 'margin' || prop === 'padding') {
|
|
52
|
+
const parts = decl.value.trim().split(/\s+/);
|
|
53
|
+
if (parts.length === 4) {
|
|
54
|
+
const [top, right, bottom, left] = parts;
|
|
55
|
+
const decls = [];
|
|
56
|
+
if (!isZero(top)) decls.push(postcss.decl({ prop: `${prop}-top`, value: top }));
|
|
57
|
+
if (!isZero(bottom)) decls.push(postcss.decl({ prop: `${prop}-bottom`, value: bottom }));
|
|
58
|
+
decls.push(postcss.decl({ prop: `${prop}-inline-start`, value: left }));
|
|
59
|
+
decls.push(postcss.decl({ prop: `${prop}-inline-end`, value: right }));
|
|
60
|
+
decl.replaceWith(decls);
|
|
61
|
+
report.converted.push({ file: filePath, from: `${prop} (shorthand)`, to: `${prop}-inline-start/${prop}-inline-end` });
|
|
62
|
+
} else if (parts.length === 1) {
|
|
63
|
+
decl.replaceWith(
|
|
64
|
+
postcss.decl({ prop: `${prop}-inline-start`, value: parts[0] }),
|
|
65
|
+
postcss.decl({ prop: `${prop}-inline-end`, value: parts[0] })
|
|
66
|
+
);
|
|
67
|
+
report.converted.push({ file: filePath, from: prop, to: `${prop}-inline-start/${prop}-inline-end` });
|
|
68
|
+
} else {
|
|
69
|
+
report.skipped.push({ file: filePath, property: prop, reason: `unsupported shorthand arity (${parts.length} values)` });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { code: root.toString(), report };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { transformCss };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
const recast = require('recast');
|
|
2
|
+
const babelParser = require('recast/parsers/babel');
|
|
3
|
+
const traverse = require('@babel/traverse').default;
|
|
4
|
+
const t = require('@babel/types');
|
|
5
|
+
const { DIRECT_RENAME_MAP, POSITION_MAP } = require('../rules/propertyMap');
|
|
6
|
+
const { isCentering, isOffScreen, isZero } = require('../rules/heuristics');
|
|
7
|
+
|
|
8
|
+
// Matches `direction === 'ltr'`, `theme.direction === 'rtl'`, `dir == 'ltr'`, etc.
|
|
9
|
+
function isDirectionTest(node) {
|
|
10
|
+
if (!t.isBinaryExpression(node) || (node.operator !== '===' && node.operator !== '==')) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
const literalSide = t.isStringLiteral(node.right) ? node.right : t.isStringLiteral(node.left) ? node.left : null;
|
|
14
|
+
if (!literalSide) return null;
|
|
15
|
+
const value = literalSide.value.toLowerCase();
|
|
16
|
+
return value === 'ltr' || value === 'rtl' ? value : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getKeyName(prop) {
|
|
20
|
+
if (!t.isObjectProperty(prop)) return null;
|
|
21
|
+
if (t.isIdentifier(prop.key) && !prop.computed) return prop.key.name;
|
|
22
|
+
if (t.isStringLiteral(prop.key)) return prop.key.value;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function setKeyName(prop, newName) {
|
|
27
|
+
if (t.isIdentifier(prop.key)) {
|
|
28
|
+
prop.key = t.identifier(newName);
|
|
29
|
+
} else {
|
|
30
|
+
prop.key = t.stringLiteral(newName);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function staticStringValue(node) {
|
|
35
|
+
if (t.isStringLiteral(node)) return node.value;
|
|
36
|
+
if (t.isNumericLiteral(node)) return String(node.value);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function convertDirectProperty(prop, name, report, filePath) {
|
|
41
|
+
const newName = DIRECT_RENAME_MAP[name];
|
|
42
|
+
let newValueNode = prop.value;
|
|
43
|
+
|
|
44
|
+
if (t.isConditionalExpression(prop.value)) {
|
|
45
|
+
const testValue = isDirectionTest(prop.value.test);
|
|
46
|
+
if (!testValue) {
|
|
47
|
+
report.skipped.push({ file: filePath, property: name, reason: 'conditional test is not a recognizable direction check' });
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
newValueNode = testValue === 'ltr' ? prop.value.consequent : prop.value.alternate;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
setKeyName(prop, newName);
|
|
54
|
+
prop.value = newValueNode;
|
|
55
|
+
report.converted.push({ file: filePath, from: name, to: newName });
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function convertPositionalProperty(prop, name, report, filePath, ctx) {
|
|
60
|
+
const newName = POSITION_MAP[name];
|
|
61
|
+
|
|
62
|
+
if (t.isConditionalExpression(prop.value)) {
|
|
63
|
+
const testValue = isDirectionTest(prop.value.test);
|
|
64
|
+
if (!testValue) {
|
|
65
|
+
report.skipped.push({ file: filePath, property: name, reason: 'conditional test is not a recognizable direction check' });
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const ltrNode = testValue === 'ltr' ? prop.value.consequent : prop.value.alternate;
|
|
69
|
+
const value = staticStringValue(ltrNode);
|
|
70
|
+
if (value !== null && (isCentering(value) || isOffScreen(value))) {
|
|
71
|
+
report.skipped.push({ file: filePath, property: name, reason: 'centering or off-screen value' });
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
setKeyName(prop, newName);
|
|
75
|
+
prop.value = ltrNode;
|
|
76
|
+
report.converted.push({ file: filePath, from: name, to: newName });
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (ctx.isFullOverlay) {
|
|
81
|
+
report.skipped.push({ file: filePath, property: name, reason: 'full-overlay pattern (top/right/bottom/left all 0)' });
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const value = staticStringValue(prop.value);
|
|
86
|
+
if (value !== null && (isCentering(value) || isOffScreen(value))) {
|
|
87
|
+
report.skipped.push({ file: filePath, property: name, reason: 'centering or off-screen value' });
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setKeyName(prop, newName);
|
|
92
|
+
report.converted.push({ file: filePath, from: name, to: newName });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// margin/padding shorthand ternary -> split LTR value into *InlineStart/*InlineEnd (+ Top/Bottom if non-zero).
|
|
97
|
+
function convertShorthandTernary(prop, baseName, report, filePath) {
|
|
98
|
+
const testValue = isDirectionTest(prop.value.test);
|
|
99
|
+
if (!testValue) {
|
|
100
|
+
report.skipped.push({ file: filePath, property: baseName, reason: 'conditional test is not a recognizable direction check' });
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const ltrNode = testValue === 'ltr' ? prop.value.consequent : prop.value.alternate;
|
|
104
|
+
if (!t.isStringLiteral(ltrNode)) {
|
|
105
|
+
report.skipped.push({ file: filePath, property: baseName, reason: 'shorthand value is not a string literal' });
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const parts = ltrNode.value.trim().split(/\s+/);
|
|
110
|
+
let entries;
|
|
111
|
+
if (parts.length === 4) {
|
|
112
|
+
const [top, right, bottom, left] = parts;
|
|
113
|
+
entries = [];
|
|
114
|
+
if (!isZero(top)) entries.push([`${baseName}Top`, top]);
|
|
115
|
+
if (!isZero(bottom)) entries.push([`${baseName}Bottom`, bottom]);
|
|
116
|
+
entries.push([`${baseName}InlineStart`, left]);
|
|
117
|
+
entries.push([`${baseName}InlineEnd`, right]);
|
|
118
|
+
} else if (parts.length === 1) {
|
|
119
|
+
entries = [
|
|
120
|
+
[`${baseName}InlineStart`, parts[0]],
|
|
121
|
+
[`${baseName}InlineEnd`, parts[0]],
|
|
122
|
+
];
|
|
123
|
+
} else {
|
|
124
|
+
report.skipped.push({ file: filePath, property: baseName, reason: `unsupported shorthand arity (${parts.length} values)` });
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
report.converted.push({ file: filePath, from: `${baseName} (shorthand)`, to: entries.map((e) => e[0]).join('/') });
|
|
129
|
+
return entries.map(([name, value]) => t.objectProperty(t.identifier(name), t.stringLiteral(value)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function convertTextAlignTernary(prop, report, filePath) {
|
|
133
|
+
const testValue = isDirectionTest(prop.value.test);
|
|
134
|
+
if (!testValue) {
|
|
135
|
+
report.skipped.push({ file: filePath, property: 'textAlign', reason: 'conditional test is not a recognizable direction check' });
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
const ltrNode = testValue === 'ltr' ? prop.value.consequent : prop.value.alternate;
|
|
139
|
+
if (!t.isStringLiteral(ltrNode)) {
|
|
140
|
+
report.skipped.push({ file: filePath, property: 'textAlign', reason: 'value is not a string literal' });
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const map = { left: 'start', right: 'end' };
|
|
144
|
+
const mapped = map[ltrNode.value];
|
|
145
|
+
if (!mapped) return false;
|
|
146
|
+
|
|
147
|
+
prop.value = t.stringLiteral(mapped);
|
|
148
|
+
report.converted.push({ file: filePath, from: 'textAlign (conditional)', to: `textAlign: '${mapped}'` });
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function processObjectExpression(objExpr, report, filePath) {
|
|
153
|
+
const props = objExpr.properties;
|
|
154
|
+
const siblingValues = {};
|
|
155
|
+
props.forEach((p) => {
|
|
156
|
+
const name = getKeyName(p);
|
|
157
|
+
if (name && (t.isStringLiteral(p.value) || t.isNumericLiteral(p.value))) {
|
|
158
|
+
siblingValues[name] = staticStringValue(p.value);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
const isFullOverlay = ['top', 'left', 'right', 'bottom'].every(
|
|
162
|
+
(k) => siblingValues[k] !== undefined && isZero(siblingValues[k])
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const newProperties = [];
|
|
166
|
+
let changed = false;
|
|
167
|
+
|
|
168
|
+
props.forEach((prop) => {
|
|
169
|
+
const name = getKeyName(prop);
|
|
170
|
+
if (!name) {
|
|
171
|
+
newProperties.push(prop);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Redundant nested `&[dir="rtl"]` override block once logical properties are used.
|
|
176
|
+
if (/\[dir=["']rtl["']\]/.test(name)) {
|
|
177
|
+
report.removed.push({ file: filePath, property: name, reason: 'redundant [dir="rtl"] override block' });
|
|
178
|
+
changed = true;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (DIRECT_RENAME_MAP[name]) {
|
|
183
|
+
if (convertDirectProperty(prop, name, report, filePath)) changed = true;
|
|
184
|
+
newProperties.push(prop);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if ((name === 'margin' || name === 'padding') && t.isConditionalExpression(prop.value)) {
|
|
189
|
+
const replacement = convertShorthandTernary(prop, name, report, filePath);
|
|
190
|
+
if (replacement) {
|
|
191
|
+
changed = true;
|
|
192
|
+
newProperties.push(...replacement);
|
|
193
|
+
} else {
|
|
194
|
+
newProperties.push(prop);
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (name === 'left' || name === 'right') {
|
|
200
|
+
if (convertPositionalProperty(prop, name, report, filePath, { isFullOverlay })) changed = true;
|
|
201
|
+
newProperties.push(prop);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (name === 'textAlign' && t.isConditionalExpression(prop.value)) {
|
|
206
|
+
if (convertTextAlignTernary(prop, report, filePath)) changed = true;
|
|
207
|
+
newProperties.push(prop);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
newProperties.push(prop);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
if (changed) {
|
|
215
|
+
objExpr.properties = newProperties;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function transformJs(code, filePath) {
|
|
220
|
+
const report = { converted: [], skipped: [], removed: [] };
|
|
221
|
+
const ast = recast.parse(code, { parser: babelParser });
|
|
222
|
+
|
|
223
|
+
traverse(ast, {
|
|
224
|
+
ObjectExpression(path) {
|
|
225
|
+
processObjectExpression(path.node, report, filePath);
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const output = recast.print(ast).code;
|
|
230
|
+
return { code: output, report };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
module.exports = { transformJs };
|