Skip to content

Commit

Permalink
Migration script (#5773)
Browse files Browse the repository at this point in the history
* skeleton

* add moved info to files to be able to rewrite imports later

* add magic-string, start load extraction and manipulation logic

* move migration logic into new svelte-migrate package

* remove commented code

* lockfile

* move components, extract load

* +page.server.js and +server.js

* remove unused code

* uncomment prompt

* add some errors

* fix detection

* consistent casing

* not sure if typescript will ever fail to parse, but in any case its handled now

* handle error with module context

* add some comments

* fix dynamic import on windows, fix js/ts file ending, handle index.json.js endpoint case

* extract imports into Svelte migration task for QOL, don't adjust imports in standalone index edge case

* show message either way, could be other things gone wrong

* windooows

* more sophisticated auto migrations
- unpack props/body
- redirect/error in load
- standalone endpoint

* fix regex, dont show props message on error page, more sophisticated error/redirect checks, set-cookie suggestion

* pretend to make this more readable

* adjust import location for error and redirect

* only show short automigration message on success, only migrate where we are confident it works, annotate non-migrated return statements, handle export { X } case

* shorten redirect/error if no second argument given

* use git mv if available

* remove unused dep

* determine whether or not to use git upfront

* add recommended next steps

* tweak

* i think we can omit the comment for successful automigrations

* move code into separate modules, to make stuff easier to test

* add the beginnings of a test suite

* fix read_samples

* only add page level migration task if something went wrong

* tweak migrate_page function, add some tests

* ugh

* only inject error when necessary

* add migrate_page_server test file

* preserve semicolons

* refactor a bit, allow +page.server.js to get away without any migration task errors if all goes well

* oops

* handle arrow functions

* add test file for +server.js, only print file-level warning if we give up

* remove some no-longer-used code

* DRY out

* small cosmetic tweak

* simplify

* make check case-insensitive

* more tests

* fix test

* refactor slightly

* handle some more cases

* handle case where status: 200 is returned from GET

* move error where it belongs

* all tests passing

* reuse keys

* fix

* fix 400 error status bug, support throw statement on arrow function, support server arrow function migration, extract migration into own file and add tests

* add is_new helper, handle more cases

* leave new Response alone

* handle some more Response cases

* fix commenting logic, handle multiline headers

* oops

* move some logic

* remove module context altogether if safe, leave error if not

* remove some no-longer-used code

* oops

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
  • Loading branch information
Rich-Harris and dummdidumm authored Aug 4, 2022
1 parent 69deeb7 commit 743f1c0
Show file tree
Hide file tree
Showing 20 changed files with 1,805 additions and 2 deletions.
3 changes: 2 additions & 1 deletion .prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@
"packages/kit/src/packaging/test/fixtures/**/expected/**/*",
"packages/kit/src/packaging/test/watch/expected/**/*",
"packages/kit/src/packaging/test/watch/package/**/*",
"packages/kit/src/core/prerender/fixtures/**/*"
"packages/kit/src/core/prerender/fixtures/**/*",
"packages/migrate/migrations/routes/*/samples.md"
],
"options": {
"requirePragma": true
Expand Down
20 changes: 20 additions & 0 deletions packages/migrate/bin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import fs from 'fs';
import { fileURLToPath } from 'url';
import colors from 'kleur';

const migration = process.argv[2];
const dir = fileURLToPath(new URL('.', import.meta.url));

const migrations = fs
.readdirSync(`${dir}/migrations`)
.filter((migration) => fs.existsSync(`${dir}/migrations/${migration}/index.js`));

if (migrations.includes(migration)) {
const { migrate } = await import(`./migrations/${migration}/index.js`);
migrate();
} else {
console.error(
colors.bold().red(`You must specify one of the following migrations: ${migrations.join(', ')}`)
);
}
237 changes: 237 additions & 0 deletions packages/migrate/migrations/routes/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import { execSync } from 'child_process';
import fs from 'fs';
import colors from 'kleur';
import path from 'path';
import prompts from 'prompts';
import glob from 'tiny-glob/sync.js';
import { pathToFileURL } from 'url';
import { migrate_scripts } from './migrate_scripts/index.js';
import { migrate_page } from './migrate_page_js/index.js';
import { migrate_page_server } from './migrate_page_server/index.js';
import { migrate_server } from './migrate_server/index.js';
import { adjust_imports, bail, dedent, move_file, relative, task } from './utils.js';

export async function migrate() {
if (!fs.existsSync('svelte.config.js')) {
bail('Please re-run this script in a directory with a svelte.config.js');
}

const { default: config } = await import(pathToFileURL(path.resolve('svelte.config.js')).href);

const routes = path.resolve(config.kit?.files?.routes ?? 'src/routes');

/** @type {string[]} */
const extensions = config.extensions ?? ['.svelte'];

/** @type {string[]} */
const module_extensions = config.kit?.moduleExtensions ?? ['.js', '.ts'];

/** @type {((filepath: string) => boolean)} */
const filter =
config.kit?.routes ??
((filepath) => !/(?:(?:^_|\/_)|(?:^\.|\/\.)(?!well-known))/.test(filepath));

const files = glob(`${routes}/**`, { filesOnly: true }).map((file) => file.replace(/\\/g, '/'));

// validate before proceeding
for (const file of files) {
const basename = path.basename(file);
if (
basename.startsWith('+page.') ||
basename.startsWith('+layout.') ||
basename.startsWith('+server.') ||
basename.startsWith('+error.')
) {
bail(`It looks like this migration has already been run (found ${relative(file)}). Aborting`);
}

if (basename.startsWith('+')) {
// prettier-ignore
bail(
`Please rename any files in ${relative(routes)} with a leading + character before running this migration (found ${relative(file)}). Aborting`
);
}
}

console.log(colors.bold().yellow('\nThis will overwrite files in the current directory!\n'));

let use_git = false;

let dir = process.cwd();
do {
if (fs.existsSync(path.join(dir, '.git'))) {
use_git = true;
break;
}
} while (dir !== (dir = path.dirname(dir)));

if (use_git) {
try {
const status = execSync('git status --porcelain', { stdio: 'pipe' }).toString();

if (status) {
const message =
'Your git working directory is dirty — we recommend committing your changes before running this migration.\n';
console.log(colors.bold().red(message));
}
} catch {
// would be weird to have a .git folder if git is not installed,
// but always expect the unexpected
const message =
'Could not detect a git installation. If this is unexpected, please raise an issue: https://github.com/sveltejs/kit.\n';
console.log(colors.bold().red(message));
use_git = false;
}
}

const response = await prompts({
type: 'confirm',
name: 'value',
message: 'Continue?',
initial: false
});

if (!response.value) {
process.exit(1);
}

for (const file of files) {
const basename = path.basename(file);
if (!filter(file) && !basename.startsWith('__')) continue;

// replace `./__types` or `./__types/foo` with `./$types`
const content = fs.readFileSync(file, 'utf8').replace(/\.\/__types(?:\/[^'"]+)?/g, './$types');

const svelte_ext = extensions.find((ext) => file.endsWith(ext));
const module_ext = module_extensions.find((ext) => file.endsWith(ext));

if (svelte_ext) {
// file is a component
const bare = basename.slice(0, -svelte_ext.length);
const [name, layout] = bare.split('@');
const is_error_page = bare === '__error';

/**
* Whether file should be moved to a subdirectory — e.g. `src/routes/about.svelte`
* should become `src/routes/about/+page.svelte`
*/
let move_to_directory = false;

/**
* The new name of the file
*/
let renamed = file.slice(0, -basename.length);

/**
* If a component has `<script context="module">`, the contents are moved
* into a sibling module with the same name
*/
let sibling;

if (bare.startsWith('__layout')) {
sibling = renamed + '+layout';
renamed += '+' + bare.slice(2); // account for __layout-foo etc
} else if (is_error_page) {
renamed += '+error';
// no sibling, because error files can no longer have load
} else if (name === 'index') {
sibling = renamed + '+page';
renamed += '+page' + (layout ? '@' + layout : '');
} else {
sibling = `${renamed}${name}/+page`;
renamed += `${name}/+page${layout ? '@' + layout : ''}`;

move_to_directory = true;
}

renamed += svelte_ext;

const { module, main } = migrate_scripts(content, is_error_page, move_to_directory);

if (move_to_directory) {
const dir = path.dirname(renamed);
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
}

move_file(file, renamed, main, use_git);

// if component has a <script context="module">, move it to a sibling .js file
if (module) {
const ext = /<script[^>]+?lang=['"](ts|typescript)['"][^]*?>/.test(module) ? '.ts' : '.js';

fs.writeFileSync(sibling + ext, migrate_page(module));
}
} else if (module_ext) {
// file is a module
const bare = basename.slice(0, -module_ext.length);
const [name] = bare.split('@');

/**
* Whether the file is paired with a page component, and should
* therefore become `+page.server.js`, or not in which case
* it should become `+server.js`
*/
const is_page_endpoint = extensions.some((ext) =>
files.includes(`${file.slice(0, -module_ext.length)}${ext}`)
);

const type = is_page_endpoint ? '+page.server' : '+server';

const move_to_directory = name !== 'index';
const is_standalone_index = !is_page_endpoint && name.startsWith('index.');

let renamed = '';
if (is_standalone_index) {
// handle <folder>/index.json.js -> <folder>.json/+server.js
const dir = path.dirname(file);
renamed =
// prettier-ignore
`${file.slice(0, -(basename.length + dir.length + 1))}${dir + name.slice('index'.length)}/+server${module_ext}`;
} else if (move_to_directory) {
renamed = `${file.slice(0, -basename.length)}${name}/${type}${module_ext}`;
} else {
renamed = `${file.slice(0, -basename.length)}${type}${module_ext}`;
}

// Standalone index endpoints are edge case enough that we don't spend time on trying to update all the imports correctly
const edited =
(is_standalone_index && /import/.test(content) ? `\n// ${task('Check imports')}\n` : '') +
(!is_standalone_index && move_to_directory ? adjust_imports(content) : content);
if (move_to_directory) {
const dir = path.dirname(renamed);
if (!fs.existsSync(dir)) fs.mkdirSync(dir);
}

move_file(
file,
renamed,
is_page_endpoint ? migrate_page_server(edited) : migrate_server(edited),
use_git
);
}
}

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 "svelte-migrate: renamed files"'),
`Review the migration guide at https://github.com/sveltejs/kit/discussions/5774`,
`Search codebase for ${cyan('"@migration"')} and manually complete migration tasks`,
use_git && cyan('git add -A'),
use_git && cyan('git commit -m "svelte-migrate: updated files"')
].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`);
}
}
104 changes: 104 additions & 0 deletions packages/migrate/migrations/routes/migrate_page_js/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import ts from 'typescript';
import {
automigration,
dedent,
error,
get_function_node,
get_object_nodes,
is_new,
is_string_like,
manual_return_migration,
parse,
rewrite_returns
} from '../utils.js';
import * as TASKS from '../tasks.js';

const give_up = `${error('Update load function', TASKS.PAGE_LOAD)}\n\n`;

/** @param {string} content */
export function migrate_page(content) {
// early out if we can tell there's no load function
// without parsing the file
if (!/load/.test(content)) return content;

const file = parse(content);
if (!file) return give_up + content;

if (!file.exports.map.has('load') && !file.exports.complex) {
// there's no load function here, so there's nothing to do
return content;
}

const name = file.exports.map.get('load');

for (const statement of file.ast.statements) {
const fn = get_function_node(statement, name);
if (fn) {
/** @type {Set<string>} */
const imports = new Set();

rewrite_returns(fn.body, (expr, node) => {
const nodes = ts.isObjectLiteralExpression(expr) && get_object_nodes(expr);

if (nodes) {
const keys = Object.keys(nodes).sort().join(' ');

if (keys === 'props') {
automigration(expr, file.code, dedent(nodes.props.getText()));
return;
}

// check for existence of `node`, otherwise it's an arrow function
// with an implicit body, which we bail out on
if (node) {
const status = nodes.status && Number(nodes.status.getText());

// logic based on https://github.com/sveltejs/kit/blob/67e2342149847d267eb0c50809a1f93f41fa529b/packages/kit/src/runtime/load.js
if (keys === 'redirect status' && status > 300 && status < 400) {
automigration(
node,
file.code,
`throw redirect(${status}, ${nodes.redirect.getText()});`
);
imports.add('redirect');
return;
}

if (nodes.error) {
const message = is_string_like(nodes.error)
? nodes.error.getText()
: is_new(nodes.error, 'Error') && nodes.error.arguments[0].getText();

if (message) {
automigration(node, file.code, `throw error(${status || 500}, ${message});`);
imports.add('error');
return;
}
} else if (status >= 400) {
automigration(node, file.code, `throw error(${status});`);
imports.add('error');
return;
}
}
}

manual_return_migration(node || fn, file.code, TASKS.PAGE_LOAD);
});

if (imports.size) {
const has_imports = file.ast.statements.some((statement) =>
ts.isImportDeclaration(statement)
);
const declaration = `import { ${[...imports.keys()].join(', ')} } from '@sveltejs/kit';`;

return declaration + (has_imports ? '\n' : '\n\n') + file.code.toString();
}

return file.code.toString();
}
}

// we failed to rewrite the load function, so we inject
// an error at the top of the file
return give_up + content;
}
13 changes: 13 additions & 0 deletions packages/migrate/migrations/routes/migrate_page_js/index.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { read_samples } from '../utils.js';
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { migrate_page } from './index.js';

for (const sample of read_samples(import.meta.url)) {
test(sample.description, () => {
const actual = migrate_page(sample.before);
assert.equal(actual, sample.after);
});
}

test.run();
Loading

0 comments on commit 743f1c0

Please sign in to comment.