Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brown-comics-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'sv': patch
---

feat(cli): allow creating reproduction from playground with `npx sv create --from-playground https://svelte.dev/playground/hello-world`
68 changes: 67 additions & 1 deletion packages/cli/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ import {
type LanguageType,
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';
Expand Down Expand Up @@ -43,7 +50,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<typeof OptionsSchema>;
type ProjectPath = v.InferOutput<typeof ProjectPathSchema>;
Expand All @@ -56,9 +64,15 @@ 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 <string>', 'create a project from the svelte playground')
.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 () => {
Expand Down Expand Up @@ -105,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: () => {
Expand Down Expand Up @@ -135,6 +155,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<TemplateType>({
message: 'Which template would you like?',
initialValue: 'minimal',
Expand Down Expand Up @@ -169,6 +192,10 @@ async function createProject(cwd: ProjectPath, options: Options) {
types: language
});

if (options.fromPlayground) {
await createProjectFromPlayground(options.fromPlayground, projectPath);
}

p.log.success('Project created');

let packageManager: AgentName | undefined | null;
Expand Down Expand Up @@ -207,3 +234,42 @@ async function createProject(cwd: ProjectPath, options: Options) {

return { directory: projectPath, addOnNextSteps, packageManager };
}

async function createProjectFromPlayground(url: string, cwd: string): Promise<void> {
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<boolean> {
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;
}
5 changes: 5 additions & 0 deletions packages/create/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@
},
"./build": {
"default": "./scripts/build-templates.js"
},
"./playground": {
"types": "./dist/playground.d.ts",
"default": "./dist/playground.js"
}
},
"devDependencies": {
"@sveltejs/cli-core": "workspace:*",
"@types/gitignore-parser": "^0.0.3",
"gitignore-parser": "^0.0.2",
"sucrase": "^3.35.0",
Expand Down
168 changes: 168 additions & 0 deletions packages/create/playground.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import path from 'node:path';
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 {
// If no link is provided, consider it invalid
if (!link) return false;

try {
const url = new URL(link);
if (url.hostname !== 'svelte.dev' || !url.pathname.startsWith('/playground/')) {
return false;
}

const { playgroundId, hash } = parsePlaygroundUrl(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 parsePlaygroundUrl(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 downloadPlaygroundData({
playgroundId,
hash
}: {
playgroundId?: string;
hash?: string;
}): Promise<PlaygroundData> {
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 decodeAndDecompressText(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; type: string; contents: string; source: string }) => {
return {
name: file.name + (file.type !== 'file' ? `.${file.type}` : ''),
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 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);
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();
}

export function detectPlaygroundDependencies(files: PlaygroundData['files']): string[] {
const packages: string[] = [];

// 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;
} 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('./') && !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 });
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: `./${mainFile.name}`,
as: 'App'
});
template.source = `<App />`;
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) {
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');
}
}
Loading
Loading