From 58d39bfd05f91300bfb4c5e0a7fbc8e4a1b1900f Mon Sep 17 00:00:00 2001 From: Manuel Serret Date: Sun, 3 Aug 2025 18:51:05 +0200 Subject: [PATCH 01/16] feat(cli): `sv create --from-playground` --- .changeset/fancy-towns-argue.md | 5 ++ packages/cli/commands/create.ts | 9 +++- packages/create/playground.ts | 78 ++++++++++++++++++++++++++++++ packages/create/test/playground.ts | 62 ++++++++++++++++++++++++ 4 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 .changeset/fancy-towns-argue.md create mode 100644 packages/create/playground.ts create mode 100644 packages/create/test/playground.ts diff --git a/.changeset/fancy-towns-argue.md b/.changeset/fancy-towns-argue.md new file mode 100644 index 00000000..2242e68b --- /dev/null +++ b/.changeset/fancy-towns-argue.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +feat(cli): `sv create --from-playground` diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index e1ea5a16..43c280f0 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -43,7 +43,8 @@ const OptionsSchema = v.strictObject({ ), addOns: v.boolean(), install: v.union([v.boolean(), v.picklist(AGENT_NAMES)]), - template: v.optional(v.picklist(templateChoices)) + template: v.optional(v.picklist(templateChoices)), + fromPlayground: v.optional(v.string()) }); type Options = v.InferOutput; type ProjectPath = v.InferOutput; @@ -56,6 +57,7 @@ export const create = new Command('create') .option('--no-types') .option('--no-add-ons', 'skips interactive add-on installer') .option('--no-install', 'skip installing dependencies') + .option('--from-playground ', 'create a project from the svelte playground') .addOption(installOption) .configureHelp(common.helpConfig) .action((projectPath, opts) => { @@ -105,6 +107,8 @@ export const create = new Command('create') }); async function createProject(cwd: ProjectPath, options: Options) { + console.log('From playground:', options.fromPlayground); + const { directory, template, language } = await p.group( { directory: () => { @@ -135,6 +139,9 @@ async function createProject(cwd: ProjectPath, options: Options) { }, template: () => { if (options.template) return Promise.resolve(options.template); + // always use the minimal template for playground projects + if (options.fromPlayground) return Promise.resolve('minimal' as TemplateType); + return p.select({ message: 'Which template would you like?', initialValue: 'minimal', diff --git a/packages/create/playground.ts b/packages/create/playground.ts new file mode 100644 index 00000000..983240a4 --- /dev/null +++ b/packages/create/playground.ts @@ -0,0 +1,78 @@ +export function validatePlaygroundUrl(link: string): boolean { + try { + const url = new URL(link); + if (url.hostname !== 'svelte.dev' || !url.pathname.startsWith('/playground/')) { + return false; + } + + const { playgroundId, hash } = extractPartsFromPlaygroundUrl(link); + return playgroundId !== undefined || hash !== undefined; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (_) { + // new Url() will throw if the URL is invalid + return false; + } +} + +export function extractPartsFromPlaygroundUrl(link: string): { + playgroundId: string | undefined; + hash: string | undefined; +} { + const url = new URL(link); + const [, playgroundId] = url.pathname.match(/\/playground\/([^/]+)/) || []; + const hash = url.hash !== '' ? url.hash.slice(1) : undefined; + + return { playgroundId, hash }; +} + +type PlaygroundData = { + name: string; + files: Array<{ + name: string; + content: string; + }>; +}; +export async function downloadFilesFromPlayground({ + playgroundId, + hash +}: { + playgroundId?: string; + hash?: string; +}): Promise { + let data = []; + // forked playgrounds have a playground_id and an optional hash. + // usually the hash is more up to date so take the hash if present. + if (hash) { + data = JSON.parse(await decode_and_decompress_text(hash)); + } else { + const response = await fetch(`https://svelte.dev/playground/api/${playgroundId}.json`); + data = await response.json(); + } + + // saved playgrounds and playground hashes have a different structure + // therefore we need to handle both cases. + const files = data.components !== undefined ? data.components : data.files; + return { + name: data.name, + files: files.map((file: { name: string; contents: string; source: string }) => { + return { + name: file.name, + content: file.source || file.contents + }; + }) + }; +} + +// Taken from https://github.com/sveltejs/svelte.dev/blob/ba7ad256f786aa5bc67eac3a58608f3f50b59e91/apps/svelte.dev/src/routes/(authed)/playground/%5Bid%5D/gzip.js#L19-L29 +/** @param {string} input */ +async function decode_and_decompress_text(input: string) { + const decoded = atob(input.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')); + return new Response(stream).text(); +} diff --git a/packages/create/test/playground.ts b/packages/create/test/playground.ts new file mode 100644 index 00000000..ba1faa71 --- /dev/null +++ b/packages/create/test/playground.ts @@ -0,0 +1,62 @@ +import { expect, test } from 'vitest'; +import { + downloadFilesFromPlayground, + extractPartsFromPlaygroundUrl, + validatePlaygroundUrl +} from '../playground.ts'; + +test.for([ + { input: 'https://svelte.dev/playground/628f435d787a465f9c1f1854134d6f70/', valid: true }, + { input: 'https://svelte.dev/playground/hello-world', valid: true }, + { + input: + 'https://svelte.dev/playground/a7aa9fd8daf445dcabd31b6aa6b1946f#H4sIAAAAAAAACm2Oz06EMBDGX2WcmCxEInKtQOLNdxAPhc5mm63Thg67moZ3NwU3e_H6_b5_CVl_ESp8J-c8XP3sDBRkrJApscKjdRRRfSSUn5B9WcDqlnoL4TleyEnWRh3pP33yLMQSUWEbp9kG6QcexJFAtkMHj1G0UHHY5g_l6w1PfmG585dM2vrewe2p6ffnKVetOpqHtj41O7QcFoHRslEX7RbqdhPU_cDtuIh4Bs-Ts9O5S0UJXf-3-NRBs24nNxgVpA2seX4P9gNjhULfgkrmhdbPCkVbd7VsUB21i7T-Akpv1IhdAQAA', + valid: true + }, + { input: 'test', valid: false }, + { input: 'google.com', valid: false }, + { input: 'https://google.com', valid: false }, + { input: 'https://google.com/playground/123', valid: false }, + { input: 'https://svelte.dev/docs/cli', valid: false } +])('validate playground url $input', (data) => { + const isValid = validatePlaygroundUrl(data.input); + + expect(isValid).toBe(data.valid); +}); + +test.for([ + { + url: 'https://svelte.dev/playground/628f435d787a465f9c1f1854134d6f70/', + expected: { playgroundId: '628f435d787a465f9c1f1854134d6f70', hash: undefined } + }, + { + url: 'https://svelte.dev/playground/hello-world', + expected: { playgroundId: 'hello-world', hash: undefined } + }, + { + url: 'https://svelte.dev/playground/a7aa9fd8daf445dcabd31b6aa6b1946f#H4sIAAAAAAAACm2Oz06EMBDGX2WcmCxEInKtQOLNdxAPhc5mm63Thg67moZ3NwU3e_H6_b5_CVl_ESp8J-c8XP3sDBRkrJApscKjdRRRfSSUn5B9WcDqlnoL4TleyEnWRh3pP33yLMQSUWEbp9kG6QcexJFAtkMHj1G0UHHY5g_l6w1PfmG585dM2vrewe2p6ffnKVetOpqHtj41O7QcFoHRslEX7RbqdhPU_cDtuIh4Bs-Ts9O5S0UJXf-3-NRBs24nNxgVpA2seX4P9gNjhULfgkrmhdbPCkVbd7VsUB21i7T-Akpv1IhdAQAA', + expected: { + playgroundId: 'a7aa9fd8daf445dcabd31b6aa6b1946f', + hash: 'H4sIAAAAAAAACm2Oz06EMBDGX2WcmCxEInKtQOLNdxAPhc5mm63Thg67moZ3NwU3e_H6_b5_CVl_ESp8J-c8XP3sDBRkrJApscKjdRRRfSSUn5B9WcDqlnoL4TleyEnWRh3pP33yLMQSUWEbp9kG6QcexJFAtkMHj1G0UHHY5g_l6w1PfmG585dM2vrewe2p6ffnKVetOpqHtj41O7QcFoHRslEX7RbqdhPU_cDtuIh4Bs-Ts9O5S0UJXf-3-NRBs24nNxgVpA2seX4P9gNjhULfgkrmhdbPCkVbd7VsUB21i7T-Akpv1IhdAQAA' + } + } +])('extract parts from playground url $url', (data) => { + const { playgroundId, hash } = extractPartsFromPlaygroundUrl(data.url); + + expect(playgroundId).toBe(data.expected.playgroundId); + expect(hash).toBe(data.expected.hash); +}); + +test('download playground test', async () => { + const t1 = await downloadFilesFromPlayground({ + playgroundId: 'hello-world', + hash: undefined + }); + const t2 = await downloadFilesFromPlayground({ + playgroundId: undefined, + hash: 'H4sIAAAAAAAACm2Oz06EMBDGX2WcmCxEInKtQOLNdxAPhc5mm63Thg67moZ3NwU3e_H6_b5_CVl_ESp8J-c8XP3sDBRkrJApscKjdRRRfSSUn5B9WcDqlnoL4TleyEnWRh3pP33yLMQSUWEbp9kG6QcexJFAtkMHj1G0UHHY5g_l6w1PfmG585dM2vrewe2p6ffnKVetOpqHtj41O7QcFoHRslEX7RbqdhPU_cDtuIh4Bs-Ts9O5S0UJXf-3-NRBs24nNxgVpA2seX4P9gNjhULfgkrmhdbPCkVbd7VsUB21i7T-Akpv1IhdAQAA' + }); + console.log(t1); + console.log(t2); + expect(true).toBe(true); // Just a placeholder to ensure the test runs without errors +}); From edb13eaaa6e25f0e4d7856e9aebb80ad45416d9d Mon Sep 17 00:00:00 2001 From: Manuel Serret Date: Fri, 15 Aug 2025 16:15:33 +0200 Subject: [PATCH 02/16] properly download files --- packages/create/playground.ts | 4 ++-- packages/create/test/playground.ts | 27 +++++++++++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/create/playground.ts b/packages/create/playground.ts index 983240a4..eda16f09 100644 --- a/packages/create/playground.ts +++ b/packages/create/playground.ts @@ -55,9 +55,9 @@ export async function downloadFilesFromPlayground({ const files = data.components !== undefined ? data.components : data.files; return { name: data.name, - files: files.map((file: { name: string; contents: string; source: string }) => { + files: files.map((file: { name: string; type: string; contents: string; source: string }) => { return { - name: file.name, + name: file.name + (file.type !== 'file' ? `.${file.type}` : ''), content: file.source || file.contents }; }) diff --git a/packages/create/test/playground.ts b/packages/create/test/playground.ts index ba1faa71..3bec84c3 100644 --- a/packages/create/test/playground.ts +++ b/packages/create/test/playground.ts @@ -47,16 +47,27 @@ test.for([ expect(hash).toBe(data.expected.hash); }); -test('download playground test', async () => { - const t1 = await downloadFilesFromPlayground({ +test.for([ + { + testName: 'playground id', playgroundId: 'hello-world', hash: undefined - }); - const t2 = await downloadFilesFromPlayground({ + }, + { + testName: 'hash', playgroundId: undefined, - hash: 'H4sIAAAAAAAACm2Oz06EMBDGX2WcmCxEInKtQOLNdxAPhc5mm63Thg67moZ3NwU3e_H6_b5_CVl_ESp8J-c8XP3sDBRkrJApscKjdRRRfSSUn5B9WcDqlnoL4TleyEnWRh3pP33yLMQSUWEbp9kG6QcexJFAtkMHj1G0UHHY5g_l6w1PfmG585dM2vrewe2p6ffnKVetOpqHtj41O7QcFoHRslEX7RbqdhPU_cDtuIh4Bs-Ts9O5S0UJXf-3-NRBs24nNxgVpA2seX4P9gNjhULfgkrmhdbPCkVbd7VsUB21i7T-Akpv1IhdAQAA' + hash: 'H4sIAAAAAAAACm2OTU7DMBCFr2JGSG1FRMjW2JbYcQfCwnGmqlUztuJxC4pyd-SEqhu273t_M5D9QpBwjiFEcY1TGKGBow-YQX7MwD-p4ipAczO_pfScLxi4aoPN-J_uIjESZ5Cgspt8YtNTzwFZVLvQ4jGzZdzv1tXd4fWGXSzEd_5SiWrvHaROndkOz7VqeVDtqduIp1RYDJ5GebGhoN4cojU9qaEwRxKRXPDurOf9QWjzN_ekRbesD1eYpZhXsNTtLWh6ggYYvxkkTwWXzwbY-nD1NII82pBx-QXBqXEFUQEAAA==' + } +])('download playground from $testName', async (data) => { + const playground = await downloadFilesFromPlayground({ + playgroundId: data.playgroundId, + hash: data.hash }); - console.log(t1); - console.log(t2); - expect(true).toBe(true); // Just a placeholder to ensure the test runs without errors + + expect(playground.name).toBe('Hello world'); + expect(playground.files).toHaveLength(1); + + const file1 = playground.files[0]; + expect(file1.name).toBe('App.svelte'); + expect(file1.content).toContain('

Hello {name}!

'); }); From 55b9a850c6c8e15518ec003175996150c4b6a8a1 Mon Sep 17 00:00:00 2001 From: Manuel Serret Date: Fri, 15 Aug 2025 16:17:10 +0200 Subject: [PATCH 03/16] shit --- packages/create/test/playground.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create/test/playground.ts b/packages/create/test/playground.ts index 3bec84c3..6bdbcae8 100644 --- a/packages/create/test/playground.ts +++ b/packages/create/test/playground.ts @@ -56,9 +56,9 @@ test.for([ { testName: 'hash', playgroundId: undefined, - hash: 'H4sIAAAAAAAACm2OTU7DMBCFr2JGSG1FRMjW2JbYcQfCwnGmqlUztuJxC4pyd-SEqhu273t_M5D9QpBwjiFEcY1TGKGBow-YQX7MwD-p4ipAczO_pfScLxi4aoPN-J_uIjESZ5Cgspt8YtNTzwFZVLvQ4jGzZdzv1tXd4fWGXSzEd_5SiWrvHaROndkOz7VqeVDtqduIp1RYDJ5GebGhoN4cojU9qaEwRxKRXPDurOf9QWjzN_ekRbesD1eYpZhXsNTtLWh6ggYYvxkkTwWXzwbY-nD1NII82pBx-QXBqXEFUQEAAA==' + hash: 'H4sIAAAAAAAACm2OTU7DMBCFr2JGSG1FRMjW2JbYcQfCwnGmqlUztuJxC4pyd-SEqhu273t_M5D9QpDwjiFEcY1TGKGBow-YQX7MwD-p4ipAczO_pfScLxi4aoPN-J_uIjESZ5Cgspt8YtNTzwFZVLvQ4jGzZdzv1tXd4fWGXSzEd_5SiWrvHaROndkOz7VqeVDtqduIp1RYDJ5GebGhoN4cojU9qaEwRxKRXPDurOf9QWjzN_ekRbesD1eYpZhXsNTtLWh6ggYYvxkkTwWXzwbY-nD1NII82pBx-QXBqXEFUQEAAA==' } -])('download playground from $testName', async (data) => { +])('download hello world playground from $testName', async (data) => { const playground = await downloadFilesFromPlayground({ playgroundId: data.playgroundId, hash: data.hash From fdb4e0c710abcc2fe25fa48106f164f2c7490c5d Mon Sep 17 00:00:00 2001 From: Manuel Serret Date: Fri, 15 Aug 2025 17:45:49 +0200 Subject: [PATCH 04/16] add setup draft --- packages/create/package.json | 1 + packages/create/playground.ts | 27 +++++++++++++++++++++++++++ packages/create/test/playground.ts | 28 ++++++++++++++++++++++++++++ pnpm-lock.yaml | 3 +++ 4 files changed, 59 insertions(+) diff --git a/packages/create/package.json b/packages/create/package.json index 0cc1a2cd..4b7f7a38 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -32,6 +32,7 @@ } }, "devDependencies": { + "@sveltejs/cli-core": "workspace:*", "@types/gitignore-parser": "^0.0.3", "gitignore-parser": "^0.0.2", "sucrase": "^3.35.0", diff --git a/packages/create/playground.ts b/packages/create/playground.ts index eda16f09..a61512c1 100644 --- a/packages/create/playground.ts +++ b/packages/create/playground.ts @@ -1,3 +1,8 @@ +import path from 'node:path'; +import * as fs from 'node:fs'; +import { parseSvelte } from '@sveltejs/cli-core/parsers'; +import * as js from '@sveltejs/cli-core/js'; + export function validatePlaygroundUrl(link: string): boolean { try { const url = new URL(link); @@ -76,3 +81,25 @@ async function decode_and_decompress_text(input: string) { const stream = new Blob([u8]).stream().pipeThrough(new DecompressionStream('gzip')); return new Response(stream).text(); } + +export function setupPlayogroundProject(playground: PlaygroundData, cwd: string): void { + for (const file of playground.files) { + const filePath = path.join(cwd, 'src', 'routes', file.name); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, file.content, 'utf8'); + } + + const filePath = path.join(cwd, 'src/routes/+page.svelte'); + const content = fs.readFileSync(filePath, 'utf-8'); + const { script, template, generateCode } = parseSvelte(content); + js.imports.addDefault(script.ast, { + from: './App.svelte', + as: 'App' + }); + template.source = template.source + `\n`; + const newContent = generateCode({ + script: script.generateCode(), + template: template.source + }); + fs.writeFileSync(filePath, newContent, 'utf-8'); +} diff --git a/packages/create/test/playground.ts b/packages/create/test/playground.ts index 6bdbcae8..4072712f 100644 --- a/packages/create/test/playground.ts +++ b/packages/create/test/playground.ts @@ -2,8 +2,16 @@ import { expect, test } from 'vitest'; import { downloadFilesFromPlayground, extractPartsFromPlaygroundUrl, + setupPlayogroundProject, validatePlaygroundUrl } from '../playground.ts'; +import { fileURLToPath } from 'node:url'; +import { create } from '../index.ts'; +import path from 'node:path'; +import * as fs from 'node:fs'; + +const resolvePath = (path: string) => fileURLToPath(new URL(path, import.meta.url)); +const testWorkspaceDir = resolvePath('../../../.test-output/create/'); test.for([ { input: 'https://svelte.dev/playground/628f435d787a465f9c1f1854134d6f70/', valid: true }, @@ -71,3 +79,23 @@ test.for([ expect(file1.name).toBe('App.svelte'); expect(file1.content).toContain('

Hello {name}!

'); }); + +test('real world download and convert playground', async () => { + const directory = path.join(testWorkspaceDir, 'real-world-playground'); + if (fs.existsSync(directory)) { + fs.rmdirSync(directory, { recursive: true }); + } + + create(directory, { + name: 'real-world-playground', + template: 'minimal', + types: 'typescript' + }); + + const playground = await downloadFilesFromPlayground({ + playgroundId: 'hello-world', + hash: undefined + }); + + setupPlayogroundProject(playground, directory); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 959d7df3..9ce3a6a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -193,6 +193,9 @@ importers: packages/create: devDependencies: + '@sveltejs/cli-core': + specifier: workspace:* + version: link:../core '@types/gitignore-parser': specifier: ^0.0.3 version: 0.0.3 From ecdf921f10510f2c047f9af229e93aa9ca2fab3c Mon Sep 17 00:00:00 2001 From: Manuel Serret Date: Fri, 15 Aug 2025 18:42:27 +0200 Subject: [PATCH 05/16] add dependency handling --- packages/create/playground.ts | 38 ++++++++++++++++++++++++++++-- packages/create/test/playground.ts | 10 +++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/create/playground.ts b/packages/create/playground.ts index a61512c1..1484543e 100644 --- a/packages/create/playground.ts +++ b/packages/create/playground.ts @@ -1,6 +1,6 @@ import path from 'node:path'; import * as fs from 'node:fs'; -import { parseSvelte } from '@sveltejs/cli-core/parsers'; +import { parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; import * as js from '@sveltejs/cli-core/js'; export function validatePlaygroundUrl(link: string): boolean { @@ -83,17 +83,40 @@ async function decode_and_decompress_text(input: string) { } export function setupPlayogroundProject(playground: PlaygroundData, cwd: string): void { + const mainFile = + playground.files.find((file) => file.name === 'App.svelte') || + playground.files.find((file) => file.name.endsWith('.svelte')) || + playground.files[0]; + + const packages: string[] = []; for (const file of playground.files) { + // detect npm packages from imports + let ast: js.AstTypes.Program | undefined; + if (file.name.endsWith('.svelte')) { + ast = parseSvelte(file.content).script.ast; + } else if (file.name.endsWith('.js') || file.name.endsWith('.ts')) { + ast = parseScript(file.content).ast; + } + if (!ast) continue; + const imports = ast.body + .filter((node): node is js.AstTypes.ImportDeclaration => node.type === 'ImportDeclaration') + .map((node) => node.source.value as string) + .filter((importPath) => !importPath.startsWith('./')); + + packages.push(...imports); + + // write file to disk const filePath = path.join(cwd, 'src', 'routes', file.name); fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, file.content, 'utf8'); } + // add app import to +page.svelte const filePath = path.join(cwd, 'src/routes/+page.svelte'); const content = fs.readFileSync(filePath, 'utf-8'); const { script, template, generateCode } = parseSvelte(content); js.imports.addDefault(script.ast, { - from: './App.svelte', + from: `./${mainFile.name}`, as: 'App' }); template.source = template.source + `\n`; @@ -102,4 +125,15 @@ export function setupPlayogroundProject(playground: PlaygroundData, cwd: string) template: template.source }); fs.writeFileSync(filePath, newContent, 'utf-8'); + + // add packages as dependencies to package.json + const packageJsonPath = path.join(cwd, 'package.json'); + const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8'); + const { data: packageJson, generateCode: generateCodeJson } = parseJson(packageJsonContent); + packageJson.dependencies ??= {}; + for (const pkg of packages) { + packageJson.dependencies[pkg] = 'latest'; + } + const newPackageJson = generateCodeJson(); + fs.writeFileSync(packageJsonPath, newPackageJson, 'utf-8'); } diff --git a/packages/create/test/playground.ts b/packages/create/test/playground.ts index 4072712f..5b3d0918 100644 --- a/packages/create/test/playground.ts +++ b/packages/create/test/playground.ts @@ -93,9 +93,17 @@ test('real world download and convert playground', async () => { }); const playground = await downloadFilesFromPlayground({ - playgroundId: 'hello-world', + playgroundId: '770bbef086034b9f8e337bab57efe8d8', hash: undefined }); setupPlayogroundProject(playground, directory); + + const pageFilePath = path.join(directory, 'src/routes/+page.svelte'); + const pageContent = fs.readFileSync(pageFilePath, 'utf-8'); + expect(pageContent).toContain(''); + + const packageJsonPath = path.join(directory, 'package.json'); + const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8'); + expect(packageJsonContent).toContain('"change-case": "latest"'); }); From ae75f95953beeb83e14b48bab07846b9b6950696 Mon Sep 17 00:00:00 2001 From: Manuel Serret Date: Sat, 16 Aug 2025 07:38:34 +0200 Subject: [PATCH 06/16] make it work --- .changeset/brown-comics-take.md | 5 +++++ .changeset/easy-cats-jam.md | 5 +++++ packages/cli/commands/create.ts | 15 +++++++++------ packages/cli/lib/testing.ts | 4 ++-- packages/create/index.ts | 9 ++++++++- packages/create/playground.ts | 16 ++++++++++++++-- .../scripts/update-template-repo-contents.js | 2 +- packages/create/test/check.ts | 2 +- packages/create/test/playground.ts | 6 +++--- 9 files changed, 48 insertions(+), 16 deletions(-) create mode 100644 .changeset/brown-comics-take.md create mode 100644 .changeset/easy-cats-jam.md diff --git a/.changeset/brown-comics-take.md b/.changeset/brown-comics-take.md new file mode 100644 index 00000000..c9851d88 --- /dev/null +++ b/.changeset/brown-comics-take.md @@ -0,0 +1,5 @@ +--- +'sv': patch +--- + +feat(create): allow creating reproduction from playground with `npx sv create --from-playground https://svelte.dev/playground/hello-world` diff --git a/.changeset/easy-cats-jam.md b/.changeset/easy-cats-jam.md new file mode 100644 index 00000000..c5ebedce --- /dev/null +++ b/.changeset/easy-cats-jam.md @@ -0,0 +1,5 @@ +--- +'sv': minor +--- + +chore(create): the `create` api is now asynchronous and must be awaited diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 43c280f0..428ed86e 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -9,7 +9,8 @@ import { create as createKit, templates, type LanguageType, - type TemplateType + type TemplateType, + validatePlaygroundUrl } from '@sveltejs/create'; import * as common from '../utils/common.ts'; import { runAddCommand } from './add/index.ts'; @@ -44,7 +45,10 @@ const OptionsSchema = v.strictObject({ addOns: v.boolean(), install: v.union([v.boolean(), v.picklist(AGENT_NAMES)]), template: v.optional(v.picklist(templateChoices)), - fromPlayground: v.optional(v.string()) + fromPlayground: v.pipe( + v.optional(v.string()), + v.check(validatePlaygroundUrl, 'Invalid playground URL') + ) }); type Options = v.InferOutput; type ProjectPath = v.InferOutput; @@ -107,8 +111,6 @@ export const create = new Command('create') }); async function createProject(cwd: ProjectPath, options: Options) { - console.log('From playground:', options.fromPlayground); - const { directory, template, language } = await p.group( { directory: () => { @@ -170,10 +172,11 @@ async function createProject(cwd: ProjectPath, options: Options) { ); const projectPath = path.resolve(directory); - createKit(projectPath, { + await createKit(projectPath, { name: path.basename(projectPath), template, - types: language + types: language, + playgroundUrl: options.fromPlayground }); p.log.success('Project created'); diff --git a/packages/cli/lib/testing.ts b/packages/cli/lib/testing.ts index 98f5cdae..c74001cf 100644 --- a/packages/cli/lib/testing.ts +++ b/packages/cli/lib/testing.ts @@ -42,9 +42,9 @@ export async function setup({ if (fs.existsSync(templatePath)) continue; if (variant === 'kit-js') { - create(templatePath, { name: variant, template: 'minimal', types: 'checkjs' }); + await create(templatePath, { name: variant, template: 'minimal', types: 'checkjs' }); } else if (variant === 'kit-ts') { - create(templatePath, { name: variant, template: 'minimal', types: 'typescript' }); + await create(templatePath, { name: variant, template: 'minimal', types: 'typescript' }); } else if (variant === 'vite-js' || variant === 'vite-ts') { const name = `template-svelte${variant === 'vite-ts' ? '-ts' : ''}`; // TODO: should probably point this to a specific commit hash (ex: `#1234abcd`) diff --git a/packages/create/index.ts b/packages/create/index.ts index 7e92162f..44453360 100644 --- a/packages/create/index.ts +++ b/packages/create/index.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; import { mkdirp, copy, dist } from './utils.ts'; +import { write_playground_files } from './playground.ts'; +export { validatePlaygroundUrl } from './playground.ts'; export type TemplateType = (typeof templateTypes)[number]; export type LanguageType = (typeof languageTypes)[number]; @@ -12,6 +14,7 @@ export type Options = { name: string; template: TemplateType; types: LanguageType; + playgroundUrl?: string; }; export type File = { @@ -30,11 +33,15 @@ export type Common = { }>; }; -export function create(cwd: string, options: Options): void { +export async function create(cwd: string, options: Options): Promise { mkdirp(cwd); write_template_files(options.template, options.types, options.name, cwd); write_common_files(cwd, options, options.name); + + if (options.playgroundUrl) { + await write_playground_files(options.playgroundUrl, cwd); + } } export type TemplateMetadata = { name: TemplateType; title: string; description: string }; diff --git a/packages/create/playground.ts b/packages/create/playground.ts index 1484543e..46503166 100644 --- a/packages/create/playground.ts +++ b/packages/create/playground.ts @@ -3,7 +3,18 @@ import * as fs from 'node:fs'; import { parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; import * as js from '@sveltejs/cli-core/js'; -export function validatePlaygroundUrl(link: string): boolean { +export async function write_playground_files(url: string, cwd: string): Promise { + if (!validatePlaygroundUrl(url)) throw new Error(`Invalid playground URL: ${url}`); + + const urlData = extractPartsFromPlaygroundUrl(url); + const playground = await downloadFilesFromPlayground(urlData); + setupPlaygroundProject(playground, cwd); +} + +export function validatePlaygroundUrl(link?: string): boolean { + // If no link is provided, consider it valid + if (!link) return true; + try { const url = new URL(link); if (url.hostname !== 'svelte.dev' || !url.pathname.startsWith('/playground/')) { @@ -38,6 +49,7 @@ type PlaygroundData = { content: string; }>; }; + export async function downloadFilesFromPlayground({ playgroundId, hash @@ -82,7 +94,7 @@ async function decode_and_decompress_text(input: string) { return new Response(stream).text(); } -export function setupPlayogroundProject(playground: PlaygroundData, cwd: string): void { +export function setupPlaygroundProject(playground: PlaygroundData, cwd: string): void { const mainFile = playground.files.find((file) => file.name === 'App.svelte') || playground.files.find((file) => file.name.endsWith('.svelte')) || diff --git a/packages/create/scripts/update-template-repo-contents.js b/packages/create/scripts/update-template-repo-contents.js index 170de38e..add74590 100644 --- a/packages/create/scripts/update-template-repo-contents.js +++ b/packages/create/scripts/update-template-repo-contents.js @@ -13,7 +13,7 @@ fs.readdirSync(repo).forEach((file) => { } }); -create(repo, { +await create(repo, { name: 'kit-template-default', template: 'demo', types: 'checkjs' diff --git a/packages/create/test/check.ts b/packages/create/test/check.ts index f2e459c5..921136dc 100644 --- a/packages/create/test/check.ts +++ b/packages/create/test/check.ts @@ -41,7 +41,7 @@ for (const template of templates) { const cwd = path.join(test_workspace_dir, `${template}-${types}`); fs.rmSync(cwd, { recursive: true, force: true }); - create(cwd, { name: `create-svelte-test-${template}-${types}`, template, types }); + await create(cwd, { name: `create-svelte-test-${template}-${types}`, template, types }); const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8')); diff --git a/packages/create/test/playground.ts b/packages/create/test/playground.ts index 5b3d0918..bea3858f 100644 --- a/packages/create/test/playground.ts +++ b/packages/create/test/playground.ts @@ -2,7 +2,7 @@ import { expect, test } from 'vitest'; import { downloadFilesFromPlayground, extractPartsFromPlaygroundUrl, - setupPlayogroundProject, + setupPlaygroundProject, validatePlaygroundUrl } from '../playground.ts'; import { fileURLToPath } from 'node:url'; @@ -86,7 +86,7 @@ test('real world download and convert playground', async () => { fs.rmdirSync(directory, { recursive: true }); } - create(directory, { + await create(directory, { name: 'real-world-playground', template: 'minimal', types: 'typescript' @@ -97,7 +97,7 @@ test('real world download and convert playground', async () => { hash: undefined }); - setupPlayogroundProject(playground, directory); + setupPlaygroundProject(playground, directory); const pageFilePath = path.join(directory, 'src/routes/+page.svelte'); const pageContent = fs.readFileSync(pageFilePath, 'utf-8'); From 188c9e59adefb7a5797998f2fd4e0941ce8b5d7d Mon Sep 17 00:00:00 2001 From: Manuel Serret Date: Sat, 16 Aug 2025 07:50:20 +0200 Subject: [PATCH 07/16] fix changesets --- .changeset/brown-comics-take.md | 2 +- .changeset/fancy-towns-argue.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/fancy-towns-argue.md diff --git a/.changeset/brown-comics-take.md b/.changeset/brown-comics-take.md index c9851d88..ec70d824 100644 --- a/.changeset/brown-comics-take.md +++ b/.changeset/brown-comics-take.md @@ -2,4 +2,4 @@ 'sv': patch --- -feat(create): allow creating reproduction from playground with `npx sv create --from-playground https://svelte.dev/playground/hello-world` +feat(cli): allow creating reproduction from playground with `npx sv create --from-playground https://svelte.dev/playground/hello-world` diff --git a/.changeset/fancy-towns-argue.md b/.changeset/fancy-towns-argue.md deleted file mode 100644 index 2242e68b..00000000 --- a/.changeset/fancy-towns-argue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'sv': patch ---- - -feat(cli): `sv create --from-playground` From 4053acd5a2a3d7559b3af3935ad5012e657c3128 Mon Sep 17 00:00:00 2001 From: Manuel Serret Date: Sat, 23 Aug 2025 16:07:32 +0200 Subject: [PATCH 08/16] fix error handling --- packages/cli/commands/create.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 428ed86e..defcd473 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -45,10 +45,7 @@ const OptionsSchema = v.strictObject({ addOns: v.boolean(), install: v.union([v.boolean(), v.picklist(AGENT_NAMES)]), template: v.optional(v.picklist(templateChoices)), - fromPlayground: v.pipe( - v.optional(v.string()), - v.check(validatePlaygroundUrl, 'Invalid playground URL') - ) + fromPlayground: v.optional(v.string()) }); type Options = v.InferOutput; type ProjectPath = v.InferOutput; @@ -65,6 +62,11 @@ export const create = new Command('create') .addOption(installOption) .configureHelp(common.helpConfig) .action((projectPath, opts) => { + if (opts.fromPlayground && !validatePlaygroundUrl(opts.fromPlayground)) { + console.error(pc.red(`Error: Invalid playground URL: ${opts.fromPlayground}`)); + process.exit(1); + } + const cwd = v.parse(ProjectPathSchema, projectPath); const options = v.parse(OptionsSchema, opts); common.runCommand(async () => { From 033939f36f2abb29466d85829e2d2ede4925d30d Mon Sep 17 00:00:00 2001 From: Manuel Serret Date: Sat, 23 Aug 2025 16:07:54 +0200 Subject: [PATCH 09/16] fix +page.svelte content --- packages/create/playground.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create/playground.ts b/packages/create/playground.ts index 46503166..a36ce129 100644 --- a/packages/create/playground.ts +++ b/packages/create/playground.ts @@ -131,7 +131,7 @@ export function setupPlaygroundProject(playground: PlaygroundData, cwd: string): from: `./${mainFile.name}`, as: 'App' }); - template.source = template.source + `\n`; + template.source = ``; const newContent = generateCode({ script: script.generateCode(), template: template.source From 50338b74664ce343e9097f00d3dc2e0fe7d8c5f9 Mon Sep 17 00:00:00 2001 From: Manuel Serret Date: Sat, 23 Aug 2025 18:56:39 +0200 Subject: [PATCH 10/16] make it more usable --- .changeset/easy-cats-jam.md | 5 -- packages/cli/commands/create.ts | 64 +++++++++++++-- packages/cli/lib/testing.ts | 4 +- packages/create/index.ts | 9 +-- packages/create/package.json | 4 + packages/create/playground.ts | 79 +++++++++++-------- .../scripts/update-template-repo-contents.js | 2 +- packages/create/test/check.ts | 2 +- packages/create/test/playground.ts | 63 +++++++++++++-- rolldown.config.js | 2 + 10 files changed, 173 insertions(+), 61 deletions(-) delete mode 100644 .changeset/easy-cats-jam.md diff --git a/.changeset/easy-cats-jam.md b/.changeset/easy-cats-jam.md deleted file mode 100644 index c5ebedce..00000000 --- a/.changeset/easy-cats-jam.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'sv': minor ---- - -chore(create): the `create` api is now asynchronous and must be awaited diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index defcd473..7c6ee1fa 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -9,9 +9,15 @@ import { create as createKit, templates, type LanguageType, - type TemplateType, - validatePlaygroundUrl + type TemplateType } from '@sveltejs/create'; +import { + downloadPlaygroundData, + parsePlaygroundUrl, + setupPlaygroundProject, + validatePlaygroundUrl, + detectPlaygroundDependencies +} from '@sveltejs/create/playground'; import * as common from '../utils/common.ts'; import { runAddCommand } from './add/index.ts'; import { detect, resolveCommand, type AgentName } from 'package-manager-detector'; @@ -113,6 +119,12 @@ export const create = new Command('create') }); async function createProject(cwd: ProjectPath, options: Options) { + if (options.fromPlayground) { + p.log.warn( + 'The Svelte maintainers have not reviewed playgrounds for malicious code. Use at your discretion.' + ); + } + const { directory, template, language } = await p.group( { directory: () => { @@ -174,13 +186,16 @@ async function createProject(cwd: ProjectPath, options: Options) { ); const projectPath = path.resolve(directory); - await createKit(projectPath, { + createKit(projectPath, { name: path.basename(projectPath), template, - types: language, - playgroundUrl: options.fromPlayground + types: language }); + if (options.fromPlayground) { + await createProjectFromPlayground(options.fromPlayground, projectPath); + } + p.log.success('Project created'); let packageManager: AgentName | undefined | null; @@ -219,3 +234,42 @@ async function createProject(cwd: ProjectPath, options: Options) { return { directory: projectPath, addOnNextSteps, packageManager }; } + +async function createProjectFromPlayground(url: string, cwd: string): Promise { + if (!validatePlaygroundUrl(url)) throw new Error(`Invalid playground URL: ${url}`); + + const urlData = parsePlaygroundUrl(url); + const playground = await downloadPlaygroundData(urlData); + + // Detect external dependencies and ask for confirmation + const dependencies = detectPlaygroundDependencies(playground.files); + const installDependencies = await confirmExternalDependencies(dependencies); + + setupPlaygroundProject(playground, cwd, installDependencies); +} + +async function confirmExternalDependencies(dependencies: string[]): Promise { + if (dependencies.length === 0) return false; + + const dependencyList = dependencies.map((dep) => `- ${dep}`).join('\n'); + + p.note( + `The following packages were found:\n\n${dependencyList}\n\nThese packages are not reviewed by the Svelte team.`, + 'External Dependencies', + { + format: (line) => line // keep original coloring + } + ); + + const confirmDeps = await p.confirm({ + message: 'Do you want to install these external dependencies?', + initialValue: false + }); + + if (p.isCancel(confirmDeps)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + return confirmDeps; +} diff --git a/packages/cli/lib/testing.ts b/packages/cli/lib/testing.ts index c74001cf..98f5cdae 100644 --- a/packages/cli/lib/testing.ts +++ b/packages/cli/lib/testing.ts @@ -42,9 +42,9 @@ export async function setup({ if (fs.existsSync(templatePath)) continue; if (variant === 'kit-js') { - await create(templatePath, { name: variant, template: 'minimal', types: 'checkjs' }); + create(templatePath, { name: variant, template: 'minimal', types: 'checkjs' }); } else if (variant === 'kit-ts') { - await create(templatePath, { name: variant, template: 'minimal', types: 'typescript' }); + create(templatePath, { name: variant, template: 'minimal', types: 'typescript' }); } else if (variant === 'vite-js' || variant === 'vite-ts') { const name = `template-svelte${variant === 'vite-ts' ? '-ts' : ''}`; // TODO: should probably point this to a specific commit hash (ex: `#1234abcd`) diff --git a/packages/create/index.ts b/packages/create/index.ts index 44453360..7e92162f 100644 --- a/packages/create/index.ts +++ b/packages/create/index.ts @@ -1,8 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { mkdirp, copy, dist } from './utils.ts'; -import { write_playground_files } from './playground.ts'; -export { validatePlaygroundUrl } from './playground.ts'; export type TemplateType = (typeof templateTypes)[number]; export type LanguageType = (typeof languageTypes)[number]; @@ -14,7 +12,6 @@ export type Options = { name: string; template: TemplateType; types: LanguageType; - playgroundUrl?: string; }; export type File = { @@ -33,15 +30,11 @@ export type Common = { }>; }; -export async function create(cwd: string, options: Options): Promise { +export function create(cwd: string, options: Options): void { mkdirp(cwd); write_template_files(options.template, options.types, options.name, cwd); write_common_files(cwd, options, options.name); - - if (options.playgroundUrl) { - await write_playground_files(options.playgroundUrl, cwd); - } } export type TemplateMetadata = { name: TemplateType; title: string; description: string }; diff --git a/packages/create/package.json b/packages/create/package.json index 4b7f7a38..a8b7d69b 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -29,6 +29,10 @@ }, "./build": { "default": "./scripts/build-templates.js" + }, + "./playground": { + "types": "./dist/playground.d.ts", + "default": "./dist/playground.js" } }, "devDependencies": { diff --git a/packages/create/playground.ts b/packages/create/playground.ts index a36ce129..7ef4188b 100644 --- a/packages/create/playground.ts +++ b/packages/create/playground.ts @@ -3,14 +3,6 @@ import * as fs from 'node:fs'; import { parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers'; import * as js from '@sveltejs/cli-core/js'; -export async function write_playground_files(url: string, cwd: string): Promise { - if (!validatePlaygroundUrl(url)) throw new Error(`Invalid playground URL: ${url}`); - - const urlData = extractPartsFromPlaygroundUrl(url); - const playground = await downloadFilesFromPlayground(urlData); - setupPlaygroundProject(playground, cwd); -} - export function validatePlaygroundUrl(link?: string): boolean { // If no link is provided, consider it valid if (!link) return true; @@ -21,7 +13,7 @@ export function validatePlaygroundUrl(link?: string): boolean { return false; } - const { playgroundId, hash } = extractPartsFromPlaygroundUrl(link); + const { playgroundId, hash } = parsePlaygroundUrl(link); return playgroundId !== undefined || hash !== undefined; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -31,7 +23,7 @@ export function validatePlaygroundUrl(link?: string): boolean { } } -export function extractPartsFromPlaygroundUrl(link: string): { +export function parsePlaygroundUrl(link: string): { playgroundId: string | undefined; hash: string | undefined; } { @@ -50,7 +42,7 @@ type PlaygroundData = { }>; }; -export async function downloadFilesFromPlayground({ +export async function downloadPlaygroundData({ playgroundId, hash }: { @@ -61,7 +53,7 @@ export async function downloadFilesFromPlayground({ // forked playgrounds have a playground_id and an optional hash. // usually the hash is more up to date so take the hash if present. if (hash) { - data = JSON.parse(await decode_and_decompress_text(hash)); + data = JSON.parse(await decodeAndDecompressText(hash)); } else { const response = await fetch(`https://svelte.dev/playground/api/${playgroundId}.json`); data = await response.json(); @@ -83,7 +75,7 @@ export async function downloadFilesFromPlayground({ // Taken from https://github.com/sveltejs/svelte.dev/blob/ba7ad256f786aa5bc67eac3a58608f3f50b59e91/apps/svelte.dev/src/routes/(authed)/playground/%5Bid%5D/gzip.js#L19-L29 /** @param {string} input */ -async function decode_and_decompress_text(input: string) { +async function decodeAndDecompressText(input: string) { const decoded = atob(input.replaceAll('-', '+').replaceAll('_', '/')); // putting it directly into the blob gives a corrupted file const u8 = new Uint8Array(decoded.length); @@ -94,15 +86,18 @@ async function decode_and_decompress_text(input: string) { return new Response(stream).text(); } -export function setupPlaygroundProject(playground: PlaygroundData, cwd: string): void { - const mainFile = - playground.files.find((file) => file.name === 'App.svelte') || - playground.files.find((file) => file.name.endsWith('.svelte')) || - playground.files[0]; - +export function detectPlaygroundDependencies(files: PlaygroundData['files']): string[] { const packages: string[] = []; - for (const file of playground.files) { - // detect npm packages from imports + + // Prefixes for packages that should be excluded (built-in or framework packages) + const excludedPrefixes = [ + '$', // SvelteKit framework imports + 'node:', // Node.js built-in modules + 'svelte', // Svelte core packages + '@sveltejs/' // All SvelteKit packages + ]; + + for (const file of files) { let ast: js.AstTypes.Program | undefined; if (file.name.endsWith('.svelte')) { ast = parseSvelte(file.content).script.ast; @@ -110,13 +105,32 @@ export function setupPlaygroundProject(playground: PlaygroundData, cwd: string): ast = parseScript(file.content).ast; } if (!ast) continue; + const imports = ast.body .filter((node): node is js.AstTypes.ImportDeclaration => node.type === 'ImportDeclaration') .map((node) => node.source.value as string) - .filter((importPath) => !importPath.startsWith('./')); + .filter((importPath) => !importPath.startsWith('./') && !importPath.startsWith('/')); packages.push(...imports); + } + + // Remove duplicates and filter out excluded packages + return [...new Set(packages)].filter((pkg) => { + return !excludedPrefixes.some((prefix) => pkg.startsWith(prefix)); + }); +} +export function setupPlaygroundProject( + playground: PlaygroundData, + cwd: string, + installDependencies: boolean = false +): void { + const mainFile = + playground.files.find((file) => file.name === 'App.svelte') || + playground.files.find((file) => file.name.endsWith('.svelte')) || + playground.files[0]; + + for (const file of playground.files) { // write file to disk const filePath = path.join(cwd, 'src', 'routes', file.name); fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -138,14 +152,17 @@ export function setupPlaygroundProject(playground: PlaygroundData, cwd: string): }); fs.writeFileSync(filePath, newContent, 'utf-8'); - // add packages as dependencies to package.json - const packageJsonPath = path.join(cwd, 'package.json'); - const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8'); - const { data: packageJson, generateCode: generateCodeJson } = parseJson(packageJsonContent); - packageJson.dependencies ??= {}; - for (const pkg of packages) { - packageJson.dependencies[pkg] = 'latest'; + // add packages as dependencies to package.json if requested + const dependencies = detectPlaygroundDependencies(playground.files); + if (installDependencies && dependencies.length >= 0) { + const packageJsonPath = path.join(cwd, 'package.json'); + const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8'); + const { data: packageJson, generateCode: generateCodeJson } = parseJson(packageJsonContent); + packageJson.dependencies ??= {}; + for (const pkg of dependencies) { + packageJson.dependencies[pkg] = 'latest'; + } + const newPackageJson = generateCodeJson(); + fs.writeFileSync(packageJsonPath, newPackageJson, 'utf-8'); } - const newPackageJson = generateCodeJson(); - fs.writeFileSync(packageJsonPath, newPackageJson, 'utf-8'); } diff --git a/packages/create/scripts/update-template-repo-contents.js b/packages/create/scripts/update-template-repo-contents.js index add74590..170de38e 100644 --- a/packages/create/scripts/update-template-repo-contents.js +++ b/packages/create/scripts/update-template-repo-contents.js @@ -13,7 +13,7 @@ fs.readdirSync(repo).forEach((file) => { } }); -await create(repo, { +create(repo, { name: 'kit-template-default', template: 'demo', types: 'checkjs' diff --git a/packages/create/test/check.ts b/packages/create/test/check.ts index 921136dc..f2e459c5 100644 --- a/packages/create/test/check.ts +++ b/packages/create/test/check.ts @@ -41,7 +41,7 @@ for (const template of templates) { const cwd = path.join(test_workspace_dir, `${template}-${types}`); fs.rmSync(cwd, { recursive: true, force: true }); - await create(cwd, { name: `create-svelte-test-${template}-${types}`, template, types }); + create(cwd, { name: `create-svelte-test-${template}-${types}`, template, types }); const pkg = JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf-8')); diff --git a/packages/create/test/playground.ts b/packages/create/test/playground.ts index bea3858f..237f47a1 100644 --- a/packages/create/test/playground.ts +++ b/packages/create/test/playground.ts @@ -1,9 +1,10 @@ import { expect, test } from 'vitest'; import { - downloadFilesFromPlayground, - extractPartsFromPlaygroundUrl, + downloadPlaygroundData, + parsePlaygroundUrl, setupPlaygroundProject, - validatePlaygroundUrl + validatePlaygroundUrl, + detectPlaygroundDependencies } from '../playground.ts'; import { fileURLToPath } from 'node:url'; import { create } from '../index.ts'; @@ -49,7 +50,7 @@ test.for([ } } ])('extract parts from playground url $url', (data) => { - const { playgroundId, hash } = extractPartsFromPlaygroundUrl(data.url); + const { playgroundId, hash } = parsePlaygroundUrl(data.url); expect(playgroundId).toBe(data.expected.playgroundId); expect(hash).toBe(data.expected.hash); @@ -67,7 +68,7 @@ test.for([ hash: 'H4sIAAAAAAAACm2OTU7DMBCFr2JGSG1FRMjW2JbYcQfCwnGmqlUztuJxC4pyd-SEqhu273t_M5D9QpDwjiFEcY1TGKGBow-YQX7MwD-p4ipAczO_pfScLxi4aoPN-J_uIjESZ5Cgspt8YtNTzwFZVLvQ4jGzZdzv1tXd4fWGXSzEd_5SiWrvHaROndkOz7VqeVDtqduIp1RYDJ5GebGhoN4cojU9qaEwRxKRXPDurOf9QWjzN_ekRbesD1eYpZhXsNTtLWh6ggYYvxkkTwWXzwbY-nD1NII82pBx-QXBqXEFUQEAAA==' } ])('download hello world playground from $testName', async (data) => { - const playground = await downloadFilesFromPlayground({ + const playground = await downloadPlaygroundData({ playgroundId: data.playgroundId, hash: data.hash }); @@ -80,24 +81,70 @@ test.for([ expect(file1.content).toContain('

Hello {name}!

'); }); +test('detect dependencies from playground files', () => { + const files = [ + { + name: 'App.svelte', + content: `` + }, + { + name: 'utils.js', + content: ` + import lodash from 'lodash'; + import './local-file.js'; + import fs from 'node:fs'; + import { someUtil } from '$lib/utils'; + import kit from '@sveltejs/kit'; + ` + } + ]; + + const dependencies = detectPlaygroundDependencies(files); + + // Should include external npm packages + expect(dependencies).toContain('change-case'); + expect(dependencies).toContain('lodash'); + + // Should exclude relative imports + expect(dependencies).not.toContain('./Component.svelte'); + expect(dependencies).not.toContain('./local-file.js'); + + // Should exclude framework/built-in imports + expect(dependencies).not.toContain('svelte/store'); + expect(dependencies).not.toContain('svelte'); + expect(dependencies).not.toContain('$app/stores'); + expect(dependencies).not.toContain('$app/environment'); + expect(dependencies).not.toContain('$lib/utils'); + expect(dependencies).not.toContain('node:fs'); + expect(dependencies).not.toContain('@sveltejs/kit'); +}); + test('real world download and convert playground', async () => { const directory = path.join(testWorkspaceDir, 'real-world-playground'); if (fs.existsSync(directory)) { fs.rmdirSync(directory, { recursive: true }); } - await create(directory, { + create(directory, { name: 'real-world-playground', template: 'minimal', types: 'typescript' }); - const playground = await downloadFilesFromPlayground({ + const playground = await downloadPlaygroundData({ playgroundId: '770bbef086034b9f8e337bab57efe8d8', hash: undefined }); - setupPlaygroundProject(playground, directory); + setupPlaygroundProject(playground, directory, true); const pageFilePath = path.join(directory, 'src/routes/+page.svelte'); const pageContent = fs.readFileSync(pageFilePath, 'utf-8'); diff --git a/rolldown.config.js b/rolldown.config.js index a9eb3aa7..bbf92423 100644 --- a/rolldown.config.js +++ b/rolldown.config.js @@ -34,6 +34,8 @@ function getConfig(project) { `${projectRoot}/lib/testing.ts`, `${projectRoot}/bin.ts` ]; + } else if (project === 'create') { + inputs = [`${projectRoot}/index.ts`, `${projectRoot}/playground.ts`]; } else { inputs = [`${projectRoot}/index.ts`]; } From 1eabc9110e663dcfe27a3b6b8903de13faf04935 Mon Sep 17 00:00:00 2001 From: Manuel <30698007+manuel3108@users.noreply.github.com> Date: Sat, 23 Aug 2025 19:00:58 +0200 Subject: [PATCH 11/16] Update packages/create/playground.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- packages/create/playground.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/create/playground.ts b/packages/create/playground.ts index 7ef4188b..92381e8c 100644 --- a/packages/create/playground.ts +++ b/packages/create/playground.ts @@ -4,8 +4,8 @@ import { parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers' import * as js from '@sveltejs/cli-core/js'; export function validatePlaygroundUrl(link?: string): boolean { - // If no link is provided, consider it valid - if (!link) return true; + // If no link is provided, consider it invalid + if (!link) return false; try { const url = new URL(link); From 85f53cb2fa73bd5953e307b4943a396b30080dc3 Mon Sep 17 00:00:00 2001 From: AdrianGonz97 <31664583+AdrianGonz97@users.noreply.github.com> Date: Mon, 25 Aug 2025 13:09:06 -0400 Subject: [PATCH 12/16] ensure `core` is built before `create` --- rolldown.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rolldown.config.js b/rolldown.config.js index bbf92423..b3b292e3 100644 --- a/rolldown.config.js +++ b/rolldown.config.js @@ -120,7 +120,7 @@ function getConfig(project) { } /** @type {RolldownOptions[]} */ -export default [getConfig('create'), getConfig('core'), getConfig('cli')]; +export default [getConfig('core'), getConfig('create'), getConfig('cli')]; /** * @param {PackageJson} pkg From df30f577bb7efa00cab5f05a7f935b8a4686f82d Mon Sep 17 00:00:00 2001 From: AdrianGonz97 <31664583+AdrianGonz97@users.noreply.github.com> Date: Fri, 29 Aug 2025 10:45:56 -0400 Subject: [PATCH 13/16] validate after valibot --- packages/cli/commands/create.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 7c6ee1fa..58180c20 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -68,13 +68,14 @@ export const create = new Command('create') .addOption(installOption) .configureHelp(common.helpConfig) .action((projectPath, opts) => { - if (opts.fromPlayground && !validatePlaygroundUrl(opts.fromPlayground)) { - console.error(pc.red(`Error: Invalid playground URL: ${opts.fromPlayground}`)); + const cwd = v.parse(ProjectPathSchema, projectPath); + const options = v.parse(OptionsSchema, opts); + + if (options.fromPlayground && !validatePlaygroundUrl(options.fromPlayground)) { + console.error(pc.red(`Error: Invalid playground URL: ${options.fromPlayground}`)); process.exit(1); } - const cwd = v.parse(ProjectPathSchema, projectPath); - const options = v.parse(OptionsSchema, opts); common.runCommand(async () => { const { directory, addOnNextSteps, packageManager } = await createProject(cwd, options); const highlight = (str: string) => pc.bold(pc.cyan(str)); From e1bc93e91689f6e9981fba19d87b057bee448e58 Mon Sep 17 00:00:00 2001 From: AdrianGonz97 <31664583+AdrianGonz97@users.noreply.github.com> Date: Fri, 29 Aug 2025 11:44:14 -0400 Subject: [PATCH 14/16] add support for import paths that specify versions and sub-paths --- packages/cli/commands/create.ts | 2 +- packages/create/playground.ts | 76 ++++++++++++++++++++------------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/packages/cli/commands/create.ts b/packages/cli/commands/create.ts index 58180c20..26636c63 100644 --- a/packages/cli/commands/create.ts +++ b/packages/cli/commands/create.ts @@ -244,7 +244,7 @@ async function createProjectFromPlayground(url: string, cwd: string): Promise; + files: Array<{ name: string; content: string }>; }; export async function downloadPlaygroundData({ @@ -74,7 +69,6 @@ export async function downloadPlaygroundData({ } // Taken from https://github.com/sveltejs/svelte.dev/blob/ba7ad256f786aa5bc67eac3a58608f3f50b59e91/apps/svelte.dev/src/routes/(authed)/playground/%5Bid%5D/gzip.js#L19-L29 -/** @param {string} input */ async function decodeAndDecompressText(input: string) { const decoded = atob(input.replaceAll('-', '+').replaceAll('_', '/')); // putting it directly into the blob gives a corrupted file @@ -86,8 +80,11 @@ async function decodeAndDecompressText(input: string) { return new Response(stream).text(); } -export function detectPlaygroundDependencies(files: PlaygroundData['files']): string[] { - const packages: string[] = []; +/** + * @returns A Map of packages with it's name as the key, and it's version as the value. + */ +export function detectPlaygroundDependencies(files: PlaygroundData['files']): Map { + const packages = new Map(); // Prefixes for packages that should be excluded (built-in or framework packages) const excludedPrefixes = [ @@ -109,15 +106,42 @@ export function detectPlaygroundDependencies(files: PlaygroundData['files']): st const imports = ast.body .filter((node): node is js.AstTypes.ImportDeclaration => node.type === 'ImportDeclaration') .map((node) => node.source.value as string) - .filter((importPath) => !importPath.startsWith('./') && !importPath.startsWith('/')); + .filter((importPath) => !importPath.startsWith('./') && !importPath.startsWith('/')) + .filter((importPath) => !excludedPrefixes.some((prefix) => importPath.startsWith(prefix))) + .map(extractPackageInfo); + + imports.forEach(({ pkgName, version }) => packages.set(pkgName, version)); + } - packages.push(...imports); + return packages; +} + +/** + * Extracts a package's name and it's versions from a provided import path. + * + * Handles imports with or without subpaths (e.g. `pkg-name/subpath`, `@org/pkg-name/subpath`) + * as well as specified versions (e.g. pkg-name@1.2.3). + */ +function extractPackageInfo(importPath: string): { pkgName: string; version: string } { + // handle scoped deps + if (importPath.startsWith('@')) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [org, pkg, _subpath] = importPath.split('/', 3); + const pkgName = `${org}/${pkg}`; + return { pkgName, version: extractPackageVersion(pkgName) }; } - // Remove duplicates and filter out excluded packages - return [...new Set(packages)].filter((pkg) => { - return !excludedPrefixes.some((prefix) => pkg.startsWith(prefix)); - }); + const [pkgName] = importPath.split('/', 2); + return { pkgName, version: extractPackageVersion(pkgName) }; +} + +function extractPackageVersion(pkgName: string) { + let version = 'latest'; + // e.g. `pkg-name@1.2.3` (starting from index 1 to ignore the first `@` in scoped packages) + if (pkgName.includes('@')) { + [, version] = pkgName.split('@'); + } + return version; } export function setupPlaygroundProject( @@ -141,26 +165,20 @@ export function setupPlaygroundProject( const filePath = path.join(cwd, 'src/routes/+page.svelte'); const content = fs.readFileSync(filePath, 'utf-8'); const { script, template, generateCode } = parseSvelte(content); - js.imports.addDefault(script.ast, { - from: `./${mainFile.name}`, - as: 'App' - }); + js.imports.addDefault(script.ast, { from: `./${mainFile.name}`, as: 'App' }); template.source = ``; - const newContent = generateCode({ - script: script.generateCode(), - template: template.source - }); + const newContent = generateCode({ script: script.generateCode(), template: template.source }); fs.writeFileSync(filePath, newContent, 'utf-8'); // add packages as dependencies to package.json if requested const dependencies = detectPlaygroundDependencies(playground.files); - if (installDependencies && dependencies.length >= 0) { + if (installDependencies && dependencies.size >= 0) { const packageJsonPath = path.join(cwd, 'package.json'); const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8'); const { data: packageJson, generateCode: generateCodeJson } = parseJson(packageJsonContent); packageJson.dependencies ??= {}; - for (const pkg of dependencies) { - packageJson.dependencies[pkg] = 'latest'; + for (const [pkg, version] of dependencies) { + packageJson.dependencies[pkg] = version; } const newPackageJson = generateCodeJson(); fs.writeFileSync(packageJsonPath, newPackageJson, 'utf-8'); From c5a73caed6cea7575b9dea2ca7cf177f7a77f593 Mon Sep 17 00:00:00 2001 From: AdrianGonz97 <31664583+AdrianGonz97@users.noreply.github.com> Date: Fri, 29 Aug 2025 12:01:40 -0400 Subject: [PATCH 15/16] modify import paths to remove version specifier --- packages/create/playground.ts | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/create/playground.ts b/packages/create/playground.ts index b4614fa2..2adf058e 100644 --- a/packages/create/playground.ts +++ b/packages/create/playground.ts @@ -123,22 +123,29 @@ export function detectPlaygroundDependencies(files: PlaygroundData['files']): Ma * as well as specified versions (e.g. pkg-name@1.2.3). */ function extractPackageInfo(importPath: string): { pkgName: string; version: string } { + let pkgName = ''; + // handle scoped deps if (importPath.startsWith('@')) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const [org, pkg, _subpath] = importPath.split('/', 3); - const pkgName = `${org}/${pkg}`; - return { pkgName, version: extractPackageVersion(pkgName) }; + pkgName = `${org}/${pkg}`; + } + + if (!pkgName) { + [pkgName] = importPath.split('/', 2); } - const [pkgName] = importPath.split('/', 2); - return { pkgName, version: extractPackageVersion(pkgName) }; + const version = extractPackageVersion(pkgName); + // strips the package's version from the name, if present + if (version !== 'latest') pkgName = pkgName.replace(`@${version}`, ''); + return { pkgName, version }; } function extractPackageVersion(pkgName: string) { let version = 'latest'; // e.g. `pkg-name@1.2.3` (starting from index 1 to ignore the first `@` in scoped packages) - if (pkgName.includes('@')) { + if (pkgName.includes('@', 1)) { [, version] = pkgName.split('@'); } return version; @@ -154,7 +161,15 @@ export function setupPlaygroundProject( playground.files.find((file) => file.name.endsWith('.svelte')) || playground.files[0]; + const dependencies = detectPlaygroundDependencies(playground.files); for (const file of playground.files) { + for (const [pkg, version] of dependencies) { + // if a version was specified, we'll remove it from all import paths + if (version !== 'latest') { + file.content = file.content.replaceAll(`${pkg}@${version}`, pkg); + } + } + // write file to disk const filePath = path.join(cwd, 'src', 'routes', file.name); fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -171,7 +186,6 @@ export function setupPlaygroundProject( fs.writeFileSync(filePath, newContent, 'utf-8'); // add packages as dependencies to package.json if requested - const dependencies = detectPlaygroundDependencies(playground.files); if (installDependencies && dependencies.size >= 0) { const packageJsonPath = path.join(cwd, 'package.json'); const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8'); From 33ba6b44d37b46f207c493fa3b2fb2682fea4b02 Mon Sep 17 00:00:00 2001 From: AdrianGonz97 <31664583+AdrianGonz97@users.noreply.github.com> Date: Fri, 29 Aug 2025 12:15:38 -0400 Subject: [PATCH 16/16] update playground tests --- packages/create/test/playground.ts | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/create/test/playground.ts b/packages/create/test/playground.ts index 237f47a1..ff1a9090 100644 --- a/packages/create/test/playground.ts +++ b/packages/create/test/playground.ts @@ -88,6 +88,7 @@ test('detect dependencies from playground files', () => { content: `