unified

Learn/Guide/Create a retext plugin

Create a retext plugin

This guide shows how to create a plugin for retext that checks the amount of spaces between sentences.

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 text file:

One sentence. Two sentences.

One sentence.  Two sentences.

We want to get a warning for the second paragraph, saying that one space instead of two spaces should be used.

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 example.md exists with:

One sentence. Two sentences.

One sentence.  Two sentences.

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

import fs from 'node:fs/promises'
import {retext} from 'retext'
import {reporter} from 'vfile-reporter'
import retextSentenceSpacing from './plugin.js'

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

const file = await retext()
  .use(retextSentenceSpacing)
  .process(document)

console.error(reporter(file))
(alias) module "node:fs/promises"
import fs
(alias) const retext: Processor<Root, undefined, undefined, Root, string>
import retext

Create a new unified processor that already uses retext-latin and retext-stringify.

(alias) function reporter(files: Array<VFile> | VFile, options?: Options | null | undefined): string
import reporter

Create a report from one or more files.

  • @param files Files or error to report.
  • @param options Configuration.
  • @returns Report.
(alias) function retextSentenceSpacing(): (tree: Root, file: VFile) => undefined
import retextSentenceSpacing
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) retext(): Processor<Root, undefined, undefined, Root, string>
import retext

Create a new unified processor that already uses retext-latin and retext-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 retextSentenceSpacing(): (tree: Root, file: VFile) => undefined
import retextSentenceSpacing
(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
namespace console
var console: Console

The console module provides a simple debugging console that is similar to the JavaScript console mechanism provided by web browsers.

The module exports two specific components:

  • A Console class with methods such as console.log(), console.error() and console.warn() that can be used to write to any Node.js stream.
  • A global console instance configured to write to process.stdout and process.stderr. The global console can be used without importing the node:console module.

Warning: The global console object's methods are neither consistently synchronous like the browser APIs they resemble, nor are they consistently asynchronous like all other Node.js streams. See the note on process I/O for more information.

Example using the global console:

console.log('hello world');
// Prints: hello world, to stdout
console.log('hello %s', 'world');
// Prints: hello world, to stdout
console.error(new Error('Whoops, something bad happened'));
// Prints error message and stack trace to stderr:
//   Error: Whoops, something bad happened
//     at [eval]:5:15
//     at Script.runInThisContext (node:vm:132:18)
//     at Object.runInThisContext (node:vm:309:38)
//     at node:internal/process/execution:77:19
//     at [eval]-wrapper:6:22
//     at evalScript (node:internal/process/execution:76:60)
//     at node:internal/main/eval_string:23:3

const name = 'Will Robinson';
console.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to stderr

Example using the Console class:

const out = getStreamSomehow();
const err = getStreamSomehow();
const myConsole = new console.Console(out, err);

myConsole.log('hello world');
// Prints: hello world, to out
myConsole.log('hello %s', 'world');
// Prints: hello world, to out
myConsole.error(new Error('Whoops, something bad happened'));
// Prints: [Error: Whoops, something bad happened], to err

const name = 'Will Robinson';
myConsole.warn(`Danger ${name}! Danger!`);
// Prints: Danger Will Robinson! Danger!, to err
(method) Console.error(message?: any, ...optionalParams: any[]): void

Prints to stderr with newline. Multiple arguments can be passed, with the first used as the primary message and all additional used as substitution values similar to printf(3) (the arguments are all passed to util.format()).

const code = 5;
console.error('error #%d', code);
// Prints: error #5, to stderr
console.error('error', code);
// Prints: error 5, to stderr

If formatting elements (e.g. %d) are not found in the first string then util.inspect() is called on each argument and the resulting string values are concatenated. See util.format() for more information.

  • @since v0.1.100
(alias) reporter(files: Array<VFile> | VFile, options?: Options | null | undefined): string
import reporter

Create a report from one or more files.

  • @param files Files or error to report.
  • @param options Configuration.
  • @returns Report.
const file: VFile

Don’t forget to npm install retext vfile-reporter!

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 report either a fatal error or any found linting messages.

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

When running our example (it doesn’t work yet though) we want to see a message for the second paragraph, saying that one space instead of two spaces should be used.

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 which will inspect. Let’s create them in our plugin file plugin.js:

/**
 * @import {Root} from 'nlcst'
 * @import {VFile} from 'vfile'
 */

export default function retextSentenceSpacing() {
  /**
   * @param {Root} tree
   * @param {VFile} file
   * @return {undefined}
   */
  return function (tree, file) {
  }
}
function retextSentenceSpacing(): (tree: Root, file: VFile) => undefined
  • @import *
  • @import
(parameter) tree: Root
  • @param tree
(parameter) file: VFile
  • @param file

First things first, we need to check tree for a pattern. We can use a utility to help us to recursively walk our tree, namely unist-util-visit. Let’s add that.

--- a/plugin.js
+++ b/plugin.js
@@ -3,6 +3,8 @@
  * @import {VFile} from 'vfile'
  */

+import {visit} from 'unist-util-visit'
+
 export default function retextSentenceSpacing() {
   /**
    * @param {Root} tree
@@ -10,5 +12,8 @@ export default function retextSentenceSpacing() {
    * @return {undefined}
    */
   return function (tree, file) {
+    visit(tree, 'ParagraphNode', function (node) {
+      console.log(node)
+    })
   }
 }

Don’t forget to npm install unist-util-visit.

If we now run our example with Node.js, as follows, we’ll see that visitor is called with both paragraphs in our example:

node example.js
{
  type: 'ParagraphNode',
  children: [
    { type: 'SentenceNode', children: [Array], position: [Object] },
    { type: 'WhiteSpaceNode', value: ' ', position: [Object] },
    { type: 'SentenceNode', children: [Array], position: [Object] }
  ],
  position: {
    start: { line: 1, column: 1, offset: 0 },
    end: { line: 1, column: 29, offset: 28 }
  }
}
{
  type: 'ParagraphNode',
  children: [
    { type: 'SentenceNode', children: [Array], position: [Object] },
    { type: 'WhiteSpaceNode', value: '  ', position: [Object] },
    { type: 'SentenceNode', children: [Array], position: [Object] }
  ],
  position: {
    start: { line: 3, column: 1, offset: 30 },
    end: { line: 3, column: 30, offset: 59 }
  }
}
no issues found

This output already shows that paragraphs contain two types of nodes: SentenceNode and WhiteSpaceNode. The latter is what we want to check, but the former is important because we only warn about whitespace between sentences in this plugin (that could be another plugin though).

Let’s now loop through the children of each paragraph. Only checking whitespace between sentences. We use a small utility for checking node types: unist-util-is.

--- a/plugin.js
+++ b/plugin.js
@@ -13,7 +13,23 @@ export default function retextSentenceSpacing() {
    */
   return function (tree, file) {
     visit(tree, 'ParagraphNode', function (node) {
-      console.log(node)
+      let index = -1
+
+      while (++index < node.children.length) {
+        const previous = node.children[index - 1]
+        const child = node.children[index]
+        const next = node.children[index + 1]
+
+        if (
+          previous &&
+          next &&
+          previous.type === 'SentenceNode' &&
+          child.type === 'WhiteSpaceNode' &&
+          next.type === 'SentenceNode'
+        ) {
+          console.log(child)
+        }
+      }
     })
   }
 }

If we now run our example with Node, as follows, we’ll see that only whitespace between sentences is logged.

node example.js
{
  type: 'WhiteSpaceNode',
  value: ' ',
  position: {
    start: { line: 1, column: 14, offset: 13 },
    end: { line: 1, column: 15, offset: 14 }
  }
}
{
  type: 'WhiteSpaceNode',
  value: '  ',
  position: {
    start: { line: 3, column: 14, offset: 43 },
    end: { line: 3, column: 16, offset: 45 }
  }
}
no issues found

Finally, let’s add a warning for the second whitespace, as it has more characters than needed. We can use file.message() to associate a message with the file.

--- a/plugin.js
+++ b/plugin.js
@@ -25,9 +25,15 @@ export default function retextSentenceSpacing() {
           next &&
           previous.type === 'SentenceNode' &&
           child.type === 'WhiteSpaceNode' &&
-          next.type === 'SentenceNode'
+          next.type === 'SentenceNode' &&
+          child.value.length !== 1
         ) {
-          console.log(child)
+          file.message(
+            'Unexpected `' +
+              child.value.length +
+              '` spaces between sentences, expected `1` space',
+            child
+          )
         }
       }
     })

If we now run our example one final time, we’ll see a message for our problem!

$ node example.js
3:14-3:16 warning Unexpected `2` spaces between sentences, expected `1` space

⚠ 1 warning

Further exercises

One space between sentences isn’t for everyone. This plugin could receive the preferred amount of spaces instead of a hard-coded 1.

If you want to warn for tabs or newlines between sentences, maybe create a plugin for that too?

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