> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fetchski-attract.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Translate your whole site into a new language, with Laravel

> One Artisan command adds a language to your site and has a Laravel AI SDK agent translate every page, blog post and FAQ, and your header, footer and cookie banner, into it, as drafts with private preview links, keeping your links, images and names.

```php app/Ai/Agents/Translator.php wrap theme={"dark"}
<?php

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\MaxTokens;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;

/**
 * Translates one document's words, given as JSON: the language, the glossary
 * and the texts under their keys. It answers every key, translated, in one
 * step. When the code found problems in its last answer, the prompt carries
 * them, and it answers again with every key.
 */
#[Provider(Lab::OpenRouter)]
#[MaxTokens(16000)]
class Translator implements Agent, HasStructuredOutput
{
    use Promptable;

    public function model(): string
    {
        return config('site-translate.model');
    }

    /**
     * Get the instructions that the agent should follow.
     */
    public function instructions(): Stringable|string
    {
        return <<<'TEXT'
            You translate a small business's website, one document at a time, for its own visitors.

            - You get the language, a glossary and the document's texts, each under a key. Answer
              every key, each with its text translated into the language.
            - Write as a native speaker would for this business: natural, warm, plain. Keep the
              meaning, the facts and the tone. Never add or drop information.
            - Keep every glossary name exactly as written, in any text.
            - Keep every word in braces, such as {year} or {business}, exactly as written: the
              site fills it in.
            - Rich text is HTML: translate the words between the tags, and keep every tag, attribute
              and link exactly as it is.
            - Leave a text as it is when it isn't words for a visitor (a code, a hashtag, a product
              name) or is already in the language.
            - When the prompt has `problems` with `yourLastAnswer`, fix each problem and answer with
              every key again.
            TEXT;
    }

    /**
     * Get the agent's structured output schema definition.
     */
    public function schema(JsonSchema $schema): array
    {
        $text = $schema->object(fn (JsonSchema $schema): array => [
            'key' => $schema->string()
                ->required(),
            'text' => $schema->string()
                ->required(),
        ]);

        return [
            'texts' => $schema->array()
                ->items($text)
                ->required(),
        ];
    }
}
```

## The situation

More and more of your customers would rather read about you in their own language. Translating the
site by hand means copying every page, post and FAQ into the new language, then the header, footer
and cookie banner, one at a time, and keeping up as the originals change. One command does all of it: it adds the language to your site, copies
everything into it, translates the words and leaves each one as a draft with a preview link. Your
links, images and business name stay as they are. You read the drafts, publish them, and put the
language live when you're happy.

## What you'll build

An Artisan command, `php artisan site:translate fr "Français"`, in your own Laravel app. For each
page, blog post and FAQ it copies the document into the new language with the Fetchski Attract SDK,
then hands its words to a Laravel AI SDK agent, which answers them translated. Your code checks every
answer: a translation that loses a text, leaves a sentence in the main language, changes an HTML
tag or a link, or translates a name from your glossary goes back to the agent with the problems named. Only a translation that passes is
written, as a draft. Your header, footer and cookie banner get the same treatment: the site names
which of its settings hold those words, the agent translates just those, and your code points each
of their links at the page in the new language. Your code, not the agent, chooses what to
translate, sends it for review and makes the preview links. Nothing goes live until a person
publishes it.

```mermaid theme={"dark"}
flowchart LR
    A[php artisan<br/>site:translate fr] --> B[Add the language,<br/>not public]
    B --> C[List pages, posts,<br/>FAQs and the<br/>site settings]
    C -- up to date --> X[Skipped]
    C -- new or changed --> D[Copy into the<br/>language, with what<br/>each one shows]
    D --> E[Agent translates<br/>the words]
    E --> F{Checks pass?}
    F -- no: the problems --> E
    F -- yes --> G[Draft saved +<br/>preview link]
    G --> H[You read and<br/>publish them]
    H -- --publish --> I[Language<br/>public]
```

> [card] Only what changed
>
> Run it again any time. A translation is redone only when its original changed since it was
> copied, so a second run on an unchanged site makes no model calls and costs nothing.

> [card] Links, images and names kept
>
> The agent sees only the words. Every link, image, color and setting stays as it was, and your
> code refuses a translation that changes an HTML tag, a link, a name from your glossary or a
> `{year}` the site fills in, or hands a sentence back untranslated.

> [card] Drafts, never live
>
> Every translation is a draft in **Submitted Drafts**. The new language stays private, so only
> people signed in to the CMS see it, until you run the command with `--publish`.

> [card] Spend counted exactly
>
> Each model call is one step, so OpenRouter's answer carries its whole cost. The run adds up its
> tokens and dollars, prints them, and starts no new document past \$10.

