Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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/tasty-friends-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'sv': patch
---

feat(cli): `--from-playground` will now bring a PlaygroundLayout to get a more consistent experience with the online playground
14 changes: 11 additions & 3 deletions packages/cli/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,11 @@ async function createProject(cwd: ProjectPath, options: Options) {
});

if (options.fromPlayground) {
await createProjectFromPlayground(options.fromPlayground, projectPath);
await createProjectFromPlayground(
options.fromPlayground,
projectPath,
language === 'typescript'
);
}

p.log.success('Project created');
Expand Down Expand Up @@ -236,15 +240,19 @@ async function createProject(cwd: ProjectPath, options: Options) {
return { directory: projectPath, addOnNextSteps, packageManager };
}

async function createProjectFromPlayground(url: string, cwd: string): Promise<void> {
async function createProjectFromPlayground(
url: string,
cwd: string,
typescript: boolean
): Promise<void> {
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(Array.from(dependencies.keys()));

setupPlaygroundProject(playground, cwd, installDependencies);
setupPlaygroundProject(url, playground, cwd, installDependencies, typescript);
}

async function confirmExternalDependencies(dependencies: string[]): Promise<boolean> {
Expand Down
2 changes: 1 addition & 1 deletion packages/create/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export type File = {
contents: string;
};

export type Condition = TemplateType | LanguageType;
export type Condition = TemplateType | LanguageType | 'playground';

export type Common = {
files: Array<{
Expand Down
50 changes: 45 additions & 5 deletions packages/create/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import path from 'node:path';
import * as js from '@sveltejs/cli-core/js';
import { parseJson, parseScript, parseSvelte } from '@sveltejs/cli-core/parsers';
import { isVersionUnsupportedBelow } from '@sveltejs/cli-core';
import { dist } from './utils.ts';
import type { Common } from './index.ts';

export function validatePlaygroundUrl(link: string): boolean {
try {
Expand Down Expand Up @@ -154,9 +156,11 @@ function extractPackageVersion(pkgName: string) {
}

export function setupPlaygroundProject(
url: string,
playground: PlaygroundData,
cwd: string,
installDependencies: boolean
installDependencies: boolean,
typescript: boolean
): void {
const mainFile = playground.files.find((file) => file.name === 'App.svelte');
if (!mainFile) throw new Error('Failed to find `App.svelte` entrypoint.');
Expand All @@ -171,17 +175,53 @@ export function setupPlaygroundProject(
}

// write file to disk
const filePath = path.join(cwd, 'src', 'routes', file.name);
const filePath = path.join(cwd, 'src', 'lib', 'playground', file.name);
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, file.content, 'utf8');
}

// add playground shared files
{
const shared = dist('shared.json');
const { files } = JSON.parse(fs.readFileSync(shared, 'utf-8')) as Common;
const playgroundFiles = files.filter((file) => file.include.includes('playground'));

for (const file of playgroundFiles) {
let contentToWrite = file.contents;

if (file.name === 'src/lib/PlaygroundLayout.svelte') {
// getting raw content
const { script, template } = parseSvelte(file.contents);
// generating new content with the right language style
const { generateCode } = parseSvelte('', { typescript });
contentToWrite = generateCode({
script: script
.generateCode()
.replaceAll('$sv-title-$sv', playground.name)
.replaceAll('$sv-url-$sv', url),
template: template.generateCode()
});
}

fs.writeFileSync(path.join(cwd, file.name), contentToWrite, 'utf-8');
}
}

// add app import to +page.svelte
const filePath = path.join(cwd, 'src/routes/+page.svelte');
const content = fs.readFileSync(filePath, 'utf-8');
const { script, generateCode } = parseSvelte(content);
js.imports.addDefault(script.ast, { from: `./${mainFile.name}`, as: 'App' });
const newContent = generateCode({ script: script.generateCode(), template: `<App />` });
const { script, generateCode } = parseSvelte(content, { typescript });
js.imports.addDefault(script.ast, { as: 'App', from: `$lib/playground/${mainFile.name}` });
js.imports.addDefault(script.ast, {
as: 'PlaygroundLayout',
from: `$lib/PlaygroundLayout.svelte`
});
const newContent = generateCode({
script: script.generateCode(),
template: `<PlaygroundLayout>
<App />
</PlaygroundLayout>`
});
fs.writeFileSync(filePath, newContent, 'utf-8');

// add packages as dependencies to package.json if requested
Expand Down
220 changes: 220 additions & 0 deletions packages/create/shared/+playground/src/lib/PlaygroundLayout.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
<script lang="ts">
import favicon from '$lib/assets/favicon.svg';

let { children } = $props();

const title = '$sv-title-$sv';
const href = '$sv-url-$sv';

let prefersDark = $state(true);
let isDark = $state(true);

function setTheme(/** @type {'dark' | 'light' | 'system'} */ value) {
isDark = value === 'dark';
localStorage.setItem('sv:theme', isDark === prefersDark ? 'system' : value);
}

$effect(() => {
document.documentElement.classList.remove('light', 'dark');

prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = localStorage.getItem('sv:theme');
isDark = theme === 'dark' || (theme === 'system' && prefersDark);

document.documentElement.classList.add(isDark ? 'dark' : 'light');
});
</script>

<svelte:head>
<title>--from-playground {title}</title>
<script>
{
const theme = localStorage.getItem('sv:theme');

document.documentElement.classList.add(
!theme || theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
: theme
);
}
</script>
</svelte:head>

<div class="layout">
<nav class="navbar">
<div class="nav-left">
<a href="/" class="svelte-icon">
<img src={favicon} alt="Svelte" width="32" height="32" />
</a>
<p class="title">{title}</p>
</div>
<div class="nav-right">
<a {href} class="raised" target="_blank" rel="noopener noreferrer">
--to-playground
<span aria-hidden="true" style="margin-left:0.25em;"> ↗</span>
</a>
<button
class="raised theme-toggle"
onclick={() => setTheme(isDark ? 'light' : 'dark')}
aria-label="Toggle theme"
>
<span class="icon"></span>
</button>
</div>
</nav>

<main class="content">
{@render children?.()}
</main>
</div>

<style>
:global(body) {
margin: 0;
}

:global(html) {
margin: 0;
--bg-1: hsl(0, 0%, 100%);
--bg-2: hsl(206, 20%, 90%);
--bg-3: hsl(206, 20%, 80%);
--navbar-bg: #fff;
--fg-1: hsl(0, 0%, 13%);
--fg-2: hsl(0, 0%, 50%);
--fg-3: hsl(0, 0%, 60%);
--link: hsl(208, 77%, 47%);
--border-radius: 4px;
--font:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans',
'Helvetica Neue', sans-serif;
color-scheme: light;
background: var(--bg-1);
color: var(--fg-1);
font-family: var(--font);
line-height: 1.5;
height: calc(100vh - 2rem);
accent-color: var(--link) !important;
min-height: 100vh;
background-color: var(--bg-1);
}

:global(html.dark) {
color-scheme: dark;
--bg-1: hsl(0, 0%, 18%);
--bg-2: hsl(0, 0%, 30%);
--bg-3: hsl(0, 0%, 40%);
--navbar-bg: hsl(220, 14%, 16%);
--fg-1: hsl(0, 0%, 75%);
--fg-2: hsl(0, 0%, 40%);
--fg-3: hsl(0, 0%, 30%);
--link: hsl(206, 96%, 72%);
}

.navbar {
color: var(--fg-1);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0em 2.5rem;
height: 3.7rem;
background-color: var(--navbar-bg);
box-shadow:
0 2px 8px 0 rgba(0, 0, 0, 0.08),
0 1.5px 4px 0 rgba(0, 0, 0, 0.04);
}

.nav-left {
display: flex;
align-items: center;
gap: 0.5rem;
}

.svelte-icon {
display: flex;
align-items: center;
text-decoration: none;
transition: opacity 0.2s ease;
}

.svelte-icon:hover {
opacity: 0.8;
}

.title {
font-size: 1.5rem;
font-weight: 400;
margin: 0;
}

.nav-right {
display: flex;
align-items: center;
gap: 1rem;
}

.raised {
background: var(--navbar-bg);
border-left: 0.5px solid var(--fg-3);
border-top: 0.5px solid var(--fg-3);
border-bottom: none;
border-right: none;
border-radius: var(--border-radius);
color: var(--fg-1);
cursor: pointer;
transition: all 0.2s ease;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.1),
0 1px 2px rgba(0, 0, 0, 0.06);
text-decoration: none;
font-weight: 500;
padding: 0.25rem 0.75rem;
font-size: 0.8rem;
}

.raised:hover {
border-left-color: var(--fg-2);
border-top-color: var(--fg-2);
box-shadow:
0 4px 8px rgba(0, 0, 0, 0.15),
0 2px 4px rgba(0, 0, 0, 0.1);
transform: translate(-1px, -1px);
}

.content {
padding: 1rem;
color: var(--fg-1);
}

.theme-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 1.8rem;
height: 1.8rem;
padding: 0;
min-width: 2rem;
}

.icon {
display: inline-block;
width: 1.5rem;
height: 1.5rem;
-webkit-mask-size: 1.5rem;
mask-size: 1.5rem;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
background-color: var(--fg-1);
}

.icon {
mask-image: url('data:image/svg+xml,%3csvg%20xmlns="http://www.w3.org/2000/svg"%20viewBox="0%200%2024%2024"%3e%3cpath%20fill="%23666"%20d="M12%2021q-3.775%200-6.388-2.613T3%2012q0-3.45%202.25-5.988T11%203.05q.625-.075.975.45t-.025%201.1q-.425.65-.638%201.375T11.1%207.5q0%202.25%201.575%203.825T16.5%2012.9q.775%200%201.538-.225t1.362-.625q.525-.35%201.075-.037t.475.987q-.35%203.45-2.937%205.725T12%2021Zm0-2q2.2%200%203.95-1.213t2.55-3.162q-.5.125-1%20.2t-1%20.075q-3.075%200-5.238-2.163T9.1%207.5q0-.5.075-1t.2-1q-1.95.8-3.163%202.55T5%2012q0%202.9%202.05%204.95T12%2019Zm-.25-6.75Z"/%3e%3c/svg%3e');
}

:global(html.dark) .icon {
mask-image: url("data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2024%2024'%3e%3cpath%20fill='%23d4d4d4'%20d='M12%2019a1%201%200%200%201%20.993.883L13%2020v1a1%201%200%200%201-1.993.117L11%2021v-1a1%201%200%200%201%201-1zm6.313-2.09.094.083.7.7a1%201%200%200%201-1.32%201.497l-.094-.083-.7-.7a1%201%200%200%201%201.218-1.567l.102.07zm-11.306.083a1%201%200%200%201%20.083%201.32l-.083.094-.7.7a1%201%200%200%201-1.497-1.32l.083-.094.7-.7a1%201%200%200%201%201.414%200zM4%2011a1%201%200%200%201%20.117%201.993L4%2013H3a1%201%200%200%201-.117-1.993L3%2011h1zm17%200a1%201%200%200%201%20.117%201.993L21%2013h-1a1%201%200%200%201-.117-1.993L20%2011h1zM6.213%204.81l.094.083.7.7a1%201%200%200%201-1.32%201.497l-.094-.083-.7-.7A1%201%200%200%201%206.11%204.74l.102.07zm12.894.083a1%201%200%200%201%20.083%201.32l-.083.094-.7.7a1%201%200%200%201-1.497-1.32l.083-.094.7-.7a1%201%200%200%201%201.414%200zM12%202a1%201%200%200%201%20.993.883L13%203v1a1%201%200%200%201-1.993.117L11%204V3a1%201%200%200%201%201-1zm0%205a5%205%200%201%201-4.995%205.217L7%2012l.005-.217A5%205%200%200%201%2012%207z'/%3e%3c/svg%3e");
}
</style>
23 changes: 21 additions & 2 deletions packages/create/test/playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,24 @@ test('real world download and convert playground async', async () => {
svelteVersion: '5.38.7'
});

setupPlaygroundProject(playground, directory, true);
setupPlaygroundProject(
'https://svelte.dev/playground/770bbef086034b9f8e337bab57efe8d8',
playground,
directory,
true,
true
);

const pageFilePath = path.join(directory, 'src/routes/+page.svelte');
const pageContent = fs.readFileSync(pageFilePath, 'utf-8');
expect(pageContent).toContain('<App />');
expect(pageContent).toContain('<PlaygroundLayout>');

const playgroundLayoutPath = path.join(directory, 'src/lib/PlaygroundLayout.svelte');
const playgroundLayoutContent = fs.readFileSync(playgroundLayoutPath, 'utf-8');
expect(playgroundLayoutContent).toContain('localStorage.getItem');
expect(playgroundLayoutContent).toContain('sv:theme');
expect(playgroundLayoutContent).toContain('770bbef086034b9f8e337bab57efe8d8');

const packageJsonPath = path.join(directory, 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8');
Expand Down Expand Up @@ -199,7 +212,13 @@ test('real world download and convert playground without async', async () => {
svelteVersion: '5.0.5'
});

setupPlaygroundProject(playground, directory, true);
setupPlaygroundProject(
'https://svelte.dev/playground/770bbef086034b9f8e337bab57efe8d8',
playground,
directory,
true,
true
);

const packageJsonPath = path.join(directory, 'package.json');
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf-8');
Expand Down
Loading