Embedding Vega charts in Ghost posts

Since I'm writing a lot about Vega in this blog these days, I'd like to find an easy way to share charts.

Up till now I'd been doing one of these:

  • From the Vega Playground, I export and download an SVG. Then I drag and drop it into my blog post to upload it. This is good because the chart is in SVG, so it scales. The drawback is that the reader can't open the code in the Playground unless I also add a direct link to it. Something like: "Try it in the playground."
  • Take a screenshot of the Vega Playground to the clipboard (Cmd+Shift 4, Drag to select the area, Ctrl and release the tap, then Cmd+V in the blog post to paste it). This is nice because I can show parts of the code at the same time in the blog post. The drawback is this is not a vector graphic anymore, it may not scale well.

What I'd like to do instead is have a way to easily share a chart so that it displays the chart in the blog post, but also shows the link so that the reader can open it.

Vega-embed

It looks like this project is just what I need. As shown in their demo, a simple HTML snippet such as this:

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
</head>

<body>  
  <div id="vis"></div>
  
  <script>
    const spec = "bar.vl.json";
  	vegaEmbed("#vis", spec)
    	// result.view provides access to the Vega View API
      .then(result => console.log(result))
      .catch(console.warn);
  </script>
</body>

and the graph definition stored in bar.vl.json will render the chart and a nice dropdown to open the Playground or export the image:

This is great. However, I don't want to have to save the JSON as a GitHub Gist or upload it to Ghost. Does Ghost even support uploads besides images?

I really like the Playground's export by link functionality, which serializes the graph configuration (JSON) into a compressed string.

Share Via URL: We pack the Vega specification as an encoded string in the URL. We use a LZ-based compression algorithm. When whitespaces are not preserved, the editor will automatically format the specification when it is loaded.

In short, I'd love it if I could simply write:

<div id="vis"></div>
<script>
vegaEmbed('#vis', 'https://vega.github.io/editor/#/url/vega/N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAXyA........');
</script>

Or even better, without having to even create a <div id="vis"></div> container:

<script>
showVegaEmbed('N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAXyA........');
</script>

Or making up my own HTML element:

<vega-embed src='N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAXyA........'></vega-embed>

The thing is vegaEmbed does not support LZ compressed string by default. When I tried in a test file, I got this error:

SyntaxError: Unexpected token < in JSON at position 0
    at JSON.parse (<anonymous>)
    at Jn (vega-embed@6:21)

Which means that it tried to deserialize my string as if it were JSON. Searching vega-embed's code reveals that it uses the vega-loader library to load the chart configuration. And this loader supports only an URL string to a JSON file, or a JSON string. See vega-loader's repository.

Vega-editor

Another idea I had was to look at the code for the Playground. Perhaps one of the export buttons is actually a direct link to the Vega server, which renders the image?

After a bit of poking around, I found out the Playground's project name is vega-editor, and I found how the "Open SVG" button works. See the code here and here. It relies on vega-view, yet another internal library, to export the SVG. Then it creates a blob that contains the SVG data, then opens a window with the uri to that blob. See vega-view's doc. Long story short, it doesn't provide the function I was hoping to find.

Writing a wrapper for Vega-embed

My best bet would be to write my own wrapper that takes the compressed JSON string, and passes the JSON to vega-embed.

Proof of concept

vega-editor uses lz-string (see the dependency). The library is accessible at https://raw.githubusercontent.com/pieroxy/lz-string/master/libs/lz-string.min.js.

Let's try some raw HTML code:

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
  <script src="https://raw.githubusercontent.com/pieroxy/lz-string/master/libs/lz-string.min.js"></script>
</head>

