New

Adding inline context to blog posts

javascript

svelte

Sometimes when explaining a detailed topic, you want to be able to provide more context to people who need it without asking them to navigate away to another page. This post details my experiments in using SvelteKit, MDsveX, and Remark plugins to build a solution for this on my site. Follow along, or check out Nicky Case’s nutshell tool for a great, out-of-the-box solution!

Giving you context where you need it

When I’m telling a story or explaining something to someone face-to-face, it’s very likely that I’ll eventually pause mid-way through and say one of the following things:

  • How much do you know about _ __?
  • Wait, actually, let me back up a bit.
  • This may sound unrelated, but bare with me.

Usually, this is because I’ve realized part way through my explanation that the person I’m talking to may not have all of the appropriate context they need to fully understand what I’m talking about.

In a realtime conversation, It’s easy for me to adjust the level of detail that I give based on my audience’s knowledge of whatever we’re talking about. If you’re an expert, I may give you little to no context and jump straight to “the solution.” If you’re brand new, I may explain every acronym, skip over the tricky edge cases, and promise to send you links to additional related content.

When I write things on the internet, I can’t make these realtime adjustments. I don’t know who is reading my work or what their background is. Some internet creators, like Maggie Appleton , include a disclosure of who their assumed audience for each piece is. In many ways, I like this approach.

But often, when writing, it’s not that I don’t have the time, energy, or interest in providing more context, but that I worry that the more advanced reader will get bored skimming through tons of things they already know and that the novice will be overwhelmed.

I wanted a digital version of my realtime assessment with the ability to inject additional context only when needed. Hyperlinks can connect you to existing resources, but they take you out of the flow of what you were doing in a way that’s not always helpful. And I say this as someone who routinely has 30+ tabs open at any time.

Nutshell to the rescue

Around the time that I started thinking about this conundrum, I saw a tweet from Nicky Case detailing a new experiment they called nutshell that they were working on. Essentially, this library could inject snippets from one post on your site into a different post.

Nutshell, or, “Expandable Explanations”!

A free tool I'm making so you can let your readers choose how in-depth they want to go. Play with the demo here: https://ncase.me/nutshell-wip/

