tw-migrate 0.1.0 → 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/vue.js
ADDED
|
@@ -0,0 +1,541 @@
|
|
|
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
|
+
}
|