tw-migrate 0.2.0 → 0.3.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/vue.js DELETED
@@ -1,541 +0,0 @@
1
- import { readFile } from "node:fs/promises";
2
- import { createRequire } from "node:module";
3
- import { join } from "node:path";
4
- import { pathToFileURL } from "node:url";
5
-
6
- import { utf8OffsetMap } from "./html.js";
7
- import { staticImportBindings, staticImports, staticStringExpression } from "./native.js";
8
-
9
- const ESCAPE_SELECTOR = /(?:::v-|:)(?:deep|global|slotted)\(([^)]*)\)/g;
10
- const ESCAPE_RESIDUE = /(?:>>>|\/deep\/|::v-deep|:deep|::v-slotted|:slotted|::v-global|:global)/;
11
- const SUPPORTED_STYLE_ATTRIBUTES = new Set(["lang", "module", "scoped", "src"]);
12
- const SUPPORTED_STYLE_LANGUAGES = new Set([undefined, "css", "scss", "sass", "less"]);
13
-
14
- // @vue/compiler-core node and element kinds; @vue/compiler-sfc does not
15
- // re-export the enums, so the numeric values are pinned here.
16
- const NODE_ELEMENT = 1;
17
- const NODE_TEXT = 2;
18
- const NODE_INTERPOLATION = 5;
19
- const PROP_ATTRIBUTE = 6;
20
- const PROP_DIRECTIVE = 7;
21
- const TAG_ELEMENT = 0;
22
- const TAG_COMPONENT = 1;
23
- const TAG_SLOT = 2;
24
-
25
- // Resolve the target project's own Vue 3 compiler. Vue 2 resolves but is
26
- // unsupported; a missing Vue installation is a recoverable package failure
27
- // mirroring a missing Sass compiler.
28
- export async function loadProjectVueCompiler(packageRoot) {
29
- const projectRequire = createRequire(join(packageRoot, "package.json"));
30
- let packagePath;
31
- try {
32
- packagePath = projectRequire.resolve("vue/package.json");
33
- } catch {
34
- throw new Error("Vue 3 with compiler-sfc must be installed in the target project.");
35
- }
36
- const { version } = JSON.parse(await readFile(packagePath, "utf8"));
37
- if (!String(version).startsWith("3.")) return { unsupportedVersion: String(version) };
38
- let modulePath;
39
- try {
40
- modulePath = projectRequire.resolve("vue/compiler-sfc");
41
- } catch {
42
- throw new Error("Vue 3 with compiler-sfc must be installed in the target project.");
43
- }
44
- const imported = await import(pathToFileURL(modulePath));
45
- return { compiler: imported.default ?? imported };
46
- }
47
-
48
- // Lower one SFC to the planner contract: plain-CSS scoped blocks in absolute
49
- // byte offsets and literal template class sites for the HTML matching model.
50
- // Returns `retained: true` when the whole file must stay untouched.
51
- export function analyzeVueSource(compiler, path, source) {
52
- const warnings = [];
53
- const warn = (code, start, end, message) =>
54
- warnings.push({ code, file: path, start, end, message });
55
-
56
- const { descriptor, errors } = compiler.parse(source, { filename: path });
57
- if (errors.length > 0) {
58
- const offset = errors[0]?.loc?.start?.offset ?? 0;
59
- warn(
60
- "unsupported-sfc-block",
61
- offset,
62
- offset,
63
- `The SFC could not be parsed: ${errors[0].message}`,
64
- );
65
- return { warnings: toByteWarnings(source, warnings), retained: true };
66
- }
67
-
68
- const template = descriptor.template;
69
- if (!template || template.lang || template.src) {
70
- const reason = !template
71
- ? "The SFC has no template block, so its class usage cannot be analyzed."
72
- : "Only the default template language with inline content is analyzed.";
73
- warn("unsupported-sfc-block", 0, 0, reason);
74
- return { warnings: toByteWarnings(source, warnings), retained: true };
75
- }
76
- if (descriptor.customBlocks.length > 0) {
77
- const block = descriptor.customBlocks[0];
78
- warn(
79
- "unsupported-sfc-block",
80
- block.loc.start.offset,
81
- block.loc.end.offset,
82
- `The custom <${block.type}> block is not analyzed.`,
83
- );
84
- return { warnings: toByteWarnings(source, warnings), retained: true };
85
- }
86
- const blocks = [];
87
- const unscopedBlocks = [];
88
- const moduleBlocks = [];
89
- const styleBlockImports = [];
90
- // Retained blocks are still real CSS that can win the cascade against the
91
- // utilities replacing a deleted scoped rule. Plain-CSS text feeds the
92
- // planner's parsed shadow index; preprocessor text is screened by the
93
- // caller before joining it.
94
- const shadowCssTexts = [];
95
- const unscopedShadowCssTexts = [];
96
- const shadowModuleCssTexts = [];
97
- const shadowPreprocessorTexts = [];
98
- const unscopedShadowPreprocessorTexts = [];
99
- let escapeUnverifiable = false;
100
- let moduleSiblingUnsupported = false;
101
- for (const style of descriptor.styles) {
102
- const start = style.loc.start.offset;
103
- const end = style.loc.end.offset;
104
- const usesDefaultModuleBinding = style.module === true || style.module === "$style";
105
- const unsupportedAttributes = Object.keys(style.attrs).filter(
106
- (attribute) => !SUPPORTED_STYLE_ATTRIBUTES.has(attribute),
107
- );
108
- if (unsupportedAttributes.length > 0) {
109
- warn(
110
- "unsupported-sfc-block",
111
- start,
112
- end,
113
- `Unsupported <style> attributes: ${unsupportedAttributes.join(", ")}.`,
114
- );
115
- if (usesDefaultModuleBinding) moduleSiblingUnsupported = true;
116
- escapeUnverifiable = true;
117
- continue;
118
- }
119
- if (style.src !== undefined) {
120
- if (style.module !== undefined) {
121
- warn("unsupported-sfc-block", start, end, "A <style module src> block is not supported.");
122
- if (usesDefaultModuleBinding) moduleSiblingUnsupported = true;
123
- escapeUnverifiable = true;
124
- } else {
125
- styleBlockImports.push({ reference: style.src, start, end });
126
- }
127
- continue;
128
- }
129
- if (style.module !== undefined && style.module !== true) {
130
- // `$style` explicitly names the same binding as a bare module, so this
131
- // unsupported sibling must keep every block feeding that object alive.
132
- if (usesDefaultModuleBinding) moduleSiblingUnsupported = true;
133
- // Named module bindings are rare and unproven; the block still feeds
134
- // the module shadow channel (type selectors stay global).
135
- warn("unsupported-sfc-block", start, end, "Named <style module> blocks are not supported.");
136
- shadowModuleCssTexts.push(style.content);
137
- continue;
138
- }
139
- if (!SUPPORTED_STYLE_LANGUAGES.has(style.lang)) {
140
- // Every unnamed module block feeds the same `$style` object, so an
141
- // unsupported sibling supplies classes the closure cannot see; the
142
- // whole module must retain.
143
- if (usesDefaultModuleBinding) moduleSiblingUnsupported = true;
144
- warn(
145
- "preprocessor-style-block",
146
- start,
147
- end,
148
- `The <style lang="${style.lang}"> language is not supported.`,
149
- );
150
- shadowPreprocessorTexts.push(style.content);
151
- continue;
152
- }
153
- const outerStart = source.lastIndexOf("<style", start);
154
- const closing = source.slice(end).match(/^<\/style\s*>/)?.[0];
155
- if (outerStart < 0 || !closing) {
156
- warn("unsupported-sfc-block", start, end, "The style block tags could not be located.");
157
- escapeUnverifiable = true;
158
- continue;
159
- }
160
- const block = {
161
- outerStart,
162
- outerEnd: end + closing.length,
163
- contentStart: start,
164
- contentEnd: end,
165
- syntax: style.lang ?? "css",
166
- content: style.content,
167
- };
168
- if (style.module === true) {
169
- // Module class and id names are localized at build time, but type and
170
- // attribute selectors (and `:global` escapes) stay global. The caller
171
- // feeds these blocks into the module shadow channel after compiling
172
- // preprocessor content, so they are not pushed as raw text here.
173
- moduleBlocks.push(block);
174
- continue;
175
- }
176
- if (!style.scoped) {
177
- unscopedBlocks.push(block);
178
- if (style.lang && style.lang !== "css") {
179
- unscopedShadowPreprocessorTexts.push(style.content);
180
- } else {
181
- unscopedShadowCssTexts.push(style.content);
182
- }
183
- continue;
184
- }
185
- // Scope-escape selectors (`:deep`, `:global`, `:slotted`) reach elements
186
- // outside this SFC even from a scoped block, so their inner selectors
187
- // must join the cascade-shadow corpus. Nested or paren-less escape forms
188
- // cannot be extracted textually and make the corpus unverifiable.
189
- for (const [, inner] of style.content.matchAll(ESCAPE_SELECTOR)) {
190
- shadowCssTexts.push(`${inner} {}`);
191
- if (inner.includes("(")) escapeUnverifiable = true;
192
- }
193
- if (ESCAPE_RESIDUE.test(style.content.replace(ESCAPE_SELECTOR, " "))) {
194
- escapeUnverifiable = true;
195
- }
196
- blocks.push(block);
197
- }
198
-
199
- const state = {
200
- elements: [],
201
- components: [],
202
- dynamic: false,
203
- vHtml: false,
204
- hasSlot: false,
205
- expressionTexts: [],
206
- };
207
- visitTemplateNode(source, template.ast, state);
208
- const alwaysRenderedRoots = template.ast.children.filter(
209
- (node) =>
210
- node.type === NODE_ELEMENT &&
211
- (node.tagType === TAG_ELEMENT || node.tagType === TAG_COMPONENT) &&
212
- !node.props?.some(
213
- (prop) =>
214
- prop.type === PROP_DIRECTIVE && ["if", "else-if", "else", "for"].includes(prop.name),
215
- ),
216
- ).length;
217
-
218
- // Script contents are outside template-class analysis, but the raw text
219
- // still protects external CSS Modules from unsafe deletion.
220
- const scriptText = [descriptor.script?.content, descriptor.scriptSetup?.content]
221
- .filter(Boolean)
222
- .join("\n");
223
- const fallthroughUnverifiable =
224
- Boolean(descriptor.script) ||
225
- descriptor.scriptSetup?.src !== undefined ||
226
- /\b(?:defineOptions|defineProps|inheritAttrs)\b/.test(descriptor.scriptSetup?.content ?? "");
227
- // A script whose imports cannot be read (external src, unsupported
228
- // language, parse failure) may still load global CSS invisible to the
229
- // shadow corpus, so its presence alone opens that surface.
230
- let scriptImportsUnverifiable = false;
231
- const styleImports = [descriptor.script, descriptor.scriptSetup].flatMap((script) => {
232
- if (!script) return [];
233
- if (script.src || ![undefined, "js", "jsx", "ts", "tsx"].includes(script.lang)) {
234
- scriptImportsUnverifiable = true;
235
- return [];
236
- }
237
- try {
238
- return staticImports(`${path}.${script.lang ?? "js"}`, script.content);
239
- } catch {
240
- scriptImportsUnverifiable = true;
241
- return [];
242
- }
243
- });
244
-
245
- let componentImports = [];
246
- if (
247
- descriptor.scriptSetup &&
248
- !descriptor.scriptSetup.src &&
249
- [undefined, "js", "jsx", "ts", "tsx"].includes(descriptor.scriptSetup.lang)
250
- ) {
251
- try {
252
- componentImports = staticImportBindings(
253
- `${path}.${descriptor.scriptSetup.lang ?? "js"}`,
254
- descriptor.scriptSetup.content,
255
- );
256
- } catch {
257
- componentImports = [];
258
- }
259
- }
260
-
261
- const offsets = utf8OffsetMap(source, [
262
- ...warnings.flatMap((warning) => [warning.start, warning.end]),
263
- ...[...blocks, ...unscopedBlocks, ...moduleBlocks].flatMap((block) => [
264
- block.outerStart,
265
- block.outerEnd,
266
- block.contentStart,
267
- block.contentEnd,
268
- ]),
269
- ...styleBlockImports.flatMap((entry) => [entry.start, entry.end]),
270
- ...[...state.elements, ...state.components].flatMap((element) =>
271
- [element.nodeStart, element.classAttribute, element.idAttribute, element.moduleBinding]
272
- .filter((value) => value !== undefined)
273
- .flatMap((value) => (typeof value === "number" ? [value] : [value.start, value.end])),
274
- ),
275
- ]);
276
- const offset = (index) => offsets.get(index);
277
- const attribute = (value) =>
278
- value && { ...value, start: offset(value.start), end: offset(value.end) };
279
- return {
280
- warnings: warnings.map((warning) => ({
281
- ...warning,
282
- start: offset(warning.start),
283
- end: offset(warning.end),
284
- })),
285
- blocks: blocks.map((block) => ({
286
- ...block,
287
- outerStart: offset(block.outerStart),
288
- outerEnd: offset(block.outerEnd),
289
- contentStart: offset(block.contentStart),
290
- contentEnd: offset(block.contentEnd),
291
- })),
292
- unscopedBlocks: unscopedBlocks.map((block) => ({
293
- ...block,
294
- outerStart: offset(block.outerStart),
295
- outerEnd: offset(block.outerEnd),
296
- contentStart: offset(block.contentStart),
297
- contentEnd: offset(block.contentEnd),
298
- })),
299
- moduleBlocks: moduleBlocks.map((block) => ({
300
- ...block,
301
- outerStart: offset(block.outerStart),
302
- outerEnd: offset(block.outerEnd),
303
- contentStart: offset(block.contentStart),
304
- contentEnd: offset(block.contentEnd),
305
- })),
306
- // `$style` outside the proven direct member sites (any expression,
307
- // interpolation, or script text) makes the module's consumers
308
- // unprovable.
309
- moduleClosureBroken:
310
- moduleSiblingUnsupported ||
311
- // An unreadable script could reference `$style` invisibly.
312
- scriptImportsUnverifiable ||
313
- /\$style|useCssModule/.test([...state.expressionTexts, scriptText].join("\n")),
314
- htmlElements: state.elements.map((element) => ({
315
- tag: element.tag,
316
- nodeStart: offset(element.nodeStart),
317
- classAttribute: attribute(element.classAttribute),
318
- idAttribute: attribute(element.idAttribute),
319
- matchClasses: element.matchClasses,
320
- moduleBinding: element.moduleBinding && {
321
- name: element.moduleBinding.name,
322
- start: offset(element.moduleBinding.start),
323
- end: offset(element.moduleBinding.end),
324
- },
325
- })),
326
- componentSites: state.components.map((element) => ({
327
- tag: element.tag,
328
- nodeStart: offset(element.nodeStart),
329
- classAttribute: attribute(element.classAttribute),
330
- idAttribute: attribute(element.idAttribute),
331
- matchClasses: element.matchClasses,
332
- })),
333
- rootStarts: template.ast.children
334
- .filter((node) => node.type === NODE_ELEMENT)
335
- .map((node) => offset(node.loc.start.offset)),
336
- // A root-level `v-for` renders a fragment, so a lone AST root is not a
337
- // fallthrough-eligible single root.
338
- rootVFor: template.ast.children.some(
339
- (node) =>
340
- node.type === NODE_ELEMENT &&
341
- node.props?.some((prop) => prop.type === PROP_DIRECTIVE && prop.name === "for"),
342
- ),
343
- // Non-comment text or interpolation roots also fragment the render
344
- // output, defeating attribute fallthrough.
345
- rootFragment:
346
- template.ast.children.filter(
347
- (node) =>
348
- node.type === NODE_ELEMENT ||
349
- node.type === NODE_INTERPOLATION ||
350
- (node.type === NODE_TEXT && node.content?.trim?.() !== ""),
351
- ).length !== 1,
352
- scriptText,
353
- // Script imports follow module-specifier semantics (bare = package);
354
- // style-block `@import`s follow CSS semantics (bare = relative). They
355
- // must resolve differently, so they stay separate.
356
- scriptStyleImports: [...new Set(styleImports)],
357
- scriptImportsUnverifiable,
358
- styleBlockImports: styleBlockImports.map((entry) => ({
359
- ...entry,
360
- start: offset(entry.start),
361
- end: offset(entry.end),
362
- })),
363
- componentImports,
364
- fallthroughUnverifiable,
365
- dynamic: state.dynamic,
366
- vHtml: state.vHtml,
367
- hasSlot: state.hasSlot,
368
- alwaysRenderedRoots,
369
- shadowCssTexts,
370
- unscopedShadowCssTexts,
371
- shadowModuleCssTexts,
372
- shadowPreprocessorTexts,
373
- unscopedShadowPreprocessorTexts,
374
- escapeUnverifiable,
375
- retained: false,
376
- };
377
- }
378
-
379
- // Post-plan integrity check: the edited SFC must still parse, and each
380
- // remaining supported scoped block contents are returned for validation by
381
- // the caller.
382
- export function verifyVueSource(compiler, path, source, includeUnscoped = false) {
383
- const { descriptor, errors } = compiler.parse(source, { filename: path });
384
- if (errors.length > 0) {
385
- throw new Error(`Edited SFC no longer parses: ${path}: ${errors[0].message}`);
386
- }
387
- return descriptor.styles
388
- .filter(
389
- (style) =>
390
- style.src === undefined &&
391
- (style.module === undefined || style.module === true) &&
392
- (style.scoped || style.module === true || includeUnscoped) &&
393
- SUPPORTED_STYLE_LANGUAGES.has(style.lang),
394
- )
395
- .map((style) => ({ content: style.content, syntax: style.lang ?? "css" }));
396
- }
397
-
398
- function visitTemplateNode(source, node, state) {
399
- if (node.type === NODE_ELEMENT) {
400
- if (node.tagType === TAG_SLOT) state.hasSlot = true;
401
- const bindingClasses = [];
402
- let classOpaque = false;
403
- let moduleBinding;
404
- for (const prop of node.props ?? []) {
405
- if (prop.type !== PROP_DIRECTIVE) continue;
406
- let provenModuleExpression = false;
407
- if (prop.name === "bind") {
408
- if (!prop.arg || !prop.arg.isStatic) {
409
- classOpaque = true;
410
- } else if (prop.arg.content === "class") {
411
- const member =
412
- node.tagType === TAG_ELEMENT && prop.exp
413
- ? /^\s*\$style\s*\.\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*$/.exec(prop.exp.content)
414
- : undefined;
415
- if (member && !moduleBinding) {
416
- // A proven `$style` member yields a hashed class that literal
417
- // and scoped analysis never see, so it is not an opaque surface.
418
- moduleBinding = {
419
- name: member[1],
420
- start: attributeRemovalStart(source, node, prop),
421
- end: prop.loc.end.offset,
422
- };
423
- provenModuleExpression = true;
424
- } else {
425
- const value = staticClassBinding(prop);
426
- if (value === undefined) classOpaque = true;
427
- else bindingClasses.push(...value.split(/[\t\n\f\r ]+/).filter(Boolean));
428
- }
429
- } else if (prop.arg.content === "id") {
430
- classOpaque = true;
431
- }
432
- }
433
- // Every unproven expression joins the module closure scan: `$style`
434
- // escaping the proven form anywhere retains the module. A dynamic
435
- // directive argument (`v-bind:[expr]`, `v-on:[expr]`) evaluates its
436
- // expression at render time, so it joins the scan too.
437
- if (!provenModuleExpression && prop.exp?.content) {
438
- state.expressionTexts.push(prop.exp.content);
439
- }
440
- if (prop.arg && !prop.arg.isStatic && prop.arg.content) {
441
- state.expressionTexts.push(prop.arg.content);
442
- }
443
- // Injected markup carries no scope attribute, so scoped proofs are
444
- // unaffected -- but it can use any class an unscoped rule targets.
445
- if (prop.name === "html") state.vHtml = true;
446
- }
447
- if (classOpaque) state.dynamic = true;
448
- const site = templateSite(source, node, bindingClasses, classOpaque, state);
449
- const element = { tag: node.tag, nodeStart: node.loc.start.offset, moduleBinding, ...site };
450
- if (node.tagType === TAG_COMPONENT) state.components.push(element);
451
- else if (node.tagType === TAG_ELEMENT) state.elements.push(element);
452
- }
453
- if (node.type === NODE_INTERPOLATION && node.content?.content) {
454
- state.expressionTexts.push(node.content.content);
455
- }
456
- for (const child of node.children ?? []) visitTemplateNode(source, child, state);
457
- }
458
-
459
- // Inline attributes consume their separator so deletion leaves no double
460
- // space. Multiline attributes keep their newline and indentation byte-exact.
461
- function attributeRemovalStart(source, node, prop) {
462
- const attributeStart = prop.loc.start.offset;
463
- let start = attributeStart;
464
- while (start > node.loc.start.offset + 1 && /\s/.test(source[start - 1])) start -= 1;
465
- return /[\r\n]/.test(source.slice(start, attributeStart)) ? attributeStart : start;
466
- }
467
-
468
- function staticClassBinding(prop) {
469
- if (!prop.exp) return undefined;
470
- try {
471
- return staticStringExpression("Component.js", prop.exp.content) ?? undefined;
472
- } catch {
473
- return undefined;
474
- }
475
- }
476
-
477
- function templateSite(source, node, bindingClasses, classOpaque, state) {
478
- let classAttribute = literalAttribute(source, node, "class");
479
- const idAttribute = literalAttribute(source, node, "id");
480
- const hasClassAttr = node.props?.some(
481
- (prop) => prop.type === PROP_ATTRIBUTE && prop.name === "class",
482
- );
483
- const hasIdAttr = node.props?.some((prop) => prop.type === PROP_ATTRIBUTE && prop.name === "id");
484
- if ((hasClassAttr && !classAttribute) || (hasIdAttr && !idAttribute)) {
485
- state.dynamic = true;
486
- return { idAttribute };
487
- }
488
-
489
- const matchClasses = bindingClasses.length
490
- ? [...(classAttribute?.value.split(/[\t\n\f\r ]+/).filter(Boolean) ?? []), ...bindingClasses]
491
- : undefined;
492
- if (
493
- !classAttribute &&
494
- (bindingClasses.length > 0 || (!classOpaque && (node.tagType === TAG_COMPONENT || idAttribute)))
495
- ) {
496
- const insertion = classInsertionOffset(source, node);
497
- if (insertion === undefined) state.dynamic = true;
498
- else classAttribute = { value: "", start: insertion, end: insertion, synthetic: true };
499
- }
500
- return { classAttribute, idAttribute, matchClasses };
501
- }
502
-
503
- // The inner span of a quoted, entity-free, literal attribute value, or
504
- // undefined when the attribute is absent or cannot be edited safely.
505
- function literalAttribute(source, node, name) {
506
- const prop = node.props?.find((prop) => prop.type === PROP_ATTRIBUTE && prop.name === name);
507
- if (!prop?.value) return undefined;
508
- const start = prop.value.loc.start.offset;
509
- const end = prop.value.loc.end.offset;
510
- const raw = source.slice(start, end);
511
- const quote = raw[0];
512
- if ((quote !== '"' && quote !== "'") || raw[raw.length - 1] !== quote) return undefined;
513
- const value = raw.slice(1, -1);
514
- if (value !== prop.value.content || value.includes("&")) return undefined;
515
- return { value, start: start + 1, end: end - 1 };
516
- }
517
-
518
- function classInsertionOffset(source, node) {
519
- const propsEnd = Math.max(
520
- node.loc.start.offset + 1 + node.tag.length,
521
- ...(node.props ?? []).map((prop) => prop.loc.end.offset),
522
- );
523
- const tagEnd = source.indexOf(">", propsEnd);
524
- if (tagEnd < 0) return undefined;
525
- let offset = tagEnd;
526
- if (source[offset - 1] === "/") offset -= 1;
527
- while (offset > node.loc.start.offset && /\s/.test(source[offset - 1])) offset -= 1;
528
- return offset;
529
- }
530
-
531
- function toByteWarnings(source, warnings) {
532
- const offsets = utf8OffsetMap(
533
- source,
534
- warnings.flatMap((warning) => [warning.start, warning.end]),
535
- );
536
- return warnings.map((warning) => ({
537
- ...warning,
538
- start: offsets.get(warning.start),
539
- end: offsets.get(warning.end),
540
- }));
541
- }