Skip to content

Instantly share code, notes, and snippets.

@Vap0r1ze
Created June 16, 2023 22:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Vap0r1ze/4df16264d450747d9d26162692e3d0af to your computer and use it in GitHub Desktop.
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.
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}`)
}
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, '&quot;')}"`
}
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