This project was made possible by the wonderful people at Infraspeak.
This program was created to batch migrate vue components using the Class Property Decorators to regular Options API syntax. The goal is to deprecate the use of Class Property Decorators on a codebase, which is not compatible with Vue 3.
- Clone this repository somewhere in your computer
git git@github.com:Marantesss/decorators-to-object-api-transpiler.git
cd decorators-to-object-api-transpiler
- Compile this project
It is recommended to use nvm
(Node Version Manager) to use the correct version of node
and pnpm
nvm use # use node 18.12.1 in .nvmrc
npm install -g pnpm # install pnpm
pnpm install # install dependencies
pnpm build # build project to dist/ folder
- Run the project with node
node dist <...args>
For your convenience, we recommend to add create a new alias/function to your shell config file, like so:
d2o() { # d2o (decorators-to-options) but call it whatever you want
nvm run 18.12.1 <decorators-to-options-api-folder>/dist/index.js "$@"
}
Note that we're using nvm
to run the project with node
18.12.1
and also support arguments with "$@"
.
Refreshing your shell session, you can now run the project anywhere:
$ d2o
Running node v18.12.1 (npm v8.19.2)
Usage: Decorators to Options API d2o <filepaths>
This CLI converts Vue SFC using class decorators syntax to regular options API
Options:
-V, --version output the version number
-f, --files <filepaths...> Accept file paths or globs (default: [])
-l, --linter <filepath> Accept linter config file path, "./.eslintrc.js" by default
-s, --silent DO NOT log stuff to console (default: false)
-h, --help display help for command
To make use of this tool from VSCode we need to setup a custom task. For maximum convenience we’ll also setup a key binding for this new task.
Open or create file <web-core-client-repo>/.vscode/tasks.json
and add a new task:
{
"label": "decorators-to-options-api",
"type": "shell",
"command": "d2o -f ${file} -l ./.eslintrc.js",
"presentation": {
"reveal": "silent",
"panel": "new"
}
}
Just remember to define your current setup by changing the <decorators-to-options-api-folder>
to your actual path.
Secondly, you can add the following to your keybindings.json
. Instructions on how to do so are available on VSCode’s docs.
{
"key": "F10", // feel free to change this to something else
"command": "workbench.action.tasks.runTask",
"args": "decorators-to-options-api" // this must be the same as label task property
}
Finally, if you're getting an command not found: d2o
error, then it's because your vscode's terminal is not reading your shell config file. You can change that in the settings.json
file by adding the following lines:
// you should change these values to osx or windows depending on your OS
// also, if your not using zsh, and are using bash, fish or even WSL, please check the documentation on how to correctly config this
"terminal.integrated.defaultProfile.linux": "zsh",
"terminal.integrated.profiles.linux": { "zsh": { "path": "/bin/zsh", "args": ["-l", "-i"] } }
There are however, some limitations which must be fixed by hand after generating an options API component:
- JSDOC is ignored during AST generation and therefore is lost on the generated component;
- Some of
ESLint
styles might not work at first (not sure why), therefore it is recommended to re-run the linter manually on generated files; - Vue's Options API Syntax has a few caveats regarding type support, so please make sure everything is working before committing your changes;
This project makes use of:
node
version18.12.1
pnpm
version7.26.3
typescript
version^4.9.5
It is recommended to use nvm
(Node Version Manager) to use the correct version of node
and pnpm
Open the project on root folder and run
nvm use
to start using the desired node version.
Then, install pnpm
with:
npm install -g pnpm
You can now start the project with:
pnpm install # install dependencies
pnpm dev # run nodemon with ts-node to start a development run
- Read Vue SFC and extract
<script>
- thanks tovue-sfc-parser
- Generate the AST and translate most common SFC class methods and decorators to Options API Syntax
-
@Component
-
@Prop
-
@Watch
- Don't generate a dedicated method, place logic on handler
-
@Emit
- We can also make use of
emits
property to Type component emits
- We can also make use of
-
@Ref
- class getters to
computed
- class methods to
methods
- class properties to
data
-
Create internal type for type checking component data(removed with the use ofdefineComponent
)
-
- class
readonly
properties to typereadonly
properties on data return type (class)readonly
properties tocomputed
-
Mixins
-
- Take care of vue lifecyle hooks
-
beforeCreate
-
created
-
beforeMount
-
mounted
-
beforeUpdate
-
updated
-
beforeUnmount
(vue 3) /beforeDestroy
(vue 2) -
unmounted
(vue 3) /destroyed
(vue 2)
-
- Write generated code on disk
- Create and write to NEW vue SFC (Ex:
component.vue
tocomponent.options.vue
) - Replace script on current SFC
- Create and write to NEW vue SFC (Ex:
- Batch process
*.vue
files in folders- Accept glob syntax
- Run eslint (code formatter) with our configuration on generated code
- Double quotes (
""
) should become single quotes (''
) - Use 4 spaces as tab size
- Leave trailing commas
- Delete
;
- Delete line terminators from interfaces
- Double quotes (
- Write as much tests as possible
- Compare input class-based component script with output options-based component script (both are
string
data type) - From class-based component script to data structure
- From data structure to generated code
- Compare input class-based component script with output options-based component script (both are
- Generate AST for better type inference
- Preserve JSDoc with complex AST (
JSDOC
node type) - Use generated AST
- Preserve JSDoc with complex AST (
- Use
defineComponent
instead ofVue.extends
for better type inference
- Generate the AST and translate less common SFC decorators to Options API Syntax
-
@PropSync
-
@Model
-
@ModelSync
-
@Provide
-
@Inject
-
@ProvideReactive
-
@InjectReactive
-
@VModel
-
- Do the exact same thing but for
vuex
stores-
@Store
-
@Action
-
@Mutation
- class getters to
getters
-
- Create command line interface
ts-morph
is a wrapper fortypescript
compiler API, which does most of the heavy lifting including generating and manipulating the AST (Abstract Syntax Tree)code-block-writer
is a package which handles code JS/TS writing and formattingeslintrc
is a package which handles code JS/TS formatting. It's Node API allows us to format code programmatically.
There are a few caveats to notice when using this tool.
In the following example supportedConditions
has an inferred type, which is the return type of getSupportedConfigConditionOptions
function.
export default class ModalGearAutomationConditions extends Vue {
// supportedConditions has inferred type GearAutomationCondition[]
readonly supportedConditions = getSupportedConfigConditionOptions(this.conditions)
}
After transpiling this component, the Data
type will have type any
, which will cause errors.
type Data = {
readonly supportedConditions: any
}
will have to be changed to
type Data = {
readonly supportedConditions: GearAutomationCondition[]
}
It seems that Class-based components allow the declaration of Components
via the @Components
decorator, even if they are not used:
@Component({
components: {
ModalGearAutomationConditionsCard,
IsSelect,
IsButton,
},
})
export default class ModalGearAutomationConditions extends Vue {
}
However, this is not the case for Options API-based components:
This is a critical problem with the Class-based component syntax, as prop manipulation is not detected.
export default class ModalGearAutomationConditions extends Vue {
@Prop({ type: Object, required: true })
readonly form!: IGearAutomationConfigForm
addNewCondition (): void {
this.form.conditions.push({
id: null,
values: [],
type: null,
})
}
}
Not anymore:
This seems to be a problem of using defineComponent
, as plugin development for Vue 2.7 and Vue 3 is quite different.
It seems that creating a typescript declaration file (*.d.ts
) does not fix the issue.
A solution/workaround is yet to be discovered.
You can find examples on the /vue
folder, which contains vue SFCs prefixed with .options.vue
and class-decorators.vue
for components using the Options API syntax and the class-based syntax respectively.
<script lang="ts">
import { Component, Prop, Vue, Ref, Emit, Watch, mixins } from 'vue-property-decorator'
import NestedComponent from './NestedComponent.vue'
import AnotherNestedComponent from './NestedComponent.vue'
import MyMixin from './MyMixin.vue'
interface MyInterface {
myProperty: string
}
// https://github.com/kaorun343/vue-property-decorator
@Component({
components: {
NestedComponent,
AnotherNestedComponent,
},
})
export default class ExampleComponent extends mixins(MyMixin) {
// Refs
@Ref('myDiv')
readonly myDiv!: HTMLDivElement
// Props
@Prop({ type: Boolean, default: true })
public readonly defaultBoolean!: boolean
@Prop({ type: String, default: 'option 1' })
public readonly defaultProp!: 'option 1' | 'option 2'
@Prop({ type: String, required: true })
public readonly requiredStringProp!: string
@Prop({ type: String, required: false })
public readonly notRequiredStringProp?: string
@Prop({ type: Object })
public readonly objectProp?: MyInterface
// data
readonly MY_CONST: number = 123
myDataOne = 'stuff'
private myPrivateData = 'more stuff'
// Watch
@Watch('myDataOne', { immediate: true, deep: true })
onMyDataOne(newVal: string, oldVal:string) {
console.log(`changed from ${oldVal} to ${newVal}!`)
}
// Emits
@Emit('my-event')
emitMyEvent (): void {}
@Emit('my-event-with-payload-param')
emitMyEventWithPayloadParam (num: number): void {
this.myDataOne = 'other stuff'
}
@Emit('my-event-with-payload-return')
emitMyEventWithPayloadReturn (num: number): string {
this.myPrivateData = 'more more stuff'
return this.myPrivateData
}
// computed
get myDataOneUppercase () {
return this.myDataOne.toUpperCase()
}
// methods
logSomething (something: any): void {
console.log(something)
}
doMath(a: number, b: number): number {
return a + b
}
doSomethingWithRef() {
this.myDiv.addEventListener('click', () => this.logSomething(this.doMath(1,1)))
}
emitAllTheThings (): void {
this.emitMyEvent()
this.emitMyEventWithPayloadParam(2)
this.emitMyEventWithPayloadReturn(2)
}
}
</script>
<script lang="ts">
import type { PropType } from 'vue'
import { defineComponent } from 'vue'
import NestedComponent from './NestedComponent.vue'
import AnotherNestedComponent from './NestedComponent.vue'
import MyMixin from './MyMixin.vue'
interface MyInterface {
myProperty: string
}
export default defineComponent({
name: 'ExampleComponent',
components: {
NestedComponent,
AnotherNestedComponent,
},
mixins: [MyMixin],
props: {
defaultBoolean: {
type: Boolean as PropType<boolean>,
default: true,
},
defaultProp: {
type: String as PropType<'option 1' | 'option 2'>,
default: 'option 1',
},
requiredStringProp: {
type: String as PropType<string>,
required: true,
},
notRequiredStringProp: {
type: String as PropType<string>,
required: false,
},
objectProp: {
type: Object as PropType<MyInterface>,
required: false,
},
},
emits: {
'my-event' (): boolean {
// TODO add validator
return true
},
'my-event-with-payload-param' (num: number): boolean {
// TODO add validator
return true
},
'my-event-with-payload-return' (num: number): boolean {
// TODO add validator
return true
},
},
data() {
return {
myDataOne: 'stuff',
myPrivateData: 'more stuff',
MY_CONST: 123
}
},
watch: {
myDataOne: {
immediate: true,
deep: true,
handler: 'onMyDataOne',
},
},
computed: {
// refs
myDiv(): HTMLDivElement {
return this.$refs.myDiv as HTMLDivElement
},
myDataOneUppercase () {
return this.myDataOne.toUpperCase()
},
},
methods: {
// Emits
emitMyEvent (): void {
this.$emit('my-event')
},
emitMyEventWithPayloadParam (num: number): void {
this.myDataOne = 'other stuff'
this.$emit('my-event-with-payload-param', num)
},
emitMyEventWithPayloadReturn (num: number): void {
this.myPrivateData = 'more more stuff'
this.$emit('my-event-with-payload-return', this.myPrivateData, num)
},
// Methods
logSomething (something: any): void {
console.log(something)
},
doMath(a: number, b: number): number {
return a + b
},
doSomethingWithRef() {
this.myDiv.addEventListener('click', () => this.logSomething(this.doMath(1,1)))
},
emitAllTheThings (): void {
this.emitMyEvent()
this.emitMyEventWithPayloadParam(2)
this.emitMyEventWithPayloadReturn(2)
},
// watch methods
onMyDataOne(newVal: string, oldVal:string) {
console.log(`changed from ${oldVal} to ${newVal}!`)
}
}
})
</script>