tw-migrate 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -47,6 +47,7 @@ node /path/to/tw-migrate/src/bin.ts
47
47
  - Vue `<style module>` blocks migrate against direct `:class="$style.x"` member bindings; any other `$style`/`useCssModule` appearance or a named module retains the block
48
48
  - direct CSS Module members, static template literals, and static expression literals
49
49
  - global `className` and `id` literals
50
+ - React/Next.js `className` logical (`&&`, `||`, `??`) and conditional (`?:`) expressions: string and direct CSS Module member result leaves migrate without evaluating their conditions, `null`/`undefined`/`false`/empty-string results are warning-free, and unsupported result leaves retain with a warning while supported siblings still migrate
50
51
  - multi-compound CSS Module selectors whose element relationships are proven from the JSX graph
51
52
  - common state pseudo-classes, global arbitrary descendant variants, and conditional at-rules (`@media`, `@supports`, `@container`, `@starting-style`)
52
53
  - the tier-1 property mapping families with shorthand/longhand normalization
@@ -68,7 +69,7 @@ Everything outside this subset is retained and reported with one of the warning
68
69
  | `computed-css-module-reference` | A computed CSS Module access cannot be verified, so the module is retained. |
69
70
  | `cross-package-stylesheet-link` | A linked stylesheet is owned by another package, so it is not analyzed outside workspace mode. |
70
71
  | `css-module-composes` | The rule uses or is targeted by `composes`, so it is retained. |
71
- | `dynamic-class-name` | A `className` value is dynamic, so the element cannot be migrated. |
72
+ | `dynamic-class-name` | A `className` value or expression result is dynamic, so that class site cannot be migrated. |
72
73
  | `dynamic-html-attribute` | An HTML attribute is not a safely writable quoted literal, so the element cannot be migrated. |
73
74
  | `dynamic-template-class` | A dynamic class binding makes a Vue template's class set unprovable, so its scoped rules are retained. |
74
75
  | `existing-tailwind-conflict` | A generated utility may conflict with a Tailwind class already on the element. |
package/dist/bin.js CHANGED
@@ -4,7 +4,7 @@ import { styleText } from "node:util";
4
4
  //#region src/bin.ts
