Created
June 16, 2023 22:49
-
-
Save Vap0r1ze/4df16264d450747d9d26162692e3d0af to your computer and use it in GitHub Desktop.
Vue 2 decompiler, probably incomplete, i made it and gave up same-day when i realized brilliant.org sourcemaps are public.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import * as acorn from 'acorn'; | |
import { generate } from 'astring'; | |
import type { | |
ConditionalExpression, | |
Expression, | |
FunctionExpression, | |
Literal, | |
Node, | |
Property, | |
ObjectExpression, | |
ArrayExpression, | |
SimpleLiteral, | |
SimpleCallExpression, | |
SpreadElement, | |
BinaryExpression, | |
} from 'estree'; | |
import { replace, traverse } from 'estraverse'; | |
import assert = require('assert') | |
const symbols = { | |
createElement: '_c', | |
list: '_vm._l', | |
createTextNode: '_vm._v', | |
stringify: '_vm._s', | |
empty: '_vm._e', | |
vm: '_vm', | |
slot: '_vm._t', | |
bind: '_vm._b', | |
bindDyn: '_vm._d', | |
static: '_vm._m', | |
listeners: '_vm._g', | |
key: '_k', | |
} | |
const listenerPrefixes = { | |
'~': 'once', | |
'!': 'capture', | |
'&': 'passive', | |
} | |
function parse(code: string, opts?: Partial<acorn.Options>) { | |
return acorn.parse(code, { | |
ecmaVersion: 2020, | |
sourceType: 'module', | |
...opts, | |
}) as Node; | |
} | |
function parseAs<K extends Node['type']>(code: string, type: K) { | |
return findNode(parse(code), type); | |
} | |
function findNode<K extends Node['type'], Found = Extract<Node, { type: K }>>( | |
ast: Node, | |
type: K, | |
predicate?: (node: Found) => boolean | |
): Found | null { | |
let found: Found | null = null; | |
traverse(ast, { | |
enter(node) { | |
if (node?.type !== type) return; | |
const foundMaybe = node as unknown as Found; | |
if (!predicate || predicate?.(foundMaybe)) { | |
found = foundMaybe; | |
this.break(); | |
} | |
}, | |
}); | |
return found; | |
} | |
function findNodes<K extends Node['type'], Found = Extract<Node, { type: K }>>( | |
ast: Node, | |
type: K, | |
predicate?: (node: Found) => boolean | |
): Found[] { | |
let found: Found[] = []; | |
traverse(ast, { | |
enter(node) { | |
if (node?.type !== type) return; | |
const foundMaybe = node as unknown as Found; | |
if (!predicate || predicate?.(foundMaybe)) found.push(foundMaybe); | |
}, | |
}); | |
return found; | |
} | |
export interface VueEmpty { | |
type: 'Empty'; | |
} | |
export interface VueFrag { | |
type: 'Fragment'; | |
children: VueElement[]; | |
} | |
export interface VueText { | |
type: 'Text'; | |
parts: (string | VueExpression)[]; | |
} | |
export interface VueExpression { | |
type: 'Expression'; | |
value: string; | |
} | |
export interface VueElement { | |
type: 'Element'; | |
name: string; | |
attrs: Record<string, string>; | |
children: Exclude<VueNode, VueEmpty | VueFrag>[]; | |
} | |
export type VueNode = VueText | VueElement | VueEmpty | VueFrag; | |
let currentCode = '' | |
export function parseRootTemplate(code: string) { | |
currentCode = code | |
const root = parseAs(code, 'ReturnStatement')!.argument! | |
const ast = parseExpression(root) | |
assert(ast.type === 'Element' || ast.type === 'Fragment') | |
currentCode = '' | |
return ast | |
} | |
function getStatic(index: number): VueElement { | |
const program = parseAs(currentCode, 'Program') | |
const statics = findNode(program, 'VariableDeclaration', node => generate(node.declarations[0].id) === 'staticRenderFns')!.declarations[0].init as ArrayExpression | |
const staticFn = statics.elements[index] | |
assert(staticFn.type === 'FunctionExpression') | |
const root = findNode(staticFn, 'ReturnStatement')!.argument! | |
const staticEl = parseExpression(root) | |
assert(staticEl.type === 'Element') | |
return staticEl | |
} | |
function parseExpression(expr: Expression) { | |
if (expr.type === 'CallExpression') { | |
const fn = generate(expr.callee) | |
if (fn === symbols.createElement) return parseCreateElement(expr) | |
if (fn === symbols.createTextNode) return parseTextNode(expr) | |
if (fn === symbols.list) return parseList(expr) | |
if (fn === symbols.slot) return parseSlot(expr) | |
if (fn === symbols.empty) return { type: 'Empty' } as VueEmpty | |
if (fn === symbols.static) return getStatic((expr.arguments[0] as SimpleLiteral).value as number) | |
} | |
if (expr.type === 'ConditionalExpression') return parseConditional(expr) | |
if (expr.type === 'ArrayExpression') return parseTemplate(expr) | |
throw new Error(`Unknown expression: ${generate(expr)}`) | |
} | |
function parseSlot(expr: SimpleCallExpression): VueElement { | |
assertNoSpreads(expr.arguments) | |
const [nameLit, fallback, attrsExpr] = expr.arguments as [SimpleLiteral, Expression?, ObjectExpression?] | |
const hasFallback = fallback && generate(fallback) !== 'null' | |
const children = hasFallback ? flattenChildren(parseChildren(fallback)) : [] | |
const el: VueElement = { | |
type: 'Element', | |
name: 'slot', | |
attrs: {}, | |
children, | |
} | |
if (attrsExpr) { | |
assertNoSpreads(attrsExpr.properties) | |
for (const prop of attrsExpr.properties) { | |
el.attrs[`:${getAttrKey(prop)}`] = getAttrValue(prop) | |
} | |
} | |
const name = nameLit.value as string | |
if (name !== 'default') el.attrs.name = name | |
return el | |
} | |
function parseTemplate(expr: ArrayExpression): VueElement { | |
return { | |
type: 'Element', | |
name: 'template', | |
attrs: {}, | |
children: flattenChildren(parseChildren(expr)), | |
} | |
} | |
function parseConditional(expr: ConditionalExpression): VueElement | VueFrag { | |
const cond = generateNorm(expr.test) | |
const ifTrue = parseExpression(expr.consequent) as VueNode | |
const ifFalse = parseExpression(expr.alternate) as VueNode | |
assert(ifTrue.type === 'Element') | |
assert(ifFalse.type === 'Element' || ifFalse.type === 'Fragment' || ifFalse.type === 'Empty') | |
ifTrue.attrs = { 'v-if': cond, ...ifTrue.attrs } | |
if (ifFalse.type === 'Empty') return ifTrue | |
const frag: VueFrag = { | |
type: 'Fragment', | |
children: [ifTrue], | |
} | |
if (ifFalse.type === 'Fragment') { | |
const [ifElse, ...elses] = ifFalse.children | |
const ifElseCond = ifElse.attrs['v-if'] | |
delete ifElse.attrs['v-if'] | |
if (ifElseCond) ifElse.attrs = { 'v-else-if': ifElseCond, ...ifElse.attrs } | |
frag.children.push(ifElse, ...elses) | |
} else { | |
if (ifFalse.attrs['v-if']) { | |
const cond = ifFalse.attrs['v-if'] | |
delete ifFalse.attrs['v-if'] | |
ifFalse.attrs = { 'v-else-if': cond, ...ifFalse.attrs } | |
} else { | |
ifFalse.attrs = { 'v-else': '', ...ifFalse.attrs } | |
} | |
frag.children.push(ifFalse) | |
} | |
return frag | |
} | |
function parseTextNode(expr: SimpleCallExpression): VueText | VueEmpty { | |
assertNoSpreads(expr.arguments) | |
const parts = unwrapBinary(expr.arguments[0], '+') | |
const vText: VueText = { | |
type: 'Text', | |
parts: [], | |
} | |
for (const part of parts) { | |
if (part.type === 'Literal' && typeof part.value === 'string') { | |
let text = part.value | |
if (part === parts[0]) text = text.trimStart() | |
if (part === parts[parts.length - 1]) text = text.trimEnd() | |
if (!text) continue | |
vText.parts.push(text) | |
} else if (part.type === 'CallExpression' && generate(part.callee) === symbols.stringify) { | |
vText.parts.push({ | |
type: 'Expression', | |
value: generateNorm(part.arguments[0]), | |
}) | |
} else { | |
throw new Error(`Unknown text node part: ${generate(part)}`) | |
} | |
} | |
if (vText.parts.length === 0) return { type: 'Empty' } | |
return vText | |
} | |
function unwrapBinary<E extends Expression>(expr: Expression, op: BinaryExpression['operator']) { | |
const parts: E[] = [] | |
while (expr.type === 'BinaryExpression' && expr.operator === '+') { | |
parts.unshift(expr.right as E) | |
expr = expr.left | |
} | |
parts.unshift(expr as E) | |
return parts | |
} | |
function parseList(expr: SimpleCallExpression): VueElement { | |
assertNoSpreads(expr.arguments) | |
const [arrayExpr, callbackExpr] = expr.arguments as [Expression, FunctionExpression] | |
const itemElement = parseCreateElement(findNode(callbackExpr, 'ReturnStatement')!.argument as SimpleCallExpression) | |
const params = callbackExpr.params.map(p => generate(p)).join(', ') | |
const array = generateNorm(arrayExpr) | |
itemElement.attrs = { | |
'v-for': `(${params}) in ${array}`, | |
...itemElement.attrs, | |
} | |
return itemElement | |
} | |
function parseCreateElement(expr: SimpleCallExpression): VueElement { | |
assertNoSpreads(expr.arguments) | |
const [tagLit, secondExpr, thirdExpr] = expr.arguments as [Expression, Expression, Expression] | |
const attrsExpr = isAttributes(secondExpr) ? secondExpr : null | |
const childrenExpr = attrsExpr ? thirdExpr : secondExpr | |
const attrs = attrsExpr ? parseAttrs(attrsExpr) : {} | |
const children = childrenExpr ? flattenChildren(parseChildren(childrenExpr)) : [] | |
let tag = (tagLit as SimpleLiteral).value as string | |
if (attrs['v-is']) { | |
const is = attrs['v-is'] | |
delete attrs['v-is'] | |
attrs[':is'] = generateNorm(tagLit) | |
tag = is | |
} | |
if (!tag) throw new Error(`Unknown tag: ${generate(tagLit)}`) | |
return { | |
type: 'Element', | |
name: tag, | |
attrs, | |
children, | |
} | |
} | |
function isAttributes(expr?: Expression): expr is ObjectExpression | SimpleCallExpression { | |
if (!expr) return false | |
if (expr.type === 'ObjectExpression') return true | |
if (expr.type === 'CallExpression') { | |
const fn = generate(expr.callee) | |
return fn === symbols.bind || fn === symbols.listeners | |
} | |
return false | |
} | |
function flattenChildren(children: VueNode[]): VueElement['children'] { | |
return children | |
.filter((child: VueNode): child is Exclude<VueNode, VueEmpty> => child.type !== 'Empty') | |
.flatMap(child => child.type === 'Fragment' ? child.children : child) | |
} | |
function parseAttrs(attrsExpr: ObjectExpression | SimpleCallExpression): Record<string, string> { | |
if (attrsExpr.type === 'CallExpression') { | |
assertNoSpreads(attrsExpr.arguments) | |
if (generate(attrsExpr.callee) === symbols.bind) { | |
const [objExpr,, boundExpr, asProp, isSync] = attrsExpr.arguments as [ObjectExpression, SimpleLiteral, Expression, SimpleLiteral, SimpleLiteral] | |
assert(asProp.value === false) | |
assert(isSync == null) | |
const simpleAttrs = parseAttrs(objExpr) | |
if (boundExpr.type === 'CallExpression' && generate(boundExpr.callee) === symbols.bindDyn) { | |
const [dynObj, dynEntries] = boundExpr.arguments as [ObjectExpression, ArrayExpression] | |
if (dynObj.properties.length !== 0) throw new Error(`Unknown dynamic object: ${generate(dynObj)}`) | |
const attrs: Record<string, string> = {} | |
for (let i = 0; i < dynEntries.elements.length; i += 2) { | |
const key = generateNorm(dynEntries.elements[i]) | |
const value = generateNorm(dynEntries.elements[i + 1]) | |
attrs[`:[${key}]`] = value | |
return { ...attrs, ...simpleAttrs } | |
} | |
} | |
return { 'v-bind': generateNorm(boundExpr), ...simpleAttrs } | |
} else if (generate(attrsExpr.callee) === symbols.listeners) { | |
const [objExpr, listenersExpr] = attrsExpr.arguments as [ObjectExpression, Expression] | |
const simpleAttrs = parseAttrs(objExpr) | |
return { 'v-on': generateNorm(listenersExpr), ...simpleAttrs } | |
} | |
throw new Error(`Unknown attribute: ${generate(attrsExpr)}`) | |
} | |
const attrs: Record<string, string> = {} | |
for (const prop of attrsExpr.properties) { | |
assert(prop.type === 'Property') | |
assert(prop.key.type === 'Identifier') | |
switch (prop.key.name) { | |
case 'staticClass': { | |
attrs['class'] = (prop.value as SimpleLiteral).value as string | |
break | |
} | |
case 'staticStyle': { | |
const style = JSON.parse(generate(prop.value)) | |
attrs['style'] = Object.entries(style).map(([key, value]) => `${key}: ${value}`).join('; ') | |
break | |
} | |
case 'ref': { | |
attrs['ref'] = (prop.value as SimpleLiteral).value as string | |
break | |
} | |
case 'key': { | |
attrs[':key'] = generateNorm(prop.value as Expression) | |
break | |
} | |
case 'class': { | |
attrs[':class'] = generateNorm(prop.value as Expression) | |
break | |
} | |
case 'style': { | |
attrs[':style'] = generateNorm(prop.value as Expression) | |
break | |
} | |
case 'on': { | |
assert(prop.value.type === 'ObjectExpression') | |
for (const eventProp of prop.value.properties) { | |
assert(eventProp.type === 'Property') | |
let listenerKey = getAttrKey(eventProp) | |
if (attrs['v-model'] && listenerKey === '__r') continue | |
if (attrs['v-model'] && listenerKey === 'input') continue | |
if (attrs['v-model'] && listenerKey === 'change') continue | |
if (eventProp.value.type === 'FunctionExpression') { | |
const modifiers: string[] = [] | |
let stmts = [...eventProp.value.body.body] | |
for (let stmt = stmts[0]; stmts[0]; stmts.shift(), stmt = stmts[0]) { | |
if (stmt.type === 'ReturnStatement') break | |
const stmtCode = generate(stmt) | |
if (stmtCode.includes('$event && $event.button !== 0')) continue | |
if (stmtCode === '$event.stopPropagation();') { modifiers.push('stop'); continue } | |
if (stmtCode === '$event.preventDefault();') { modifiers.push('prevent'); continue } | |
if (stmtCode.includes('$event.target !== $event.currentTarget')) { modifiers.push('self'); continue } | |
const keyChecks = findNodes(stmt, 'CallExpression', node => generate(node.callee) === symbols.key) | |
for (const keyCheck of keyChecks) { | |
const keyCode = keyCheck.arguments[1] as SimpleLiteral | |
modifiers.push(keyCode.value as string) | |
} | |
if (keyChecks.length) continue | |
break | |
} | |
let pre: string | |
while (pre = Object.keys(listenerPrefixes).find(pre => listenerKey.startsWith(pre))) { | |
listenerKey = listenerKey.slice(pre.length) | |
modifiers.push(listenerPrefixes[pre]) | |
} | |
if (stmts.length > 1) throw new Error(`Unknown event handler: ${stmts.map(stmt => generate(stmt)).join('\n')}`) | |
for (const mod of modifiers) listenerKey += `.${mod}` | |
if (stmts[0]) attrs[`@${listenerKey}`] = generateNorm(stmts[0].type === 'ReturnStatement' ? stmts[0].argument! : stmts[0]) | |
else attrs[`@${listenerKey}`] = '' | |
} else { | |
attrs[`@${listenerKey}`] = generateNorm(eventProp.value) | |
} | |
} | |
break | |
} | |
case 'attrs': { | |
assert(prop.value.type === 'ObjectExpression') | |
for (const attrProp of prop.value.properties) { | |
assert(attrProp.type === 'Property') | |
const key = getAttrKey(attrProp) | |
if (attrProp.value.type === 'Literal' && typeof attrProp.value.value === 'string') { | |
attrs[key] = attrProp.value.value | |
} else { | |
attrs[`:${key}`] = generateNorm(attrProp.value as Expression) | |
} | |
} | |
break | |
} | |
case 'directives': { | |
assert(prop.value.type === 'ArrayExpression') | |
for (const dirExpr of prop.value.elements) { | |
assert(dirExpr.type === 'ObjectExpression') | |
assert(dirExpr.properties.every((prop): prop is Property => prop.type === 'Property')) | |
const nameProp = dirExpr.properties.find(prop => generate(prop.key) === 'rawName') | |
const expressionProp = dirExpr.properties.find(prop => generate(prop.key) === 'expression') | |
assert(nameProp.value.type === 'Literal' && expressionProp.value.type === 'Literal') | |
attrs[nameProp.value.value as string] = expressionProp.value.value as string | |
} | |
break | |
} | |
case 'domProps': { | |
assert(prop.value.type === 'ObjectExpression') | |
for (const attrProp of prop.value.properties) { | |
assert(attrProp.type === 'Property') | |
const attrKey = getAttrKey(attrProp) | |
if (attrs['v-model'] && attrKey === 'checked') continue | |
if (attrs['v-model'] && attrKey === 'value') continue | |
assert(attrProp.value.type === 'CallExpression') | |
if (generate(attrProp.value.callee) !== symbols.stringify) throw new Error(`Unknown domProp: ${generate(attrProp.value)}`) | |
attrs[`:${attrKey}.prop`] = generateNorm(attrProp.value.arguments[0]) | |
} | |
break | |
} | |
case 'model': { | |
assert(prop.value.type === 'ObjectExpression') | |
assertNoSpreads(prop.value.properties) | |
const valueProp = prop.value.properties.find(prop => generate(prop.key) === 'value') | |
attrs['v-model'] = generateNorm(valueProp.value) | |
break | |
} | |
case 'tag': { | |
attrs['v-is'] = (prop.value as SimpleLiteral).value as string | |
break | |
} | |
case 'scopedSlots': { | |
// TODO | |
break | |
} | |
case 'slot': break | |
default: { | |
throw new Error(`Unknown attribute: ${prop.key.name}`) | |
} | |
} | |
} | |
return attrs | |
} | |
function parseChildren(childrenExpr: Expression) { | |
if (childrenExpr.type === 'ArrayExpression') { | |
assertNoSpreads(childrenExpr.elements) | |
return childrenExpr.elements.map(el => parseExpression(el)) | |
} else { | |
return [parseExpression(childrenExpr)] | |
} | |
} | |
function generateNorm(expr: Node) { | |
return generate(replace(JSON.parse(JSON.stringify(expr)), { | |
enter(node: Node) { | |
if (node.type === 'MemberExpression' && !node.computed && generate(node.object) === symbols.vm) { | |
return node.property | |
} | |
} | |
}) as Node) | |
} | |
function assertNoSpreads<T extends { type: string }>(list: T[]): asserts list is Exclude<T, SpreadElement>[] { | |
assert(list.every((arg): arg is T => arg.type !== 'SpreadElement')) | |
} | |
function getAttrValue(prop: Property) { | |
if (prop.value.type === 'Literal' && typeof prop.value.value === 'string') return prop.value.value | |
return generateNorm(prop.value) | |
} | |
function getAttrKey(prop: Property) { | |
if (prop.key.type === 'Literal') return prop.key.value as string | |
if (prop.key.type === 'Identifier') return prop.key.name | |
throw new Error(`Unknown key type: ${prop.key.type}`) | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { VueElement, VueFrag, VueText } from './parse' | |
const singleTags = new Set([ | |
'img', | |
'br', | |
'hr', | |
'input', | |
'link', | |
'meta', | |
]) | |
const DO_NEWLINE = true | |
export function transpileTemplate(el: VueElement | VueFrag) { | |
const transpiled = el.type === 'Fragment' ? el.children.map(node => transpileNode(node)).join(joiner) : transpileNode(el) | |
return `<template>${joiner}${transpiled}${joiner}</template>` | |
} | |
const joiner = DO_NEWLINE ? '\n' : '' | |
function transpileNode(node: VueText | VueElement): string { | |
if (node.type === 'Element') { | |
let opener = `<${node.name}` | |
for (const [key, value] of Object.entries(node.attrs)) { | |
opener += ` ${key}` | |
if (value == null || typeof value.replace !== 'function') console.log(key, value) | |
if (value) opener += `="${value.replace(/"/g, '"')}"` | |
} | |
if (singleTags.has(node.name) || node.children.length === 0) return `${opener} />` | |
return `${opener}>${joiner}${node.children.map(node => transpileNode(node)).join(joiner)}${joiner} </${node.name}>` | |
} else if (node.type === 'Text') { | |
return node.parts.map(part => { | |
if (typeof part === 'string') return part | |
if (part.type === 'Expression') return `{{ ${part.value} }}` | |
}).join('') | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment