You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte/playgrounds/sandbox/scripts/download.js

728 lines
21 KiB

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: {
'create-test': {
type: 'string'
}
},
allowPositionals: true
});
const create_test_name = values['create-test'] ?? null;
const url_arg = positionals[0];
if (!url_arg) {
console.error(`Missing URL argument`);
process.exit(1);
}
const base_dir = import.meta.dirname;
/**
* 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;
}
}
// Check if it's a local directory first (before URL parsing)
const is_local = is_local_directory(url_arg);
const resolved_test_path = ['runtime-runes', 'runtime-legacy']
.map((d) => path.resolve(`${base_dir}/../../../packages/svelte/tests/${d}/samples/${url_arg}`))
.find(fs.existsSync);
/** @type {URL | null} */
let url = null;
if (!is_local && !resolved_test_path) {
try {
url = new URL(url_arg);
} catch (e) {
console.error(`${url_arg} is not a valid URL or local directory`);
process.exit(1);
}
}
/**
* 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`;
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();
}
}
/**
* 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}'`;
});
}
/**
* 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<string, {layout?: {path: string, contents: string}, page?: {path: string, contents: string}}>}
*/
function build_route_tree(files, routes_prefix) {
/** @type {Map<string, {layout?: {path: string, contents: string}, page?: {path: string, contents: string}}>} */
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<string, any>} 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 (/<script[^>]*>/.test(content)) {
// Add import after the opening script tag
content = content.replace(/(<script[^>]*>)/, `$1\n\t${import_statement}`);
} else {
// Add a new script block at the beginning
content = `<script>\n\t${import_statement}\n</script>\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<string, string>} */
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, '<!-- no child content -->');
contents = contents.replace(/\{@render\s+children\(\)\}/g, '<!-- no child content -->');
}
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: `<script>\n\timport ${comp_name} from './${first_component.name}';\n</script>\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<void>} 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 (resolved_test_path) {
// Copy files from test
console.log(`Processing test ${url_arg}`);
files = get_all_files(resolved_test_path)
.filter((file) => !file.path.includes('_'))
.map((file) => {
return {
name: file.name === 'main.svelte' ? 'App.svelte' : file.name,
contents: file.contents
};
});
} 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);
if (test_parts.length > 2) {
console.error(
`Invalid test name "${create_test_name}". Expected e.g. "hello-world" or "runtime-legacy/hello-world"`
);
process.exit(1);
}
const suite_name = test_parts.length === 2 ? test_parts[0] : 'runtime-runes';
const test_name = test_parts[test_parts.length - 1];
const output_dir = path.join(
base_dir,
'../../..',
'packages/svelte/tests',
suite_name,
'samples',
test_name
);
if (fs.existsSync(output_dir)) {
console.warn(`Test folder "${output_dir}" already exists, overriding its contents`);
fs.rmSync(output_dir, { recursive: true, force: true });
}
fs.mkdirSync(output_dir, { recursive: true });
for (const file of files) {
const output_name = file.name === 'App.svelte' ? 'main.svelte' : file.name;
const output_path = path.join(output_dir, output_name);
fs.mkdirSync(path.dirname(output_path), { recursive: true });
fs.writeFileSync(output_path, file.contents);
}
fs.writeFileSync(
path.join(output_dir, '_config.js'),
`import { test } from '../../test';
export default test({
async test({ assert, target }) {
}
});
`
);
console.log(`Test created at ${output_dir}`);
} else {
for (const file of files) {
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')}`);
}