<body>  
  <div id="vis"></div>
  
  <script>
    const spec = LZString.decompress("N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhCY6AGzhoQAGTZJxEAATIAJnC0StAQSSktOOACctupAE9pIAO41d9NAIAMXmfBpkWGgALD4yOEi6ujTiZJ4ydpgoqADaoOJICEromGxJ8moaEABC9gDCbDFOmFZFAGZsVghoabL2lspIZGRWfJhKMj1sDDhMjqkgUJXiAMr2CExs8iAAujJ1NHDyuhAtIJnD4pgAqhC6qzJseHsQDM1r+7sTufnGCIcnZ6sAvg8MVst0FhcPgiLQIJhqHRGEwGBBrFMjnAjpQpggCEhMOIABwAZgATAQAJxwKD4qACABs+KJUCJAEYmF4GV5ycFdAB2IlRDn43ReXG4gQEWrOAheOpQbHYjnYuBMDkCARwYLY-HYuq6bH0pACXH05VEqlMYL03FMOrBKBwekEek+LwAUnxAnxXnkmLgEOdrq8plIPrd8nUmhEYkk3yk6Uy2VkSCYiicDSamLQoAiVnhaZAiVjuZA30jslqmmTzVSoEw7VjciyDHEdCTm22ynzMnrdCeKRA9jgSCsTjs9jYdV7-Yujz2ua8g899J+UbaHXQXR6fQGICGIzGU89M4Sc6cUxicwWSwnGy2Oz2B3rn3ODyuXZAt3uMiQz5eSHkbw+p10pQVFUKyRpW1bKMGbAANYjEmVhsOWsh5N+hSaIB0xOFBcDjJM0ynosywyMQ34MF6exfj+7x3v+F7NteEzHrM8wEROH57IxMw0AAXkoIGLlWy4vkkUBQU4W6jOM3bTrRV7KBRv7UWc6FVO+n7IZRf5nPuSGvFRRz-vODxiFYmDZpeLboBx3Ebo0+gDiu0DItEsQFr87YAsowJ4IQBDgpCtD0MwcIIhI-QomiGJYnihIkmSFLUrSDJMiybKctynJ8gKQoikgYoSlKMpygqSoqmqGpajqeoGnARqUiaZoWlaNp2g6gbup63oum6-pwG1wZFGGEgFkZUDfmRFYgBkWTKAAHtU4E5DQ00yCWZCxq47gwIOCFIFUqCgOZ9GtIkyRxgmG6Ha2e4FouJ1yfGibrHRV39POvyFouU2xo4MgCbG8gxH2A4rRoa2eXAARBDIDbWmgNSkTIPHwXDVgIzmO17aAd05A9F3PTk6kKfpZxvaBk0xsoUzBsDS6xrZMTfk4q2xqN-RkI0P3o8gmM5pip1JOdTayZZeHMeeMjGaZ+0gLZ1itl61riM5cRPcLuEntZThXMor6uW5+wzeNrTQGNs1a1YmxHMoizYAhN2gCbiboJzjSW1LICKHUpm-QosaoVoxHyKRWgxFoxwzAAIsNMiKGtStdgdNDyICuHU9UvvKEBkiXBbyLuxbgSmfryBWFBCe08ovRQN7IB1PB5ZY3z92C0WyJTPo2Z53L0tzdLjuxnNqsWbz-QzkWM34tm-dm0P5zoLmb0yOMDujU7PZC8P8l6feY9L5Pfer99G9zzp35E-ei+10ngIr6bItp7PlOi2eyyFoWIFAA");
  	vegaEmbed("#vis", spec)
    	// result.view provides access to the Vega View API
      .then(result => console.log(result))
      .catch(console.warn);
  </script>
</body>

But the Developer console showed this error:

Cross-Origin Read Blocking (CORB) blocked cross-origin response https://raw.githubusercontent.com/pieroxy/lz-string/master/libs/lz-string.min.js with MIME type text/plain. See https://www.chromestatus.com/feature/5629709824032768 for more details.

Let's find a proper CDN for the script. I found this one: https://cdnjs.com/libraries/lz-string.

Now this error pops up:

Fetch API cannot load file:///Users/[username]/Desktop/%C2%80%C2%80%C2%80%C2%80%C2%80%C2%80%C2%80%C2%80@. URL scheme "file" is not supported.

It still thought that my spec was an URL instead of a JSON string. When I console.log(spec), it outputs gibberish. Checking the editor's own decompression, I found out that they use another method instead (see the code): LZString.decompressFromEncodedURIComponent.

Now it deserializes the JSON configuration correctly, but fails to load the JSON file containing the data. I want it to load file:///Users/[username]/Desktop/%7B%22$schema%22:%22https://vega.github.io/schema/vega/v5.json%22,%22title%22:%22.... I realized that I had misread the vega-embed spec. It states for the spec parameter:

String : A URL string from which to load the Vega specification. This URL will be subject to standard browser security restrictions. Typically this URL will point to a file on the same host and port number as the web page itself.
Object : The Vega/Vega-Lite specification as a parsed JSON object.

I should pass the deserialized JSON, not the JSON string! And now it finally works.

Screenshot of the page in action

