Codepen in a URL

2021-02-22

OR: Data URLs are neat

Data URLs are cool - embed things directly, cut out a round-trip for images etc, all very nice things. You've probably seen them tucked in a stylesheet somewhere, e.g.:

.foo {
  background-image(url("data:..."))
}

Instead of fetching an image resource, the data URL embeds the image data into the CSS/HTML response, saving a round trip to the server. Nice!

data:text/html is really cool (although troublesome for security). With a properly encoded URL you can share an entire website, JS and all, without requiring a single server anywhere. Better yet, they ~can~ could be shared as links! Need an OCR text parser? Want an IDE? Want to make some kind of weird HTML exquisite corpse? Here, have a link!

<a href="data:text/html,%3Ch1%3EHello%20world!%3C%2Fh1%3E">Click here!</a>

So cool!! They were "distributed apps" before they involved sucking up more energy than Argentina because, uh, censorship or something and absolutely not completely predictable tulip mania that the human race is doomed to repeat every few decades.

I can point exactly to the high-water mark: a long time ago, someone shared a data-url link that rendered three-panel interface very similar to Codepen's well-known editor. The HTML looked something like this:

<!-- SIMPLIFIED EXAMPLE -->
<style>
  style[contenteditable],
  script[contenteditable],
  div[contenteditable] {
    display: inline-block;
    width: 33.3%;
    height: 33.3%;
  }
</style>
<style contenteditable></style>
<script contenteditable></script>
<div contenteditable></div>
<div id="main"></div>
<script>
  document
    .querySelector("div[contenteditable]")
    .addEventListener(
      "blur",
      () => (document.querySelector("#main").innerHTML = e.target.innerHTML)
    )
</script>

Unfortunately the origins of this little trick were lost to the sands of time, so I don't know which direction to tip my hat in.

This was head-sploding for a few reasons; one, the sheer elegance was staggering. It had never occured to me to make a style or script block visible, much less editable, much less that an editable block of those types would immediately be re-evaluated by the browser. I mean, c'mon. Chef's kiss.

The cleverness stuck with me. In a fit of boredom, I tried to recreate it this weekend. Once I had the simple version working, I decided to make some improvements:

With a few days tinkering, my masterpiece was complete. Now the sad part: due to the aforementioned (inevitable) security issues, the html flavor of data URLs are largely neutered; odds are, you'll have to copy the link location of the above and manually open it in a new tab. This is all for good reason (e.g. spammers encoding fake Google login portals in email links), but does suck a little fun out of things. It also won't store state on Firefox, sorry.

That aside, let's talk about how we achieved the above! Here's the source of the above, unmangled:

