tw-migrate 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JongKyung Lee
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,121 @@
1
+ # tw-migrate
2
+
3
+ Preview and migrate static React/Next.js stylesheet references to Tailwind v4 utilities.
4
+
5
+ ## Usage
6
+
7
+ Run it in the package you want to migrate — no install step:
8
+
9
+ ```bash
10
+ npx tw-migrate # Preview the current package
11
+ npx tw-migrate --write # Migrate the current package
12
+ npx tw-migrate path/to/Button.module.scss # Preview one stylesheet
13
+ npx tw-migrate path/to/Button.module.scss --write # Migrate one stylesheet
14
+ npx tw-migrate --workspaces --write # Migrate every package
15
+ ```
16
+
17
+ `pnpm dlx tw-migrate` and `yarn dlx tw-migrate` take the same arguments.
18
+
19
+ > **Not on npm yet.** Until the first release the commands above will 404 — build it from a clone instead (see [Run from a clone](#run-from-a-clone)).
20
+
21
+ The CLI previews changes by default. Pass `--tailwind-css path/to/globals.css` when the current package has multiple Tailwind entries. `--force` skips package groups that fail discovery or input parsing; plan-integrity and write failures always stop the run.
22
+
23
+ ## Run from a clone
24
+
25
+ The migration planner is a native addon, so a clone needs one build before the CLI runs:
26
+
27
+ ```bash
28
+ vp install # or: pnpm install
29
+ vp run build:debug # compiles the addon for your platform
30
+ node bin/tw-migrate.js --help
31
+ ```
32
+
33
+ Then use `node bin/tw-migrate.js` wherever the commands above use `npx tw-migrate`. The CLI always migrates the package it is run from, so to migrate a different project, run the built entrypoint by absolute path from that project's directory:
34
+
35
+ ```bash
36
+ cd ../some-app
37
+ node /path/to/tw-migrate/bin/tw-migrate.js --write
38
+ ```
39
+
40
+ ## Current support
41
+
42
+ - `.css`, `.scss`, `.sass`, and `.less` stylesheets
43
+ - SCSS/Sass/Less values evaluated with the target project's installed compiler; ambiguous mixin and partial origins are retained
44
+ - `.js`, `.jsx`, `.ts`, and `.tsx` source files
45
+ - static `.html` literal `class`/`id` attributes scoped by local external stylesheet links (link-level `print` media supported; other link media conditions are retained)
46
+ - direct CSS Module members, static template literals, and static expression literals
47
+ - global `className` and `id` literals
48
+ - multi-compound CSS Module selectors whose element relationships are proven from the JSX graph
49
+ - common state pseudo-classes, global arbitrary descendant variants, and conditional at-rules (`@media`, `@supports`, `@container`, `@starting-style`)
50
+ - the tier-1 property mapping families with shorthand/longhand normalization
51
+ - exact Tailwind theme tokens and breakpoints with arbitrary-value fallback
52
+ - generated candidates are compiled against the project's Tailwind entry; failures retain the source rule
53
+ - batch migration of every stylesheet in a package, and `--workspaces` runs across packages
54
+ - CSS Module cleanup when every reference is safely migrated
55
+
56
+ Everything outside this subset is retained and reported with one of the warning codes below.
57
+
58
+ ## Warning codes
59
+
60
+ | Code | Meaning |
61
+ | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
62
+ | `aliased-css-module-reference` | A CSS Module class is aliased to a local binding, so the module is retained. |
63
+ | `batch-stylesheet-conflict` | Utilities generated from different stylesheets conflict on the same JSX element, so the contributing rule is retained. |
64
+ | `candidate-compilation-failure` | A generated candidate did not compile under the project's Tailwind entry, so its rule is retained. |
65
+ | `computed-css-module-reference` | A computed CSS Module access cannot be verified, so the module is retained. |
66
+ | `cross-package-stylesheet-link` | A linked stylesheet is owned by another package, so it is not analyzed outside workspace mode. |
67
+ | `css-module-composes` | The rule uses or is targeted by `composes`, so it is retained. |
68
+ | `dynamic-class-name` | A `className` value is dynamic, so the element cannot be migrated. |
69
+ | `dynamic-html-attribute` | An HTML attribute is not a safely writable quoted literal, so the element cannot be migrated. |
70
+ | `existing-tailwind-conflict` | A generated utility may conflict with a Tailwind class already on the element. |
71
+ | `inferred-preprocessor-source` | A linked CSS file was matched to a uniquely named preprocessor source file. |
72
+ | `module-utilities-conflict` | Utilities generated from different module classes on one element overlap, so their rules are retained. |
73
+ | `non-classname-css-module-reference` | A CSS Module class is used outside a supported `className`, so the module is retained. |
74
+ | `rebuild-required` | A preprocessor entry was migrated; rebuild it to refresh its generated CSS. |
75
+ | `reference-only-css-module-consumer` | A reference-only (non-writable) source uses the CSS Module, so it is retained. |
76
+ | `retained-global-rule` | Global CSS is never deleted automatically. |
77
+ | `shared-preprocessor-source` | A Sass partial must be analyzed through every consuming entry, so it is retained. |
78
+ | `unproven-css-module-relationship` | A compound selector's element relationship could not be proven for every usage. |
79
+ | `unproven-script-reference` | An inline script names a CSS Module class, so the module is retained. |
80
+ | `unproven-source-map` | A generated rule does not map uniquely to one authored source rule, so it is retained. |
81
+ | `unresolved-selector-target` | No exclusively supported `className` references were found for the rule. |
82
+ | `unsupported-animation` | The animation references keyframes that cannot be converted. |
83
+ | `unsupported-at-rule` | The rule contains or sits inside an at-rule outside the supported set. |
84
+ | `unsupported-container-query` | The `@container` condition has no Tailwind variant equivalent. |
85
+ | `unsupported-css-module-reference` | The CSS Module has an import or reference that cannot be migrated safely. |
86
+ | `unsupported-declaration` | A declaration is outside the supported property subset. |
87
+ | `unsupported-html-base` | A remote or unrepresentable base URL prevents safe stylesheet link resolution. |
88
+ | `unsupported-html-stylesheet-link` | Only local package stylesheet links are analyzed. |
89
+ | `unsupported-important` | `!important` declarations are not migrated. |
90
+ | `unsupported-link-media` | A stylesheet link or `@import` media condition cannot be represented safely. |
91
+ | `unsupported-media-query` | The `@media` condition has no Tailwind variant equivalent. |
92
+ | `unsupported-nested-at-rule` | A nested conditional at-rule could not be fully converted. |
93
+ | `unsupported-overlap` | Shorthand and longhand declarations overlap in a way that cannot be normalized. |
94
+ | `unsupported-rule-content` | The rule contains non-declaration content that cannot be converted. |
95
+ | `unsupported-selector` | The selector is outside the supported subset. |
96
+ | `unsupported-starting-style` | The `@starting-style` condition could not be converted. |
97
+ | `unsupported-supports-query` | The `@supports` condition has no Tailwind variant equivalent. |
98
+ | `unsupported-value` | A declaration value cannot be represented as a Tailwind utility. |
99
+
100
+ See the [core RFC](./rfcs/css-to-tailwind-migration-cli.md) and [preprocessor/HTML RFC](./rfcs/preprocessor-and-html-migration.md) for the complete design and remaining scope.
101
+
102
+ ## Testing the packaged CLI
103
+
104
+ Run `vp run test:snapshots` to build, pack, install, and test the published CLI shape against the cross-platform snapshot corpus. The command uses the npm registry and performs one shared install per test process. See [`crates/snapshots/README.md`](./crates/snapshots/README.md) for targeted runs and snapshot review commands.
105
+
106
+ ## Testing browser ecosystem compatibility
107
+
108
+ Build the current-platform package and install Chromium before a focused browser run:
109
+
110
+ ```bash
111
+ vp run build
112
+ vp run artifacts
113
+ vp exec playwright install chromium
114
+ vp run test:ecosystem --case react-vite-css
115
+ ```
116
+
117
+ Use `--case production-react-vite-css` for the installed CLI production-build smoke, or `--all` for all twelve controlled runtime/stylesheet cells. The default `vp run test` and packaged snapshots remain browser-free.
118
+
119
+ Pinned external projects run only in the **Ecosystem browser** GitHub Actions workflow on `main`, manual dispatch, or a pull request carrying the `test:e2e` label. The workflow checks them out under the runner's temporary directory without credentials or secrets; there is intentionally no contributor-facing local external command.
120
+
121
+ On failure, each OS/case job uploads only its bounded phase ledger, computed-style captures, screenshots, migration output, source diff, and registry/install/build/server logs. See the [browser ecosystem RFC](./rfcs/browser-ecosystem-e2e.md) for immutable external evidence and the manifest, isolation, and oracle contracts.
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { migrate } from '../index.js';
4
+
5
+ const usage = 'Usage: tw-migrate [style-file] [--tailwind-css <entry.css>] [--workspaces] [--force] [--write]';
6
+
7
+ async function main() {
8
+ const args = process.argv.slice(2);
9
+ let styleFile;
10
+ let tailwindCss;
11
+ let write = false;
12
+ let force = false;
13
+ let workspaces = false;
14
+
15
+ for (let index = 0; index < args.length; index += 1) {
16
+ const argument = args[index];
17
+ if (argument === '--write') write = true;
18
+ else if (argument === '--force') force = true;
19
+ else if (argument === '--workspaces') workspaces = true;
20
+ else if (argument === '--tailwind-css') {
21
+ tailwindCss = args[++index];
22
+ if (!tailwindCss) throw new Error(`${usage}\n--tailwind-css requires a path.`);
23
+ } else if (argument === '--help' || argument === '-h') {
24
+ console.log(usage);
25
+ return;
26
+ } else if (argument.startsWith('-')) {
27
+ throw new Error(`Unknown option: ${argument}`);
28
+ } else if (!styleFile) styleFile = argument;
29
+ else throw new Error(`Unexpected argument: ${argument}`);
30
+ }
31
+
32
+ const report = await migrate({ styleFile, tailwindCss, write, force, workspaces });
33
+ if (report.diff) process.stdout.write(report.diff);
34
+ for (const warning of report.warnings) {
35
+ console.warn(`warning[${warning.code}] ${warning.file}:${warning.start}-${warning.end} ${warning.message}`);
36
+ }
37
+ for (const failure of report.failures) {
38
+ console.warn(`skipped[${failure.package}] ${failure.message}`);
39
+ }
40
+ console.log(`${write ? 'Applied' : 'Previewed'} ${report.changedFiles.length} file(s); ${report.convertedRules} rule(s) converted, ${report.retainedRules} retained.`);
41
+ }
42
+
43
+ main().catch((error) => {
44
+ console.error(`tw-migrate: ${error.message}`);
45
+ process.exitCode = 1;
46
+ });
package/html.js ADDED
@@ -0,0 +1,153 @@
1
+ import { parse } from 'parse5';
2
+
3
+ const RECOVERABLE_PARSE_ERRORS = new Set(['missing-doctype']);
4
+ const TEMPLATE_MARKERS = /\{\{|\}\}|\$\{|<%|%>|<#|#>|\[%|%\]/;
5
+
6
+ export function parseHtmlSource(path, source) {
7
+ const errors = [];
8
+ const document = parse(source, {
9
+ sourceCodeLocationInfo: true,
10
+ onParseError(error) {
11
+ if (!RECOVERABLE_PARSE_ERRORS.has(error.code)) errors.push(error);
12
+ },
13
+ });
14
+ if (errors.length > 0) {
15
+ throw new Error(`Failed to parse ${path}: ${errors.map((error) => error.code).join(', ')}`);
16
+ }
17
+
18
+ const links = [];
19
+ const bases = [];
20
+ const elements = [];
21
+ const dynamicAttributes = [];
22
+ const scriptTexts = [];
23
+
24
+ function visit(node) {
25
+ if (node.tagName === 'script') {
26
+ for (const child of node.childNodes ?? []) {
27
+ if (child.nodeName === '#text' && child.value) scriptTexts.push(child.value);
28
+ }
29
+ }
30
+ if (node.tagName) {
31
+ const attributes = new Map(node.attrs.map((attribute) => [attribute.name, attribute.value]));
32
+ const locations = node.sourceCodeLocation?.attrs;
33
+ if (locations) {
34
+ if (node.tagName === 'link'
35
+ && stylesheetRel(attributes.get('rel'))
36
+ && !attributes.has('disabled')) {
37
+ const href = locatedAttribute(source, locations.href, attributes.get('href'));
38
+ const media = locatedAttribute(source, locations.media, attributes.get('media'));
39
+ const tag = node.sourceCodeLocation?.startTag ?? node.sourceCodeLocation;
40
+ if (href && tag) {
41
+ links.push({
42
+ href: href.value,
43
+ media: media?.value ?? '',
44
+ start: href.start,
45
+ end: href.end,
46
+ tagStart: tag.startOffset,
47
+ tagEnd: tag.endOffset,
48
+ });
49
+ }
50
+ }
51
+ if (node.tagName === 'base' && bases.length === 0) {
52
+ const href = locatedAttribute(source, locations.href, attributes.get('href'));
53
+ if (href) bases.push({ href: href.value, writable: href.writable, start: href.start, end: href.end });
54
+ }
55
+
56
+ let classAttribute = locatedAttribute(source, locations.class, attributes.get('class'));
57
+ const idAttribute = locatedAttribute(source, locations.id, attributes.get('id'));
58
+ // A class attribute that exists but cannot be located as a writable
59
+ // value (e.g. valueless `<main class>`) must not look absent, or the
60
+ // id-only branch would synthesize a duplicate class attribute.
61
+ const unparsedClass = !classAttribute && idAttribute && locations.class
62
+ ? { start: locations.class.startOffset, end: locations.class.endOffset }
63
+ : undefined;
64
+ const dynamic = unparsedClass
65
+ ?? [classAttribute, idAttribute].find((attribute) =>
66
+ attribute && (!attribute.writable || TEMPLATE_MARKERS.test(attribute.value)),
67
+ ) ?? (classAttribute && !classAttribute.quoted ? classAttribute : undefined);
68
+ if (dynamic) {
69
+ dynamicAttributes.push({ start: dynamic.start, end: dynamic.end });
70
+ } else if (classAttribute || idAttribute) {
71
+ if (!classAttribute && idAttribute) {
72
+ const insertion = classInsertionOffset(source, node.sourceCodeLocation?.startTag);
73
+ if (insertion !== undefined) {
74
+ classAttribute = { value: '', start: insertion, end: insertion, synthetic: true };
75
+ }
76
+ }
77
+ elements.push({ classAttribute, idAttribute });
78
+ }
79
+ }
80
+ }
81
+
82
+ for (const child of node.childNodes ?? []) visit(child);
83
+ if (node.content) visit(node.content);
84
+ }
85
+
86
+ visit(document);
87
+ return {
88
+ ...toByteOffsets(source, { links, bases, elements, dynamicAttributes }),
89
+ scriptText: scriptTexts.join('\n'),
90
+ };
91
+ }
92
+
93
+ function toByteOffsets(source, parsed) {
94
+ const offset = (index) => Buffer.byteLength(source.slice(0, index));
95
+ const attribute = (value) => value && { ...value, start: offset(value.start), end: offset(value.end) };
96
+ return {
97
+ links: parsed.links.map((link) => ({
98
+ ...link,
99
+ start: offset(link.start),
100
+ end: offset(link.end),
101
+ tagStart: offset(link.tagStart),
102
+ tagEnd: offset(link.tagEnd),
103
+ })),
104
+ bases: parsed.bases.map((base) => ({ ...base, start: offset(base.start), end: offset(base.end) })),
105
+ elements: parsed.elements.map((element) => ({
106
+ classAttribute: attribute(element.classAttribute),
107
+ idAttribute: attribute(element.idAttribute),
108
+ })),
109
+ dynamicAttributes: parsed.dynamicAttributes.map(attribute),
110
+ };
111
+ }
112
+
113
+ function classInsertionOffset(source, startTag) {
114
+ if (!startTag) return undefined;
115
+ let offset = startTag.endOffset - 1;
116
+ if (source[offset] !== '>') return undefined;
117
+ while (offset > startTag.startOffset && /\s/.test(source[offset - 1])) offset -= 1;
118
+ if (source[offset - 1] === '/') offset -= 1;
119
+ return offset;
120
+ }
121
+
122
+ function stylesheetRel(value = '') {
123
+ const tokens = value.toLowerCase().split(/\s+/);
124
+ return tokens.includes('stylesheet') && !tokens.includes('alternate');
125
+ }
126
+
127
+ function locatedAttribute(source, location, parsedValue) {
128
+ if (!location || parsedValue === undefined) return undefined;
129
+ const raw = source.slice(location.startOffset, location.endOffset);
130
+ const equals = raw.indexOf('=');
131
+ if (equals < 0) return undefined;
132
+ let start = equals + 1;
133
+ while (/\s/.test(raw[start] ?? '')) start += 1;
134
+ const quote = raw[start];
135
+ let end;
136
+ const quoted = quote === '"' || quote === "'";
137
+ if (quoted) {
138
+ start += 1;
139
+ end = raw.lastIndexOf(quote);
140
+ if (end < start) return undefined;
141
+ } else {
142
+ end = start;
143
+ while (end < raw.length && !/\s/.test(raw[end])) end += 1;
144
+ }
145
+ const value = raw.slice(start, end);
146
+ return {
147
+ value: value.includes('&') ? parsedValue : value,
148
+ quoted,
149
+ writable: !value.includes('&'),
150
+ start: location.startOffset + start,
151
+ end: location.startOffset + end,
152
+ };
153
+ }
package/index.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ export interface MigrateOptions {
2
+ styleFile?: string;
3
+ cwd?: string;
4
+ write?: boolean;
5
+ tailwindCss?: string;
6
+ workspaces?: boolean;
7
+ force?: boolean;
8
+ }
9
+
10
+ export interface MigrationWarning {
11
+ code: string;
12
+ file: string;
13
+ /** Byte offsets into the authored file, or (0, 0) when no unique mapping exists. */
14
+ start: number;
15
+ end: number;
16
+ message: string;
17
+ }
18
+
19
+ export interface RuleReport {
20
+ selector: string;
21
+ status: 'converted' | 'retained';
22
+ candidates: string[];
23
+ file: string;
24
+ /** Rule span in the analysis source (compiled CSS for preprocessor stylesheets). */
25
+ ruleId: { start: number; end: number };
26
+ /** Rule span in the authored file, or (0, 0) when no unique mapping exists. */
27
+ authoredSpan: { start: number; end: number };
28
+ }
29
+
30
+ export interface MigrationFailure {
31
+ package: string;
32
+ message: string;
33
+ }
34
+
35
+ export interface MigrationReport {
36
+ changedFiles: string[];
37
+ diff: string;
38
+ convertedRules: number;
39
+ retainedRules: number;
40
+ rules: RuleReport[];
41
+ candidates: string[];
42
+ warnings: MigrationWarning[];
43
+ failures: MigrationFailure[];
44
+ }
45
+
46
+ export function migrate(options?: MigrateOptions): Promise<MigrationReport>;