And here it is as an HTML snippet, hosted in this blog post. Hopefully it doesn't break in the future and you get to see it:

To summarize, the code is:

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.4.4/lz-string.min.js"></script>
</head>

<body>  
  <div id="vis"></div>
  
  <script>
    const spec = JSON.parse(LZString.decompressFromEncodedURIComponent("N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhCY6AGzhoQAGTZJxEAATIAJnC0StAQSSktOOACctupAE9pIAO41d9NAIAMXmfBpkWGgALD4yOEi6ujTiZJ4ydpgoqADaoOJICEromGxJ8moaEABC9gDCbDFOmFZFAGZsVghoabL2lspIZGRWfJhKMj1sDDhMjqkgUJXiAMr2CExs8iAAujJ1NHDyuhAtIJnD4pgAqhC6qzJseHsQDM1r+7sTufnGCIcnZ6sAvg8MVst0FhcPgiLQIJhqHRGEwGBBrFMjnAjpQpggCEhMOIABwAZgATAQAJxwKD4qACABs+KJUCJAEYmF4GV5ycFdAB2IlRDn43ReXG4gQEWrOAheOpQbHYjnYuBMDkCARwYLY-HYuq6bH0pACXH05VEqlMYL03FMOrBKBwekEek+LwAUnxAnxXnkmLgEOdrq8plIPrd8nUmhEYkk3yk6Uy2VkSCYiicDSamLQoAiVnhaZAiVjuZA30jslqmmTzVSoEw7VjciyDHEdCTm22ynzMnrdCeKRA9jgSCsTjs9jYdV7-Yujz2ua8g899J+UbaHXQXR6fQGICGIzGU89M4Sc6cUxicwWSwnGy2Oz2B3rn3ODyuXZAt3uMiQz5eSHkbw+p10pQVFUKyRpW1bKMGbAANYjEmVhsOWsh5N+hSaIB0xOFBcDjJM0ynosywyMQ34MF6exfj+7x3v+F7NteEzHrM8wEROH57IxMw0AAXkoIGLlWy4vkkUBQU4W6jOM3bTrRV7KBRv7UWc6FVO+n7IZRf5nPuSGvFRRz-vODxiFYmDZpeLboBx3Ebo0+gDiu0DItEsQFr87YAsowJ4IQBDgpCtD0MwcIIhI-QomiGJYnihIkmSFLUrSDJMiybKctynJ8gKQoikgYoSlKMpygqSoqmqGpajqeoGnARqUiaZoWlaNp2g6gbup63oum6-pwG1wZFGGEgFkZUDfmRFYgBkWTKAAHtU4E5DQ00yCWZCxq47gwIOCFIFUqCgOZ9GtIkyRxgmG6Ha2e4FouJ1yfGibrHRV39POvyFouU2xo4MgCbG8gxH2A4rRoa2eXAARBDIDbWmgNSkTIPHwXDVgIzmO17aAd05A9F3PTk6kKfpZxvaBk0xsoUzBsDS6xrZMTfk4q2xqN-RkI0P3o8gmM5pip1JOdTayZZeHMeeMjGaZ+0gLZ1itl61riM5cRPcLuEntZThXMor6uW5+wzeNrTQGNs1a1YmxHMoizYAhN2gCbiboJzjSW1LICKHUpm-QosaoVoxHyKRWgxFoxwzAAIsNMiKGtStdgdNDyICuHU9UvvKEBkiXBbyLuxbgSmfryBWFBCe08ovRQN7IB1PB5ZY3z92C0WyJTPo2Z53L0tzdLjuxnNqsWbz-QzkWM34tm-dm0P5zoLmb0yOMDujU7PZC8P8l6feY9L5Pfer99G9zzp35E-ei+10ngIr6bItp7PlOi2eyyFoWIFAA"));
    vegaEmbed("#vis", spec)
    	// result.view provides access to the Vega View API
      .then(result => console.log(result))
      .catch(console.warn);
  </script>
</body>

Turning it into a Web Component

Hardcoded data

Web Components were popularized by the now near-defunct Polymer. Now that they've become an actual standard, we can create custom Web Components without Polymer. Let's take a look at https://developer.mozilla.org/en-US/docs/Web/Web_Components/Using_custom_elements.

I used their tooltip script, took out the unneeded parts, and ended up with:

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.4.4/lz-string.min.js"></script>
  <script>
