Notes

A Remark Plugin for Easier Code Snippets


TL;DR
I couldn’t find a way to include code snippets from separate code files in markdown documents in the way that I wanted (specified in the frontmatter and with the ability to choose which lines to import), so I created my own Remark plugin (with some help from Claude 🤖). Now I can use the shortcode :code[1-4]: to get exactly the code I want!

When writing an article that includes code snippets, it is often useful to have the complete code together in a file or a section at the end. But using markdown’s regular code blocks would mean a lot of duplicated text and possible discrepancies between the snippets and the final code (if I forget to update the code in both places).

There is already a Remark plugin to do this, but I wanted to be able to specify the code files in the frontmatter, which this plugin doesn’t support.

RemarkCodeSnippets

With my own plugin I can specify one or more code files in the frontmatter, as a string, a list of strings, or a dictionary.

---
code: "path/to/code.py"
---

<!-- or -->

---
code:
- "path/to/code.py"
- "path/to/code.R"
---

<!-- or -->

---
code:
  py: "path/to/code.py"
  r: "path/to/code.R"
---

And then I can reference the code in the markdown using :code:.

<!-- Gives the only/first code file -->
:code:

<!-- Gives the first code file -->
:code[0]:

<!-- Gives the code file named py -->
:code[py]:

It is also possible to reference a range of lines in the code file.

<!-- Gives lines 5-10 in the only/first code file -->
:code[5,10]:

<!-- Gives lines 5-10 in the first code file -->
:code[0][5,10]:

<!-- Gives lines 5-10 in the code file named py -->
:code[py][5,10]:

Example (this page)

---
title: A Remark Plugin for Easier Code Snippets
date: 2024-05-29
code:
  markdown: "src/content/notes/remark-code-snippets.md"
  plugin: "src/plugins/remark-code-snippets.ts"
---
## Example (this page)

:code[markdown][0,7]:
:code[markdown][72,79]:

## Plugin Code

:code[plugin]:

Plugin Code

import { readFile } from 'fs/promises'
import type { Root, Code, Text } from 'mdast'
import type { VFile } from 'vfile'
import { visit } from 'unist-util-visit'

export default function remarkCodeSnippets() {
  async function transform(tree: Root, file: VFile): Promise<void> {
    // Get code file/s from Astro frontmatter
    const fmCode = file.data.astro?.frontmatter?.code as string | string[] | Record<string, string>

    // If no code file/s are specified, do nothing
    if (!fmCode) return

    // Create an array to store the code file/s
    const codeList: { name: string, path: string, lines: string[], ext: string }[] = []

    const readCodeFile = async (path: string, name?: string) => {
      const fileContent = await readFile(path, 'utf8')

      return {
        name: name || 'default',
        path: path,
        ext: path.split('.').pop()?.toLowerCase() || '',
        lines: fileContent.split('\n'),
      }
    }

    if (typeof fmCode === 'string') {
      codeList.push(await readCodeFile(fmCode))
    } else if (Array.isArray(fmCode)) {
      codeList.push(...await Promise.all(fmCode.map((item) => readCodeFile(item))))
    } else {
      codeList.push(...await Promise.all(Object.entries(fmCode).map(([name, path]) => readCodeFile(path, name))))
    }

    const getSnippet = (listIndex?: string, start?: string, end?: string) => {
      let codeItem

      if (!listIndex) {
        // No index specified, use first
        codeItem = codeList[0]
      } else if (Number.isInteger(Number(listIndex)) && Number(listIndex) > 0) {
        // Index is an integer
        codeItem = codeList?.[Number(listIndex)] || codeList[0]
      } else {
        // Index is a string, use first item that matches, if no match use first
        codeItem = codeList.find(item => item.name == listIndex) || codeList[0]
      }

      if (!start || !end) return { snippet: codeItem.lines.join('\n'), ext: codeItem.ext }

      const startNum = Math.max(0, +start - 1)
      const endNum = Math.min(codeItem.lines.length, +end)

      const snippet = startNum < endNum ? codeItem.lines.slice(startNum, endNum).join('\n') : ''

      return { snippet, ext: codeItem.ext }
    }

    visit(tree, 'text', (node: Text, index, parent) => {
      const regex = /:code(?:\[(\w+)\])?(?:\[(\d+),(\d+)\])?:/g

      if (!node.value.match(regex) || !parent || index === undefined) return

      const parts = node.value.split(regex)
      const newNodes: Array<Text | Code> = []

      for (let i = 0; i < parts.length; i += 4) {
        if (parts[i]) newNodes.push({ type: 'text', value: parts[i] })

        if (i + 1 < parts.length) {
          const { snippet, ext } = getSnippet(parts[i + 1], parts[i + 2], parts[i + 3])

          newNodes.push({
            type: 'code',
            value: snippet,
            lang: ext
          })
        }
      }

      if (newNodes.length > 0) {
        parent.children.splice(index, 1, ...newNodes)
      }
    })
  }

  return transform
}