Copy Buttons and Line Numbers in mdsvex With twinkleplop
I’ve had syntax highlighting on this blog since I moved it over to mdsvex back in 2021. What I’ve never had are the controls: a button to copy the code, highlighted lines, or line numbers you can turn on and off.
Today I sorted all three. Very good, I’m happy with the result! 😅
This post covers how I swapped Prism for twinkleplop, then built a code block component around it. If you’re reading this on the site, every code block in the post is using it. Try the buttons in the top right of this one:
1import { language } from '@twinkleplop/typescript';
2
3const typescript = language();
4const html = typescript('const answer = 42;');Why I switched
mdsvex ships with Prism, and Prism did the job for years. The thing
that pushed me over was finding out that 52 code fences across 16 of
my posts had line highlight markers like {2,7} on them. Prism had
been quietly ignoring every one of them. I’d added those markers,
never seen them work, and stopped bothering.
twinkleplop is a syntax highlighter from pngwn. It runs at build time, so none of the highlighter ends up in the browser, there’s a package per language, and themes are CSS custom properties. I went with the Night Owl theme, which is what I had with Prism anyway.
The piece that made it a good fit for mdsvex is @twinkleplop/markdown-core. It reads the fence metadata that Shiki
and VitePress use, so {2,7} line highlights, title="file.ts" and :line-numbers all work without me writing a parser.
The highlighter
mdsvex can swap its highlighter for a function that takes the code, the language and the fence metadata. Mine creates a twinkleplop renderer with the languages I use:
1import { create_renderer } from '@twinkleplop/markdown-core';
2import { language as bash } from '@twinkleplop/bash';
3import { language as typescript } from '@twinkleplop/typescript';
4import { language as svelte } from '@twinkleplop/svelte';
5
6const renderer = create_renderer({
7 languages: {
8 bash: bash(),
9 typescript: typescript(),
10 svelte: svelte(),
11 ts: 'typescript',
12 sh: 'bash',
13 },
14 on_unknown_language: 'plain',
15 line_numbers: true,
16});The highlighted lines there are the ones worth knowing about. Aliases
map ts and sh onto the full language names, and on_unknown_language: 'plain' renders anything without a grammar as
escaped plain text instead of failing the build. I’ve got GraphQL,
PowerShell and Dockerfile fences in old posts, and there aren’t
twinkleplop packages for those yet.
line_numbers: true renders the numbers into every block. More on why
in a bit.
Then it goes into the mdsvex config:
1import { highlight_code } from './src/lib/markdown/highlighter.ts';
2
3const config = defineConfig({
4 extensions: ['.svelte.md', '.md', '.svx'],
5 highlight: {
6 highlighter: highlight_code,
7 },
8});The catch with controls
This is the reason I never added a copy button before. An mdsvex highlighter returns a string, and that string ends up in the page as one opaque blob of HTML. There’s no component to put a button on.
The way round it was to have the highlighter return a Svelte component instead of HTML, with the highlighted HTML passed in as a prop:
1export function highlight_code(code, language, meta) {
2 const html = renderer.fence(language, meta, code);
3
4 // Pass as string expressions so Svelte doesn't parse `{` or `<`
5 // in the highlighted source as template syntax
6 return `<CodeBlock html={${JSON.stringify(html)}} />`;
7}That JSON.stringify matters. Highlighted Svelte code is full of
curly braces, and if they went straight into the template Svelte would
try to evaluate them.
That leaves one problem: every post with a code block now uses CodeBlock, and every post needs to import it. I’m not adding an
import to 240 posts by hand, so a small remark plugin does it:
1function code_block_import() {
2 const code_block_import =
3 "import CodeBlock from '#lib/components/code-block.svelte';";
4 const instance_script =
5 /^\s*<script(?![^>]*\bcontext=)(?![^>]*\bmodule\b)[^>]*>/;
6
7 return function transformer(tree) {
8 let has_code = false;
9 visit(tree, 'code', () => {
10 has_code = true;
11 return EXIT;
12 });
13 if (!has_code) return;
14
15 let script;
16 visit(tree, 'html', (node) => {
17 if (instance_script.test(node.value)) {
18 script = node;
19 return EXIT;
20 }
21 });
22
23 if (script) {
24 script.value = script.value.replace(
25 instance_script,
26 (tag) => `${tag}\n\t${code_block_import}`,
27 );
28 } else {
29 tree.children.unshift({
30 type: 'html',
31 value: `<script>\n\t${code_block_import}\n</script>`,
32 });
33 }
34 };
35}Remark plugins run before mdsvex highlights anything, so the plugin
can still see the code fences. A lot of my posts already have their
own <script> block, and a component can only have one, so the plugin
adds the import to an existing script if there is one. The code nodes are the fences themselves, which means <script> tags inside
code examples don’t confuse it.
Copying without the line numbers
The copy button clones the code, drops the line numbers and copies what is left:
1async function copy_code() {
2 const code = block?.querySelector('pre code');
3 if (!code) return;
4
5 const clone = code.cloneNode(true) as HTMLElement;
6 clone.querySelectorAll('.ln').forEach((ln) => ln.remove());
7
8 try {
9 await navigator.clipboard.writeText(clone.textContent ?? '');
10 copy_status = 'Copied';
11 } catch {
12 copy_status = 'Copy failed';
13 }
14}I checked this against a 14 line block and got 14 clean lines back, tabs and blank lines included. The icon swaps to a tick for a couple of seconds, and a hidden status message tells screen readers it copied.
Line numbers without the flash
Line numbers are always in the markup and hidden with CSS. The toggle
flips a data-line-numbers attribute on <html>, so one click turns
them on for every block on the page:
1.twinkleplop .ln {
2 display: none;
3 user-select: none;
4}
5
6[data-line-numbers] .twinkleplop .ln {
7 display: inline-block;
8}user-select: none stops the numbers getting caught when someone
highlights code by hand.
To remember the setting between visits I copied what my theme picker already does. The toggle saves a cookie, and a server hook adds the attribute before the page is sent:
1export const line_numbers: Handle = async ({ event, resolve }) => {
2 const visible = event.cookies.get('line_numbers') === '1';
3
4 return await resolve(event, {
5 transformPageChunk: ({ html }) =>
6 visible
7 ? html.replace('<html ', '<html data-line-numbers ')
8 : html,
9 });
10};Reading it from local storage in the browser would work too, but the numbers would pop in after the page loaded. With the hook they’re there on first paint.
Language icons
The header shows a small logo for the language, with the name in a
tooltip. The logos are paths from Simple Icons, and the highlighter looks up
the right one at build time and passes it to CodeBlock as a prop.
That way each post only ships the logos it actually uses, rather than
every post downloading all of them.
I hand-rolled the copy and line number icons too. For three icons it came out smaller than pulling in an icon package.
Highlights that go edge to edge
The last bit was alignment. The prose styles put padding on the <pre>, so highlighted lines stopped short of the edges. Moving the
padding onto each line and laying the lines out as a grid fixed it:
1.twinkleplop code {
2 display: grid;
3 min-width: 100%;
4 width: max-content;
5}
6
7.twinkleplop .l {
8 min-height: 1lh;
9 padding-inline: var(--code-gutter, 1.5rem);
10}width: max-content means a highlight follows the line when a long
block scrolls sideways, and min-height: 1lh stops blank lines
collapsing. The header uses the same gutter variable, so the language
icon sits in line with the code and the copy button’s icon lines up
with the right-hand edge.
Wrapping up
That’s it! Syntax highlighting that runs at build time, a copy button, line highlights that finally work, and line numbers that remember a reader’s choice. All of it comes down to one change: the highlighter returns a component instead of a string.
The whole thing is in the repo for this site.
There's a reactions leaderboard you can check out too.
Sign up for the newsletter
Want to keep up to date with what I'm working on?
Join other developers and sign up for the newsletter.
I care about the protection of your data. Read the Privacy Policy for more info.