DEV Community

Penn
Penn

Posted on

how to retain position of markdown element in remark.js

remark.js is great tool that transforms markdown with plugins.

I usually combine remark-parse, remark-rehype and rehype-react to transform markdown into react components. The configuration of the processor is like:

import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkRehype from 'remark-rehype'
import rehypeReact from 'rehype-react'

const processor = unified()
  .use(remarkParse)
  .use(remarkRehype)
  .use(rehypeReact, {
    components: {
      //custom components here
    },
  })
Enter fullscreen mode Exit fullscreen mode

The problem

I recently found that the position of the markdown elements is not available in the react scope. For example, if we have a markdown file like:

# Headline
Enter fullscreen mode Exit fullscreen mode

The expected position prop should be passed to the corresponding react component like:

function Headline(props: { position: { line: { start: number; end: number } } }) {}
Enter fullscreen mode Exit fullscreen mode

This is an important feature for the markdown editor. When the user scrolls the preview view, the editor view should scroll to the corresponding position. If the position of the origin markdown element is available, then the editor knows very well which line to scroll to.

The investigation

This first thing I asked myself to do is checking whether remark already knows the position. Luck me, the answer is yes.

I learnt there by inspect the mdast(markdown-abstract-syntax-tree). Run the following test.js and it will generate the description of the tree in json format.

import { unified } from 'unified'
import remarkParse from 'remark-parse'

const processor = unified().use(remarkParse)

const markdown = `# Headline`

console.log(JSON.stringify(processor.parse(markdown), null, 2))
Enter fullscreen mode Exit fullscreen mode

And you will get the result as:

{
  "type": "root",
  "children": [
    {
      "type": "heading",
      "depth": 1,
      "children": [
        {
          "type": "text",
          "value": "Headline",
          "position": {
            "start": {
              "line": 1,
              "column": 3,
              "offset": 2
            },
            "end": {
              "line": 1,
              "column": 11,
              "offset": 10
            }
          }
        }
      ],
      "position": {
        "start": {
          "line": 1,
          "column": 1,
          "offset": 0
        },
        "end": {
          "line": 1,
          "column": 11,
          "offset": 10
        }
      }
    }
  ],
  "position": {
    "start": {
      "line": 1,
      "column": 1,
      "offset": 0
    },
    "end": {
      "line": 1,
      "column": 11,
      "offset": 10
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, the position of the headline is

{
  "start": {
    "line": 1,
    "column": 1,
    "offset": 0
  },
  "end": {
    "line": 1,
    "column": 11,
    "offset": 10
  }
}
Enter fullscreen mode Exit fullscreen mode

So which plugin remove the position object?

The plugin remark-rehype is the one that remove the position object. It generates hast(html-abstract-syntax-tree) from each mdast using specify handler. The default handlers don't expose position property to the hast. Here is the source code of the headline handler.

export function heading(state, node) {
  /** @type {Element} */
  const result = {
    type: 'element',
    tagName: 'h' + node.depth,
    properties: {},
    children: state.all(node),
  }
  state.patch(node, result)
  return state.applyData(node, result)
}
Enter fullscreen mode Exit fullscreen mode

PS: source code

The solution

The answer to the solution is custom all the handlers of remark-rehype. The following code is the custom handler for the headline.

/**
 * @typedef {import('hast').Element} Element
 * @typedef {import('mdast').Heading} Heading
 * @typedef {import('../state.js').State} State
 */
import gatherPosition from './gather-position.js'
/**
 * Turn an mdast `heading` node into hast.
 *
 * @param {State} state
 *   Info passed around.
 * @param {Heading} node
 *   mdast node.
 * @returns {Element}
 *   hast node.
 */
function gatherPosition(node) {
  return {
    [`data-startline`]: node.position.start.line,
    [`data-startcolumn`]: node.position.start.column,
    [`data-startoffset`]: node.position.start.offset,
    [`data-endline`]: node.position.end.line,
    [`data-endcolumn`]: node.position.end.column,
    [`data-endoffset`]: node.position.end.offset,
  }
}

export function heading(state, node) {
  /** @type {Element} */
  const result = {
    type: 'element',
    tagName: 'h' + node.depth,
    properties: { ...gatherPosition(node) },
    children: state.all(node),
  }
  state.patch(node, result)
  return state.applyData(node, result)
}
Enter fullscreen mode Exit fullscreen mode

The custom handler is almost the same as the default one. The only difference is that it adds the position object to the properties of the hast node.

Thanks for reading.

Top comments (0)