<!DOCTYPE html>
<html>
  <body>
    <style>
      html,
      body {
        height: 100%;
        margin: 0;
        width: 100%;
      }

      div[contenteditable] {
        border: 1px solid #000;
        box-sizing: border-box;
        display: inline-block;
        height: 33%;
        font-family: monospace;
        overflow: auto;
        position: relative;
        width: 33.3333%;
        white-space: pre-wrap;
      }

      div[contenteditable]::after {
        bottom: 3px;
        color: grey;
        position: absolute;
        right: 3px;
      }

      div:nth-of-type(1)[contenteditable]::after {
        content: "html";
      }

      div:nth-of-type(2)[contenteditable]::after {
        content: "script";
      }

      div:nth-of-type(3)[contenteditable]::after {
        content: "style";
      }

      iframe {
        height: 66%;
        width: 100%;
      }
    </style>
    <div contenteditable></div>
    <div contenteditable></div>
    <div contenteditable></div>
    <iframe></iframe>
    <script>
      let fromUrl
      if (window.location.hash != "") {
        fromUrl = atob(window.location.hash.slice(1)).split(",")
        document
          .querySelectorAll("div[contenteditable]")
          .forEach(
            (i, ind) => (i.textContent = decodeURIComponent(fromUrl[ind]))
          )
        updateIframe()
      }
      window.addEventListener("blur", updateIframe, true)

      function updateIframe() {
        document.querySelector("iframe").src =
          "data:text/html," +
          Array.from(document.querySelectorAll("div[contenteditable]"))
            .map(i => {
              const tagName = window
                .getComputedStyle(i, ":after")
                .content.replace(/"/g, "")
              return encodeURIComponent(
                tagName != "none"
                  ? `<${tagName}>${i.textContent}</${tagName}>`
                  : i.textContent
              )
            })
            .join("\n")
        window.history.pushState(
          "",
          "",
          document.location.href.split("#")[0] +
            "#" +
            btoa(
              Array.from(document.querySelectorAll("div[contenteditable]"))
                .map(i => encodeURIComponent(i.textContent))
                .join()
            )
        )
      }
    </script>
  </body>
</html>

Let's cut this elephant up one piece at a time. We'll start with the CSS:

/* Fit the <body> to the viewport, reset margins. */
html,
body {
  height: 100%;
  margin: 0;
  width: 100%;
}

/* Mostly obvious stuff; took awhile to figure out the pre-wrap thing. */
div[contenteditable] {
  border: 1px solid #000;
  box-sizing: border-box;
  display: inline-block;
  height: 33%;
  font-family: monospace;
  overflow: auto;
  position: relative;
  width: 33.3333%;
  white-space: pre-wrap;
}

/* Setting up our labels. */
div[contenteditable]::after {
  bottom: 3px;
  color: grey;
  position: absolute;
  right: 3px;
}

/* I used the names of the blocks as an.. "optimization". Probably not worth it. */
div:nth-of-type(1)[contenteditable]::after {
  content: "html";
}

div:nth-of-type(2)[contenteditable]::after {
  content: "script";
}

div:nth-of-type(3)[contenteditable]::after {
  content: "style";
}

/* Fill the rest with an iframe. */
iframe {
  height: 66%;
  width: 100%;
}

If you can write a shorter version of the above, pop it in the comments. Now, the HTML.

<!-- Our three "panes", labeled via nth-of-type selectors in CSS. -->
<div contenteditable></div>
<div contenteditable></div>
<div contenteditable></div>
<!-- 
An iframe! What's this doing here? I render everything in the panes in the
iframe via... you guessed it, a data url. Originally I did the same 
<style contenteditable> etc trick, but then I decided to use thei iframe for
simplicity (e.g. no global state for JS). Rejected alternatives include IIFE-ing
the JS and monkey-patching window etc and running it in the same context.
-->
<iframe></iframe>

Finally, the JavaScript:

let fromUrl
if (window.location.hash != "") {
  /*
   * The URL is two parts, the editor itself and the state of the editor stored
   * in the # (hash, fragment, etc). The editor itself is the HTML for.. well,
   * all of this. I put it through a minifier and encodeURIComponent, then
   * prepended data:text/html, to it.
   *
   * The editor state is the contents of each pane in order, URI encoded and b64
   * encoded. (#<html chunk>,<js chunk>,<style chunk>).
   *
   * We b64 the hash because... uh.. I think originally I was worried about
   * colliding with the main data URL, but I'm not sure that makes sense.
   *
   * Ah well. Guess my "distributed app" probably burns cycles after all, sorry.
   */
  fromUrl = atob(window.location.hash.slice(1)).split(",")
  document
    .querySelectorAll("div[contenteditable]")
    // Use the index to get the corresponding stored state and write it
    .forEach((i, ind) => (i.textContent = decodeURIComponent(fromUrl[ind])))

  // If we've got initial state, update the iframe so it renders
  updateIframe()
}

/*
 * When you unfocus one of the editables, we update the iframe. Probably could
 * have listened this to the divs directly; my initial ideas included
 * overwriting the entire DOM every change, so those handlers would have gotten
 * lost. We use capture for ~for some reason I don't really remember~ because
 * blur events don't bubble.
 */
window.addEventListener("blur", updateIframe, true)

function updateIframe() {
  // Update the iframe src attribute to be a new data URL
  document.querySelector("iframe").src =
    "data:text/html," +
    Array.from(document.querySelectorAll("div[contenteditable]"))
      .map(i => {
        // Stupid tagname optimization here (how much did this _really_ shave off?)
        const tagName = window
          .getComputedStyle(i, ":after")
          .content.replace(/"/g, "")

        // Create a new <tagName> element and stuff the contents of it's partner in
        return encodeURIComponent(
          tagName != "none"
            ? `<${tagName}>${i.textContent}</${tagName}>`
            : i.textContent
        )
      })
      .join("\n")

  /*
   * Update the URL to the latest state of the editing panes.
   *
   * This was the trickiest part by far, as browsers _really_ don't like it when
   * data URL-based HTML tries to do _anything_ to the URL (with cause). I tried
   * everything in my workaround bag of tricks; creating a <form> and
   * GET-submitting it on the new URL, creating an <a> tag and assigning it the
   * new URL, then .click()-ing it, window.open(), etc.
   *
   * Finally I found a workaround via window.history - Chrome will happily let
   * JS from a data URL pushState/replaceState without complaint. Voila!
   */
  window.history.pushState(
    "",
    "",
    document.location.href.split("#")[0] +
      "#" +
      btoa(
        Array.from(document.querySelectorAll("div[contenteditable]"))
          .map(i => encodeURIComponent(i.textContent))
          .join()
      )
  )
  /*
   * Sadly, this part only works on Chrome. Firefox throws an exotic-looking
   * NS_ERROR_FAILURE message whenever you try any window.history funny
   * business, which seems like the right move to me but does put a damper on my
   * fun.
   */
}

And that's it!