Skip Navigation

Scott Spence

Copy Buttons and Line Numbers in mdsvex With twinkleplop

• 6 min read

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.

Copyright © 2017 - 2026 - All rights reserved Scott Spence