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 +});