(Inspired by Telescopic Text & Wikiwand's Link Preview)

8:24AM

·

2021-08-26

While this idea has been around for a while , this was the first time I had seen it and I immediately loved it. It seemed like the perfect way to both include net-new informational asides or to include a small reference to something you’ve written elsewhere into the area where it’s needed.

Here’s an example. Say that I’m writing an article about a commonly-used piece of jargon like a11y . Anyone familiar with web accessibility will probably recognize the numeronym and skim right past it, whereas someone who needs more information can expand the section to learn what that means and then continue reading with minimal flow interruption.

You can find many more nutshell examples from Nicky in this fabulous resource about AI safety.

I can also use these pieces of expandable context to reference pages that I manage within my note-taking system that I don’t want to publish as their own page on my site. For example, if I wanted to reference an idea about the types of problems that AI can (and can't) solve I can link you to my unpublished notes page, as I am doing here.

These pages contain quotes and my own very rough notes as I think through topics. When I link to them, the information that you see expanded inline contains a quote and a link to the original source rather than to a published page on my site. This makes it easy for me to publish and reference just the pieces I want to share, while still providing the reader additional information about where my thinking is coming from.

Implementing expandable inline context

I’ll cut to the chase and say that while I could have used nutshell to solve this problem – Nicky has even provided a script file to implement nutshell on your site with one line of code! – I didn’t. 🫣

I had this wild idea about making something similar, but slightly different and was really determined to build it myself. So, here we are.

Based on the source code, nutshell seems to work by accessing the raw HTML content of a page, digging through to find the relevant section, and then inserting that section somewhere else. But, since I only intended to use this for displaying other content that was on the same site, I wondered if there was another way.

My Setup

Part of the reason why I was so intent on rolling my own solution to this expandable inline context problem was because of the tech stack I used to build this site:

  • SvelteKit - used to build the bulk of my site
  • Obsidian - used as a Content Management System (CMS) for writing, editing, and updating my content in markdown files complete with bi-directional-links
  • MDsveX - a preprocessor that allows me to write bits of svelte code inside markdown documents

Svelte is largely centered around the idea of “components”, that is, small reusable pieces of code. Since my entire website was built with Svelte, and I was intending to make my site static such that all of the content existed in markdown files in the same repo as one another and the rest of my site, I was convinced that there was a way to make pieces of my posts into “small reusable pieces of writing”. I eventually solved it (kind of), but it turns out this was a bit more complicated than I thought.

The scope of my solution

Unlike nutshell which can inject any section of a post into an expandable section, my version currently only allows me to inject the “summary” section of each post. This works fine for my needs, but keep it in mind if you plan to try this yourself.

But, unlike Nutshell, it allows me to inject content from unpublished pages as well as published ones, as I mentioned earlier.

After a bit of time trying to solve this myself, I posted an idea within the GitHub repo for MDsveX. The preprocessor creator and maintainer, pngwn was unbelievably helpful in helping me figure out how to start solving this problem.

The solution, ultimately, would take several steps. Detailing each step would make this post ridiculously long, even for my standards. So, instead, I’ll give a very brief overview how this all works.

First, a little background into the technical side of all of this.

MDsveX and Svelte Preprocessing

The MDsveX preprocessor converts markdown into a related structure called the Abstract Syntax Tree before processing it as a svelte component.

For instance, consider this bit of markdown:

markdown

# This is an H1 level title

And this is some text.

When parsed as AST, that heading and text are represented like this.

AST

{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [
        {
          "type": "text",
          "value": "This is an H1 level title",
          "position": {
            "start": {
              "line": 1,
              "column": 3,
              "offset": 2
            },
            "end": {
              "line": 1,
              "column": 28,
              "offset": 27
            }
          }
        }
      ],
      "position": {
        "start": {
          "line": 1,
          "column": 1,
          "offset": 0
        },
        "end": {
          "line": 1,
          "column": 28,
          "offset": 27
        }
      }
    },
    {
      "type": "paragraph",
      "children": [
        {
          "type": "text",
          "value": "And this is some text.",
          "position": {
            "start": {
              "line": 3,
              "column": 1,
              "offset": 29
            },
            "end": {
              "line": 3,
              "column": 23,
              "offset": 51
            }
          }
        }
      ],
      "position": {
        "start": {
          "line": 3,
          "column": 1,
          "offset": 29
        },
        "end": {
          "line": 3,
          "column": 23,
          "offset": 51
        }
      }
    }
  ],
  "position": {
    "start": {
      "line": 1,
      "column": 1,
      "offset": 0
    },
    "end": {
      "line": 4,
      "column": 1,
      "offset": 52
    }
  }
}

And further parsed as a svelte component, it may only include html, like this:

svelte

<h1>This is an H1 level title</h1>
<p>And this is some text.</p>

If you’re using MDsveX out-of-the-box, you don’t need to worry about any of this. It all happens behind the scenes such that you can write svelte in your .md files (or markdown in your .svelte files) and everything should parse automatically.

If you want to do something that isn’t out-of-the-box for MDsveX, like adding a custom id to each of your heading elements so you can make them directly linkable, you’ll need to use either a remark (a markdown processor) or rehype (an HTML processor) plugin. For my purposes, I wanted to make changes to the markdown, before it is HTML, so I needed to use remark plugins.

Remark Plugins

I needed remark plugins to do a number of tasks to make my inline expandable sections possible. This is roughly what I needed to accomplish:

  1. Parse all the headings (e.g., h1, h2, h3 etc.) in the document. Give each one a unique, slugified ID based on the heading’s content.
  2. Wrap everything between two h2 elements (including the first h2) in a section tag. Add a  data-id attribute to each section. The data-id is a slugified version of the header text. This allows me to differentiate between different sections in my post.
  3. Find the section whose data-id is summary and inject it into the frontmatter, making it accessible to svelte.

New to remark?

The plugins I just described are the first remark plugins I had ever written. I wrote them with large help from this tutorial from Ryan Filler. If you’re new to remark plugins, I highly recommend checking it out.

Essentially, if I started with markdown like this:

markdown

## Summary
This is a summary of my post. Pretty great, right?