// Create a class for the element
class VegaEmbed extends HTMLElement {
  constructor() {
    // Always call super first in constructor
    super();

    // Create a shadow root
    const shadow = this.attachShadow({mode: 'open'});

    // Create div
    const divWrapper = document.createElement('div');

    // Attach the created elements to the shadow dom
    shadow.appendChild(divWrapper);

    // Load the chart
    const spec = JSON.parse(LZString.decompressFromEncodedURIComponent("N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhCY6AGzhoQAGTZJxEAATIAJnC0StAQSSktOOACctupAE9pIAO41d9NAIAMXmfBpkWGgALD4yOEi6ujTiZJ4ydpgoqADaoOJICEromGxJ8moaEABC9gDCbDFOmFZFAGZsVghoabL2lspIZGRWfJhKMj1sDDhMjqkgUJXiAMr2CExs8iAAujJ1NHDyuhAtIJnD4pgAqhC6qzJseHsQDM1r+7sTufnGCIcnZ6sAvg8MVst0FhcPgiLQIJhqHRGEwGBBrFMjnAjpQpggCEhMOIABwAZgATAQAJxwKD4qACABs+KJUCJAEYmF4GV5ycFdAB2IlRDn43ReXG4gQEWrOAheOpQbHYjnYuBMDkCARwYLY-HYuq6bH0pACXH05VEqlMYL03FMOrBKBwekEek+LwAUnxAnxXnkmLgEOdrq8plIPrd8nUmhEYkk3yk6Uy2VkSCYiicDSamLQoAiVnhaZAiVjuZA30jslqmmTzVSoEw7VjciyDHEdCTm22ynzMnrdCeKRA9jgSCsTjs9jYdV7-Yujz2ua8g899J+UbaHXQXR6fQGICGIzGU89M4Sc6cUxicwWSwnGy2Oz2B3rn3ODyuXZAt3uMiQz5eSHkbw+p10pQVFUKyRpW1bKMGbAANYjEmVhsOWsh5N+hSaIB0xOFBcDjJM0ynosywyMQ34MF6exfj+7x3v+F7NteEzHrM8wEROH57IxMw0AAXkoIGLlWy4vkkUBQU4W6jOM3bTrRV7KBRv7UWc6FVO+n7IZRf5nPuSGvFRRz-vODxiFYmDZpeLboBx3Ebo0+gDiu0DItEsQFr87YAsowJ4IQBDgpCtD0MwcIIhI-QomiGJYnihIkmSFLUrSDJMiybKctynJ8gKQoikgYoSlKMpygqSoqmqGpajqeoGnARqUiaZoWlaNp2g6gbup63oum6-pwG1wZFGGEgFkZUDfmRFYgBkWTKAAHtU4E5DQ00yCWZCxq47gwIOCFIFUqCgOZ9GtIkyRxgmG6Ha2e4FouJ1yfGibrHRV39POvyFouU2xo4MgCbG8gxH2A4rRoa2eXAARBDIDbWmgNSkTIPHwXDVgIzmO17aAd05A9F3PTk6kKfpZxvaBk0xsoUzBsDS6xrZMTfk4q2xqN-RkI0P3o8gmM5pip1JOdTayZZeHMeeMjGaZ+0gLZ1itl61riM5cRPcLuEntZThXMor6uW5+wzeNrTQGNs1a1YmxHMoizYAhN2gCbiboJzjSW1LICKHUpm-QosaoVoxHyKRWgxFoxwzAAIsNMiKGtStdgdNDyICuHU9UvvKEBkiXBbyLuxbgSmfryBWFBCe08ovRQN7IB1PB5ZY3z92C0WyJTPo2Z53L0tzdLjuxnNqsWbz-QzkWM34tm-dm0P5zoLmb0yOMDujU7PZC8P8l6feY9L5Pfer99G9zzp35E-ei+10ngIr6bItp7PlOi2eyyFoWIFAA"));
  	console.log(spec);
    vegaEmbed(divWrapper, spec)
    	// result.view provides access to the Vega View API
      .then(result => console.log(result))
      .catch(console.warn);
  }
}

// Define the new element
customElements.define('vega-embed', VegaEmbed);
  </script>
</head>

<body>
  <vega-embed></vega-embed>
</body>

And it rendered perfectly, just like the screenshot above!

Parameterize the data

Now let's make the compressed JSON an attribute to the element.

The tooltip example both an attribute and an data- prefixed attribute:

