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`
69 changes: 68 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,11 +64,18 @@ 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) => {
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);
}

common.runCommand(async () => {
const { directory, addOnNextSteps, packageManager } = await createProject(cwd, options);
const highlight = (str: string) => pc.bold(pc.cyan(str));
Expand Down Expand Up @@ -105,6 +120,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 +156,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 +193,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 +235,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.keys().toArray());

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
200 changes: 200 additions & 0 deletions packages/create/playground.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import fs from 'node:fs';
import path from 'node:path';
import * as js from '@sveltejs/cli-core/js';
import { parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers';

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

/**
* @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<string, string> {
const packages = new Map<string, 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('/'))
.filter((importPath) => !excludedPrefixes.some((prefix) => importPath.startsWith(prefix)))
.map(extractPackageInfo);

imports.forEach(({ pkgName, version }) => packages.set(pkgName, version));
}

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 } {
let pkgName = '';

// handle scoped deps
if (importPath.startsWith('@')) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [org, pkg, _subpath] = importPath.split('/', 3);
pkgName = `${org}/${pkg}`;
}

if (!pkgName) {
[pkgName] = importPath.split('/', 2);
}

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('@', 1)) {
[, version] = pkgName.split('@');
}
return version;
}

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];

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 });
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
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, version] of dependencies) {
packageJson.dependencies[pkg] = version;
}
const newPackageJson = generateCodeJson();
fs.writeFileSync(packageJsonPath, newPackageJson, 'utf-8');
}
}
Loading
Loading