tw-migrate 0.1.0-alpha.1 → 0.2.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 +13 -1
- package/bin/tw-migrate.js +15 -10
- package/html.js +74 -36
- package/index.d.ts +1 -1
- package/index.js +1283 -312
- package/native.js +18 -11
- package/package.json +14 -12
- package/style-compiler.js +52 -37
- package/vue.js +541 -0
- package/rfcs/batch-css-migration.md +0 -297
- package/rfcs/browser-ecosystem-e2e.md +0 -338
- package/rfcs/css-to-tailwind-migration-cli.md +0 -521
- package/rfcs/preprocessor-and-html-migration.md +0 -497
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tw-migrate
|
|
2
2
|
|
|
3
|
-
Preview and migrate static React/Next.js stylesheet references to Tailwind v4 utilities.
|
|
3
|
+
Preview and migrate static React/Next.js, Vue 3 SFC, and HTML stylesheet references to Tailwind v4 utilities.
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
@@ -41,6 +41,10 @@ node /path/to/tw-migrate/bin/tw-migrate.js --write
|
|
|
41
41
|
- SCSS/Sass/Less values evaluated with the target project's installed compiler; ambiguous mixin and partial origins are retained
|
|
42
42
|
- `.js`, `.jsx`, `.ts`, and `.tsx` source files
|
|
43
43
|
- static `.html` literal `class`/`id` attributes scoped by local external stylesheet links (link-level `print` media supported; other link media conditions are retained)
|
|
44
|
+
- Vue 3 `.vue` files: literal template `class`/`id` attributes and static string/template `:class` bindings matched against inline CSS, SCSS, Sass, and Less `<style scoped>` blocks plus statically imported stylesheets, parsed and compiled with the target project's dependencies
|
|
45
|
+
- direct `<script setup>` component imports can prove child-root and caller class surfaces across SFCs; unresolved components, exported roots, non-static bindings, and callers outside that graph retain scoped rules
|
|
46
|
+
- unscoped Vue styles migrate only in private single-source packages where their global usage is closed; broader unscoped surfaces retain with warnings
|
|
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
|
|
44
48
|
- direct CSS Module members, static template literals, and static expression literals
|
|
45
49
|
- global `className` and `id` literals
|
|
46
50
|
- multi-compound CSS Module selectors whose element relationships are proven from the JSX graph
|
|
@@ -60,23 +64,29 @@ Everything outside this subset is retained and reported with one of the warning
|
|
|
60
64
|
| `aliased-css-module-reference` | A CSS Module class is aliased to a local binding, so the module is retained. |
|
|
61
65
|
| `batch-stylesheet-conflict` | Utilities generated from different stylesheets conflict on the same JSX element, so the contributing rule is retained. |
|
|
62
66
|
| `candidate-compilation-failure` | A generated candidate did not compile under the project's Tailwind entry, so its rule is retained. |
|
|
67
|
+
| `component-class-target` | A child component's root element can carry classes a Vue scoped rule matches, so the rule is retained. |
|
|
63
68
|
| `computed-css-module-reference` | A computed CSS Module access cannot be verified, so the module is retained. |
|
|
64
69
|
| `cross-package-stylesheet-link` | A linked stylesheet is owned by another package, so it is not analyzed outside workspace mode. |
|
|
65
70
|
| `css-module-composes` | The rule uses or is targeted by `composes`, so it is retained. |
|
|
66
71
|
| `dynamic-class-name` | A `className` value is dynamic, so the element cannot be migrated. |
|
|
67
72
|
| `dynamic-html-attribute` | An HTML attribute is not a safely writable quoted literal, so the element cannot be migrated. |
|
|
73
|
+
| `dynamic-template-class` | A dynamic class binding makes a Vue template's class set unprovable, so its scoped rules are retained. |
|
|
68
74
|
| `existing-tailwind-conflict` | A generated utility may conflict with a Tailwind class already on the element. |
|
|
69
75
|
| `inferred-preprocessor-source` | A linked CSS file was matched to a uniquely named preprocessor source file. |
|
|
70
76
|
| `module-utilities-conflict` | Utilities generated from different module classes on one element overlap, so their rules are retained. |
|
|
71
77
|
| `non-classname-css-module-reference` | A CSS Module class is used outside a supported `className`, so the module is retained. |
|
|
78
|
+
| `open-root-fallthrough` | A parent component can merge classes onto a Vue SFC's single root element, so its scoped rules are retained. |
|
|
79
|
+
| `preprocessor-style-block` | A Vue `<style>` block uses a preprocessor language outside the supported SCSS, Sass, and Less set. |
|
|
72
80
|
| `rebuild-required` | A preprocessor entry was migrated; rebuild it to refresh its generated CSS. |
|
|
73
81
|
| `reference-only-css-module-consumer` | A reference-only (non-writable) source uses the CSS Module, so it is retained. |
|
|
74
82
|
| `retained-global-rule` | Global CSS is never deleted automatically. |
|
|
83
|
+
| `shadowed-scoped-rule` | Other package CSS also targets a class a Vue scoped rule matches, so the rule is retained to preserve the cascade. |
|
|
75
84
|
| `shared-preprocessor-source` | A Sass partial must be analyzed through every consuming entry, so it is retained. |
|
|
76
85
|
| `unproven-css-module-relationship` | A compound selector's element relationship could not be proven for every usage. |
|
|
77
86
|
| `unproven-script-reference` | An inline script names a CSS Module class, so the module is retained. |
|
|
78
87
|
| `unproven-source-map` | A generated rule does not map uniquely to one authored source rule, so it is retained. |
|
|
79
88
|
| `unresolved-selector-target` | No exclusively supported `className` references were found for the rule. |
|
|
89
|
+
| `unscoped-style-block` | An unscoped Vue style does not have a closed private single-source usage surface, so it is retained. |
|
|
80
90
|
| `unsupported-animation` | The animation references keyframes that cannot be converted. |
|
|
81
91
|
| `unsupported-at-rule` | The rule contains or sits inside an at-rule outside the supported set. |
|
|
82
92
|
| `unsupported-container-query` | The `@container` condition has no Tailwind variant equivalent. |
|
|
@@ -91,9 +101,11 @@ Everything outside this subset is retained and reported with one of the warning
|
|
|
91
101
|
| `unsupported-overlap` | Shorthand and longhand declarations overlap in a way that cannot be normalized. |
|
|
92
102
|
| `unsupported-rule-content` | The rule contains non-declaration content that cannot be converted. |
|
|
93
103
|
| `unsupported-selector` | The selector is outside the supported subset. |
|
|
104
|
+
| `unsupported-sfc-block` | A Vue SFC or one of its blocks cannot be analyzed safely, so it is retained. |
|
|
94
105
|
| `unsupported-starting-style` | The `@starting-style` condition could not be converted. |
|
|
95
106
|
| `unsupported-supports-query` | The `@supports` condition has no Tailwind variant equivalent. |
|
|
96
107
|
| `unsupported-value` | A declaration value cannot be represented as a Tailwind utility. |
|
|
108
|
+
| `unsupported-vue-version` | The project's Vue version is not Vue 3, so its SFCs are retained. |
|
|
97
109
|
|
|
98
110
|
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.
|
|
99
111
|
|
package/bin/tw-migrate.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { migrate } from
|
|
3
|
+
import { migrate } from "../index.js";
|
|
4
4
|
|
|
5
|
-
const usage =
|
|
5
|
+
const usage =
|
|
6
|
+
"Usage: tw-migrate [style-file] [--tailwind-css <entry.css>] [--workspaces] [--force] [--write]";
|
|
6
7
|
|
|
7
8
|
async function main() {
|
|
8
9
|
const args = process.argv.slice(2);
|
|
@@ -14,16 +15,16 @@ async function main() {
|
|
|
14
15
|
|
|
15
16
|
for (let index = 0; index < args.length; index += 1) {
|
|
16
17
|
const argument = args[index];
|
|
17
|
-
if (argument ===
|
|
18
|
-
else if (argument ===
|
|
19
|
-
else if (argument ===
|
|
20
|
-
else if (argument ===
|
|
18
|
+
if (argument === "--write") write = true;
|
|
19
|
+
else if (argument === "--force") force = true;
|
|
20
|
+
else if (argument === "--workspaces") workspaces = true;
|
|
21
|
+
else if (argument === "--tailwind-css") {
|
|
21
22
|
tailwindCss = args[++index];
|
|
22
23
|
if (!tailwindCss) throw new Error(`${usage}\n--tailwind-css requires a path.`);
|
|
23
|
-
} else if (argument ===
|
|
24
|
+
} else if (argument === "--help" || argument === "-h") {
|
|
24
25
|
console.log(usage);
|
|
25
26
|
return;
|
|
26
|
-
} else if (argument.startsWith(
|
|
27
|
+
} else if (argument.startsWith("-")) {
|
|
27
28
|
throw new Error(`Unknown option: ${argument}`);
|
|
28
29
|
} else if (!styleFile) styleFile = argument;
|
|
29
30
|
else throw new Error(`Unexpected argument: ${argument}`);
|
|
@@ -32,12 +33,16 @@ async function main() {
|
|
|
32
33
|
const report = await migrate({ styleFile, tailwindCss, write, force, workspaces });
|
|
33
34
|
if (report.diff) process.stdout.write(report.diff);
|
|
34
35
|
for (const warning of report.warnings) {
|
|
35
|
-
console.warn(
|
|
36
|
+
console.warn(
|
|
37
|
+
`warning[${warning.code}] ${warning.file}:${warning.start}-${warning.end} ${warning.message}`,
|
|
38
|
+
);
|
|
36
39
|
}
|
|
37
40
|
for (const failure of report.failures) {
|
|
38
41
|
console.warn(`skipped[${failure.package}] ${failure.message}`);
|
|
39
42
|
}
|
|
40
|
-
console.log(
|
|
43
|
+
console.log(
|
|
44
|
+
`${write ? "Applied" : "Previewed"} ${report.changedFiles.length} file(s); ${report.convertedRules} rule(s) converted, ${report.retainedRules} retained.`,
|
|
45
|
+
);
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
main().catch((error) => {
|
package/html.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { parse } from
|
|
1
|
+
import { parse } from "parse5";
|
|
2
2
|
|
|
3
|
-
const RECOVERABLE_PARSE_ERRORS = new Set([
|
|
3
|
+
const RECOVERABLE_PARSE_ERRORS = new Set(["missing-doctype"]);
|
|
4
4
|
const TEMPLATE_MARKERS = /\{\{|\}\}|\$\{|<%|%>|<#|#>|\[%|%\]/;
|
|
5
5
|
|
|
6
6
|
export function parseHtmlSource(path, source) {
|
|
@@ -12,7 +12,7 @@ export function parseHtmlSource(path, source) {
|
|
|
12
12
|
},
|
|
13
13
|
});
|
|
14
14
|
if (errors.length > 0) {
|
|
15
|
-
throw new Error(`Failed to parse ${path}: ${errors.map((error) => error.code).join(
|
|
15
|
+
throw new Error(`Failed to parse ${path}: ${errors.map((error) => error.code).join(", ")}`);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const links = [];
|
|
@@ -22,25 +22,27 @@ export function parseHtmlSource(path, source) {
|
|
|
22
22
|
const scriptTexts = [];
|
|
23
23
|
|
|
24
24
|
function visit(node) {
|
|
25
|
-
if (node.tagName ===
|
|
25
|
+
if (node.tagName === "script") {
|
|
26
26
|
for (const child of node.childNodes ?? []) {
|
|
27
|
-
if (child.nodeName ===
|
|
27
|
+
if (child.nodeName === "#text" && child.value) scriptTexts.push(child.value);
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
if (node.tagName) {
|
|
31
31
|
const attributes = new Map(node.attrs.map((attribute) => [attribute.name, attribute.value]));
|
|
32
32
|
const locations = node.sourceCodeLocation?.attrs;
|
|
33
33
|
if (locations) {
|
|
34
|
-
if (
|
|
35
|
-
&&
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
if (
|
|
35
|
+
node.tagName === "link" &&
|
|
36
|
+
stylesheetRel(attributes.get("rel")) &&
|
|
37
|
+
!attributes.has("disabled")
|
|
38
|
+
) {
|
|
39
|
+
const href = locatedAttribute(source, locations.href, attributes.get("href"));
|
|
40
|
+
const media = locatedAttribute(source, locations.media, attributes.get("media"));
|
|
39
41
|
const tag = node.sourceCodeLocation?.startTag ?? node.sourceCodeLocation;
|
|
40
42
|
if (href && tag) {
|
|
41
43
|
links.push({
|
|
42
44
|
href: href.value,
|
|
43
|
-
media: media?.value ??
|
|
45
|
+
media: media?.value ?? "",
|
|
44
46
|
start: href.start,
|
|
45
47
|
end: href.end,
|
|
46
48
|
tagStart: tag.startOffset,
|
|
@@ -48,30 +50,40 @@ export function parseHtmlSource(path, source) {
|
|
|
48
50
|
});
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
|
-
if (node.tagName ===
|
|
52
|
-
const href = locatedAttribute(source, locations.href, attributes.get(
|
|
53
|
-
if (href)
|
|
53
|
+
if (node.tagName === "base" && bases.length === 0) {
|
|
54
|
+
const href = locatedAttribute(source, locations.href, attributes.get("href"));
|
|
55
|
+
if (href)
|
|
56
|
+
bases.push({
|
|
57
|
+
href: href.value,
|
|
58
|
+
writable: href.writable,
|
|
59
|
+
start: href.start,
|
|
60
|
+
end: href.end,
|
|
61
|
+
});
|
|
54
62
|
}
|
|
55
63
|
|
|
56
|
-
let classAttribute = locatedAttribute(source, locations.class, attributes.get(
|
|
57
|
-
const idAttribute = locatedAttribute(source, locations.id, attributes.get(
|
|
64
|
+
let classAttribute = locatedAttribute(source, locations.class, attributes.get("class"));
|
|
65
|
+
const idAttribute = locatedAttribute(source, locations.id, attributes.get("id"));
|
|
58
66
|
// A class attribute that exists but cannot be located as a writable
|
|
59
67
|
// value (e.g. valueless `<main class>`) must not look absent, or the
|
|
60
68
|
// id-only branch would synthesize a duplicate class attribute.
|
|
61
|
-
const unparsedClass =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
const unparsedClass =
|
|
70
|
+
!classAttribute && idAttribute && locations.class
|
|
71
|
+
? { start: locations.class.startOffset, end: locations.class.endOffset }
|
|
72
|
+
: undefined;
|
|
73
|
+
const dynamic =
|
|
74
|
+
unparsedClass ??
|
|
75
|
+
[classAttribute, idAttribute].find(
|
|
76
|
+
(attribute) =>
|
|
77
|
+
attribute && (!attribute.writable || TEMPLATE_MARKERS.test(attribute.value)),
|
|
78
|
+
) ??
|
|
79
|
+
(classAttribute && !classAttribute.quoted ? classAttribute : undefined);
|
|
68
80
|
if (dynamic) {
|
|
69
81
|
dynamicAttributes.push({ start: dynamic.start, end: dynamic.end });
|
|
70
82
|
} else if (classAttribute || idAttribute) {
|
|
71
83
|
if (!classAttribute && idAttribute) {
|
|
72
84
|
const insertion = classInsertionOffset(source, node.sourceCodeLocation?.startTag);
|
|
73
85
|
if (insertion !== undefined) {
|
|
74
|
-
classAttribute = { value:
|
|
86
|
+
classAttribute = { value: "", start: insertion, end: insertion, synthetic: true };
|
|
75
87
|
}
|
|
76
88
|
}
|
|
77
89
|
elements.push({ classAttribute, idAttribute });
|
|
@@ -86,13 +98,39 @@ export function parseHtmlSource(path, source) {
|
|
|
86
98
|
visit(document);
|
|
87
99
|
return {
|
|
88
100
|
...toByteOffsets(source, { links, bases, elements, dynamicAttributes }),
|
|
89
|
-
scriptText: scriptTexts.join(
|
|
101
|
+
scriptText: scriptTexts.join("\n"),
|
|
90
102
|
};
|
|
91
103
|
}
|
|
92
104
|
|
|
105
|
+
// Map UTF-16 string indices to UTF-8 byte offsets in one source pass, so
|
|
106
|
+
// large documents stay linear instead of rescanning the prefix per site.
|
|
107
|
+
export function utf8OffsetMap(source, indices) {
|
|
108
|
+
const sorted = [...new Set(indices)].sort((left, right) => left - right);
|
|
109
|
+
const map = new Map();
|
|
110
|
+
let lastIndex = 0;
|
|
111
|
+
let bytes = 0;
|
|
112
|
+
for (const index of sorted) {
|
|
113
|
+
bytes += Buffer.byteLength(source.slice(lastIndex, index));
|
|
114
|
+
lastIndex = index;
|
|
115
|
+
map.set(index, bytes);
|
|
116
|
+
}
|
|
117
|
+
return map;
|
|
118
|
+
}
|
|
119
|
+
|
|
93
120
|
function toByteOffsets(source, parsed) {
|
|
94
|
-
const
|
|
95
|
-
|
|
121
|
+
const offsets = utf8OffsetMap(source, [
|
|
122
|
+
...parsed.links.flatMap((link) => [link.start, link.end, link.tagStart, link.tagEnd]),
|
|
123
|
+
...parsed.bases.flatMap((base) => [base.start, base.end]),
|
|
124
|
+
...parsed.elements.flatMap((element) =>
|
|
125
|
+
[element.classAttribute, element.idAttribute]
|
|
126
|
+
.filter(Boolean)
|
|
127
|
+
.flatMap((value) => [value.start, value.end]),
|
|
128
|
+
),
|
|
129
|
+
...parsed.dynamicAttributes.flatMap((value) => [value.start, value.end]),
|
|
130
|
+
]);
|
|
131
|
+
const offset = (index) => offsets.get(index);
|
|
132
|
+
const attribute = (value) =>
|
|
133
|
+
value && { ...value, start: offset(value.start), end: offset(value.end) };
|
|
96
134
|
return {
|
|
97
135
|
links: parsed.links.map((link) => ({
|
|
98
136
|
...link,
|
|
@@ -101,7 +139,7 @@ function toByteOffsets(source, parsed) {
|
|
|
101
139
|
tagStart: offset(link.tagStart),
|
|
102
140
|
tagEnd: offset(link.tagEnd),
|
|
103
141
|
})),
|
|
104
|
-
bases: parsed.bases.map(
|
|
142
|
+
bases: parsed.bases.map(attribute),
|
|
105
143
|
elements: parsed.elements.map((element) => ({
|
|
106
144
|
classAttribute: attribute(element.classAttribute),
|
|
107
145
|
idAttribute: attribute(element.idAttribute),
|
|
@@ -113,24 +151,24 @@ function toByteOffsets(source, parsed) {
|
|
|
113
151
|
function classInsertionOffset(source, startTag) {
|
|
114
152
|
if (!startTag) return undefined;
|
|
115
153
|
let offset = startTag.endOffset - 1;
|
|
116
|
-
if (source[offset] !==
|
|
154
|
+
if (source[offset] !== ">") return undefined;
|
|
117
155
|
while (offset > startTag.startOffset && /\s/.test(source[offset - 1])) offset -= 1;
|
|
118
|
-
if (source[offset - 1] ===
|
|
156
|
+
if (source[offset - 1] === "/") offset -= 1;
|
|
119
157
|
return offset;
|
|
120
158
|
}
|
|
121
159
|
|
|
122
|
-
function stylesheetRel(value =
|
|
160
|
+
function stylesheetRel(value = "") {
|
|
123
161
|
const tokens = value.toLowerCase().split(/\s+/);
|
|
124
|
-
return tokens.includes(
|
|
162
|
+
return tokens.includes("stylesheet") && !tokens.includes("alternate");
|
|
125
163
|
}
|
|
126
164
|
|
|
127
165
|
function locatedAttribute(source, location, parsedValue) {
|
|
128
166
|
if (!location || parsedValue === undefined) return undefined;
|
|
129
167
|
const raw = source.slice(location.startOffset, location.endOffset);
|
|
130
|
-
const equals = raw.indexOf(
|
|
168
|
+
const equals = raw.indexOf("=");
|
|
131
169
|
if (equals < 0) return undefined;
|
|
132
170
|
let start = equals + 1;
|
|
133
|
-
while (/\s/.test(raw[start] ??
|
|
171
|
+
while (/\s/.test(raw[start] ?? "")) start += 1;
|
|
134
172
|
const quote = raw[start];
|
|
135
173
|
let end;
|
|
136
174
|
const quoted = quote === '"' || quote === "'";
|
|
@@ -144,9 +182,9 @@ function locatedAttribute(source, location, parsedValue) {
|
|
|
144
182
|
}
|
|
145
183
|
const value = raw.slice(start, end);
|
|
146
184
|
return {
|
|
147
|
-
value: value.includes(
|
|
185
|
+
value: value.includes("&") ? parsedValue : value,
|
|
148
186
|
quoted,
|
|
149
|
-
writable: !value.includes(
|
|
187
|
+
writable: !value.includes("&"),
|
|
150
188
|
start: location.startOffset + start,
|
|
151
189
|
end: location.startOffset + end,
|
|
152
190
|
};
|
package/index.d.ts
CHANGED
|
@@ -18,7 +18,7 @@ export interface MigrationWarning {
|
|
|
18
18
|
|
|
19
19
|
export interface RuleReport {
|
|
20
20
|
selector: string;
|
|
21
|
-
status:
|
|
21
|
+
status: "converted" | "retained";
|
|
22
22
|
candidates: string[];
|
|
23
23
|
file: string;
|
|
24
24
|
/** Rule span in the analysis source (compiled CSS for preprocessor stylesheets). */
|