## Intro
Here's the first section of the post itself, where I give lots of other information.

The resulting HTML (within my svelte component) after my remark plugins ran would be something like this:

svelte

<section data-id="summary">

	<h2 data-id="summary">Summary</h2>
	<p>This is a summary of my post. Pretty great, right?</p>
	
</section>

<section data-id="intro">

	<h2 data-id="intro">Intro</h2>
	<p>Here's the first section of the post itself, where I give lots of other information.</p>
	
</section>

Markdown Frontmatter

There’s a piece of this I’ve slightly skimmed over thus far, and it’s that markdown utilizes a system of defining metadata for a page in a section referred to as “frontmatter.” This is typically written in either YAML or TOML syntax and often looks something like this:

markdown

---
title: My awesome markdown post
author: Amber
tags: [
	"javascript",
	"svelte"
]
---

MDsveX exposes the frontmatter as an object with key/value pairs so that you can use them within svelte. If I used console.log to print out the metadata for the example page described above, it would print something like this:

console.log

{
	title: 'My awesome markdown post',
	author: 'Amber',
	tags: [
		"javascript",
		"svelte"
	]
}

My remark plugins also took any content that was in the section with data-id of summary and injected that in the frontmatter of each post, so that I could more easily access the content from that section.

So, after my remark plugins all run, the printed out metadata would look like this:

javascript

{
	title: 'My awesome markdown post',
	author: 'Amber',
	tags: [
		"javascript",
		"svelte"
	],
	summary: [
		{
			id: "summary",
			contents: "<p>This is a summary of my post. Pretty great, right?</p>"
		}
	]
}

To recap, all of the metadata except summary was written in the original markdown for the post. My remark plugins make sure that whatever is in the summary section of my post also gets injected into the page’s metadata for use in my inline expandable sections.

Can’t you just put the summary in the frontmatter?

Yup, totally. I decided I didn’t want to do that, mostly because having a paragraph or more of content in the frontmatter of all of my posts felt cumbersome. But also because I wanted to figure out how this process worked in case I want to try to expand it beyond just the summary in the future.

Getting the summaries for all posts

So now, I’ve got my summary in the frontmatter of my post. I can access it within svelte. Great!

But, the whole point of the inline context is that it pulls in the context from a different page. So, I needed to make sure that the frontmatter for all posts was accessible to any post.

To do that, I used a function to get all the markdown files from within my website’s repo:

javascript

export const fetchMarkdownPosts = async () => {

	// this uses Vite's import.meta.glob 
	// to collect all markdown files
	const allPostFiles = 
		import.meta.glob('/src/routes/mdContent/*.md')

	const iterablePostFiles = Object.entries(allPostFiles)

	const allPosts = await Promise.all(
		iterablePostFiles.map(async ([path, resolver]) => {
			const { metadata } = await resolver()
			const postPath = path.slice(11, -3)

			return {
				meta: metadata,
				path: postPath,
			}
		})
	)

	return allPosts
}

Then, inside my api/+server.js file, I return the metadata for all of my posts:

+server.js

import { json } from '@sveltejs/kit'

export const GET = async () => {

	// collect all the markdown posts
	const allPosts = await fetchMarkdownPosts()

	return json({ allPosts })

}

Last, I run that script inside my +page.js for my site.

+page.js

export const load = async ({ fetch }) => {
	const response = await fetch(`/api`)
	const posts = await response.json()

		return {
			posts
		}
}

Great, now I can access the metadata for all of my pages via the page data ! We’ll come back to this.

Now that I’ve got all of my summary information for all of my posts accessible on each page, I just need to know where to inject the content. One of the cool things about MDsveX is that you can customize elements that are typically pretty standard in markdown. In my case, I wanted to do some custom things with <a> links.

In markdown, you’d designate a link like this:

markdown

