@dialpad/dialtone-css 8.71.0-next.1 → 8.71.0-next.2

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.
@@ -0,0 +1,1033 @@
1
+ #!/usr/bin/env node
2
+ /* eslint-disable max-lines */
3
+ /* eslint-disable complexity */
4
+
5
+ /**
6
+ * @fileoverview Migration script to convert d-d-flex patterns to <dt-stack> components
7
+ *
8
+ * Usage:
9
+ * npx dialtone-migrate-flex-to-stack [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-flex-to-stack
19
+ * npx dialtone-migrate-flex-to-stack --dry-run
20
+ * npx dialtone-migrate-flex-to-stack --cwd ./src
21
+ * npx dialtone-migrate-flex-to-stack --yes
22
+ */
23
+
24
+ import fs from 'fs/promises';
25
+ import path from 'path';
26
+ import readline from 'readline';
27
+
28
+ /**
29
+ * Simple recursive file finder (replaces glob)
30
+ */
31
+ async function findFiles(dir, extensions, ignore = []) {
32
+ const results = [];
33
+
34
+ async function walk(currentDir) {
35
+ try {
36
+ const entries = await fs.readdir(currentDir, { withFileTypes: true });
37
+
38
+ for (const entry of entries) {
39
+ const fullPath = path.join(currentDir, entry.name);
40
+
41
+ // Skip ignored directories
42
+ if (ignore.some(ig => fullPath.includes(ig))) continue;
43
+
44
+ if (entry.isDirectory()) {
45
+ await walk(fullPath);
46
+ } else if (entry.isFile()) {
47
+ const matchesExtension = extensions.some(ext => entry.name.endsWith(ext));
48
+ if (matchesExtension) {
49
+ results.push(fullPath);
50
+ }
51
+ }
52
+ }
53
+ } catch {
54
+ // Skip directories we can't read
55
+ }
56
+ }
57
+
58
+ await walk(dir);
59
+ return results;
60
+ }
61
+
62
+ /**
63
+ * Validate and resolve explicitly specified files
64
+ * @param {string[]} filePaths - Array of file paths (relative or absolute)
65
+ * @param {string[]} extensions - Expected file extensions
66
+ * @returns {Promise<string[]>} - Array of validated absolute paths
67
+ */
68
+ async function validateAndResolveFiles(filePaths, extensions) {
69
+ const resolvedFiles = [];
70
+ const errors = [];
71
+
72
+ for (const filePath of filePaths) {
73
+ // Resolve to absolute path
74
+ const absolutePath = path.isAbsolute(filePath)
75
+ ? filePath
76
+ : path.resolve(process.cwd(), filePath);
77
+
78
+ // Check if file exists and is a file
79
+ try {
80
+ const stat = await fs.stat(absolutePath);
81
+
82
+ if (!stat.isFile()) {
83
+ errors.push(`Not a file: ${filePath}`);
84
+ continue;
85
+ }
86
+
87
+ // Check extension
88
+ const hasValidExtension = extensions.some(ext => absolutePath.endsWith(ext));
89
+ if (!hasValidExtension) {
90
+ errors.push(`Invalid extension for ${filePath}. Expected: ${extensions.join(', ')}`);
91
+ continue;
92
+ }
93
+
94
+ resolvedFiles.push(absolutePath);
95
+ } catch (err) {
96
+ if (err.code === 'ENOENT') {
97
+ errors.push(`File not found: ${filePath}`);
98
+ } else {
99
+ errors.push(`Error accessing ${filePath}: ${err.message}`);
100
+ }
101
+ }
102
+ }
103
+
104
+ // Report errors but continue with valid files
105
+ if (errors.length > 0) {
106
+ console.log(log.yellow('\n⚠ File validation issues:'));
107
+ errors.forEach(err => console.log(log.yellow(` ${err}`)));
108
+ console.log();
109
+ }
110
+
111
+ if (resolvedFiles.length === 0 && filePaths.length > 0) {
112
+ throw new Error('No valid files to process. All specified files had errors.');
113
+ }
114
+
115
+ return resolvedFiles;
116
+ }
117
+
118
+ //------------------------------------------------------------------------------
119
+ // Conversion Mappings
120
+ //------------------------------------------------------------------------------
121
+
122
+ const FLEX_TO_PROP = {
123
+ // Align mappings (d-ai-* → align prop)
124
+ 'd-ai-flex-start': { prop: 'align', value: 'start' },
125
+ 'd-ai-center': { prop: 'align', value: 'center' },
126
+ 'd-ai-flex-end': { prop: 'align', value: 'end' },
127
+ 'd-ai-stretch': { prop: 'align', value: 'stretch' },
128
+ 'd-ai-baseline': { prop: 'align', value: 'baseline' },
129
+ 'd-ai-normal': { prop: 'align', value: 'normal' },
130
+
131
+ // Justify mappings (d-jc-* → justify prop)
132
+ 'd-jc-flex-start': { prop: 'justify', value: 'start' },
133
+ 'd-jc-center': { prop: 'justify', value: 'center' },
134
+ 'd-jc-flex-end': { prop: 'justify', value: 'end' },
135
+ 'd-jc-space-around': { prop: 'justify', value: 'around' },
136
+ 'd-jc-space-between': { prop: 'justify', value: 'between' },
137
+ 'd-jc-space-evenly': { prop: 'justify', value: 'evenly' },
138
+
139
+ // Direction mappings (d-fd-* → direction prop)
140
+ 'd-fd-row': { prop: 'direction', value: 'row' },
141
+ 'd-fd-column': { prop: 'direction', value: 'column' },
142
+ 'd-fd-row-reverse': { prop: 'direction', value: 'row-reverse' },
143
+ 'd-fd-column-reverse': { prop: 'direction', value: 'column-reverse' },
144
+
145
+ // Gap mappings (d-g* → gap prop)
146
+ 'd-g0': { prop: 'gap', value: '0' },
147
+ 'd-g8': { prop: 'gap', value: '400' },
148
+ 'd-g16': { prop: 'gap', value: '500' },
149
+ 'd-g24': { prop: 'gap', value: '550' },
150
+ 'd-g32': { prop: 'gap', value: '600' },
151
+ 'd-g48': { prop: 'gap', value: '650' },
152
+ 'd-g64': { prop: 'gap', value: '700' },
153
+
154
+ // Grid-gap mappings (d-gg* → gap prop) - deprecated utilities, same as d-g*
155
+ 'd-gg0': { prop: 'gap', value: '0' },
156
+ 'd-gg8': { prop: 'gap', value: '400' },
157
+ 'd-gg16': { prop: 'gap', value: '500' },
158
+ 'd-gg24': { prop: 'gap', value: '550' },
159
+ 'd-gg32': { prop: 'gap', value: '600' },
160
+ 'd-gg48': { prop: 'gap', value: '650' },
161
+ 'd-gg64': { prop: 'gap', value: '700' },
162
+ };
163
+
164
+ // Classes to remove (redundant on dt-stack)
165
+ const CLASSES_TO_REMOVE = ['d-d-flex', 'd-fl-center'];
166
+
167
+ // Classes that have no prop equivalent - retain as classes on dt-stack
168
+ const RETAIN_PATTERNS = [
169
+ /^d-fw-/, // flex-wrap
170
+ /^d-fl-/, // flex-grow, flex-shrink, flex-basis (Note: d-fl-center handled separately in CLASSES_TO_REMOVE)
171
+ /^d-as-/, // align-self
172
+ /^d-order/, // order
173
+ /^d-ac-/, // align-content
174
+ /^d-flow\d+$/, // flow gap
175
+ /^d-gg?(80|96|112|128|144|160|176|192|208)$/, // large gaps without prop equivalent (d-g* and d-gg*)
176
+ /^d-flg/, // deprecated flex gap (custom property based) - retain with info message
177
+ /^d-ji-/, // justify-items (grid/flex hybrid)
178
+ /^d-js-/, // justify-self (grid/flex hybrid)
179
+ /^d-plc-/, // place-content (grid shorthand)
180
+ /^d-pli-/, // place-items (grid shorthand)
181
+ /^d-pls-/, // place-self (grid shorthand)
182
+ ];
183
+
184
+ // Native HTML elements that are safe to convert to dt-stack
185
+ // Custom Vue components (anything with hyphens or PascalCase) should NOT be converted
186
+ const NATIVE_HTML_ELEMENTS = new Set([
187
+ 'div', 'span', 'section', 'article', 'aside', 'nav', 'main',
188
+ 'header', 'footer', 'ul', 'ol', 'li', 'form', 'fieldset',
189
+ 'label', 'p', 'figure', 'figcaption', 'details', 'summary',
190
+ 'address', 'blockquote', 'dialog', 'menu', 'a', 'button',
191
+ 'table', 'thead', 'tbody', 'tfoot', 'tr', 'td', 'th',
192
+ ]);
193
+
194
+ //------------------------------------------------------------------------------
195
+ // Pattern Detection
196
+ //------------------------------------------------------------------------------
197
+
198
+ /**
199
+ * Regex to match elements with d-d-flex or d-fl-center in class attribute
200
+ * Captures: tag name (including hyphenated), attributes before class, class value, attributes after class, self-closing
201
+ * Uses [\w-]+ to capture hyphenated tag names like 'code-well-header'
202
+ */
203
+ const ELEMENT_REGEX = /<([\w-]+)([^>]*?)\bclass="([^"]*\b(?:d-d-flex|d-fl-center)\b[^"]*)"([^>]*?)(\/?)>/g;
204
+
205
+ /**
206
+ * Find all elements with d-d-flex or d-fl-center in a template string
207
+ */
208
+ function findFlexElements(content) {
209
+ const matches = [];
210
+ let match;
211
+
212
+ while ((match = ELEMENT_REGEX.exec(content)) !== null) {
213
+ const [fullMatch, tagName, attrsBefore, classValue, attrsAfter, selfClosing] = match;
214
+
215
+ // Skip if already dt-stack
216
+ if (tagName === 'dt-stack' || tagName === 'DtStack') continue;
217
+
218
+ // Skip custom Vue components - only convert native HTML elements
219
+ // Custom components have their own behavior and shouldn't be replaced with dt-stack
220
+ if (!NATIVE_HTML_ELEMENTS.has(tagName.toLowerCase())) continue;
221
+
222
+ // Skip if d-d-flex only appears with responsive prefix (e.g., lg:d-d-flex)
223
+ // Check if there's a bare d-d-flex or d-fl-center (not preceded by breakpoint prefix)
224
+ const classes = classValue.split(/\s+/);
225
+ const hasBareFlexClass = classes.includes('d-d-flex') || classes.includes('d-fl-center');
226
+ if (!hasBareFlexClass) continue;
227
+
228
+ matches.push({
229
+ fullMatch,
230
+ tagName,
231
+ attrsBefore: attrsBefore.trim(),
232
+ classValue,
233
+ attrsAfter: attrsAfter.trim(),
234
+ selfClosing: selfClosing === '/',
235
+ index: match.index,
236
+ endIndex: match.index + fullMatch.length,
237
+ });
238
+ }
239
+
240
+ return matches;
241
+ }
242
+
243
+ /**
244
+ * Find the matching closing tag for an element, accounting for nesting
245
+ * @param {string} content - The file content
246
+ * @param {number} startPos - Position after the opening tag ends
247
+ * @param {string} tagName - The tag name to find closing tag for
248
+ * @returns {object|null} - { index, length } of the closing tag, or null if not found
249
+ */
250
+ function findMatchingClosingTag(content, startPos, tagName) {
251
+ let depth = 1;
252
+ let pos = startPos;
253
+
254
+ // Regex patterns for this specific tag
255
+ // Opening tag: <tagName followed by whitespace, >, or />
256
+ const openPatternStr = `<${tagName}(?:\\s[^>]*?)?>`;
257
+ const selfClosePatternStr = `<${tagName}(?:\\s[^>]*?)?/>`;
258
+ const closePatternStr = `</${tagName}>`;
259
+
260
+ while (depth > 0 && pos < content.length) {
261
+ // Find next opening tag (non-self-closing)
262
+ const openMatch = content.slice(pos).match(new RegExp(openPatternStr));
263
+ // Find next self-closing tag (doesn't affect depth)
264
+ const selfCloseMatch = content.slice(pos).match(new RegExp(selfClosePatternStr));
265
+ // Find next closing tag
266
+ const closeMatch = content.slice(pos).match(new RegExp(closePatternStr));
267
+
268
+ if (!closeMatch) {
269
+ // No closing tag found - malformed HTML
270
+ return null;
271
+ }
272
+
273
+ const closePos = pos + closeMatch.index;
274
+
275
+ // Check if there's an opening tag before this closing tag
276
+ let openPos = openMatch ? pos + openMatch.index : Infinity;
277
+
278
+ // If the opening tag is actually a self-closing tag, it doesn't count
279
+ if (selfCloseMatch && openMatch && selfCloseMatch.index === openMatch.index) {
280
+ openPos = Infinity; // Treat as no opening tag
281
+ }
282
+
283
+ if (openPos < closePos) {
284
+ // Found an opening tag before the closing tag - increase depth
285
+ depth++;
286
+ pos = openPos + openMatch[0].length;
287
+ } else {
288
+ // Found a closing tag
289
+ depth--;
290
+ if (depth === 0) {
291
+ return {
292
+ index: closePos,
293
+ length: closeMatch[0].length,
294
+ };
295
+ }
296
+ pos = closePos + closeMatch[0].length;
297
+ }
298
+ }
299
+
300
+ return null; // No matching closing tag found
301
+ }
302
+
303
+ //------------------------------------------------------------------------------
304
+ // Transformation Logic
305
+ //------------------------------------------------------------------------------
306
+
307
+ /**
308
+ * Check if an element should be skipped (not migrated)
309
+ * @param {object} element - Element with classValue property
310
+ * @returns {object|null} - Returns skip info object if should skip, null if should migrate
311
+ */
312
+ function shouldSkipElement(element) {
313
+ const classes = element.classValue.split(/\s+/).filter(Boolean);
314
+
315
+ // Skip grid containers (not flexbox)
316
+ if (classes.includes('d-d-grid') || classes.includes('d-d-inline-grid')) {
317
+ return {
318
+ reason: 'Grid container detected (not flexbox)',
319
+ severity: 'info',
320
+ message: `Skipping <${element.tagName}> - uses CSS Grid (d-d-grid/d-d-inline-grid), not flexbox`,
321
+ };
322
+ }
323
+
324
+ // Skip inline-flex (DtStack is block-level only)
325
+ if (classes.includes('d-d-inline-flex')) {
326
+ return {
327
+ reason: 'Inline-flex not supported by DtStack',
328
+ severity: 'info',
329
+ message: `Skipping <${element.tagName}> - d-d-inline-flex not supported (DtStack is block-level)`,
330
+ };
331
+ }
332
+
333
+ // Skip d-d-contents (layout tree manipulation)
334
+ if (classes.includes('d-d-contents')) {
335
+ return {
336
+ reason: 'Display: contents detected',
337
+ severity: 'warning',
338
+ message: `Skipping <${element.tagName}> - d-d-contents manipulates layout tree, verify layout after migration if converted manually`,
339
+ };
340
+ }
341
+
342
+ // Skip deprecated flex column system (complex child selectors)
343
+ if (classes.some(cls => /^d-fl-col\d+$/.test(cls))) {
344
+ return {
345
+ reason: 'Deprecated flex column system (d-fl-col*)',
346
+ severity: 'warning',
347
+ message: `Skipping <${element.tagName}> - d-fl-col* uses complex child selectors, requires manual migration (utility deprecated, see DLT-1763)`,
348
+ };
349
+ }
350
+
351
+ // Skip auto-spacing utilities (margin-based, incompatible with gap)
352
+ const autoSpacingClass = classes.find(cls => /^d-stack\d+$/.test(cls) || /^d-flow\d+$/.test(cls));
353
+ if (autoSpacingClass) {
354
+ return {
355
+ reason: 'Auto-spacing utility (margin-based)',
356
+ severity: 'warning',
357
+ message: `Skipping <${element.tagName}> - ${autoSpacingClass} uses margin-based spacing, incompatible with gap-based DtStack`,
358
+ };
359
+ }
360
+
361
+ return null; // No skip reason, proceed with migration
362
+ }
363
+
364
+ /**
365
+ * Transform a flex element to dt-stack
366
+ * @returns {object|null} - Transformation object or null if element should be skipped
367
+ */
368
+ function transformElement(element, showOutline = false) {
369
+ // Check if element should be skipped
370
+ const skipInfo = shouldSkipElement(element);
371
+ if (skipInfo) {
372
+ return { skip: true, ...skipInfo };
373
+ }
374
+
375
+ const classes = element.classValue.split(/\s+/).filter(Boolean);
376
+ const props = [];
377
+ const retainedClasses = [];
378
+ const directionClasses = ['d-fd-row', 'd-fd-column', 'd-fd-row-reverse', 'd-fd-column-reverse'];
379
+
380
+ // Check for d-fl-center (combination utility - sets display:flex + align:center + justify:center)
381
+ const hasFlCenter = classes.includes('d-fl-center');
382
+
383
+ // Find ALL direction utilities present
384
+ const foundDirectionClasses = classes.filter(cls => directionClasses.includes(cls));
385
+ const directionCount = foundDirectionClasses.length;
386
+
387
+ for (const cls of classes) {
388
+ // Check if class should be removed
389
+ if (CLASSES_TO_REMOVE.includes(cls)) continue;
390
+
391
+ // Special handling for direction utilities
392
+ if (FLEX_TO_PROP[cls] && FLEX_TO_PROP[cls].prop === 'direction') {
393
+ if (directionCount === 1) {
394
+ // Single direction utility - safe to convert
395
+ const { prop, value } = FLEX_TO_PROP[cls];
396
+
397
+ // Skip d-fd-column since DtStack defaults to column
398
+ if (value !== 'column') {
399
+ props.push({ prop, value });
400
+ }
401
+ // Don't add to retainedClasses - it's been converted (or omitted as redundant)
402
+ continue;
403
+ } else if (directionCount > 1) {
404
+ // Multiple direction utilities - retain all, let CSS cascade decide
405
+ retainedClasses.push(cls);
406
+ continue;
407
+ }
408
+ }
409
+
410
+ // Check if class converts to a prop (non-direction)
411
+ if (FLEX_TO_PROP[cls]) {
412
+ const { prop, value } = FLEX_TO_PROP[cls];
413
+ // Avoid duplicate props
414
+ if (!props.some(p => p.prop === prop)) {
415
+ props.push({ prop, value });
416
+ }
417
+ continue;
418
+ }
419
+
420
+ // Check if class should be retained
421
+ if (RETAIN_PATTERNS.some(pattern => pattern.test(cls))) {
422
+ retainedClasses.push(cls);
423
+ continue;
424
+ }
425
+
426
+ // Keep other classes (non-flex utilities like d-p16, d-mb8, etc.)
427
+ retainedClasses.push(cls);
428
+ }
429
+
430
+ // Handle d-fl-center: extract align="center" and justify="center" props
431
+ // d-fl-center sets: display:flex + align-items:center + justify-content:center
432
+ if (hasFlCenter) {
433
+ // Only add if not already present (avoid duplicates)
434
+ if (!props.some(p => p.prop === 'align')) {
435
+ props.push({ prop: 'align', value: 'center' });
436
+ }
437
+ if (!props.some(p => p.prop === 'justify')) {
438
+ props.push({ prop: 'justify', value: 'center' });
439
+ }
440
+ }
441
+
442
+ // Add default direction="row" if no direction utilities found OR multiple found
443
+ if (directionCount === 0 || directionCount > 1) {
444
+ props.unshift({ prop: 'direction', value: 'row' });
445
+ }
446
+
447
+ // Build the new element
448
+ let newElement = '<dt-stack';
449
+
450
+ // Add `as` prop for non-div elements to preserve semantic HTML
451
+ const tagLower = element.tagName.toLowerCase();
452
+ if (tagLower !== 'div') {
453
+ newElement += ` as="${element.tagName}"`;
454
+ }
455
+
456
+ // Add converted props
457
+ for (const { prop, value } of props) {
458
+ newElement += ` ${prop}="${value}"`;
459
+ }
460
+
461
+ // Add retained classes if any
462
+ if (retainedClasses.length > 0) {
463
+ newElement += ` class="${retainedClasses.join(' ')}"`;
464
+ }
465
+
466
+ // Add other attributes (before and after class)
467
+ if (element.attrsBefore) {
468
+ newElement += ` ${element.attrsBefore}`;
469
+ }
470
+ if (element.attrsAfter) {
471
+ newElement += ` ${element.attrsAfter}`;
472
+ }
473
+
474
+ // Add migration marker for visual debugging (if flag is set)
475
+ if (showOutline) {
476
+ newElement += ' data-migrate-outline';
477
+ }
478
+
479
+ // Close tag
480
+ newElement += element.selfClosing ? ' />' : '>';
481
+
482
+ return {
483
+ original: element.fullMatch,
484
+ transformed: newElement,
485
+ tagName: element.tagName,
486
+ props,
487
+ retainedClasses,
488
+ };
489
+ }
490
+
491
+ //------------------------------------------------------------------------------
492
+ // Console Helpers (replace chalk)
493
+ //------------------------------------------------------------------------------
494
+
495
+ const colors = {
496
+ reset: '\x1b[0m',
497
+ red: '\x1b[31m',
498
+ green: '\x1b[32m',
499
+ yellow: '\x1b[33m',
500
+ cyan: '\x1b[36m',
501
+ gray: '\x1b[90m',
502
+ bold: '\x1b[1m',
503
+ };
504
+
505
+ const log = {
506
+ cyan: (msg) => console.log(`${colors.cyan}${msg}${colors.reset}`),
507
+ gray: (msg) => console.log(`${colors.gray}${msg}${colors.reset}`),
508
+ red: (msg) => `${colors.red}${msg}${colors.reset}`,
509
+ green: (msg) => `${colors.green}${msg}${colors.reset}`,
510
+ yellow: (msg) => `${colors.yellow}${msg}${colors.reset}`,
511
+ bold: (msg) => console.log(`${colors.bold}${msg}${colors.reset}`),
512
+ };
513
+
514
+ //------------------------------------------------------------------------------
515
+ // Interactive Prompt (replace inquirer)
516
+ //------------------------------------------------------------------------------
517
+
518
+ async function prompt(question) {
519
+ const rl = readline.createInterface({
520
+ input: process.stdin,
521
+ output: process.stdout,
522
+ });
523
+
524
+ return new Promise((resolve) => {
525
+ rl.question(question, (answer) => {
526
+ rl.close();
527
+ resolve(answer.toLowerCase().trim());
528
+ });
529
+ });
530
+ }
531
+
532
+ //------------------------------------------------------------------------------
533
+ // File Processing
534
+ //------------------------------------------------------------------------------
535
+
536
+ /**
537
+ * Process a single file
538
+ */
539
+ async function processFile(filePath, options) {
540
+ const content = await fs.readFile(filePath, 'utf-8');
541
+
542
+ // Find elements with d-d-flex
543
+ const elements = findFlexElements(content);
544
+
545
+ if (elements.length === 0) return { changes: 0, skipped: 0 };
546
+
547
+ log.cyan(`\n📄 ${filePath}`);
548
+ log.gray(` Found ${elements.length} element(s) with d-d-flex\n`);
549
+
550
+ // Check for dynamic :class bindings with flex utilities
551
+ const dynamicClassRegex = /:(class|v-bind:class)="([^"]*)"/g;
552
+ let dynamicMatch;
553
+ const flexUtilityPattern = /d-d-flex|d-fl-center|d-ai-|d-jc-|d-fd-|d-gg?\d/;
554
+
555
+ while ((dynamicMatch = dynamicClassRegex.exec(content)) !== null) {
556
+ const bindingContent = dynamicMatch[2];
557
+ if (flexUtilityPattern.test(bindingContent)) {
558
+ console.log(log.yellow(` ⚠ Skipped: dynamic :class binding with flex utilities at position ${dynamicMatch.index}. Consider refactoring to dynamic DtStack props.`));
559
+ log.gray(` "${bindingContent.length > 60 ? bindingContent.substring(0, 60) + '...' : bindingContent}"`);
560
+ log.gray(` Requires manual review - cannot auto-migrate dynamic bindings\n`);
561
+ }
562
+ }
563
+
564
+ let changes = 0;
565
+ let skipped = 0;
566
+ let applyAll = options.yes || false;
567
+
568
+ // Collect all transformations with their positions first
569
+ // We need to process all elements and find their closing tags BEFORE making any changes
570
+ const transformations = [];
571
+
572
+ for (const element of elements) {
573
+ const transformation = transformElement(element, options.showOutline);
574
+
575
+ // Handle skipped elements
576
+ if (transformation.skip) {
577
+ const icon = transformation.severity === 'warning' ? '⚠' : 'ℹ';
578
+ const colorFn = transformation.severity === 'warning' ? log.yellow : log.gray;
579
+ console.log(colorFn(` ${icon} ${transformation.message}`));
580
+ log.gray(` ${element.fullMatch}`);
581
+ console.log();
582
+ skipped++;
583
+ continue;
584
+ }
585
+
586
+ // Find the matching closing tag position (in original content)
587
+ let closingTag = null;
588
+ if (!element.selfClosing) {
589
+ closingTag = findMatchingClosingTag(content, element.endIndex, element.tagName);
590
+ }
591
+
592
+ // Show before/after
593
+ console.log(log.red(' - ') + transformation.original);
594
+ console.log(log.green(' + ') + transformation.transformed);
595
+
596
+ if (transformation.retainedClasses.length > 0) {
597
+ console.log(log.yellow(` ⚠ Retained classes: ${transformation.retainedClasses.join(', ')}`));
598
+
599
+ // Add specific info for edge case utilities
600
+ const hasFlg = transformation.retainedClasses.some(cls => /^d-flg/.test(cls));
601
+ const hasGridHybrid = transformation.retainedClasses.some(cls => /^d-(ji-|js-|plc-|pli-|pls-)/.test(cls));
602
+
603
+ if (hasFlg) {
604
+ log.gray(` ℹ d-flg* is deprecated - consider replacing with DtStack gap prop or at least d-g* gap utilities`);
605
+ }
606
+ if (hasGridHybrid) {
607
+ log.gray(` ℹ Grid/flex hybrid utilities (d-ji-*, d-js-*, d-plc-*, etc.) retained - no DtStack prop equivalent`);
608
+ }
609
+ }
610
+ console.log();
611
+
612
+ if (options.dryRun) {
613
+ changes++;
614
+ continue;
615
+ }
616
+
617
+ let shouldApply = applyAll;
618
+
619
+ if (!applyAll) {
620
+ const answer = await prompt(' Apply? [y]es / [n]o / [a]ll / [q]uit: ');
621
+
622
+ if (answer === 'q' || answer === 'quit') break;
623
+ if (answer === 'a' || answer === 'all') {
624
+ applyAll = true;
625
+ shouldApply = true;
626
+ }
627
+ if (answer === 'y' || answer === 'yes') shouldApply = true;
628
+ }
629
+
630
+ if (shouldApply) {
631
+ transformations.push({
632
+ // Opening tag replacement
633
+ openStart: element.index,
634
+ openEnd: element.endIndex,
635
+ openReplacement: transformation.transformed,
636
+ // Closing tag replacement (if not self-closing)
637
+ closeStart: closingTag ? closingTag.index : null,
638
+ closeEnd: closingTag ? closingTag.index + closingTag.length : null,
639
+ closeReplacement: '</dt-stack>',
640
+ selfClosing: element.selfClosing,
641
+ });
642
+ changes++;
643
+ } else {
644
+ skipped++;
645
+ }
646
+ }
647
+
648
+ // Apply all transformations in reverse order (end to start) to preserve positions
649
+ if (!options.dryRun && transformations.length > 0) {
650
+ // Sort by position descending (process from end of file to start)
651
+ // We need to handle both opening and closing tags, so collect all replacements
652
+ const allReplacements = [];
653
+
654
+ for (const t of transformations) {
655
+ // Add opening tag replacement
656
+ allReplacements.push({
657
+ start: t.openStart,
658
+ end: t.openEnd,
659
+ replacement: t.openReplacement,
660
+ });
661
+
662
+ // Add closing tag replacement if not self-closing
663
+ if (!t.selfClosing && t.closeStart !== null) {
664
+ allReplacements.push({
665
+ start: t.closeStart,
666
+ end: t.closeEnd,
667
+ replacement: t.closeReplacement,
668
+ });
669
+ }
670
+ }
671
+
672
+ // Sort by start position descending
673
+ allReplacements.sort((a, b) => b.start - a.start);
674
+
675
+ // Apply replacements from end to start
676
+ let newContent = content;
677
+ for (const r of allReplacements) {
678
+ newContent = newContent.slice(0, r.start) + r.replacement + newContent.slice(r.end);
679
+ }
680
+
681
+ await fs.writeFile(filePath, newContent, 'utf-8');
682
+ console.log(log.green(` ✓ Saved ${changes} change(s)`));
683
+
684
+ // Check if file needs DtStack import
685
+ const importCheck = detectMissingStackImport(newContent, changes > 0);
686
+ if (importCheck?.needsImport) {
687
+ printImportInstructions(filePath, importCheck);
688
+ return { changes, skipped, needsImport: true };
689
+ }
690
+ }
691
+
692
+ return { changes, skipped, needsImport: false };
693
+ }
694
+
695
+ /**
696
+ * Remove data-migrate-outline attributes from files
697
+ * @param {string} filePath - Path to file to clean
698
+ * @param {object} options - Options object with dryRun, yes flags
699
+ * @returns {object} - { changes, skipped }
700
+ */
701
+ async function cleanupMarkers(filePath, options) {
702
+ const content = await fs.readFile(filePath, 'utf8');
703
+
704
+ // Find all data-migrate-outline attributes
705
+ const markerPattern = /\s+data-migrate-outline(?:="[^"]*")?/g;
706
+ const matches = [...content.matchAll(markerPattern)];
707
+
708
+ if (matches.length === 0) {
709
+ return { changes: 0, skipped: 0 };
710
+ }
711
+
712
+ // Show what we found
713
+ console.log(log.cyan(`\n📄 ${filePath}`));
714
+ console.log(log.gray(` Found ${matches.length} marker(s)\n`));
715
+
716
+ if (options.dryRun) {
717
+ // Preview only
718
+ matches.forEach((match, idx) => {
719
+ const start = Math.max(0, match.index - 50);
720
+ const end = Math.min(content.length, match.index + match[0].length + 50);
721
+ const context = content.slice(start, end);
722
+ console.log(log.yellow(` ${idx + 1}. ${context.replace(/\n/g, ' ')}`));
723
+ });
724
+ return { changes: matches.length, skipped: 0 };
725
+ }
726
+
727
+ // Remove all markers
728
+ const newContent = content.replace(markerPattern, '');
729
+
730
+ // Write back
731
+ await fs.writeFile(filePath, newContent, 'utf8');
732
+
733
+ console.log(log.green(` ✓ Removed ${matches.length} marker(s)`));
734
+
735
+ return { changes: matches.length, skipped: 0 };
736
+ }
737
+
738
+ /**
739
+ * Check if a file needs DtStack import
740
+ * @param {string} content - Full file content
741
+ * @param {boolean} usesStack - Whether file has <dt-stack> in template
742
+ * @returns {object|null} - Detection result with suggested import path, or null if import exists
743
+ */
744
+ function detectMissingStackImport(content, usesStack) {
745
+ if (!usesStack) return null;
746
+
747
+ // Check if DtStack is already imported
748
+ const hasImport = /import\s+(?:\{[^}]*\bDtStack\b[^}]*\}|DtStack)\s+from/.test(content);
749
+ if (hasImport) return null;
750
+
751
+ // Analyze existing imports to suggest appropriate path
752
+ const importPath = detectImportPattern(content);
753
+
754
+ return {
755
+ needsImport: true,
756
+ suggestedPath: importPath,
757
+ hasComponentsObject: /components:\s*\{/.test(content),
758
+ };
759
+ }
760
+
761
+ /**
762
+ * Detect import pattern from existing imports in file
763
+ * @param {string} content - File content
764
+ * @returns {string} - Suggested import path
765
+ */
766
+ function detectImportPattern(content) {
767
+ // Check for @/ alias (absolute from package root)
768
+ if (content.includes('from \'@/components/')) {
769
+ return '@/components/stack';
770
+ }
771
+
772
+ // Check for relative barrel imports
773
+ if (content.includes('from \'./\'')) {
774
+ return './'; // User should adjust based on context
775
+ }
776
+
777
+ // Check for external package imports
778
+ if (content.includes('from \'@dialpad/dialtone-vue') || content.includes('from \'@dialpad/dialtone-icons')) {
779
+ return '@dialpad/dialtone-vue3';
780
+ }
781
+
782
+ // Default suggestion
783
+ return '@/components/stack';
784
+ }
785
+
786
+ /**
787
+ * Print instructions for adding DtStack import and registration
788
+ * @param {string} filePath - Path to the file
789
+ * @param {object} importCheck - Result from detectMissingStackImport
790
+ */
791
+ function printImportInstructions(filePath, importCheck) {
792
+ console.log(log.yellow('\n⚠️ ACTION REQUIRED: Add DtStack import and registration'));
793
+ console.log(log.cyan(` File: ${filePath}`));
794
+ console.log();
795
+ console.log(log.gray(' Add this import to your <script> block:'));
796
+ console.log(log.green(` import { DtStack } from '${importCheck.suggestedPath}';`));
797
+ console.log();
798
+
799
+ if (importCheck.hasComponentsObject) {
800
+ console.log(log.gray(' Add to your components object:'));
801
+ console.log(log.green(' components: {'));
802
+ console.log(log.green(' // ... existing components'));
803
+ console.log(log.green(' DtStack,'));
804
+ console.log(log.green(' },'));
805
+ } else {
806
+ console.log(log.gray(' Create or update your components object:'));
807
+ console.log(log.green(' export default {'));
808
+ console.log(log.green(' components: { DtStack },'));
809
+ console.log(log.green(' // ... rest of your component'));
810
+ console.log(log.green(' };'));
811
+ }
812
+ console.log();
813
+ }
814
+
815
+ //------------------------------------------------------------------------------
816
+ // Argument Parsing (simple, no yargs)
817
+ //------------------------------------------------------------------------------
818
+
819
+ function parseArgs() {
820
+ const args = process.argv.slice(2);
821
+ const options = {
822
+ cwd: process.cwd(),
823
+ dryRun: false,
824
+ yes: false,
825
+ extensions: ['.vue'],
826
+ patterns: [],
827
+ hasExtFlag: false, // Track if --ext was used
828
+ files: [], // Explicit file list via --file flag
829
+ showOutline: false, // Add migration marker for visual debugging
830
+ removeOutline: false, // Remove migration markers (cleanup mode)
831
+ };
832
+
833
+ for (let i = 0; i < args.length; i++) {
834
+ const arg = args[i];
835
+
836
+ if (arg === '--help' || arg === '-h') {
837
+ console.log(`
838
+ Usage: npx dialtone-migrate-flex-to-stack [options]
839
+
840
+ Migrates d-d-flex utility patterns to <dt-stack> components.
841
+
842
+ After migration, you'll need to add DtStack imports manually.
843
+ The script will print detailed instructions for each file.
844
+
845
+ Options:
846
+ --cwd <path> Working directory (default: current directory)
847
+ --ext <ext> File extension to process (default: .vue)
848
+ Can be specified multiple times (e.g., --ext .vue --ext .md)
849
+ --file <path> Specific file to process (can be specified multiple times)
850
+ Relative or absolute paths supported
851
+ When used, --cwd is ignored for file discovery
852
+ --dry-run Show changes without applying them
853
+ --yes, -y Apply all changes without prompting
854
+ --show-outline Add data-migrate-outline attribute for visual debugging
855
+ --remove-outline Remove data-migrate-outline attributes after review
856
+ --help, -h Show help
857
+
858
+ Post-Migration Steps:
859
+ 1. Review template changes with data-migrate-outline markers
860
+ 2. Add DtStack imports as instructed by the script
861
+ 3. Test your application
862
+ 4. Run with --remove-outline to clean up markers
863
+
864
+ Examples:
865
+ npx dialtone-migrate-flex-to-stack # Process .vue files
866
+ npx dialtone-migrate-flex-to-stack --ext .md # Process .md files only
867
+ npx dialtone-migrate-flex-to-stack --ext .vue --ext .md # Process both
868
+ npx dialtone-migrate-flex-to-stack --ext .md --cwd ./docs # Process .md in docs/
869
+ npx dialtone-migrate-flex-to-stack --dry-run # Preview changes
870
+ npx dialtone-migrate-flex-to-stack --yes # Auto-apply all changes
871
+
872
+ # Target specific files:
873
+ npx dialtone-migrate-flex-to-stack --file src/App.vue --dry-run
874
+ npx dialtone-migrate-flex-to-stack --file ./component1.vue --file ./component2.vue --yes
875
+ npx dialtone-migrate-flex-to-stack --file /absolute/path/to/file.vue
876
+ `);
877
+ process.exit(0);
878
+ }
879
+
880
+ if (arg === '--cwd' && args[i + 1]) {
881
+ options.cwd = path.resolve(args[++i]);
882
+ } else if (arg === '--ext' && args[i + 1]) {
883
+ // First --ext call clears the default
884
+ if (!options.hasExtFlag) {
885
+ options.extensions = [];
886
+ options.hasExtFlag = true;
887
+ }
888
+ const ext = args[++i];
889
+ // Add leading dot if not present
890
+ options.extensions.push(ext.startsWith('.') ? ext : `.${ext}`);
891
+ } else if (arg === '--dry-run') {
892
+ options.dryRun = true;
893
+ } else if (arg === '--yes' || arg === '-y') {
894
+ options.yes = true;
895
+ } else if (arg === '--show-outline') {
896
+ options.showOutline = true;
897
+ } else if (arg === '--remove-outline') {
898
+ options.removeOutline = true;
899
+ } else if (arg === '--file' && args[i + 1]) {
900
+ const filePath = args[++i];
901
+ options.files.push(filePath);
902
+ } else if (!arg.startsWith('-')) {
903
+ options.patterns.push(arg);
904
+ }
905
+ }
906
+
907
+ // Validate mutually exclusive flags - CRITICAL SAFETY CHECK
908
+ if (options.showOutline && options.removeOutline) {
909
+ throw new Error('Cannot use --show-outline and --remove-outline together');
910
+ }
911
+
912
+ // Display mode warning for clarity
913
+ if (options.removeOutline) {
914
+ console.log(log.yellow('\n⚠️ CLEANUP MODE: Will remove data-migrate-outline attributes only'));
915
+ console.log(log.yellow(' No flex-to-stack transformations will be performed\n'));
916
+ }
917
+
918
+ return options;
919
+ }
920
+
921
+ //------------------------------------------------------------------------------
922
+ // Main
923
+ //------------------------------------------------------------------------------
924
+
925
+ async function main() {
926
+ const options = parseArgs();
927
+
928
+ log.bold('\n🔄 Flex to Stack Migration Tool\n');
929
+
930
+ // Show mode
931
+ if (options.files.length > 0) {
932
+ log.gray(`Mode: Targeted files (${options.files.length} specified)`);
933
+ } else {
934
+ log.gray(`Mode: Directory scan`);
935
+ log.gray(`Working directory: ${options.cwd}`);
936
+ log.gray(`Extensions: ${options.extensions.join(', ')}`);
937
+ }
938
+
939
+ if (options.dryRun) {
940
+ console.log(log.yellow('DRY RUN - no files will be modified'));
941
+ }
942
+ if (options.yes) {
943
+ console.log(log.yellow('AUTO-APPLY - all changes will be applied without prompts'));
944
+ }
945
+
946
+ // Find files - conditional based on --file flag
947
+ let files;
948
+ if (options.files.length > 0) {
949
+ // Use explicitly specified files
950
+ files = await validateAndResolveFiles(options.files, options.extensions);
951
+ } else {
952
+ // Use directory scanning (current behavior)
953
+ files = await findFiles(options.cwd, options.extensions, ['node_modules', 'dist', 'coverage']);
954
+ }
955
+
956
+ log.gray(`Found ${files.length} file(s) to scan\n`);
957
+
958
+ if (files.length === 0) {
959
+ console.log(log.yellow('No files found matching the patterns.'));
960
+ return;
961
+ }
962
+
963
+ // Process files
964
+ let totalChanges = 0;
965
+ let totalSkipped = 0;
966
+ let filesModified = 0;
967
+ let filesNeedingImports = 0;
968
+ const fileList = [];
969
+
970
+ for (const file of files) {
971
+ let result;
972
+
973
+ if (options.removeOutline) {
974
+ // CLEANUP MODE ONLY - No transformations will happen
975
+ // Only removes data-migrate-outline attributes
976
+ result = await cleanupMarkers(file, {
977
+ dryRun: options.dryRun,
978
+ yes: options.yes,
979
+ });
980
+ } else {
981
+ // MIGRATION MODE - Normal flex-to-stack transformation
982
+ // Can optionally add markers with --show-outline
983
+ result = await processFile(file, {
984
+ dryRun: options.dryRun,
985
+ yes: options.yes,
986
+ showOutline: options.showOutline,
987
+ });
988
+ }
989
+
990
+ totalChanges += result.changes;
991
+ totalSkipped += result.skipped;
992
+ if (result.changes > 0) filesModified++;
993
+
994
+ // Track files that need imports
995
+ if (result.needsImport) {
996
+ filesNeedingImports++;
997
+ fileList.push(file);
998
+ }
999
+ }
1000
+
1001
+ // Summary
1002
+ log.bold('\n📊 Summary\n');
1003
+ console.log(` Files scanned: ${files.length}`);
1004
+ console.log(` Files modified: ${filesModified}`);
1005
+
1006
+ if (options.removeOutline) {
1007
+ console.log(` Markers removed: ${totalChanges}`);
1008
+ } else {
1009
+ console.log(` Changes applied: ${totalChanges}`);
1010
+ console.log(` Changes skipped: ${totalSkipped}`);
1011
+ }
1012
+
1013
+ if (filesNeedingImports > 0 && !options.removeOutline && !options.dryRun) {
1014
+ console.log(log.yellow(`\n⚠️ ${filesNeedingImports} file(s) need DtStack import/registration`));
1015
+ console.log(log.gray(' See instructions above for each file.'));
1016
+ console.log();
1017
+ console.log(log.gray(' Quick checklist:'));
1018
+ fileList.forEach(file => {
1019
+ console.log(log.gray(` [ ] ${file}`));
1020
+ });
1021
+ }
1022
+
1023
+ if (options.dryRun && totalChanges > 0) {
1024
+ console.log(log.yellow('\n Run without --dry-run to apply changes.'));
1025
+ }
1026
+
1027
+ console.log();
1028
+ }
1029
+
1030
+ main().catch((error) => {
1031
+ console.error(`${colors.red}Error:${colors.reset}`, error.message);
1032
+ process.exit(1);
1033
+ });