Self-nested components with svelte

I'm using Pocketbase to store structured content for this site and to make it a bit easier on myself, I've created a small svelte-based CMS with the tiptap editor.

Tiptap exports structured data in the form nested objects. A paragraph is an array of nested text objects that can have marks to define markup:

{
	type: "text",
	text: "This is a very formatted text",
	marks: [
		{ type: "bold" },
		{ type: "underline" },
		{ type: "strike" },
	]
}

Each text element can have multiple markup tags, and I could just use a span and add these marks as css classes to convert the text to markup. But I wanted to render the proper html tags for each mark, e.g.:

const ELEMENT_MAP = {
  underline: 'u',
  highlight: 'mark',
  strike: 's',
  italic: 'em',
  bold: 'strong'
};

In order to do this we have to nest these markup tags somehow, so the marks from the example above should yield:

<strong><u><s>This is a very formatted text</s></u></strong>

Svelte element and self

With a few recent additions to svelte we can use svelte:element and svelte:self to render these marks in a nested fashion.

Given a list of marks we have to pop one off the array and render that mark as a HTML element, then we have to render ourselves again with the remaining marks. We use a <slot/> to render the nested content all the way through.

The component (Mark.svelte) looks like this:

<script>
	export let marks = [];

	// Pop a mark off the list
    // and take the rest as a separate value
	const [mark, ...remainingMarks] = marks;
	const ELEMENT_MAP = {
		underline: 'u',
		highlight: 'mark',
		strike: 's',
		italic: 'em',
		bold: 'strong'
	};
</script>

{#if mark}
   <!-- render HTML element for mark type -->
	<svelte:element this={ELEMENT_MAP[mark.type]}>

		{#if remainingMarks.length > 0}
			<!-- render self with the remaining marks-->
			<svelte:self marks={remainingMarks}>
				<slot />
			</svelte:self>
		{:else}
			<!-- no remaining marks, render the slot contents -->
			<slot />
		{/if}

	</svelte:element>
{:else}
	<!-- no mark to render, render the slot contents -->
	<slot />
{/if}

First we have to check if we have a mark to work with, otherwise just render the slot value.

  1. Then we render the element from the map with the given mark.

  2. If we have remaining marks, we render ourselves with the remainder of the marks. Otherwise we'll render the slot content nested in our freshly generated html element.

  3. If we have no marks, just render the slot contents

Result

We can now call the component from the parent with the given marks and text:

<Mark marks={block.marks}>{block.text}</Mark>

This should yield the following text:

This is a very formatted text

Possible issues

If you're having issues with the content not properly updating on refresh or navigation, try rendering the content in a key

{#key block.id}
  <Mark marks={block.marks}>{block.text}</Mark>
{/key}