One of the most important elements in a developer blog or technical
documentation is the code playground. Also known as a “sandbox”,
this widget lets you edit a code snippet and instantly see the
results. Here's the playground I use on this blog:
Recently, I started thinking about writing blog posts on TypeScript
and UI development. That raised an important question: how should I
showcase code examples? In my previous Neovim posts, I used the
Astro code component, which provides syntax highlighting and great
theming. But this time, I wanted to go further. Instead of just
displaying static snippets, I wanted the code to run live and give
readers the ability to experiment with it directly.
#
My requirements
- 1. An intuitive code editor
- 2. It must be interactive
- 3. It needs to support TypeScript
- 4. No third party service
- 5. Capture and display console output
#
The right editor
First I had to find a good editor the user could interact with, I
had two candidates in mind, the Monaco Editor and CodeMirror. First one does also power vscode and is definitly a greate editor
but it comes with a much larger footprint and many features i dont't
really need like a builtin lsp client. So I decided to go with
CodeMirror for now.
#
Making it interactive
Now that i had a great editor, the next challenge was figuing out
how to process the code so that the user can see changes live. I
solved this using an iframe, which is essentially an HTML element
that lets you embed another HTML document inside your page. By
combining the JavaScript code, the HTML and the CSS into a single
string and passing it to the iframe srcdoc attribute, the browser treats it as a fully self-contained document.
This means the iframe has its own execution context, so the code inside
it runs in isolation from the parent page. It also allows you to safely
run arbitrary JavaScript and apply styles without affecting the rest
of your site, all without linking to an external HTML file.
#
Transpiling TypeScript
Since I plan to write most of the snippets in TypeScript, I needed a
way to transpile TypeScript into JavaScript directly in the browser.
Luckily there is esbuild, esbuild is an extremly fast modern bundler written in go that
also supports typescript and jsx out of the box. Esbuild is also
shipped as a wasm (WebAssembly) binary that we can execute directly in the browser. One
small caveat is that esbuild does not do any type checking and type declarations
are parsed and ignored, but it does support TypeScript-only syntax extensions
like enums and converts them to JavaScript. More details can be found
here.
One other nice thing about esbuild is that it can bundle code. This
means we can use esm export and import syntax to define modules in separate files for better structure. The
only thing we need to do is define how esbuild should resolve and load
referenced files. This can be achieved by defining a small custom plugin
that resolved and loads files directly from memory, instead of the filesystem.
With this setup we can now transpile and bundle all of our tyepscript
files and utilize all typescript features.
#
Capturing console output
Now we are able to write TypeScript, bundle it and run it
together with our HTML and CSS in the iframe. The last missing
piece is capturing the console output. We achieve this by
injecting a small snippet into the iframe script that
monkey-patches the original console.log, console.error, and console.warn functions and then sends
their content back to the parent application via postMessage.
const parent = window.parent;
['log','error','warn'].forEach(fn => {
const orig = console[fn];
console[fn] = (...args) => {
orig(...args);
parent.postMessage({ type: 'console', level: fn, sandboxId: '${sandboxId}', args }, '*');
};
});
#
Wrapping up
With these pieces in place, we now have a solid and flexible
foundation. There are still thinks I’d like to improve in the
future, adding React support, enabling the use of additional npm
dependencies, and further refining the editor experience, but
for now, this setup provides a strong starting point.