> [!NOTE]
>
> **Before you start**, you need:
>
> * **A Fetchski Attract site** you can sign in to as someone who may manage API keys and languages.
> * **PHP 8.5 and Composer**, with the Laravel installer (`composer global require laravel/installer`).
>   This page was built with Laravel 13.42, the Laravel AI SDK 1.4 and Pest.
> * **An OpenRouter account with credit and an API key** ([openrouter.ai/settings/keys](https://openrouter.ai/settings/keys)).
>   The agent runs on `~anthropic/claude-sonnet-latest`, OpenRouter's name for the newest Claude
>   Sonnet, so it moves to each new Sonnet by itself. When you make the key, give it a credit limit
>   (for example \$20), so nothing can spend more.
> * **About 30 minutes** to set it up.

## Make the API key

The app gets its own key, with only what the translation needs. It can add a language and write
drafts, but it can't publish a page, a post or an FAQ.

**1. Open API Keys**

Sign in to your site's CMS at `https://acme.example/cms-login`. Press **⌘K**
(**Ctrl+K** on Windows and Linux), type `API Keys` and press **Enter**.
Click **Create API Key**.

**2. Name it**

**Label**: `Translate the site`.

**3. Pick the Site translation pack**

Under **Scopes**, keep **Start from a pack** and pick the **Site translation** pack. It ticks
these thirteen scopes:

| Scope | Why |
| - | - |
| `languages:read` | See which languages the site has |
| `languages:write` | Add the new language, and make it public with `--publish` |
| `content:pages:write` | Read, copy, translate and share your pages, and send them for review |
| `content:blog:write` | The same for your blog posts |
| `content:faq-items:write` | The same for your FAQs |
| `global:write` | Read and translate your header, footer and cookie banner (Site settings and Site design), and send them for review |
| `content:authors:write` | Copy and translate the authors your posts credit |
| `content:blog-categories:write` | Copy and translate your blog's categories |
| `content:faq-categories:write` | Copy and translate your FAQ categories |
| `content:forms:write` | Copy and translate the forms your pages show |
| `content:photo-albums:write` | Copy and translate the photo albums your pages show |
| `content:images:write` | Copy your pages' images into the language (the same pictures; images aren't translated) |
| `content:videos:write` | Copy your pages' videos into the language, the same way |

Add nothing else. Without `content:pages:publish` (and its blog and FAQ twins)
the key can't take a translation live, and without `global:publish` it can't take the new
header and footer live either. `global:write` could change any of your site's settings: the
command writes only the words and links each language keeps its own copy of.

Check **Granted** before you create the key: it should list exactly the 13 scopes above.
Your site's packs can be edited, so if it shows any other scope (a publish or delete one, say),
take it out with **Edit scopes**, or have someone who manages API keys choose **Restore default
packs** in **Scope Packs**.

No **Site translation** pack in the list? Someone who manages API keys restores it: ⌘K, **Scope
Packs**, **Restore default packs**. Or choose **Pick scopes one by one** and tick the thirteen
scopes above.

**4. Pick the signing mode**

**Request signing**: **Token + signature on writes**. The SDK signs each write for you with the
key's signing secret.

**5. Create it and copy both values**

Click **Create key**. Under **Your API key is ready**, copy the **Secret token** (`fsk_acme_…`) and
the **Signing secret** (`fss_…`). They are shown once: keep them for steps 2 and 3 below.

## Step by step

**1. Create the app**

```bash Terminal theme={"dark"}
laravel new site-translate --pest --database=sqlite --no-node --no-boost --no-interaction
cd site-translate
```

**Why:** a plain Laravel app with Pest for the tests. The command needs no front end, so no
starter kit and no Node build; the app starts on SQLite, which this Recipe doesn't use.

**You should see:** an **Application ready** box ending `Build something amazing!`.

**2. Install the SDKs**

Your own site serves the SDK, and any API key from it lets you in. In a terminal, in your Laravel
app's folder, run:

```bash Terminal theme={"dark"} theme={"dark"}
composer config repositories.fetchski composer https://acme.example/api/v1/sdk/composer
composer config bearer.acme.example fsk_acme_…
composer require fetchski/attract-cms-laravel
```

The first line tells Composer the SDK comes from your site (it's saved in `composer.json`). The second
saves your key in the app's `auth.json`. The third installs the SDK, and the API client it uses, from
your site. Put your site's address in the first two lines (set your site in the bar under this page's
title and the example shows it), and paste your whole key in place of `fsk_acme_…`.

> [!NOTE]
>
> **Any API key from your site works**, the same one your app uses included: its scopes and signing
> mode don't matter for installing, only that it's switched on and not past its expiry date. No key
> yet? Make one in the CMS under **API Keys** ([Authentication](https://docs.fetchski-attract.com/authentication.md)).

> [!WARNING]
>
> **`auth.json` holds a key to your site: never commit it.** A Laravel app's `.gitignore` already
> lists `/auth.json`; keep that line there (add it if yours doesn't have it). In CI, write the same
> `auth.json` from a secret before installing.

> [!TIP]
>
> **`401 Unauthorized`?** Your site refused the key in `auth.json`: it's mistyped or cut short,
> switched off, or past its expiry date. Run the second line again with the right key.

Then add the Laravel AI SDK:

```bash Terminal theme={"dark"}
composer require laravel/ai
```

**Why:** `fetchski/attract-cms-laravel` calls your site, and `laravel/ai` runs the agent, with
OpenRouter as one of its providers and a fake for the tests.

**You should see:** `Using version … for fetchski/attract-cms-laravel` and `Using version … for
        laravel/ai`.

**3. Point the app at your site**

```bash Terminal theme={"dark"}
php artisan attract:install
```

It asks for your site's slug and address, then the **Secret token** and the **Signing secret**
you copied.

**Why:** it publishes `config/fetchski-attract.php` and writes the four values to `.env`, then
calls the site to check it's reachable, the key is accepted and the clocks agree.

**You should see:** `Config`, `Reachable` and `Clock` read `ok`, and `INFO The site acme is
        ready.` The **Key accepted** row reads `warn`, `the key works but may not list pages`: that's
expected, the key reads pages through their drafts.

**4. Add the model's environment variables**

Open `.env` and add these lines at the end, with your OpenRouter key:

```ini .env theme={"dark"}
# OpenRouter: your key, and the model the agent runs on
OPENROUTER_API_KEY=sk-or-v1-…
SITE_TRANSLATE_MODEL=~anthropic/claude-sonnet-latest
```

**Why:** the Laravel AI SDK's `openrouter` provider reads `OPENROUTER_API_KEY` by itself, so its
config needs no publishing. The tests fake the model, so they never need the key.

**5. Gather the translator's options**

Create the file, with your own names in `glossary`:

```php config/site-translate.php theme={"dark"}
<?php

return [

    /*
    |--------------------------------------------------------------------------
    | The model
    |--------------------------------------------------------------------------
    |
    | Any OpenRouter model id works; a `~provider/family-latest` alias always
    | serves that family's newest model.
    |
    */

    'model' => env('SITE_TRANSLATE_MODEL', '~anthropic/claude-sonnet-latest'),

    /*
    |--------------------------------------------------------------------------
    | Limits
    |--------------------------------------------------------------------------
    |
    | A document gets one model call, plus one per round of fixes, up to
    | `attempts`. The run starts no new document once it has spent
    | `max_cost_per_run_usd`.
    |
    */

    'attempts' => 3,

    /*
    |--------------------------------------------------------------------------
    | Time limit
    |--------------------------------------------------------------------------
    |
    | How long one model call may take, in seconds: room for your longest page
    | on a slow model, so it only ends a call that hangs. A call that fails
    | (a timeout, an outage, a rate limit) uses up one of the attempts.
    |
    */

    'timeout_seconds' => 600,

    'max_cost_per_run_usd' => 10,

    /*
    |--------------------------------------------------------------------------
    | Glossary
    |--------------------------------------------------------------------------
    |
    | Names never translated: your business name, your services' and products'
    | names, place names you keep as they are.
    |
    */

    'glossary' => [
        'Acme',
        'Acme Outfitters',
        'Trailhead Tune-Up',
    ],

];
```

**Why:** the limits cap every run, whatever the model does, and the glossary goes to the agent
with every document. Add a name whenever a translation changes one.

**6. Make the agent**

```bash Terminal theme={"dark"}
php artisan make:agent Translator --structured
```

Then replace the file it made. It is the file pinned beside this page; here it is again in
full:

```php app/Ai/Agents/Translator.php theme={"dark"}
<?php

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Attributes\MaxTokens;
use Laravel\Ai\Attributes\Provider;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Enums\Lab;
use Laravel\Ai\Promptable;
use Stringable;

/**
 * Translates one document's words, given as JSON: the language, the glossary
 * and the texts under their keys. It answers every key, translated, in one
 * step. When the code found problems in its last answer, the prompt carries
 * them, and it answers again with every key.
 */
#[Provider(Lab::OpenRouter)]
#[MaxTokens(16000)]
class Translator implements Agent, HasStructuredOutput
{
    use Promptable;

    public function model(): string
    {
        return config('site-translate.model');
    }

    /**
     * Get the instructions that the agent should follow.
     */
    public function instructions(): Stringable|string
    {
        return <<<'TEXT'
            You translate a small business's website, one document at a time, for its own visitors.

            - You get the language, a glossary and the document's texts, each under a key. Answer
              every key, each with its text translated into the language.
            - Write as a native speaker would for this business: natural, warm, plain. Keep the
              meaning, the facts and the tone. Never add or drop information.
            - Keep every glossary name exactly as written, in any text.
            - Keep every word in braces, such as {year} or {business}, exactly as written: the
              site fills it in.
            - Rich text is HTML: translate the words between the tags, and keep every tag, attribute
              and link exactly as it is.
            - Leave a text as it is when it isn't words for a visitor (a code, a hashtag, a product
              name) or is already in the language.
            - When the prompt has `problems` with `yourLastAnswer`, fix each problem and answer with
              every key again.
            TEXT;
    }

    /**
     * Get the agent's structured output schema definition.
     */
    public function schema(JsonSchema $schema): array
    {
        $text = $schema->object(fn (JsonSchema $schema): array => [
            'key' => $schema->string()
                ->required(),
            'text' => $schema->string()
                ->required(),
        ]);

        return [
            'texts' => $schema->array()
                ->items($text)
                ->required(),
        ];
    }
}
```

**Why:** a structured agent answers JSON in the shape its `schema()` gives, in one step, with no
tools. One step per call means the raw OpenRouter answer the SDK keeps holds the call's whole
cost (`usage.cost`), so the run's spend is exact. `#[Provider(Lab::OpenRouter)]` sends it to
OpenRouter, and `model()` reads the model from the config.

**You should see:** `INFO Agent [app/Ai/Agents/Translator.php] created successfully.`

**7. Reach any document on the site**

Create `app/Services/SiteDocuments.php`:

```php app/Services/SiteDocuments.php theme={"dark"}
<?php

namespace App\Services;

use Fetchski\AttractCmsLaravel\Exceptions\NotFound;
use Fetchski\AttractCmsLaravel\Facades\Attract;

/**
 * Any document on the site, by its type: read in a language, copied into one,
 * its wording written back. Pages, posts and FAQs relate to documents of any
 * type (images, authors, categories, forms), so this reaches each through the
 * site's REST routes with `request()`, the SDK's call for a route by name.
 *
 * A document is the array the site sends; a reference to one is
 * `['aposDocId' => …, 'type' => …, 'slug' => …, 'title' => …]`.
 */
class SiteDocuments
{
    /** Copied into the language so pages keep them, never translated. */
    private const MEDIA = ['@apostrophecms/image', '@apostrophecms/file', 'video'];

    /**
     * A page lives under the page routes; every other type under its own name.
     *
     * @param  array<string, mixed>  $doc
     */
    public static function isPage(array $doc): bool
    {
        return str_starts_with((string) ($doc['slug'] ?? ''), '/');
    }

    /** The path of a document's `_url` (`/about-us`), without the site's address. */
    public static function pathOf(mixed $url): ?string
    {
        $path = is_string($url) ? parse_url($url, PHP_URL_PATH) : null;

        return is_string($path) ? $path : null;
    }

    /** @param  array<string, mixed>  $doc */
    public static function isMedia(array $doc): bool
    {
        return in_array($doc['type'] ?? '', self::MEDIA, true);
    }

    /**
     * A copy is up to date when it was copied from the current original and written since: its
     * translation is saved. ISO dates compare as strings.
     *
     * @param  array<string, mixed>|null  $original
     * @param  array<string, mixed>|null  $copy
     */
    public static function isUpToDate(?array $original, ?array $copy): bool
    {
        $copiedAt = $copy['localizedAt'] ?? null;

        return $original !== null
            && $copy !== null
            && is_string($copiedAt)
            && $copiedAt >= (string) $original['updatedAt']
            && (string) $copy['updatedAt'] > $copiedAt;
    }

    /**
     * Every draft of a type in `$locale`, as stored (so with core's
     * `localizedAt`, which the SDK's own models leave out), a page at a time.
     *
     * @param  array<string, string|int>  $query
     * @return list<array<string, mixed>>
     */
    public function list(string $type, string $locale, array $query = []): array
    {
        $all = [];
        $pages = 1;

        for ($page = 1; $page <= $pages; $page++) {
            $answer = Attract::site()->request('GET', "/{$type}", query: [
                ...$query,
                'aposLocale' => $locale,
                'aposMode' => 'draft',
                'perPage' => 100,
                'page' => $page,
            ]);

            array_push($all, ...($answer['results'] ?? []));
            $pages = $answer['pages'] ?? 1;
        }

        return $all;
    }

    /**
     * The draft in `$locale`, with its related documents; null when the language has no copy yet.
     *
     * @param  array<string, mixed>  $doc
     * @return array<string, mixed>|null
     */
    public function get(array $doc, string $locale): ?array
    {
        try {
            return Attract::site()->request('GET', $this->route($doc), query: [
                'aposLocale' => $locale,
                'aposMode' => 'draft',
            ]);
        } catch (NotFound) {
            return null;
        }
    }

    /**
     * Copies the draft in `$from` into `$to`, as a draft there; `$update` overwrites a copy
     * already there.
     *
     * @param  array<string, mixed>  $doc
     */
    public function copy(array $doc, string $from, string $to, bool $update): void
    {
        Attract::site()->request('POST', "{$this->route($doc)}:{$from}:draft/localize", body: [
            'toLocale' => $to,
            'update' => $update,
        ]);
    }

    /**
     * Writes the translated wording to the draft in `$locale`, with `$links`
     * (the site settings' links pointed at the language's pages). Core's PATCH
     * takes `@<widget id>.<field>` keys, so only the words change.
     *
     * @param  array<string, mixed>  $doc
     * @param  list<array{key: string, text: string}>  $wording
     * @param  list<array{key: string, text: string}>  $links
     */
    public function write(array $doc, string $locale, array $wording, array $links = []): void
    {
        Attract::site()->request(
            'PATCH',
            $this->route($doc),
            query: ['aposLocale' => $locale, 'aposMode' => 'draft'],
            body: array_column([...$wording, ...$links], 'text', 'key'),
        );
    }

    /**
     * Puts the draft in `$locale` in the CMS's review queue (Submitted Drafts).
     *
     * @param  array<string, mixed>  $doc
     */
    public function submit(array $doc, string $locale): void
    {
        Attract::site()->request('POST', "{$this->route($doc)}:{$locale}:draft/submit");
    }

    /**
     * The documents `$doc` shows (images, authors, categories, forms…), once
     * each. Pages are left out: every page is in the run already.
     *
     * @param  array<string, mixed>  $doc
     * @return list<array<string, mixed>>
     */
    public static function related(array $doc): array
    {
        $found = [];

        $visit = function (mixed $value, bool $root) use (&$visit, &$found): void {
            if (! is_array($value)) {
                return;
            }

            // A related document comes projected: its `_id` (`<aposDocId>:<locale>:<mode>`),
            // not its aposDocId. One with no locale in its `_id` (a media folder) is the
            // same in every language: nothing to copy.
            if (! $root && ($value['metaType'] ?? null) === 'doc' && is_string($value['_id'] ?? null)) {
                $parts = explode(':', $value['_id']);
                $aposDocId = $parts[0];

                if (isset($parts[1]) && ! self::isPage($value)) {
                    $found[$aposDocId] = [
                        'aposDocId' => $aposDocId,
                        'type' => $value['type'] ?? '',
                        'slug' => $value['slug'] ?? null,
                        'title' => $value['title'] ?? null,
                    ];
                }

                return;
            }

            foreach ($value as $key => $child) {
                // A page's tree and its published twin are pages, not what it shows.
                if (! in_array($key, ['_ancestors', '_children', '_parent', '_publishedDoc'], true)) {
                    $visit($child, false);
                }
            }
        };

        $visit($doc, true);

        return array_values($found);
    }

    /** @param  array<string, mixed>  $doc */
    private function route(array $doc): string
    {
        $type = self::isPage($doc) ? '@apostrophecms/page' : $doc['type'];

        return "/{$type}/{$doc['aposDocId']}";
    }
}
```

**Why:** a page shows documents of many types (images, authors, categories, forms), and the site
copies each into a language through the same routes. `request()` calls those routes by name, with
the key, its signature, retries and the SDK's exceptions, as every SDK method does.

**8. Find the words, and check a translation**

Create `app/Support/Wording.php`:

```php app/Support/Wording.php theme={"dark"}
<?php

namespace App\Support;

/**
 * The words a visitor reads in a document, under the keys core's PATCH writes
 * them back by, and the checks a translation of them must pass.
 */
final class Wording
{
    /** Keys that hold codes, addresses or settings, never words a visitor reads. */
    private const NOT_WORDING_KEYS = [
        'type', 'metaType', 'slug', 'path', 'aposDocId', 'aposLocale', 'aposMode',
        'visibility', 'titleSortified', 'highSearchText', 'lowSearchText', 'searchSummary',
        'parkedId', 'source', 'scopedArrayName',
        'submitted', // who sent it for review, and when
    ];

    private const NOT_WORDING_SUFFIX = '/(By|Url|Href|Color|ColorLight|Custom|Icon|IconFamily|Css|Slug|Email|Phone|Anchor|Code)$/';

    private const NOT_WORDS = [
        '#^(https?:|mailto:|tel:|/|\#|--)#', // addresses, colors, design tokens
        '/^[a-z0-9]+([-_.][a-z0-9]+)*$/', // choices, icon names, slugs
        '/^\S*[a-z][A-Z]\S*$/', // one camelCase token: a choice (belowHeadline)
        '#^[^\s<>]*[@/_{}=][^\s<>]*$#', // one token holding code characters (@apostrophecms/any-page-type)
        '/^\d{4}-\d\d-\d\d/', // dates
        '/^[^\p{L}]*$/u', // no letters at all
    ];

    /**
     * Titles, headings, text, rich text, button labels, SEO text. Codes,
     * colors, addresses and settings stay out, and so do related documents
     * (they are translated as documents of their own). `$fields` limits it to
     * those fields (the site settings' own words: the rest of their fields
     * are shared by every language).
     *
     * @param  array<string, mixed>  $doc
     * @param  list<string>|null  $fields
     * @return list<array{key: string, text: string}>
     */
    public static function of(array $doc, ?array $fields = null): array
    {
        $found = [];

        $visit = function (mixed $value, ?string $owner, string $path) use (&$visit, &$found): void {
            if (is_string($value)) {
                $text = trim($value);

                if ($text !== '' && ! self::isNotWords($text)) {
                    $found[] = ['key' => $owner ? "@{$owner}.{$path}" : $path, 'text' => $value];
                }

                return;
            }

            if (! is_array($value) || ($value['metaType'] ?? null) === 'doc') {
                return;
            }

            if (array_is_list($value)) {
                foreach ($value as $index => $item) {
                    // A widget or array row has an id: its fields are keyed by it.
                    if (is_array($item) && is_string($item['_id'] ?? null)) {
                        $visit($item, $item['_id'], '');
                    } else {
                        $visit($item, $owner, $path === '' ? (string) $index : "{$path}.{$index}");
                    }
                }

                return;
            }

            foreach ($value as $key => $child) {
                if (self::isWordingKey((string) $key)) {
                    $visit(self::unwrap($child), $owner, $path === '' ? (string) $key : "{$path}.{$key}");
                }
            }
        };

        foreach ($doc as $key => $value) {
            if (self::isWordingKey((string) $key) && ($fields === null || in_array($key, $fields, true))) {
                $visit(self::unwrap($value), null, (string) $key);
            }
        }

        return $found;
    }

    /**
     * The links in `$fields` of the site settings (a `url` in a list row,
     * `headerCtaUrl`…) that point at a page the language has, each written to
     * point at that page in the language instead, under the key core's PATCH
     * writes it back by. `$pointTo` maps a page's path to its path in the
     * language. A link's `?query` and `#anchor` are kept; a link to another
     * site is left alone.
     *
     * @param  array<string, mixed>  $doc
     * @param  list<string>  $fields
     * @param  array<string, string>  $pointTo
     * @return list<array{key: string, text: string}>
     */
    public static function linksTo(array $doc, array $fields, array $pointTo): array
    {
        $found = [];

        $visit = function (mixed $value, ?string $owner, string $key) use (&$visit, &$found, $pointTo): void {
            if (is_array($value) && array_is_list($value)) {
                foreach ($value as $row) {
                    foreach (is_array($row) ? $row : [] as $field => $child) {
                        $visit($child, is_string($row['_id'] ?? null) ? $row['_id'] : $owner, (string) $field);
                    }
                }

                return;
            }

            // A link on this site starts with one slash; another site's is left alone.
            if (! is_string($value) || preg_match('/(^url|Url)$/', $key) !== 1 || preg_match('#^/(?!/)#', $value) !== 1) {
                return;
            }

            $cut = strcspn($value, '?#');
            $to = $pointTo[substr($value, 0, $cut)] ?? null;
            $link = $to.substr($value, $cut);

            if ($to !== null && $link !== $value) {
                $found[] = ['key' => $owner ? "@{$owner}.{$key}" : $key, 'text' => $link];
            }
        };

        foreach ($fields as $field) {
            $visit($doc[$field] ?? null, null, $field);
        }

        return $found;
    }

    /**
     * Why a translation can't be saved: a key missing or invented, an HTML tag,
     * a link or a {placeholder} changed, a glossary name translated, a sentence
     * left untranslated. Empty when it can be.
     *
     * @param  list<array{key: string, text: string}>  $original
     * @param  list<array{key?: mixed, text?: mixed}>  $translated
     * @param  list<string>  $glossary
     * @return list<string>
     */
    public static function problems(array $original, array $translated, array $glossary): array
    {
        $problems = [];
        $given = [];

        foreach ($translated as $item) {
            $given[(string) ($item['key'] ?? '')] = (string) ($item['text'] ?? '');
        }

        foreach ($original as ['key' => $key, 'text' => $text]) {
            $translation = $given[$key] ?? '';

            if (trim($translation) === '') {
                $problems[] = "{$key}: missing. Translate every key you were given.";

                continue;
            }

            if (self::tags($translation) !== self::tags($text)) {
                $tags = self::tags($text) ?: 'none';
                $problems[] = "{$key}: the HTML tags changed. Keep exactly these, in order: {$tags}.";
            }

            if (self::links($translation) !== self::links($text)) {
                $problems[] = "{$key}: a link changed. Keep every href and src exactly as it was.";
            }

            if (self::placeholders($translation) !== self::placeholders($text)) {
                $kept = self::placeholders($text) ?: 'none';
                $problems[] = "{$key}: keep these exactly as written, the site fills them in: {$kept}.";
            }

            foreach ($glossary as $name) {
                if (str_contains($text, $name) && ! str_contains($translation, $name)) {
                    $problems[] = "{$key}: \"{$name}\" is in the glossary. Keep it exactly as written.";
                }
            }

            if (self::leftAsIs($text, $translation, $glossary)) {
                $problems[] = "{$key}: still in the original language. Translate it.";
            }
        }

        $known = array_column($original, 'key');

        foreach (array_keys($given) as $key) {
            if (! in_array($key, $known, true)) {
                $problems[] = "{$key}: not one of the keys you were given. Leave it out.";
            }
        }

        return $problems;
    }

    /**
     * A sentence sent back as it was: not translated. A sentence: four words or more, glossary
     * names aside, two of them lowercase (a name or an address alone never counts).
     *
     * @param  list<string>  $glossary
     */
    private static function leftAsIs(string $text, string $translation, array $glossary): bool
    {
        $plain = fn (string $value): string => trim(
            (string) preg_replace('/\s+/u', ' ', strip_tags($value)),
        );

        if ($plain($text) !== $plain($translation)) {
            return false;
        }

        preg_match_all('/\p{L}+/u', str_replace($glossary, ' ', $plain($text)), $words);

        $lowercase = array_filter(
            $words[0],
            fn (string $word) => preg_match('/^\p{Ll}/u', $word) === 1,
        );

        return count($words[0]) >= 4 && count($lowercase) >= 2;
    }

    private static function isWordingKey(string $key): bool
    {
        return ! str_starts_with($key, '_')
            && ! in_array($key, self::NOT_WORDING_KEYS, true)
            && preg_match(self::NOT_WORDING_SUFFIX, $key) !== 1;
    }

    private static function isNotWords(string $text): bool
    {
        foreach (self::NOT_WORDS as $pattern) {
            if (preg_match($pattern, $text) === 1) {
                return true;
            }
        }

        return false;
    }

    /** An area holds its widgets under `items`. */
    private static function unwrap(mixed $value): mixed
    {
        return is_array($value) && ($value['metaType'] ?? null) === 'area'
            ? ($value['items'] ?? [])
            : $value;
    }

    private static function tags(string $text): string
    {
        preg_match_all('/<\/?([a-z][a-z0-9]*)\b[^>]*>/i', $text, $matches, PREG_SET_ORDER);

        return implode(' ', array_map(
            fn (array $match): string => str_starts_with($match[0], '</') ? "/{$match[1]}" : $match[1],
            $matches,
        ));
    }

    /** The {year}-style words the site fills in, in any order (a language may move them). */
    private static function placeholders(string $text): string
    {
        preg_match_all('/\{[a-z]+\}/', $text, $matches);
        sort($matches[0]);

        return implode(' ', $matches[0]);
    }

    private static function links(string $text): string
    {
        preg_match_all('/\b(?:href|src)\s*=\s*"([^"]*)"/i', $text, $matches);

        return implode(' ', $matches[1]);
    }
}
```

**Why:** `of()` keeps titles, headings, text, rich text and button labels, keyed as the site's
`PATCH` writes them back, so a write changes the words and nothing else. For the site settings
it takes only the fields the site names with `getGlobalTranslatedFields()`: every other setting
is shared by all your languages. `linksTo()` points each header and footer link to one of your
pages at that page in the new language; a link to another site stays as it is. `problems()` is
the check every answer must pass before anything is written.

**9. Describe the run's outcome**

Create `app/Support/TranslatedDocument.php`:

```php app/Support/TranslatedDocument.php theme={"dark"}
<?php

namespace App\Support;

/** One document's outcome, for the report. */
final readonly class TranslatedDocument
{
    /**
     * @param  string  $kind  page, blog post, FAQ, site settings, or shown on them
     * @param  string|null  $previewUrl  The draft on the site, for anyone with the link, without
     *                                   signing in. Pages and posts only.
     * @param  string|null  $problem  Why it was skipped or failed; null once saved.
     */
    public function __construct(
        public string $title,
        public string $kind,
        public ?string $previewUrl,
        public ?string $problem,
    ) {}

    /** Left untranslated: counts against the run, and fails the command. */
    public function failed(): bool
    {
        return $this->problem !== null && $this->problem !== 'Nothing to translate.';
    }
}
```

And `app/Support/TranslationReport.php`:

```php app/Support/TranslationReport.php theme={"dark"}
<?php

namespace App\Support;

/** What one run did, and what it spent. */
final class TranslationReport
{
    /** @var list<TranslatedDocument> */
    public array $documents = [];

    public int $upToDate = 0;

    public int $inputTokens = 0;

    public int $outputTokens = 0;

    public float $costUsd = 0.0;

    public function __construct(
        public readonly bool $added,
        public readonly bool $published,
    ) {}

    /** How many documents were left untranslated. */
    public function failed(): int
    {
        return count(array_filter($this->documents, fn (TranslatedDocument $doc): bool => $doc->failed()));
    }
}
```

**10. Write the translator**

Create `app/Services/SiteTranslator.php`:

```php app/Services/SiteTranslator.php theme={"dark"}
<?php

namespace App\Services;

use App\Ai\Agents\Translator;
use App\Support\TranslatedDocument;
use App\Support\TranslationReport;
use App\Support\Wording;
use Fetchski\AttractCmsLaravel\Data\SiteLanguagesLanguagesItem;
use Fetchski\AttractCmsLaravel\Facades\Attract;
use Laravel\Ai\Exceptions\FailoverableException;
use Laravel\Ai\Responses\AgentResponse;
use RuntimeException;

/**
 * Translates the site's pages, blog posts and FAQs (and what they show), and
 * its header, footer and cookie banner, into a language, adding the language
 * first if the site lacks it. Every translation is a draft; `$publish` makes
 * the language public, nothing else.
 */
class SiteTranslator
{
    /** What the run lists, by kind: the type, its list's query, and how to share one. */
    private const KINDS = [
        'page' => ['type' => '@apostrophecms/page', 'query' => ['all' => 1, 'flat' => 1], 'share' => 'pages'],
        'blog post' => ['type' => '@apostrophecms/blog', 'query' => [], 'share' => 'blogPosts'],
        'FAQ' => ['type' => 'faq-item', 'query' => [], 'share' => null],
    ];

    /** Site settings and Site design: one document per language. */
    private const SETTINGS = '@apostrophecms/global';

    public function __construct(private SiteDocuments $docs) {}

    public function translate(string $code, string $name, bool $publish = false): TranslationReport
    {
        $report = new TranslationReport(
            added: $this->ensureLanguage($code, $name, $publish),
            published: $publish,
        );
        $main = $this->mainLocale($code);

        // 1. What needs translating: never copied, or changed since its copy.
        [$all, $paths] = $this->listAll($main, $code);
        $jobs = [];

        foreach ($all as [$job, $upToDate]) {
            if ($upToDate) {
                $report->upToDate++;
            } else {
                $jobs[] = $job;
            }
        }

        // 2. Copy what any of them shows that needs its words (by the same rule, so a copy a
        //    stopped run left is translated by the next), then each one: a write keeps a related
        //    document only once it exists in the language. A key missing a scope stops here,
        //    before any copy is overwritten.
        $shown = [];
        $seen = [];

        $copyShown = function (array $doc) use (&$copyShown, &$shown, &$seen, $main, $code): void {
            foreach (SiteDocuments::related($this->docs->get($doc, $main) ?? []) as $related) {
                if (isset($seen[$related['aposDocId']])) {
                    continue;
                }

                $seen[$related['aposDocId']] = true;
                $copy = $this->docs->get($related, $code);

                // A copy is all an image or a file needs; anything else needs its words, unless up to date.
                if ($copy !== null && (SiteDocuments::isMedia($related)
                    || SiteDocuments::isUpToDate($this->docs->get($related, $main), $copy))) {
                    continue;
                }

                $this->docs->copy($related, $main, $code, update: $copy !== null);

                if (! SiteDocuments::isMedia($related)) {
                    $shown[] = [...$related, 'kind' => 'shown on them', 'share' => null];
                }

                $copyShown($related);
            }
        };

        foreach ($all as [$job]) {
            $copyShown($job);
        }

        foreach ($jobs as $index => $job) {
            $this->docs->copy($job, $main, $code, update: true);

            // The settings' links to pages point at the language's pages.
            if ($job['kind'] === 'site settings') {
                $jobs[$index]['links'] = Wording::linksTo($job['original'], $job['fields'], $this->pathsIn($code, $paths));
            }
        }

        // 3. Translate each copy and send it for review, within the run's budget.
        $budget = config('site-translate.max_cost_per_run_usd');

        foreach ([...$jobs, ...$shown] as $job) {
            $report->documents[] = $report->costUsd >= $budget
                ? $this->outcome($job, null, "Not translated: the run reached \${$budget}. Run it again to go on.")
                : $this->translateOne($job, $code, $name, $report);
        }

        return $report;
    }

    /** Adds the language, not public, when the site lacks it; `$publish` makes it public. */
    private function ensureLanguage(string $code, string $name, bool $publish): bool
    {
        $list = Attract::languages()->listSiteLanguages()->results->first()
            ?? throw new RuntimeException('The site has no language list: open Languages in the CMS once, then run this again.');

        $languages = $list->languages ?? collect();
        $known = $languages->firstWhere('code', $code);

        if ($known && (! $publish || $known->public)) {
            return false;
        }

        Attract::languages()->updateSiteLanguages(
            id: $list->id,
            languages: $known
                ? $languages->map(fn (SiteLanguagesLanguagesItem $language) => $language->code === $code
                    ? new SiteLanguagesLanguagesItem(name: $language->name, code: $code, id: $language->id, public: true)
                    : $language)
                : $languages->concat([new SiteLanguagesLanguagesItem(name: $name, code: $code, public: $publish)]),
        );

        return ! $known;
    }

    /** The site's main language: the one without an address prefix. Checks `$code` is one of its languages now. */
    private function mainLocale(string $code): string
    {
        $locales = Attract::internationalization()->getLocales();
        $main = collect($locales)->search(fn ($locale) => ! $locale->prefix);

        if (! is_string($main) || ! isset($locales[$code]) || $code === $main) {
            $codes = implode(', ', array_keys($locales));

            throw new RuntimeException("\"{$code}\" isn't one of the site's further languages: the site has {$codes}.");
        }

        return $main;
    }

    /**
     * Every page (parents first: a page is copied only under its parent), blog
     * post and FAQ, and the site settings, each with whether its translation is
     * up to date: copied from the current original, and written since the
     * copy. With each page's and post's path, by id.
     *
     * @return array{0: list<array{0: array<string, mixed>, 1: bool}>, 1: array<string, string>}
     */
    private function listAll(string $main, string $code): array
    {
        $out = [];
        $paths = [];

        foreach (self::KINDS as $kind => ['type' => $type, 'query' => $query, 'share' => $share]) {
            $copies = collect($this->docs->list($type, $code, $query))->keyBy('aposDocId');
            $originals = collect($this->docs->list($type, $main, $query))
                ->sortBy([['level', 'asc'], ['rank', 'asc']]);

            foreach ($originals as $original) {
                $path = SiteDocuments::pathOf($original['_url'] ?? null);

                if ($path !== null && $kind !== 'FAQ') {
                    $paths[$original['aposDocId']] = $path;
                }

                $copy = $copies->get($original['aposDocId']);

                // The page tree's list sends a child page's main fields only: read it whole.
                if ($copy && ! array_key_exists('localizedAt', $copy)) {
                    $copy = $this->docs->get($copy, $code);
                }

                $out[] = [
                    [
                        'aposDocId' => $original['aposDocId'],
                        'type' => $original['type'],
                        'slug' => $original['slug'] ?? null,
                        'title' => $original['title'] ?? null,
                        'kind' => $kind,
                        'share' => $share,
                    ],
                    SiteDocuments::isUpToDate($original, $copy),
                ];
            }
        }

        // The header, footer and cookie banner: the settings' fields the site says each language
        // translates.
        $settings = $this->docs->list(self::SETTINGS, $main)[0] ?? null;

        if ($settings !== null) {
            $upToDate = SiteDocuments::isUpToDate($settings, $this->docs->get($settings, $code));

            $out[] = [
                [
                    'aposDocId' => $settings['aposDocId'],
                    'type' => $settings['type'],
                    'slug' => $settings['slug'] ?? null,
                    'title' => 'Header, footer and cookie banner',
                    'kind' => 'site settings',
                    'share' => null,
                    'original' => $settings,
                    'fields' => $upToDate
                        ? []
                        : Attract::languages()->getGlobalTranslatedFields()->translated->all(),
                ],
                $upToDate,
            ];
        }

        return [$out, $paths];
    }

    /**
     * Each page's and post's path → its path in the language (`/about-us` → `/fr/about-us`).
     *
     * @param  array<string, string>  $paths
     * @return array<string, string>
     */
    private function pathsIn(string $code, array $paths): array
    {
        $pointTo = [];

        foreach (['page', 'blog post'] as $kind) {
            ['type' => $type, 'query' => $query] = self::KINDS[$kind];

            foreach ($this->docs->list($type, $code, $query) as $copy) {
                $from = $paths[$copy['aposDocId']] ?? null;
                $to = SiteDocuments::pathOf($copy['_url'] ?? null);

                if ($from !== null && $to !== null) {
                    $pointTo[$from] = $to;
                }
            }
        }

        return $pointTo;
    }

    /**
     * Has the agent translate one copy, saves it, and sends it for review.
     *
     * @param  array<string, mixed>  $job
     */
    private function translateOne(array $job, string $code, string $name, TranslationReport $report): TranslatedDocument
    {
        $copy = $this->docs->get($job, $code);
        $texts = $copy ? Wording::of($copy, $job['fields'] ?? null) : [];

        if (! $copy || $texts === []) {
            return $this->outcome($job, null, $copy ? 'Nothing to translate.' : "No copy in {$code}.");
        }

        $glossary = config('site-translate.glossary');
        $prompt = ['language' => "{$name} ({$code})", 'glossary' => $glossary, 'texts' => $texts];
        $problems = [];
        $failure = null;

        for ($attempt = 1; $attempt <= config('site-translate.attempts'); $attempt++) {
            try {
                $response = Translator::make()->prompt(
                    json_encode($prompt, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
                    timeout: config('site-translate.timeout_seconds'),
                );
            } catch (FailoverableException $error) {
                // The model didn't answer: a timeout, an outage, a rate limit. Try again.
                $failure = rtrim($error->getMessage(), '.');

                continue;
            }

            $failure = null;
            $this->spend($response, $report);

            $translated = $response['texts'] ?? [];
            $problems = Wording::problems($texts, $translated, $glossary);

            if ($problems === []) {
                $this->docs->write($job, $code, $translated, $job['links'] ?? []);
                $this->docs->submit($job, $code);

                return $this->outcome($job, $this->previewUrl($job, $copy, $code), null);
            }

            $prompt = [...$prompt, 'yourLastAnswer' => $translated, 'problems' => $problems];
        }

        if ($failure !== null) {
            return $this->outcome($job, null, "Not translated: the model didn't answer ({$failure}). Run it again to go on.");
        }

        return $this->outcome($job, null, 'Not saved: '.implode(' ', array_slice($problems, 0, 3)));
    }

    /** Adds one call's tokens, and its cost as OpenRouter reports it in the raw answer. */
    private function spend(AgentResponse $response, TranslationReport $report): void
    {
        $report->inputTokens += $response->usage->inputTokens;
        $report->outputTokens += $response->usage->outputTokens;
        $report->costUsd += (float) ($response->raw?->json('usage.cost') ?? 0);
    }

    /**
     * The saved draft, for anyone with the link, without signing in. Pages and posts only.
     *
     * @param  array<string, mixed>  $job
     * @param  array<string, mixed>  $copy
     */
    private function previewUrl(array $job, array $copy, string $code): ?string
    {
        $id = "{$job['aposDocId']}:{$code}:draft";
        $url = $copy['_url'] ?? null;

        $shared = match ($job['share']) {
            'pages' => Attract::pages()->sharePage(id: $id, share: true),
            'blogPosts' => Attract::blogPosts()->shareBlog(id: $id, share: true),
            default => null,
        };

        if (! is_string($url) || ! $shared?->aposShareKey) {
            return null;
        }

        return $url.'?'.http_build_query(['aposShareKey' => $shared->aposShareKey, 'aposShareId' => $id]);
    }

    /** @param  array<string, mixed>  $job */
    private function outcome(array $job, ?string $previewUrl, ?string $problem): TranslatedDocument
    {
        return new TranslatedDocument(
            title: $job['title'] ?? $job['slug'] ?? $job['aposDocId'],
            kind: $job['kind'],
            previewUrl: $previewUrl,
            problem: $problem,
        );
    }
}
```

**Why:** your code does the whole run in three phases, and the agent only translates.

**1. What needs translating**

Every page (parents first), post and FAQ, and the site settings, compared with its copy: a
translation is up to date when it was copied from the current original and written since.

**2. Copy**

What any of them shows first, by the same rule (so a copy a stopped run left is translated
by the next run), then each one, over an older copy. A key missing a scope stops the run
here, before anything is overwritten. The site settings' links to your pages are then
pointed at the copies.

**3. Translate**

One call per document, plus one per round of fixes, up to `attempts`. Each call may take
up to `timeout_seconds` (ten minutes: room for a long page on a slow model, so it only ends
a call that hangs), and a call that fails uses up an attempt rather than stopping the run.
A translation that passes is written, sent for review and, for pages and posts, shared for
its preview link.

**11. Add the command**

```bash Terminal theme={"dark"}
php artisan make:command SiteTranslate
```

Replace the file it made:

```php app/Console/Commands/SiteTranslate.php theme={"dark"}
<?php

namespace App\Console\Commands;

use App\Services\SiteTranslator;
use App\Support\TranslatedDocument;
use Fetchski\AttractCmsLaravel\Exceptions\AttractException;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use RuntimeException;

#[Signature('site:translate
    {code : The language code, such as fr or pt-br}
    {name : The language as its speakers write it, such as "Français"}
    {--publish : Also make the language public, once its drafts are published}')]
#[Description('Translate the site’s pages, blog posts and FAQs into a language, as drafts for review')]
class SiteTranslate extends Command
{
    /**
     * Execute the console command.
     */
    public function handle(SiteTranslator $translator): int
    {
        $code = $this->argument('code');
        $name = $this->argument('name');

        if (preg_match('/^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|[0-9]{3}))?$/', $code) !== 1) {
            $this->components->error("\"{$code}\" isn't a language code: use lowercase, such as fr or pt-br");

            return self::FAILURE;
        }

        try {
            $report = $translator->translate($code, $name, $this->option('publish'));
        } catch (AttractException|RuntimeException $error) {
            // A missing scope or a refused language: the site's message names it. Nothing was overwritten.
            $this->components->error($error->getMessage());

            return self::FAILURE;
        }

        if ($report->added) {
            $this->components->info($report->published
                ? "Added {$name} ({$code}) to the site, public."
                : "Added {$name} ({$code}) to the site, not public yet.");
        }

        if ($report->documents !== []) {
            $this->table(['', 'Document', 'Preview'], array_map(fn (TranslatedDocument $doc): array => [
                $doc->kind,
                $doc->title,
                $doc->problem ?? $doc->previewUrl ?? 'Submitted Drafts in the CMS',
            ], $report->documents));
        }

        $failed = $report->failed();
        $translated = count($report->documents) - $failed;
        $cost = number_format($report->costUsd, 4);

        $this->components->info("{$translated} translated, {$failed} not, {$report->upToDate} already up to date.");
        $this->components->info("Tokens: {$report->inputTokens} in, {$report->outputTokens} out. Cost: \${$cost}.");

        if ($report->published) {
            $this->components->success("{$name} is public: visitors see each page you have published in it.");
        }

        return $failed > 0 ? self::FAILURE : self::SUCCESS;
    }
}
```

**Why:** Laravel injects the `SiteTranslator` into `handle()`. Any document left untranslated
returns `FAILURE` (exit code `1`), so a script around it can tell.

**You should see:** `php artisan site:translate --help` prints the description, `<code>`,
`<name>` and `--publish`.

**12. Format the code**

```bash Terminal theme={"dark"}
vendor/bin/pint
```

**Why:** Pint applies Laravel's code style to every file you added.

## Run it

**1. Translate**

Give the language's code and its name as its speakers write it:

```bash Terminal theme={"dark"}
php artisan site:translate fr "Français"
```

**You should see**, after a few seconds per document (the cost and tokens are the model's, so
yours will read differently):

```text Terminal theme={"dark"}

   INFO  Added Français (fr) to the site, not public yet.

+---------------+----------------------------------+-----------------------------------------------------------------------------------------+
|               | Document                         | Preview                                                                                 |
+---------------+----------------------------------+-----------------------------------------------------------------------------------------+
| page          | Home                             | https://acme.example/fr/?aposShareKey=…&aposShareId=…                                   |
| page          | About us                         | https://acme.example/fr/about-us?aposShareKey=…&aposShareId=…                           |
| page          | Services                         | https://acme.example/fr/services?aposShareKey=…&aposShareId=…                           |
| page          | Contact us                       | https://acme.example/fr/contact-us?aposShareKey=…&aposShareId=…                         |
| blog post     | Now open on Saturday mornings    | https://acme.example/fr/blog/now-open-on-saturday-mornings?aposShareKey=…&aposShareId=… |
| FAQ           | When are you open?               | Submitted Drafts in the CMS                                                             |
| FAQ           | Do I need to book?               | Submitted Drafts in the CMS                                                             |
| site settings | Header, footer and cookie banner | Submitted Drafts in the CMS                                                             |
| shown on them | Contact form                     | Submitted Drafts in the CMS                                                             |
| shown on them | Jordan Lee                       | Submitted Drafts in the CMS                                                             |
| shown on them | News                             | Submitted Drafts in the CMS                                                             |
+---------------+----------------------------------+-----------------------------------------------------------------------------------------+

   INFO  11 translated, 0 not, 0 already up to date.

   INFO  Tokens: 45380 in, 10860 out. Cost: $0.3150.
```

Pages and blog posts get a preview link. FAQs and what the pages show have no page of their
own: they are in **Submitted Drafts**.

**2. Read the drafts**

Open a **Preview** link: it is the translated page as visitors will see it, with no sign-in,
even while the language is private. Send it only to the people reviewing it.

Your header, footer and cookie banner are one draft, **Header, footer and cookie banner** in
**Submitted Drafts**. Their links to your pages already point at the pages in the new language
(`/about-us` becomes `/fr/about-us`); links to other sites stay as they are.

**3. Publish them**

Sign in to the CMS, press **⌘K**, type `Submitted Drafts` and press **Enter**. Open each
translation, change anything you like, and click **Publish**. Not happy with one? Dismiss it
there: it stays an unpublished draft.

**4. Put the language live**

```bash Terminal theme={"dark"}
php artisan site:translate fr "Français" --publish
```

It makes the language public, translates anything new or changed (as drafts, as always), and
ends:

```text Terminal theme={"dark"}
   INFO  0 translated, 0 not, 11 already up to date.

   INFO  Tokens: 0 in, 0 out. Cost: $0.0000.

   SUCCESS  Français is public: visitors see each page you have published in it.
```

Visitors now get a language switcher in your site's header. A page you haven't published in
French isn't there in French.

**5. Keep it current**

Run the same command after you change your site. Only what changed is translated again, and its
new draft goes back to **Submitted Drafts**.

## If something goes wrong

> [accordion] … isn't a language code
>
> Use a lowercase code, such as `fr`, or a language and a country, such as `pt-br`. Put the name in
> quotes when it has a space: `"Português (Brasil)"`.

> [accordion] ERROR Site … answered PATCH /api/v1/site-languages/… with 400 invalid
>
> The site refused the new language, and the end of the line says why:
>
> * `“en” is the main language’s code`: that code is your main language's. Give the new language
>   its own.
> * `“/api” is already one of the site’s own addresses`: some codes are taken by your site's own
>   addresses. Choose a different code.
>
> Nothing was added or copied. Run the command again with another code.

> [accordion] An error naming a scope
>
> The key lacks that scope: the run stopped before it overwrote anything. Add the scope to the key
> under **API Keys** (a page that shows a kind of document missing from the table above, such as a
> team member, needs that kind's `content:…:write` too), then run the command again.

> [accordion] HTTP request returned status code 401: … User not found.
>
> OpenRouter refused `OPENROUTER_API_KEY`. Copy the key again from
> [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys); it starts with `sk-or-v1-`.
> The documents copied before the error are translated on the next run.

> [accordion] Not saved: … in the table
>
> The agent's answer didn't pass the checks in `attempts` calls, and the words after `Not saved:`
> are the problems left. The draft is the untouched copy. Run the command again to retry just that
> one; if a name keeps changing, add it to `glossary` in `config/site-translate.php`.

> [accordion] Not translated: the run reached \$10
>
> The run's budget is spent. Everything before it is saved: run the command again to go on, or
> raise `max_cost_per_run_usd` in `config/site-translate.php`.

> [accordion] Not translated: the model didn't answer (…)
>
> Every attempt at that document failed: the words in brackets say how (a timeout, an outage, a
> rate limit). The other documents went on, and this one's draft is the untouched copy. Run the
> command again to translate just that one. If it keeps timing out on a long page, raise
> `timeout_seconds` in `config/site-translate.php`.

> [accordion] Composer answers 401 Unauthorized
>
> Your site refused the key in `auth.json`: it's mistyped or cut short, switched off, or past its
> **Expires** date. Run the `composer config bearer…` line again with the whole key.

## Test it

The tests fake both sides. `FakeSite` keeps the site's documents in memory and answers the routes the
translator calls, on top of `Attract::fake()`, which answers every SDK method and records it.
`Translator::fake()` answers for the model: so the real translator runs, with the real checks, and
nothing is sent or spent.

**1. Remove the starter tests**

```bash Terminal theme={"dark"}
rm tests/Unit/ExampleTest.php tests/Feature/ExampleTest.php
```

**2. The fake site**

```php tests/FakeSite.php theme={"dark"}
<?php

namespace Tests;

use Fetchski\AttractCmsLaravel\Data\Blog;
use Fetchski\AttractCmsLaravel\Data\Page;
use Fetchski\AttractCmsLaravel\Data\SiteLanguages;
use Fetchski\AttractCmsLaravel\Facades\Attract;

/**
 * A fake site whose stored drafts live in memory, under `<aposDocId>:<locale>`
 * (`$stored` starts as the main language's): `request()` answers the routes
 * the translator calls (list, get, localize, PATCH, submit) from them, and the
 * SDK's methods answer as `Attract::fake()` does, from `$answers`, over a site
 * with English and a private French. `$log` lists each copy and write, in
 * order. Ids are 10 characters or more, as the site's are, which tells them
 * from a type's name.
 */
class FakeSite
{
    /** @var array<string, array<string, mixed>> */
    public array $docs = [];

    /** @var list<string> Each copy and write, in order: "copy <id> to fr", "write <id> in fr". */
    public array $log = [];

    /**
     * @param  list<array<string, mixed>>  $stored
     * @param  array<string, mixed>  $answers
     */
    public function __construct(array $stored, array $answers = [])
    {
        foreach ($stored as $doc) {
            $this->docs["{$doc['aposDocId']}:en"] = $doc;
        }

        Attract::fake([
            'languages.listSiteLanguages' => [
                'results' => [SiteLanguages::factory()->make(['id' => 'langs', 'languages' => []])],
                'pages' => 1,
                'currentPage' => 1,
            ],
            'internationalization.getLocales' => [
                'en' => ['label' => 'English'],
                'fr' => ['label' => 'Français', 'prefix' => '/fr', 'private' => true],
            ],
            'languages.getGlobalTranslatedFields' => ['translated' => ['tagline', 'headerNav', 'footerLinkGroups']],
            'pages.sharePage' => Page::factory()->make(['aposShareKey' => 'share-key']),
            'blogPosts.shareBlog' => Blog::factory()->make(['aposShareKey' => 'share-key']),
            ...$answers,
            'request' => fn (array $args) => $this->route(...$args),
        ]);
    }

    /** @param  array<string, mixed>  $query */
    private function route(string $method, string $path, array $query, mixed $body): mixed
    {
        preg_match('#^/(.+?)(?:/([a-z0-9]{10,})(?::[a-z-]+:draft)?)?(?:/(localize|submit))?$#', $path, $match);
        [, $type, $id, $action] = $match + [null, null, null, null];
        $locale = $query['aposLocale'] ?? '';

        if ($method === 'GET' && ! $id) {
            $results = array_values(array_filter(
                $this->docs,
                fn (array $doc, string $key): bool => str_ends_with($key, ":{$locale}") && $this->typeOf($doc) === $type,
                ARRAY_FILTER_USE_BOTH,
            ));

            return ['results' => $results, 'pages' => 1];
        }

        if ($method === 'GET') {
            return $this->docs["{$id}:{$locale}"] ?? Attract::notFound();
        }

        if ($action === 'localize') {
            $to = $body['toLocale'];

            if (isset($this->docs["{$id}:{$to}"]) && ! $body['update']) {
                return Attract::siteError(409);
            }

            $this->log[] = "copy {$id} to {$to}";
            $at = now()->toISOString();
            $original = $this->docs["{$id}:en"];
            $this->docs["{$id}:{$to}"] = [
                ...$original,
                ...(isset($original['_url']) ? ['_url' => str_replace('.example/', ".example/{$to}/", $original['_url'])] : []),
                '_id' => "{$id}:{$to}:draft",
                'updatedAt' => $at,
                'localizedAt' => $at,
            ];

            return [];
        }

        if ($method === 'PATCH') {
            $this->log[] = "write {$id} in {$locale}";
            $this->docs["{$id}:{$locale}"] = [
                ...$this->docs["{$id}:{$locale}"],
                ...$body,
                'updatedAt' => now()->addSecond()->toISOString(),
            ];

            return $this->docs["{$id}:{$locale}"];
        }

        return [];
    }

    /** @param  array<string, mixed>  $doc */
    private function typeOf(array $doc): string
    {
        return str_starts_with($doc['slug'] ?? '', '/') ? '@apostrophecms/page' : $doc['type'];
    }
}
```

**Why:** `Attract::fake()` takes a closure for `request`, called with each call's method, path,
query and body, so one small router stands in for the site's routes. A conflict answers with
`Attract::siteError(409)`, as the site does when a copy is already there.

**3. The wording's tests**

```php tests/Unit/WordingTest.php theme={"dark"}
<?php

use App\Services\SiteDocuments;
use App\Support\Wording;

const PAGE = [
    '_id' => 'aboutpage01:en:draft',
    'aposDocId' => 'aboutpage01',
    'type' => 'default-page',
    'slug' => '/about',
    'title' => 'About us',
    'seoDescription' => 'Who we are',
    'sections' => [
        'metaType' => 'area',
        'items' => [
            [
                '_id' => 'herowidget1',
                'metaType' => 'widget',
                'type' => 'hero',
                'heroHeadline' => 'Welcome in',
                'heroEyebrowPlacement' => 'belowHeadline',
                'heroPrimaryCtaUrl' => 'https://acme.example/book',
                'heroBg' => 'raised',
                'heroIcon' => 'arrow-right',
                'heroImage' => [
                    'metaType' => 'area',
                    'items' => [
                        [
                            '_id' => 'imagewidget',
                            'metaType' => 'widget',
                            'type' => '@apostrophecms/image',
                            'linkTo' => '@apostrophecms/any-page-type',
                            '_image' => [
                                [
                                    '_id' => 'photo000001:en:draft',
                                    'type' => '@apostrophecms/image',
                                    'metaType' => 'doc',
                                    'slug' => 'image-shop',
                                    'title' => 'Shop',
                                ],
                            ],
                        ],
                    ],
                ],
            ],
            [
                '_id' => 'textwidget1',
                'metaType' => 'widget',
                'type' => '@apostrophecms/rich-text',
                'content' => '<p>We <a href="/contact">answer</a> fast.</p>',
            ],
        ],
    ],
    '_author' => [['_id' => 'author00001:en:draft', 'type' => 'author', 'metaType' => 'doc', 'title' => 'Jordan']],
    '_ancestors' => [['_id' => 'homepage001:en:draft', 'type' => 'home-page', 'metaType' => 'doc', 'slug' => '/']],
    '_folder' => [['_id' => 'folder00001', 'type' => 'image-folder', 'metaType' => 'doc']],
    'updatedBy' => ['title' => 'Jordan Lee', 'username' => 'jordan'],
];

test('the wording is the words a visitor reads, under the keys PATCH writes them back by', function () {
    expect(Wording::of(PAGE))->toBe([
        ['key' => 'title', 'text' => 'About us'],
        ['key' => 'seoDescription', 'text' => 'Who we are'],
        ['key' => '@herowidget1.heroHeadline', 'text' => 'Welcome in'],
        ['key' => '@textwidget1.content', 'text' => '<p>We <a href="/contact">answer</a> fast.</p>'],
    ]);
});

test('what a page shows leaves out pages and a folder shared by every language', function () {
    expect(array_column(SiteDocuments::related(PAGE), 'aposDocId'))->toBe(['photo000001', 'author00001']);
});

test('a translation that keeps keys, tags, links and names passes', function () {
    $original = [
        ['key' => 'title', 'text' => 'About Acme'],
        ['key' => '@t.content', 'text' => '<p>We <a href="/contact">answer</a> fast.</p>'],
    ];
    $translated = [
        ['key' => 'title', 'text' => 'À propos de Acme'],
        ['key' => '@t.content', 'text' => '<p>Nous <a href="/contact">répondons</a> vite.</p>'],
    ];

    expect(Wording::problems($original, $translated, ['Acme']))->toBe([]);
});

test('every problem with a translation is named', function () {
    $original = [
        ['key' => 'title', 'text' => 'About Acme'],
        ['key' => '@t.content', 'text' => '<p>We <a href="/contact">answer</a> fast.</p>'],
    ];
    $translated = [
        ['key' => 'title', 'text' => 'À propos d’Akmé'],
        ['key' => '@t.content', 'text' => '<p>Nous <a href="/contact-fr">répondons</a> <b>vite</b>.</p>'],
        ['key' => 'slug', 'text' => 'a-propos'],
    ];

    expect(Wording::problems($original, $translated, ['Acme']))->toBe([
        'title: "Acme" is in the glossary. Keep it exactly as written.',
        '@t.content: the HTML tags changed. Keep exactly these, in order: p a /a /p.',
        '@t.content: a link changed. Keep every href and src exactly as it was.',
        'slug: not one of the keys you were given. Leave it out.',
    ]);
});

test('a sentence sent back as it was is named; a short label, a name or an address may stay', function () {
    $original = [
        ['key' => 'title', 'text' => 'Marketing'],
        ['key' => 'note', 'text' => 'Acme Outfitters'],
        ['key' => 'address', 'text' => 'Mill Valley, New York'],
        ['key' => '@t.content', 'text' => '<p>Is a bath included with my stay?</p>'],
    ];
    $translated = [
        ['key' => 'title', 'text' => 'Marketing'],
        ['key' => 'note', 'text' => 'Acme Outfitters'],
        ['key' => 'address', 'text' => 'Mill Valley, New York'],
        ['key' => '@t.content', 'text' => '<p>Is a bath included with my  stay?</p>'],
    ];

    expect(Wording::problems($original, $translated, ['Acme Outfitters']))->toBe([
        '@t.content: still in the original language. Translate it.',
    ]);
});

test('a {placeholder} the site fills in must come back as it was, in any order', function () {
    $original = [
        ['key' => 'footerCopyrightText', 'text' => '© {year} {business}. All rights reserved.'],
        ['key' => 'footerNote', 'text' => 'Since {year}'],
    ];
    $translated = [
        ['key' => 'footerCopyrightText', 'text' => '{business} © {year}. Tous droits réservés.'],
        ['key' => 'footerNote', 'text' => 'Depuis {année}'],
    ];

    expect(Wording::problems($original, $translated, []))->toBe([
        'footerNote: keep these exactly as written, the site fills them in: {year}.',
    ]);
});
```

**4. The translator's tests**

```php tests/Feature/SiteTranslatorTest.php theme={"dark"}
<?php

use App\Ai\Agents\Translator;
use App\Services\SiteTranslator;
use Fetchski\AttractCmsLaravel\Facades\Attract;
use GuzzleHttp\Psr7\Response as Psr7Response;
use Illuminate\Http\Client\Response;
use Laravel\Ai\Exceptions\ProviderConnectionException;
use Laravel\Ai\Responses\Data\Meta;
use Laravel\Ai\Responses\Data\TextUsage;
use Laravel\Ai\Responses\StructuredTextResponse;
use Laravel\Ai\Responses\TextResponse;
use Tests\FakeSite;

const OLD = '2026-01-01T00:00:00.000Z';

const HOME = [
    '_id' => 'homepage001:en:draft',
    'aposDocId' => 'homepage001',
    'type' => 'home-page',
    'slug' => '/',
    'title' => 'Home',
    'level' => 0,
    'updatedAt' => OLD,
    '_url' => 'https://acme.example/',
    'sections' => [
        'metaType' => 'area',
        'items' => [
            [
                '_id' => 'herowidget1',
                'metaType' => 'widget',
                'type' => 'hero',
                'heroHeadline' => 'Welcome to Acme',
                'heroImage' => [
                    'metaType' => 'area',
                    'items' => [
                        [
                            '_id' => 'imagewidget',
                            'metaType' => 'widget',
                            'type' => '@apostrophecms/image',
                            '_image' => [
                                ['_id' => 'photo000001:en:draft', 'type' => '@apostrophecms/image', 'metaType' => 'doc', 'slug' => 'image-shop'],
                            ],
                        ],
                    ],
                ],
            ],
        ],
    ],
];

const PHOTO = [
    '_id' => 'photo000001:en:draft',
    'aposDocId' => 'photo000001',
    'type' => '@apostrophecms/image',
    'slug' => 'image-shop',
    'title' => 'Shop',
    'updatedAt' => OLD,
];

const FAQ = [
    '_id' => 'faqitem0001:en:draft',
    'aposDocId' => 'faqitem0001',
    'type' => 'faq-item',
    'slug' => 'hours',
    'title' => 'When are you open?',
    'updatedAt' => OLD,
];

/**
 * A fake translator: it answers every text it is given with "[fr]" in front of its words
 * (tags and links kept), and OpenRouter's cost of $0.002 in its raw answer.
 */
function translateWithFrPrefix(): void
{
    Translator::fake(function (string $prompt): TextResponse {
        $texts = array_map(fn (array $item): array => [
            'key' => $item['key'],
            // Mark the words, never the tags: "<p>Hi</p>" → "<p>[fr] Hi</p>".
            'text' => preg_replace('/(^|>)([^<]*\p{L}[^<]*)/u', '$1[fr] $2', $item['text'], 1),
        ], json_decode($prompt, true)['texts']);

        $response = new StructuredTextResponse(['texts' => $texts], '', new TextUsage(1000, 200), new Meta);

        return $response->withRawResponse(new Response(new Psr7Response(200, [], '{"usage":{"cost":0.002}}')));
    });
}

test('it adds the language, copies what pages show first, and writes only drafts', function () {
    $site = new FakeSite([HOME, PHOTO, FAQ]);
    translateWithFrPrefix();

    $report = app(SiteTranslator::class)->translate('fr', 'Français');

    expect($report->added)->toBeTrue();
    Attract::assertCalled('languages.updateSiteLanguages', fn (array $args) => $args['languages']->toArray() === [
        ['name' => 'Français', 'code' => 'fr', 'public' => false],
    ]);
    // The photo is copied into French before the page that shows it is written.
    expect($site->log)->toBe([
        'copy photo000001 to fr',
        'copy homepage001 to fr',
        'copy faqitem0001 to fr',
        'write homepage001 in fr',
        'write faqitem0001 in fr',
    ]);
    expect($site->docs['homepage001:fr']['@herowidget1.heroHeadline'])->toBe('[fr] Welcome to Acme')
        ->and($site->docs['faqitem0001:fr']['title'])->toBe('[fr] When are you open?');
    expect(array_map(fn ($doc) => [$doc->title, $doc->problem], $report->documents))->toBe([
        ['Home', null],
        ['When are you open?', null],
    ]);
    expect($report->documents[0]->previewUrl)
        ->toBe('https://acme.example/fr/?aposShareKey=share-key&aposShareId=homepage001%3Afr%3Adraft');
    expect($report->inputTokens)->toBe(2000)
        ->and($report->costUsd)->toEqualWithDelta(0.004, 1e-9);
    Attract::assertNotCalled('pages.publishPage');
});

test('a translation already up to date is never redone', function () {
    $site = new FakeSite([FAQ]);
    $site->docs['faqitem0001:fr'] = [...FAQ, 'localizedAt' => '2026-02-01T00:00:00.000Z', 'updatedAt' => '2026-02-02T00:00:00.000Z'];
    Translator::fake()->preventStrayPrompts();

    $report = app(SiteTranslator::class)->translate('fr', 'Français');

    expect($report->upToDate)->toBe(1)
        ->and($report->documents)->toBe([]);
    Translator::assertNeverPrompted();
});

test('a copy a stopped run left untranslated is translated by the next run', function () {
    $category = [
        '_id' => 'category001:en:draft',
        'aposDocId' => 'category001',
        'type' => 'faq-category',
        'slug' => 'visiting',
        'title' => 'Visiting',
        'updatedAt' => OLD,
    ];
    $faq = [...FAQ, '_category' => [['_id' => 'category001:en:draft', 'type' => 'faq-category', 'metaType' => 'doc']]];
    $site = new FakeSite([$faq, $category]);
    // The last run translated the FAQ, copied its category, then stopped before writing its words.
    $site->docs['faqitem0001:fr'] = [...$faq, 'localizedAt' => '2026-02-01T00:00:00.000Z', 'updatedAt' => '2026-02-02T00:00:00.000Z'];
    $site->docs['category001:fr'] = [...$category, 'localizedAt' => OLD, 'updatedAt' => OLD];
    translateWithFrPrefix();

    $report = app(SiteTranslator::class)->translate('fr', 'Français');

    expect($site->log)->toBe(['copy category001 to fr', 'write category001 in fr'])
        ->and($site->docs['category001:fr']['title'])->toBe('[fr] Visiting')
        ->and($report->upToDate)->toBe(1);
});

test('it translates the header, footer and cookie banner, their links pointed at the pages in the language', function () {
    $about = [
        '_id' => 'aboutpage01:en:draft',
        'aposDocId' => 'aboutpage01',
        'type' => 'default-page',
        'slug' => '/about-us',
        'title' => 'About us',
        'level' => 1,
        'updatedAt' => OLD,
        '_url' => 'https://acme.example/about-us',
    ];
    $settings = [
        '_id' => 'settings001:en:draft',
        'aposDocId' => 'settings001',
        'type' => '@apostrophecms/global',
        'slug' => 'global',
        'updatedAt' => OLD,
        'businessName' => 'Acme',
        'fontDisplay' => 'Cabinet Grotesk',
        'tagline' => 'Open every day',
        'headerNav' => [
            ['_id' => 'navrow00001', 'label' => 'About us', 'url' => '/about-us', 'placement' => 'more'],
            ['_id' => 'navrow00002', 'label' => 'Book', 'url' => 'https://book.example/about-us'],
        ],
        'footerLinkGroups' => [
            [
                '_id' => 'footgroup01',
                'heading' => 'Explore',
                'links' => [['_id' => 'footlink001', 'label' => 'Our hours', 'url' => '/about-us#hours']],
            ],
        ],
    ];
    $site = new FakeSite([$about, $settings]);
    translateWithFrPrefix();

    $report = app(SiteTranslator::class)->translate('fr', 'Français');

    $french = $site->docs['settings001:fr'];
    // Only the words each language keeps its own copy of; never a shared setting.
    expect($french['tagline'])->toBe('[fr] Open every day')
        ->and($french['@navrow00001.label'])->toBe('[fr] About us')
        ->and($french['@footlink001.label'])->toBe('[fr] Our hours')
        ->and($french['fontDisplay'])->toBe('Cabinet Grotesk')
        ->and($french['businessName'])->toBe('Acme');
    // The links to a page here point at it in French; another site's link stays as it is.
    expect($french['@navrow00001.url'])->toBe('/fr/about-us')
        ->and($french['@footlink001.url'])->toBe('/fr/about-us#hours')
        ->and($french)->not->toHaveKey('@navrow00002.url');
    expect(array_map(fn ($doc) => [$doc->title, $doc->kind, $doc->problem], $report->documents))
        ->toContain(['Header, footer and cookie banner', 'site settings', null]);
});

test('a translation that keeps failing its checks is never written', function () {
    $site = new FakeSite([FAQ]);
    Translator::fake(fn () => ['texts' => []]);

    $report = app(SiteTranslator::class)->translate('fr', 'Français');

    expect($report->documents[0]->problem)->toBe('Not saved: title: missing. Translate every key you were given.')
        ->and($site->docs['faqitem0001:fr']['title'])->toBe('When are you open?'); // the copy, untouched
    Translator::assertPrompted(fn ($prompt) => str_contains($prompt->prompt, '"problems"'));
});

test('a model call that fails is tried again', function () {
    $site = new FakeSite([FAQ]);
    $calls = 0;
    Translator::fake(function (string $prompt) use (&$calls): array {
        if (++$calls === 1) {
            throw ProviderConnectionException::forProvider('openrouter');
        }

        return ['texts' => [['key' => 'title', 'text' => 'Quand êtes-vous ouverts ?']]];
    });

    $report = app(SiteTranslator::class)->translate('fr', 'Français');

    expect($report->documents[0]->problem)->toBeNull()
        ->and($site->docs['faqitem0001:fr']['title'])->toBe('Quand êtes-vous ouverts ?');
});

test('a model that never answers leaves only that document to the next run', function () {
    $site = new FakeSite([HOME, PHOTO, FAQ]);
    Translator::fake(function (string $prompt): array {
        $texts = json_decode($prompt, true)['texts'];

        if ($texts[0]['text'] === 'When are you open?') {
            throw ProviderConnectionException::forProvider('openrouter');
        }

        return ['texts' => array_map(fn (array $item): array => [...$item, 'text' => "[fr] {$item['text']}"], $texts)];
    });

    $report = app(SiteTranslator::class)->translate('fr', 'Français');
    $problems = collect($report->documents)->mapWithKeys(fn ($doc) => [$doc->title => $doc->problem]);

    expect($problems['When are you open?'])->toBe("Not translated: the model didn't answer (Could not connect to AI provider [openrouter]). Run it again to go on.")
        ->and($problems['Home'])->toBeNull()
        ->and($site->docs['faqitem0001:fr']['title'])->toBe('When are you open?'); // the copy, untouched
});
```

**Why:** the fake translator puts `[fr]` in front of the words, so a test can see what was
written, and gives each answer a raw OpenRouter body with its cost, as a real answer has.

**5. The command's tests**

```php tests/Feature/SiteTranslateCommandTest.php theme={"dark"}
<?php

use App\Ai\Agents\Translator;
use Fetchski\AttractCmsLaravel\Facades\Attract;

test('a code that isn’t a language code never reaches the site', function () {
    Attract::fake();

    $this->artisan('site:translate', ['code' => 'French', 'name' => 'Français'])
        ->expectsOutputToContain("isn't a language code")
        ->assertFailed();

    Attract::assertNothingCalled();
});

test('a scope the key lacks fails with the site’s own message', function () {
    Attract::fake(['languages.listSiteLanguages' => Attract::forbidden()]);
    Translator::fake()->preventStrayPrompts();

    $this->artisan('site:translate', ['code' => 'fr', 'name' => 'Français'])
        ->expectsOutputToContain('forbidden')
        ->assertFailed();
});
```

**6. Run them**

```bash Terminal theme={"dark"}
php artisan test --compact
```

**You should see:**

```text Terminal theme={"dark"}
  ...............

  Tests:    15 passed (44 assertions)
```

## Turn it off

> [card] Revoke the site key
>
> In the CMS, **⌘K** → `API Keys`, open **Translate the site**, and switch **Enabled** off (or
> delete it). The app's next call is refused.

> [card] Revoke the OpenRouter key
>
> Delete it at [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys), then remove the lines
> you added to `.env`.

> [card] Take the language down
>
> **⌘K** → `Languages`, set its **Public** to **No** (visitors stop seeing it) or remove it. Its
> translations are kept: add the same code again and they come back.

> [card] Nothing runs on its own
>
> The command works only when you run it. Drafts it already made stay in the CMS until you publish or dismiss them.

## Related

> [card] [Your site in more than one language](https://docs.fetchski-attract.com/guides/build-pages/languages.md)
>
> Languages, translating by hand, and what changes for visitors

> [card] [Update the site's languages](https://docs.fetchski-attract.com/laravel/reference/languages/update-site-languages.md)
>
> The call that adds a language and makes it public

> [card] [Testing in Laravel](https://docs.fetchski-attract.com/laravel/testing.md)
>
> `Attract::fake()` and the assertions

> [card] [The same Recipe in AdonisJS](https://docs.fetchski-attract.com/recipes/adonis-agent-translate-your-site.md)
>
> An OpenRouter agent with one checked save tool


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.