@dialpad/dialtone-css 8.81.0-next.1 → 8.81.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.
@@ -363,6 +363,7 @@ function parseArgs (args) {
363
363
  help: args.includes('--help'),
364
364
  dryRun: args.includes('--dry-run'),
365
365
  autoYes: args.includes('--yes'),
366
+ noImport: args.includes('--no-import'),
366
367
  healthCheck: args.includes('--health-check'),
367
368
  all: args.includes('--all'),
368
369
  cwd: cwdIndex !== -1 && args[cwdIndex + 1]
@@ -778,6 +779,7 @@ async function runStandaloneMigration (migration, opts) {
778
779
  const args = ['--cwd', opts.cwd];
779
780
  if (opts.dryRun) args.push('--dry-run');
780
781
  if (opts.autoYes) args.push('--yes');
782
+ if (opts.noImport) args.push('--no-import');
781
783
 
782
784
  return new Promise((resolve, reject) => {
783
785
  const child = spawn(process.execPath, [scriptPath, ...args], {
@@ -1028,15 +1028,28 @@ async function processFile(filePath, options) {
1028
1028
  newContent = newContent.slice(0, r.start) + r.replacement + newContent.slice(r.end);
1029
1029
  }
1030
1030
 
1031
- await fs.writeFile(filePath, newContent, 'utf-8');
1032
- console.log(log.green(` ✓ Saved ${changes} change(s)`));
1031
+ // Auto-inject DtStack import before writing; fall back to manual instructions if needed.
1032
+ // Skipped entirely when --no-import is set (e.g. DtStack is globally registered).
1033
+ const importCheck = options.noImport ? null : detectMissingStackImport(newContent, changes > 0);
1034
+ let finalContent = newContent;
1035
+ if (importCheck?.needsImport) {
1036
+ const injected = injectComponentImport(newContent, 'DtStack', importCheck.suggestedPath);
1037
+ if (injected) finalContent = injected;
1038
+ }
1039
+
1040
+ await fs.writeFile(filePath, finalContent, 'utf-8');
1033
1041
 
1034
- // Check if file needs DtStack import
1035
- const importCheck = detectMissingStackImport(newContent, changes > 0);
1036
1042
  if (importCheck?.needsImport) {
1043
+ if (finalContent !== newContent) {
1044
+ console.log(log.green(` ✓ Saved ${changes} change(s) + added DtStack import`));
1045
+ return { changes, skipped, needsImport: false };
1046
+ }
1047
+ console.log(log.green(` ✓ Saved ${changes} change(s)`));
1037
1048
  printImportInstructions(filePath, importCheck);
1038
1049
  return { changes, skipped, needsImport: true };
1039
1050
  }
1051
+
1052
+ console.log(log.green(` ✓ Saved ${changes} change(s)`));
1040
1053
  }
1041
1054
 
1042
1055
  return { changes, skipped, needsImport: false };
@@ -1133,6 +1146,70 @@ function detectImportPattern(content) {
1133
1146
  return '@/components/stack';
1134
1147
  }
1135
1148
 
1149
+ /**
1150
+ * Attempt to auto-insert a component import (and Options API registration) into a Vue SFC.
1151
+ * Returns updated content on success, or null when the insertion can't be made safely
1152
+ * (caller should fall back to printing manual instructions).
1153
+ *
1154
+ * Handles:
1155
+ * - <script setup>: inserts import after the last existing import; no components
1156
+ * registration needed (auto-registered when imported in setup context).
1157
+ * - Options API with existing `components: {}`: inserts import + adds to the object.
1158
+ * - Options API without a components object: returns null (manual step required).
1159
+ */
1160
+ function injectComponentImport(content, componentName, importPath) {
1161
+ if (new RegExp(`import\\s+(?:\\{[^}]*\\b${componentName}\\b[^}]*\\}|${componentName})\\s+from`).test(content)) {
1162
+ return null; // already imported
1163
+ }
1164
+
1165
+ const isScriptSetup = /<script\b[^>]*\bsetup\b/.test(content);
1166
+ const scriptBlockRe = isScriptSetup
1167
+ ? /<script\b[^>]*\bsetup\b[^>]*>([\s\S]*?)<\/script>/
1168
+ : /<script\b(?![^>]*\bsetup\b)[^>]*>([\s\S]*?)<\/script>/;
1169
+ const scriptMatch = scriptBlockRe.exec(content);
1170
+ if (!scriptMatch) return null;
1171
+
1172
+ const scriptInnerStart = scriptMatch.index + scriptMatch[0].indexOf('>') + 1;
1173
+ const scriptInner = scriptMatch[1];
1174
+
1175
+ const importLineRe = /^import\s.+from\s+['"][^'"]+['"]\s*;?/gm;
1176
+ let lastImportMatch = null;
1177
+ let m;
1178
+ while ((m = importLineRe.exec(scriptInner)) !== null) lastImportMatch = m;
1179
+
1180
+ const insertOffset = lastImportMatch
1181
+ ? scriptInnerStart + lastImportMatch.index + lastImportMatch[0].length
1182
+ : scriptInnerStart;
1183
+
1184
+ const importLine = `\nimport { ${componentName} } from '${importPath}';`;
1185
+ let out = content.slice(0, insertOffset) + importLine + content.slice(insertOffset);
1186
+
1187
+ if (isScriptSetup) return out; // import alone is sufficient for <script setup>
1188
+
1189
+ // Options API: also register in components: {}
1190
+ // Re-exec against the updated content to get current positions, then restrict
1191
+ // the components: { search to within the script block and after export default
1192
+ // to avoid matching template bindings or helper objects that appear earlier.
1193
+ const scriptMatchUpdated = scriptBlockRe.exec(out);
1194
+ if (!scriptMatchUpdated) return null;
1195
+ const scriptBodyStart = scriptMatchUpdated.index + scriptMatchUpdated[0].indexOf('>') + 1;
1196
+ const scriptBodyText = scriptMatchUpdated[1];
1197
+ const exportDefaultMatch = /\bexport\s+default\b/.exec(scriptBodyText);
1198
+ if (!exportDefaultMatch) return null;
1199
+ const searchFrom = scriptBodyStart + exportDefaultMatch.index;
1200
+ const compMatchInSlice = /components\s*:\s*\{/.exec(out.slice(searchFrom));
1201
+ if (!compMatchInSlice) return null; // no components object — can't safely auto-register
1202
+ const compAbsIndex = searchFrom + compMatchInSlice.index;
1203
+
1204
+ const lineStart = out.lastIndexOf('\n', compAbsIndex) + 1;
1205
+ const compIndent = out.slice(lineStart, compAbsIndex).match(/^[ \t]*/)[0];
1206
+ const memberIndent = compIndent + ' ';
1207
+ const insertAt = compAbsIndex + compMatchInSlice[0].length;
1208
+ out = out.slice(0, insertAt) + `\n${memberIndent}${componentName},` + out.slice(insertAt);
1209
+
1210
+ return out;
1211
+ }
1212
+
1136
1213
  /**
1137
1214
  * Print instructions for adding DtStack import and registration
1138
1215
  * @param {string} filePath - Path to the file
@@ -1179,6 +1256,7 @@ function parseArgs() {
1179
1256
  files: [], // Explicit file list via --file flag
1180
1257
  showOutline: false, // Add migration marker for visual debugging
1181
1258
  removeOutline: false, // Remove migration markers (cleanup mode)
1259
+ noImport: false, // Skip import injection (for apps that globally register DtStack)
1182
1260
  };
1183
1261
 
1184
1262
  for (let i = 0; i < args.length; i++) {
@@ -1206,11 +1284,12 @@ Options:
1206
1284
  --yes, -y Apply all changes without prompting
1207
1285
  --show-outline Add data-migrate-outline attribute for visual debugging
1208
1286
  --remove-outline Remove data-migrate-outline attributes after review
1287
+ --no-import Skip import injection (use when DtStack is globally registered)
1209
1288
  --help, -h Show help
1210
1289
 
1211
1290
  Post-Migration Steps:
1212
1291
  1. Review template changes with data-migrate-outline markers
1213
- 2. Add DtStack imports as instructed by the script
1292
+ 2. Add DtStack imports as instructed by the script (skipped with --no-import)
1214
1293
  3. Test your application
1215
1294
  4. Run with --remove-outline to clean up markers
1216
1295
 
@@ -1252,6 +1331,8 @@ Examples:
1252
1331
  options.showOutline = true;
1253
1332
  } else if (arg === '--remove-outline') {
1254
1333
  options.removeOutline = true;
1334
+ } else if (arg === '--no-import') {
1335
+ options.noImport = true;
1255
1336
  } else if (arg === '--file' && args[i + 1]) {
1256
1337
  const filePath = args[++i];
1257
1338
  options.files.push(filePath);
@@ -1348,6 +1429,7 @@ async function main() {
1348
1429
  yes: options.yes,
1349
1430
  showOutline: options.showOutline,
1350
1431
  validate: options.validate,
1432
+ noImport: options.noImport,
1351
1433
  });
1352
1434
  }
1353
1435
 
@@ -1002,17 +1002,31 @@ function flagDynamicClasses (content) {
1002
1002
  const re = new RegExp(DYNAMIC_CLASS_PATTERN);
1003
1003
  if (!re.test(content)) return content;
1004
1004
 
1005
- const lines = content.split('\n');
1006
- const out = lines.map(line => {
1007
- if (!/:class=|v-bind:class=/.test(line)) return line;
1008
- if (!new RegExp(DYNAMIC_CLASS_PATTERN).test(line)) return line;
1009
- // Preserve the line's indentation; put the marker on its own line above.
1010
- const indentMatch = line.match(/^[ \t]*/);
1011
- const indent = indentMatch ? indentMatch[0] : '';
1012
- return `${indent}<!-- dt-text-migrate: review dynamic class -->\n${line}`;
1013
- });
1005
+ // Walk whole opening tags (quote-aware) so the marker is never injected between
1006
+ // attributes of a multi-line element. The old line-by-line approach would insert
1007
+ // the comment before the :class= line, which lands it between attributes when the
1008
+ // opening tag spans multiple lines.
1009
+ const tagRe = new RegExp(`<[a-zA-Z][\\w-]*(${ATTR_BODY})>`, 'g');
1010
+ const insertions = [];
1011
+ let m;
1014
1012
 
1015
- return out.join('\n');
1013
+ while ((m = tagRe.exec(content)) !== null) {
1014
+ if (!new RegExp(DYNAMIC_CLASS_PATTERN).test(m[0])) continue;
1015
+ const before = content.slice(Math.max(0, m.index - 80), m.index);
1016
+ if (/<!--\s*dt-text-migrate:\s*review dynamic class\s*-->/.test(before)) continue;
1017
+ insertions.push(m.index);
1018
+ }
1019
+
1020
+ if (insertions.length === 0) return content;
1021
+
1022
+ let out = content;
1023
+ for (const idx of insertions.reverse()) {
1024
+ const lineStart = out.lastIndexOf('\n', idx - 1) + 1;
1025
+ const indent = out.slice(lineStart, idx).match(/^[ \t]*/)[0];
1026
+ const marker = `${indent}<!-- dt-text-migrate: review dynamic class -->\n`;
1027
+ out = out.slice(0, lineStart) + marker + out.slice(lineStart);
1028
+ }
1029
+ return out;
1016
1030
  }
1017
1031
 
1018
1032
  // Find composed typography classes on tags outside the rewrite scope
@@ -1245,6 +1259,70 @@ function detectMissingDtTextImport (content, usesText) {
1245
1259
  };
1246
1260
  }
1247
1261
 
1262
+ /**
1263
+ * Attempt to auto-insert a component import (and Options API registration) into a Vue SFC.
1264
+ * Returns updated content on success, or null when the insertion can't be made safely
1265
+ * (caller should fall back to printing manual instructions).
1266
+ *
1267
+ * Handles:
1268
+ * - <script setup>: inserts import after the last existing import; no components
1269
+ * registration needed (auto-registered when imported in setup context).
1270
+ * - Options API with existing `components: {}`: inserts import + adds to the object.
1271
+ * - Options API without a components object: returns null (manual step required).
1272
+ */
1273
+ export function injectComponentImport (content, componentName, importPath) {
1274
+ if (new RegExp(`import\\s+(?:\\{[^}]*\\b${componentName}\\b[^}]*\\}|${componentName})\\s+from`).test(content)) {
1275
+ return null; // already imported
1276
+ }
1277
+
1278
+ const isScriptSetup = /<script\b[^>]*\bsetup\b/.test(content);
1279
+ const scriptBlockRe = isScriptSetup
1280
+ ? /<script\b[^>]*\bsetup\b[^>]*>([\s\S]*?)<\/script>/
1281
+ : /<script\b(?![^>]*\bsetup\b)[^>]*>([\s\S]*?)<\/script>/;
1282
+ const scriptMatch = scriptBlockRe.exec(content);
1283
+ if (!scriptMatch) return null;
1284
+
1285
+ const scriptInnerStart = scriptMatch.index + scriptMatch[0].indexOf('>') + 1;
1286
+ const scriptInner = scriptMatch[1];
1287
+
1288
+ const importLineRe = /^import\s.+from\s+['"][^'"]+['"]\s*;?/gm;
1289
+ let lastImportMatch = null;
1290
+ let m;
1291
+ while ((m = importLineRe.exec(scriptInner)) !== null) lastImportMatch = m;
1292
+
1293
+ const insertOffset = lastImportMatch
1294
+ ? scriptInnerStart + lastImportMatch.index + lastImportMatch[0].length
1295
+ : scriptInnerStart;
1296
+
1297
+ const importLine = `\nimport { ${componentName} } from '${importPath}';`;
1298
+ let out = content.slice(0, insertOffset) + importLine + content.slice(insertOffset);
1299
+
1300
+ if (isScriptSetup) return out; // import alone is sufficient for <script setup>
1301
+
1302
+ // Options API: also register in components: {}
1303
+ // Re-exec against the updated content to get current positions, then restrict
1304
+ // the components: { search to within the script block and after export default
1305
+ // to avoid matching template bindings or helper objects that appear earlier.
1306
+ const scriptMatchUpdated = scriptBlockRe.exec(out);
1307
+ if (!scriptMatchUpdated) return null;
1308
+ const scriptBodyStart = scriptMatchUpdated.index + scriptMatchUpdated[0].indexOf('>') + 1;
1309
+ const scriptBodyText = scriptMatchUpdated[1];
1310
+ const exportDefaultMatch = /\bexport\s+default\b/.exec(scriptBodyText);
1311
+ if (!exportDefaultMatch) return null;
1312
+ const searchFrom = scriptBodyStart + exportDefaultMatch.index;
1313
+ const compMatchInSlice = /components\s*:\s*\{/.exec(out.slice(searchFrom));
1314
+ if (!compMatchInSlice) return null; // no components object — can't safely auto-register
1315
+ const compAbsIndex = searchFrom + compMatchInSlice.index;
1316
+
1317
+ const lineStart = out.lastIndexOf('\n', compAbsIndex) + 1;
1318
+ const compIndent = out.slice(lineStart, compAbsIndex).match(/^[ \t]*/)[0];
1319
+ const memberIndent = compIndent + ' ';
1320
+ const insertAt = compAbsIndex + compMatchInSlice[0].length;
1321
+ out = out.slice(0, insertAt) + `\n${memberIndent}${componentName},` + out.slice(insertAt);
1322
+
1323
+ return out;
1324
+ }
1325
+
1248
1326
  function printImportInstructions (filePath, importCheck) {
1249
1327
  console.log(log.yellow('\n⚠️ ACTION REQUIRED: Add DtText import and registration'));
1250
1328
  log.cyan(` File: ${filePath}`);
@@ -1450,9 +1528,6 @@ async function processFile (filePath, options) {
1450
1528
 
1451
1529
  if (!shouldApply) return { changes: 0, needsImport: false };
1452
1530
 
1453
- await fs.writeFile(filePath, transformed, 'utf-8');
1454
- console.log(log.green(' ✓ Saved'));
1455
-
1456
1531
  // Only warn about missing DtText import when we actually INSERTED new <dt-text> elements
1457
1532
  // — review-marker-only changes (eyebrow/d-code--sm/d-fs-* flags) don't require an import.
1458
1533
  // Use a count delta (not boolean presence) so partial migrations on a file that already
@@ -1460,14 +1535,34 @@ async function processFile (filePath, options) {
1460
1535
  const beforeCount = (content.match(/<dt-text\b/g) || []).length;
1461
1536
  const afterCount = (transformed.match(/<dt-text\b/g) || []).length;
1462
1537
  const addedDtText = afterCount > beforeCount;
1463
- const importCheck = detectMissingDtTextImport(transformed, addedDtText);
1464
- if (importCheck?.needsImport) printImportInstructions(filePath, importCheck);
1538
+ // Auto-inject DtText import before writing; fall back to manual instructions if needed.
1539
+ // Skipped entirely when --no-import is set (e.g. DtText is globally registered).
1540
+ const importCheck = options.noImport ? null : detectMissingDtTextImport(transformed, addedDtText);
1541
+
1542
+ let finalContent = transformed;
1543
+ if (importCheck?.needsImport) {
1544
+ const injected = injectComponentImport(transformed, 'DtText', importCheck.suggestedPath);
1545
+ if (injected) finalContent = injected;
1546
+ }
1547
+
1548
+ await fs.writeFile(filePath, finalContent, 'utf-8');
1549
+
1550
+ if (importCheck?.needsImport) {
1551
+ if (finalContent !== transformed) {
1552
+ console.log(log.green(' ✓ Saved + added DtText import'));
1553
+ } else {
1554
+ console.log(log.green(' ✓ Saved'));
1555
+ printImportInstructions(filePath, importCheck);
1556
+ }
1557
+ } else {
1558
+ console.log(log.green(' ✓ Saved'));
1559
+ }
1465
1560
 
1466
1561
  if (notes.length > 0) {
1467
1562
  for (const note of notes) log.gray(` ℹ ${note}`);
1468
1563
  }
1469
1564
 
1470
- return { changes: 1, needsImport: !!importCheck?.needsImport };
1565
+ return { changes: 1, needsImport: importCheck?.needsImport && finalContent === transformed };
1471
1566
  }
1472
1567
 
1473
1568
  //------------------------------------------------------------------------------
@@ -1484,6 +1579,7 @@ function parseArgs () {
1484
1579
  files: [],
1485
1580
  removeMarkers: false,
1486
1581
  validate: false,
1582
+ noImport: false, // Skip import injection (for apps that globally register DtText)
1487
1583
  };
1488
1584
 
1489
1585
  for (let i = 0; i < args.length; i++) {
@@ -1503,6 +1599,7 @@ Options:
1503
1599
  --remove-markers Strip all <!-- dt-text-migrate: review ... --> comments
1504
1600
  --validate Read-only mode: scan existing <dt-text> for prop bugs
1505
1601
  (object syntax, invalid values, mixed CSS classes)
1602
+ --no-import Skip import injection (use when DtText is globally registered)
1506
1603
  --help, -h Show help
1507
1604
 
1508
1605
  Examples:
@@ -1512,7 +1609,7 @@ Examples:
1512
1609
  npx dialtone-migrate-typography --remove-markers --cwd ./src
1513
1610
 
1514
1611
  Post-Migration Steps:
1515
- 1. Add DtText imports as instructed by the script
1612
+ 1. Add DtText imports as instructed by the script (skipped with --no-import)
1516
1613
  2. Review files marked with <!-- dt-text-migrate: review --> comments
1517
1614
  3. Run with --remove-markers to clean up all markers after manual review
1518
1615
  `);
@@ -1531,6 +1628,8 @@ Post-Migration Steps:
1531
1628
  options.removeMarkers = true;
1532
1629
  } else if (arg === '--validate') {
1533
1630
  options.validate = true;
1631
+ } else if (arg === '--no-import') {
1632
+ options.noImport = true;
1534
1633
  }
1535
1634
  }
1536
1635
 
@@ -12,6 +12,7 @@ import {
12
12
  detectImportPathFor,
13
13
  removeMarkersForTest,
14
14
  validateDtTextProps,
15
+ injectComponentImport,
15
16
  } from './index.mjs';
16
17
 
17
18
  function run (input) {
@@ -987,6 +988,26 @@ describe('wrapper safety — positive cases still convert (no false positives)',
987
988
  });
988
989
  });
989
990
 
991
+ describe('dynamic :class flagging — multi-line element', () => {
992
+ it('marker goes before the opening < of a multi-line element, not between attributes', () => {
993
+ const input = [
994
+ '<div',
995
+ ' v-if="condition"',
996
+ ' :class="{ \'d-headline--md\': isHeading }"',
997
+ '>x</div>',
998
+ ].join('\n');
999
+ const out = run(input);
1000
+ // Marker must appear on its own line before <div, never inside the tag
1001
+ assert.ok(out.includes('<!-- dt-text-migrate: review dynamic class -->'), 'should emit marker');
1002
+ const markerIdx = out.indexOf('<!-- dt-text-migrate: review dynamic class -->');
1003
+ const divIdx = out.indexOf('<div');
1004
+ assert.ok(markerIdx < divIdx, 'marker must precede the opening <div');
1005
+ // The tag itself must remain structurally intact (comment not injected mid-tag)
1006
+ assert.ok(out.includes(' :class='), ':class line must be unchanged');
1007
+ assert.ok(out.includes(' v-if='), 'v-if line must be unchanged');
1008
+ });
1009
+ });
1010
+
990
1011
  describe('wrapper safety — override path (d-fw-*, d-fc-*, etc.) with component children', () => {
991
1012
  // Override path mirrors the composed-path safety: if the rewriteable tag
992
1013
  // wraps a component/block child, skip auto-conversion. Behavior here is a
@@ -1018,3 +1039,152 @@ describe('wrapper safety — override path (d-fw-*, d-fc-*, etc.) with component
1018
1039
  );
1019
1040
  });
1020
1041
  });
1042
+
1043
+ // ---------------------------------------------------------------------------
1044
+ // injectComponentImport — auto import insertion
1045
+ // ---------------------------------------------------------------------------
1046
+
1047
+ describe('injectComponentImport — <script setup>', () => {
1048
+ it('inserts import after the last existing import', () => {
1049
+ const content = [
1050
+ '<script setup>',
1051
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1052
+ '</script>',
1053
+ '<template><p>x</p></template>',
1054
+ ].join('\n');
1055
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1056
+ assert.ok(out.includes('import { DtText } from \'@dialpad/dialtone-vue\';'), 'import inserted');
1057
+ const stackIdx = out.indexOf('DtStack');
1058
+ const textIdx = out.indexOf('DtText');
1059
+ assert.ok(textIdx > stackIdx, 'DtText import comes after DtStack import');
1060
+ });
1061
+
1062
+ it('inserts import when no existing imports present', () => {
1063
+ const content = [
1064
+ '<script setup>',
1065
+ 'const x = 1;',
1066
+ '</script>',
1067
+ ].join('\n');
1068
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1069
+ assert.ok(out.includes('import { DtText } from \'@dialpad/dialtone-vue\';'), 'import inserted');
1070
+ });
1071
+
1072
+ it('returns null when component is already imported', () => {
1073
+ const content = [
1074
+ '<script setup>',
1075
+ 'import { DtText } from \'@dialpad/dialtone-vue\';',
1076
+ '</script>',
1077
+ ].join('\n');
1078
+ assert.equal(injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue'), null);
1079
+ });
1080
+
1081
+ it('does not add to components object (not needed for script setup)', () => {
1082
+ const content = [
1083
+ '<script setup>',
1084
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1085
+ '</script>',
1086
+ ].join('\n');
1087
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1088
+ assert.ok(!out.includes('components:'), 'no components object added for script setup');
1089
+ });
1090
+
1091
+ it('handles <script setup lang="ts">', () => {
1092
+ const content = [
1093
+ '<script setup lang="ts">',
1094
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1095
+ '</script>',
1096
+ ].join('\n');
1097
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1098
+ assert.ok(out !== null, 'should succeed');
1099
+ assert.ok(out.includes('import { DtText } from \'@dialpad/dialtone-vue\';'));
1100
+ });
1101
+ });
1102
+
1103
+ describe('injectComponentImport — Options API', () => {
1104
+ it('inserts import and adds to existing components object', () => {
1105
+ const content = [
1106
+ '<script>',
1107
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1108
+ 'export default {',
1109
+ ' components: {',
1110
+ ' DtStack,',
1111
+ ' },',
1112
+ '};',
1113
+ '</script>',
1114
+ ].join('\n');
1115
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1116
+ assert.ok(out.includes('import { DtText } from \'@dialpad/dialtone-vue\';'), 'import inserted');
1117
+ assert.ok(out.includes('DtText,'), 'DtText added to components');
1118
+ assert.ok(out.includes('DtStack,'), 'existing DtStack preserved');
1119
+ });
1120
+
1121
+ it('returns null when no components object exists', () => {
1122
+ const content = [
1123
+ '<script>',
1124
+ 'export default {',
1125
+ ' data () { return {}; },',
1126
+ '};',
1127
+ '</script>',
1128
+ ].join('\n');
1129
+ assert.equal(injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue'), null);
1130
+ });
1131
+
1132
+ it('returns null when no script block found', () => {
1133
+ const content = '<template><p>x</p></template>';
1134
+ assert.equal(injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue'), null);
1135
+ });
1136
+
1137
+ it('returns null when component is already imported', () => {
1138
+ const content = [
1139
+ '<script>',
1140
+ 'import { DtText } from \'@dialpad/dialtone-vue\';',
1141
+ 'export default { components: { DtText } };',
1142
+ '</script>',
1143
+ ].join('\n');
1144
+ assert.equal(injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue'), null);
1145
+ });
1146
+
1147
+ it('does not corrupt a helper object with components: { before export default', () => {
1148
+ const content = [
1149
+ '<script>',
1150
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1151
+ 'const editorConfig = { components: { toolbar: true } };',
1152
+ 'export default {',
1153
+ ' components: {',
1154
+ ' DtStack,',
1155
+ ' },',
1156
+ '};',
1157
+ '</script>',
1158
+ ].join('\n');
1159
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1160
+ assert.ok(out, 'should return updated content');
1161
+ assert.ok(out.includes('import { DtText } from \'@dialpad/dialtone-vue\';'), 'import inserted');
1162
+ // DtText must be added to export default components, not to the editorConfig object
1163
+ const editorConfigIdx = out.indexOf('editorConfig');
1164
+ const dtTextIdx = out.indexOf('DtText,');
1165
+ const exportDefaultIdx = out.indexOf('export default');
1166
+ assert.ok(dtTextIdx > exportDefaultIdx, 'DtText registered after export default');
1167
+ assert.ok(dtTextIdx > editorConfigIdx, 'DtText not inserted into editorConfig helper');
1168
+ assert.ok(!out.slice(0, exportDefaultIdx).includes('DtText,'), 'DtText not in pre-export-default scope');
1169
+ });
1170
+
1171
+ it('returns null when components: { appears only in a template binding, not in export default', () => {
1172
+ // Simulate a file where the script has no components option but the template
1173
+ // has a :config="{ components: { ... } }" binding — the template is masked
1174
+ // during injectComponentImport (which operates on the full SFC), so the
1175
+ // components: { in the template must not be matched.
1176
+ const content = [
1177
+ '<template>',
1178
+ ' <some-editor :config="{ components: { toolbar: MyBar } }" />',
1179
+ '</template>',
1180
+ '<script>',
1181
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1182
+ 'export default {',
1183
+ ' data () { return {}; },',
1184
+ '};',
1185
+ '</script>',
1186
+ ].join('\n');
1187
+ // No components: { in export default → should return null (can't auto-register)
1188
+ assert.equal(injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue'), null);
1189
+ });
1190
+ });
@@ -363,6 +363,7 @@ function parseArgs (args) {
363
363
  help: args.includes('--help'),
364
364
  dryRun: args.includes('--dry-run'),
365
365
  autoYes: args.includes('--yes'),
366
+ noImport: args.includes('--no-import'),
366
367
  healthCheck: args.includes('--health-check'),
367
368
  all: args.includes('--all'),
368
369
  cwd: cwdIndex !== -1 && args[cwdIndex + 1]
@@ -778,6 +779,7 @@ async function runStandaloneMigration (migration, opts) {
778
779
  const args = ['--cwd', opts.cwd];
779
780
  if (opts.dryRun) args.push('--dry-run');
780
781
  if (opts.autoYes) args.push('--yes');
782
+ if (opts.noImport) args.push('--no-import');
781
783
 
782
784
  return new Promise((resolve, reject) => {
783
785
  const child = spawn(process.execPath, [scriptPath, ...args], {
@@ -1028,15 +1028,28 @@ async function processFile(filePath, options) {
1028
1028
  newContent = newContent.slice(0, r.start) + r.replacement + newContent.slice(r.end);
1029
1029
  }
1030
1030
 
1031
- await fs.writeFile(filePath, newContent, 'utf-8');
1032
- console.log(log.green(` ✓ Saved ${changes} change(s)`));
1031
+ // Auto-inject DtStack import before writing; fall back to manual instructions if needed.
1032
+ // Skipped entirely when --no-import is set (e.g. DtStack is globally registered).
1033
+ const importCheck = options.noImport ? null : detectMissingStackImport(newContent, changes > 0);
1034
+ let finalContent = newContent;
1035
+ if (importCheck?.needsImport) {
1036
+ const injected = injectComponentImport(newContent, 'DtStack', importCheck.suggestedPath);
1037
+ if (injected) finalContent = injected;
1038
+ }
1039
+
1040
+ await fs.writeFile(filePath, finalContent, 'utf-8');
1033
1041
 
1034
- // Check if file needs DtStack import
1035
- const importCheck = detectMissingStackImport(newContent, changes > 0);
1036
1042
  if (importCheck?.needsImport) {
1043
+ if (finalContent !== newContent) {
1044
+ console.log(log.green(` ✓ Saved ${changes} change(s) + added DtStack import`));
1045
+ return { changes, skipped, needsImport: false };
1046
+ }
1047
+ console.log(log.green(` ✓ Saved ${changes} change(s)`));
1037
1048
  printImportInstructions(filePath, importCheck);
1038
1049
  return { changes, skipped, needsImport: true };
1039
1050
  }
1051
+
1052
+ console.log(log.green(` ✓ Saved ${changes} change(s)`));
1040
1053
  }
1041
1054
 
1042
1055
  return { changes, skipped, needsImport: false };
@@ -1133,6 +1146,70 @@ function detectImportPattern(content) {
1133
1146
  return '@/components/stack';
1134
1147
  }
1135
1148
 
1149
+ /**
1150
+ * Attempt to auto-insert a component import (and Options API registration) into a Vue SFC.
1151
+ * Returns updated content on success, or null when the insertion can't be made safely
1152
+ * (caller should fall back to printing manual instructions).
1153
+ *
1154
+ * Handles:
1155
+ * - <script setup>: inserts import after the last existing import; no components
1156
+ * registration needed (auto-registered when imported in setup context).
1157
+ * - Options API with existing `components: {}`: inserts import + adds to the object.
1158
+ * - Options API without a components object: returns null (manual step required).
1159
+ */
1160
+ function injectComponentImport(content, componentName, importPath) {
1161
+ if (new RegExp(`import\\s+(?:\\{[^}]*\\b${componentName}\\b[^}]*\\}|${componentName})\\s+from`).test(content)) {
1162
+ return null; // already imported
1163
+ }
1164
+
1165
+ const isScriptSetup = /<script\b[^>]*\bsetup\b/.test(content);
1166
+ const scriptBlockRe = isScriptSetup
1167
+ ? /<script\b[^>]*\bsetup\b[^>]*>([\s\S]*?)<\/script>/
1168
+ : /<script\b(?![^>]*\bsetup\b)[^>]*>([\s\S]*?)<\/script>/;
1169
+ const scriptMatch = scriptBlockRe.exec(content);
1170
+ if (!scriptMatch) return null;
1171
+
1172
+ const scriptInnerStart = scriptMatch.index + scriptMatch[0].indexOf('>') + 1;
1173
+ const scriptInner = scriptMatch[1];
1174
+
1175
+ const importLineRe = /^import\s.+from\s+['"][^'"]+['"]\s*;?/gm;
1176
+ let lastImportMatch = null;
1177
+ let m;
1178
+ while ((m = importLineRe.exec(scriptInner)) !== null) lastImportMatch = m;
1179
+
1180
+ const insertOffset = lastImportMatch
1181
+ ? scriptInnerStart + lastImportMatch.index + lastImportMatch[0].length
1182
+ : scriptInnerStart;
1183
+
1184
+ const importLine = `\nimport { ${componentName} } from '${importPath}';`;
1185
+ let out = content.slice(0, insertOffset) + importLine + content.slice(insertOffset);
1186
+
1187
+ if (isScriptSetup) return out; // import alone is sufficient for <script setup>
1188
+
1189
+ // Options API: also register in components: {}
1190
+ // Re-exec against the updated content to get current positions, then restrict
1191
+ // the components: { search to within the script block and after export default
1192
+ // to avoid matching template bindings or helper objects that appear earlier.
1193
+ const scriptMatchUpdated = scriptBlockRe.exec(out);
1194
+ if (!scriptMatchUpdated) return null;
1195
+ const scriptBodyStart = scriptMatchUpdated.index + scriptMatchUpdated[0].indexOf('>') + 1;
1196
+ const scriptBodyText = scriptMatchUpdated[1];
1197
+ const exportDefaultMatch = /\bexport\s+default\b/.exec(scriptBodyText);
1198
+ if (!exportDefaultMatch) return null;
1199
+ const searchFrom = scriptBodyStart + exportDefaultMatch.index;
1200
+ const compMatchInSlice = /components\s*:\s*\{/.exec(out.slice(searchFrom));
1201
+ if (!compMatchInSlice) return null; // no components object — can't safely auto-register
1202
+ const compAbsIndex = searchFrom + compMatchInSlice.index;
1203
+
1204
+ const lineStart = out.lastIndexOf('\n', compAbsIndex) + 1;
1205
+ const compIndent = out.slice(lineStart, compAbsIndex).match(/^[ \t]*/)[0];
1206
+ const memberIndent = compIndent + ' ';
1207
+ const insertAt = compAbsIndex + compMatchInSlice[0].length;
1208
+ out = out.slice(0, insertAt) + `\n${memberIndent}${componentName},` + out.slice(insertAt);
1209
+
1210
+ return out;
1211
+ }
1212
+
1136
1213
  /**
1137
1214
  * Print instructions for adding DtStack import and registration
1138
1215
  * @param {string} filePath - Path to the file
@@ -1179,6 +1256,7 @@ function parseArgs() {
1179
1256
  files: [], // Explicit file list via --file flag
1180
1257
  showOutline: false, // Add migration marker for visual debugging
1181
1258
  removeOutline: false, // Remove migration markers (cleanup mode)
1259
+ noImport: false, // Skip import injection (for apps that globally register DtStack)
1182
1260
  };
1183
1261
 
1184
1262
  for (let i = 0; i < args.length; i++) {
@@ -1206,11 +1284,12 @@ Options:
1206
1284
  --yes, -y Apply all changes without prompting
1207
1285
  --show-outline Add data-migrate-outline attribute for visual debugging
1208
1286
  --remove-outline Remove data-migrate-outline attributes after review
1287
+ --no-import Skip import injection (use when DtStack is globally registered)
1209
1288
  --help, -h Show help
1210
1289
 
1211
1290
  Post-Migration Steps:
1212
1291
  1. Review template changes with data-migrate-outline markers
1213
- 2. Add DtStack imports as instructed by the script
1292
+ 2. Add DtStack imports as instructed by the script (skipped with --no-import)
1214
1293
  3. Test your application
1215
1294
  4. Run with --remove-outline to clean up markers
1216
1295
 
@@ -1252,6 +1331,8 @@ Examples:
1252
1331
  options.showOutline = true;
1253
1332
  } else if (arg === '--remove-outline') {
1254
1333
  options.removeOutline = true;
1334
+ } else if (arg === '--no-import') {
1335
+ options.noImport = true;
1255
1336
  } else if (arg === '--file' && args[i + 1]) {
1256
1337
  const filePath = args[++i];
1257
1338
  options.files.push(filePath);
@@ -1348,6 +1429,7 @@ async function main() {
1348
1429
  yes: options.yes,
1349
1430
  showOutline: options.showOutline,
1350
1431
  validate: options.validate,
1432
+ noImport: options.noImport,
1351
1433
  });
1352
1434
  }
1353
1435
 
@@ -1002,17 +1002,31 @@ function flagDynamicClasses (content) {
1002
1002
  const re = new RegExp(DYNAMIC_CLASS_PATTERN);
1003
1003
  if (!re.test(content)) return content;
1004
1004
 
1005
- const lines = content.split('\n');
1006
- const out = lines.map(line => {
1007
- if (!/:class=|v-bind:class=/.test(line)) return line;
1008
- if (!new RegExp(DYNAMIC_CLASS_PATTERN).test(line)) return line;
1009
- // Preserve the line's indentation; put the marker on its own line above.
1010
- const indentMatch = line.match(/^[ \t]*/);
1011
- const indent = indentMatch ? indentMatch[0] : '';
1012
- return `${indent}<!-- dt-text-migrate: review dynamic class -->\n${line}`;
1013
- });
1005
+ // Walk whole opening tags (quote-aware) so the marker is never injected between
1006
+ // attributes of a multi-line element. The old line-by-line approach would insert
1007
+ // the comment before the :class= line, which lands it between attributes when the
1008
+ // opening tag spans multiple lines.
1009
+ const tagRe = new RegExp(`<[a-zA-Z][\\w-]*(${ATTR_BODY})>`, 'g');
1010
+ const insertions = [];
1011
+ let m;
1014
1012
 
1015
- return out.join('\n');
1013
+ while ((m = tagRe.exec(content)) !== null) {
1014
+ if (!new RegExp(DYNAMIC_CLASS_PATTERN).test(m[0])) continue;
1015
+ const before = content.slice(Math.max(0, m.index - 80), m.index);
1016
+ if (/<!--\s*dt-text-migrate:\s*review dynamic class\s*-->/.test(before)) continue;
1017
+ insertions.push(m.index);
1018
+ }
1019
+
1020
+ if (insertions.length === 0) return content;
1021
+
1022
+ let out = content;
1023
+ for (const idx of insertions.reverse()) {
1024
+ const lineStart = out.lastIndexOf('\n', idx - 1) + 1;
1025
+ const indent = out.slice(lineStart, idx).match(/^[ \t]*/)[0];
1026
+ const marker = `${indent}<!-- dt-text-migrate: review dynamic class -->\n`;
1027
+ out = out.slice(0, lineStart) + marker + out.slice(lineStart);
1028
+ }
1029
+ return out;
1016
1030
  }
1017
1031
 
1018
1032
  // Find composed typography classes on tags outside the rewrite scope
@@ -1245,6 +1259,70 @@ function detectMissingDtTextImport (content, usesText) {
1245
1259
  };
1246
1260
  }
1247
1261
 
1262
+ /**
1263
+ * Attempt to auto-insert a component import (and Options API registration) into a Vue SFC.
1264
+ * Returns updated content on success, or null when the insertion can't be made safely
1265
+ * (caller should fall back to printing manual instructions).
1266
+ *
1267
+ * Handles:
1268
+ * - <script setup>: inserts import after the last existing import; no components
1269
+ * registration needed (auto-registered when imported in setup context).
1270
+ * - Options API with existing `components: {}`: inserts import + adds to the object.
1271
+ * - Options API without a components object: returns null (manual step required).
1272
+ */
1273
+ export function injectComponentImport (content, componentName, importPath) {
1274
+ if (new RegExp(`import\\s+(?:\\{[^}]*\\b${componentName}\\b[^}]*\\}|${componentName})\\s+from`).test(content)) {
1275
+ return null; // already imported
1276
+ }
1277
+
1278
+ const isScriptSetup = /<script\b[^>]*\bsetup\b/.test(content);
1279
+ const scriptBlockRe = isScriptSetup
1280
+ ? /<script\b[^>]*\bsetup\b[^>]*>([\s\S]*?)<\/script>/
1281
+ : /<script\b(?![^>]*\bsetup\b)[^>]*>([\s\S]*?)<\/script>/;
1282
+ const scriptMatch = scriptBlockRe.exec(content);
1283
+ if (!scriptMatch) return null;
1284
+
1285
+ const scriptInnerStart = scriptMatch.index + scriptMatch[0].indexOf('>') + 1;
1286
+ const scriptInner = scriptMatch[1];
1287
+
1288
+ const importLineRe = /^import\s.+from\s+['"][^'"]+['"]\s*;?/gm;
1289
+ let lastImportMatch = null;
1290
+ let m;
1291
+ while ((m = importLineRe.exec(scriptInner)) !== null) lastImportMatch = m;
1292
+
1293
+ const insertOffset = lastImportMatch
1294
+ ? scriptInnerStart + lastImportMatch.index + lastImportMatch[0].length
1295
+ : scriptInnerStart;
1296
+
1297
+ const importLine = `\nimport { ${componentName} } from '${importPath}';`;
1298
+ let out = content.slice(0, insertOffset) + importLine + content.slice(insertOffset);
1299
+
1300
+ if (isScriptSetup) return out; // import alone is sufficient for <script setup>
1301
+
1302
+ // Options API: also register in components: {}
1303
+ // Re-exec against the updated content to get current positions, then restrict
1304
+ // the components: { search to within the script block and after export default
1305
+ // to avoid matching template bindings or helper objects that appear earlier.
1306
+ const scriptMatchUpdated = scriptBlockRe.exec(out);
1307
+ if (!scriptMatchUpdated) return null;
1308
+ const scriptBodyStart = scriptMatchUpdated.index + scriptMatchUpdated[0].indexOf('>') + 1;
1309
+ const scriptBodyText = scriptMatchUpdated[1];
1310
+ const exportDefaultMatch = /\bexport\s+default\b/.exec(scriptBodyText);
1311
+ if (!exportDefaultMatch) return null;
1312
+ const searchFrom = scriptBodyStart + exportDefaultMatch.index;
1313
+ const compMatchInSlice = /components\s*:\s*\{/.exec(out.slice(searchFrom));
1314
+ if (!compMatchInSlice) return null; // no components object — can't safely auto-register
1315
+ const compAbsIndex = searchFrom + compMatchInSlice.index;
1316
+
1317
+ const lineStart = out.lastIndexOf('\n', compAbsIndex) + 1;
1318
+ const compIndent = out.slice(lineStart, compAbsIndex).match(/^[ \t]*/)[0];
1319
+ const memberIndent = compIndent + ' ';
1320
+ const insertAt = compAbsIndex + compMatchInSlice[0].length;
1321
+ out = out.slice(0, insertAt) + `\n${memberIndent}${componentName},` + out.slice(insertAt);
1322
+
1323
+ return out;
1324
+ }
1325
+
1248
1326
  function printImportInstructions (filePath, importCheck) {
1249
1327
  console.log(log.yellow('\n⚠️ ACTION REQUIRED: Add DtText import and registration'));
1250
1328
  log.cyan(` File: ${filePath}`);
@@ -1450,9 +1528,6 @@ async function processFile (filePath, options) {
1450
1528
 
1451
1529
  if (!shouldApply) return { changes: 0, needsImport: false };
1452
1530
 
1453
- await fs.writeFile(filePath, transformed, 'utf-8');
1454
- console.log(log.green(' ✓ Saved'));
1455
-
1456
1531
  // Only warn about missing DtText import when we actually INSERTED new <dt-text> elements
1457
1532
  // — review-marker-only changes (eyebrow/d-code--sm/d-fs-* flags) don't require an import.
1458
1533
  // Use a count delta (not boolean presence) so partial migrations on a file that already
@@ -1460,14 +1535,34 @@ async function processFile (filePath, options) {
1460
1535
  const beforeCount = (content.match(/<dt-text\b/g) || []).length;
1461
1536
  const afterCount = (transformed.match(/<dt-text\b/g) || []).length;
1462
1537
  const addedDtText = afterCount > beforeCount;
1463
- const importCheck = detectMissingDtTextImport(transformed, addedDtText);
1464
- if (importCheck?.needsImport) printImportInstructions(filePath, importCheck);
1538
+ // Auto-inject DtText import before writing; fall back to manual instructions if needed.
1539
+ // Skipped entirely when --no-import is set (e.g. DtText is globally registered).
1540
+ const importCheck = options.noImport ? null : detectMissingDtTextImport(transformed, addedDtText);
1541
+
1542
+ let finalContent = transformed;
1543
+ if (importCheck?.needsImport) {
1544
+ const injected = injectComponentImport(transformed, 'DtText', importCheck.suggestedPath);
1545
+ if (injected) finalContent = injected;
1546
+ }
1547
+
1548
+ await fs.writeFile(filePath, finalContent, 'utf-8');
1549
+
1550
+ if (importCheck?.needsImport) {
1551
+ if (finalContent !== transformed) {
1552
+ console.log(log.green(' ✓ Saved + added DtText import'));
1553
+ } else {
1554
+ console.log(log.green(' ✓ Saved'));
1555
+ printImportInstructions(filePath, importCheck);
1556
+ }
1557
+ } else {
1558
+ console.log(log.green(' ✓ Saved'));
1559
+ }
1465
1560
 
1466
1561
  if (notes.length > 0) {
1467
1562
  for (const note of notes) log.gray(` ℹ ${note}`);
1468
1563
  }
1469
1564
 
1470
- return { changes: 1, needsImport: !!importCheck?.needsImport };
1565
+ return { changes: 1, needsImport: importCheck?.needsImport && finalContent === transformed };
1471
1566
  }
1472
1567
 
1473
1568
  //------------------------------------------------------------------------------
@@ -1484,6 +1579,7 @@ function parseArgs () {
1484
1579
  files: [],
1485
1580
  removeMarkers: false,
1486
1581
  validate: false,
1582
+ noImport: false, // Skip import injection (for apps that globally register DtText)
1487
1583
  };
1488
1584
 
1489
1585
  for (let i = 0; i < args.length; i++) {
@@ -1503,6 +1599,7 @@ Options:
1503
1599
  --remove-markers Strip all <!-- dt-text-migrate: review ... --> comments
1504
1600
  --validate Read-only mode: scan existing <dt-text> for prop bugs
1505
1601
  (object syntax, invalid values, mixed CSS classes)
1602
+ --no-import Skip import injection (use when DtText is globally registered)
1506
1603
  --help, -h Show help
1507
1604
 
1508
1605
  Examples:
@@ -1512,7 +1609,7 @@ Examples:
1512
1609
  npx dialtone-migrate-typography --remove-markers --cwd ./src
1513
1610
 
1514
1611
  Post-Migration Steps:
1515
- 1. Add DtText imports as instructed by the script
1612
+ 1. Add DtText imports as instructed by the script (skipped with --no-import)
1516
1613
  2. Review files marked with <!-- dt-text-migrate: review --> comments
1517
1614
  3. Run with --remove-markers to clean up all markers after manual review
1518
1615
  `);
