@ -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 < 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 \t import ${ 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 ( 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' ) } ` ) ;
}