Strategies

Pluggable translation strategies – pick DeepL, Copilot AI, or a callable, or implement your own to route through any service.

Built-in strategies handle batching and partial failures – per-unit failures keep source text and emit a warning hook; only zero survivors throw.

The Interface

namespace JohannSchopplich\ContentTranslator\Translation;

interface Strategy
{
    /**
     * @param list<TranslationUnit> $units
     * @return list<string|null>
     *
     * @throws TranslationException When zero units could be translated
     */
    public function execute(array $units, ExecutionOptions $options): array;
}

The contract is short: return one result per input in the same order, and stay stateless across calls. A unit you could not translate returns as null – the caller keeps its source text and records a rejection, so it reaches translation results instead of disappearing. Throw TranslationException only when zero units survived.

Two things are handled for you, so a custom strategy never has to repeat them:

  • Untranslatable text never arrives. Blanks, pure numbers, standalone URLs, and prose that is only KirbyTag placeholders are filtered out before execute() is called, and their source text is spliced back into the result. A batch with nothing else left never calls the strategy at all – so $units is never empty.
  • The result is validated afterwards. Any unit that lost, invented, or repeated a <cN/> token is reverted to source and recorded as a rejection, whichever strategy produced it. So is any slot that comes back null, blank, or as a non-string. Returning an empty string does not blank the field; it counts as a failed unit. The warning hook fires for the checks run here, but not for a slot you deliberately returned as null: you already know the reason, so emitting it is yours (see below).

DeepLStrategy

Selected by 'strategy' => 'deepl'. It is also the default when neither strategy nor the deprecated translateFn is configured, whether or not DeepL.apiKey is set. A missing key surfaces as AuthException on the first translation.

The whole batch fails together: when DeepL rejects the request, every unit fires content-translator.translate:warning and TranslationException is thrown.

public function __construct(DeepL|null $deepL = null)
deepL
DeepL | null
Inject a custom DeepL client – useful in tests, when you want non-singleton instances, or to override language mapping. Defaults to DeepL::instance().
use JohannSchopplich\ContentTranslator\Translation\Strategies\DeepLStrategy;
use JohannSchopplich\ContentTranslator\Translator;

Translator::translateText('Hello', 'de', 'en', new DeepLStrategy());
Build a custom DeepL client inside Kirby's ready callback; at the top level of config.php the plugin options are not readable yet, so new DeepL() throws AuthException: Missing DeepL API key even though the key sits right next to it.
For request-option tuning (formality, glossary IDs, custom tag_handling), see the DeepL client reference.

CopilotAIStrategy

Context-aware AI translation through any Kirby Copilot provider. Selected by 'strategy' => 'ai', or by passing a CopilotAIStrategy instance directly. Provider credentials stay in Kirby Copilot; this plugin never talks to an AI provider itself.

Units are sent in batches; a batch whose response is malformed keeps its source text and is reported as rejections. Each rejected unit emits a content-translator.translate:warning event – see Rejection Reasons for the reasons it reports.

Selecting 'strategy' => 'ai' without the Copilot plugin installed throws LogicException.
public function __construct(
    Client|null $client = null,
    string|null $systemPrompt = null,
)
client
\\JohannSchopplich\\Copilot\\AI\\Client | null
Inject a Copilot client. Defaults to Client::instance().
systemPrompt
String | null
Override the system prompt for this instance. Falls back to johannschopplich.content-translator.ai.systemPrompt, then to the built-in default – see Custom System Prompt.
use JohannSchopplich\ContentTranslator\Translation\Strategies\CopilotAIStrategy;
use JohannSchopplich\ContentTranslator\Translator;

Translator::translateText('Hello', 'de', 'en', new CopilotAIStrategy());

CallableStrategy

Wraps a closure as a Strategy – the simplest extension point and the migration path off the old translateFn option. A closure set as the strategy option is wrapped automatically.

The closure runs once per text – no batching. An exception it throws reaches your code as-is – TranslationException comes from the built-in strategies only. CallableStrategy is fine for low-volume sites. For batching, per-field dispatch, or structured exceptions, implement a custom strategy.

public function __construct(Closure $translate)
translate
Closure(string $text, string $target, ?string $source): string
Translation function. Returns the translated text. The same signature as the legacy translateFn.
site/config/config.php
use JohannSchopplich\ContentTranslator\Translation\Strategies\CallableStrategy;

return [
    'johannschopplich.content-translator' => [
        'strategy' => new CallableStrategy(
            fn (string $text, string $target, ?string $source) => myTranslateFunction($text, $target, $source),
        ),
    ],
];

Strategy Resolution

Translator resolves the active strategy in this order, first match wins:

1. Method Parameter

The ?Strategy $strategy argument on translateText, translateTexts, and translateContent.

Translator::translateText('Hello', 'de', 'en', new DeepLStrategy());

2. strategy Config Option

site/config/config.php
return [
    'johannschopplich.content-translator' => [
        'strategy' => 'deepl',
    ],
];

Four value types are accepted: the string presets 'deepl' and 'ai', a closure with the signature of CallableStrategy, or an instance of any class implementing Strategy.

3. Legacy translateFn deprecated

Kept for back-compat. Wrapped in CallableStrategy automatically.

4. Default

new DeepLStrategy().

Implementing a Custom Strategy

Implement the interface, attempt each unit, and only throw when zero units survive – matching the failure pattern of the built-in strategies:

use JohannSchopplich\ContentTranslator\Translation\Exception\TranslationException;
use JohannSchopplich\ContentTranslator\Translation\ExecutionOptions;
use JohannSchopplich\ContentTranslator\Translation\Strategy;
use Kirby\Cms\App;
use Throwable;

final class MyApiStrategy implements Strategy
{
    public function execute(array $units, ExecutionOptions $options): array
    {
        // A unit left `null` keeps its source text and is reported as a rejection
        $results = array_fill(0, count($units), null);
        $translatedCount = 0;
        $lastError = null;

        foreach ($units as $i => $unit) {
            try {
                $results[$i] = myTranslate(
                    text: $unit->text,
                    target: $options->targetLanguage->code,
                    source: $options->sourceLanguage?->code,
                );
                $translatedCount++;
            } catch (Throwable $error) {
                $lastError = $error;
                App::instance()->trigger('content-translator.translate:warning', [
                    'unit' => $unit,
                    'reason' => $error->getMessage(),
                    'previous' => $error,
                ]);
            }
        }

        if ($translatedCount === 0) {
            throw new TranslationException(
                strategy: 'my-api',
                reason: $lastError?->getMessage() ?? 'unknown error',
                unitsAttempted: count($units),
            );
        }

        return $results;
    }
}

The three moves to remember:

  1. Leave a failed unit null – the caller keeps its source text and records the rejection, so it shows up in translation results instead of passing as a translation. Pre-filling with source text still works, but the unit passes every check and is counted as translated, so the rejection never reaches the result.
  2. Emit content-translator.translate:warning for each unit you return as null – nothing else does, and the recorded rejection only says missing translation.
  3. Throw TranslationException only when zero units survived – partial success is success.

Wire it up:

site/config/config.php
return [
    'johannschopplich.content-translator' => [
        'strategy' => new MyApiStrategy(),
    ],
];

This works as long as the constructor stays inert. A constructor that reads Kirby options or calls App::instance() runs too early here – wrap the instantiation in Kirby's ready callback instead, as shown under DeepLStrategy.