unified

Learn/Guide/Create a remark plugin

Create a remark plugin

This guide shows how to create a plugin for remark that turns emoji shortcodes (gemoji, such as :+1:) into Unicode emoji (👍). It looks for a regex in the text and replaces it.

Stuck? Have an idea for another guide? See support.md.

Contents

Case

Before we start, let’s first outline what we want to make. Say we have the following file:

Look, the moon :new_moon_with_face:

And we’d like to turn that into:

Look, the moon 🌚

In the next step we’ll write the code to use our plugin.

Setting up

Let’s set up a project. Create a folder, example, enter it, and initialize a new project:

mkdir example
cd example
npm init -y

Then make sure the project is a module, so that import and export work, by changing package.json:

--- a/package.json
+++ b/package.json
@@ -1,6 +1,7 @@
 {
   "name": "example",
   "version": "1.0.0",
+  "type": "module",
   "main": "index.js",
   "scripts": {
     "test": "echo \"Error: no test specified\" && exit 1"

Make sure input.md exists with:

Look, the moon :new_moon_with_face:

Now, let’s create an example.js file that will process our file and report any found problems.

import fs from 'node:fs/promises'
import {remark} from 'remark'
import remarkGemoji from './plugin.js'

const document = await fs.readFile('input.md', 'utf8')

const file = await remark().use(remarkGemoji).process(document)

await fs.writeFile('output.md', String(file))
(alias) module "node:fs/promises"
import fs
(alias) const remark: Processor<Root, undefined, undefined, Root, string>
import remark

Create a new unified processor that already uses remark-parse and remark-stringify.

(alias) function remarkGemoji(): (tree: Root) => undefined
import remarkGemoji
const document: string
(alias) module "node:fs/promises"
import fs
function readFile(path: PathLike | fs.FileHandle, options: ({
    encoding: BufferEncoding;
    flag?: OpenMode | undefined;
} & EventEmitter<T extends EventMap<T> = DefaultEventMap>.Abortable) | BufferEncoding): Promise<string> (+2 overloads)

Asynchronously reads the entire contents of a file.

  • @param path A path to a file. If a URL is provided, it must use the file: protocol. If a FileHandle is provided, the underlying file will not be closed automatically.
  • @param options An object that may contain an optional flag. If a flag is not provided, it defaults to 'r'.
const file: VFile
(alias) remark(): Processor<Root, undefined, undefined, Root, string>
import remark

Create a new unified processor that already uses remark-parse and remark-stringify.

(method) Processor<Root, undefined, undefined, Root, string>.use<[], Root, undefined>(plugin: Plugin<[], Root, undefined>, ...parameters: [] | [boolean]): Processor<Root, Root, undefined, Root, string> (+2 overloads)

Configure the processor to use a plugin, a list of usable values, or a preset.

If the processor is already using a plugin, the previous plugin configuration is changed based on the options that are passed in. In other words, the plugin is not added a second time.

Note: use cannot be called on frozen processors. Call the processor first to create a new unfrozen processor.

  • @example There are many ways to pass plugins to .use(). This example gives an overview:
    import {unified} from 'unified'
    
    unified()
      // Plugin with options:
      .use(pluginA, {x: true, y: true})
      // Passing the same plugin again merges configuration (to `{x: true, y: false, z: true}`):
      .use(pluginA, {y: false, z: true})
      // Plugins:
      .use([pluginB, pluginC])
      // Two plugins, the second with options:
      .use([pluginD, [pluginE, {}]])
      // Preset with plugins and settings:
      .use({plugins: [pluginF, [pluginG, {}]], settings: {position: false}})
      // Settings only:
      .use({settings: {position: false}})
    
  • @template {Array} [Parameters=[]]
  • @template {Node | string | undefined} [Input=undefined]
  • @template [Output=Input]
  • @overload
  • @overload
  • @overload
  • @param value Usable value.
  • @param parameters Parameters, when a plugin is given as a usable value.
  • @returns Current processor.
(alias) function remarkGemoji(): (tree: Root) => undefined
import remarkGemoji
(method) Processor<Root, Root, undefined, Root, string>.process(file?: Compatible | undefined): Promise<VFile> (+1 overload)

Process the given file as configured on the processor.

Note: process freezes the processor if not already frozen.

Note: process performs the parse, run, and stringify phases.

  • @overload
  • @overload
  • @param file File (optional); typically string or VFile]; any value accepted as x in new VFile(x).
  • @param done Callback (optional).
  • @returns Nothing if done is given. Otherwise a promise, rejected with a fatal error or resolved with the processed file. The parsed, transformed, and compiled value is available at file.value (see note).

    Note: unified typically compiles by serializing: most compilers return string (or Uint8Array). Some compilers, such as the one configured with rehype-react, return other values (in this case, a React tree). If you’re using a compiler that doesn’t serialize, expect different result values.

    To register custom results in TypeScript, add them to {@linkcode CompileResultMap}.

const document: string
(alias) module "node:fs/promises"
import fs
function writeFile(file: PathLike | fs.FileHandle, data: string | NodeJS.ArrayBufferView | Iterable<string | NodeJS.ArrayBufferView> | AsyncIterable<string | NodeJS.ArrayBufferView> | internal.Stream, options?: (ObjectEncodingOptions & {
    mode?: Mode | undefined;
    flag?: OpenMode | undefined;
    flush?: boolean | undefined;
} & EventEmitter<T extends EventMap<...> = DefaultEventMap>.Abortable) | BufferEncoding | null): Promise<void>

Asynchronously writes data to a file, replacing the file if it already exists. data can be a string, a buffer, an AsyncIterable, or an Iterable object.

The encoding option is ignored if data is a buffer.

If options is a string, then it specifies the encoding.

The mode option only affects the newly created file. See fs.open() for more details.

Any specified FileHandle has to support writing.

It is unsafe to use fsPromises.writeFile() multiple times on the same file without waiting for the promise to be settled.

Similarly to fsPromises.readFile - fsPromises.writeFile is a convenience method that performs multiple write calls internally to write the buffer passed to it. For performance sensitive code consider using fs.createWriteStream() or filehandle.createWriteStream().

It is possible to use an AbortSignal to cancel an fsPromises.writeFile(). Cancelation is "best effort", and some amount of data is likely still to be written.

import { writeFile } from 'node:fs/promises';
import { Buffer } from 'node:buffer';

try {
  const controller = new AbortController();
  const { signal } = controller;
  const data = new Uint8Array(Buffer.from('Hello Node.js'));
  const promise = writeFile('message.txt', data, { signal });

  // Abort the request before the promise settles.
  controller.abort();

  await promise;
} catch (err) {
  // When a request is aborted - err is an AbortError
  console.error(err);
}

Aborting an ongoing request does not abort individual operating system requests but rather the internal buffering fs.writeFile performs.

  • @since v10.0.0
  • @param file filename or FileHandle
  • @return Fulfills with undefined upon success.
var String: StringConstructor
(value?: any) => string

Allows manipulation and formatting of text strings and determination and location of substrings within strings.

const file: VFile

Don’t forget to npm install remark!

If you read the guide on using unified, you’ll see some familiar statements. First, we load dependencies, then we read the file in. We process that file with the plugin we’ll create next and finally we write it out again.

Note that we directly depend on remark. This is a package that exposes a unified processor, and comes with the markdown parser and markdown compiler attached.

Now we’ve got everything set up except for the plugin itself. We’ll do that in the next section.

Plugin

We’ll need a plugin, and for our case also a transform. Let’s create them in our plugin file plugin.js:

/**
 * @import {Root} from 'mdast'
 */

/**
 * Turn gemoji shortcodes (`:+1:`) into emoji (`👍`).
 *
 * @returns
 *   Transform.
 */
export default function remarkGemoji() {
  /**
   * @param {Root} tree
   * @return {undefined}
   */
  return function (tree) {
  }
}
function remarkGemoji(): (tree: Root) => undefined

Turn gemoji shortcodes (:+1:) into emoji (👍).

  • @returns Transform.
(parameter) tree: Root
  • @param tree

That’s how most plugins start. A function that returns another function.

For this use case, we could walk the tree and replace nodes with unist-util-visit, which is how many plugins work. But a different utility is even simpler: mdast-util-find-and-replace. It looks for a regex and lets you then replace that match.

Let’s add that.

--- a/plugin.js
+++ b/plugin.js
@@ -2,6 +2,8 @@
  * @import {Root} from 'mdast'
  */

+import {findAndReplace} from 'mdast-util-find-and-replace'
+
 /**
  * Turn gemoji shortcodes (`:+1:`) into emoji (`👍`).
  *
@@ -14,5 +16,16 @@ export default function remarkGemoji() {
    * @return {undefined}
    */
   return function (tree) {
+    findAndReplace(tree, [
+      /:(\+1|[-\w]+):/g,
+      /**
+       * @param {string} _
+       * @param {string} $1
+       * @return {undefined}
+       */
+      function (_, $1) {
+        console.log(arguments)
+      }
+    ])
   }
 }

