UNPKG

svelte-migrate

Version:

A CLI for migrating Svelte(Kit) codebases

445 lines (384 loc) 11.8 kB
import * as p from '@clack/prompts'; import pc from 'picocolors'; import MagicString from 'magic-string'; import { execFileSync, execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; import semver from 'semver'; import ts from 'typescript'; /** @param {string} message */ export function bail(message) { p.log.error(pc.bold(pc.red(message))); process.exit(1); } /** @param {string} file */ export function relative(file) { return path.relative('.', file); } /** * * @param {string} file * @param {string} renamed * @param {string} content * @param {boolean} use_git */ export function move_file(file, renamed, content, use_git) { if (use_git) { execFileSync('git', ['mv', file, renamed]); } else { fs.unlinkSync(file); } fs.writeFileSync(renamed, content); } /** * @param {string} contents * @param {string} indent */ export function comment(contents, indent) { return contents.replace(new RegExp(`^${indent}`, 'gm'), `${indent}// `); } /** @param {string} content */ export function dedent(content) { const indent = guess_indent(content); if (!indent) return content; /** @type {string[]} */ const substitutions = []; try { const ast = ts.createSourceFile( 'filename.ts', content, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS ); const code = new MagicString(content); /** @param {ts.Node} node */ function walk(node) { if (ts.isTemplateLiteral(node)) { let pos = node.pos; while (/\s/.test(content[pos])) pos += 1; code.overwrite(pos, node.end, `____SUBSTITUTION_${substitutions.length}____`); substitutions.push(node.getText()); } node.forEachChild(walk); } ast.forEachChild(walk); return code .toString() .replace(new RegExp(`^${indent}`, 'gm'), '') .replace(/____SUBSTITUTION_(\d+)____/g, (match, index) => substitutions[index]); } catch { // as above — ignore this edge case return content; } } /** @param {string} content */ export function guess_indent(content) { const lines = content.split('\n'); const tabbed = lines.filter((line) => /^\t+/.test(line)); const spaced = lines.filter((line) => /^ {2,}/.test(line)); if (tabbed.length === 0 && spaced.length === 0) { return null; } // More lines tabbed than spaced? Assume tabs, and // default to tabs in the case of a tie (or nothing // to go on) if (tabbed.length >= spaced.length) { return '\t'; } // Otherwise, we need to guess the multiple const min = spaced.reduce((previous, current) => { const count = /^ +/.exec(current)?.[0].length ?? 0; return Math.min(count, previous); }, Infinity); return ' '.repeat(min); } /** * @param {string} content * @param {number} offset */ export function indent_at_line(content, offset) { const substr = content.substring(content.lastIndexOf('\n', offset) + 1, offset); return /\s*/.exec(substr)?.[0] ?? ''; } /** * @param {string} content * @param {string} except */ export function except_str(content, except) { const start = content.indexOf(except); const end = start + except.length; return content.substring(0, start) + content.substring(end); } /** * @returns {boolean} True if git is installed */ export function check_git() { 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.'; p.log.warning(pc.bold(pc.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/cli.\n'; p.log.warning(pc.bold(pc.red(message))); use_git = false; } } return use_git; } /** * Get a list of all files in a directory * @param {string} cwd - the directory to walk * @param {boolean} [dirs] - whether to include directories in the result */ export function walk(cwd, dirs = false) { /** @type {string[]} */ const all_files = []; /** @param {string} dir */ function walk_dir(dir) { const files = fs.readdirSync(path.join(cwd, dir)); for (const file of files) { const joined = path.join(dir, file); const stats = fs.statSync(path.join(cwd, joined)); if (stats.isDirectory()) { if (dirs) all_files.push(joined); walk_dir(joined); } else { all_files.push(joined); } } } return walk_dir(''), all_files; } /** @param {string} str */ export function posixify(str) { return str.replace(/\\/g, '/'); } /** * @param {string} content * @param {Array<[string, string, string?, ('dependencies' | 'devDependencies')?]>} updates */ export function update_pkg(content, updates) { const indent = content.split('\n')[1].match(/^\s+/)?.[0] || ' '; const pkg = JSON.parse(content); /** * @param {string} name * @param {string} version * @param {string} [additional] * @param {'dependencies' | 'devDependencies' | undefined} [insert] */ function update_pkg(name, version, additional = '', insert) { /** * @param {string} type */ const updateVersion = (type) => { const existingRange = pkg[type]?.[name]; if ( existingRange && semver.validRange(existingRange) && !semver.subset(existingRange, version) ) { // Check if the new version range is an upgrade const minExistingVersion = semver.minVersion(existingRange); const minNewVersion = semver.minVersion(version); if (minExistingVersion && minNewVersion && semver.gt(minNewVersion, minExistingVersion)) { log_migration(`Updated ${name} to ${version}`); pkg[type][name] = version; } } }; updateVersion('dependencies'); updateVersion('devDependencies'); if (insert && !pkg[insert]?.[name]) { if (!pkg[insert]) pkg[insert] = {}; // Insert the property in sorted position without adjusting other positions so diffs are easier to read const sorted_keys = Object.keys(pkg[insert]).sort(); const index = sorted_keys.findIndex((key) => name.localeCompare(key) === -1); const insert_index = index !== -1 ? index : sorted_keys.length; const new_properties = Object.entries(pkg[insert]); new_properties.splice(insert_index, 0, [name, version]); pkg[insert] = Object.fromEntries(new_properties); log_migration(`Added ${name} version ${version} ${additional}`); } } for (const update of updates) { update_pkg(...update); } const result = JSON.stringify(pkg, null, indent); if (content.endsWith('\n')) return result + '\n'; return result; } const logged_migrations = new Set(); /** * @param {import('ts-morph').SourceFile} source * @param {string} text */ export function log_on_ts_modification(source, text) { let logged = false; const log = () => { if (!logged) { logged = true; log_migration(text); } }; source.onModified(log); return () => source.onModified(log, false); } /** @param {string} text */ export function log_migration(text) { if (logged_migrations.has(text)) return; console.log(text); logged_migrations.add(text); } /** * Parses the scripts contents and invoked `transform_script_code` with it, then runs the result through `transform_svelte_code`. * The result is written back to disk. * @param {string} file_path * @param {(code: string, is_ts: boolean, file_path: string) => string} transform_script_code * @param {(code: string, file_path: string) => string} transform_svelte_code */ export function update_svelte_file(file_path, transform_script_code, transform_svelte_code) { try { const content = fs.readFileSync(file_path, 'utf-8'); const updated = content.replace( /<script([^]*?)>([^]+?)<\/script>(\n*)/g, (_match, attrs, contents, whitespace) => { return `<script${attrs}>${transform_script_code( contents, (attrs.includes('lang=') || attrs.includes('type=')) && (attrs.includes('ts') || attrs.includes('typescript')), file_path )}</script>${whitespace}`; } ); fs.writeFileSync(file_path, transform_svelte_code(updated, file_path), 'utf-8'); } catch (err) { // TODO: change to import('svelte/compiler').Warning after upgrading to Svelte 5 const e = /** @type {any} */ (err); console.warn(buildExtendedLogMessage(e), e.frame); console.info(e.stack); } } /** * Reads the file and invokes `transform_code` with its contents. The result is written back to disk. * @param {string} file_path * @param {(code: string, is_ts: boolean, file_path: string) => string} transform_code */ export function update_js_file(file_path, transform_code) { try { const content = fs.readFileSync(file_path, 'utf-8'); const updated = transform_code(content, file_path.endsWith('.ts'), file_path); fs.writeFileSync(file_path, updated, 'utf-8'); } catch (err) { // TODO: change to import('svelte/compiler').Warning after upgrading to Svelte 5 const e = /** @type {any} */ (err); console.warn(buildExtendedLogMessage(e), e.frame); console.info(e.stack); } } /** * @param {any} w */ export function buildExtendedLogMessage(w) { const parts = []; if (w.filename) { parts.push(w.filename); } if (w.start) { parts.push(':', w.start.line, ':', w.start.column); } if (w.message) { if (parts.length > 0) { parts.push(' '); } parts.push(w.message); } return parts.join(''); } /** * Updates the tsconfig/jsconfig.json file with the provided function. * @param {(content: string) => string} update_tsconfig_content */ export function update_tsconfig(update_tsconfig_content) { const file = fs.existsSync('tsconfig.json') ? 'tsconfig.json' : fs.existsSync('jsconfig.json') ? 'jsconfig.json' : null; if (file) { fs.writeFileSync(file, update_tsconfig_content(fs.readFileSync(file, 'utf8'))); } } /** @param {string | URL} test_file */ export function read_samples(test_file) { const markdown = fs.readFileSync(test_file, 'utf8').replaceAll('\r\n', '\n'); const samples = markdown .split(/^##/gm) .slice(1) .map((block) => { const description = block.split('\n')[0]; const before = /```(js|ts|svelte) before\n([^]*?)\n```/.exec(block); const after = /```(js|ts|svelte) after\n([^]*?)\n```/.exec(block); const match = /> file: (.+)/.exec(block); return { description, before: before ? before[2] : '', after: after ? after[2] : '', filename: match?.[1], solo: block.includes('> solo') }; }); if (samples.some((sample) => sample.solo)) { return samples.filter((sample) => sample.solo); } return samples; } /** * @param {import('ts-morph').SourceFile} source * @param {string} _import * @param {string} method */ export function add_named_import(source, _import, method) { const existing = source.getImportDeclaration(_import); if (existing) { if (existing.getNamedImports().some((i) => i.getName() === method)) return; existing?.addNamedImport(method); } else { source.addImportDeclaration({ moduleSpecifier: _import, namedImports: [method] }); } } /** * @param {(string | false)[]} next_steps */ export function migration_succeeded(next_steps) { p.log.success(pc.bold(pc.green('✔ Your project has been migrated'))); if (!next_steps || next_steps.length === 0) { return; } /** @type {string[]} */ const messages = []; next_steps.forEach((step, i) => { if (!step) return; messages.push(`${i + 1}: ${step}`); }); p.note(messages.join('\n'), 'Recommended next steps:'); }