[this is the linked text](https://amberlearns.com)

That is compiled as HTML like this:

html

<a href="https://amberlearns.com">this is the linked text</a>

But I wanted to conditionally parse the <a> element so that if a link was to a page outside of amberlearns.com it would just return a regular <a> link. But if it was on my site, it would pass the href value to a custom component to create my inline collapsible sections.

The simplified version is something like this:

a.svelte

<script>
import InlineCollapsible from '$layout/Inline--Collapsible.svelte'

// href is automatically passed as a prop
export let href;

// does the link point to a different site?
$: external = href?.includes('//');
</script>

{#if external}
	<a {href}>
		<slot />
	</a>
{:else}
	<InlineCollapsible {href}>
	</InlineCollapsible>
{/if}

To make sure that MDsveX used this custom <a> element instead of the standard markdown parsing pattern anytime it saw a link in markdown, I had to include the following in my +layout.svelte file (more info in the MDsveX docs .)

+layout.svelte

<script context="module">

	import { a } from './markdown';
	export { a };

</script>

Then, to get the summary inside the InlineCollapsible component, I accessed the page data and found the associated metadata and summary for the href value that was passed. Here’s a simplified version:

svelte

<script>
	// bring in our page data
	import { page } from '$app/stores';

	import { onMount } from 'svelte'

	// extract the posts from our page data
	$: ({ posts } = $page.data);

	// this will be passed through from our 
	// a.svelte file
	export let href;

	let summary;

	let isJavascriptEnabled = false;

	onMount(async () => {
		if (href && !summary) {
			// remove file extension
			const noFileHref = href.replace('.md', '');

			// find the post with matching path
			const thisRef = posts.find(d => d.path.includes(noFileHref));

			// extract the metadata and summary
			const { meta } = thisRef;
			let { summary } = meta;
		}

		// if js ran, switch this to true
		isJavascriptEnabled = true;
	}

</script>

<p>{@html summary}</p>

In reality, the way I’m displaying the summary is a bit more complex than a <p> tag. After all, it has to show and hide itself when either a mouse, tap, or keyboard triggers it. And it needs let screen reader users know whether the context was expanded or not.

I also wanted to make sure this used some form of progressive enhancement. That is, if javascript was turned off, I wanted my inline expandable sections to become regular links that direct you to the original source.

After a lot of poking around, I landed on a solution that works, but still feels a little hacky.

Before detailing it, I need to get one thing out of the way. I didn’t use a standard <button> element, but a <div> with role = button. 😬 I know, I know. This is generally no good, very bad practice. The note I wrote myself in the code literally reads:

I know, cursed “role = button” but I need it to be registered by assistive tech as a button (i.e., a clickable element that doesn’t navigate) and also wrap like “normal” text visually. Since buttons are “inline-block” elements, they don’t wrap. This is a temporary workaround til I figure out something better.

For what it’s worth, this has been listed as a CSS Working Group issue since 2018, and while it seems like using a <button> with display: contents is close to solving this issue, it has a bunch of accessibility issues, including a chromium bug that prevents keyboard focus. So, I’m still looking for something that meets all my needs better than this workaround.

Here’s a simplified version, inspired by the Collapsible Sections in Heydon Pickering’s Inclusive Components :

Inline--Collapsible.svelte

<script>
	// the script from the last code block goes here
	let expanded = false;

	let isJavascriptEnabled = false;	
</script>

<!-- if JS is on, make our sections expandable inline -->
{#if isJavascriptEnabled}
	<!-- only showing on click here, but don't forget to add all focus and keydown events too!-->
	<div class='container--expand'
		role="button"
		tabindex = "0"
		on:click={() => (expanded = !expanded)}
	>		
		
	</div>
{:else}
	<!-- if Javascript is turned off (or the user is reading this in a reader app) make this a regular link -->
	<a {href}><slot /></a>
{/if}

<!-- only add this to the DOM when expanded is true -->
{#if expanded}
<div>
	{@html summary}
</div>
{/if}

Add some styling and you’ve got it 🪄

Was it worth it?

Creating these expandable sections was a complicated process that kept me bug fixing for a super long time. To be honest, I had to fix some bugs while writing this post. I’d be lying if I said I never considered either abandoning this or using nutshells out of the box.

That said, I’m really happy with my solution. It’s not perfect, but it allows me to work and build things in a way that makes sense in my head. This process also gave me a great opportunity to learn more about Remark plugins and MDsveX which definitely does not feel like time wasted.

At the end of the day, I find the expandable sections ridiculously delightful and I’m really excited to figure out how I can use them more.