From PrismJS to Better PrismJS to MicroLighter in Three Painless* Steps
An excellent feature of Eleventy is the @11ty/eleventy-plugin-syntaxhighlight plugin, which adds build-time syntax highlighting support via PrismJS. Official support keeps 11ty sites from worrying about client-side bloat from JS-based highlighters and the common failure mode of over-including language definitions.
But nothing's perfect.
Contents
The official plugin integrates naturally, and I took to using it via the usual triple-backtick markdown syntax:
# The Usual Markdown Nonsense { #the-usual-nonsense }
And now for something entirely different:
```js
function yourCodeHere() { /* ... */ }
```
Etc., etc.This works a treat, until you want a bit more control. At that point, what seems magical starts to feel restrictive.
A recent goal was to add line numbers.
After reading some of the plugin code that invokes PrismJS, I closed my laptop in frustration, unable to cajole eleventy-plugin-syntaxhighlight into outputting line numbers. Walking away from the keyboard paid off a few days later when I recalled the paired shortcode code path generated stable per-line delimiters. This allowed reuse of the plugin's primary logic, wrapped in a bit of string munging to add the necessary styling hooks:
// From this site's Eleventy config file
// Because my esm conversion is still partial:
const { default: syntaxHighlight } =
await import("@11ty/eleventy-plugin-syntaxhighlight");
// ...
module.exports = async function(config) {
// ...
let syntaxHighlightOptions = {
// For faster CSS selectors
preAttributes: { highlighted: "highlighted" },
codeAttributes: { highlighted: "highlighted" },
};
config.addPlugin(syntaxHighlight,
syntaxHighlightOptions);
config.addPairedShortcode("code",
function(content,
language="",
highlightLines="") {
// The design of cssMin and addToBundle are outside
// the scope of this post.
//
// Suffice to say, we're inlining the contents
// of the file path passed as an argument to it.
//
addToBundle(
this,
"css",
``);
let xformed = syntaxHighlight.pairedShortcode.call(
this,
content,
language,
highlightLines,
syntaxHighlightOptions
);
// Wrap each line in `...`
let _preamble = "";
let _end = "
";
let _content = "";
// Preamble and postfix might be different than
// defaults above, so preserve them.
let preambleEnd =
xformed.indexOf(">",
xformed.indexOf("");
_content =
xformed.substring(preambleEnd, contentEnd);
let parts = _content.split("
");
// Wrap each line in a stylable
_content = parts.map((line) => {
return `${line}`;
}).join(`
`);
return _preamble + _content + _end;
});
// ...
};
Add a bit of CSS that does the counting for us, ét voila!, line numbers:
pre[highlighted] {
display: block;
overflow-x: auto;
isolation: isolate;
max-width: 100%;
contain: content;
& > code {
width: 100%;
text-wrap-mode: wrap;
counter-reset: step;
counter-increment: step 0;
& .line::before {
content: counter(step);
counter-increment: step;
width: 1.5rem;
padding-right: 0.5rem;
margin-right: 0.5rem;
display: inline-block;
text-align: right;
color: var(--color-mid);
border-right: 1px solid currentColor;
}
}
}If the JavaScript approach looks familiar, that might be because it's the technique outlined last year when I pressed the 11ty Bundler into service as a poor man's route-based code splitter, allowing each shortcode that requires JavaScript to lazily include it only for pages that feature those components:
// An `.eleventy.js` configuration file
module.exports = async function(config) {
// Register some bundles that templates output.
config.addBundle("js");
// etc., ...
// Needed to avoid Markdown double-processing.
let removeNewlines = function(str) {
return str.replace(/[\n\r]/g, "");
}
// Invoke shortcodes that `config.addBundle()`
// generates from within *other* shortcodes,
// allowing us to use the Bundler for duplicate code
// removal and hoisting dependencies to the optimal
// place in output templates.
function addToBundle(scope, bundle, code) {
config.getPairedShortcode(bundle).
call(scope, removeNewlines(code));
}
config.addShortcode("someblock",
function(/* ... */) {
addToBundle(this, "js", `
`);
// ...
});
// ...
};The 11ty syntax highlighting hack only works because the output always has a delimiter when invoked via HighlightPairedShortcode. While brittle, it does work. Invoking it via Nunjucks paired shortcode syntax is longer than triple-backticks, but not painful:
# The Usual Markdown Nonsense { #the-usual-nonsense }
And now for something entirely different:
{% code "js" %}
function yourCodeHere() { /* ... */ }
{% endcode %}
Etc.This blog hasn't been code-heavy of late, but this has been working well enough for a fair few months. If anyone is interested to see this wrapped up in an 11ty plugin, let me know.
Line numbers scratched an itch, but the situation was uneasy; PrismJS emits sub-optimal markup and features like highlighting never worked. I've remained curious about other solutions.
Enter my colleague Dave Rupert's extremely cool MicroLighter project. If you'd like to learn more about the ultra-modern web decisions behind it, he's got a great blog post that teases out the whys and wherefores.
As I've been thinking about syntax highlighting alternatives for use here and an upcoming, 11ty-based multi-document HTML slide system (provisional name: "Faro"), reworking {% code "..." %} to use something else behind the scenes has seemed increasingly beneficial. To that end, here's the general approach I've ended up with to integrate MicroLighter:
- Ensure that all the files get copied to the output directory. It's a bit more complicated than this in practice, as I avoid copies in favour of symlinks for speed in dev mode — this blog rebuilds from scratch in less than six seconds and sub-second incrementally on a Chromebook — but the effect is the same:
config.addPassthroughCopy({
overwrite: false,
"node_modules/microlighter/dist": "assets/microlighter",
});- Use the approach from last year's post to include scripts and inline the CSS only when needed. This has three components:
- Adding the core web component JS to the bundle.
- Inlining theme CSS (also via bundler).
- Ensuring tags are generated for each used language.
jsin addition tojavascript, e.g.). I don't have a solution for language dependencies, but top-level grammar preloading avoids network stalls in my limited use. - Handle content escaping in shortcodes. By default, 11ty and Nunjucks (my templating engine of choice) escape content, replacing
<with<, e.g. which is what we want for code that will live inside ablock. It's less desirable in cases where the surrounding markup is intended to land in the final page as HTML, as is the case with web components. Sidestepping Nunjucks escaping relies on old...env.filters.safe()andenv.filters.escape()hacks, and skipping Markdown escaping comes courtesy ofmarkdown-it-ignore. Together, these allow passthrough of web component markup amidst other processing.
Step #2 is the linchpin. I would not have felt comfortable adopting a client-side solution without the ability to load dependencies in a way that plays well with other content and only includes code when highlighting is required and brings in language definitions as-needed.
The dependency chain for this new shortcode is a bit unwieldy and site-specific, so I hesitate to offer as a plugin. If someone with ambition is interested, I'd be happy to share my hacky code and talk through how it might be made more general.
FOOTNOTES
- I understand the brand-consistency arguments for 11ty's Build Awesome rebranding. I'm a huge fan of Zach, the lovely and supportive community he has built, and the OSS funding model that the Fonticons, Inc. folks have put into practice with Font Awesome, Web Awesome, and now Build Awesome. I get it, deeply. When the Build Awesome kickstarter was announced, I was early to throw cash at the effort, and I'm pleased as punch to see the community's outpouring of support for the project. ...all of which is preamble to say that I won't be uttering the words "Build Awesome" unironically. It's always eleventy to me, and I hope everyone will forgive (or at least accommodate) my style guide transgressions.
- You might be thinking "surely nobody includes every PrismJS language in their bundles in 2026?", and I'd reply that you, sir or madam, are not looking closely at the bundles of the pages you experience feeling "a little bit slow". Not only is correct dependency inclusion like hen's teeth in "modern" frontends, I've personally diagnosed close to a dozen cases of PrismJS language definition over-inclusion over the past few years. Perhaps syntax highlighting blunders will fade away in the future, but that point was not last year, last month, or yesterday.