@linden.dev/vue-unclassify
Version:
Create Vue 3 script setup SFC from Vue2/3 class based TypeScript SFCs
360 lines (307 loc) • 14.8 kB
text/typescript
import { AnyNode, CallExpression, ClassDeclaration, ExportDefaultDeclaration, ExportNamedDeclaration,
Expression, Literal, MethodDefinition, PropertyDefinition, Node} from 'acorn';
import { applyRecursively, DeconstructedProperty, isDecorated, isDecoratedWith, parseTS } from './astTools';
const removeExports = ['vue-property-decorator', 'vue-class-component', 'vue-facing-decorator', ' Vue ', ' Vue, '];
interface Issue {
message: string;
node: Node;
}
export function transpileTemplate(codeText: string, context?: any) {
const emits = [...codeText.matchAll(/\$emit\s?\(['"]([a-zA-Z0-9]+)['"]/g)].map(x => x[1]);
if (context)
context.emits = [...new Set(emits)];
return codeText.replace(/\$emit\s?\(/g, 'emit(');
}
export function transpile(codeText: string, templateContext?: { emits?: string[] }) {
// Fixup: interface before @Component -> syntax error
codeText = codeText.replace(/@Component[\(\s$]/, ';$&');
const code = parseTS(codeText);
let xformed = '';
const issues: Issue[] = [];
function emitSectionHeader(text: string | null) {
emitLine(`// ${text}`);
}
function emitLine(text: string | null) {
if (text?.trim()?.length) {
xformed += text;
emitNewLine();
}
}
function emitNewLine() {
xformed += code.newLine;
}
function emitComments(node: AnyNode) {
const comments = code.getCommentsFor(node);
if (comments?.length) {
emitNewLine();
xformed += comments;
}
}
// Imports
emitLine('import { ref, computed, watch, onMounted } from \'vue\'');
code.ast.body.filter(x => x.type === 'ImportDeclaration')
.map(code.getSource)
.filter((x: string | null) => !removeExports.some(r => x!.includes(r)))
.map(emitLine);
emitNewLine();
// Try vue-facing-decorator style class def first (class X extends Vue ... export default X)
let classNode = code.ast.body.find(x => x.type === 'ClassDeclaration' && x.superClass?.type === 'Identifier' && x.superClass.name === 'Vue') as ClassDeclaration;
if (classNode == null) {
// Try old style (export default class X)
const expDefNode = code.ast.body.find(x => x.type === 'ExportDefaultDeclaration') as ExportDefaultDeclaration;
if (expDefNode?.declaration?.type === 'ClassDeclaration')
classNode = expDefNode?.declaration as ClassDeclaration;
}
const className = classNode && code.getSource(classNode.id);
// Code outside class
const ignoredOutsideTypes = ['EmptyStatement', 'ExportDefaultDeclaration', 'ExportNamedDeclaration', 'ImportDeclaration',];
const outsideCode = code.ast.body.filter(x => x !== classNode && !ignoredOutsideTypes.includes(x.type));
if (outsideCode?.length) {
for (const c of outsideCode) {
emitComments(c);
emitLine(code.unIndent(code.getSource(c)!));
emitNewLine();
}
}
if (!classNode) {
emitSectionHeader('Transpilation failed; could not identify component class node');
return xformed;
}
emitComments(classNode);
const memberNodes = classNode.body.body;
const properties = memberNodes.filter(x => x.type === 'PropertyDefinition') as any as PropertyDefinition[];
// Static non-reactive data (static properties)
const staticMembers = properties.filter(x => x.static).map(code.deconstructProperty);
if (staticMembers?.length) {
emitSectionHeader('Static shared data (move to separate script section?)');
for (const { id, typeStr, node } of staticMembers) {
emitComments(node);
const initializer = node.value != null ? ' = ' + code.unIndent(code.getSource(node.value)!) : '';
emitLine(`const ${id}${typeStr ? ': ' + typeStr : ''}${initializer};`);
}
emitNewLine();
}
// Static non-reactive data (uninitialized instance properties)
const nonReactiveMembers = properties.filter(x => !x.static && !isDecorated(x) && x.value == null).map(code.deconstructProperty);
if (nonReactiveMembers?.length) {
emitSectionHeader('Non-reactive data');
for (const { id, typeStr, node } of nonReactiveMembers) {
code.deconstructProperty(node);
emitComments(node);
emitLine(`let ${id}${typeStr ? ': ' + typeStr : ''};`);
}
emitNewLine();
}
// Props
const props = properties.filter(x => isDecoratedWith(x, 'Prop')).map(code.deconstructProperty);
const propIdentifiers: { [id: string]: AnyNode } = {};
if (props?.length) {
emitSectionHeader('Props');
emitLine('const props = defineProps<{');
for (const { id, typeStr, node } of props) {
propIdentifiers[id] = node;
emitComments(node);
emitLine(`\t${id}${typeStr ? ': ' + typeStr : ''}${node.value != null ? ' = ' + code.getSource(node.value) : ''};`);
}
emitLine('}>();');
emitNewLine();
}
// Emits - found by usage
const emits: { [id: string]: AnyNode | null } = {};
templateContext?.emits?.forEach(x => emits[x] = null);
applyRecursively(classNode.body, n => {
if (n.type === 'CallExpression' && n.callee.type === 'MemberExpression') {
const name = code.getSource(n.callee.property);
if (name === '$emit' && n.arguments?.length >= 1) {
const eventName = (n.arguments[0] as Literal).value;
if (typeof eventName === 'string' && !emits[eventName])
emits[eventName] = n;
else
issues.push({ message: 'Failed to interpret $emit call', node: n })
}
}
});
const emitNames = Object.keys(emits);
if (emitNames.length) {
emitSectionHeader('Emits');
emitLine(`const emit = defineEmits(['${emitNames.join('\', \'')}']);`);
emitNewLine();
}
// Refs
const refs = properties.filter(x => !x.static && !isDecorated(x) && x.value != null).map(code.deconstructProperty);
const refIdentifiers: { [id: string]: AnyNode } = {};
if (refs?.length) {
emitSectionHeader('State');
for (const { id, typeStr, node } of refs) {
refIdentifiers[id] = node;
emitComments(node);
emitLine(`const ${id} = ref${typeStr ? (`<${typeStr}>`) : ''}(${code.getSource(node.value)});`);
}
emitNewLine();
}
// function/lambda body transpilation
const thisDot = `([^a-zA-Z0-9]|^)this\\.`;
function replaceThisExpr(code: string, member: string, prefix?: string, newName?: string | null, suffix?: string) {
const regex = new RegExp(`${thisDot}${member}([^a-zA-Z0-9]|$)`, 'g');
return code.replace(regex, `$1${prefix ?? ''}${newName ?? member}${suffix ?? ''}$2`)
}
const computedIdentifiers: { [id: string]: AnyNode } = {};
const staticRefRegexp = new RegExp(`([^a-zA-Z0-9]|^)${className}\\.`, 'g');
const watchRegexp = new RegExp(`${thisDot}\\$watch\\s?\\(\\s?['"]([^'"]+)['"]`, 'g');
const otherMemberRegexp = new RegExp(`${thisDot}`, 'g');
function transpiledText(node: MethodDefinition | PropertyDefinition | Expression | string, unIndent?: boolean) {
let bodyText: string;
if (typeof node == 'string')
bodyText = node;
else if (node.type === 'MethodDefinition')
bodyText = code.asLambda(node)!;
else
bodyText = code.getSource(node)!;
// this.$watch(...) -> watch(...) (keep `this.` to apply observables etc below)
bodyText = bodyText.replace(watchRegexp, '$1watch(() => this.$2');
if (typeof node !== 'string')
reportShadowedProps(node, issues);
// this.[prop] -> props.[prop]
for (const prop of Object.keys(propIdentifiers))
bodyText = replaceThisExpr(bodyText, prop, 'props.');
// this.[observable] -> [observable].value
for (const prop of Object.keys(refIdentifiers))
bodyText = replaceThisExpr(bodyText, prop, '', null, '.value');
// this.[computed] -> [computed].value
for (const prop of Object.keys(computedIdentifiers))
bodyText = replaceThisExpr(bodyText, prop, '', null, '.value');
// this.$emit(ev, ...) -> emit(ev, ...)
bodyText = replaceThisExpr(bodyText, '\\$emit', '', 'emit');
// this.$nextTick(...) -> nextTick(ev, ...)
bodyText = replaceThisExpr(bodyText, '\\$nextTick', '', 'nextTick');
// <className>.method/property (static member reference)
bodyText = bodyText.replace(staticRefRegexp, '$1');
// this.[other member] -> [other member]
bodyText = bodyText.replace(otherMemberRegexp, '$1');
if (unIndent === false)
return bodyText;
return code.unIndent(bodyText);
}
const methods = memberNodes.filter(x => x.type === 'MethodDefinition') as MethodDefinition[];
// Computeds
const computeds = methods.filter(x => !isDecorated(x) && x.kind == 'get').map(code.deconstructProperty);
const computedSetters = new Map(
methods.filter(x => !isDecorated(x) && x.kind == 'set')
.map(code.deconstructProperty)
.map(x => [x.id, x.node]));
if (computeds?.length) {
// Gather definitions
for (const { id, node } of computeds)
computedIdentifiers[id] = node;
// Transpile references
emitSectionHeader('Computeds');
for (const { id, node } of computeds) {
const setter = computedSetters.get(id);
if (setter) {
emitComments(node);
emitLine(`const ${id} = computed({`);
emitComments(node);
emitLine(`\tget: ${transpiledText(node, false)},`);
emitLine(`\tset: ${transpiledText(setter, false)}`);
emitLine(`});`);
emitNewLine();
} else {
emitComments(node);
emitLine(`const ${id} = computed(${transpiledText(node)});`);
emitNewLine();
}
}
}
// Watches
const watches = methods.filter(x => isDecoratedWith(x, 'Watch')).map(code.deconstructProperty);
if (watches?.length) {
emitSectionHeader('Watches');
for (const { node } of watches) {
const deco = (node as any).decorators[0].expression as CallExpression;
const decoArg0 = (deco.arguments[0] as Literal).value;
const decoArg1 = (deco.arguments?.length > 1 ? deco.arguments[1] : null) as Expression;
const watchedExpr = transpiledText(`this.${decoArg0}`);
const handler = transpiledText(node);
const extraArg = `${decoArg1 ? (', ' + code.getSource(decoArg1)) : ''}`;
emitComments(node);
emitLine(`watch(() => ${watchedExpr}, ${handler}${extraArg});`);
emitNewLine();
}
}
const plainMethods = methods.filter(x => !isDecorated(x) && x.kind == 'method').map(code.deconstructProperty)
// Life cycle hooks
const specialMethods = ['created', 'mounted'];
const specialFunctions = plainMethods.filter(({ id }) => specialMethods.includes(id));
if (specialFunctions?.length) {
emitSectionHeader('Initialization');
for (const { id, node } of specialFunctions) {
emitComments(node);
if (id == 'created')
emitLine(code.unIndent(transpiledText((node.value! as any).body)).slice(2, -2).trim());
else if (id == 'mounted')
emitLine(`onMounted(${transpiledText(node)});`);
else
continue;
emitNewLine();
}
}
function emitFunctions(functions: DeconstructedProperty[]) {
for (const f of functions) {
emitComments(f.node);
emitLine(`${f.async ? 'async ' : ''}function ${f.id}${transpiledText(f.node.value!)}`);
emitNewLine();
}
}
// Regular functions
const functions = plainMethods.filter(({ id, node }) => !node.static && !specialMethods.includes(id));
if (functions?.length) {
emitSectionHeader('Functions');
emitFunctions(functions);
}
// Static functions
const staticFunctions = plainMethods.filter(({ id, node }) => node.static && !specialMethods.includes(id));
if (staticFunctions?.length) {
emitSectionHeader('Static functions');
emitFunctions(staticFunctions);
}
// Exports (skip export of Vue class from vue-facing-decorator)
const exportNodes = code.ast.body.filter(x => x.type === 'ExportNamedDeclaration')
.filter(c => code.getSource((c as ExportNamedDeclaration).specifiers[0]) !== className);
if (exportNodes?.length) {
emitSectionHeader('Exports');
for (const c of exportNodes) {
emitComments(c);
emitLine(code.unIndent(code.getSource(c)!));
emitNewLine();
}
}
if (issues?.length) {
emitSectionHeader('Transpilation issues');
issues.forEach(x => emitLine(`// * ${x.message} (script section, row ${x.node.loc?.start?.line})`));
}
return xformed;
}
function reportShadowedProps(node: Expression | MethodDefinition | PropertyDefinition, issues: Issue[]) {
const thisUses: string[] = [];
const locals = new Map<string, Node>();
applyRecursively(node, x => {
if (x.type === 'MemberExpression' && x.object.type === 'ThisExpression') {
const member = x.property.type === 'Identifier' ? x.property.name : null;
if (member)
thisUses.push(member);
} else if (x.type === 'VariableDeclaration') {
for (const decl of x.declarations) {
if (decl.id.type === 'Identifier' && decl.init?.type !== 'CallExpression' && !locals.has(decl.id.name))
locals.set(decl.id.name, decl);
}
}
});
const shadows = new Set(thisUses.filter(x => locals.has(x)));
for (const x of shadows) {
const node = locals.get(x);
issues.push({
message: `Local '${x}' shadows use of member with the same name. Rename to avoid compilation errors.`,
node: node!
});
}
}