Extending RedPen

Three events, all on the plugin’s services. Between them they cover the three things a site is likely to need: a rule of your own, a field type RedPen does not know about, and a validation backend it has never heard of.

A rule of your own

A rule takes a Fragment and returns Issues. It touches no database and no Craft services, which is why the whole library is testable without booting an application — and why yours will be too.

use justinholtweb\redpen\models\Issue;
use justinholtweb\redpen\rules\PhraseRule;

class NoFirstPersonRule extends PhraseRule
{
    public function id(): string { return 'style.no-first-person'; }
    public function label(): string { return 'No first person'; }
    public function category(): string { return Issue::CATEGORY_STYLE; }

    protected function phrases(): array
    {
        return ['I think' => null, 'in my opinion' => null];
    }
}

Register it:

use justinholtweb\redpen\events\RegisterRulesEvent;
use justinholtweb\redpen\services\Rules;
use yii\base\Event;

Event::on(Rules::class, Rules::EVENT_REGISTER_RULES, function(RegisterRulesEvent $event) {
    $event->rules[] = new NoFirstPersonRule();
});

Reuse a built-in id to replace that rule entirely — which is the supported way to change behaviour the options do not reach.

Writing a rule from scratch

Extend BaseRule and implement check(Fragment $fragment): array. Two methods decide where your rule runs:

  • prosePartOnly() — return true for sentence-scoped rules, so they skip titles, labels and table cells. Word-level rules must return false, or a banned term in a headline is silently ignored.
  • worksInSourceSpace() — return true if your rule inspects markup or whitespace. The prose pass strips tags and collapses whitespace, so a rule that checks for double spaces finds nothing there.

Also implement options() if the rule should be configurable per profile, and enabledByDefault() if it should ship off.

A field type RedPen does not know

Extractors map a field class to the code that pulls text out of it. Ship one for your custom field and it works everywhere the built-ins do — including nested inside Matrix.

use justinholtweb\redpen\events\RegisterExtractorsEvent;
use justinholtweb\redpen\services\Extractors;
use yii\base\Event;

Event::on(Extractors::class, Extractors::EVENT_REGISTER_EXTRACTORS, function(RegisterExtractorsEvent $event) {
    $event->extractors[MyCustomField::class] = new MyCustomFieldExtractor();
});

An extractor returns Fragments. If your field holds HTML, build the fragment from HtmlText so findings map back to offsets in the source and click-to-highlight keeps working.

A whole validation backend

A checker takes an array of fragments and a profile, and returns issues. This is how the LanguageTool and LLM backends are implemented, and there is nothing privileged about them:

use justinholtweb\redpen\events\RegisterCheckersEvent;
use justinholtweb\redpen\services\Checkers;
use yii\base\Event;

Event::on(Checkers::class, Checkers::EVENT_REGISTER_CHECKERS, function(RegisterCheckersEvent $event) {
    $event->checkers['my-service'] = new MyServiceChecker();
});

Then add its handle to backends. Implement isAvailable() honestly — return false when unconfigured, and RedPen skips it rather than failing the whole check.

Testing your extension

Rules, extractors and response parsing are plain PHP over plain value objects, and network access sits behind HttpClientInterface. That means you can test a rule by constructing a Fragment and asserting on the returned issues, with no database, no application boot and no fixtures.

One gotcha: do not call Craft::t() or Craft::warning() from a rule. The Craft class is not autoloadable on its own, so it breaks exactly the standalone testing that makes rules cheap to write. RedPen’s own Log class exists as the seam for this — copy the pattern if you need it.