Custom Svelte Markdoc Renderer

Published Apr 20, 2024

Table of Contents

What Is Markdoc?

You can find the source code on GitHub.

Markdoc is an extension of Markdown which enhances Markdown with custom nodes, tags, attributes, variables, and interactive elements using the framework of your choice.

Here’s a simple example that uses a title variable, and a custom <Callout> component:

posts/markdoc.md
---
title: What is Markdoc?
---

# {% $frontmatter.title %}

Markdoc is a Markdown-based syntax and toolchain for creating custom documentation sites. Stripe created Markdoc to power [our public docs](http://stripe.com/docs).

{% callout type="check" %}
Markdoc is open-source—check out its [source](http://github.com/markdoc/markdoc) to see how it works.
{% /callout %}

Here is how you process Markdown using Markdoc:

routes/[slug]/+page.server.ts
async function html(slug: string) {
	// ...
	const ast = Markdoc.parse(post)
	const content = Markdoc.transform(ast, {
    // components
		tags: {
			callout: {
				render: 'Callout',
				attributes: {
					type: {
						type: String,
						default: 'note',
					},
				},
			},
		},
    // variables
		variables: {
			frontmatter: getFrontmatter(ast.attributes.frontmatter),
		},
	})
  // using the HTML renderer
	return Markdoc.renderers.html(content)
}

export async function load({ params }) {
	return { content: await html(params.slug) }
}

Render the HTML on the page:

routes/[slug]/+page.svelte
<script lang="ts">
	let { data } = $props()
</script>

{@html data.content}

Markdoc doesn’t know how to render the custom <Callout> component yet, which is where a custom renderer comes in — Markdoc has a HTML, and React renderer, but it’s relatively simple to make your own renderer.

Creating The Markdoc Renderer

Instead of returning HTML, we can return a tree of renderable elements, and create a custom renderer using special Svelte elements.

routes/[slug]/+page.server.ts
async function markdoc(slug: string) {
  // ...
  const content = Markdoc.transform(ast, config)
  return JSON.stringify(content.children)
}

export async function load({ params }) {
	return { children: await markdoc(params.slug) }
}

If you log content.children you get a renderable tree:

terminal
{
  "$$mdtype": "Tag",
  "name": "Document",
  "attributes": {},
  "children": [
    "0": {
      "$$mdtype": "Tag",
      "name": "Heading",
      "attributes": { ... 2 items },
      "children": [ ... 2 items ],
    },
		// ...
  ],
}

To create a custom renderer we just need to loop over the renderable tree, and render elements based on the name of the child node.

routes/[slug]/+page.svelte
<script lang="ts">
	import MarkdocRenderer from '$lib/markdoc/renderer.svelte'

	let { data } = $props()
</script>

<MarkdocRenderer children={JSON.parse(data.children)} />

For this we can use special Svelte elements:

  • <svelte:component to render custom Svelte components
  • <svelte:element> to render regular HTML elements
  • <svelte:self> to recursively use the renderer.svelte component, and pass the children prop until there are no more children
$lib/markdoc/renderer.svelte
<script lang="ts">
	import Callout from './components/callout.svelte'
	import Counter from './components/counter.svelte'

	let { children }: any = $props()

	const components = { Callout, Counter }
</script>

{#each children as child}
  <!-- this is a custom element -->
	{#if components[child.name]}
    <!-- render it -->
		<svelte:component this={components[child.name]} {...child.attributes}>
      <!-- recurse over children -->
			<svelte:self children={child.children} />
		</svelte:component>
	{:else}
    <!-- this is a regular HTML element -->
		<svelte:element this={child.name} {...child.attributes}>
      <!-- recurse over children -->
			<svelte:self children={child.children} />
		</svelte:element>
	{/if}

  <!-- this is a plain text node -->
	{#if typeof child === 'string'}
		{child}
	{/if}
{/each}

Creating a custom renderer might sound complicated, but you’re just taking a representation, or a tree of renderable nodes, and looping over the nodes to render elements on the page which could be anything from custom Svelte components, to regular HTML elements, and text nodes.

That’s it! 😄

Support

If you want to support the content you're reading or watching on YouTube consider becoming a patreon starting low as 1$ per month.

Become a patreon
Subscribe For Updates
Found a mistake?

Every post is a Markdown file so contributing is simple as following the link below and pressing the pencil icon inside GitHub to edit it.

Edit on GitHub