how to retain position of markdown element in remark.js
Tags: markdown, remarkjs, sourcemap, react
Published: 2023-09-04 06:42:53

> [remark.js](https://github.com/remarkjs/remark) is great tool that transforms markdown with plugins.

I usually combine [remark-parse](https://github.com/remarkjs/remark), [remark-rehype](https://github.com/remarkjs/remark-rehype) and [rehype-react](https://github.com/rehypejs/rehype-react) to transform markdown into react components. The configuration of the processor is like:

```javascript
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
    },
  })
```

## 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:

```markdown
# Headline
```

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

```typescript
function Headline(props: { position: { line: { start: number; end: number } } }) {}
```

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.

```javascript
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))
```

And you will get the result as:

```json
{
  "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
    }
  }
}
```

As you can see, the position of the headline is

```json
{
  "start": {
    "line": 1,
    "column": 1,
    "offset": 0
  },
  "end": {
    "line": 1,
    "column": 11,
    "offset": 10
  }
}
```

> 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.

```javascript
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)
}
```

PS: [source code](https://github.com/syntax-tree/mdast-util-to-hast/blob/main/lib/handlers/heading.js#L20)

## 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.

```javascript
/**
 * @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)
}
```

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**.