@@ -1531,6 +1628,8 @@ Post-Migration Steps:
1531
1628
  options.removeMarkers = true;
1532
1629
  } else if (arg === '--validate') {
1533
1630
  options.validate = true;
1631
+ } else if (arg === '--no-import') {
1632
+ options.noImport = true;
1534
1633
  }
1535
1634
  }
1536
1635
 
@@ -12,6 +12,7 @@ import {
12
12
  detectImportPathFor,
13
13
  removeMarkersForTest,
14
14
  validateDtTextProps,
15
+ injectComponentImport,
15
16
  } from './index.mjs';
16
17
 
17
18
  function run (input) {
@@ -987,6 +988,26 @@ describe('wrapper safety — positive cases still convert (no false positives)',
987
988
  });
988
989
  });
989
990
 
991
+ describe('dynamic :class flagging — multi-line element', () => {
992
+ it('marker goes before the opening < of a multi-line element, not between attributes', () => {
993
+ const input = [
994
+ '<div',
995
+ ' v-if="condition"',
996
+ ' :class="{ \'d-headline--md\': isHeading }"',
997
+ '>x</div>',
998
+ ].join('\n');
999
+ const out = run(input);
1000
+ // Marker must appear on its own line before <div, never inside the tag
1001
+ assert.ok(out.includes('<!-- dt-text-migrate: review dynamic class -->'), 'should emit marker');
1002
+ const markerIdx = out.indexOf('<!-- dt-text-migrate: review dynamic class -->');
1003
+ const divIdx = out.indexOf('<div');
1004
+ assert.ok(markerIdx < divIdx, 'marker must precede the opening <div');
1005
+ // The tag itself must remain structurally intact (comment not injected mid-tag)
1006
+ assert.ok(out.includes(' :class='), ':class line must be unchanged');
1007
+ assert.ok(out.includes(' v-if='), 'v-if line must be unchanged');
1008
+ });
1009
+ });
1010
+
990
1011
  describe('wrapper safety — override path (d-fw-*, d-fc-*, etc.) with component children', () => {
991
1012
  // Override path mirrors the composed-path safety: if the rewriteable tag
992
1013
  // wraps a component/block child, skip auto-conversion. Behavior here is a
@@ -1018,3 +1039,152 @@ describe('wrapper safety — override path (d-fw-*, d-fc-*, etc.) with component
1018
1039
  );
1019
1040
  });
