Skip to content

Commit 21a011f

Browse files
feat(eslint-plugin-dialtone): DLT-3227 DLT-3228 add deprecated-tshirt-sizes ESLint rule and migration codemod (#1159)
1 parent c432e0a commit 21a011f

5 files changed

Lines changed: 901 additions & 0 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* @fileoverview Migration script to convert t-shirt size props to numeric scale on Dialtone components.
5+
*
6+
* Transforms: size="sm" → :size="200", label-size="xs" → :label-size="100", speed="md" → :speed="300"
7+
*
8+
* Usage:
9+
* npx dialtone-migrate-tshirt-to-numeric [options]
10+
*
11+
* Options:
12+
* --cwd <path> Working directory (default: current directory)
13+
* --dry-run Show changes without applying them
14+
* --yes Apply all changes without prompting
15+
* --help Show help
16+
*
17+
* Examples:
18+
* npx dialtone-migrate-tshirt-to-numeric
19+
* npx dialtone-migrate-tshirt-to-numeric --dry-run
20+
* npx dialtone-migrate-tshirt-to-numeric --cwd ./src
21+
*/
22+
23+
import fs from 'fs/promises';
24+
import { realpathSync } from 'node:fs';
25+
import path from 'path';
26+
import readline from 'readline';
27+
import { fileURLToPath } from 'node:url';
28+
29+
// ---------------------------------------------------------------------------
30+
// Mapping
31+
// ---------------------------------------------------------------------------
32+
33+
const SIZE_MAP = {
34+
xs: '100',
35+
sm: '200',
36+
md: '300',
37+
lg: '400',
38+
xl: '500',
39+
'2xl': '600',
40+
'3xl': '700',
41+
};
42+
43+
const TSHIRT_VALUES = Object.keys(SIZE_MAP).join('|');
44+
45+
// Match any prop ending in size/Size/speed/Speed with a t-shirt value.
46+
// In the replacer, check that the character before the match is NOT a colon (v-bind).
47+
const PROP_REGEX = new RegExp(
48+
`([\\w-]*(?:[Ss]ize|[Ss]peed))="(${TSHIRT_VALUES})"`,
49+
'g',
50+
);
51+
52+
// Props that end in "size" but are NOT component scale sizes — exclude from migration
53+
const EXCLUDED_PROPS = ['button-width-size', 'buttonWidthSize', 'background-size', 'backgroundSize', 'font-size', 'fontSize'];
54+
55+
// Only match on Dialtone component tags
56+
// Use [\s\S] instead of [^>] to match across newlines in multiline tags
57+
const DT_TAG_PATTERN = /<(dt-[\w-]+|Dt\w+)\b[\s\S]*?>/g;
58+
59+
// ---------------------------------------------------------------------------
60+
// File finder
61+
// ---------------------------------------------------------------------------
62+
63+
async function findFiles (dir, extensions, ignore = []) {
64+
const results = [];
65+
66+
async function walk (currentDir) {
67+
try {
68+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
69+
for (const entry of entries) {
70+
const fullPath = path.join(currentDir, entry.name);
71+
if (ignore.some(ig => fullPath.includes(ig))) continue;
72+
if (entry.isDirectory()) {
73+
await walk(fullPath);
74+
} else if (entry.isFile()) {
75+
if (extensions.some(ext => entry.name.endsWith(ext))) {
76+
results.push(fullPath);
77+
}
78+
}
79+
}
80+
} catch {
81+
// Skip unreadable directories
82+
}
83+
}
84+
85+
await walk(dir);
86+
return results;
87+
}
88+
89+
// ---------------------------------------------------------------------------
90+
// Transform logic
91+
// ---------------------------------------------------------------------------
92+
93+
function transformContent (content) {
94+
let transformed = content;
95+
let count = 0;
96+
97+
// Replace t-shirt sizes only within Dialtone component tags
98+
transformed = transformed.replace(DT_TAG_PATTERN, (tag) => {
99+
PROP_REGEX.lastIndex = 0;
100+
return tag.replace(PROP_REGEX, (match, propName, tshirt, offset, fullTag) => {
101+
// Skip if preceded by ':' (already a v-bind expression)
102+
if (offset > 0 && fullTag[offset - 1] === ':') return match;
103+
// Skip excluded prop names (not component scale sizes)
104+
if (EXCLUDED_PROPS.includes(propName)) return match;
105+
if (SIZE_MAP[tshirt]) {
106+
count++;
107+
return `:${propName}="${SIZE_MAP[tshirt]}"`;
108+
}
109+
return match;
110+
});
111+
});
112+
113+
return { transformed, count };
114+
}
115+
116+
export { transformContent, SIZE_MAP };
117+
118+
// ---------------------------------------------------------------------------
119+
// CLI
120+
// ---------------------------------------------------------------------------
121+
122+
function printHelp () {
123+
console.log(`
124+
Usage: npx dialtone-migrate-tshirt-to-numeric [options]
125+
126+
Converts t-shirt size props to numeric scale on Dialtone components.
127+
128+
size="sm" → :size="200"
129+
label-size="xs" → :label-size="100"
130+
speed="md" → :speed="300"
131+
132+
Options:
133+
--cwd <path> Working directory (default: current directory)
134+
--dry-run Show changes without applying them
135+
--yes Apply all changes without prompting
136+
--help Show help
137+
138+
Size mapping:
139+
xs → 100 sm → 200 md → 300 lg → 400 xl → 500
140+
2xl → 600 3xl → 700
141+
`);
142+
}
143+
144+
async function prompt (question) {
145+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
146+
return new Promise(resolve => {
147+
rl.question(question, answer => {
148+
rl.close();
149+
resolve(answer.trim().toLowerCase());
150+
});
151+
});
152+
}
153+
154+
// eslint-disable-next-line complexity
155+
async function main () {
156+
const args = process.argv.slice(2);
157+
158+
if (args.includes('--help')) {
159+
printHelp();
160+
process.exit(0);
161+
}
162+
163+
const dryRun = args.includes('--dry-run');
164+
const autoYes = args.includes('--yes');
165+
const cwdIndex = args.indexOf('--cwd');
166+
const cwd = cwdIndex !== -1 && args[cwdIndex + 1]
167+
? path.resolve(args[cwdIndex + 1])
168+
: process.cwd();
169+
170+
console.log(`\nScanning ${cwd} for t-shirt size usage on Dialtone components...\n`);
171+
172+
const extensions = ['.vue', '.md', '.html', '.js', '.ts', '.jsx', '.tsx'];
173+
const ignore = ['node_modules', 'dist', '.git', '.vuepress/public'];
174+
const files = await findFiles(cwd, extensions, ignore);
175+
176+
const changes = [];
177+
178+
for (const file of files) {
179+
const content = await fs.readFile(file, 'utf8');
180+
const { transformed, count } = transformContent(content);
181+
if (count > 0) {
182+
changes.push({ file, content, transformed, count });
183+
}
184+
}
185+
186+
if (changes.length === 0) {
187+
console.log('No t-shirt size usage found. Nothing to migrate.');
188+
process.exit(0);
189+
}
190+
191+
console.log(`Found ${changes.reduce((sum, c) => sum + c.count, 0)} t-shirt size references across ${changes.length} files:\n`);
192+
193+
for (const { file, count } of changes) {
194+
const rel = path.relative(cwd, file);
195+
console.log(` ${rel} (${count} change${count > 1 ? 's' : ''})`);
196+
}
197+
198+
if (dryRun) {
199+
console.log('\n--dry-run: No files were modified.\n');
200+
process.exit(0);
201+
}
202+
203+
if (!autoYes) {
204+
const answer = await prompt('\nApply changes? (y/N) ');
205+
if (answer !== 'y' && answer !== 'yes') {
206+
console.log('Cancelled.');
207+
process.exit(0);
208+
}
209+
}
210+
211+
for (const { file, transformed } of changes) {
212+
await fs.writeFile(file, transformed, 'utf8');
213+
}
214+
215+
console.log(`\nMigrated ${changes.reduce((sum, c) => sum + c.count, 0)} references across ${changes.length} files.\n`);
216+
}
217+
218+
// Only run CLI when executed directly (not when imported for testing).
219+
// Uses realpathSync to resolve symlinks from npx/npm bin shims.
220+
const isDirectRun = (() => {
221+
try {
222+
return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
223+
} catch {
224+
return false;
225+
}
226+
})();
227+
228+
if (isDirectRun) {
229+
main().catch(err => {
230+
console.error(err);
231+
process.exit(1);
232+
});
233+
}

0 commit comments

Comments
 (0)