Menu
Tags

Making the Markdown Pretty

Published on Nov 4, 2023

Table of Contents

Let’s make Markdown Pretty

I want to write in pure Markdown because I love the flexibility and the complete avoidance of vendor lock-in. However, I want to push it beyond its normal limits. The things I see as critical parts of my own blog and writing style are the following:

  • Automatic table of contents on longer articles that need them.
  • Clean image formating, lazy loading, responsive scaling, efficient formats.
  • Collapsable sections. When articles get long, I want readers to be able to collapse sections as needed.
  • Clean code formatting with ‘copy’ links.
  • Comments using something simple and straightforward.

Table of Contents

Pretty simple stuff here, we’ll use remark-toc, and import in our svelte.config.ts. Moving forward, any markdown file that contains a heading with the name ‘Contents’ will render the table of contents. We can use some CSS to target this heading and move it somewhere better so it isn’t planted in the middle of our post, but for now I’ll leave it there.

svelte.config.ts
...
remarkPlugins: [ ..., [remarkToc]],
...

…now the hard part is that I don’t want to render the ToC directly ontop of the post, I want it to appear on the sidebar, something that is more accessible as users scroll down the page. (and just realizing now that I want to lock to the scroll position as well) I’ve come up with a solution that feels extremely hacky, but it seems to be doing the trick.

First, I’ll set the table of contents to not render within the mdsvex.svelte template with CSS:

src/mdsvex.svelte
#table-of-contents {
    display: none;
}

Then, within my Nav.svelte component, I’ll use some vanilla JS to clone the table of contents elemets.

src/lib/components/Nav.svelte
import { afterUpdate } from 'svelte';
afterUpdate(()=>{
    const toc = document.getElementById('table-of-contents');
    const tocList = document.getElementById('table-of-contents')?.nextElementSibling;
    document.getElementById('side-toc')?.appendChild(toc);
    document.getElementById('side-toc')?.appendChild(tocList);
    document.getElementById('side-toc')?.style.setProperty('display', 'flex');
})

But, now I’ve got to do some styling on an element that isn’t currently rendered, so the Svelte compliler/transpiler/whatever will strip that CSS out. So, I got lazy and dumped these styles into my global.css.

Finally, I’ve got to make sure that we are only rendering this on blog post pages, and I want to remove it compeletly when I’m in a smaller viewport…but again with no element rendered to style, CSS won’t quite work for removal on smaller displays. So, I’ll lean on the svelte:window component.

src/lib/components/Nav.svelte
<script>

...

import { page } from '$app/stores'
let innerWidth = 0;

</script>

<svelte:window bind:innerWidth>

...

{#if $page.params.slug && innerWidth > 1000}
<div id="side-toc" />
{/if}

Code Block - Nice and Clean

I want my code blocks to be as clean and easy to read as possible. I’ve already implemented using shiki and it makes it look great, but I want to have file names and a copy link in these blocks. Again, we’ll turn to remark plugins: remark-code-title will render a nice little div that we can style just above our code block. I just put some basic styling to match the code styling I’m using:

src/global.css
[data-remark-code-title="true"] {
    margin-bottom: -5px;
    padding: 0.5rem;
    background-color: #282A36;
    color: var(--code-color);
    border-radius: var(--pico-border-radius);
}

The copy functionality is trickier. We’ll need to create a custom component here, and loop through all code blocks in HTML pre tags, and render the copy button on top. Vyacheslav Basharov has us covered here. I followed his code pretty much directly, and it worked pefectly. Such a clean and simple approach to this problem. I had to do some more styling to match here, but everything came out great.

src/mdsvex.svelte
onMount(()=> {
        const codeBlocks = document.querySelectorAll('[data-remark-code-title="true"]');
        codeBlocks.forEach((block) => {
            const copyPrompt = document.createElement('div');
            copyPrompt.classList.add('copy-prompt');
            const copyPromptText = document.createElement('p');
            copyPromptText.innerText = ' Copy';
            const copyIcon = document.createElement('div');
            copyIcon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>'
            copyPrompt.appendChild(copyIcon);
            copyPrompt.appendChild(copyPromptText);
            block.appendChild(copyPrompt);
            block.querySelector('.copy-prompt').addEventListener("click", (event) => {
                copy(block.nextElementSibling.querySelector("code")?.textContent);
                block.querySelector(".copy-prompt > p").innerText = "Copied!";
                setTimeout(() => {
                    block.querySelector(".copy-prompt > p").innerText = "Copy";
                }, 1000);
            })
        })
    })

Clean image formating

I’m serving up images in a basic way now, but I want to splice in auto-generated modern formats and responsive sizes in the HTML picture tag. We’ve got a custom img.svelte component being used with mdsvex, but I want to pass in this optimized image. I’ve been working with @zerodevx/svelte-img to handle this dynamic image generation, but the challenge is importing the image correctly without triggering vite errors. The solution isn’t pretty, but its functional. We’ll use Vite’s glob import to import all photos, then use the source string passed from the Markdown into the object of all images.

src/lib/components/img.svelte

<script>
	import Img from '@zerodevx/svelte-img';

	export let src: string;
	export let alt: string;

	const modules = import.meta.glob('$lib/assets/*.*', {
        import: 'default',
		eager: true,
		query: { as: 'run' }
	});

</script>

<Img src={modules[src]} {alt} />
example image 2

The result above is from Daniel J. Schwarz on Unsplash. Its original jpg measures in at ~1.3mb. When serving up with this dynamic import, the rendered image comes in as a 236kb AVIF. Pretty sweet to cut an entire 1mb off the payload for the page.

Wrapping Up

We’ve got a clean presentation of our markdown now, with a table of contents, clean code blocks with a copy function, and images being served up in the most efficent and modern way possible. Once I get this deployed, I’ll see the final payload size and rendering time and update this post here.