← all posts
2026.10.28 · 4 min

Vale 1.0.0; prose linting for Drupal

Drupal is 25 this year, and its code has had linters for a long time. Its words haven't, and with Drupal AI writing straight into the node form, more of them now come from a model. A search of drupal.org turns up readability scores and spellcheckers, but nothing that holds a save to your own style guide.

Vale (for Drupal) does that with the Vale prose linter. Break an error-level rule and the save is refused, or only the publish if drafts should save freely. 1.0.0 is out now for Drupal 10.3, 11 and 12, and needs the Vale binary on your server.

Doesn't CI already do this?

Vale in CI is the right tool for Markdown in a repository. Content in Drupal arrives through a save instead: the node form, a Fully Decoupled frontend over JSON:API, a workspace being published. The module checks there, on the server, whatever editor the author used.

Setting it up

drush vale:install puts the binary in bin/ beside your web root, checked against its published SHA-256:

Install the module and Vale, on a Composer site with Drush
composer require drupal/vale
drush en vale
drush vale:install

Write a .vale.ini, run bin/vale sync beside it, deploy the styles directory with the site, and point settings.php at both:

settings.php
$settings['vale_binary'] = $app_root . '/../bin/vale';
$settings['vale_config'] = $app_root . '/../.vale.ini';

// Optional.
$config['vale.settings']['publish_only'] = TRUE;
$config['vale.settings']['workspace_mode'] = 'block';
// Keyed by your Markdown text format's machine name.
$config['vale.settings']['formats'] = ['markdown' => 'md'];

Then tick Check with Vale under Prose checks in a text field's settings, and the Status report confirms it.

Each field can set a minimum level, or change any rule's level or turn it off. With publish-only mode on, drafts save freely and the publish is held to the rules, following Content Moderation when it's installed.

On druxtjs.org: a hedge and a cliché in a text paragraph, and the publish refused until they go.

Writing your own rules

Published styles are a start, but your own rules matter most: product names, words your organisation avoids, phrases your editors overuse. Each rule is a short YAML file in a folder under styles, kept in your codebase and deployed with the site:

styles/House/Terms.yml
extends: substitution
message: "Use '%s' instead of '%s'."
level: error
ignorecase: false
swap:
  drupal: Drupal
  headless: Fully Decoupled

Add House to BasedOnStyles in .vale.ini, and a save with "headless drupal" in it is refused. drush vale:check node.article.body "Some text." tries a rule against a field before an editor meets it. Levels are the site builder's call, so start a new rule at warning and promote it once you trust it.

Vale's styles documentation covers the other checks, from banned words to sentence length, and you can browse published styles in Vale's explorer. The rules this post was held to are in this site's .vale.ini.

Over JSON:API

This is the part I built it for. On a Fully Decoupled site, a Druxt or Nuxt frontend writes over JSON:API, and the check runs in the entity's validation, which those writes go through as well as the form. Post a body with a repeated word, with Vale's built-in style on, and you get a 422 that points at the field:

POST /jsonapi/node/article, with "Check the the docs." in the body
{
  "errors": [
    {
      "title": "Unprocessable Content",
      "status": "422",
      "detail": "body.0.value: Body: Vale.Repetition: 'the' is repeated! (line 1: \"the the\")",
      "source": {
        "pointer": "/data/attributes/body/value"
      }
    }
  ]
}

Several errors in one value share that entry, one per line of detail, each naming its field. With publish-only mode on, a refused publish comes back on the entity, at /data, still naming the field. Warnings and suggestions come back in meta.vale, on a refused write or a successful one, with checked false if Vale couldn't run:

meta.vale on a 201, with a warning
{
  "meta": {
    "vale": {
      "checked": true,
      "alerts": [
        {
          "entity_type": "node",
          "bundle": "article",
          "id": "a22048fc-b0ec-4a9b-a80b-14be85498d81",
          "field": "body",
          "delta": 0,
          "rule": "Microsoft.Adverbs",
          "level": "warning",
          "message": "Remove 'very' if it's not important to the meaning of the statement.",
          "line": 1,
          "start": 9,
          "end": 12,
          "match": "very",
          "description": "",
          "link": "https://learn.microsoft.com/en-us/style-guide/word-choice/use-simple-words-concise-sentences"
        }
      ]
    }
  }
}

So a Fully Decoupled editor gets the same feedback as the node form, without rebuilding the rules in JavaScript.

Checking before the save

POST /vale/check returns the alerts a field would get, with the sentence around each, and saves nothing. It's built for an editor that checks as you type, in Druxt or anywhere else.

POST /vale/check
{
  "entity_type": "node",
  "bundle": "article",
  "field": "body",
  "format": "basic_html",
  "text": "<p>Check the the docs. This step is very easy to miss.</p>"
}
The response
{
  "checked": true,
  "publish_only": true,
  "alerts": [
    {
      "rule": "Vale.Repetition",
      "level": "error",
      "message": "'the' is repeated!",
      "match": "the the",
      "sentence": "Check the the docs."
    },
    {
      "rule": "Microsoft.Adverbs",
      "level": "warning",
      "message": "Remove 'very' if it's not important to the meaning of the statement.",
      "match": "very",
      "sentence": "This step is very easy to miss.",
      "link": "https://learn.microsoft.com/en-us/style-guide/word-choice/use-simple-words-concise-sentences"
    }
  ]
}

A caller needs the Use the Vale check endpoint permission and edit access to the field, and a cookie session sends an X-CSRF-Token.

Before a workspace goes live

Before a workspace is published, the module checks everything in it. With workspace_mode set to block, the publish is refused while anything has an error or a value can't be checked, even though an ordinary save goes through when Vale can't run. The default, advise, publishes and lists the findings.

Publish-only mode and workspaces suit each other. Inside a workspace every save is a draft, including an edit to a page that's already live, so editors save freely and the workspace's publish is where the rules apply, in advise mode as well as block. drush vale:lint --workspace=stage runs the same check from a deploy script.

Publishing the Stage workspace, refused: two errors in a draft, and the warning listed beside them.

Before you install

Vale ships as one binary, and it has to be on every web server and in the CLI container. drush vale:install covers Linux and macOS. On a container host, build it into every image that serves Drupal or runs Drush, as druxtjs.org does on Lagoon, and add gcompat on Alpine. Other managed hosts are on the 1.1.0 list.

On a test site, Vale added about 55 ms to an uncached JSON:API save of a short paragraph, and nothing when the text hadn't changed, because results are cached until the text or the styles do. If Vale is missing or times out, the content saves and the author is told it wasn't checked, unless you set fail_closed.

For saves that skip validation, like a migration, drush vale:lint checks the content afterwards and exits with 1, so a deploy can depend on it. Any text field you opt in is checked, on a Drupal CMS site too. The title is a base field rather than one you opt in, and Canvas keeps its text in component props, so neither is checked.

The code is on GitHub, and the issue queue is on Drupal.org. If your editors rely on Vale, sponsoring is what funds the next release.

Next up are hooks for other modules and summary fields, with AI-suggested rewrites further out. What would you want it to do for your editors?

Read next

All posts

Discussion

via GitHub Discussions