Svelte Zen Garden

with Monaco Editor and Netlify Dev

The CSS Zen Garden era was hugely inspirational to many. I figured I could try making my own with modern technologies.

Demo Up Front

You can edit your CSS and it updates live.

sveltezen

you can also paste in a link to github gist in the editor. if the github gist has a file called zengarden.css it will pull that css and apply it. Once you're happy with what you have, you can send it as a url by appending a path search param, e.g.

Table of Contents

Design Goals

The original project was served on a PHP server, and people who wanted to take part had to host it themselves or PR into the project.

  • I wanted to update it to JAMstack
  • I wanted to use GitHub Gists which are a wonderfully low friction way of sharing code
  • I also wanted to be able to edit live with modern editor experience (where, for example, i can save to localStorage or expand gist URLs) instead of living in browser devtools. Basically I want Monaco.

Svelte CSS in JS

To dynamically render the CSS, i'd have to come up with a strategy to "CSS in JS" in Svelte. At first I tried using the svelte:head tag:

<script>
  let myCSS = 'body { color: red }'
</script>
<svelte:head>
    <style>
    {myCSS}
    </style>
</svelte:head>

But Svelte doesn't work like that - all css is statically compiled, and changing myCSS doesn't update the head component.

So what I ended up with was something like this:

<script>
  import { onMount, onDestroy } from 'svelte'
  import { userCSS } from './store'
  onMount(renderCSS)
  onDestroy(() => {
    var ss = document.getElementById("unique-stylesheet-id")
    ss.innerHTML = '' // not actually sure if i need this
  })
  let _userCSS // $ store syntax buggy
  userCSS.subscribe(v => {
    _userCSS = v
  })
  function renderCSS() {
    var ss = document.getElementById("unique-stylesheet-id");
    if (!ss) return // not rendered yet
    ss.innerHTML = _userCSS
  }
  $: {
    console.log({ _userCSS })
    renderCSS()
  }
</script>
<svelte:head>
  <style id="unique-stylesheet-id"> </style>
</svelte:head>

This means that whatever CSS string I put in userCSS, will be applied to my Zen Garden HTML. Great! Now the hard part.

Monaco Editor

Monaco is what VScode, and CodeSandBox, use for code editing. It's obviously one of the best code editors in the world. It's always been on my want-to-try-list and this is the perfect proejct.

In my eyes, the docs for Monaco aren't very friendly. It took me a bit of digging around to find anything and it doesnt work with rollup and when I initially tried to set it up with webpack, the web workers would fail randomly. They seem stable now but it seems a way heavier build than optimal.

Still, it works! ğŸŽ‰ So here is my "baby's first Monaco Editor" tutorial.

Webpack Setup and rendering the Editor

There is a Parcel setup for Monaco too, but I only tried Webpack with ESM.

Resources:

I went with option 1 in the Readme, configuring webpack.config.js as instructed.

The basic Svelte integration with Monaco looks something like this (improvements welcome!)

<script>
  import { onMount, onDestroy } from 'svelte'
  import * as monaco from 'monaco-editor';
  let editor
  onMount(mountEditor)
  function mountEditor() {
      editor = monaco.editor.create(document.getElementById('monaco-container'), {
        value: [
          'function x() {',
          '\tconsole.log("Hello world!");',
          '}'
        ].join('\n'),
        language: 'css',
      });
  }
  onDestroy(() => {
    if (editor) {
      editor.dispose();
      const model = editor.getModel();
      if (model) model.dispose();
    }
  })
</script>

<style>
  #monaco-container {
    height: 40vh;
    width: 40vw;
    min-width: 600px;
    position: fixed;
    z-index: 999;
    bottom: 0;
    left: 0;
  }
</style>
<div id="monaco-container"></div>

Monaco-Svelte Data Flow

We need to tell Monaco to update Svelte, and more trickily, tell Svelte to update Monaco.

<script>
  import { onMount, onDestroy } from 'svelte'
  import { userCSS } from './store'
  import * as monaco from 'monaco-editor';
  import { pannable } from './pannable.js';
  let editor, modelChangeSub
  let x = 0
  let y = 0
  onMount(mountEditor)
  function mountEditor() {
    setTimeout(() => {
      editor = monaco.editor.create(document.getElementById('monaco-container'), {
        // redacted
      });
      modelChangeSub = editor.getModel().onDidChangeContent(v => {
        userCSS.set(editor.getModel().getValue()) // if Monaco is updated, update Svelte store
      })
    }, 200)
  }
  userCSS.subscribe(v => {
    if (!editor || !editor.getModel()) return
    const curVal = editor.getModel().getValue()
    if (curVal !== v) {
      editor.setValue(v) // if svelte store is updated, update Monaco accordingly
    }
  })
  onDestroy(() => {
    if (editor) {
      editor.dispose();
      const model = editor.getModel();
      if (model) model.dispose();
    }
    if (modelChangeSub) modelChangeSub.dispose() // clean up subscription
  })