<popup-info img="img/alt.png" data-text="Your card validation code (CVC) is an extra security feature — it is the last 3 or 4 numbers on the  back of your card."></popup-info>

I am not sure if there is a technical difference, or just a semantic meaning - perhaps img is a standard attribute, and text is a made-up one?

Let's try to use src.

...

To my surprise, neither src, data-src or data-text worked. Yet, it really should. In the debugger console, I can get the value:

document.querySelector('vega-embed').getAttribute('data-text');
==> 'bonjour'

Then why does it not work when reading the attribute from within the component?

Notice how the outerHTML does not include the data-text either.

But it does work fine in the Tooltip example.

Tooltip's outerHTML includes all the attributes.

Eventually I found out why. In the tooltip example, the HTML is rendered first, then the Custom Element is registered. The keyword being defer on line 6:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Pop-up info box — web components</title>
    <script src="main.js" defer></script>
  </head>
  <body>
    <h1>Pop-up info widget - web components</h1>
    <form>
      <div>
        <label for="cvc">Enter your CVC <popup-info img="img/alt.png" data-text="Your card validation code (CVC) is an extra security feature — it is the last 3 or 4 numbers on the back of your card."></popup-info></label>
        <input type="text" id="cvc">
      </div>
    </form>
  </body>
</html>

So I put my <script> to the bottom of my HTML file and finally, it worked. Yay! Let's try and render two embeds to make sure it works for multiple instances.

Yep, it works! Here's the code snippet:

<!DOCTYPE html>
<head>
  <meta charset="utf-8">
  <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.4.4/lz-string.min.js"></script>
</head>

