Skip to content

Commit 3747a4d

Browse files
manuel3108CopilotAdrianGonz97jycouet
authored
feat(cli): sv create --from-playground (#662)
* feat(cli): `sv create --from-playground` * properly download files * shit * add setup draft * add dependency handling * make it work * fix changesets * fix error handling * fix +page.svelte content * make it more usable * Update packages/create/playground.ts Co-authored-by: Copilot <[email protected]> * ensure `core` is built before `create` * validate after valibot * add support for import paths that specify versions and sub-paths * modify import paths to remove version specifier * update playground tests * code tweaks * final tweaks * add support for version query paramenter * docs * Update documentation/docs/20-commands/10-sv-create.md Co-authored-by: jyc.dev <[email protected]> --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: AdrianGonz97 <[email protected]> Co-authored-by: jyc.dev <[email protected]>
1 parent 9f3cc15 commit 3747a4d

File tree

9 files changed

+478
-2
lines changed

9 files changed

+478
-2
lines changed

.changeset/brown-comics-take.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'sv': patch
3+
---
4+
5+
feat(cli): create projects from the svelte playground with `npx sv create --from-playground <url>`

documentation/docs/20-commands/10-sv-create.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@ npx sv create [options] [path]
1212

1313
## Options
1414

15+
### `--from-playground <url>`
16+
17+
Create a SvelteKit project from a Svelte Playground URL. This downloads all playground files, detects external dependencies, and sets up a complete SvelteKit project structure with everything ready to go.
18+
19+
Example:
20+
21+
```sh
22+
npx sv create --from-playground=https://svelte.dev/playground/hello-world
23+
```
24+
1525
### `--template <name>`
1626

1727
Which project template to use:

packages/cli/bin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ import { migrate } from './commands/migrate.ts';
88
import { check } from './commands/check.ts';
99
import { helpConfig } from './utils/common.ts';
1010

11+
// adds a gap of spacing between the executing command and the output
12+
console.log();
13+
1114
program.name(pkg.name).version(pkg.version, '-v, --version').configureHelp(helpConfig);
1215
program.addCommand(create).addCommand(add).addCommand(migrate).addCommand(check);
1316
program.parse();

packages/cli/commands/create.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@ import {
1111
type LanguageType,
1212
type TemplateType
1313
} from '@sveltejs/create';
14+
import {
15+
downloadPlaygroundData,
16+
parsePlaygroundUrl,
17+
setupPlaygroundProject,
18+
validatePlaygroundUrl,
19+
detectPlaygroundDependencies
20+
} from '@sveltejs/create/playground';
1421
import * as common from '../utils/common.ts';
1522
import { runAddCommand } from './add/index.ts';
1623
import { detect, resolveCommand, type AgentName } from 'package-manager-detector';
@@ -43,7 +50,8 @@ const OptionsSchema = v.strictObject({
4350
),
4451
addOns: v.boolean(),
4552
install: v.union([v.boolean(), v.picklist(AGENT_NAMES)]),
46-
template: v.optional(v.picklist(templateChoices))
53+
template: v.optional(v.picklist(templateChoices)),
54+
fromPlayground: v.optional(v.string())
4755
});
4856
type Options = v.InferOutput<typeof OptionsSchema>;
4957
type ProjectPath = v.InferOutput<typeof ProjectPathSchema>;
@@ -56,11 +64,18 @@ export const create = new Command('create')
5664
.option('--no-types')
5765
.option('--no-add-ons', 'skips interactive add-on installer')
5866
.option('--no-install', 'skip installing dependencies')
67+
.option('--from-playground <url>', 'create a project from the svelte playground')
5968
.addOption(installOption)
6069
.configureHelp(common.helpConfig)
6170
.action((projectPath, opts) => {
6271
const cwd = v.parse(ProjectPathSchema, projectPath);
6372
const options = v.parse(OptionsSchema, opts);
73+
74+
if (options.fromPlayground && !validatePlaygroundUrl(options.fromPlayground)) {
75+
console.error(pc.red(`Error: Invalid playground URL: ${options.fromPlayground}`));
76+
process.exit(1);
77+
}
78+
6479
common.runCommand(async () => {
6580
const { directory, addOnNextSteps, packageManager } = await createProject(cwd, options);
6681
const highlight = (str: string) => pc.bold(pc.cyan(str));
@@ -105,6 +120,12 @@ export const create = new Command('create')
105120
});
106121

107122
async function createProject(cwd: ProjectPath, options: Options) {
123+
if (options.fromPlayground) {
124+
p.log.warn(
125+
'The Svelte maintainers have not reviewed playgrounds for malicious code. Use at your discretion.'
126+
);
127+
}
128+
108129
const { directory, template, language } = await p.group(
109130
{
110131
directory: () => {
@@ -135,6 +156,9 @@ async function createProject(cwd: ProjectPath, options: Options) {
135156
},
136157
template: () => {
137158
if (options.template) return Promise.resolve(options.template);
159+
// always use the minimal template for playground projects
160+
if (options.fromPlayground) return Promise.resolve<TemplateType>('minimal');
161+
138162
return p.select<TemplateType>({
139163
message: 'Which template would you like?',
140164
initialValue: 'minimal',
@@ -169,6 +193,10 @@ async function createProject(cwd: ProjectPath, options: Options) {
169193
types: language
170194
});
171195

196+
if (options.fromPlayground) {
197+
await createProjectFromPlayground(options.fromPlayground, projectPath);
198+
}
199+
172200
p.log.success('Project created');
173201

174202
let packageManager: AgentName | undefined | null;
@@ -207,3 +235,34 @@ async function createProject(cwd: ProjectPath, options: Options) {
207235

208236
return { directory: projectPath, addOnNextSteps, packageManager };
209237
}
238+
239+
async function createProjectFromPlayground(url: string, cwd: string): Promise<void> {
240+
const urlData = parsePlaygroundUrl(url);
241+
const playground = await downloadPlaygroundData(urlData);
242+
243+
// Detect external dependencies and ask for confirmation
244+
const dependencies = detectPlaygroundDependencies(playground.files);
245+
const installDependencies = await confirmExternalDependencies(dependencies.keys().toArray());
246+
247+
setupPlaygroundProject(playground, cwd, installDependencies);
248+
}
249+
250+
async function confirmExternalDependencies(dependencies: string[]): Promise<boolean> {
251+
if (dependencies.length === 0) return false;
252+
253+
const dependencyList = dependencies.map(pc.yellowBright).join(', ');
254+
p.log.warn(
255+
`The following external dependencies were found in the playground:\n\n${dependencyList}`
256+
);
257+
258+
const installDeps = await p.confirm({
259+
message: 'Do you want to install these external dependencies?',
260+
initialValue: false
261+
});
262+
if (p.isCancel(installDeps)) {
263+
p.cancel('Operation cancelled.');
264+
process.exit(0);
265+
}
266+
267+
return installDeps;
268+
}

packages/create/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,14 @@
2929
},
3030
"./build": {
3131
"default": "./scripts/build-templates.js"
32+
},
33+
"./playground": {
34+
"types": "./dist/playground.d.ts",
35+
"default": "./dist/playground.js"
3236
}
3337
},
3438
"devDependencies": {
39+
"@sveltejs/cli-core": "workspace:*",
3540
"@types/gitignore-parser": "^0.0.3",
3641
"gitignore-parser": "^0.0.2",
3742
"sucrase": "^3.35.0",

packages/create/playground.ts

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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

Comments
 (0)