diff --git a/playgrounds/sandbox/scripts/download.js b/playgrounds/sandbox/scripts/download.js index b5eaeaa8f5..b0bad54e73 100644 --- a/playgrounds/sandbox/scripts/download.js +++ b/playgrounds/sandbox/scripts/download.js @@ -1,6 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { parseArgs } from 'node:util'; +import { execSync } from 'node:child_process'; +import { chromium } from 'playwright'; const { values, positionals } = parseArgs({ options: { @@ -19,53 +21,639 @@ if (!url_arg) { process.exit(1); } -/** @type {URL} */ -let url; +const base_dir = import.meta.dirname; -try { - url = new URL(url_arg); -} catch (e) { - console.error(`${url_arg} is not a URL`); - process.exit(1); +/** + * Check if the argument is a local directory path + * @param {string} arg + * @returns {boolean} + */ +function is_local_directory(arg) { + try { + return fs.existsSync(arg) && fs.statSync(arg).isDirectory(); + } catch { + return false; + } } -if (url.origin !== 'https://svelte.dev' || !url.pathname.startsWith('/playground/')) { - console.error(`${url_arg} is not a Svelte playground URL`); - process.exit(1); +// Check if it's a local directory first (before URL parsing) +const is_local = is_local_directory(url_arg); + +/** @type {URL | null} */ +let url = null; + +if (!is_local) { + try { + url = new URL(url_arg); + } catch (e) { + console.error(`${url_arg} is not a valid URL or local directory`); + process.exit(1); + } } -let files; +/** + * Check if URL is a GitHub repository URL + * @param {URL} url + * @returns {boolean} + */ +function is_github_url(url) { + return url.hostname === 'github.com' && url.pathname.split('/').filter(Boolean).length >= 2; +} + +/** + * Check if URL is a StackBlitz GitHub project URL + * @param {URL} url + * @returns {boolean} + */ +function is_stackblitz_github_url(url) { + return url.hostname === 'stackblitz.com' && url.pathname.startsWith('/github/'); +} + +/** + * Check if URL is a StackBlitz edit project URL (non-GitHub) + * @param {URL} url + * @returns {boolean} + */ +function is_stackblitz_edit_url(url) { + return url.hostname === 'stackblitz.com' && url.pathname.startsWith('/edit/'); +} + +/** + * Extract GitHub repo info from a StackBlitz GitHub URL + * @param {URL} url + * @returns {{ owner: string, repo: string, path?: string }} + */ +function extract_stackblitz_github_info(url) { + // URL format: /github/owner/repo or /github/owner/repo/tree/branch/path + const parts = url.pathname.split('/').filter(Boolean); + // parts[0] = 'github', parts[1] = owner, parts[2] = repo + return { + owner: parts[1], + repo: parts[2], + path: parts.length > 3 ? parts.slice(3).join('/') : undefined + }; +} + +/** + * Clone a GitHub repository to a temporary directory + * @param {URL} url + * @param {string} target_dir + */ +function clone_github_repo(url, target_dir) { + // Extract repo URL (handle both https://github.com/owner/repo and https://github.com/owner/repo/tree/branch/path) + const parts = url.pathname.split('/').filter(Boolean); + const owner = parts[0]; + const repo = parts[1]; + const repo_url = `https://github.com/${owner}/${repo}.git`; + + console.log(`Cloning ${repo_url}...`); + execSync(`git clone --depth 1 ${repo_url} "${target_dir}"`, { stdio: 'inherit' }); +} + +/** + * Clone a StackBlitz GitHub project to a temporary directory + * (Converts to regular GitHub clone) + * @param {URL} url + * @param {string} target_dir + */ +function clone_stackblitz_github_project(url, target_dir) { + const info = extract_stackblitz_github_info(url); + const repo_url = `https://github.com/${info.owner}/${info.repo}.git`; -if (url.hash.length > 1) { - const decoded = atob(url.hash.slice(1).replaceAll('-', '+').replaceAll('_', '/')); - // putting it directly into the blob gives a corrupted file - const u8 = new Uint8Array(decoded.length); - for (let i = 0; i < decoded.length; i++) { - u8[i] = decoded.charCodeAt(i); + console.log(`StackBlitz GitHub project detected, cloning from ${repo_url}...`); + + execSync(`git clone --depth 1 ${repo_url} "${target_dir}"`, { stdio: 'inherit' }); +} + +/** + * Download a StackBlitz project using browser automation + * @param {URL} url + * @param {string} target_dir + */ +async function download_stackblitz_project(url, target_dir) { + console.log(`Downloading StackBlitz project via browser automation...`); + console.log(`URL: ${url.href}`); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ acceptDownloads: true }); + const page = await context.newPage(); + + try { + // Navigate to the StackBlitz project + console.log('Loading StackBlitz project (this may take a moment)...'); + await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 20000 }); + + // Set up download handler + const downloadPromise = page.waitForEvent('download', { timeout: 30000 }); + await page.locator('button[aria-label*="Download Project" i]', { timeout: 30000 }).click(); + console.log('Triggering download...'); + + // Wait for the download to start + const download = await downloadPromise; + + // Save the downloaded file + const zip_path = path.join(target_dir, 'project.zip'); + await download.saveAs(zip_path); + + console.log('Download complete, extracting...'); + + // Extract the zip file + if (process.platform === 'win32') { + execSync( + `powershell -Command "Expand-Archive -Path '${zip_path}' -DestinationPath '${target_dir}' -Force"`, + { stdio: 'inherit' } + ); + } else { + execSync(`unzip -o "${zip_path}" -d "${target_dir}"`, { stdio: 'inherit' }); + } + + // Remove the zip file + fs.unlinkSync(zip_path); + + // Check if files were extracted into a subdirectory + const entries = fs.readdirSync(target_dir); + if (entries.length === 1) { + const subdir = path.join(target_dir, entries[0]); + if (fs.statSync(subdir).isDirectory()) { + // Move files from subdirectory to target_dir + const subentries = fs.readdirSync(subdir); + for (const entry of subentries) { + fs.renameSync(path.join(subdir, entry), path.join(target_dir, entry)); + } + fs.rmdirSync(subdir); + } + } + + console.log('StackBlitz project downloaded successfully'); + } finally { + await browser.close(); } - const stream = new Blob([u8]).stream().pipeThrough(new DecompressionStream('gzip')); - const json = await new Response(stream).text(); +} - files = JSON.parse(json).files; -} else { - const id = url.pathname.split('/')[2]; - const response = await fetch(`https://svelte.dev/playground/api/${id}.json`); - - files = (await response.json()).components.map((data) => { - const basename = `${data.name}.${data.type}`; - - return { - type: 'file', - name: basename, - basename, - contents: data.source, - text: true - }; +/** + * Recursively get all files in a directory + * @param {string} dir + * @param {string} base + * @returns {Array<{path: string, name: string, contents: string}>} + */ +function get_all_files(dir, base = '') { + /** @type {Array<{path: string, name: string, contents: string}>} */ + const results = []; + + if (!fs.existsSync(dir)) return results; + + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + const full_path = path.join(dir, entry.name); + const relative_path = base ? `${base}/${entry.name}` : entry.name; + + if (entry.isDirectory()) { + // Skip node_modules, .git, etc. + if (['node_modules', '.git', '.svelte-kit', 'build', 'dist'].includes(entry.name)) { + continue; + } + results.push(...get_all_files(full_path, relative_path)); + } else if ( + entry.name.endsWith('.svelte') || + entry.name.endsWith('.js') || + entry.name.endsWith('.ts') + ) { + results.push({ + path: relative_path, + name: entry.name, + contents: fs.readFileSync(full_path, 'utf-8') + }); + } + } + + return results; +} + +/** + * Detect project type from files + * @param {Array<{path: string, name: string, contents: string}>} files + * @returns {{ type: 'sveltekit' | 'vite', has_app_imports: boolean, has_page_svelte: boolean }} + */ +function detect_project_type(files) { + let has_app_imports = false; + let has_page_svelte = false; + + for (const file of files) { + // Check for $app/* imports + if (/from\s+['"](\$app\/[^'"]+)['"]/.test(file.contents)) { + has_app_imports = true; + } + // Check for +page.svelte or +layout.svelte + if (file.name === '+page.svelte' || file.name === '+layout.svelte') { + has_page_svelte = true; + } + } + + return { + type: has_page_svelte ? 'sveltekit' : 'vite', + has_app_imports, + has_page_svelte + }; +} + +/** + * Convert a route path to a PascalCase component name + * @param {string} route_path - e.g., "about", "blog/post" + * @returns {string} - e.g., "About", "BlogPost" + */ +function route_to_component_name(route_path) { + if (!route_path || route_path === '') return 'Page'; + + return route_path + .split('/') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(''); +} + +/** + * Transform $lib/* imports to relative imports (flattened) + * @param {string} content + * @returns {string} + */ +function transform_lib_imports(content) { + // Replace $lib/ imports with relative paths to flattened files + return content.replace(/from\s+['"](\$lib\/[^'"]+)['"]/g, (match, import_path) => { + // Get just the filename from the lib path + const lib_path = import_path.replace('$lib/', ''); + const filename = path.basename(lib_path); + return `from './${filename}'`; }); } -const base_dir = import.meta.dirname; +/** + * Build the route tree from SvelteKit files + * @param {Array<{path: string, name: string, contents: string}>} files + * @param {string} routes_prefix - e.g., "src/routes" + * @returns {Map} + */ +function build_route_tree(files, routes_prefix) { + /** @type {Map} */ + const routes = new Map(); + for (const file of files) { + if (!file.path.startsWith(routes_prefix)) continue; + + const relative_to_routes = file.path.slice(routes_prefix.length + 1); // +1 for the slash + const dir = path.dirname(relative_to_routes); + const route_key = dir === '.' ? '' : dir; + + if (!routes.has(route_key)) { + routes.set(route_key, {}); + } + + const route = routes.get(route_key); + if (file.name === '+layout.svelte') { + route.layout = { path: file.path, contents: file.contents }; + } else if (file.name === '+page.svelte') { + route.page = { path: file.path, contents: file.contents }; + } + } + + return routes; +} + +/** + * Get child routes of a given route + * @param {Map} routes + * @param {string} parent_route + * @returns {string[]} + */ +function get_child_routes(routes, parent_route) { + const children = []; + for (const route of routes.keys()) { + if (route === parent_route) continue; + + const parent_prefix = parent_route === '' ? '' : parent_route + '/'; + if ( + parent_route === '' + ? !route.includes('/') + : route.startsWith(parent_prefix) && !route.slice(parent_prefix.length).includes('/') + ) { + children.push(route); + } + } + return children; +} + +/** + * Transform a layout file's content to replace {@render children()} with a component + * @param {string} content + * @param {string} child_component_name + * @returns {string} + */ +function transform_layout_content(content, child_component_name) { + // Add import for the child component at the top of the script + const import_statement = `import ${child_component_name} from './${child_component_name}.svelte';`; + + // Check if there's already a script tag + if (/]*>/.test(content)) { + // Add import after the opening script tag + content = content.replace(/(]*>)/, `$1\n\t${import_statement}`); + } else { + // Add a new script block at the beginning + content = `\n\n${content}`; + } + + // Replace {@render children()} or {@render children?.()} with the component + content = content.replace(/\{@render\s+children\?\.\(\)\}/g, `<${child_component_name} />`); + content = content.replace(/\{@render\s+children\(\)\}/g, `<${child_component_name} />`); + + return content; +} + +/** + * Convert a SvelteKit project to plain Svelte components + * @param {string} repo_dir + * @returns {Array<{name: string, contents: string}>} + */ +function convert_sveltekit_project(repo_dir) { + const all_files = get_all_files(repo_dir); + /** @type {Array<{name: string, contents: string}>} */ + const output_files = []; + + // Find the routes directory + let routes_prefix = ''; + for (const file of all_files) { + if (file.path.includes('src/routes/')) { + routes_prefix = 'src/routes'; + break; + } + } + + if (!routes_prefix) { + console.error('Could not find src/routes directory'); + process.exit(1); + } + + // Build route tree + const routes = build_route_tree(all_files, routes_prefix); + + // Process lib files - flatten them to root level + const lib_files = all_files.filter((f) => f.path.startsWith('src/lib/')); + for (const file of lib_files) { + // Flatten to just the filename + const new_path = path.basename(file.path); + let contents = file.contents; + contents = transform_lib_imports(contents); + output_files.push({ + name: new_path, + contents + }); + } + + // Sort routes by depth (deepest first) so we can process children before parents + const sorted_routes = [...routes.keys()].sort((a, b) => { + const depth_a = a === '' ? 0 : a.split('/').length; + const depth_b = b === '' ? 0 : b.split('/').length; + return depth_b - depth_a; + }); + + // Map to store what component each route renders + /** @type {Map} */ + const route_component_map = new Map(); + + // First pass: convert all pages + for (const route_key of sorted_routes) { + const route = routes.get(route_key); + if (route?.page) { + const component_name = route_to_component_name(route_key); + let contents = route.page.contents; + contents = transform_lib_imports(contents); + output_files.push({ + name: `${component_name}.svelte`, + contents + }); + // If no layout, this is what the route renders + if (!route.layout) { + route_component_map.set(route_key, component_name); + } + } + } + + // Second pass: convert layouts (from deepest to root) + for (const route_key of sorted_routes) { + const route = routes.get(route_key); + if (route?.layout) { + const is_root = route_key === ''; + const component_name = is_root ? 'App' : route_to_component_name(route_key) + 'Layout'; + + // Determine what child component this layout should render + let child_component = ''; + + // Check if there's a page at this route level + if (route.page) { + child_component = route_to_component_name(route_key); + } else { + // Find child routes that have content + const children = get_child_routes(routes, route_key); + if (children.length > 0) { + // Use the first child's component (or its layout if it has one) + const first_child = children[0]; + child_component = + route_component_map.get(first_child) || route_to_component_name(first_child); + } + } + + let contents = route.layout.contents; + contents = transform_lib_imports(contents); + + if (child_component) { + contents = transform_layout_content(contents, child_component); + } else { + // No child, just remove {@render children()} or {@render children?.()} + contents = contents.replace(/\{@render\s+children\?\.\(\)\}/g, ''); + contents = contents.replace(/\{@render\s+children\(\)\}/g, ''); + } + + output_files.push({ + name: `${component_name}.svelte`, + contents + }); + + // This route now renders the layout + route_component_map.set(route_key, component_name); + } + } + + // If there's no root layout but there's a root page, rename it to App.svelte + if (!routes.get('')?.layout && routes.get('')?.page) { + const page_index = output_files.findIndex((f) => f.name === 'Page.svelte'); + if (page_index !== -1) { + output_files[page_index].name = 'App.svelte'; + } + } + + // If there's no App.svelte yet, create one that imports the first available component + if (!output_files.some((f) => f.name === 'App.svelte')) { + const first_component = output_files.find( + (f) => f.name.endsWith('.svelte') && !f.name.includes('/') + ); + if (first_component) { + const comp_name = first_component.name.replace('.svelte', ''); + output_files.push({ + name: 'App.svelte', + contents: `\n\n<${comp_name} />\n` + }); + } + } + + return output_files; +} + +/** + * Convert a regular Vite+Svelte project + * @param {string} repo_dir + * @returns {Array<{name: string, contents: string}>} + */ +function convert_vite_project(repo_dir) { + const all_files = get_all_files(repo_dir); + /** @type {Array<{name: string, contents: string}>} */ + const output_files = []; + + // Find src directory + const src_files = all_files.filter( + (f) => + f.path.startsWith('src/') && + (f.name.endsWith('.svelte') || f.name.endsWith('.js') || f.name.endsWith('.ts')) + ); + + for (const file of src_files) { + // Flatten all files to root level (just the filename) + const new_path = path.basename(file.path); + + let contents = file.contents; + contents = transform_lib_imports(contents); + + output_files.push({ + name: new_path, + contents + }); + } + + return output_files; +} + +/** + * Process a local or cloned directory + * @param {string} dir_path + * @returns {Array<{name: string, contents: string}>} + */ +function process_directory(dir_path) { + const all_files = get_all_files(dir_path); + const project_info = detect_project_type(all_files); + + console.log(`Detected project type: ${project_info.type}`); + + // Check for $app/* imports + if (project_info.has_app_imports) { + console.error('Error: This SvelteKit project uses $app/* imports which cannot be converted.'); + console.error('The playground does not support SvelteKit runtime features.'); + process.exit(1); + } + + // Convert based on project type + if (project_info.type === 'sveltekit') { + console.log('Converting SvelteKit project to plain Svelte...'); + return convert_sveltekit_project(dir_path); + } else { + console.log('Processing Vite+Svelte project...'); + return convert_vite_project(dir_path); + } +} + +/** + * Reset a directory so it exists and is empty + * @param {string} dir_path + */ +/** + * Create a temporary directory, run an action, and always clean up + * @param {string} base_dir + * @param {(dir: string) => void | Promise} action + */ +async function with_tmp_dir(base_dir, action) { + const tmp_dir = path.join(base_dir, '.tmp-repo'); + + try { + if (fs.existsSync(tmp_dir)) { + fs.rmSync(tmp_dir, { recursive: true, force: true }); + } + fs.mkdirSync(tmp_dir, { recursive: true }); + await action(tmp_dir); + } finally { + if (fs.existsSync(tmp_dir)) { + fs.rmSync(tmp_dir, { recursive: true, force: true }); + } + } +} + +// Main logic +let files; + +// Check if it's a local directory first (before URL parsing) +if (is_local) { + console.log(`Processing local directory: ${url_arg}`); + files = process_directory(url_arg); +} else if (url && is_github_url(url)) { + // GitHub repository handling + await with_tmp_dir(base_dir, (tmp_dir) => { + clone_github_repo(url, tmp_dir); + files = process_directory(tmp_dir); + }); +} else if (url && is_stackblitz_github_url(url)) { + // StackBlitz GitHub project handling (redirect to GitHub clone) + await with_tmp_dir(base_dir, (tmp_dir) => { + clone_stackblitz_github_project(url, tmp_dir); + files = process_directory(tmp_dir); + }); +} else if (url && is_stackblitz_edit_url(url)) { + // StackBlitz edit URLs - use browser automation to download + await with_tmp_dir(base_dir, async (tmp_dir) => { + await download_stackblitz_project(url, tmp_dir); + files = process_directory(tmp_dir); + }); +} else if (url && url.origin === 'https://svelte.dev' && url.pathname.startsWith('/playground/')) { + // Svelte playground URL handling (existing logic) + if (url.hash.length > 1) { + const decoded = atob(url.hash.slice(1).replaceAll('-', '+').replaceAll('_', '/')); + // putting it directly into the blob gives a corrupted file + const u8 = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i++) { + u8[i] = decoded.charCodeAt(i); + } + const stream = new Blob([u8]).stream().pipeThrough(new DecompressionStream('gzip')); + const json = await new Response(stream).text(); + + files = JSON.parse(json).files; + } else { + const id = url.pathname.split('/')[2]; + const response = await fetch(`https://svelte.dev/playground/api/${id}.json`); + + files = (await response.json()).components.map((data) => { + const basename = `${data.name}.${data.type}`; + + return { + type: 'file', + name: basename, + basename, + contents: data.source, + text: true + }; + }); + } +} else { + console.error( + `${url_arg} is not a supported URL (Svelte playground, GitHub repository, or StackBlitz project)` + ); + process.exit(1); +} + +// Output files if (create_test_name) { const test_parts = create_test_name.split('/').filter(Boolean); @@ -111,8 +699,14 @@ export default test({ }); ` ); + + console.log(`Test created at ${output_dir}`); } else { for (const file of files) { - fs.writeFileSync(path.join(base_dir, '..', 'src', file.name), file.contents); + const output_path = path.join(base_dir, '..', 'src', file.name); + fs.mkdirSync(path.dirname(output_path), { recursive: true }); + fs.writeFileSync(output_path, file.contents); } + + console.log(`Files written to ${path.join(base_dir, '..', 'src')}`); }