<body>
  <vega-embed src="N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhCY6AGzhoQAGTZJxEAATIAJnC0StAQSSktOOACctupAE9pIAO41d9NAIAMXmfBpkWGgALD4yOEi6ujTiZJ4ydpgoqADaoOJICEromGxJ8moaEABC9gDCbDFOmFZFAGZsVghoabL2lspIZGRWfJhKMj1sDDhMjqkgUJXiAMr2CExs8iAAujJ1NHDyuhAtIJnD4pgAqhC6qzJseHsQDM1r+7sTufnGCIcnZ6sAvg8MVst0FhcPgiLQIJhqHRGEwGBBrFMjnAjpQpggCEhMOIABwAZgATAQAJxwKD4qACABs+KJUCJAEYmF4GV5ycFdAB2IlRDn43ReXG4gQEWrOAheOpQbHYjnYuBMDkCARwYLY-HYuq6bH0pACXH05VEqlMYL03FMOrBKBwekEek+LwAUnxAnxXnkmLgEOdrq8plIPrd8nUmhEYkk3yk6Uy2VkSCYiicDSamLQoAiVnhaZAiVjuZA30jslqmmTzVSoEw7VjciyDHEdCTm22ynzMnrdCeKRA9jgSCsTjs9jYdV7-Yujz2ua8g899J+UbaHXQXR6fQGICGIzGU89M4Sc6cUxicwWSwnGy2Oz2B3rn3ODyuXZAt3uMiQz5eSHkbw+p10pQVFUKyRpW1bKMGbAANYjEmVhsOWsh5N+hSaIB0xOFBcDjJM0ynosywyMQ34MF6exfj+7x3v+F7NteEzHrM8wEROH57IxMw0AAXkoIGLlWy4vkkUBQU4W6jOM3bTrRV7KBRv7UWc6FVO+n7IZRf5nPuSGvFRRz-vODxiFYmDZpeLboBx3Ebo0+gDiu0DItEsQFr87YAsowJ4IQBDgpCtD0MwcIIhI-QomiGJYnihIkmSFLUrSDJMiybKctynJ8gKQoikgYoSlKMpygqSoqmqGpajqeoGnARqUiaZoWlaNp2g6gbup63oum6-pwG1wZFGGEgFkZUDfmRFYgBkWTKAAHtU4E5DQ00yCWZCxq47gwIOCFIFUqCgOZ9GtIkyRxgmG6Ha2e4FouJ1yfGibrHRV39POvyFouU2xo4MgCbG8gxH2A4rRoa2eXAARBDIDbWmgNSkTIPHwXDVgIzmO17aAd05A9F3PTk6kKfpZxvaBk0xsoUzBsDS6xrZMTfk4q2xqN-RkI0P3o8gmM5pip1JOdTayZZeHMeeMjGaZ+0gLZ1itl61riM5cRPcLuEntZThXMor6uW5+wzeNrTQGNs1a1YmxHMoizYAhN2gCbiboJzjSW1LICKHUpm-QosaoVoxHyKRWgxFoxwzAAIsNMiKGtStdgdNDyICuHU9UvvKEBkiXBbyLuxbgSmfryBWFBCe08ovRQN7IB1PB5ZY3z92C0WyJTPo2Z53L0tzdLjuxnNqsWbz-QzkWM34tm-dm0P5zoLmb0yOMDujU7PZC8P8l6feY9L5Pfer99G9zzp35E-ei+10ngIr6bItp7PlOi2eyyFoWIFAA"></vega-embed>
  <vega-embed src="N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhCY6AGzhoQAJTjykc0gAIobGuO3E28hgjja2AM23y2ScREuGAgklLSQAdxoATemgCAAxBMvA0ZFhoACwhMjhIvr4GZIEyvpooqADaoOJI5sqYbJhI8gAy9o4AQgCeAML6kjKYAE4OEFZsrQhoubK1OEroSGRkrXyYSjLjbAw4TLV9IHoGAMq1CEwmIAC6MlY06r4QywVz4pgAqhC+ezJseMsQZvcgSKc5K01rNABeSl2AF8pKBMINhiAcEdPIdjspVuJfgDPGJWpg0G0GHAgfsQAxWvJlFhcPgiLQIJhqHRGEwGBA4K09Jc4JdKHoEARNOIABwAZgATAQAJxwKACqACABsAuFUGFAEYmEFFUEJdFfAB2YVJTUC3xBPl8gQEdpeAhBKxQHk8zU8uBMTUCARwaI8gU8qy+HkKpACPkKl3C6VMaIKvlMKzRKBwBUEBUhIIAUgFAgFQQ0U0pKbTQTcpBz6bsHREYkkILyBUhxVKFSqpxa7UcXR6fTBEOUo3Gk2mIDh8hOZwQF2utzej0+2RAL16eI+yxrZUa63+gIrAyGyjgAA8ppdYUcB0USkufqvURF8kT0Iv5MukauAKK71kY3EyAnXjDYPCEAgUqlaHoZh6UZZk9ypDkuUwXlBRFMUJWlWV5SVFUE3VLUdS1fVDWNU0kHNS1rVte1HWdV13U9b1fX9QM4GDKVQ3DSNo1jeNE0LDNNDgbNU3TfM4E44tHFLCQQHfacoDKHi2xAfJCnQPQ7FaTxwU3dBumSK9PCbMhISkqYyG6JZ0jYZADDQUAMlKY9a0qDo6nvA94UUn5Nm2IkZDRDFUFATTGWUXweJjcQtNSA5DzuVyVxRB4cGUGdxKBCTFD00LJ1AQ55C-JTulUhRISch5WiOfd0BKyI3zxZBWgAawyjdISQJlYVaMzLJAayslkE86wchomnEmRWT0IKOtfALfL7Ghso66BpIREw8oilzvnWdydhBEBtzmy8ymUHx-BgbQCG0AUhpAJYpogPav3CSqTrOi7KWazAXHEMhFA6-sounUp0Xez6lC21lfEBr6pp+rdQvB4GHgYKZWhURIaHpXayG09BDvoR7zuS5LQUaoodwxA42t6Kautssp7JqAaLJB8RRuGUAJpUqadshyKOtmeYDr8AItoQBh5B8oJKAELarsy7mpt5+L0HuqIhZFsWJa29pkjR67bv5o7Hr5C76DgGydYx-b0AACmssxKBegGPsUbQAGptBthBKFB2GAEoCDxiLZqm4gymxZQAGIrEji6ykvDrg9MfS2YupgPnUAwWZAePQ-QBA-F8L6tqmXdvtltakQ2ol8fXNTqxJ1r2spzJqb6umnMZ5nxpZdnQE5mWXNAeW9cFmRhdFtBxclmRpem-uQEHxW4AiZWR9V8eJYeSOGR8wMNZR7XQBu82v2x47TsNwv4FNg-dat927f+t7HYsV2769p-ff96bA9ALPIQjqOtoxwxnHEOicu7J1TvIdOICE7KFzkkAuLQ65m0xn2boyBMDW00LbREyILCnVvPePBz4ILZAVPsbQABySgyZKHeySslYEQA"></vega-embed>
</body>

