I recently published shiki-element, a simple web component used to apply syntax highlighting to text via the shiki library.
It was a fun experience writing a vanilla web component for this using only modern solutions, so here's a brief (it didn't end up so brief after all) write up in case anyone else wants to try the same. Also to show you don't always need a framework.
NOTE: I understand there's a fair amount of boilerplate to follow, in real world cases I'd normally suggest choosing focused libraries to fill the gaps and something like lit-element for the rendering/propagation layer. This is just to demonstrate how you can make a vanilla component and my particular experience.
The objective
The objective I had was to create a web component which wraps the shiki library and has the following interface/consumption:
<shiki-highlight language="javascript">
function hello() {
return 'hello world';
}
</shiki-highlight>
I didn't want to use any frameworks or libraries, zero-dependencies other than the shiki
dependency if possible.
I also wanted to go ESM-only, i.e. no CommonJS support and no CommonJS dependencies.
Initial project setup
My immediate thought was to throw together the basic tooling stack I wanted:
- TypeScript
- mocha
- web-test-runner
- prettier
- eslint
- typescript-eslint
- eslint-config-google
With all of my sources in a src/
directory and my tests in src/test/
.
TypeScript
Seeing as I wanted to write ESM and output ESM, my tsconfig.json
was fairly straight forward:
{
"compilerOptions": {
"target": "es2017",
"module": "esnext",
"moduleResolution": "node",
"declaration": true,
"outDir": "./lib",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*.ts"
]
}
ESLint
To keep things simple, I chose to use Google's lint config and tweak a couple of rules for my own preference in .eslintrc.json
:
{
"extends": [
"eslint:recommended",
"google",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"plugins": ["@typescript-eslint"],
"rules": {
"indent": "off",
"comma-dangle": ["error", "never"],
"spaced-comment": "off",
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-inferrable-types": "off"
}
}
I disabled the no-unused-vars
rule as the TypeScript compiler does such a check already, and better than the one ESLint does (via noUnusedLocals
and noUnusedParameters
).
I also disabled no-inferrable-types
as I prefer declaring my types than relying on inference, for consistency.
Prettier
I also chose to add my own .prettierrc.json
to configure a few Prettier options to my preference, but the defaults are probably fine for most people.
web-test-runner
I configured web-test-runner to use my transpiled tests via puppeteer in web-test-runner.config.mjs
:
import {puppeteerLauncher} from '@web/test-runner-puppeteer';
export default {
nodeResolve: true,
files: 'lib/test/**/*_test.js',
testFramework: {
config: {
ui: 'bdd'
}
},
coverage: true,
coverageConfig: {
include: ['lib/**/*.js'],
exclude: ['lib/test/**/*.js']
},
browsers: [
puppeteerLauncher()
]
};
Again, fairly simple, I want to use mocha's BDD interface with test coverage enabled, launched via puppeteer.
Keep in mind, I chose to run WTR against my transpiled sources as they're almost equal to my actual sources. It is possible, however, to have WTR run against your TypeScript sources by using the esbuild plugin.
Assertions
The last missing piece of my setup was what I'll use for assertions in my tests.
I'd usually opt for chai, but it is increasingly becoming outdated (or has already, to be honest). It provides no official ESM entrypoint, which means I would be forced to support CommonJS in my stack in order to use it. This would mean introducing a bundle in my build process, unacceptable!
So I happily threw chai away, and pestered people for suggestions of alternatives which support ESM. This is where I came across uvu.
uvu
is very small, supports TypeScript and is published as ESM! Great.
It does come with its own mocha-alternative but I'm not sure I'm a fan of the design, so I chose to use only the uvu/assert
module it contains and stick with mocha.
Finally, some code 👀
I suspect its unusual to throw together a whole project setup before even writing a line of code, so feel free to skip most of the above 😬
A simple component
To begin with, remember our expected HTML usage:
<shiki-highlight language="javascript">
console.log(12345);
</shiki-highlight>
So we know from this, our component needs to roughly look like so:
class ShikiHighlight extends HTMLElement {
public language?: string;
}
customElements.define('shiki-highlight', ShikiHighlight);
Right now, this'll render nothing but has the right interface.
Attributes and properties aren't the same
We have a language
property, but a property is not the same as a HTML attribute. So our language
attribute will do nothing right now, and won't sync with the property.
// These are not equivalent
node.setAttribute('language', 'javascript');
node.language = 'javascript';
This is solved by using the attributeChanged
callback and observedAttributes
:
class ShikiHighlight extends HTMLElement {
public language?: string;
public static get observedAttributes(): string[] {
return ['language'];
}
public attributeChangedCallback(
name: string,
oldValue: string,
newValue: string
): void {
if (name === 'language') {
this.language = newValue;
}
}
}
The observedAttributes
static is used by the browser to determine which attributes to trigger the change callback for. The change callback (attributeChangedCallback
) is fired each time one of the observed attributes changes value.
This means any time language
changes on the element, our property will also be set to the same value.
NOTE: for now, synchronisation won't happen the other way, i.e. the property being set won't set the attribute.
Creating a shadow root
Ultimately, we want to render our syntax highlighted nodes in a shadow root so we don't affect the consumer's DOM tree (the "light DOM").
So we need a root:
public constructor() {
super();
this.attachShadow({mode: 'open'});
}
This'll result in DOM like so:
<shiki-highlight>
#shadow-root (open)
<!-- syntax highlight result will live here -->
function hello() {
return 'hello world';
}
</shiki-highlight>
Observing light DOM contents
We need something to syntax highlight... the contents of the element if you remember from our previous example:
<shiki-highlight>
console.log(12345); // This is text content of the element
</shiki-highlight>
We need to observe changes to this text content and trigger a new syntax highlight each time, outputting the resulting HTML to the shadow root we created earlier.
This can be done by a MutationObserver:
public constructor() {
super();
this.attachShadow({mode: 'open'});
this._observer = new MutationObserver(() =>
this._domChanged());
}
public connectedCallback(): void {
this._observer.observe(this, {
characterData: true,
subtree: true,
childList: true
});
}
public disconnectedCallback(): void {
this._observer.disconnect();
}
protected _domChanged(): void {
// Fired any time the dom changes
}
connectedCallback
is called by the browser when the element is added to the DOM tree, disconnectedCallback
is called when it is removed from the DOM tree.
In our case, we want to observe the light DOM (this
) when connected, and stop observing when disconnected.
We are observing changes to the text (characterData
) and child nodes (childList
).
NOTE: A bit of a TIL, setting textContent
does not mutate characterData
, in fact it mutates the childList
as it results in setting a new text node as a child.
Our _domChanged
can be implemented like so:
protected _domChanged(): void {
this._render();
}
protected _render(): void {
// do some syntax highlighting here
}
Observing property changes
Remember our language
property? We need to re-render each time that changes as the syntax highlighting will differ per language.
We can implement this kind of observer by getters and setters:
// Change our language property to be protected
protected _language?: string;
// Replace the original property with a getter
public get language(): string|undefined {
return this._language;
}
// and a setter which triggers a re-render
public set language(v: string) {
this._language = v;
this._render();
}
Now any time we set the language
property, we will re-render.
Remember we also need to ensure the previous attribute changed callback sets language
(and not _language
), so it triggers a re-render too.
Implementing the render method
Finally, we need to do the work for syntax highlighting:
protected _render(): void {
const highlightedHTML = highlightText(this.textContent ?? '');
this.shadowRoot.innerHTML = highlightedHTML;
}
Pretty basic, we pass the light DOM text content to our highlighting library, which then returns HTML we append into our shadow root.
Our DOM will then look like this:
<shiki-highlight language="javascript">
# shadow-root (open)
<pre class="shiki"> ... </pre>
console.log(12345);
</shiki-highlight>
Tests and what not
After this, I wrote a bunch of unit tests using uvu and mocha:
import {assert} from 'uvu/assert';
describe('shiki-highlight', () => {
it('should work', () => {
assert.is(actual, expected);
});
});
Which I ran via WTR:
npx web-test-runner
I also setup a github workflow and some package scripts (lint, format, etc.).
Wrap-up
Again, I'm writing this really because I enjoyed being able to produce a component using vanilla JS and modern tooling.
I've given no consideration to older browsers and have relied on a fair amount of features only the latest support.
There is nothing wrong with bundling, either, I just wanted to avoid it in this case as it'd be nice to publish and write ESM as-is.
You can view the finished component here:
https://github.com/43081j/shiki-element
You can see a demo here (may load a little slow initially since shiki is a bit on the chunky side):
https://webcomponents.dev/preview/cQHUW8WJDlFAKqWpKibc
Not everything needs a framework, or a base library. The point at which you'll likely need to consider one is when you need state or more complex data propagation/observation.
Top comments (0)