</script>
<!-- etc -->

Draggable Editor using a Svelte Action

This was so easy to implement thanks to Svelte's great docs on actions!

Actions are like hooks that can help abstract logic, like dispatching DOM CustomEvents so you can basically invent your own special events as a user of the action. I copied pannable.js completely and implemented it to get a draggable editor:

<script>
  // etc
  import { pannable } from './pannable.js';
  let x = 0
  let y = 0
  function handlePanMove(event) {
    x = x + event.detail.dx
    y = y + event.detail.dy
  }
</script>
<div use:pannable
  on:panmove={handlePanMove}
  style="transform:
  translate({x}px,{y}px)"
  id="monaco-container"
>
</div>

And there you have a draggable element!!! amazing!!!

On/Off Toggle

I also added a toggle, becaue I don't want the editor on screen at all times.

<script>
  // etc
  let showEditor = true
  function toggleEditor() {
    showEditor = !showEditor
    if (showEditor) mountEditor()
  }
</script>
<style>
  #editorWithButton {
    height: 40vh;
    width: 40vw;
    min-width: 600px;
    position: fixed;
    z-index: 999;
    bottom: 0;
    left: 0;
  }
  #monaco-container {
    height: 100%;
    width: 100%;
  }
  #handButton {
    position: fixed;
    width: 60px;
    height: 60px;
    z-index: 9999;
    top: -30px;
    right: -30px;
    background-color: #0C9;
    border-radius: 50px;
    text-align: center;
    box-shadow: 2px 2px 3px #999;
    font-size: 3rem;
  }
</style>
<div use:pannable
  on:panmove={handlePanMove}
  style="transform:
  translate({x}px,{y}px)"
  id="editorWithButton"
>
  <button id="handButton" on:click={toggleEditor}>✍️</button>
  {#if showEditor}
  <div id="monaco-container"></div>
  {/if}
</div>

Gist API and Netlify Functions

The backend section of this is in the store. I've written about my love for Svelte Stores before.

Frankly I'm not super proud of the code I wrote here but basically I wrote a writable store that persists to localStorage. You can also intercept value updates but be careful not to make an infinite loop:

// store.js
import { writable } from 'svelte/store';

let _userCSS = localStorage.getItem('userCSS');
_userCSS = _userCSS ? JSON.parse(_userCSS) : defaultUserCSS();
export const userCSS = writable(_userCSS);

// special hacks to respond
userCSS.subscribe((value) => {
  // secret hack - to restore default css - type 'default'
  if (value === 'default') userCSS.set(defaultUserCSS());
  if (value) localStorage.setItem('userCSS', JSON.stringify(value));
});

// defaultUserCSS is a getter function that gets a css string

So far so good. The more interesting feature I want is to be able to paste in a path to a gist, and have it resolve to CSS.

The code below works in the browser console, but if you try to do this from the clientside, you get CORS errors:

fetch('https://api.github.com/gists/0e1d14276ef9d2608453fed3c7dfa4ec')
// CORS Error
  .then(results => {
    return results.json();
  })
  .then(data => {
    console.log(data.files);
  });

Fortunately I already have advice on Solving CORS once and for all!

Getting around CORS with Netlify Functions

The rough idea is that you never get CORS if you ping an api on your own domain, and once you're in Node, you're free to ping any other API domain. So you should set up a little proxy serverless function to do this.

This is the same tech underlying the fun little http://is-this.netlify.com/ utility I wrote for Netlifiers :)

const fetch = require('node-fetch');
exports.handler = async function(event, context) {
  let path = event.queryStringParameters.path || null;
  const segments = path.split('/');
  // loosely rewrite the gist URL to the API form
  // this means people who self host their own css can do so if they respond with the right structure
  if (segments[2] === 'gist.github.com') {
    path = `https://api.github.com/gists/${segments[4]}`;
  }
  try {
    const server = await fetch(path).then(async (res) => {
      const data = await res.json();
      return data;
    });
    return {
      statusCode: 200,
      body: JSON.stringify(server)
    };
  } catch (err) {
    console.log(err); // output to netlify function log
    return {
      statusCode: 500,
      body: JSON.stringify({ msg: err.message }) // Could be a custom message
    };
  }
};

However, this Netlify Function would only work once you deploy to Netlify.

You can use Netlify Dev to run local emulation of Netlify to develop locally:

# netlify .toml
[build]
  functions="functions"
  command="yarn build"

[dev]
  command = "yarn start" # Command to start your dev server
  targetPort=8080     # svelte webpack template's port
  publish = "public"

Again, you can see all this in action here https://github.com/sw-yx/svelte-zen-garden.

Future?

We could add a "save to Gist" feature.

We could add ability to pull image, font, and other assets from Gist.

We could add some sort of leaderboard or dynamic link list of other people who have submitted their zen gardens

If you want to work on this lmk!


Webmentions

Failed to load...