-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add Svelte 4 migration (#9729)
Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com>
- Loading branch information
1 parent
c8310d7
commit 8299da1
Showing
6 changed files
with
518 additions
and
14 deletions.
There are no files selected for viewing
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'svelte-migrate': minor | ||
--- | ||
|
||
feat: add Svelte 4 migration |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
import colors from 'kleur'; | ||
import fs from 'node:fs'; | ||
import prompts from 'prompts'; | ||
import glob from 'tiny-glob/sync.js'; | ||
import { bail, check_git } from '../../utils.js'; | ||
import { update_js_file, update_svelte_file } from './migrate.js'; | ||
|
||
export async function migrate() { | ||
if (!fs.existsSync('package.json')) { | ||
bail('Please re-run this script in a directory with a package.json'); | ||
} | ||
|
||
console.log(colors.bold().yellow('\nThis will update files in the current directory\n')); | ||
|
||
const use_git = check_git(); | ||
|
||
const response = await prompts({ | ||
type: 'confirm', | ||
name: 'value', | ||
message: 'Continue?', | ||
initial: false | ||
}); | ||
|
||
if (!response.value) { | ||
process.exit(1); | ||
} | ||
|
||
// const { default: config } = fs.existsSync('svelte.config.js') | ||
// ? await import(pathToFileURL(path.resolve('svelte.config.js')).href) | ||
// : { default: {} }; | ||
|
||
/** @type {string[]} */ | ||
const svelte_extensions = /* config.extensions ?? - disabled because it would break .svx */ ['.svelte']; | ||
const extensions = [...svelte_extensions, '.ts', '.js']; | ||
// TODO read tsconfig/jsconfig if available? src/** will be good for 99% of cases | ||
const files = glob('src/**', { filesOnly: true, dot: true }).map((file) => | ||
file.replace(/\\/g, '/') | ||
); | ||
|
||
for (const file of files) { | ||
if (extensions.some((ext) => file.endsWith(ext))) { | ||
if (svelte_extensions.some((ext) => file.endsWith(ext))) { | ||
update_svelte_file(file); | ||
} else { | ||
update_js_file(file); | ||
} | ||
} | ||
} | ||
|
||
console.log(colors.bold().green('✔ Your project has been migrated')); | ||
|
||
console.log('\nRecommended next steps:\n'); | ||
|
||
const cyan = colors.bold().cyan; | ||
|
||
const tasks = [ | ||
use_git && cyan('git commit -m "migration to Svelte 4"'), | ||
'Review the migration guide at TODO', | ||
'Read the updated docs at https://svelte.dev/docs' | ||
].filter(Boolean); | ||
|
||
tasks.forEach((task, i) => { | ||
console.log(` ${i + 1}: ${task}`); | ||
}); | ||
|
||
console.log(''); | ||
|
||
if (use_git) { | ||
console.log(`Run ${cyan('git diff')} to review changes.\n`); | ||
} | ||
} |
This file contains 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
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
import fs from 'node:fs'; | ||
import { Project, ts, Node } from 'ts-morph'; | ||
|
||
/** @param {string} file_path */ | ||
export function update_svelte_file(file_path) { | ||
const content = fs.readFileSync(file_path, 'utf-8'); | ||
const updated = content.replace( | ||
/<script([^]*?)>([^]+?)<\/script>(\n*)/g, | ||
(_match, attrs, contents, whitespace) => { | ||
return `<script${attrs}>${transform_code(contents)}</script>${whitespace}`; | ||
} | ||
); | ||
fs.writeFileSync(file_path, transform_svelte_code(updated), 'utf-8'); | ||
} | ||
|
||
/** @param {string} file_path */ | ||
export function update_js_file(file_path) { | ||
const content = fs.readFileSync(file_path, 'utf-8'); | ||
const updated = transform_code(content); | ||
fs.writeFileSync(file_path, updated, 'utf-8'); | ||
} | ||
|
||
/** @param {string} code */ | ||
export function transform_code(code) { | ||
const project = new Project({ useInMemoryFileSystem: true }); | ||
const source = project.createSourceFile('svelte.ts', code); | ||
update_imports(source); | ||
update_typeof_svelte_component(source); | ||
update_action_types(source); | ||
update_action_return_types(source); | ||
return source.getFullText(); | ||
} | ||
|
||
/** @param {string} code */ | ||
export function transform_svelte_code(code) { | ||
return update_transitions(update_svelte_options(code)); | ||
} | ||
|
||
/** | ||
* <svelte:options tag=".." /> -> <svelte:options customElement=".." /> | ||
* @param {string} code | ||
*/ | ||
function update_svelte_options(code) { | ||
return code.replace(/<svelte:options([^]*?)\stag=([^]*?)\/?>/, (match) => { | ||
return match.replace('tag=', 'customElement='); | ||
}); | ||
} | ||
|
||
/** | ||
* transition/in/out:x -> transition/in/out:x|global | ||
* @param {string} code | ||
*/ | ||
function update_transitions(code) { | ||
return code.replace(/(\s)(transition:|in:|out:)(\w+)(?=[\s>=])/g, '$1$2$3|global'); | ||
} | ||
|
||
/** | ||
* Action<T> -> Action<T, any> | ||
* @param {import('ts-morph').SourceFile} source | ||
*/ | ||
function update_action_types(source) { | ||
const imports = get_imports(source, 'svelte/action', 'Action'); | ||
for (const namedImport of imports) { | ||
const identifiers = find_identifiers(source, namedImport.getAliasNode()?.getText() ?? 'Action'); | ||
for (const id of identifiers) { | ||
const parent = id.getParent(); | ||
if (Node.isTypeReference(parent)) { | ||
const type_args = parent.getTypeArguments(); | ||
if (type_args.length === 1) { | ||
parent.addTypeArgument('any'); | ||
} else if (type_args.length === 0) { | ||
parent.addTypeArgument('HTMLElement'); | ||
parent.addTypeArgument('any'); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* ActionReturn -> ActionReturn<any> | ||
* @param {import('ts-morph').SourceFile} source | ||
*/ | ||
function update_action_return_types(source) { | ||
const imports = get_imports(source, 'svelte/action', 'ActionReturn'); | ||
for (const namedImport of imports) { | ||
const identifiers = find_identifiers( | ||
source, | ||
namedImport.getAliasNode()?.getText() ?? 'ActionReturn' | ||
); | ||
for (const id of identifiers) { | ||
const parent = id.getParent(); | ||
if (Node.isTypeReference(parent)) { | ||
const type_args = parent.getTypeArguments(); | ||
if (type_args.length === 0) { | ||
parent.addTypeArgument('any'); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* SvelteComponentTyped -> SvelteComponent | ||
* @param {import('ts-morph').SourceFile} source | ||
*/ | ||
function update_imports(source) { | ||
const identifiers = find_identifiers(source, 'SvelteComponent'); | ||
const can_rename = identifiers.every((id) => { | ||
const parent = id.getParent(); | ||
return ( | ||
(Node.isImportSpecifier(parent) && | ||
!parent.getAliasNode() && | ||
parent.getParent().getParent().getParent().getModuleSpecifier().getText() === 'svelte') || | ||
!is_declaration(parent) | ||
); | ||
}); | ||
|
||
const imports = get_imports(source, 'svelte', 'SvelteComponentTyped'); | ||
for (const namedImport of imports) { | ||
if (can_rename) { | ||
namedImport.renameAlias('SvelteComponent'); | ||
if ( | ||
namedImport | ||
.getParent() | ||
.getElements() | ||
.some((e) => !e.getAliasNode() && e.getNameNode().getText() === 'SvelteComponent') | ||
) { | ||
namedImport.remove(); | ||
} else { | ||
namedImport.setName('SvelteComponent'); | ||
namedImport.removeAlias(); | ||
} | ||
} else { | ||
namedImport.renameAlias('SvelteComponentTyped'); | ||
namedImport.setName('SvelteComponent'); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* typeof SvelteComponent -> typeof SvelteComponent<any> | ||
* @param {import('ts-morph').SourceFile} source | ||
*/ | ||
function update_typeof_svelte_component(source) { | ||
const imports = get_imports(source, 'svelte', 'SvelteComponent'); | ||
|
||
for (const type of imports) { | ||
if (type) { | ||
const name = type.getAliasNode() ?? type.getNameNode(); | ||
name.findReferencesAsNodes().forEach((ref) => { | ||
const parent = ref.getParent(); | ||
if (parent && Node.isTypeQuery(parent)) { | ||
const id = parent.getFirstChildByKind(ts.SyntaxKind.Identifier); | ||
if (id?.getText() === name.getText()) { | ||
const typeArguments = parent.getTypeArguments(); | ||
if (typeArguments.length === 0) { | ||
parent.addTypeArgument('any'); | ||
} | ||
} | ||
} | ||
}); | ||
} | ||
} | ||
} | ||
|
||
/** | ||
* @param {import('ts-morph').SourceFile} source | ||
* @param {string} from | ||
* @param {string} name | ||
*/ | ||
function get_imports(source, from, name) { | ||
return source | ||
.getImportDeclarations() | ||
.filter((i) => i.getModuleSpecifierValue() === from) | ||
.flatMap((i) => i.getNamedImports()) | ||
.filter((i) => i.getName() === name); | ||
} | ||
|
||
/** | ||
* @param {import('ts-morph').SourceFile} source | ||
* @param {string} name | ||
*/ | ||
function find_identifiers(source, name) { | ||
return source.getDescendantsOfKind(ts.SyntaxKind.Identifier).filter((i) => i.getText() === name); | ||
} | ||
|
||
/** | ||
* Does not include imports | ||
* @param {Node} node | ||
*/ | ||
function is_declaration(node) { | ||
return ( | ||
Node.isVariableDeclaration(node) || | ||
Node.isFunctionDeclaration(node) || | ||
Node.isClassDeclaration(node) || | ||
Node.isTypeAliasDeclaration(node) || | ||
Node.isInterfaceDeclaration(node) | ||
); | ||
} |
Oops, something went wrong.