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 aFileHandle
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
orVFile
]; any value accepted asx
innew 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 atfile.value
(see note).Note: unified typically compiles by serializing: most compilers return
string
(orUint8Array
). Some compilers, such as the one configured withrehype-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 asconsole.log()
,console.error()
andconsole.warn()
that can be used to write to any Node.js stream. - A global
console
instance configured to write toprocess.stdout
andprocess.stderr
. The globalconsole
can be used without importing thenode: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
- @see source
(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!