4

Using Web Components With 11ty

I took a progressive enhancement approach to using frontend javascript for my blog, Web Components are the perfect fit here.

I've talked about earlier in this series that I wanted to bring things back to basics with this blog, focusing on web fundamentals e.g. html and css. In addition to this, by using 11ty, I'm able to author in Markdown, meaning I'm free to add HTML anywhere in my posts.

However, whist I'm focusing on HTML/CSS, there are areas where it makes sense to sprinkle in JavaScript, for extra interactivity, this is where Web Components come in.

Picture of my fighting cats Chewie and Beau
Hey! He mentioned web components, get him! (Chewie and Beau are friends really)

A Google engineer said it better than I could:

In this article I'll explain how I went about setting up a development environment for Web Components, as well as simple production optimizations.

But first, I want to discuss the approach that I've taken for consuming web components in this site. All content should be available without JavaScript/Web Components available, but where they are available, the content should be progressively enhanced.

Progressive enhancement web component use cases

Here are a couple of uses cases I had for progressively enhanced content, using JavaScript.

YouTube embed

To embed a YouTube video via progressive enhancement, you first need to identify what is the minimal HTML-only implementation of the content, this is:

  • A link which when clicked navigates to the video.
  • An image thumbnail to be used for the link to wrap.
  • A caption for the video, important for accessibility.

The second part of this is identifying a component to use to embed the YouTube player, I wasn't going to re-invent the wheel here.

lite-youtube-embed from Paul Irish, is the perfect fit here.

npm install lite-youtube-embed

<lite-youtube
class="video"
videoid="j8mJrhhdHWc"
style="background-image: url('https://i.ytimg.com/vi/j8mJrhhdHWc/hqdefault.jpg');"
>

<a
onclick="('customElements' in window) && event.preventDefault()"
title="Play Video"
class="no-js"
target="_blank"
href="https://youtube.com?w=j8mJrhhdHWc"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<g transform="translate(-339 -150.484)">
<path fill="var(--White, #fff)" d="M-1978.639,24.261h0a1.555,1.555,0,0,1-1.555-1.551V9.291a1.555,1.555,0,0,1,1.555-1.551,1.527,1.527,0,0,1,.748.2l11.355,6.9a1.538,1.538,0,0,1,.793,1.362,1.526,1.526,0,0,1-.793,1.348l-11.355,6.516A1.52,1.52,0,0,1-1978.639,24.261Z" transform="translate(2329 150.484)"/>
<path fill="var(--Primary, #000)" d="M16.563.563a16,16,0,1,0,16,16A16,16,0,0,0,16.563.563Zm7.465,17.548L12.672,24.627a1.551,1.551,0,0,1-2.3-1.355V9.853a1.552,1.552,0,0,1,2.3-1.355l11.355,6.9A1.553,1.553,0,0,1,24.027,18.111Z" transform="translate(338.438 149.922)" />
</g>
</svg></a
>

</lite-youtube>

There's a couple of things going on above:

  • background-image server from youtube CDN.
  • There is an <a> by default, this will open the youtube video in a new tab
  • onclick to prevent opening a new tab.

Explaining the onclick: Whats happening here is.

  • If Web Components/JavaScript are not available on the site, the onclick is ignored, and links as expected, I do this by checking if customElements is supported in the browser.
  • When JS/Web Components are enabled and the link is clicked, the tab does not open, and the click is instead handled by lite-youtube, resulting in a youtube embed.

Like so:

Live code demos

At some point I will have to do a post that goes into more detail of exactly how my live demos are authored using Markdown in 11ty, but they are ultimately rendered using a web component.

Let's get meta, here is a Live demo web component that renders itself.


<live-demo id="my-live-demo">
<div slot="html">
&lt;div class=&quot;my-div&quot;&gt;styled by the css&lt;/div&gt;
</div>
<div slot="css">
.my-div {
color: var(--Primary, blue);
}
</div>
</div>
live-demo {
width: 400px;
height: 300px;
margin: 3rem;
min-height: auto;
display: flex;
}

The approach I've taken here is that when the web component is not available, the code is just rendered and syntax highlighted, but when JS is available a live demo component appears. If you were to disable JavaScript in your browser you should just see the code snippets instead.

I made use of slots, one for js one for html and one for css. The web component then takes the text content and renders it appropriately.

This approach is a lot like https://open-wc.org/mdjs/, which I hope to use in the future for my blog, but it was fun to see about how I could build this myself.

Setting up a dev environment for 11ty and Web Components

Setting up a development environment for 11ty and web components is pretty simple, especially if are using pure JavaScript, and don't need any build process. I found that having no build process was such a breath of fresh air, development tools should just get out of your way and let you code.

If you are just working with vanilla web components and don't want to use any dependencies from NPM, then good news, you don't need to do anything special, just use the default 11ty dev server, and move on to create great content!

If you want to use some components or libraries from NPM e.g. lit-html/lit-element you will need a way to transform bare imports into relative urls that work in the browser, e.g.

import { LitElement } from "lit";

would become something like:

import { LitElement } from "./../node_modules/lit-element/lit-element.js";

The best tool for doing this is https://www.npmjs.com/package/es-dev-server.

At the time of writing this tool is in the process of getting moved over to @web/dev-server. For this example, i'll use @web/dev-server but es-dev-server would work too.

npm i --save-dev @web/dev-server

First off, when serving an 11ty website you would normally use npx eleventy --serve, however instead we're going to use npx eleventy --watch.
This will give us all the live building of your 11ty site, but without a server.

For our server, this is where @web/dev-server will come in, which can be run like so:

web-dev-server --node-resolve --open

In order to combine these two tasks we can use concurrently

npm i concurrently --save-dev

and combine them into a npm script:

    "start": "concurrently \"npx eleventy --watch\" \"web-dev-server  --node-resolve\"",

Combining the above will give us a dev server, however we have not told it how find our 11ty _site folder, as well as resolving our node modules.
In order to do this we will need to introduce a small config file and implement a simple middleware to do the following:

  • If the request is an 11ty asset serve it from _site by appending _site to url.
  • If the request is for a html page serve it from _site
  • Otherwise move to next() which will allow JS files to be handled by logic to resolve ESM imports.

Create a file call web-dev-server.config.js

module.exports = {
port: 8000,
watch: true,
rootDir: ".",
middleware: [serve11tyAssets({ dist: "_site_" })],
nodeResolve: true,
};

This should all be quite straight forward to understand hopefully:

  • port: Local port for the server
  • watch: Makes browser reload whenever something changes
  • rootDir: This should be the root dir that contains node_modules and the 11ty _site folder.
  • middleware: functions that get executed on requests, i'll explain serve11tyAssets shortly.
  • nodeResolve: flag to convert import foo from 'bar'

serve11tyAssets will look something like this.

const path = require("path");
const fs = require("fs").promises;
const URL = require("url").URL;
/**
*
* Check if asset lives in 11ty _site folder, if not serve from root folder.
*/

const serve11tyAssets = ({ dist = "_site" } = {}) => {
return async (context, next) => {
// Node URL requires a full url so... whatever.com (url isnot important)
const pathName = new URL(`https://whatever.com${context.url}`).pathname;
// is the request for a html file?
const url = pathName.endsWith("/") ? `${pathName}index.html` : pathName;
try {
// check if the file exists, if so, modify the url to come from `_site` folder.
const stats = await fs.stat(path.join(dist, url));
if (stats.isFile()) {
context.url = `/${dist}${pathName}`;
}
return next();
} catch {
return next();
}
};
};

Hopefully this example makes sense, and shows how simple it is to add vanilla JavaScript modules into your 11ty development server.
You can easily add new tools into this chain if you need as well e.g. gulp

    "start": "npx gulp && concurrently \"npx gulp watch\" \"npx eleventy --watch\" \"web-dev-server\""

Production optimization of JavaScript

When it comes to choosing tools to optimize your JavaScript for an 11ty project, the choice is entirely up to you, if like me you don't want to configure a complex build, you can leverage the great work of others, by using Open WC rollup config.

Here is my config.

npm i rollup deepmerge rollup-plugin-output-manifest @open-wc/building-rollup -D
import merge from "deepmerge";
import { createBasicConfig } from "@open-wc/building-rollup";
import outputManifest from "rollup-plugin-output-manifest";

const entrypoints = {
index: "src/assets/index.js",
};

const baseConfig = createBasicConfig({
outputDir: "dist/assets",
});

export default merge(baseConfig, {
input: entrypoints,
plugins: [
outputManifest({
// ../ to go outside of dist and into include
fileName: "../../src/_includes/manifest.json",
// assets is my folder of choice for js files
publicPath: "assets/",
}),
],
});

You can add extra entrypoints, which is helpful, if you only want to load some components on some pages.

In order to hook this back into 11ty I'm making use of rollup-plugin-output-manifest. This outputs a manifest.json file.
You could output this as a data file if you wanted to, but I wanted to add a little more logic to my scripts so I could do different things depending on if in production mode or not.

Create a file called src/_data/assets.js, which will be read as Global Data File.

module.exports = {
getPath: (assetName) => {
if (process.env.NODE_ENV === "production") {
const assets = require("../_includes/manifest.json");
const modulePath = assets[assetName];
if (!modulePath) {
throw new Error(
`error with getAsset, ${assetName} does not exist in manifest.json`
);
}
return `/${modulePath}`;
} else {
return `/src/assets/${assetName}`;
}
},
};

Then in 11ty templates:

    <script src="{{ assets.getPath("index.js")}}" type="module"></script>

Doing this allowed me to just serve the unmodified src code when in development, but embed the production assets, which have hashes in their names for cache busting.

If you are wondering how to set the NODE_ENV flag, here is my build script.

    "build": "rm -rf dist && NODE_ENV=production rollup -c rollup.config.js && NODE_ENV=production npx eleventy"

And that's my setup, I'm sure there are better ways of doing this but it got the job done for me, hopefully this was useful.