feat(cli): add support to Svelte and Vue.js code on v1 migration (#10544)

* feat(cli): add support to Svelte and Vue.js code on v1 migration

* clippy
This commit is contained in:
Lucas Fernandes Nogueira 2024-08-12 01:45:42 -03:00 committed by GitHub
parent 3998570fd3
commit 3bec7b1595
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 739 additions and 228 deletions

View File

@ -0,0 +1,6 @@
---
"tauri-cli": patch:feat
"@tauri-apps/cli": patch:feat
---
v1 migrate script now migrates Svelte and Vue.js code.

View File

@ -5228,6 +5228,7 @@ dependencies = [
"local-ip-address",
"log",
"magic_string",
"memchr",
"minisign",
"notify",
"notify-debouncer-mini",

View File

@ -100,6 +100,7 @@ magic_string = "0.3"
phf = { version = "0.11", features = ["macros"] }
walkdir = "2"
elf = "0.7"
memchr = "2"
[target."cfg(windows)".dependencies.windows-sys]
version = "0.52"

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
use std::{path::Path, process::Command};
use std::process::Command;
use anyhow::Context;
@ -17,37 +17,6 @@ pub struct CargoInstallOptions<'a> {
pub target: Option<&'a str>,
}
pub fn install(dependencies: &[String], cwd: Option<&Path>) -> crate::Result<()> {
let dependencies_str = if dependencies.len() > 1 {
"dependencies"
} else {
"dependency"
};
log::info!(
"Installing Cargo {dependencies_str} {}...",
dependencies
.iter()
.map(|d| format!("\"{d}\""))
.collect::<Vec<_>>()
.join(", ")
);
let mut cmd = Command::new("cargo");
cmd.arg("add").args(dependencies);
if let Some(cwd) = cwd {
cmd.current_dir(cwd);
}
let status = cmd.status().with_context(|| "failed to run cargo")?;
if !status.success() {
anyhow::bail!("Failed to install Cargo {dependencies_str}");
}
Ok(())
}
pub fn install_one(options: CargoInstallOptions) -> crate::Result<()> {
let mut cargo = Command::new("cargo");
cargo.arg("add");

View File

@ -3,7 +3,7 @@
// SPDX-License-Identifier: MIT
use crate::{
helpers::{app_paths::walk_builder, cargo, npm::PackageManager},
helpers::{app_paths::walk_builder, npm::PackageManager},
Result,
};
use anyhow::Context;
@ -16,6 +16,8 @@ use oxc_span::SourceType;
use std::{fs, path::Path};
mod partial_loader;
const RENAMED_MODULES: phf::Map<&str, &str> = phf::phf_map! {
"tauri" => "core",
"window" => "webviewWindow"
@ -51,12 +53,12 @@ const MODULES_MAP: phf::Map<&str, &str> = phf::phf_map! {
"@tauri-apps/api/shell" => "@tauri-apps/plugin-shell",
"@tauri-apps/api/updater" => "@tauri-apps/plugin-updater",
};
const JS_EXTENSIONS: &[&str] = &["js", "mjs", "jsx", "ts", "mts", "tsx"];
const JS_EXTENSIONS: &[&str] = &["js", "mjs", "jsx", "ts", "mts", "tsx", "svelte", "vue"];
/// Returns a list of paths that could not be migrated
pub fn migrate(app_dir: &Path, tauri_dir: &Path) -> Result<()> {
pub fn migrate(app_dir: &Path) -> Result<Vec<String>> {
let mut new_npm_packages = Vec::new();
let mut new_cargo_packages = Vec::new();
let mut new_plugins = Vec::new();
let pre = env!("CARGO_PKG_VERSION_PRE");
let npm_version = if pre.is_empty() {
@ -92,12 +94,7 @@ pub fn migrate(app_dir: &Path, tauri_dir: &Path) -> Result<()> {
let ext = path.extension().unwrap_or_default();
if JS_EXTENSIONS.iter().any(|e| e == &ext) {
let js_contents = std::fs::read_to_string(path)?;
let new_contents = migrate_imports(
path,
&js_contents,
&mut new_cargo_packages,
&mut new_npm_packages,
)?;
let new_contents = migrate_imports(path, &js_contents, &mut new_plugins)?;
if new_contents != js_contents {
fs::write(path, new_contents)
.with_context(|| format!("Error writing {}", path.display()))?;
@ -113,25 +110,38 @@ pub fn migrate(app_dir: &Path, tauri_dir: &Path) -> Result<()> {
.context("Error installing new npm packages")?;
}
new_cargo_packages.sort();
new_cargo_packages.dedup();
if !new_cargo_packages.is_empty() {
cargo::install(&new_cargo_packages, Some(tauri_dir))
.context("Error installing new Cargo packages")?;
}
Ok(())
Ok(new_plugins)
}
fn migrate_imports<'a>(
path: &'a Path,
js_source: &'a str,
new_cargo_packages: &mut Vec<String>,
new_npm_packages: &mut Vec<String>,
new_plugins: &mut Vec<String>,
) -> crate::Result<String> {
let mut magic_js_source = MagicString::new(js_source);
let source_type = SourceType::from_path(path).unwrap();
let has_partial_js = path
.extension()
.map_or(false, |ext| ext == "vue" || ext == "svelte");
let sources = if !has_partial_js {
vec![(SourceType::from_path(path).unwrap(), js_source, 0i64)]
} else {
partial_loader::PartialLoader::parse(
path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
js_source,
)
.unwrap()
.into_iter()
.map(|s| (s.source_type, s.source_text, s.start as i64))
.collect()
};
for (source_type, js_source, script_start) in sources {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, js_source, source_type).parse();
if !ret.errors.is_empty() {
@ -162,8 +172,8 @@ fn migrate_imports<'a>(
// +1 and -1, to skip modifying the import quotes
magic_js_source
.overwrite(
stmt.source.span.start as i64 + 1,
stmt.source.span.end as i64 - 1,
script_start + stmt.source.span.start as i64 + 1,
script_start + stmt.source.span.end as i64 - 1,
module,
Default::default(),
)
@ -173,10 +183,7 @@ fn migrate_imports<'a>(
// if module was pluginified, add to packages
let module = module.split_once("plugin-");
if let Some((_, module)) = module {
let js_plugin = format!("@tauri-apps/plugin-{module}");
let cargo_crate = format!("tauri-plugin-{module}");
new_npm_packages.push(js_plugin);
new_cargo_packages.push(cargo_crate);
new_plugins.push(module.to_string());
}
}
@ -213,9 +220,8 @@ fn migrate_imports<'a>(
import if PLUGINIFIED_MODULES.contains(&import) && module == "@tauri-apps/api" => {
let js_plugin: &str = MODULES_MAP[&format!("@tauri-apps/api/{import}")];
let (_, plugin_name) = js_plugin.split_once("plugin-").unwrap();
let cargo_crate = format!("tauri-plugin-{plugin_name}");
new_npm_packages.push(js_plugin.to_string());
new_cargo_packages.push(cargo_crate);
new_plugins.push(plugin_name.to_string());
if specifier.local.name.as_str() != import {
let local = &specifier.local.name;
@ -243,8 +249,8 @@ fn migrate_imports<'a>(
if let Some(new_identifier) = new_identifier {
magic_js_source
.overwrite(
specifier.span.start as _,
specifier.span.end as _,
script_start + specifier.span.start as i64,
script_start + specifier.span.end as i64,
new_identifier,
Default::default(),
)
@ -265,7 +271,7 @@ fn migrate_imports<'a>(
};
magic_js_source
.remove(start as _, end as _)
.remove(script_start + start as i64, script_start + end as i64)
.map_err(|e| anyhow::anyhow!("{e}"))
.context("failed to remove identifier")?;
}
@ -290,7 +296,7 @@ fn migrate_imports<'a>(
if !imports_to_add.is_empty() {
for import in imports_to_add {
magic_js_source
.append_right(start as _, &import)
.append_right(script_start as u32 + start, &import)
.map_err(|e| anyhow::anyhow!("{e}"))
.context("failed to add import")?;
}
@ -299,11 +305,12 @@ fn migrate_imports<'a>(
if !stmts_to_add.is_empty() {
for stmt in stmts_to_add {
magic_js_source
.append_right(start as _, stmt)
.append_right(script_start as u32 + start, stmt)
.map_err(|e| anyhow::anyhow!("{e}"))
.context("failed to add statement")?;
}
}
}
Ok(magic_js_source.to_string())
}
@ -314,7 +321,143 @@ mod tests {
use super::*;
#[test]
fn migrates() {
fn migrates_vue() {
let input = r#"
<template>
<div>Tauri!</div>
</template>
<script setup>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, dialog, cli as superCli } from "@tauri-apps/api";
import { appWindow } from "@tauri-apps/api/window";
import { convertFileSrc } from "@tauri-apps/api/tauri";
import { open } from "@tauri-apps/api/dialog";
import { register } from "@tauri-apps/api/globalShortcut";
import clipboard from "@tauri-apps/api/clipboard";
import * as fs from "@tauri-apps/api/fs";
import "./App.css";
</script>
<style>
.greeting {
color: red;
font-weight: bold;
}
</style>
"#;
let expected = r#"
<template>
<div>Tauri!</div>
</template>
<script setup>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, } from "@tauri-apps/api";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { convertFileSrc } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { register } from "@tauri-apps/plugin-global-shortcut";
import clipboard from "@tauri-apps/plugin-clipboard-manager";
import * as fs from "@tauri-apps/plugin-fs";
import "./App.css";
import * as dialog from "@tauri-apps/plugin-dialog"
import * as cli as superCli from "@tauri-apps/plugin-cli"
const appWindow = getCurrentWebviewWindow()
</script>
<style>
.greeting {
color: red;
font-weight: bold;
}
</style>
"#;
let mut new_plugins = Vec::new();
let migrated = migrate_imports(Path::new("file.vue"), input, &mut new_plugins).unwrap();
assert_eq!(migrated, expected);
assert_eq!(
new_plugins,
vec![
"dialog",
"cli",
"dialog",
"global-shortcut",
"clipboard-manager",
"fs"
]
);
}
#[test]
fn migrates_svelte() {
let input = r#"
<form>
</form>
<script>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, dialog, cli as superCli } from "@tauri-apps/api";
import { appWindow } from "@tauri-apps/api/window";
import { convertFileSrc } from "@tauri-apps/api/tauri";
import { open } from "@tauri-apps/api/dialog";
import { register } from "@tauri-apps/api/globalShortcut";
import clipboard from "@tauri-apps/api/clipboard";
import * as fs from "@tauri-apps/api/fs";
import "./App.css";
</script>
"#;
let expected = r#"
<form>
</form>
<script>
import { useState } from "react";
import reactLogo from "./assets/react.svg";
import { invoke, } from "@tauri-apps/api";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { convertFileSrc } from "@tauri-apps/api/core";
import { open } from "@tauri-apps/plugin-dialog";
import { register } from "@tauri-apps/plugin-global-shortcut";
import clipboard from "@tauri-apps/plugin-clipboard-manager";
import * as fs from "@tauri-apps/plugin-fs";
import "./App.css";
import * as dialog from "@tauri-apps/plugin-dialog"
import * as cli as superCli from "@tauri-apps/plugin-cli"
const appWindow = getCurrentWebviewWindow()
</script>
"#;
let mut new_plugins = Vec::new();
let migrated = migrate_imports(Path::new("file.svelte"), input, &mut new_plugins).unwrap();
assert_eq!(migrated, expected);
assert_eq!(
new_plugins,
vec![
"dialog",
"cli",
"dialog",
"global-shortcut",
"clipboard-manager",
"fs"
]
);
}
#[test]
fn migrates_js() {
let input = r#"
import { useState } from "react";
import reactLogo from "./assets/react.svg";
@ -456,40 +599,21 @@ function App() {
export default App;
"#;
let mut new_cargo_packages = Vec::new();
let mut new_npm_packages = Vec::new();
let mut new_plugins = Vec::new();
let migrated = migrate_imports(
Path::new("file.js"),
input,
&mut new_cargo_packages,
&mut new_npm_packages,
)
.unwrap();
let migrated = migrate_imports(Path::new("file.js"), input, &mut new_plugins).unwrap();
assert_eq!(migrated, expected);
assert_eq!(
new_cargo_packages,
new_plugins,
vec![
"tauri-plugin-dialog",
"tauri-plugin-cli",
"tauri-plugin-dialog",
"tauri-plugin-global-shortcut",
"tauri-plugin-clipboard-manager",
"tauri-plugin-fs"
]
);
assert_eq!(
new_npm_packages,
vec![
"@tauri-apps/plugin-dialog",
"@tauri-apps/plugin-cli",
"@tauri-apps/plugin-dialog",
"@tauri-apps/plugin-global-shortcut",
"@tauri-apps/plugin-clipboard-manager",
"@tauri-apps/plugin-fs"
"dialog",
"cli",
"dialog",
"global-shortcut",
"clipboard-manager",
"fs"
]
);
}

View File

@ -0,0 +1,69 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// taken from https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/partial_loader/mod.rs
mod svelte;
mod vue;
use oxc_span::SourceType;
pub use self::{svelte::SveltePartialLoader, vue::VuePartialLoader};
const SCRIPT_START: &str = "<script";
const SCRIPT_END: &str = "</script>";
#[derive(Debug, Clone, Copy)]
pub struct JavaScriptSource<'a> {
pub source_text: &'a str,
pub source_type: SourceType,
/// The javascript source could be embedded in some file,
/// use `start` to record start offset of js block in the original file.
pub start: usize,
}
impl<'a> JavaScriptSource<'a> {
pub fn new(source_text: &'a str, source_type: SourceType, start: usize) -> Self {
Self {
source_text,
source_type,
start,
}
}
}
pub struct PartialLoader;
impl PartialLoader {
/// Extract js section of specifial files.
/// Returns `None` if the specifial file does not have a js section.
pub fn parse<'a>(ext: &str, source_text: &'a str) -> Option<Vec<JavaScriptSource<'a>>> {
match ext {
"vue" => Some(VuePartialLoader::new(source_text).parse()),
"svelte" => Some(SveltePartialLoader::new(source_text).parse()),
_ => None,
}
}
}
/// Find closing angle for situations where there is another `>` in between.
/// e.g. `<script generic="T extends Record<string, string>">`
fn find_script_closing_angle(source_text: &str, pointer: usize) -> Option<usize> {
let mut numbers_of_open_angle = 0;
for (offset, c) in source_text[pointer..].char_indices() {
match c {
'>' => {
if numbers_of_open_angle == 0 {
return Some(offset);
}
numbers_of_open_angle -= 1;
}
'<' => {
numbers_of_open_angle += 1;
}
_ => {}
}
}
None
}

View File

@ -0,0 +1,93 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// taken from https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/partial_loader/svelte.rs
use memchr::memmem::Finder;
use oxc_span::SourceType;
use super::{find_script_closing_angle, JavaScriptSource, SCRIPT_END, SCRIPT_START};
pub struct SveltePartialLoader<'a> {
source_text: &'a str,
}
impl<'a> SveltePartialLoader<'a> {
pub fn new(source_text: &'a str) -> Self {
Self { source_text }
}
pub fn parse(self) -> Vec<JavaScriptSource<'a>> {
self
.parse_script()
.map_or_else(Vec::new, |source| vec![source])
}
fn parse_script(&self) -> Option<JavaScriptSource<'a>> {
let script_start_finder = Finder::new(SCRIPT_START);
let script_end_finder = Finder::new(SCRIPT_END);
let mut pointer = 0;
// find opening "<script"
let offset = script_start_finder.find(self.source_text[pointer..].as_bytes())?;
pointer += offset + SCRIPT_START.len();
// find closing ">"
let offset = find_script_closing_angle(self.source_text, pointer)?;
// get lang="ts" attribute
let content = &self.source_text[pointer..pointer + offset];
let is_ts = content.contains("ts");
pointer += offset + 1;
let js_start = pointer;
// find "</script>"
let offset = script_end_finder.find(self.source_text[pointer..].as_bytes())?;
let js_end = pointer + offset;
let source_text = &self.source_text[js_start..js_end];
let source_type = SourceType::default()
.with_module(true)
.with_typescript(is_ts);
Some(JavaScriptSource::new(source_text, source_type, js_start))
}
}
#[cfg(test)]
mod test {
use super::{JavaScriptSource, SveltePartialLoader};
fn parse_svelte(source_text: &str) -> JavaScriptSource<'_> {
let sources = SveltePartialLoader::new(source_text).parse();
*sources.first().unwrap()
}
#[test]
fn test_parse_svelte() {
let source_text = r#"
<script>
console.log("hi");
</script>
<h1>Hello World</h1>
"#;
let result = parse_svelte(source_text);
assert_eq!(result.source_text.trim(), r#"console.log("hi");"#);
}
#[test]
fn test_parse_svelte_ts_with_generic() {
let source_text = r#"
<script lang="ts" generics="T extends Record<string, unknown>">
console.log("hi");
</script>
<h1>Hello World</h1>
"#;
let result = parse_svelte(source_text);
assert_eq!(result.source_text.trim(), r#"console.log("hi");"#);
}
}

View File

@ -0,0 +1,246 @@
// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
// SPDX-License-Identifier: Apache-2.0
// SPDX-License-Identifier: MIT
// taken from https://github.com/oxc-project/oxc/blob/main/crates/oxc_linter/src/partial_loader/vue.rs
use memchr::memmem::Finder;
use oxc_span::SourceType;
use super::{find_script_closing_angle, JavaScriptSource, SCRIPT_END, SCRIPT_START};
pub struct VuePartialLoader<'a> {
source_text: &'a str,
}
impl<'a> VuePartialLoader<'a> {
pub fn new(source_text: &'a str) -> Self {
Self { source_text }
}
pub fn parse(self) -> Vec<JavaScriptSource<'a>> {
self.parse_scripts()
}
/// Each *.vue file can contain at most
/// * one `<script>` block (excluding `<script setup>`).
/// * one `<script setup>` block (excluding normal `<script>`).
/// <https://vuejs.org/api/sfc-spec.html#script>
fn parse_scripts(&self) -> Vec<JavaScriptSource<'a>> {
let mut pointer = 0;
let Some(result1) = self.parse_script(&mut pointer) else {
return vec![];
};
let Some(result2) = self.parse_script(&mut pointer) else {
return vec![result1];
};
vec![result1, result2]
}
fn parse_script(&self, pointer: &mut usize) -> Option<JavaScriptSource<'a>> {
let script_start_finder = Finder::new(SCRIPT_START);
let script_end_finder = Finder::new(SCRIPT_END);
// find opening "<script"
let offset = script_start_finder.find(self.source_text[*pointer..].as_bytes())?;
*pointer += offset + SCRIPT_START.len();
// find closing ">"
let offset = find_script_closing_angle(self.source_text, *pointer)?;
// get ts and jsx attribute
let content = &self.source_text[*pointer..*pointer + offset];
let is_ts = content.contains("ts");
let is_jsx = content.contains("tsx") || content.contains("jsx");
*pointer += offset + 1;
let js_start = *pointer;
// find "</script>"
let offset = script_end_finder.find(self.source_text[*pointer..].as_bytes())?;
let js_end = *pointer + offset;
*pointer += offset + SCRIPT_END.len();
let source_text = &self.source_text[js_start..js_end];
let source_type = SourceType::default()
.with_module(true)
.with_typescript(is_ts)
.with_jsx(is_jsx);
Some(JavaScriptSource::new(source_text, source_type, js_start))
}
}
#[cfg(test)]
mod test {
use super::{JavaScriptSource, VuePartialLoader};
fn parse_vue(source_text: &str) -> JavaScriptSource<'_> {
let sources = VuePartialLoader::new(source_text).parse();
*sources.first().unwrap()
}
#[test]
fn test_parse_vue_one_line() {
let source_text = r#"
<template>
<h1>hello world</h1>
</template>
<script> console.log("hi") </script>
"#;
let result = parse_vue(source_text);
assert_eq!(result.source_text, r#" console.log("hi") "#);
}
#[test]
fn test_build_vue_with_ts_flag_1() {
let source_text = r#"
<script lang="ts" setup generic="T extends Record<string, string>">
1/1
</script>
"#;
let result = parse_vue(source_text);
assert!(result.source_type.is_typescript());
assert_eq!(result.source_text.trim(), "1/1");
}
#[test]
fn test_build_vue_with_ts_flag_2() {
let source_text = r"
<script lang=ts setup>
1/1
</script>
";
let result = parse_vue(source_text);
assert!(result.source_type.is_typescript());
assert_eq!(result.source_text.trim(), "1/1");
}
#[test]
fn test_build_vue_with_ts_flag_3() {
let source_text = r"
<script lang='ts' setup>
1/1
</script>
";
let result = parse_vue(source_text);
assert!(result.source_type.is_typescript());
assert_eq!(result.source_text.trim(), "1/1");
}
#[test]
fn test_build_vue_with_tsx_flag() {
let source_text = r"
<script lang=tsx setup>
1/1
</script>
";
let result = parse_vue(source_text);
assert!(result.source_type.is_jsx());
assert!(result.source_type.is_typescript());
assert_eq!(result.source_text.trim(), "1/1");
}
#[test]
fn test_build_vue_with_escape_string() {
let source_text = r"
<script setup>
a.replace(/&#39;/g, '\''))
</script>
<template> </template>
";
let result = parse_vue(source_text);
assert!(!result.source_type.is_typescript());
assert_eq!(result.source_text.trim(), r"a.replace(/&#39;/g, '\''))");
}
#[test]
fn test_multi_level_template_literal() {
let source_text = r"
<script setup>
`a${b( `c \`${d}\``)}`
</script>
";
let result = parse_vue(source_text);
assert_eq!(result.source_text.trim(), r"`a${b( `c \`${d}\``)}`");
}
#[test]
fn test_brace_with_regex_in_template_literal() {
let source_text = r"
<script setup>
`${/{/}`
</script>
";
let result = parse_vue(source_text);
assert_eq!(result.source_text.trim(), r"`${/{/}`");
}
#[test]
fn test_no_script() {
let source_text = r"
<template></template>
";
let sources = VuePartialLoader::new(source_text).parse();
assert!(sources.is_empty());
}
#[test]
fn test_syntax_error() {
let source_text = r"
<script>
console.log('error')
";
let sources = VuePartialLoader::new(source_text).parse();
assert!(sources.is_empty());
}
#[test]
fn test_multiple_scripts() {
let source_text = r"
<template></template>
<script>a</script>
<script setup>b</script>
";
let sources = VuePartialLoader::new(source_text).parse();
assert_eq!(sources.len(), 2);
assert_eq!(sources[0].source_text, "a");
assert_eq!(sources[1].source_text, "b");
}
#[test]
fn test_unicode() {
let source_text = r"
<script setup>
let = '2000';
const t = useTranslate({
'zh-CN': {
calendar: '',
tiledDisplay: '',
},
});
</script>
";
let result = parse_vue(source_text);
assert_eq!(
result.source_text.trim(),
"let 日历 = '2000年';
const t = useTranslate({
'zh-CN': {
calendar: '',
tiledDisplay: '',
},
});"
.trim()
);
}
}

View File

@ -17,9 +17,11 @@ pub fn run() -> Result<()> {
let tauri_dir = tauri_dir();
let app_dir = app_dir();
let migrated = config::migrate(tauri_dir).context("Could not migrate config")?;
let mut migrated = config::migrate(tauri_dir).context("Could not migrate config")?;
manifest::migrate(tauri_dir).context("Could not migrate manifest")?;
frontend::migrate(app_dir, tauri_dir)?;
let plugins = frontend::migrate(app_dir)?;
migrated.plugins.extend(plugins);
// Add plugins
for plugin in migrated.plugins {