Don’t forget to npm install mdast-util-find-and-replace!

If we now run our example with Node.js, we’ll see that console.log is called:

node example.js
[Arguments] {
  '0': ':new_moon_with_face:',
  '1': 'new_moon_with_face',
  '2': {
    index: 15,
    input: 'Look, the moon :new_moon_with_face:',
    stack: [ [Object], [Object], [Object] ]
  }
}

This output shows that the regular expression matches the emoji shortcode. The second argument is the name of the emoji. That’s what we want.

We can look that name up to find the corresponding Unicode emoji. We can use the gemoji package for that. It exposes a nameToEmoji record.

--- a/plugin.js
+++ b/plugin.js
@@ -2,6 +2,7 @@
  * @import {Root} from 'mdast'
  */

+import {nameToEmoji} from 'gemoji'
 import {findAndReplace} from 'mdast-util-find-and-replace'

 /**
@@ -21,10 +22,10 @@ export default function remarkGemoji() {
       /**
        * @param {string} _
        * @param {string} $1
-       * @return {undefined}
+       * @return {string | false}
        */
       function (_, $1) {
-        console.log(arguments)
+        return Object.hasOwn(nameToEmoji, $1) ? nameToEmoji[$1] : false
       }
     ])
   }

Don’t forget to npm install gemoji!

If we now run our example again with Node…

node example.js

…and open output.md, we’ll see that the shortcode is replaced with the emoji!

Look, the moon 🌚

That’s it! For a complete version of this plugin, see remark-gemoji.

If you haven’t already, check out the other articles in the learn section!