|
| 1 | +import fs from 'node:fs'; |
| 2 | +import path from 'node:path'; |
| 3 | +import * as js from '@sveltejs/cli-core/js'; |
| 4 | +import { parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; |
| 5 | + |
| 6 | +export function validatePlaygroundUrl(link: string): boolean { |
| 7 | + try { |
| 8 | + const url = new URL(link); |
| 9 | + if (url.hostname !== 'svelte.dev' || !url.pathname.startsWith('/playground/')) { |
| 10 | + return false; |
| 11 | + } |
| 12 | + |
| 13 | + const { playgroundId, hash } = parsePlaygroundUrl(link); |
| 14 | + return playgroundId !== undefined || hash !== undefined; |
| 15 | + } catch { |
| 16 | + // new Url() will throw if the URL is invalid |
| 17 | + return false; |
| 18 | + } |
| 19 | +} |
| 20 | + |
| 21 | +type PlaygroundURL = { |
| 22 | + playgroundId?: string; |
| 23 | + hash?: string; |
| 24 | + svelteVersion?: string; |
| 25 | +}; |
| 26 | + |
| 27 | +export function parsePlaygroundUrl(link: string): PlaygroundURL { |
| 28 | + const url = new URL(link); |
| 29 | + const [, playgroundId] = url.pathname.match(/\/playground\/([^/]+)/) || []; |
| 30 | + const hash = url.hash !== '' ? url.hash.slice(1) : undefined; |
| 31 | + const svelteVersion = url.searchParams.get('version') || undefined; |
| 32 | + |
| 33 | + return { playgroundId, hash, svelteVersion }; |
| 34 | +} |
| 35 | + |
| 36 | +type PlaygroundData = { |
| 37 | + name: string; |
| 38 | + files: Array<{ name: string; content: string }>; |
| 39 | + svelteVersion?: string; |
| 40 | +}; |
| 41 | + |
| 42 | +export async function downloadPlaygroundData({ |
| 43 | + playgroundId, |
| 44 | + hash, |
| 45 | + svelteVersion |
| 46 | +}: PlaygroundURL): Promise<PlaygroundData> { |
| 47 | + let data = []; |
| 48 | + // forked playgrounds have a playground_id and an optional hash. |
| 49 | + // usually the hash is more up to date so take the hash if present. |
| 50 | + if (hash) { |
| 51 | + data = JSON.parse(await decodeAndDecompressText(hash)); |
| 52 | + } else { |
| 53 | + const response = await fetch(`https://svelte.dev/playground/api/${playgroundId}.json`); |
| 54 | + data = await response.json(); |
| 55 | + } |
| 56 | + |
| 57 | + // saved playgrounds and playground hashes have a different structure |
| 58 | + // therefore we need to handle both cases. |
| 59 | + const files = data.components !== undefined ? data.components : data.files; |
| 60 | + return { |
| 61 | + name: data.name, |
| 62 | + files: files.map((file: { name: string; type: string; contents: string; source: string }) => { |
| 63 | + return { |
| 64 | + name: file.name + (file.type !== 'file' ? `.${file.type}` : ''), |
| 65 | + content: file.source || file.contents |
| 66 | + }; |
| 67 | + }), |
| 68 | + svelteVersion |
| 69 | + }; |
| 70 | +} |
| 71 | + |
| 72 | +// Taken from https://github.com/sveltejs/svelte.dev/blob/ba7ad256f786aa5bc67eac3a58608f3f50b59e91/apps/svelte.dev/src/routes/(authed)/playground/%5Bid%5D/gzip.js#L19-L29 |
| 73 | +async function decodeAndDecompressText(input: string) { |
| 74 | + const decoded = atob(input.replaceAll('-', '+').replaceAll('_', '/')); |
| 75 | + // putting it directly into the blob gives a corrupted file |
| 76 | + const u8 = new Uint8Array(decoded.length); |
| 77 | + for (let i = 0; i < decoded.length; i++) { |
| 78 | + u8[i] = decoded.charCodeAt(i); |
| 79 | + } |
| 80 | + const stream = new Blob([u8]).stream().pipeThrough(new DecompressionStream('gzip')); |
| 81 | + return new Response(stream).text(); |
| 82 | +} |
| 83 | + |
| 84 | +/** |
| 85 | + * @returns A Map of packages with it's name as the key, and it's version as the value. |
| 86 | + */ |
| 87 | +export function detectPlaygroundDependencies(files: PlaygroundData['files']): Map<string, string> { |
| 88 | + const packages = new Map<string, string>(); |
| 89 | + |
| 90 | + // Prefixes for packages that should be excluded (built-in or framework packages) |
| 91 | + const excludedPrefixes = [ |
| 92 | + '$', // SvelteKit framework imports |
| 93 | + 'node:', // Node.js built-in modules |
| 94 | + 'svelte', // Svelte core packages |
| 95 | + '@sveltejs/' // All SvelteKit packages |
| 96 | + ]; |
| 97 | + |
| 98 | + for (const file of files) { |
| 99 | + let ast: js.AstTypes.Program | undefined; |
| 100 | + if (file.name.endsWith('.svelte')) { |
| 101 | + ast = parseSvelte(file.content).script.ast; |
| 102 | + } else if (file.name.endsWith('.js') || file.name.endsWith('.ts')) { |
| 103 | + ast = parseScript(file.content).ast; |
| 104 | + } |
| 105 | + if (!ast) continue; |
| 106 | + |
| 107 | + const imports = ast.body |
| 108 | + .filter((node): node is js.AstTypes.ImportDeclaration => node.type === 'ImportDeclaration') |
| 109 | + .map((node) => node.source.value as string) |
| 110 | + .filter((importPath) => !importPath.startsWith('./') && !importPath.startsWith('/')) |
| 111 | + .filter((importPath) => !excludedPrefixes.some((prefix) => importPath.startsWith(prefix))) |
| 112 | + .map(extractPackageInfo); |
| 113 | + |
| 114 | + imports.forEach(({ pkgName, version }) => packages.set(pkgName, version)); |
| 115 | + } |
| 116 | + |
| 117 | + return packages; |
| 118 | +} |
| 119 | + |
| 120 | +/** |
| 121 | + * Extracts a package's name and it's versions from a provided import path. |
| 122 | + * |
| 123 | + * Handles imports with or without subpaths (e.g. `pkg-name/subpath`, `@org/pkg-name/subpath`) |
| 124 | + * as well as specified versions (e.g. [email protected]). |
| 125 | + */ |
| 126 | +function extractPackageInfo(importPath: string): { pkgName: string; version: string } { |
| 127 | + let pkgName = ''; |
| 128 | + |
| 129 | + // handle scoped deps |
| 130 | + if (importPath.startsWith('@')) { |
| 131 | + // eslint-disable-next-line @typescript-eslint/no-unused-vars |
| 132 | + const [org, pkg, _subpath] = importPath.split('/', 3); |
| 133 | + pkgName = `${org}/${pkg}`; |
| 134 | + } |
| 135 | + |
| 136 | + if (!pkgName) { |
| 137 | + [pkgName] = importPath.split('/', 2); |
| 138 | + } |
| 139 | + |
| 140 | + const version = extractPackageVersion(pkgName); |
| 141 | + // strips the package's version from the name, if present |
| 142 | + if (version !== 'latest') pkgName = pkgName.replace(`@${version}`, ''); |
| 143 | + return { pkgName, version }; |
| 144 | +} |
| 145 | + |
| 146 | +function extractPackageVersion(pkgName: string) { |
| 147 | + let version = 'latest'; |
| 148 | + // e.g. `[email protected]` (starting from index 1 to ignore the first `@` in scoped packages) |
| 149 | + if (pkgName.includes('@', 1)) { |
| 150 | + [, version] = pkgName.split('@'); |
| 151 | + } |
| 152 | + return version; |
| 153 | +} |
| 154 | + |
| 155 | +export function setupPlaygroundProject( |
| 156 | + playground: PlaygroundData, |
| 157 | + cwd: string, |
| 158 | + installDependencies: boolean |
| 159 | +): void { |
| 160 | + const mainFile = playground.files.find((file) => file.name === 'App.svelte'); |
| 161 | + if (!mainFile) throw new Error('Failed to find `App.svelte` entrypoint.'); |
| 162 | + |
| 163 | + const dependencies = detectPlaygroundDependencies(playground.files); |
| 164 | + for (const file of playground.files) { |
| 165 | + for (const [pkg, version] of dependencies) { |
| 166 | + // if a version was specified, we'll remove it from all import paths |
| 167 | + if (version !== 'latest') { |
| 168 | + file.content = file.content.replaceAll(`${pkg}@${version}`, pkg); |
| 169 | + } |
| 170 | + } |
| 171 | + |
| 172 | + // write file to disk |
| 173 | + const filePath = path.join(cwd, 'src', 'routes', file.name); |
| 174 | + fs.mkdirSync(path.dirname(filePath), { recursive: true }); |
| 175 | + fs.writeFileSync(filePath, file.content, 'utf8'); |
| 176 | + } |
| 177 | + |
| 178 | + // add app import to +page.svelte |
| 179 | + const filePath = path.join(cwd, 'src/routes/+page.svelte'); |
| 180 | + const content = fs.readFileSync(filePath, 'utf-8'); |
| 181 | + const { script, generateCode } = parseSvelte(content); |
| 182 | + js.imports.addDefault(script.ast, { from: `./${mainFile.name}`, as: 'App' }); |
| 183 | + const newContent = generateCode({ script: script.generateCode(), template: `<App />` }); |
| 184 | + fs.writeFileSync(filePath, newContent, 'utf-8'); |
| 185 | + |
| 186 | + // add packages as dependencies to package.json if requested |
| 187 | + const pkgPath = path.join(cwd, 'package.json'); |
| 188 | + const pkgSource = fs.readFileSync(pkgPath, 'utf-8'); |
| 189 | + const pkgJson = parseJson(pkgSource); |
| 190 | + let updatePackageJson = false; |
| 191 | + if (installDependencies && dependencies.size >= 0) { |
| 192 | + updatePackageJson = true; |
| 193 | + pkgJson.data.dependencies ??= {}; |
| 194 | + for (const [dep, version] of dependencies) { |
| 195 | + pkgJson.data.dependencies[dep] = version; |
| 196 | + } |
| 197 | + } |
| 198 | + |
| 199 | + // we want to change the svelte version, even if the user decieded |
| 200 | + // to not install external dependencies |
| 201 | + if (playground.svelteVersion) { |
| 202 | + updatePackageJson = true; |
| 203 | + |
| 204 | + // from https://github.com/sveltejs/svelte.dev/blob/ba7ad256f786aa5bc67eac3a58608f3f50b59e91/packages/repl/src/lib/workers/npm.ts#L14 |
| 205 | + const pkgPrNewRegex = /^(pr|commit|branch)-(.+)/; |
| 206 | + const match = pkgPrNewRegex.exec(playground.svelteVersion); |
| 207 | + pkgJson.data.devDependencies['svelte'] = match |
| 208 | + ? `https://pkg.pr.new/svelte@${match[2]}` |
| 209 | + : `^${playground.svelteVersion}`; |
| 210 | + } |
| 211 | + |
| 212 | + // only update the package.json if we made any changes |
| 213 | + if (updatePackageJson) fs.writeFileSync(pkgPath, pkgJson.generateCode(), 'utf-8'); |
| 214 | +} |
0 commit comments