workstar-compiler 0.1.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 +5 -1
- package/bin/workstar-compile.mjs +15 -1
- package/dist/src/compat-rules.d.ts +2 -0
- package/dist/src/compat-rules.js +4 -0
- package/dist/src/compat.d.ts +5 -0
- package/dist/src/compat.js +12 -0
- package/dist/src/index.js +8 -3
- package/dist/src/project.d.ts +15 -1
- package/dist/src/project.js +56 -4
- package/dist/src/react-compat.d.ts +6 -0
- package/dist/src/react-compat.js +369 -0
- package/dist/src/react-entry-compat.d.ts +2 -0
- package/dist/src/react-entry-compat.js +105 -0
- package/dist/src/react-import-resolution.d.ts +2 -0
- package/dist/src/react-import-resolution.js +15 -0
- package/dist/src/vite.d.ts +4 -0
- package/dist/src/vite.js +109 -2
- package/dist/src/vue-compat.d.ts +2 -0
- package/dist/src/vue-compat.js +168 -0
- package/dist/src/vue-script-compat.d.ts +8 -0
- package/dist/src/vue-script-compat.js +244 -0
- package/package.json +6 -2
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import ts from 'typescript';
|
|
2
|
+
import { reject } from './compat-rules.js';
|
|
3
|
+
function importedNames(statement) {
|
|
4
|
+
if (statement.importClause?.isTypeOnly || statement.importClause?.name)
|
|
5
|
+
return [];
|
|
6
|
+
const bindings = statement.importClause?.namedBindings;
|
|
7
|
+
if (!bindings || !ts.isNamedImports(bindings))
|
|
8
|
+
return [];
|
|
9
|
+
if (bindings.elements.some((element) => element.isTypeOnly || element.propertyName))
|
|
10
|
+
return [];
|
|
11
|
+
return bindings.elements.map((element) => element.name.text);
|
|
12
|
+
}
|
|
13
|
+
function containsUnsupportedEntrySyntax(node) {
|
|
14
|
+
if (ts.isJsxElement(node) ||
|
|
15
|
+
ts.isJsxSelfClosingElement(node) ||
|
|
16
|
+
ts.isJsxFragment(node) ||
|
|
17
|
+
(ts.isIdentifier(node) &&
|
|
18
|
+
(node.text === 'createRoot' || node.text === 'StrictMode')))
|
|
19
|
+
return true;
|
|
20
|
+
return ts.forEachChild(node, containsUnsupportedEntrySyntax) === true;
|
|
21
|
+
}
|
|
22
|
+
function singleComponent(node, strictMode, filename) {
|
|
23
|
+
let element = node;
|
|
24
|
+
if (ts.isJsxElement(element)) {
|
|
25
|
+
if (!strictMode ||
|
|
26
|
+
element.openingElement.tagName.getText() !== 'StrictMode' ||
|
|
27
|
+
element.closingElement.tagName.getText() !== 'StrictMode')
|
|
28
|
+
reject(filename, 'React root entry');
|
|
29
|
+
const children = element.children.filter((child) => !ts.isJsxText(child) || child.getText().trim() !== '');
|
|
30
|
+
if (children.length !== 1)
|
|
31
|
+
reject(filename, 'React root entry');
|
|
32
|
+
element = children[0];
|
|
33
|
+
}
|
|
34
|
+
if (!ts.isJsxSelfClosingElement(element) ||
|
|
35
|
+
element.attributes.properties.length !== 0 ||
|
|
36
|
+
!ts.isIdentifier(element.tagName))
|
|
37
|
+
reject(filename, 'React root entry');
|
|
38
|
+
return element.tagName.text;
|
|
39
|
+
}
|
|
40
|
+
/** Replace the conventional createRoot entry with a Workstar mount. */
|
|
41
|
+
export function convertReactRootEntry(source, filename) {
|
|
42
|
+
const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
43
|
+
const imports = file.statements.filter(ts.isImportDeclaration);
|
|
44
|
+
const rootImport = imports.find((statement) => ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
45
|
+
statement.moduleSpecifier.text === 'react-dom/client');
|
|
46
|
+
if (!rootImport)
|
|
47
|
+
return undefined;
|
|
48
|
+
const reactImport = imports.find((statement) => ts.isStringLiteral(statement.moduleSpecifier) &&
|
|
49
|
+
statement.moduleSpecifier.text === 'react');
|
|
50
|
+
const strictMode = reactImport !== undefined;
|
|
51
|
+
if (imports.filter((entry) => ts.isStringLiteral(entry.moduleSpecifier) &&
|
|
52
|
+
entry.moduleSpecifier.text === 'react-dom/client').length !== 1 ||
|
|
53
|
+
imports.filter((entry) => ts.isStringLiteral(entry.moduleSpecifier) &&
|
|
54
|
+
entry.moduleSpecifier.text === 'react').length > 1 ||
|
|
55
|
+
importedNames(rootImport).join(',') !== 'createRoot' ||
|
|
56
|
+
(reactImport && importedNames(reactImport).join(',') !== 'StrictMode'))
|
|
57
|
+
reject(filename, 'React root imports');
|
|
58
|
+
const statement = file.statements.at(-1);
|
|
59
|
+
if (!statement || !ts.isExpressionStatement(statement))
|
|
60
|
+
reject(filename, 'React root entry');
|
|
61
|
+
const renderCall = statement.expression;
|
|
62
|
+
if (!ts.isCallExpression(renderCall) ||
|
|
63
|
+
!ts.isPropertyAccessExpression(renderCall.expression) ||
|
|
64
|
+
renderCall.expression.name.text !== 'render' ||
|
|
65
|
+
renderCall.arguments.length !== 1)
|
|
66
|
+
reject(filename, 'React root entry');
|
|
67
|
+
const createCall = renderCall.expression.expression;
|
|
68
|
+
if (!ts.isCallExpression(createCall) ||
|
|
69
|
+
!ts.isIdentifier(createCall.expression) ||
|
|
70
|
+
createCall.expression.text !== 'createRoot' ||
|
|
71
|
+
createCall.arguments.length !== 1 ||
|
|
72
|
+
!ts.isIdentifier(createCall.arguments[0]))
|
|
73
|
+
reject(filename, 'React root entry');
|
|
74
|
+
const component = singleComponent(renderCall.arguments[0], strictMode, filename);
|
|
75
|
+
if (!imports.some((entry) => ts.isStringLiteral(entry.moduleSpecifier) &&
|
|
76
|
+
entry.moduleSpecifier.text.startsWith('.') &&
|
|
77
|
+
importedNames(entry).includes(component)))
|
|
78
|
+
reject(filename, 'local root component import');
|
|
79
|
+
if (file.statements.some((entry) => entry !== statement &&
|
|
80
|
+
!ts.isImportDeclaration(entry) &&
|
|
81
|
+
containsUnsupportedEntrySyntax(entry)))
|
|
82
|
+
reject(filename, 'extra React entry syntax');
|
|
83
|
+
const edits = [
|
|
84
|
+
{ start: rootImport.getStart(file), end: rootImport.getEnd(), text: '' },
|
|
85
|
+
...(reactImport
|
|
86
|
+
? [
|
|
87
|
+
{
|
|
88
|
+
start: reactImport.getStart(file),
|
|
89
|
+
end: reactImport.getEnd(),
|
|
90
|
+
text: '',
|
|
91
|
+
},
|
|
92
|
+
]
|
|
93
|
+
: []),
|
|
94
|
+
{
|
|
95
|
+
start: statement.getStart(file),
|
|
96
|
+
end: statement.getEnd(),
|
|
97
|
+
text: `mount(${createCall.arguments[0].getText(file)}, ${component}({}));`,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
let output = source;
|
|
101
|
+
for (const edit of edits.sort((left, right) => right.start - left.start)) {
|
|
102
|
+
output = output.slice(0, edit.start) + edit.text + output.slice(edit.end);
|
|
103
|
+
}
|
|
104
|
+
return `import { mount } from 'workstar';\n${output}`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { dirname, extname, resolve } from 'node:path';
|
|
3
|
+
/** Resolve a local TSX component to an explicit opt-in compatibility import. */
|
|
4
|
+
export function resolveReactComponentImport(filename, specifier) {
|
|
5
|
+
if (!specifier.startsWith('.'))
|
|
6
|
+
return undefined;
|
|
7
|
+
const candidate = specifier.endsWith('.tsx')
|
|
8
|
+
? specifier
|
|
9
|
+
: extname(specifier) === ''
|
|
10
|
+
? `${specifier}.tsx`
|
|
11
|
+
: undefined;
|
|
12
|
+
if (!candidate || !existsSync(resolve(dirname(filename), candidate)))
|
|
13
|
+
return undefined;
|
|
14
|
+
return `${candidate}?workstar`;
|
|
15
|
+
}
|
package/dist/src/vite.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { Plugin } from 'vite';
|
|
2
2
|
export interface WorkstarPluginOptions {
|
|
3
3
|
source?: string;
|
|
4
|
+
/** Compile ordinary TSX/Vue imports within source, without ?workstar. */
|
|
5
|
+
foreign?: 'explicit' | 'automatic' | 'runtime';
|
|
6
|
+
/** Optional absolute runtime directory for isolated migration builds. */
|
|
7
|
+
runtimeImportSource?: string;
|
|
4
8
|
}
|
|
5
9
|
/** Compile authored components as Vite modules without writing into src. */
|
|
6
10
|
export declare function workstar(options?: WorkstarPluginOptions): Plugin;
|
package/dist/src/vite.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { extname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
2
|
+
import { dirname, extname, isAbsolute, relative, resolve, sep, } from 'node:path';
|
|
3
3
|
import { compileComponentParts } from './index.js';
|
|
4
|
+
import { convertForeignComponent } from './compat.js';
|
|
5
|
+
import { convertReactRootEntry } from './react-entry-compat.js';
|
|
6
|
+
import { reactComponentExportName } from './react-compat.js';
|
|
7
|
+
import { resolveReactComponentImport } from './react-import-resolution.js';
|
|
4
8
|
import { transpileComponent } from './source-map.js';
|
|
5
9
|
/** Compile authored components as Vite modules without writing into src. */
|
|
6
10
|
export function workstar(options = {}) {
|
|
7
11
|
let sourceDirectory;
|
|
8
12
|
let development = false;
|
|
9
13
|
const styleSuffix = '.css?workstar-style';
|
|
14
|
+
const foreignPrefix = '\0workstar-foreign:';
|
|
15
|
+
const foreignStylePrefix = '\0workstar-foreign-style:';
|
|
16
|
+
const foreignStylePublic = 'virtual:workstar-foreign-style:';
|
|
17
|
+
const foreignStyles = new Map();
|
|
10
18
|
const compiled = new Map();
|
|
11
19
|
function isAuthoredComponent(filename) {
|
|
12
20
|
const localPath = relative(sourceDirectory, filename);
|
|
@@ -15,6 +23,13 @@ export function workstar(options = {}) {
|
|
|
15
23
|
!localPath.startsWith(`..${sep}`) &&
|
|
16
24
|
!isAbsolute(localPath));
|
|
17
25
|
}
|
|
26
|
+
function isForeignSource(filename) {
|
|
27
|
+
const localPath = relative(sourceDirectory, filename);
|
|
28
|
+
return ((filename.endsWith('.tsx') || filename.endsWith('.vue')) &&
|
|
29
|
+
localPath !== '..' &&
|
|
30
|
+
!localPath.startsWith(`..${sep}`) &&
|
|
31
|
+
!isAbsolute(localPath));
|
|
32
|
+
}
|
|
18
33
|
function rootComponents(modules) {
|
|
19
34
|
const roots = new Set();
|
|
20
35
|
const visited = new Set();
|
|
@@ -31,20 +46,79 @@ export function workstar(options = {}) {
|
|
|
31
46
|
modules.forEach(visit);
|
|
32
47
|
return [...roots];
|
|
33
48
|
}
|
|
49
|
+
function compileForeignSource(source, filename) {
|
|
50
|
+
const converted = convertForeignComponent(source, filename, {
|
|
51
|
+
resolveReactImport: (specifier) => resolveReactComponentImport(filename, specifier),
|
|
52
|
+
});
|
|
53
|
+
const namedExport = filename.endsWith('.tsx')
|
|
54
|
+
? reactComponentExportName(source, filename)
|
|
55
|
+
: undefined;
|
|
56
|
+
const generated = compileComponentParts(converted, `${filename}.workstar`, {
|
|
57
|
+
cssImport: foreignStylePublic + encodeURIComponent(filename) + '.css',
|
|
58
|
+
});
|
|
59
|
+
foreignStyles.set(filename, generated.css);
|
|
60
|
+
const output = transpileComponent(`${generated.code}\nexport { render as ${namedExport ?? 'default'} };\n`, converted, filename, generated.origins);
|
|
61
|
+
return { code: output.code, map: null };
|
|
62
|
+
}
|
|
34
63
|
return {
|
|
35
64
|
name: 'workstar',
|
|
36
65
|
enforce: 'pre',
|
|
66
|
+
config() {
|
|
67
|
+
if (options.foreign !== 'runtime')
|
|
68
|
+
return;
|
|
69
|
+
const runtime = options.runtimeImportSource ?? 'workstar/compat/react';
|
|
70
|
+
const react = isAbsolute(runtime) ? `${runtime}/index.js` : runtime;
|
|
71
|
+
const client = isAbsolute(runtime)
|
|
72
|
+
? `${runtime}/client.js`
|
|
73
|
+
: `${runtime}/client`;
|
|
74
|
+
const router = isAbsolute(runtime)
|
|
75
|
+
? resolve(runtime, '../react-router/index.js')
|
|
76
|
+
: 'workstar/compat/react-router';
|
|
77
|
+
return {
|
|
78
|
+
esbuild: {
|
|
79
|
+
jsx: 'automatic',
|
|
80
|
+
jsxImportSource: runtime,
|
|
81
|
+
},
|
|
82
|
+
resolve: {
|
|
83
|
+
alias: [
|
|
84
|
+
{ find: 'react-router', replacement: router },
|
|
85
|
+
{ find: 'react-dom/client', replacement: client },
|
|
86
|
+
{ find: 'react', replacement: react },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
},
|
|
37
91
|
configResolved(config) {
|
|
38
92
|
sourceDirectory = resolve(config.root, options.source ?? 'src');
|
|
39
93
|
development = config.command === 'serve';
|
|
40
94
|
},
|
|
41
|
-
resolveId(id) {
|
|
95
|
+
resolveId(id, importer) {
|
|
96
|
+
if (id.startsWith(foreignStylePublic)) {
|
|
97
|
+
const encoded = id.slice(foreignStylePublic.length, -'.css'.length);
|
|
98
|
+
return foreignStylePrefix + decodeURIComponent(encoded) + '.css';
|
|
99
|
+
}
|
|
100
|
+
if (id.endsWith('?workstar') && importer && id.startsWith('.')) {
|
|
101
|
+
const importerPath = importer.startsWith(foreignPrefix)
|
|
102
|
+
? importer.slice(foreignPrefix.length)
|
|
103
|
+
: importer.split('?', 1)[0];
|
|
104
|
+
const filename = resolve(dirname(importerPath), id.slice(0, -'?workstar'.length));
|
|
105
|
+
return isForeignSource(filename) ? foreignPrefix + filename : null;
|
|
106
|
+
}
|
|
42
107
|
if (!id.endsWith(styleSuffix))
|
|
43
108
|
return null;
|
|
44
109
|
const filename = id.slice(0, -styleSuffix.length);
|
|
45
110
|
return isAuthoredComponent(filename) ? id : null;
|
|
46
111
|
},
|
|
47
112
|
async load(id) {
|
|
113
|
+
if (id.startsWith(foreignStylePrefix)) {
|
|
114
|
+
return (foreignStyles.get(id.slice(foreignStylePrefix.length, -'.css'.length)) ?? null);
|
|
115
|
+
}
|
|
116
|
+
if (id.startsWith(foreignPrefix)) {
|
|
117
|
+
const filename = id.slice(foreignPrefix.length);
|
|
118
|
+
this.addWatchFile(filename);
|
|
119
|
+
const source = await readFile(filename, 'utf8');
|
|
120
|
+
return compileForeignSource(source, filename);
|
|
121
|
+
}
|
|
48
122
|
if (!id.endsWith(styleSuffix))
|
|
49
123
|
return null;
|
|
50
124
|
const filename = id.slice(0, -styleSuffix.length);
|
|
@@ -55,6 +129,14 @@ export function workstar(options = {}) {
|
|
|
55
129
|
},
|
|
56
130
|
transform(source, id) {
|
|
57
131
|
const filename = id.split('?', 1)[0];
|
|
132
|
+
if (options.foreign === 'automatic' && isForeignSource(filename)) {
|
|
133
|
+
const entry = filename.endsWith('.tsx')
|
|
134
|
+
? convertReactRootEntry(source, filename)
|
|
135
|
+
: undefined;
|
|
136
|
+
if (entry)
|
|
137
|
+
return { code: entry, map: null };
|
|
138
|
+
return compileForeignSource(source, filename);
|
|
139
|
+
}
|
|
58
140
|
if (!isAuthoredComponent(filename))
|
|
59
141
|
return null;
|
|
60
142
|
const generated = compileComponentParts(source, filename, {
|
|
@@ -70,6 +152,31 @@ export function workstar(options = {}) {
|
|
|
70
152
|
};
|
|
71
153
|
},
|
|
72
154
|
async handleHotUpdate(context) {
|
|
155
|
+
if (options.foreign !== 'runtime' && isForeignSource(context.file)) {
|
|
156
|
+
const filename = context.file;
|
|
157
|
+
const source = await context.read();
|
|
158
|
+
const entry = options.foreign === 'automatic' && filename.endsWith('.tsx')
|
|
159
|
+
? convertReactRootEntry(source, filename)
|
|
160
|
+
: undefined;
|
|
161
|
+
if (!entry) {
|
|
162
|
+
const converted = convertForeignComponent(source, filename, {
|
|
163
|
+
resolveReactImport: (specifier) => resolveReactComponentImport(filename, specifier),
|
|
164
|
+
});
|
|
165
|
+
const next = compileComponentParts(converted, `${filename}.workstar`);
|
|
166
|
+
foreignStyles.set(filename, next.css);
|
|
167
|
+
}
|
|
168
|
+
const components = [
|
|
169
|
+
context.server.moduleGraph.getModuleById(foreignPrefix + filename),
|
|
170
|
+
options.foreign === 'automatic'
|
|
171
|
+
? context.server.moduleGraph.getModuleById(filename)
|
|
172
|
+
: undefined,
|
|
173
|
+
].filter((module) => module !== undefined);
|
|
174
|
+
const stylesheet = context.server.moduleGraph.getModuleById(foreignStylePrefix + filename + '.css');
|
|
175
|
+
components.forEach((module) => context.server.moduleGraph.invalidateModule(module));
|
|
176
|
+
if (stylesheet)
|
|
177
|
+
context.server.moduleGraph.invalidateModule(stylesheet);
|
|
178
|
+
return [...components, stylesheet].filter((module) => module !== undefined);
|
|
179
|
+
}
|
|
73
180
|
if (!isAuthoredComponent(context.file))
|
|
74
181
|
return;
|
|
75
182
|
const previous = compiled.get(context.file);
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { parseFragment } from 'parse5';
|
|
2
|
+
import { pathExpression, reject } from './compat-rules.js';
|
|
3
|
+
import { convertVueScript } from './vue-script-compat.js';
|
|
4
|
+
function content(source, node, filename) {
|
|
5
|
+
const location = node.sourceCodeLocation;
|
|
6
|
+
if (!location?.startTag || !location.endTag)
|
|
7
|
+
reject(filename, 'unclosed ' + node.tagName);
|
|
8
|
+
return source.slice(location.startTag.endOffset, location.endTag.startOffset);
|
|
9
|
+
}
|
|
10
|
+
function templatePath(path, refs, stores, filename) {
|
|
11
|
+
if (!pathExpression.test(path))
|
|
12
|
+
reject(filename, 'expression ' + path);
|
|
13
|
+
const root = path.split('.', 1)[0];
|
|
14
|
+
const fields = stores.get(root);
|
|
15
|
+
if (fields) {
|
|
16
|
+
const field = path.slice(root.length + 1).split('.', 1)[0];
|
|
17
|
+
if (!fields.has(field))
|
|
18
|
+
reject(filename, 'store field ' + path);
|
|
19
|
+
return path;
|
|
20
|
+
}
|
|
21
|
+
if (!refs.has(root))
|
|
22
|
+
return path;
|
|
23
|
+
const suffix = path.slice(root.length);
|
|
24
|
+
if (suffix.startsWith('.value'))
|
|
25
|
+
reject(filename, 'explicit .value in a Vue template');
|
|
26
|
+
return root + '.value' + suffix;
|
|
27
|
+
}
|
|
28
|
+
/** Converts Vue SFC markup with typed defineProps and optional CSS. */
|
|
29
|
+
export function convertVueComponent(source, filename = 'Component.vue') {
|
|
30
|
+
const blockErrors = [];
|
|
31
|
+
const fragment = parseFragment(source, {
|
|
32
|
+
sourceCodeLocationInfo: true,
|
|
33
|
+
onParseError: (error) => blockErrors.push(error.code),
|
|
34
|
+
});
|
|
35
|
+
if (blockErrors.length)
|
|
36
|
+
reject(filename, 'invalid SFC markup: ' + blockErrors[0]);
|
|
37
|
+
const meaningful = fragment.childNodes.filter((node) => !('value' in node) || node.value.trim());
|
|
38
|
+
if (meaningful.some((node) => !('tagName' in node)))
|
|
39
|
+
reject(filename, 'content outside SFC blocks');
|
|
40
|
+
const blocks = meaningful;
|
|
41
|
+
if (blocks.some((node) => !['template', 'script', 'style'].includes(node.tagName)))
|
|
42
|
+
reject(filename, 'unknown SFC block');
|
|
43
|
+
const templates = blocks.filter((node) => node.tagName === 'template');
|
|
44
|
+
const scripts = blocks.filter((node) => node.tagName === 'script');
|
|
45
|
+
const styles = blocks.filter((node) => node.tagName === 'style');
|
|
46
|
+
if (templates.length !== 1 || scripts.length > 1 || styles.length > 1)
|
|
47
|
+
reject(filename, 'SFC block count');
|
|
48
|
+
if (templates[0].attrs.length)
|
|
49
|
+
reject(filename, 'template attributes');
|
|
50
|
+
let scriptContent = '';
|
|
51
|
+
let refs = new Set();
|
|
52
|
+
let stores = new Map();
|
|
53
|
+
if (scripts[0]) {
|
|
54
|
+
const attrs = new Map(scripts[0].attrs.map((attribute) => [attribute.name, attribute.value]));
|
|
55
|
+
if (attrs.size !== 2 || !attrs.has('setup') || attrs.get('lang') !== 'ts')
|
|
56
|
+
reject(filename, 'script setup attributes');
|
|
57
|
+
const converted = convertVueScript(content(source, scripts[0], filename), filename);
|
|
58
|
+
scriptContent = [converted.props, converted.setup]
|
|
59
|
+
.filter(Boolean)
|
|
60
|
+
.join('\n');
|
|
61
|
+
refs = converted.refs;
|
|
62
|
+
stores = converted.stores;
|
|
63
|
+
}
|
|
64
|
+
let markup = content(source, templates[0], filename);
|
|
65
|
+
const templateErrors = [];
|
|
66
|
+
const templateTree = parseFragment(markup, {
|
|
67
|
+
sourceCodeLocationInfo: true,
|
|
68
|
+
onParseError: (error) => templateErrors.push(error.code),
|
|
69
|
+
});
|
|
70
|
+
if (templateErrors.length)
|
|
71
|
+
reject(filename, 'invalid template markup: ' + templateErrors[0]);
|
|
72
|
+
const replacements = [];
|
|
73
|
+
const visit = (nodes) => {
|
|
74
|
+
for (const node of nodes) {
|
|
75
|
+
if (!('tagName' in node)) {
|
|
76
|
+
if ('value' in node && node.sourceCodeLocation) {
|
|
77
|
+
const offset = node.sourceCodeLocation.startOffset;
|
|
78
|
+
const raw = markup.slice(offset, node.sourceCodeLocation.endOffset);
|
|
79
|
+
for (const match of raw.matchAll(/\{\{([^{}]+)\}\}/g)) {
|
|
80
|
+
const value = templatePath(match[1].trim(), refs, stores, filename);
|
|
81
|
+
replacements.push({
|
|
82
|
+
start: offset + match.index,
|
|
83
|
+
end: offset + match.index + match[0].length,
|
|
84
|
+
value: '{' + value + '}',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const start = node.sourceCodeLocation?.startTag?.startOffset;
|
|
91
|
+
const originalTag = start === undefined
|
|
92
|
+
? ''
|
|
93
|
+
: /^<\s*([A-Za-z][\w-]*)/.exec(markup.slice(start))?.[1];
|
|
94
|
+
if (!originalTag ||
|
|
95
|
+
/^[A-Z]/.test(originalTag) ||
|
|
96
|
+
originalTag === 'slot' ||
|
|
97
|
+
originalTag === 'component')
|
|
98
|
+
reject(filename, 'Vue component tag');
|
|
99
|
+
for (const attribute of node.attrs) {
|
|
100
|
+
const name = attribute.name;
|
|
101
|
+
if (!name.startsWith(':') && !name.startsWith('@')) {
|
|
102
|
+
if (name.startsWith('v-') ||
|
|
103
|
+
name.startsWith('#') ||
|
|
104
|
+
name === 'ref' ||
|
|
105
|
+
name === 'key')
|
|
106
|
+
reject(filename, 'Vue directive');
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const target = name.slice(1);
|
|
110
|
+
if (!/^[a-z][a-z0-9-]*$/.test(target) ||
|
|
111
|
+
target === 'style' ||
|
|
112
|
+
target === 'class' ||
|
|
113
|
+
target === 'key' ||
|
|
114
|
+
target === 'ref' ||
|
|
115
|
+
!pathExpression.test(attribute.value)) {
|
|
116
|
+
reject(filename, 'Vue directive ' + name);
|
|
117
|
+
}
|
|
118
|
+
const root = attribute.value.split('.', 1)[0];
|
|
119
|
+
if (name.startsWith('@') && (refs.has(root) || stores.has(root)))
|
|
120
|
+
reject(filename, 'state used as event handler');
|
|
121
|
+
const location = node.sourceCodeLocation?.attrs?.[name];
|
|
122
|
+
if (!location)
|
|
123
|
+
reject(filename, 'Vue directive ' + name);
|
|
124
|
+
replacements.push({
|
|
125
|
+
start: location.startOffset,
|
|
126
|
+
end: location.endOffset,
|
|
127
|
+
value: (name.startsWith('@') ? 'on:' : '') +
|
|
128
|
+
target +
|
|
129
|
+
'={' +
|
|
130
|
+
(name.startsWith('@')
|
|
131
|
+
? attribute.value
|
|
132
|
+
: templatePath(attribute.value, refs, stores, filename)) +
|
|
133
|
+
'}',
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
visit(node.tagName === 'template'
|
|
137
|
+
? node.content.childNodes
|
|
138
|
+
: node.childNodes);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
visit(templateTree.childNodes);
|
|
142
|
+
for (const replacement of replacements.sort((left, right) => right.start - left.start)) {
|
|
143
|
+
markup =
|
|
144
|
+
markup.slice(0, replacement.start) +
|
|
145
|
+
replacement.value +
|
|
146
|
+
markup.slice(replacement.end);
|
|
147
|
+
}
|
|
148
|
+
if (markup.includes('{{') || markup.includes('}}'))
|
|
149
|
+
reject(filename, 'unparsed interpolation');
|
|
150
|
+
let style = '';
|
|
151
|
+
if (styles[0]) {
|
|
152
|
+
const attrs = styles[0].attrs;
|
|
153
|
+
if (attrs.length > 1 || (attrs.length === 1 && attrs[0]?.name !== 'scoped'))
|
|
154
|
+
reject(filename, 'style attributes');
|
|
155
|
+
style =
|
|
156
|
+
'<style' +
|
|
157
|
+
(attrs.length ? '' : ' global') +
|
|
158
|
+
'>\n' +
|
|
159
|
+
content(source, styles[0], filename) +
|
|
160
|
+
'\n</style>\n';
|
|
161
|
+
}
|
|
162
|
+
return ((scriptContent
|
|
163
|
+
? '<script lang="ts">\n' + scriptContent + '\n</script>\n'
|
|
164
|
+
: '') +
|
|
165
|
+
markup +
|
|
166
|
+
'\n' +
|
|
167
|
+
style);
|
|
168
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface VueScriptCompat {
|
|
2
|
+
readonly props: string;
|
|
3
|
+
readonly setup: string;
|
|
4
|
+
readonly refs: ReadonlySet<string>;
|
|
5
|
+
readonly stores: ReadonlyMap<string, ReadonlySet<string>>;
|
|
6
|
+
}
|
|
7
|
+
/** Translate primitive Vue refs, flat reactive records, props, and their handlers. */
|
|
8
|
+
export declare function convertVueScript(script: string, filename: string): VueScriptCompat;
|