5
5
  const { version } = {
6
6
  name: "tw-migrate",
7
- version: "0.3.0",
7
+ version: "0.4.0",
8
8
  description: "Preview and migrate React, Vue, and HTML CSS to Tailwind utilities",
9
9
  keywords: [
10
10
  "codemod",
@@ -33,7 +33,7 @@ const { version } = {
33
33
  scripts: {
34
34
  "build": "vp pack && napi build --manifest-path crates/tw_migrate/Cargo.toml --package-json-path package.json --output-dir . --platform --release --no-js --dts binding.d.ts",
35
35
  "build:debug": "napi build --manifest-path crates/tw_migrate/Cargo.toml --package-json-path package.json --output-dir . --platform --no-js --dts binding.d.ts",
36
- "artifacts": "napi artifacts --output-dir . --npm-dir npm",
36
+ "artifacts": "node -e \"const fs=require('node:fs'); const files=fs.readdirSync('.').filter((file)=>file.startsWith('tw-migrate.')&&file.endsWith('.node')); if(files.length===0) throw new Error('no tw-migrate.*.node artifact in the package root; run build first'); for(const file of files) fs.copyFileSync(file, 'npm/'+file.slice('tw-migrate.'.length, -'.node'.length)+'/'+file)\"",
37
37
  "test": "cargo test && pnpm build:debug && vp test",
38
38
  "test:ecosystem": "node ecosystem-ci/run.ts",
39
39
  "typecheck": "tsc --noEmit",
@@ -41,7 +41,7 @@ const { version } = {
41
41
  "test:snapshots": "pnpm snapshots:prepare && cargo test -p tw-migrate-snapshots",
42
42
  "prepare": "vp config"
43
43
  },
44
- dependencies: { "parse5": "7.3.0" },
44
+ dependencies: { "parse5": "8.0.1" },
45
45
  devDependencies: {
46
46
  "@napi-rs/cli": "catalog:",
47
47
  "@types/node": "catalog:",
@@ -74,8 +74,8 @@ const { version } = {
74
74
  "x86_64-pc-windows-msvc"
75
75
  ]
76
76
  },
77
- engines: { "node": ">=22.18.0" },
78
- packageManager: "pnpm@11.15.1"
77
+ engines: { "node": ">=22.23.2" },
78
+ packageManager: "pnpm@11.20.0"
79
79
  };
80
80
  const usage = "Usage: tw-migrate [style-file] [options]";
81
81
  const help = `${usage}
@@ -134,7 +134,10 @@ async function main() {
134
134
  workspaces
135
135
  });
136
136
  if (report.diff) process.stdout.write(report.diff);
137
- for (const warning of report.warnings) console.warn(styleText("yellow", `warning[${warning.code}] ${warning.file}:${warning.start}-${warning.end} ${warning.message}`, { stream: process.stderr }));
137
+ for (const warning of report.warnings) {
138
+ const location = warning.line ? `${warning.file}:${warning.line}:${warning.column}-${warning.endLine}:${warning.endColumn}` : warning.file;
139
+ console.warn(styleText("yellow", `warning[${warning.code}] ${location} ${warning.message}`, { stream: process.stderr }));
140
+ }
138
141
  for (const failure of report.failures) console.warn(styleText("yellow", `skipped[${failure.package}] ${failure.message}`, { stream: process.stderr }));
139
142
  console.log(`${write ? "Applied" : "Previewed"} ${report.changedFiles.length} file(s); ${report.convertedRules} rule(s) converted, ${report.retainedRules} retained.`);
140
143
  }
package/dist/index.d.ts CHANGED
@@ -13,6 +13,11 @@ interface MigrationWarning {
13
13
  /** Byte offsets into the authored file, or (0, 0) when no unique mapping exists. */
14
14
  start: number;
15
15
  end: number;
16
+ /** 1-based UTF-16 positions, omitted when no unique authored mapping exists. */
17
+ line?: number;
18
+ column?: number;
19
+ endLine?: number;
20
+ endColumn?: number;
16
21
  message: string;
17
22
  }
18
23
  interface RuleReport {
package/dist/index.js CHANGED
@@ -19,14 +19,13 @@ function unifiedDiff(path, before, after) {
19
19
  }
20
20
  //#endregion
21
21
  //#region src/parser/html.ts
22
- const RECOVERABLE_PARSE_ERRORS = /* @__PURE__ */ new Set(["missing-doctype"]);
23
22
  const TEMPLATE_MARKERS = /\{\{|\}\}|\$\{|<%|%>|<#|#>|\[%|%\]/;
24
23
  function parseHtmlSource(path, source) {
25
24
  const errors = [];
26
25
  const document = parse(source, {
27
26
  sourceCodeLocationInfo: true,
28
27
  onParseError(error) {
29
- if (!RECOVERABLE_PARSE_ERRORS.has(error.code)) errors.push(error);
28
+ if (error.code !== "missing-doctype") errors.push(error);
30
29
  }
31
30
  });
32
31
  if (errors.length > 0) throw new Error(`Failed to parse ${path}: ${errors.map((error) => error.code).join(", ")}`);
@@ -78,13 +77,16 @@ function parseHtmlSource(path, source) {
78
77
  });
79
78
  else if (classAttribute || idAttribute) {
80
79
  if (!classAttribute && idAttribute) {
81
- const insertion = classInsertionOffset$1(source, node.sourceCodeLocation?.startTag);
82
- if (insertion !== void 0) classAttribute = {
83
- value: "",
84
- start: insertion,
85
- end: insertion,
86
- synthetic: true
87
- };
80
+ const startTag = node.sourceCodeLocation?.startTag;
81
+ if (startTag && source[startTag.endOffset - 1] === ">") {
82
+ const insertion = classInsertionOffset(source, startTag.startOffset, startTag.endOffset - 1);
83
+ classAttribute = {
84
+ value: "",
85
+ start: insertion,
86
+ end: insertion,
87
+ synthetic: true
88
+ };
89
+ }
88
90
  }
89
91
  elements.push({
90
92
  classAttribute,
@@ -159,12 +161,10 @@ function toByteOffsets(source, parsed) {
159
161
  dynamicAttributes: parsed.dynamicAttributes.map((value) => attribute(value))
160
162
  };
161
163
  }
162
- function classInsertionOffset$1(source, startTag) {
163
- if (!startTag) return void 0;
164
- let offset = startTag.endOffset - 1;
165
- if (source[offset] !== ">") return void 0;
166
- while (offset > startTag.startOffset && /\s/.test(source[offset - 1] ?? "")) offset -= 1;
164
+ function classInsertionOffset(source, floor, tagEnd) {
165
+ let offset = tagEnd;
167
166
  if (source[offset - 1] === "/") offset -= 1;
167
+ while (offset > floor && /\s/.test(source[offset - 1] ?? "")) offset -= 1;
168
168
  return offset;
169
169
  }
170
170
  function stylesheetRel(value = "") {
@@ -240,11 +240,13 @@ async function loadProjectModule(packageRoot, name, errorMessage) {
240
240
  const imported = await import(pathToFileURL(modulePath).href);
241
241
  return imported.default ?? imported;
242
242
  }
243
+ const missingCompilerMessage = (name) => `${name} must be installed in the target project.`;
244
+ const MISSING_STYLE_COMPILER_MESSAGES = /* @__PURE__ */ new Set([missingCompilerMessage("Sass"), missingCompilerMessage("Less")]);
243
245
  function loadProjectSass(packageRoot) {
244
- return loadProjectModule(packageRoot, "sass", "Sass must be installed in the target project.");
246
+ return loadProjectModule(packageRoot, "sass", missingCompilerMessage("Sass"));
245
247
  }
246
248
  function loadProjectLess(packageRoot) {
247
- return loadProjectModule(packageRoot, "less", "Less must be installed in the target project.");
249
+ return loadProjectModule(packageRoot, "less", missingCompilerMessage("Less"));
248
250
  }
249
251
  async function compileStyleEntry(compilers, packageRoot, entryPath, source, options = {}) {
250
252
  if (isSassPath(entryPath)) return compileSassEntry(compilers.sass ??= await loadProjectSass(packageRoot), entryPath, source, options);
@@ -356,6 +358,9 @@ const RECOVERABLE_INPUT_ERROR = "TW_MIGRATE_RECOVERABLE_INPUT:";
356
358
  function errorCode(error) {
357
359
  return error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : void 0;
358
360
  }
361
+ function errorMessage(error) {
362
+ return error instanceof Error ? error.message : String(error);
363
+ }
359
364
  function isWithin(root, path) {
360
365
  return path === root || path.startsWith(`${root}${sep}`);
361
366
  }
@@ -404,21 +409,20 @@ function recordSnapshot(snapshots, path, source) {
404
409
  return source;
405
410
  }
406
411
  function packageFailure(workspaceRoot, packageRoot, error) {
407
- const message = error instanceof Error ? error.message : String(error);
412
+ const message = errorMessage(error);
408
413
  return {
409
414
  package: normalizedRelativePath(workspaceRoot, packageRoot) || ".",
410
415
  message: message.startsWith("TW_MIGRATE_RECOVERABLE_INPUT:") ? message.slice(29) : message
411
416
  };
412
417
  }
413
418
  function isRecoverablePlanningError(error) {
414
- return (error instanceof Error ? error.message : String(error)).startsWith(RECOVERABLE_INPUT_ERROR);
419
+ return errorMessage(error).startsWith(RECOVERABLE_INPUT_ERROR);
415
420
  }
416
421
  function isMissingStyleCompilerError(error) {
417
- const message = error instanceof Error ? error.message : String(error);
418
- return /^(?:Sass|Less) must be installed in the target project\.$/.test(message);
422
+ return MISSING_STYLE_COMPILER_MESSAGES.has(errorMessage(error));
419
423
  }
420
424
  function isIntegrityError(error) {
421
- return (error instanceof Error ? error.message : String(error)).startsWith("Source changed during planning:");
425
+ return errorMessage(error).startsWith("Source changed during planning:");
422
426
  }
423
427
  function normalizedRelativePath(root, path) {
424
428
  return relative(root, path).split(sep).join("/");
@@ -528,7 +532,7 @@ async function resolveScope(options) {
528
532
  const currentPackage = await findPackageRoot(cwd);
529
533
  const gitRoot = await findGitRoot(currentPackage);
530
534
  const workspaceRoot = gitRoot && !await isIgnoredByGit(gitRoot, currentPackage) ? gitRoot : currentPackage;
531
- const allPaths = await discoverFiles(workspaceRoot, workspaceRoot === gitRoot);
535
+ const allPaths = workspaceRoot === gitRoot ? await discoverGitFiles(workspaceRoot) : await collectFiles(workspaceRoot, isRelevantDiscoveredFile);
532
536
  const scannedPaths = workspaceRoot === gitRoot ? [.../* @__PURE__ */ new Set([...allPaths, ...await collectFiles(workspaceRoot, isRelevantDiscoveredFile)])] : [...allPaths];
533
537
  const explicitStyle = options.styleFile ? resolve(cwd, options.styleFile) : void 0;
534
538
  const configuredEntry = options.tailwindCss ? resolve(cwd, options.tailwindCss) : void 0;
@@ -603,8 +607,7 @@ async function isIgnoredByGit(gitRoot, path) {
603
607
  return false;
604
608
  }
605
609
  }
606
- async function discoverFiles(root, useGit) {
607
- if (!useGit) return collectFiles(root, isRelevantDiscoveredFile);
610
+ async function discoverGitFiles(root) {
608
611
  const { stdout } = await run("git", [
609
612
  "ls-files",
610
613
  "-co",
@@ -645,7 +648,6 @@ async function collectFiles(root, include) {
645
648
  const files = [];
646
649
  async function visit(directory) {
647
650
  const entries = await readdir(directory, { withFileTypes: true });
648
- entries.sort((left, right) => left.name.localeCompare(right.name));
649
651
  for (const entry of entries) {
650
652
  const path = join(directory, entry.name);
651
653
  if (entry.isDirectory()) {
@@ -885,8 +887,30 @@ async function loadTailwind(packageRoot, tailwindCss, snapshots, workspaceRoot)
885
887
  const { __unstable__loadDesignSystem: loadDesignSystem } = await loadProjectModule(packageRoot, "tailwindcss", "Tailwind v4 must be installed in the target project.");
886
888
  const css = await snapshotFile(snapshots, tailwindCss);
887
889
  const base = dirname(tailwindCss);
888
- const loadModule = createModuleLoader(snapshots, workspaceRoot);
889
- const loadStylesheet = createStylesheetLoader(projectRequire, packagePath, snapshots, workspaceRoot);
890
+ const tailwindRoot = dirname(packagePath);
891
+ const loadModule = async (id, moduleBase) => {
892
+ const path = createRequire(join(moduleBase, "package.json")).resolve(id);
893
+ if (isProjectInput(workspaceRoot, path)) await snapshotFile(snapshots, path);
894
+ const imported = await import(pathToFileURL(path).href);
895
+ return {
896
+ path,
897
+ base: dirname(path),
898
+ module: imported.default ?? imported
899
+ };
900
+ };
901
+ const loadStylesheet = async (id, sheetBase) => {
902
+ let path;
903
+ if (id === "tailwindcss") path = join(tailwindRoot, "index.css");
904
+ else if (id.startsWith("tailwindcss/")) {
905
+ const subpath = id.slice(12);
906
+ path = join(tailwindRoot, subpath.endsWith(".css") ? subpath : `${subpath}.css`);
907
+ } else if (id.startsWith(".") || isAbsolute(id)) path = resolve(sheetBase, id);
908
+ else path = projectRequire.resolve(id);
909
+ return {
910
+ content: isProjectInput(workspaceRoot, path) ? await snapshotFile(snapshots, path) : await readFile(path, "utf8"),
911
+ base: dirname(path)
912
+ };
913
+ };
890
914
  const themeTokens = {
891
915
  ...extractThemeTokens(await readFile(join(dirname(packagePath), "theme.css"), "utf8")),
892
916
  ...await extractThemeTokensFromGraph(css, base, loadStylesheet)
@@ -922,34 +946,6 @@ function invalidCandidates(tailwind, candidates) {
922
946
  const generated = tailwind.designSystem.candidatesToCss(candidates);
923
947
  return candidates.filter((_, index) => generated[index] === null);
924
948
  }
925
- function createModuleLoader(snapshots, workspaceRoot) {
926
- return async (id, base) => {
927
- const path = createRequire(join(base, "package.json")).resolve(id);
928
- if (isProjectInput(workspaceRoot, path)) await snapshotFile(snapshots, path);
929
- const imported = await import(pathToFileURL(path).href);
930
- return {
931
- path,
932
- base: dirname(path),
933
- module: imported.default ?? imported
934
- };
935
- };
936
- }
937
- function createStylesheetLoader(projectRequire, tailwindPackagePath, snapshots, workspaceRoot) {
938
- const tailwindRoot = dirname(tailwindPackagePath);
939
- return async (id, base) => {
940
- let path;
941
- if (id === "tailwindcss") path = join(tailwindRoot, "index.css");
942
- else if (id.startsWith("tailwindcss/")) {
943
- const subpath = id.slice(12);
944
- path = join(tailwindRoot, subpath.endsWith(".css") ? subpath : `${subpath}.css`);
945
- } else if (id.startsWith(".") || isAbsolute(id)) path = resolve(base, id);
946
- else path = projectRequire.resolve(id);
947
- return {
948
- content: isProjectInput(workspaceRoot, path) ? await snapshotFile(snapshots, path) : await readFile(path, "utf8"),
949
- base: dirname(path)
950
- };
951
- };
952
- }
953
949
  //#endregion
954
950
  //#region src/parser/vue.ts
955
951
  const ESCAPE_SELECTOR = /(?:::v-|:)(?:deep|global|slotted)\(([^)]*)\)/g;
@@ -967,6 +963,13 @@ const SUPPORTED_STYLE_LANGUAGES = /* @__PURE__ */ new Set([
967
963
  "sass",
968
964
  "less"
969
965
  ]);
966
+ const SUPPORTED_SCRIPT_LANGUAGES = /* @__PURE__ */ new Set([
967
+ void 0,
968
+ "js",
969
+ "jsx",
970
+ "ts",
971
+ "tsx"
972
+ ]);
970
973
  const NODE_ELEMENT = 1;
971
974
  const NODE_TEXT = 2;
972
975
  const NODE_INTERPOLATION = 5;
@@ -997,29 +1000,48 @@ function analyzeVueSource(compiler, path, source) {
997
1000
  message
998
1001
  });
999
1002
  const { descriptor, errors } = compiler.parse(source, { filename: path });
1003
+ const styleRanges = descriptor.styles.map((style) => ({
1004
+ start: style.loc.start.offset,
1005
+ end: style.loc.end.offset
1006
+ }));
1007
+ const byteRanges = (offset) => ({
1008
+ warnings: warnings.map((warning) => ({
1009
+ ...warning,
1010
+ start: offset(warning.start),
1011
+ end: offset(warning.end)
1012
+ })),
1013
+ styleRanges: styleRanges.map((range) => ({
1014
+ start: offset(range.start),
1015
+ end: offset(range.end)
1016
+ }))
1017
+ });
1018
+ const retained = () => {
1019
+ const offsets = utf8OffsetMap(source, [...warnings, ...styleRanges].flatMap((range) => [range.start, range.end]));
1020
+ return {
1021
+ retained: true,
1022
+ ...byteRanges(offsetLookup(offsets))
1023
+ };
1024
+ };
1000
1025
  if (errors.length > 0) {
1001
1026
  const offset = errors[0]?.loc?.start?.offset ?? 0;
1002
1027
  warn("unsupported-sfc-block", offset, offset, `The SFC could not be parsed: ${errors[0].message}`);
1003
- return {
1004
- warnings: toByteWarnings(source, warnings),
1005
- retained: true
1006
- };
1028
+ if (errors[0]?.loc?.start?.offset === 0) Object.assign(warnings[0], {
1029
+ line: 1,
1030
+ column: 1,
1031
+ endLine: 1,
1032
+ endColumn: 1
1033
+ });
1034
+ return retained();
1007
1035
  }
1008
1036
  const template = descriptor.template;
1009
1037
  if (!template || template.lang || template.src) {
1010
1038
  warn("unsupported-sfc-block", 0, 0, !template ? "The SFC has no template block, so its class usage cannot be analyzed." : "Only the default template language with inline content is analyzed.");
1011
- return {
1012
- warnings: toByteWarnings(source, warnings),
1013
- retained: true
1014
- };
1039
+ return retained();
1015
1040
  }
1016
1041
  if (descriptor.customBlocks.length > 0) {
1017
1042
  const block = descriptor.customBlocks[0];
1018
1043
  warn("unsupported-sfc-block", block.loc.start.offset, block.loc.end.offset, `The custom <${block.type}> block is not analyzed.`);
1019
- return {
1020
- warnings: toByteWarnings(source, warnings),
1021
- retained: true
1022
- };
1044
+ return retained();
1023
1045
  }
1024
1046
  const blocks = [];
1025
1047
  const unscopedBlocks = [];
@@ -1119,13 +1141,7 @@ function analyzeVueSource(compiler, path, source) {
1119
1141
  let scriptImportsUnverifiable = false;
1120
1142
  const styleImports = [descriptor.script, descriptor.scriptSetup].flatMap((script) => {
1121
1143
  if (!script) return [];
1122
- if (script.src || ![
1123
- void 0,
1124
- "js",
1125
- "jsx",
1126
- "ts",
1127
- "tsx"
1128
- ].includes(script.lang)) {
1144
+ if (script.src || !SUPPORTED_SCRIPT_LANGUAGES.has(script.lang)) {
1129
1145
  scriptImportsUnverifiable = true;
1130
1146
  return [];
1131
1147
  }
@@ -1137,19 +1153,14 @@ function analyzeVueSource(compiler, path, source) {
1137
1153
  }
1138
1154
  });
1139
1155
  let componentImports = [];
1140
- if (descriptor.scriptSetup && !descriptor.scriptSetup.src && [
1141
- void 0,
1142
- "js",
1143
- "jsx",
1144
- "ts",
1145
- "tsx"
1146
- ].includes(descriptor.scriptSetup.lang)) try {
1156
+ if (descriptor.scriptSetup && !descriptor.scriptSetup.src && SUPPORTED_SCRIPT_LANGUAGES.has(descriptor.scriptSetup.lang)) try {
1147
1157
  componentImports = staticImportBindings(`${path}.${descriptor.scriptSetup.lang ?? "js"}`, descriptor.scriptSetup.content);
1148
1158
  } catch {
1149
1159
  componentImports = [];
1150
1160
  }
1151
1161
  const offsets = utf8OffsetMap(source, [
1152
1162
  ...warnings.flatMap((warning) => [warning.start, warning.end]),
1163
+ ...styleRanges.flatMap((range) => [range.start, range.end]),
1153
1164
  ...[
1154
1165
  ...blocks,
1155
1166
  ...unscopedBlocks,
@@ -1194,11 +1205,7 @@ function analyzeVueSource(compiler, path, source) {
1194
1205
  }
1195
1206
  });
1196
1207
  return {
1197
- warnings: warnings.map((warning) => ({
1198
- ...warning,
1199
- start: offset(warning.start),
1200
- end: offset(warning.end)
1201
- })),
1208
+ ...byteRanges(offset),
1202
1209
  blocks: blocks.map(toByteBlock),
1203
1210
  unscopedBlocks: unscopedBlocks.map(toByteBlock),
1204
1211
  moduleBlocks: moduleBlocks.map(toByteBlock),
@@ -1309,7 +1316,7 @@ function templateSite(source, node, bindingClasses, classOpaque, state) {
1309
1316
  }
1310
1317
  const matchClasses = bindingClasses.length ? [...classAttribute?.value.split(/[\t\n\f\r ]+/).filter(Boolean) ?? [], ...bindingClasses] : void 0;
1311
1318
  if (!classAttribute && (bindingClasses.length > 0 || !classOpaque && (node.tagType === TAG_COMPONENT || idAttribute))) {
1312
- const insertion = classInsertionOffset(source, node);
1319
+ const insertion = nodeClassInsertionOffset(source, node);
1313
1320
  if (insertion === void 0) state.dynamic = true;
1314
1321
  else classAttribute = {
1315
1322
  value: "",
@@ -1340,22 +1347,11 @@ function literalAttribute(source, node, name) {
1340
1347
  end: end - 1
1341
1348
  };
1342
1349
  }
1343
- function classInsertionOffset(source, node) {
1350
+ function nodeClassInsertionOffset(source, node) {
1344
1351
  const propsEnd = Math.max(node.loc.start.offset + 1 + node.tag.length, ...(node.props ?? []).map((prop) => prop.loc.end.offset));
1345
1352
  const tagEnd = source.indexOf(">", propsEnd);
1346
1353
  if (tagEnd < 0) return void 0;
1347
- let offset = tagEnd;
1348
- if (source[offset - 1] === "/") offset -= 1;
1349
- while (offset > node.loc.start.offset && /\s/.test(source[offset - 1])) offset -= 1;
1350
- return offset;
1351
- }
1352
- function toByteWarnings(source, warnings) {
1353
- const offsets = utf8OffsetMap(source, warnings.flatMap((warning) => [warning.start, warning.end]));
1354
- return warnings.map((warning) => ({
1355
- ...warning,
1356
- start: offsets.get(warning.start) ?? warning.start,
1357
- end: offsets.get(warning.end) ?? warning.end
1358
- }));
1354
+ return classInsertionOffset(source, node.loc.start.offset, tagEnd);
1359
1355
  }
1360
1356
  //#endregion
1361
1357
  //#region src/plan/vue.ts
@@ -1473,6 +1469,7 @@ async function preparePackageVue({ packageRoot, styleCompilers, sourceFiles, sty
1473
1469
  const none = {
1474
1470
  files: /* @__PURE__ */ new Map(),
1475
1471
  stylesheets: [],
1472
+ styleRanges: /* @__PURE__ */ new Map(),
1476
1473
  stylePaths: /* @__PURE__ */ new Set(),
1477
1474
  unscopedPaths: /* @__PURE__ */ new Set(),
1478
1475
  warnings: [],
@@ -1768,6 +1765,7 @@ async function preparePackageVue({ packageRoot, styleCompilers, sourceFiles, sty
1768
1765
  return {
1769
1766
  files,
1770
1767
  stylesheets,
1768
+ styleRanges: new Map([...analyses].map(([path, analysis]) => [path, analysis.styleRanges])),
1771
1769
  stylePaths,
1772
1770
  unscopedPaths,
1773
1771
  warnings,
@@ -1783,7 +1781,7 @@ async function verifySnapshots(snapshots) {
1783
1781
  const read = reads[index];
1784
1782
  if (read.status === "rejected") {
1785
1783
  const error = read.reason;
1786
- const detail = errorCode(error) ?? (error instanceof Error ? error.message : String(error));
1784
+ const detail = errorCode(error) ?? errorMessage(error);
1787
1785
  throw new Error(`Source changed after planning: ${path} (${detail})`);
1788
1786
  }
1789
1787
  if (read.value !== before) throw new Error(`Source changed after planning: ${path}`);
@@ -1818,6 +1816,38 @@ async function writeChanges(changes, deletions) {
1818
1816
  }
1819
1817
  //#endregion
1820
1818
  //#region src/index.ts
1819
+ function indexWarningPositions(source, offsets, unicodeSeparatorsAreNewlines, formFeedIsNewline, initial = {
1820
+ line: 1,
1821
+ column: 1
1822
+ }) {
1823
+ const targets = [...offsets].sort((left, right) => left - right);
1824
+ const positions = /* @__PURE__ */ new Map();
1825
+ let target = 0;
1826
+ let byte = 0;
1827
+ let { line, column } = initial;
1828
+ let previousWasCarriageReturn = false;
1829
+ const record = () => {
1830
+ while (targets[target] === byte) {
1831
+ positions.set(byte, {
1832
+ line,
1833
+ column
1834
+ });
1835
+ target += 1;
1836
+ }
1837
+ };
1838
+ record();
1839
+ for (const character of source) {
1840
+ byte += Buffer.byteLength(character);
1841
+ while ((targets[target] ?? Infinity) < byte) target += 1;
1842
+ if (character === "\r" || character === "\n" && !previousWasCarriageReturn || unicodeSeparatorsAreNewlines && (character === "\u2028" || character === "\u2029") || formFeedIsNewline && character === "\f") {
1843
+ line += 1;
1844
+ column = 1;
1845
+ } else if (character !== "\n") column += character.length;
1846
+ previousWasCarriageReturn = character === "\r";
1847
+ record();
1848
+ }
1849
+ return positions;
1850
+ }
1821
1851
  async function migrate(options = {}) {
1822
1852
  if (options.styleFile && options.workspaces) throw new TypeError("styleFile cannot be combined with workspaces");
1823
1853
  if (options.tailwindCss && options.workspaces) throw new TypeError("tailwindCss cannot be combined with workspaces");
@@ -1847,13 +1877,15 @@ async function migrate(options = {}) {
1847
1877
  const sourceFiles = sourceCandidates.flatMap((file) => file ? [file] : []);
1848
1878
  if (explicitStyle && isStylesheetPath(explicitStyle) && !styleSources.has(explicitStyle)) styleSources.set(explicitStyle, await snapshotFile(snapshots, explicitStyle));
1849
1879
  if (configuredEntry && !styleSources.has(configuredEntry)) styleSources.set(configuredEntry, await snapshotFile(snapshots, configuredEntry));
1880
+ const vueStyleRanges = /* @__PURE__ */ new Map();
1850
1881
  const context = {
1851
1882
  ...scope,
1852
1883
  options,
1853
1884
  snapshots,
1854
1885
  styleSources,
1855
1886
  sourceFiles,
1856
- styleDependents: indexStylesheetDependents(styleSources)
1887
+ styleDependents: indexStylesheetDependents(styleSources),
1888
+ vueStyleRanges
1857
1889
  };
1858
1890
  const failures = [];
1859
1891
  const plans = [];
@@ -1880,6 +1912,42 @@ async function migrate(options = {}) {
1880
1912
  await writeChanges(changed, deleted);
1881
1913
  }
1882
1914
  warnings.sort((left, right) => left.file.localeCompare(right.file) || left.start - right.start || left.end - right.end || left.code.localeCompare(right.code));
1915
+ const warningOffsets = /* @__PURE__ */ new Map();
1916
+ for (const warning of warnings) {
1917
+ if (warning.start === 0 && warning.end === 0) continue;
1918
+ const offsets = warningOffsets.get(warning.file) ?? /* @__PURE__ */ new Set();
1919
+ offsets.add(warning.start).add(warning.end);
1920
+ warningOffsets.set(warning.file, offsets);
1921
+ }
1922
+ const warningPositions = new Map([...warningOffsets].flatMap(([file, offsets]) => {
1923
+ const source = snapshots.get(file);
1924
+ if (!source) return [];
1925
+ const extension = extname(file);
1926
+ const javascriptSource = SOURCE_EXTENSIONS.has(extension) && extension !== ".html" && extension !== ".vue";
1927
+ const styleRanges = vueStyleRanges.get(file) ?? [];
1928
+ const defaultOffsets = new Set(offsets);
1929
+ for (const range of styleRanges) defaultOffsets.add(range.start);
1930
+ const defaultPositions = indexWarningPositions(source, defaultOffsets, javascriptSource, isStylesheetPath(file));
1931
+ const bytes = Buffer.from(source);
1932
+ const sortedOffsets = [...offsets].sort((left, right) => left - right);
1933
+ const stylePositions = /* @__PURE__ */ new Map();
1934
+ let offsetIndex = 0;
1935
+ for (const range of styleRanges) {
1936
+ while ((sortedOffsets[offsetIndex] ?? Infinity) < range.start) offsetIndex += 1;
1937
+ const relativeOffsets = /* @__PURE__ */ new Set();
1938
+ while ((sortedOffsets[offsetIndex] ?? Infinity) <= range.end) {
1939
+ relativeOffsets.add(sortedOffsets[offsetIndex] - range.start);
1940
+ offsetIndex += 1;
1941
+ }
1942
+ const initial = defaultPositions.get(range.start);
1943
+ if (!initial) continue;
1944
+ for (const [offset, position] of indexWarningPositions(bytes.subarray(range.start, range.end).toString(), relativeOffsets, false, true, initial)) stylePositions.set(range.start + offset, position);
1945
+ }
1946
+ return [[file, {
1947
+ default: defaultPositions,
1948
+ style: stylePositions
1949
+ }]];
1950
+ }));
1883
1951
  failures.sort((left, right) => left.package.localeCompare(right.package));
1884
1952
  return {
1885
1953
  changedFiles,
@@ -1891,15 +1959,29 @@ async function migrate(options = {}) {
1891
1959
  file: normalizedRelativePath(cwd, rule.file)
1892
1960
  })),
1893
1961
  candidates: [...candidates].sort(),
1894
- warnings: warnings.map((warning) => ({
1895
- ...warning,
1896
- file: normalizedRelativePath(cwd, warning.file)
1897
- })),
1962
+ warnings: warnings.map((warning) => {
1963
+ const indexed = warningPositions.get(warning.file);
1964
+ const styleFrom = indexed?.style.get(warning.start);
1965
+ const styleTo = indexed?.style.get(warning.end);
1966
+ const located = warning.start !== 0 || warning.end !== 0;
1967
+ const from = located ? styleFrom ?? indexed?.default.get(warning.start) : void 0;
1968
+ const to = located ? styleFrom && styleTo ? styleTo : indexed?.default.get(warning.end) : void 0;
1969
+ return {
1970
+ ...warning,
1971
+ ...from && to ? {
1972
+ line: from.line,
1973
+ column: from.column,
1974
+ endLine: to.line,
1975
+ endColumn: to.column
1976
+ } : {},
1977
+ file: normalizedRelativePath(cwd, warning.file)
1978
+ };
1979
+ }),
1898
1980
  failures
1899
1981
  };
1900
1982
  }
1901
1983
  async function planPackage(context, packageRoot) {
1902
- const { options, snapshots, workspaceRoot, explicitStyle, configuredEntry, styleSources, sourceFiles, styleDependents, pathOwners, targetable, selectedPackages, scannedPaths } = context;
1984
+ const { options, snapshots, workspaceRoot, explicitStyle, configuredEntry, styleSources, sourceFiles, styleDependents, pathOwners, targetable, selectedPackages, scannedPaths, vueStyleRanges } = context;
1903
1985
  const recover = (error, fatal = false) => {
1904
1986
  if (!options.force || fatal) throw error;
1905
1987
  return { failure: packageFailure(workspaceRoot, packageRoot, error) };
@@ -1936,6 +2018,12 @@ async function planPackage(context, packageRoot) {
1936
2018
  } catch (error) {
1937
2019
  return recover(error, isIntegrityError(error));
1938
2020
  }
2021
+ for (const [path, preparedRanges] of preparedVue.styleRanges) {
2022
+ const ranges = vueStyleRanges.get(path) ?? [];
2023
+ ranges.push(...preparedRanges);
2024
+ ranges.sort((left, right) => left.start - right.start);
2025
+ vueStyleRanges.set(path, ranges);
2026
+ }
1939
2027
  if (explicitStyle && extname(explicitStyle) === ".vue" && preparedVue.stylesheets.length === 0 && ![...preparedVue.stylePaths].some((path) => !isStylesheetModule(path))) return vueWarningsOnlyResult(preparedVue);
1940
2028
  const packageSources = [...sourceFiles.filter((file) => extname(file.path) !== ".html").map((file) => preparedVue.files.get(file.path) ?? file), ...preparedHtml.files];
1941
2029
  const ownedStyles = [...styleSources.keys()].filter((path) => pathOwners.get(path) === packageRoot && (targetable.has(path) || preparedHtml.stylePaths.has(path) || preparedVue.stylePaths.has(path)));
@@ -2167,7 +2255,7 @@ function mergePlans(plans, originals) {
2167
2255
  retainedRules += plan.retainedRules;
2168
2256
  rules.push(...plan.rules);
2169
2257
  for (const warning of plan.warnings) {
2170
- const key = `${warning.code}\0${warning.file}\0${warning.start}\0${warning.end}`;
2258
+ const key = `${warning.code}\0${warning.file}\0${warning.start}\0${warning.end}\0${warning.message}`;
2171
2259
  if (seenWarnings.has(key)) continue;
2172
2260
  seenWarnings.add(key);
2173
2261
  warnings.push(warning);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tw-migrate",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Preview and migrate React, Vue, and HTML CSS to Tailwind utilities",
5
5
  "keywords": [
6
6
  "codemod",
@@ -35,7 +35,7 @@
35
35
  "scripts": {
36
36
  "build": "vp pack && napi build --manifest-path crates/tw_migrate/Cargo.toml --package-json-path package.json --output-dir . --platform --release --no-js --dts binding.d.ts",
37
37
  "build:debug": "napi build --manifest-path crates/tw_migrate/Cargo.toml --package-json-path package.json --output-dir . --platform --no-js --dts binding.d.ts",
38
- "artifacts": "napi artifacts --output-dir . --npm-dir npm",
38
+ "artifacts": "node -e \"const fs=require('node:fs'); const files=fs.readdirSync('.').filter((file)=>file.startsWith('tw-migrate.')&&file.endsWith('.node')); if(files.length===0) throw new Error('no tw-migrate.*.node artifact in the package root; run build first'); for(const file of files) fs.copyFileSync(file, 'npm/'+file.slice('tw-migrate.'.length, -'.node'.length)+'/'+file)\"",
39
39
  "test": "cargo test && pnpm build:debug && vp test",
40
40
  "test:ecosystem": "node ecosystem-ci/run.ts",
41
41
  "typecheck": "tsc --noEmit",
@@ -44,7 +44,7 @@
44
44
  "prepare": "vp config"
45
45
  },
46
46
  "dependencies": {
47
- "parse5": "7.3.0"
47
+ "parse5": "8.0.1"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@napi-rs/cli": "catalog:",
@@ -62,11 +62,11 @@
62
62
  "vue": "catalog:"
63
63
  },
64
64
  "optionalDependencies": {
65
- "tw-migrate-darwin-arm64": "0.3.0",
66
- "tw-migrate-darwin-x64": "0.3.0",
67
- "tw-migrate-linux-arm64-gnu": "0.3.0",
68
- "tw-migrate-linux-x64-gnu": "0.3.0",
69
- "tw-migrate-win32-x64-msvc": "0.3.0"
65
+ "tw-migrate-darwin-arm64": "0.4.0",
66
+ "tw-migrate-darwin-x64": "0.4.0",
67
+ "tw-migrate-linux-arm64-gnu": "0.4.0",
68
+ "tw-migrate-linux-x64-gnu": "0.4.0",
69
+ "tw-migrate-win32-x64-msvc": "0.4.0"
70
70
  },
71
71
  "napi": {
72
72
  "binaryName": "tw-migrate",
@@ -79,7 +79,7 @@
79
79
  ]
80
80
  },
81
81
  "engines": {
82
- "node": ">=22.18.0"
82
+ "node": ">=22.23.2"
83
83
  },
84
- "packageManager": "pnpm@11.15.1"
84
+ "packageManager": "pnpm@11.20.0"
85
85
  }