1020
1041
  });
1042
+
1043
+ // ---------------------------------------------------------------------------
1044
+ // injectComponentImport — auto import insertion
1045
+ // ---------------------------------------------------------------------------
1046
+
1047
+ describe('injectComponentImport — <script setup>', () => {
1048
+ it('inserts import after the last existing import', () => {
1049
+ const content = [
1050
+ '<script setup>',
1051
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1052
+ '</script>',
1053
+ '<template><p>x</p></template>',
1054
+ ].join('\n');
1055
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1056
+ assert.ok(out.includes('import { DtText } from \'@dialpad/dialtone-vue\';'), 'import inserted');
1057
+ const stackIdx = out.indexOf('DtStack');
1058
+ const textIdx = out.indexOf('DtText');
1059
+ assert.ok(textIdx > stackIdx, 'DtText import comes after DtStack import');
1060
+ });
1061
+
1062
+ it('inserts import when no existing imports present', () => {
1063
+ const content = [
1064
+ '<script setup>',
1065
+ 'const x = 1;',
1066
+ '</script>',
1067
+ ].join('\n');
1068
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1069
+ assert.ok(out.includes('import { DtText } from \'@dialpad/dialtone-vue\';'), 'import inserted');
1070
+ });
1071
+
1072
+ it('returns null when component is already imported', () => {
1073
+ const content = [
1074
+ '<script setup>',
1075
+ 'import { DtText } from \'@dialpad/dialtone-vue\';',
1076
+ '</script>',
1077
+ ].join('\n');
1078
+ assert.equal(injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue'), null);
1079
+ });
1080
+
1081
+ it('does not add to components object (not needed for script setup)', () => {
1082
+ const content = [
1083
+ '<script setup>',
1084
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1085
+ '</script>',
1086
+ ].join('\n');
1087
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1088
+ assert.ok(!out.includes('components:'), 'no components object added for script setup');
1089
+ });
1090
+
1091
+ it('handles <script setup lang="ts">', () => {
1092
+ const content = [
1093
+ '<script setup lang="ts">',
1094
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1095
+ '</script>',
1096
+ ].join('\n');
1097
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1098
+ assert.ok(out !== null, 'should succeed');
1099
+ assert.ok(out.includes('import { DtText } from \'@dialpad/dialtone-vue\';'));
1100
+ });
1101
+ });
1102
+
1103
+ describe('injectComponentImport — Options API', () => {
1104
+ it('inserts import and adds to existing components object', () => {
1105
+ const content = [
1106
+ '<script>',
1107
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1108
+ 'export default {',
1109
+ ' components: {',
1110
+ ' DtStack,',
1111
+ ' },',
1112
+ '};',
1113
+ '</script>',
1114
+ ].join('\n');
1115
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1116
+ assert.ok(out.includes('import { DtText } from \'@dialpad/dialtone-vue\';'), 'import inserted');
1117
+ assert.ok(out.includes('DtText,'), 'DtText added to components');
1118
+ assert.ok(out.includes('DtStack,'), 'existing DtStack preserved');
1119
+ });
1120
+
1121
+ it('returns null when no components object exists', () => {
1122
+ const content = [
1123
+ '<script>',
1124
+ 'export default {',
1125
+ ' data () { return {}; },',
1126
+ '};',
1127
+ '</script>',
1128
+ ].join('\n');
1129
+ assert.equal(injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue'), null);
1130
+ });
1131
+
1132
+ it('returns null when no script block found', () => {
1133
+ const content = '<template><p>x</p></template>';
1134
+ assert.equal(injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue'), null);
1135
+ });
1136
+
1137
+ it('returns null when component is already imported', () => {
1138
+ const content = [
1139
+ '<script>',
1140
+ 'import { DtText } from \'@dialpad/dialtone-vue\';',
1141
+ 'export default { components: { DtText } };',
1142
+ '</script>',
1143
+ ].join('\n');
1144
+ assert.equal(injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue'), null);
1145
+ });
1146
+
1147
+ it('does not corrupt a helper object with components: { before export default', () => {
1148
+ const content = [
1149
+ '<script>',
1150
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1151
+ 'const editorConfig = { components: { toolbar: true } };',
1152
+ 'export default {',
1153
+ ' components: {',
1154
+ ' DtStack,',
1155
+ ' },',
1156
+ '};',
1157
+ '</script>',
1158
+ ].join('\n');
1159
+ const out = injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue');
1160
+ assert.ok(out, 'should return updated content');
1161
+ assert.ok(out.includes('import { DtText } from \'@dialpad/dialtone-vue\';'), 'import inserted');
1162
+ // DtText must be added to export default components, not to the editorConfig object
1163
+ const editorConfigIdx = out.indexOf('editorConfig');
1164
+ const dtTextIdx = out.indexOf('DtText,');
1165
+ const exportDefaultIdx = out.indexOf('export default');
1166
+ assert.ok(dtTextIdx > exportDefaultIdx, 'DtText registered after export default');
1167
+ assert.ok(dtTextIdx > editorConfigIdx, 'DtText not inserted into editorConfig helper');
1168
+ assert.ok(!out.slice(0, exportDefaultIdx).includes('DtText,'), 'DtText not in pre-export-default scope');
1169
+ });
1170
+
1171
+ it('returns null when components: { appears only in a template binding, not in export default', () => {
1172
+ // Simulate a file where the script has no components option but the template
1173
+ // has a :config="{ components: { ... } }" binding — the template is masked
1174
+ // during injectComponentImport (which operates on the full SFC), so the
1175
+ // components: { in the template must not be matched.
1176
+ const content = [
1177
+ '<template>',
1178
+ ' <some-editor :config="{ components: { toolbar: MyBar } }" />',
1179
+ '</template>',
1180
+ '<script>',
1181
+ 'import { DtStack } from \'@dialpad/dialtone-vue\';',
1182
+ 'export default {',
1183
+ ' data () { return {}; },',
1184
+ '};',
1185
+ '</script>',
1186
+ ].join('\n');
1187
+ // No components: { in export default → should return null (can't auto-register)
1188
+ assert.equal(injectComponentImport(content, 'DtText', '@dialpad/dialtone-vue'), null);
1189
+ });
1190
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dialpad/dialtone-css",
3
- "version": "8.81.0-next.1",
3
+ "version": "8.81.0-next.2",
4
4
  "description": "Dialpad's design system",
5
5
  "keywords": [
6
6
  "Dialpad",
@@ -84,7 +84,7 @@
84
84
  "less": "^4.2.0",
85
85
  "oslllo-svg-fixer": "^2.2.0",
86
86
  "through2": "^4.0.2",
87
- "@dialpad/dialtone-tokens": "1.48.0-next.19",
87
+ "@dialpad/dialtone-tokens": "1.48.0-next.20",
88
88
  "@dialpad/postcss-responsive-variations": "1.2.4-next.1"
89
89
  },
90
90
  "peerDependencies": {