<script>
  // Create a class for the element
  class VegaEmbed extends HTMLElement {
    constructor() {
      // Always call super first in constructor
      super();
  
      // Create a shadow root
      const shadow = this.attachShadow({mode: 'open'});
  
      // Create div
      const divWrapper = document.createElement('div');
    
      // Attach the created elements to the shadow dom
      shadow.appendChild(divWrapper);
  
      // Load the chart
      const src = this.getAttribute('src');
      const spec = JSON.parse(LZString.decompressFromEncodedURIComponent(src));
      vegaEmbed(divWrapper, spec)
        // result.view provides access to the Vega View API
        .then(result => console.log(result))
        .catch(console.warn);
    }
  }
  
  // Define the new element
  customElements.define('vega-embed', VegaEmbed);
</script>

Configuring Ghost

Now all that's left is hosting this custom element code somewhere, and load it in Ghost.

The simplest way is to go to Ghost's Settings, Code injection, Site Footer and paste:

  • the <script> imports of vega and lz-string.
  • the <script> that defines <vega-embed>.

Then in blog posts, I can add an HTML widget with the following code:

<vega-embed src="[compressed JSON string]"></vega-embed>

And it should render correctly. Note that it will only render in view mode, not edit mode.

Unfortunately, when I tried to do that, the widget failed to render. In the markup I saw that Chrome, or most probably Ghost prepended the post URL:

Let's not use the src attribute and use our own. By using another attribute name, compressed-spec, we avoid this issue while making it more meaningful:

<vega-embed compressed-spec="[compressed JSON string]"></vega-embed>

Better imports

I remember that there was a syntax to import custom components as html in the header. This would clean up my code quite a bit. Then the imports to Vega and lz-string and the code for my custom element could be isolated away.

I opened most of what I could find on Web Components on MDN but could not find the syntax. Eventually I found this post, which is exactly what I was looking for. So in my main page, all I theoretically had to do was import the component, then use it like this:

<!doctype html>
<head>
    <link rel="import" href="vega-embed.html">
</head>
<body>
    <vega-embed compressed-spec="..."></vega-embed>
</body>

When I tried it on my page, it didn't work. In the Chrome Developer tools, I didn't even see my vega-embed.html component being downloaded.

vega-embed.html is not being downloaded.

Eventually I found this post, which states that imports were deprecated. And indeed, when I check on caniuse, it says that no browser supports the feature anymore:

Deprecated method of including and reusing HTML documents in other HTML documents. Superseded by ES modules.

So I should use ES modules. The Vega usage documentation does mention the term "module", but does not show usage as ES modules. Rather, they use the old <script src> notation:

<head>
  <script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
  <script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
</head>

And what of lz-string? When I check the source code on CDN, it looks like it supports both types of definitions. A global LZString for the old way of doing things at the beginning, and a module export at the end of the file:

