diff --git a/content/tutorial/common/src/routes/+page.svelte b/content/tutorial/common/src/routes/+page.svelte new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/client/adapters/filesystem/index.js b/src/lib/client/adapters/filesystem/index.js index e9d11f0fa..d3bd0e6ba 100644 --- a/src/lib/client/adapters/filesystem/index.js +++ b/src/lib/client/adapters/filesystem/index.js @@ -1,7 +1,7 @@ /** * @param {import('$lib/types').Stub[]} stubs * @param {(progress: number, status: string) => void} cb - * @returns {Promise} + * @returns {Promise} */ export async function create(stubs, cb) { const res = await fetch('/backend', { @@ -56,10 +56,6 @@ export async function create(stubs, cb) { await new Promise((f) => setTimeout(f, 100)); // wait for chokidar return will_restart_vite_dev_server(stubs); - }, - - async destroy() { - navigator.sendBeacon(`/backend/destroy?id=${id}`); } }; } diff --git a/src/lib/client/adapters/webcontainer/index.js b/src/lib/client/adapters/webcontainer/index.js index 0ef49a1d4..125cc0e7e 100644 --- a/src/lib/client/adapters/webcontainer/index.js +++ b/src/lib/client/adapters/webcontainer/index.js @@ -16,16 +16,17 @@ function console_stream(label) { } /** - * @param {import('$lib/types').Stub[]} stubs - * @param {(progress: number, status: string) => void} callback - * @returns {Promise} + * @param {import('svelte/store').Writable} base + * @param {import('svelte/store').Writable} error + * @param {import('svelte/store').Writable<{ value: number, text: string }>} progress + * @returns {Promise} */ -export async function create(stubs, callback) { +export async function create(base, error, progress) { if (/safari/i.test(navigator.userAgent) && !/chrome/i.test(navigator.userAgent)) { throw new Error('WebContainers are not supported by Safari'); } - callback(0, 'loading files'); + progress.set({ value: 0, text: 'loading files' }); /** * Keeps track of the latest create/reset to ensure things are not processed in parallel. @@ -35,15 +36,15 @@ export async function create(stubs, callback) { let running; /** Paths and contents of the currently loaded file stubs */ - let current_stubs = stubs_to_map(stubs); + let current_stubs = stubs_to_map([]); /** @type {boolean} Track whether there was an error from vite dev server */ let vite_error = false; - callback(1 / 5, 'booting webcontainer'); + progress.set({ value: 1 / 5, text: 'booting webcontainer' }); vm = await WebContainer.boot(); - callback(2 / 5, 'writing virtual files'); + progress.set({ value: 2 / 5, text: 'writing virtual files' }); const common = await ready; await vm.mount({ 'common.zip': { @@ -51,11 +52,10 @@ export async function create(stubs, callback) { }, 'unzip.cjs': { file: { contents: common.unzip } - }, - ...convert_stubs_to_tree(stubs) + } }); - callback(3 / 5, 'unzipping files'); + progress.set({ value: 3 / 5, text: 'unzipping files' }); const unzip = await vm.spawn('node', ['unzip.cjs']); unzip.output.pipeTo(console_stream('unzip')); const code = await unzip.exit; @@ -66,41 +66,51 @@ export async function create(stubs, callback) { await vm.spawn('chmod', ['a+x', 'node_modules/vite/bin/vite.js']); - callback(4 / 5, 'starting dev server'); - const base = await new Promise(async (fulfil, reject) => { - const error_unsub = vm.on('error', (error) => { - error_unsub(); - reject(new Error(error.message)); - }); + vm.on('server-ready', (_port, url) => { + base.set(url); + }); - const ready_unsub = vm.on('server-ready', (_port, base) => { - ready_unsub(); - callback(5 / 5, 'ready'); - fulfil(base); // this will be the last thing that happens if everything goes well - }); + vm.on('error', ({ message }) => { + error.set(new Error(message)); + }); - await run_dev(); + let launched = false; - async function run_dev() { - const process = await vm.spawn('turbo', ['run', 'dev']); + async function launch() { + if (launched) return; + launched = true; - // TODO differentiate between stdout and stderr (sets `vite_error` to `true`) - // https://github.com/stackblitz/webcontainer-core/issues/971 - process.output.pipeTo(console_stream('dev')); + progress.set({ value: 4 / 5, text: 'starting dev server' }); - // keep restarting dev server (can crash in case of illegal +files for example) - process.exit.then((code) => { - if (code !== 0) { - setTimeout(() => { - run_dev(); - }, 2000); - } + await new Promise(async (fulfil, reject) => { + const error_unsub = vm.on('error', (error) => { + error_unsub(); + reject(new Error(error.message)); }); - } - }); + + const ready_unsub = vm.on('server-ready', (_port, base) => { + ready_unsub(); + progress.set({ value: 5 / 5, text: 'ready' }); + fulfil(base); // this will be the last thing that happens if everything goes well + }); + + await run_dev(); + + async function run_dev() { + const process = await vm.spawn('turbo', ['run', 'dev']); + + // TODO differentiate between stdout and stderr (sets `vite_error` to `true`) + // https://github.com/stackblitz/webcontainer-core/issues/971 + process.output.pipeTo(console_stream('dev')); + + // keep restarting dev server (can crash in case of illegal +files for example) + await process.exit; + run_dev(); + } + }); + } return { - base, reset: async (stubs) => { await running; /** @type {Function} */ @@ -146,7 +156,7 @@ export async function create(stubs, callback) { // For some reason, server-ready is fired again when the vite dev server is restarted. // We need to wait for it to finish before we can continue, else we might // request files from Vite before it's ready, leading to a timeout. - const will_restart = will_restart_vite_dev_server(to_write); + const will_restart = launched && to_write.some(will_restart_vite_dev_server); const promise = will_restart ? new Promise((fulfil, reject) => { const error_unsub = vm.on('error', (error) => { @@ -190,60 +200,57 @@ export async function create(stubs, callback) { // Also trigger a reload of the iframe in case new files were added / old ones deleted, // because that can result in a broken UI state - return will_restart || vite_error || to_delete.length > 0 || added_new_file; + const should_reload = !launched || will_restart || vite_error || to_delete.length > 0; + // `|| added_new_file`, but I don't actually think that's necessary? + + await launch(); + + return should_reload; }, - update: async (stubs) => { + update: async (file) => { await running; /** @type {import('@webcontainer/api').FileSystemTree} */ const root = {}; - for (const stub of stubs) { - let tree = root; - - const path = stub.name.split('/').slice(1); - const basename = /** @type {string} */ (path.pop()); + let tree = root; - for (const part of path) { - if (!tree[part]) { - /** @type {import('@webcontainer/api').FileSystemTree} */ - const directory = {}; + const path = file.name.split('/').slice(1); + const basename = /** @type {string} */ (path.pop()); - tree[part] = { - directory - }; - } + for (const part of path) { + if (!tree[part]) { + /** @type {import('@webcontainer/api').FileSystemTree} */ + const directory = {}; - tree = /** @type {import('@webcontainer/api').DirectoryNode} */ (tree[part]).directory; + tree[part] = { + directory + }; } - tree[basename] = to_file(stub); + tree = /** @type {import('@webcontainer/api').DirectoryNode} */ (tree[part]).directory; } + tree[basename] = to_file(file); + await vm.mount(root); - stubs_to_map(stubs, current_stubs); + current_stubs.set(file.name, file); await new Promise((f) => setTimeout(f, 200)); // wait for chokidar - return will_restart_vite_dev_server(stubs); - }, - destroy: async () => { - vm.teardown(); + return will_restart_vite_dev_server(file); } }; } /** - * @param {import('$lib/types').Stub[]} stubs + * @param {import('$lib/types').Stub} file */ -function will_restart_vite_dev_server(stubs) { - return stubs.some( - (stub) => - stub.type === 'file' && - (stub.name === '/vite.config.js' || - stub.name === '/svelte.config.js' || - stub.name === '/.env') +function will_restart_vite_dev_server(file) { + return ( + file.type === 'file' && + (file.name === '/vite.config.js' || file.name === '/svelte.config.js' || file.name === '/.env') ); } @@ -272,11 +279,11 @@ function convert_stubs_to_tree(stubs, depth = 1) { return tree; } -/** @param {import('$lib/types').FileStub} stub */ -function to_file(stub) { +/** @param {import('$lib/types').FileStub} file */ +function to_file(file) { // special case - if (stub.name === '/src/app.html') { - const contents = stub.contents.replace( + if (file.name === '/src/app.html') { + const contents = file.contents.replace( '', '' ); @@ -286,7 +293,7 @@ function to_file(stub) { }; } - const contents = stub.text ? stub.contents : base64.toByteArray(stub.contents); + const contents = file.text ? file.contents : base64.toByteArray(file.contents); return { file: { contents } @@ -294,12 +301,12 @@ function to_file(stub) { } /** - * @param {import('$lib/types').Stub[]} stubs + * @param {import('$lib/types').Stub[]} files * @returns {Map} */ -function stubs_to_map(stubs, map = new Map()) { - for (const stub of stubs) { - map.set(stub.name, stub); +function stubs_to_map(files, map = new Map()) { + for (const file of files) { + map.set(file.name, file); } return map; } diff --git a/src/lib/server/content.js b/src/lib/server/content.js index d10024d05..cfdd1bac5 100644 --- a/src/lib/server/content.js +++ b/src/lib/server/content.js @@ -101,6 +101,7 @@ export function get_exercise(slug) { } const b = walk(`${dir}/app-b`); + const has_solution = Object.keys(b).length > 0; const part_meta = json(`content/tutorial/${part_dir}/meta.json`); const chapter_meta = json(`content/tutorial/${part_dir}/${chapter_dir}/meta.json`); @@ -152,6 +153,36 @@ export function get_exercise(slug) { }; } + const editing_constraints = { + create: new Set(exercise_meta.editing_constraints?.create ?? []), + remove: new Set(exercise_meta.editing_constraints?.remove ?? []) + }; + + const solution = { ...a }; + + for (const stub of Object.values(b)) { + if (stub.type === 'file' && stub.contents.startsWith('__delete')) { + // remove file + editing_constraints.remove.add(stub.name); + delete solution[stub.name]; + } else if (stub.name.endsWith('/__delete')) { + // remove directory + const parent = stub.name.slice(0, stub.name.lastIndexOf('/')); + editing_constraints.remove.add(parent); + delete solution[parent]; + for (const k in solution) { + if (k.startsWith(parent + '/')) { + delete solution[k]; + } + } + } else { + if (!solution[stub.name]) { + editing_constraints.create.add(stub.name); + } + solution[stub.name] = stub; + } + } + return { part: { slug: part_dir, @@ -169,10 +200,7 @@ export function get_exercise(slug) { prev, next, dir, - editing_constraints: { - create: exercise_meta.editing_constraints?.create ?? [], - remove: exercise_meta.editing_constraints?.remove ?? [] - }, + editing_constraints, html: transform(markdown, { codespan: (text) => filenames.size > 1 && filenames.has(text) @@ -180,7 +208,8 @@ export function get_exercise(slug) { : `${text}` }), a, - b + b: solution, + has_solution }; } @@ -218,7 +247,7 @@ function extract_frontmatter(markdown, dir) { * exclude?: string[] * }} options */ -export function walk(cwd, options = {}) { +function walk(cwd, options = {}) { /** @type {Record} */ const result = {}; diff --git a/src/lib/types/index.d.ts b/src/lib/types/index.d.ts index 394f858be..f0779b4a8 100644 --- a/src/lib/types/index.d.ts +++ b/src/lib/types/index.d.ts @@ -16,18 +16,10 @@ export interface DirectoryStub { export type Stub = FileStub | DirectoryStub; -export interface AdapterInternal { - base: string; +export interface Adapter { /** Returns `false` if the reset was in such a way that a reload of the iframe isn't needed */ reset(files: Array): Promise; - update(file: Array): Promise; - destroy(): Promise; -} - -export interface Adapter extends AdapterInternal { - reset(files: Array): Promise; - update(file: Array): Promise; - init: Promise; + update(file: FileStub): Promise; } export interface Scope { @@ -55,11 +47,12 @@ export interface Exercise { html: string; dir: string; editing_constraints: { - create: string[]; - remove: string[]; + create: Set; + remove: Set; }; a: Record; b: Record; + has_solution: boolean; } export interface ExerciseStub { @@ -80,6 +73,6 @@ export interface PartStub { } export interface EditingConstraints { - create: string[]; - remove: string[]; + create: Set; + remove: Set; } diff --git a/src/routes/tutorial/[slug]/+page.svelte b/src/routes/tutorial/[slug]/+page.svelte index 355e1cff3..39d7baa27 100644 --- a/src/routes/tutorial/[slug]/+page.svelte +++ b/src/routes/tutorial/[slug]/+page.svelte @@ -1,7 +1,7 @@ @@ -57,7 +119,7 @@ index={data.index} exercise={data.exercise} on:select={(e) => { - state.select_file(e.detail.file); + select_file(e.detail.file); }} /> @@ -72,16 +134,16 @@
- +
diff --git a/src/routes/tutorial/[slug]/Editor.svelte b/src/routes/tutorial/[slug]/Editor.svelte index d755bbf03..387644008 100644 --- a/src/routes/tutorial/[slug]/Editor.svelte +++ b/src/routes/tutorial/[slug]/Editor.svelte @@ -1,7 +1,7 @@