Building interactive tutorials with WebContainers
Jamie
Posted on November 28, 2023
In this tutorial, we will create an interactive guide akin to those found in a framework's documentation. This is beneficial for anyone looking to create an engaging user experience or those simply interested in learning about three intriguing pieces of technology.
On the left, there's a guide for users to follow. The top right features an interactive editor where users can practice what they're learning. The bottom right displays test output, indicating whether the user has successfully completed the tutorial and understood the content.
We'll use some innovative technologies, including WebContainers, CodeMirror, and XTerm, to build this. If you're not familiar with these, don't worry, we'll cover them all during the process.
You can find the completed version here. If you want to follow along, use the start branch as your starting point.
Let's go!
Code structure
In our repository, there's an example
directory. This contains a simple Vite application that our users will interact with.
-
README.md
is the tutorial content that will be displayed on the page. -
main.js
is the file that users will manipulate using the editor. -
main.test.js
contains a set of tests that the maintainer has defined to ensure the user has successfully completed the task. -
package.json
file lists our dependencies and commands. Note that it includes atest
command which executesvitest
.
Displaying the tutorial
Let's begin by displaying our README.md
file on the page. We'll utilise the typography plugin from Tailwind and the Marked library to accomplish this.
Tailwind typography
The @tailwindcss/typography
plugin will enable us to style plain HTML attractively, which we'll render from our markdown file.
First, let's install the package and add it to our Tailwind configuration:
npm install -D @tailwindcss/typography
// tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: [require('@tailwindcss/typography')]
};
Finally, modify the Tutorial
component to accept a content
prop and display the HTML within a prose
div.
// src/routes/Tutorial.svelte
<script lang="ts">
export let content: string;
</script>
<div class="bg-white rounded shadow-sm h-full w-full">
<div class="prose p-8 max-w-none">
{@html content}
</div>
</div>
Loading and parsing markdown
Let's install marked
and create a function to read and parse a markdown file:
npm install marked
// src/lib/server/markdown.ts
import { marked } from 'marked';
import { readFile } from 'fs/promises';
export async function readMarkdownFile(filename: string): Promise<string> {
const file = await readFile(filename, 'utf-8');
return marked.parse(file);
}
Note that we've created this file in the lib/server
directory. This function can only be run on the server-side as that's where our markdown files are accessible.
Now that we have a method for obtaining our markdown, let's load it onto the server. We can then pass it into our main route and, finally, pass the rendered HTML as a prop to Tutorial
.
// src/routes/+page.server.ts
import { readMarkdownFile } from '$lib/server/markdown';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {
tutorialMd: await readMarkdownFile('example/README.md')
};
};
<script lang="ts">
import Tutorial from './Tutorial.svelte';
import Editor from './Editor.svelte';
import Output from './Output.svelte';
import type { PageData } from './$types';
export let data: PageData;
</script>
<div class="flex flex-row items-stretch h-screen gap-8 p-8">
<div class="w-1/2"><Tutorial content={data.tutorialMd} /></div>
<div class="w-1/2 flex flex-col gap-8">
<div class="h-1/2">
<Editor />
</div>
<div class="h-1/2">
<Output />
</div>
</div>
</div>
Our users can now learn about our product in detail. Next, we will discuss WebContainers.
WebContainers
WebContainers are an extremely effective browser-based runtime capable of executing Node commands. This makes them ideal for interactive tutorials, like running vitest
in our case.
To get started, install the @webcontainer/api
package:
npm install @webcontainer/api
For WebContainers to function correctly, we need to set Cross-Origin-Embedder-Policy
and Cross-Origin-Opener-Policy
. We will use a SvelteKit hook to set these headers for each request.
// src/hooks.server.ts
export async function handle({ event, resolve }) {
const response = await resolve(event);
response.headers.set('cross-origin-opener-policy', 'same-origin');
response.headers.set('cross-origin-embedder-policy', 'require-corp');
response.headers.set('cross-origin-resource-policy', 'cross-origin');
return response;
}
Loading our example files
We must load the files in a specific format, a FileSystemTree
, before passing them to the browser. This FileSystemTree
will then be loaded into our WebContainer. To achieve this, we'll create a loadFileSystem
function and modify our data loader accordingly.
// src/lib/server/files.ts
import { readFile } from 'fs/promises';
import type { FileSystemTree } from '@webcontainer/api';
const files = ['package.json', 'main.js', 'main.test.js'];
export function loadFileSystem(basePath: string): Promise<FileSystemTree> {
return files.reduce(async (acc, file) => {
const rest = await acc;
const contents = await readFile(`${basePath}/${file}`, 'utf-8');
return { ...rest, [file]: { file: { contents } } };
}, Promise.resolve({}));
}
// src/routes/+page.server.ts
import { loadFileSystem } from '$lib/server/files';
import { readMarkdownFile } from '$lib/server/markdown';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {
fileSystem: await loadFileSystem('example'),
tutorialMd: await readMarkdownFile('example/README.md')
};
};
Loading the WebContainer
Let's create a loadWebcontainer
function which initialises our runtime, mounts the filesystem and installs our dependencies using npm install
. This happens entirely in on the client-side so we’ll create this new file within lib/client
.
// src/lib/client/webcontainer.ts
import { WebContainer, type FileSystemTree } from '@webcontainer/api';
export async function loadWebcontainer(fileSystem: FileSystemTree) {
const webcontainer = await WebContainer.boot();
await webcontainer.mount(fileSystem);
const installProcess = await webcontainer.spawn('npm', ['install']);
await installProcess.exit;
return webcontainer;
}
Now, we can modify our route so that it creates the container and initiates our test process when the route is mounted.
// src/routes/+page.svelte
<script lang="ts">
import Tutorial from './Tutorial.svelte';
import Editor from './Editor.svelte';
import Output from './Output.svelte';
import type { PageData } from './$types';
import type { WebContainer } from '@webcontainer/api';
import { loadWebcontainer } from '$lib/client/webcontainer';
import { onMount } from 'svelte';
export let data: PageData;
let webcontainer: WebContainer;
async function startTestProcess() {
webcontainer = await loadWebcontainer(data.fileSystem);
const testProcess = await webcontainer.spawn('npm', ['test']);
testProcess.output.pipeTo(
new WritableStream({
write(data) {
console.log(data);
}
})
);
}
onMount(startTestProcess);
</script>
<div class="flex flex-row items-stretch h-screen gap-8 p-8">
<div class="w-1/2"><Tutorial content={data.tutorialMd} /></div>
<div class="w-1/2 flex flex-col gap-8">
<div class="h-1/2">
<Editor />
</div>
<div class="h-1/2">
<Output />
</div>
</div>
</div>
Observe how we're directing the process's output to our console. By opening the developer tools, we can see the output from Vitest.
CodeMirror
So far, we've displayed our tutorial content and executed our example in a WebContainer. Although this is quite useful, it doesn't allow the user to interact with the example. To address this, we'll add CodeMirror, a web-based code editor.
npm install codemirror @codemirror/lang-javascript @codemirror/state
Update your Editor
component to include the following:
// src/routes/Editor.svelte
<script lang="ts">
import { basicSetup, EditorView } from 'codemirror';
import { EditorState } from '@codemirror/state';
import { javascript } from '@codemirror/lang-javascript';
import { createEventDispatcher, onMount } from 'svelte';
export let doc: string;
let container: HTMLDivElement;
let view: EditorView;
const dispatch = createEventDispatcher();
onMount(() => {
view = new EditorView({
state: EditorState.create({
doc,
extensions: [basicSetup, javascript()]
}),
parent: container,
dispatch: async (transaction) => {
view.update([transaction]);
if (transaction.docChanged) {
dispatch('change', transaction.newDoc.toString());
}
}
});
() => {
view.destroy();
};
});
</script>
<div class="bg-white rounded shadow-sm h-full w-full">
<div bind:this={container} />
</div>
In this process, we create a new CodeMirror editor and initialize the document with JavaScript features when the component mounts. The initial code is passed as a property and change events are dispatched every time the user interacts with the editor.
But, if you were to type anything into the editor now, our test process wouldn't update. We need to write the changes to the filesystem within the container. Once this is done, Vitest will detect the changes and re-run the tests.
// src/routes/+page.svelte
<script lang="ts">
// ...
function handleChange(e: { detail: string }) {
if (!webcontainer) return;
webcontainer.fs.writeFile('main.js', e.detail);
}
// ...
</script>
<div class="flex flex-row items-stretch h-screen gap-8 p-8">
<div class="w-1/2"><Tutorial content={data.tutorialMd} /></div>
<div class="w-1/2 flex flex-col gap-8">
<div class="h-1/2">
<Editor doc={data.fileSystem['main.js'].file.contents} on:change={handleChange} />
</div>
<div class="h-1/2">
<Output />
</div>
</div>
</div>
Now, our tests are re-run whenever we change the code.
XTerm
We want to avoid requiring users to open the developer tools to see the output. Instead, let's use XTerm, a browser-based tool, that enables us to display a terminal.
npm install xterm
Create a new file named src/lib/client/terminal.ts
. This file should contain the following code, which creates a new terminal instance in the browser.
// src/lib/client/terminal.ts
import { browser } from '$app/environment';
import type { Terminal } from 'xterm';
export let loaded = false;
export let terminal: Promise<Terminal> = new Promise(() => {});
async function load() {
terminal = new Promise((resolve) => {
import('xterm').then(({ Terminal }) => {
loaded = true;
resolve(
new Terminal({
convertEol: true,
fontSize: 16,
theme: {
foreground: '#000',
background: '#fff'
}
})
);
});
});
}
if (browser && !loaded) {
load();
}
Let's display our terminal within the Output
component:
// src/routes/Output.svelte
<script lang="ts">
import { terminal } from '$lib/client/terminal';
import { onMount } from 'svelte';
import 'xterm/css/xterm.css';
let container: HTMLDivElement;
onMount(async () => {
(await terminal).open(container);
});
</script>
<div class="bg-white rounded shadow-sm h-full w-full">
<div class="p-8" bind:this={container} />
</div>
Finally, direct our output to the terminal rather than the console.
// src/routes/+page.svelte
async function startTestProcess() {
webcontainer = await loadWebcontainer(data.fileSystem);
const term = await terminal;
const testProcess = await webcontainer.spawn("npm", ["test"]);
testProcess.output.pipeTo(
new WritableStream({
write(data) {
term.write(data);
},
})
);
}
And that's everything needed to display terminal output within the browser.
Conclusion
I hope this tutorial has provided a helpful guide on creating interactive experiences for your users. But there's more that can be done:
- File tree navigation for multi-file examples
- Web outputs for UI frameworks
- Code-highlighting within the tutorial
- And much more
If you want to achieve something similar for your product without the tedious task of building a production-ready version, consider the Interactive-Tutorial-as-a-Service product by DocLabs.
See you soon 👋
Posted on November 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.