var LZString=function(){function o(o,r){if(!t[o]){t[o]={};[a bunch of code](h=Math.pow(2,m),m++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module&&(module.exports=LZString);

Let's try to use the module feature of LZString to start:

import LZString from "https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.4.4/lz-string.min.js";

When I refreshed my page, Chrome threw this error:

vega-embed-module.js:1 Uncaught SyntaxError: Cannot use import statement outside a module

It turns out that in my HTML page, instead of loading the Javascript like this:

<script src="vega-embed-module.js"></script>

I should load it as a module like this:

<script type="module" src="vega-embed-module.js"></script>

Then I got this error:

Uncaught SyntaxError: The requested module 'https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.4.4/lz-string.min.js' does not provide an export named 'default'

Looking at lz-string's code more closely, I realized that at the end of the script, it does not export LZString, but it module.exports=LZString. Their notation is not the ES Module export notation. It's the CommonJS notation, also used by Node.js. Darn.

I also tried the same with vegaEmbed, and it looks like it's the same problem.

If I really want to use the ES module route, I'd have to use Webpack or a similar tool to load all the JS as npm packages, and bundle it all as an ES module, then use that in my custom component module.

Do I want to mess with it? https://dev.to/underscorecode/javascript-bundlers-an-in-depth-comparative-is-webpack-still-the-best-bundler-in-2021-59jk. No, not really.

In short

The code I added to my Ghost footer config is this:

<!-- vega-embed -->
<script src="https://cdn.jsdelivr.net/npm/vega@5"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-lite@5"></script>
<script src="https://cdn.jsdelivr.net/npm/vega-embed@6"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/lz-string/1.4.4/lz-string.min.js"></script>
<script>
  // Create a class for the element
  class VegaEmbed extends HTMLElement {
    constructor() {
      // Always call super first in constructor
      super();
  
      // Create a shadow root
      const shadow = this.attachShadow({mode: 'open'});
  
      // Create div
      const divWrapper = document.createElement('div');
    
      // Attach the created elements to the shadow dom
      shadow.appendChild(divWrapper);
  
      // Load the chart
      const src = this.getAttribute('compressed-spec');
      const spec = JSON.parse(LZString.decompressFromEncodedURIComponent(src));
      vegaEmbed(divWrapper, spec)
        // result.view provides access to the Vega View API
        .then(result => console.log(result))
        .catch(console.warn);
    }
  }
  
  // Define the new element
  customElements.define('vega-embed', VegaEmbed);
</script>

Then in any blog post, I can add this kind of raw HTML:

<vega-embed compressed-spec="N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhCY6AGzhoQAGTZJxEAATIAJnC0StAQSSktOOACctupAE9pIAO41d9NAIAMXmfBpkWGgALD4yOEi6ujTiZJ4ydpgoqADaoOJICEromGxJ8moaEABC9gDCbDFOmFZFAGZsVghoabL2lspIZGRWfJhKMj1sDDhMjqkgUJXiAMr2CExs8iAAujJ1NHDyuhAtIJnD4pgAqhC6qzJseHsQDM1r+7sTufnGCIcnZ6sAvg8MVst0FhcPgiLQIJhqHRGEwGBBrFMjnAjpQpggCEhMOIABwAZgATAQAJxwKD4qACABs+KJUCJAEYmF4GV5ycFdAB2IlRDn43ReXG4gQEWrOAheOpQbHYjnYuBMDkCARwYLY-HYuq6bH0pACXH05VEqlMYL03FMOrBKBwekEek+LwAUnxAnxXnkmLgEOdrq8plIPrd8nUmhEYkk3yk6Uy2VkSCYiicDSamLQoAiVnhaZAiVjuZA30jslqmmTzVSoEw7VjciyDHEdCTm22ynzMnrdCeKRA9jgSCsTjs9jYdV7-Yujz2ua8g899J+UbaHXQXR6fQGICGIzGU89M4Sc6cUxicwWSwnGy2Oz2B3rn3ODyuXZAt3uMiQz5eSHkbw+p10pQVFUKyRpW1bKMGbAANYjEmVhsOWsh5N+hSaIB0xOFBcDjJM0ynosywyMQ34MF6exfj+7x3v+F7NteEzHrM8wEROH57IxMw0AAXkoIGLlWy4vkkUBQU4W6jOM3bTrRV7KBRv7UWc6FVO+n7IZRf5nPuSGvFRRz-vODxiFYmDZpeLboBx3Ebo0+gDiu0DItEsQFr87YAsowJ4IQBDgpCtD0MwcIIhI-QomiGJYnihIkmSFLUrSDJMiybKctynJ8gKQoikgYoSlKMpygqSoqmqGpajqeoGnARqUiaZoWlaNp2g6gbup63oum6-pwG1wZFGGEgFkZUDfmRFYgBkWTKAAHtU4E5DQ00yCWZCxq47gwIOCFIFUqCgOZ9GtIkyRxgmG6Ha2e4FouJ1yfGibrHRV39POvyFouU2xo4MgCbG8gxH2A4rRoa2eXAARBDIDbWmgNSkTIPHwXDVgIzmO17aAd05A9F3PTk6kKfpZxvaBk0xsoUzBsDS6xrZMTfk4q2xqN-RkI0P3o8gmM5pip1JOdTayZZeHMeeMjGaZ+0gLZ1itl61riM5cRPcLuEntZThXMor6uW5+wzeNrTQGNs1a1YmxHMoizYAhN2gCbiboJzjSW1LICKHUpm-QosaoVoxHyKRWgxFoxwzAAIsNMiKGtStdgdNDyICuHU9UvvKEBkiXBbyLuxbgSmfryBWFBCe08ovRQN7IB1PB5ZY3z92C0WyJTPo2Z53L0tzdLjuxnNqsWbz-QzkWM34tm-dm0P5zoLmb0yOMDujU7PZC8P8l6feY9L5Pfer99G9zzp35E-ei+10ngIr6bItp7PlOi2eyyFoWIFAA"></vega-embed>