> ## 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

> One command in your AdonisJS app adds a language to your site and has an AI 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.

```ts app/services/translation_tools.ts wrap theme={"dark"}
import { tool } from '@openrouter/agent'
import { z } from 'zod'
import type { Wording } from '#services/site_documents'

/** What the run saved, for the code around the agent (never the model's word for it). */
export interface TranslationRun {
  saved: boolean
  /** What the last check found, for the report when nothing was saved. */
  problems: string[]
}

const TAG = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi
const LINK = /\b(?:href|src)\s*=\s*"([^"]*)"/gi

const tagsOf = (text: string) =>
  [...text.matchAll(TAG)]
    .map((match) => (match[0].startsWith('</') ? `/${match[1]}` : match[1]))
    .join(' ')
const linksOf = (text: string) => [...text.matchAll(LINK)].map((match) => match[1]).join(' ')
/** The {year}-style words the site fills in, in any order (a language may move them). */
const placeholdersOf = (text: string) => (text.match(/\{[a-z]+\}/g) ?? []).sort().join(' ')
const plain = (text: string) => text.replace(TAG, ' ').replace(/\s+/g, ' ').trim()

/**
 * 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).
 */
const leftAsIs = (text: string, translation: string, glossary: readonly string[]) => {
  if (plain(text) !== plain(translation)) return false
  const rest = glossary.reduce((left, name) => left.replaceAll(name, ' '), plain(text))
  const words = rest.match(/\p{L}+/gu) ?? []
  return words.length >= 4 && words.filter((word) => /^\p{Ll}/u.test(word)).length >= 2
}

/**
 * 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.
 */
export function problemsWith(
  original: readonly Wording[],
  translated: readonly Wording[],
  glossary: readonly string[]
): string[] {
  const problems: string[] = []
  const given = new Map(translated.map(({ key, text }) => [key, text]))

  for (const { key, text } of original) {
    const translation = given.get(key)
    if (translation === undefined || !translation.trim()) {
      problems.push(`${key}: missing. Translate every key you were given.`)
      continue
    }
    if (tagsOf(translation) !== tagsOf(text)) {
      problems.push(
        `${key}: the HTML tags changed. Keep exactly these, in order: ${tagsOf(text) || 'none'}.`
      )
    }
    if (linksOf(translation) !== linksOf(text)) {
      problems.push(`${key}: a link changed. Keep every href and src exactly as it was.`)
    }
    if (placeholdersOf(translation) !== placeholdersOf(text)) {
      const kept = placeholdersOf(text) || 'none'
      problems.push(`${key}: keep these exactly as written, the site fills them in: ${kept}.`)
    }
    for (const name of glossary) {
      if (text.includes(name) && !translation.includes(name)) {
        problems.push(`${key}: "${name}" is in the glossary. Keep it exactly as written.`)
      }
    }
    if (leftAsIs(text, translation, glossary)) {
      problems.push(`${key}: still in the original language. Translate it.`)
    }
  }
  const known = new Set(original.map(({ key }) => key))
  for (const key of given.keys()) {
    if (!known.has(key)) {
      problems.push(`${key}: not one of the keys you were given. Leave it out.`)
    }
  }
  return problems
}

/**
 * The agent's one tool: check a whole translation and, once it passes, save
 * it to the language's draft with `save`. Nothing is saved until it passes.
 */
export function translationTools(
  original: readonly Wording[],
  glossary: readonly string[],
  save: (translated: readonly Wording[]) => Promise<void>,
  run: TranslationRun
) {
  const saveTranslation = tool({
    name: 'save_translation',
    description:
      'Check the whole translation and save it as the draft in the new language. It names every problem to fix; call it again with all the texts once they are fixed.',
    inputSchema: z.object({
      texts: z
        .array(z.object({ key: z.string(), text: z.string() }))
        .describe('Every key you were given, each with its translated text'),
    }),
    execute: async ({ texts }) => {
      if (run.saved) {
        return { error: 'Already saved. Stop here.' }
      }
      const problems = problemsWith(original, texts, glossary)
      run.problems = problems
      if (problems.length) {
        return { saved: false, problems }
      }
      await save(texts)
      run.saved = true
      return { saved: true }
    },
  })

  return [saveTranslation] as const
}
```

## 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 Ace command, `node ace site:translate fr "Français"`, in your own AdonisJS 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 an agent built with OpenRouter's Agent SDK (`@openrouter/agent`). The agent has one
tool, `save_translation`, which checks the translation and writes it to the language's draft only
once it passes. 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,
copies it, sends it for review and makes the preview links. Nothing goes live until a person
publishes it.

```mermaid theme={"dark"}
flowchart LR
    A[node ace<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 -- problems --> E
    E -- passes --> F[Draft saved +<br/>preview link]
    F --> G[You read and<br/>publish them]
    G -- --publish --> H[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 a
> check 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 capped and reported
>
> Every run prints its tokens and cost. One document stops at $0.50, and a run 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.
> * **Node.js 26.12.1 or later** (`node --version`). This page was built with AdonisJS 7.6,
>   `@openrouter/agent` 1.1 and Zod 4.6.
> * **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 4 below.

## Step by step

**1. Create the app**

```bash Terminal theme={"dark"}
npm create adonisjs@latest site-translate -- --kit=api
cd site-translate
```

**Why:** the API starter kit is the smallest AdonisJS 7 app with Ace, Japa tests, ESLint and
Prettier set up. It starts on SQLite, which this Recipe doesn't use.

**You should see:** `Your AdonisJS project has been created successfully!`, then a prompt inside
`site-translate`.

**2. Point @fetchski at your site**

Do this in `site-translate`, with the **Secret token** you copied above.

Your own site serves the `@fetchski` packages, and any API key from it lets you in. In your
project's folder (beside its `package.json`), make a file named `.npmrc` holding these two lines:

```ini .npmrc theme={"dark"} theme={"dark"}
@fetchski:registry=https://acme.example/api/v1/sdk/npm/
//acme.example/api/v1/sdk/npm/:_authToken=fsk_acme_…
```

The first line says `@fetchski` packages come from your site; the second is the key to sign in with.
Put your site's address in both 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_…`. Write the key itself, never a
variable or a value from `.env`: npm and pnpm don't read `.env`, and pnpm refuses a variable here.

> [!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)).

Then keep the file out of git. Add this line to the project's `.gitignore` (make the file if
there isn't one):

```text .gitignore theme={"dark"} theme={"dark"}
.npmrc
```

> [!WARNING]
>
> **`.npmrc` now holds a key to your site: never commit it.** If git already tracks a `.npmrc`, stop
> tracking it with `git rm --cached .npmrc`. Keep these lines in this project's `.npmrc` only, never
> in the `.npmrc` in your home folder. In CI, write the same file from a secret before installing.

**Why:** your site serves the Fetchski SDK, so npm asks it for every `@fetchski/…` package, with
your key.

**You should see:** `.npmrc` beside `package.json`, and `.npmrc` as the last line of `.gitignore`.

**3. Install the four packages**

In a terminal, in the same folder as the `.npmrc`, run the line for the package manager your
project uses:

```bash npm theme={"dark"} theme={"dark"}
npm install @fetchski/attract-cms-adonis @fetchski/attract-cms-sdk @openrouter/agent zod
```

```bash pnpm theme={"dark"} theme={"dark"}
pnpm add @fetchski/attract-cms-adonis @fetchski/attract-cms-sdk @openrouter/agent zod
```

```bash bun theme={"dark"} theme={"dark"}
bun add @fetchski/attract-cms-adonis @fetchski/attract-cms-sdk @openrouter/agent zod
```

> [!TIP]
>
> **`401 Unauthorized`?** Your site refused the key in `.npmrc`: it's mistyped or cut short, switched
> off, or past its expiry date. Check too that `.npmrc` sits in the folder you ran the command in.

**Why:** `@fetchski/attract-cms-sdk` calls your site and `@fetchski/attract-cms-adonis` fits it into
AdonisJS; `@openrouter/agent` runs the agent and its tool, and `zod` describes the tool's input.

**You should see:** `added … packages`.

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

```bash Terminal theme={"dark"}
node ace configure @fetchski/attract-cms-adonis
```

It asks for your site's address, then the **Secret token** and the **Signing secret** you copied
(both hidden as you type).

**Why:** it reads your site's slug from the key, writes the address and both values to `.env`, the
rules that check them to `start/env.ts`, and `config/attract.ts`, and registers the provider that
puts the site's client in the container. A mistyped key or address writes nothing.

**You should see:** `DONE:` lines for `config/attract.ts`, `.env`, `start/env.ts` and `adonisrc.ts`,
then `[ success ] @fetchski/attract-cms-adonis is configured. Next: node ace attract:check`. Run
that: it ends `[ success ] 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.

**5. 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-…
OPENROUTER_MODEL=~anthropic/claude-sonnet-latest
```

Replace `start/env.ts` with this whole file:

```ts start/env.ts theme={"dark"}
/*
|--------------------------------------------------------------------------
| Environment variables service
|--------------------------------------------------------------------------
|
| The `Env.create` method creates an instance of the Env service. The
| service validates the environment variables and also cast values
| to JavaScript data types.
|
*/

import { Env } from '@adonisjs/core/env'

export default await Env.create(new URL('../', import.meta.url), {
  // Node
  NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
  PORT: Env.schema.number(),
  HOST: Env.schema.string({ format: 'host' }),
  LOG_LEVEL: Env.schema.string(),

  // App
  APP_KEY: Env.schema.secret(),
  APP_URL: Env.schema.string({ format: 'url', tld: false }),

  // Session
  SESSION_DRIVER: Env.schema.enum(['cookie', 'memory', 'database'] as const),

  /*
  |----------------------------------------------------------
  | Variables for configuring @fetchski/attract-cms-adonis
  |----------------------------------------------------------
  */
  FETCHSKI_ATTRACT_SITE: Env.schema.string(),
  FETCHSKI_ATTRACT_URL: Env.schema.string({ format: 'url', tld: false }),
  FETCHSKI_ATTRACT_API_KEY: Env.schema.secret(),
  FETCHSKI_ATTRACT_SIGNING_SECRET: Env.schema.secret.optional(),

  // OpenRouter
  OPENROUTER_API_KEY: Env.schema.secret(),
  OPENROUTER_MODEL: Env.schema.string(),
})
```

And replace `.env.test` with this whole file:

```ini .env.test theme={"dark"}
SESSION_DRIVER=memory

# Tests fake the site and the model: placeholders only
FETCHSKI_ATTRACT_SITE=acme
FETCHSKI_ATTRACT_URL=https://acme.example
FETCHSKI_ATTRACT_API_KEY=fsk_acme_0a1b2c3d_not-a-real-secret
OPENROUTER_API_KEY=sk-or-v1-not-a-real-key
OPENROUTER_MODEL=test/fake-model
```

**Why:** the whole file is the starter kit's, the rules `configure` added, and the two OpenRouter
ones. The app refuses to boot when a value is missing, and the keys stay `Secret`s, which print as
`[redacted]` in logs. Tests never call the site or the model, so placeholders do there.

**You should see:** nothing yet: the next steps use them.

**6. Gather the model's options**

Create the file:

```ts config/openrouter.ts theme={"dark"}
import env from '#start/env'

/**
 * The model the translator runs on, and the limits every run stays inside.
 */
const openrouterConfig = {
  apiKey: env.get('OPENROUTER_API_KEY'),
  model: env.get('OPENROUTER_MODEL'),
  maxSteps: 6, // tool rounds per document, at most
  attempts: 3, // tries per document when the model doesn't answer (a timeout, an outage, a rate limit)
  // How long one model call may take: room for your longest page on a slow model, so it only ends a call that hangs.
  timeoutSeconds: 600,
  maxCostPerDocumentUsd: 0.5, // one document stops once it has spent this much
  maxCostPerRunUsd: 10, // the run starts no new document past this
}

export default openrouterConfig
```

**Why:** the three numbers cap every run, whatever the model does. A document needs one or two
steps: one to save, and one more when the check names a problem to fix.

**7. Put the model in the container**

```bash Terminal theme={"dark"}
node ace make:provider openrouter --register
```

Then replace the file it made:

```ts providers/openrouter_provider.ts theme={"dark"}
import { OpenRouter } from '@openrouter/agent'
import type { ApplicationService } from '@adonisjs/core/types'
import type openrouterConfig from '#config/openrouter'

export default class OpenrouterProvider {
  constructor(protected app: ApplicationService) {}

  /**
   * One OpenRouter client for the whole app. Tests swap it for a fake model.
   */
  register() {
    this.app.container.singleton(OpenRouter, () => {
      const config = this.app.config.get<typeof openrouterConfig>('openrouter')

      return new OpenRouter({ apiKey: config.apiKey.release() })
    })
  }
}
```

**Why:** `OpenRouter` is a class, so it is its own container key, as the package made `SiteClient`:
`@inject()` hands both to the service, and a test replaces each with a fake.

**You should see:** `DONE: create providers/openrouter_provider.ts` and `DONE: update adonisrc.ts
        file`.

**8. List the names never translated**

Create the file, with your own names:

```text storage/glossary.txt theme={"dark"}
# Names the translator never translates, one per line: your business name,
# your services' and products' names, place names you keep as they are.
Acme
Acme Outfitters
Trailhead Tune-Up
```

**Why:** the agent gets this list with every document, and the check refuses a translation that
loses one of these names. Add a name whenever a translation changes one.

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

Create `app/services/site_documents.ts`:

```ts app/services/site_documents.ts theme={"dark"}
import { NotFound, type SiteClient } from '@fetchski/attract-cms-sdk'

/** A document as the site's REST routes send it, under the API's own names. */
export interface StoredDoc {
  _id: string
  aposDocId: string
  type: string
  slug?: string
  title?: string
  _url?: string
  [field: string]: unknown
}

/** Which document: enough to reach it in any language. */
export interface DocRef {
  aposDocId: string
  type: string
  slug?: string
  title?: string
}

/** One piece of a document's wording, under the key core's PATCH writes it back by. */
export interface Wording {
  key: string
  text: string
}

// Copied into the language so pages keep them, never translated.
const MEDIA = new Set(['@apostrophecms/image', '@apostrophecms/file', 'video'])

// Keys that hold codes, addresses or settings, never words a visitor reads.
const NOT_WORDING_KEYS = new Set([
  'type',
  'metaType',
  'slug',
  'path',
  'aposDocId',
  'aposLocale',
  'aposMode',
  'visibility',
  'titleSortified',
  'highSearchText',
  'lowSearchText',
  'searchSummary',
  'parkedId',
  'source',
  'scopedArrayName',
  'submitted', // who sent it for review, and when
])
const NOT_WORDING_SUFFIX =
  /(By|Url|Href|Color|ColorLight|Custom|Icon|IconFamily|Css|Slug|Email|Phone|Anchor|Code)$/
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
]

/** A page lives under the page routes; every other type under its own name. */
export const isPage = (doc: DocRef) => typeof doc.slug === 'string' && doc.slug.startsWith('/')
export const isMedia = (doc: DocRef) => MEDIA.has(doc.type)

const route = (doc: DocRef) => `/${isPage(doc) ? '@apostrophecms/page' : doc.type}/${doc.aposDocId}`

/**
 * 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.
 */
export class SiteDocuments {
  constructor(protected site: SiteClient) {}

  /**
   * 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.
   */
  async list(type: string, locale: string, query: Record<string, string | number> = {}) {
    const all: StoredDoc[] = []
    for (let page = 1, pages = 1; page <= pages; page++) {
      const answer = await this.site.request<{ results: StoredDoc[]; pages?: number }>(
        'GET',
        `/${type}`,
        { query: { ...query, aposLocale: locale, aposMode: 'draft', perPage: 100, page } }
      )
      all.push(...(answer?.results ?? []))
      pages = answer?.pages ?? 1
    }
    return all
  }

  /** The draft in `locale`, with its related documents; null when the language has no copy yet. */
  async get(doc: DocRef, locale: string) {
    try {
      return await this.site.request<StoredDoc>('GET', route(doc), {
        query: { aposLocale: locale, aposMode: 'draft' },
      })
    } catch (error) {
      if (error instanceof NotFound) {
        return null
      }
      throw error
    }
  }

  /** Copies the draft in `from` into `to`, as a draft there; `update` overwrites a copy already there. */
  async copy(doc: DocRef, from: string, to: string, update: boolean) {
    await this.site.request('POST', `${route(doc)}:${from}:draft/localize`, {
      body: { toLocale: to, update },
    })
  }

  /**
   * Writes the translated wording to the draft in `locale`. Core's PATCH takes
   * `@<widget id>.<field>` keys, so only the words change, never a setting.
   */
  async write(doc: DocRef, locale: string, wording: readonly Wording[]) {
    await this.site.request('PATCH', route(doc), {
      query: { aposLocale: locale, aposMode: 'draft' },
      body: Object.fromEntries(wording.map(({ key, text }) => [key, text])),
    })
  }

  /** Puts the draft in `locale` in the CMS's review queue (Submitted Drafts). */
  async submit(doc: DocRef, locale: string) {
    await this.site.request('POST', `${route(doc)}:${locale}:draft/submit`)
  }
}

/**
 * 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.
 */
export function isUpToDate(original: StoredDoc | null, copy: StoredDoc | null) {
  const copiedAt = typeof copy?.localizedAt === 'string' ? copy.localizedAt : null
  return Boolean(
    original &&
    copiedAt &&
    copiedAt >= String(original.updatedAt) &&
    String(copy?.updatedAt) > copiedAt
  )
}

/**
 * The words a visitor reads in `doc`: 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).
 */
export function wordingOf(doc: StoredDoc, fields?: readonly string[]): Wording[] {
  const found: Wording[] = []

  const visit = (value: unknown, owner: string | null, path: string) => {
    if (typeof value === 'string') {
      const text = value.trim()
      if (text && !NOT_WORDS.some((pattern) => pattern.test(text))) {
        found.push({ key: owner ? `@${owner}.${path}` : path, text: value })
      }
      return
    }
    if (Array.isArray(value)) {
      value.forEach((item, index) => {
        // A widget or array row has an id: its fields are keyed by it.
        if (item && typeof item === 'object' && typeof item._id === 'string') {
          visit(item, item._id, '')
        } else {
          visit(item, owner, path ? `${path}.${index}` : String(index))
        }
      })
      return
    }
    if (!value || typeof value !== 'object' || (value as StoredDoc).metaType === 'doc') {
      return
    }
    for (const [key, child] of Object.entries(value)) {
      if (key.startsWith('_') || NOT_WORDING_KEYS.has(key) || NOT_WORDING_SUFFIX.test(key)) {
        continue
      }
      const area = child as { metaType?: string; items?: unknown[] } | null
      visit(area?.metaType === 'area' ? area.items : child, owner, path ? `${path}.${key}` : key)
    }
  }

  for (const [key, value] of Object.entries(doc)) {
    if (
      !key.startsWith('_') &&
      !NOT_WORDING_KEYS.has(key) &&
      !NOT_WORDING_SUFFIX.test(key) &&
      (!fields || fields.includes(key))
    ) {
      const area = value as { metaType?: string; items?: unknown[] } | null
      visit(area?.metaType === 'area' ? area.items : value, null, key)
    }
  }
  return found
}

/** The path of a document's `_url` (`/about-us`), without the site's address. */
export const pathOf = (url: unknown) =>
  typeof url === 'string' ? (URL.parse(url, 'https://site.invalid')?.pathname ?? null) : null

/**
 * 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.
 */
export function linksTo(
  doc: StoredDoc,
  fields: readonly string[],
  pointTo: ReadonlyMap<string, string>
): Wording[] {
  const found: Wording[] = []
  const visit = (value: unknown, owner: string | null, key: string) => {
    if (Array.isArray(value)) {
      for (const row of value) {
        if (row && typeof row === 'object') {
          for (const [field, child] of Object.entries(row)) {
            visit(child, typeof row._id === 'string' ? row._id : owner, field)
          }
        }
      }
      return
    }
    // A link on this site starts with one slash; another site's is left alone.
    if (typeof value !== 'string' || !/(^url|Url)$/.test(key) || !/^\/(?!\/)/.test(value)) {
      return
    }
    const [, path = '', rest = ''] = /^([^?#]*)(.*)$/.exec(value) ?? []
    const to = pointTo.get(path)
    if (to && `${to}${rest}` !== value) {
      found.push({ key: owner ? `@${owner}.${key}` : key, text: `${to}${rest}` })
    }
  }
  for (const field of fields) {
    visit(doc[field], null, field)
  }
  return found
}

/**
 * The documents `doc` shows (images, authors, categories, forms…), once each.
 * Pages are left out: every page is in the run already.
 */
export function relatedTo(doc: StoredDoc): DocRef[] {
  const found = new Map<string, DocRef>()

  const visit = (value: unknown, root: boolean) => {
    if (Array.isArray(value)) {
      value.forEach((item) => visit(item, false))
      return
    }
    if (!value || typeof value !== 'object') {
      return
    }
    const item = value as StoredDoc
    // 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 && item.metaType === 'doc' && typeof item._id === 'string') {
      const [aposDocId, locale] = item._id.split(':')
      if (locale && !isPage(item)) {
        found.set(aposDocId, {
          aposDocId,
          type: item.type,
          slug: item.slug,
          title: item.title,
        })
      }
      return
    }
    for (const [key, child] of Object.entries(item)) {
      // A page's tree and its published twin are pages, not what it shows.
      if (!['_ancestors', '_children', '_parent', '_publishedDoc'].includes(key)) {
        visit(child, false)
      }
    }
  }

  visit(doc, true)
  return [...found.values()]
}
```

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

> [card] Only the words
>
> `wordingOf()` 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.

> [card] What each page shows
>
> `relatedTo()` finds the images, authors and categories a document shows. They are copied
> into the language first: a page keeps a related document only once it exists there.

> [card] Only the settings' own words
>
> For the site settings, `wordingOf()` takes only the fields the site names with
> `getGlobalTranslatedFields()`; every other setting is shared by all your languages.

> [card] Links to your pages
>
> `linksTo()` points each header and footer link to one of your pages at that page in the new
> language, keeping its `#anchor`. A link to another site stays as it is.

**10. Give the agent its tool**

Create `app/services/translation_tools.ts`. It is the file pinned beside this page; here it is
again in full:

```ts app/services/translation_tools.ts theme={"dark"}
import { tool } from '@openrouter/agent'
import { z } from 'zod'
import type { Wording } from '#services/site_documents'

/** What the run saved, for the code around the agent (never the model's word for it). */
export interface TranslationRun {
  saved: boolean
  /** What the last check found, for the report when nothing was saved. */
  problems: string[]
}

const TAG = /<\/?([a-z][a-z0-9]*)\b[^>]*>/gi
const LINK = /\b(?:href|src)\s*=\s*"([^"]*)"/gi

const tagsOf = (text: string) =>
  [...text.matchAll(TAG)]
    .map((match) => (match[0].startsWith('</') ? `/${match[1]}` : match[1]))
    .join(' ')
const linksOf = (text: string) => [...text.matchAll(LINK)].map((match) => match[1]).join(' ')
/** The {year}-style words the site fills in, in any order (a language may move them). */
const placeholdersOf = (text: string) => (text.match(/\{[a-z]+\}/g) ?? []).sort().join(' ')
const plain = (text: string) => text.replace(TAG, ' ').replace(/\s+/g, ' ').trim()

/**
 * 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).
 */
const leftAsIs = (text: string, translation: string, glossary: readonly string[]) => {
  if (plain(text) !== plain(translation)) return false
  const rest = glossary.reduce((left, name) => left.replaceAll(name, ' '), plain(text))
  const words = rest.match(/\p{L}+/gu) ?? []
  return words.length >= 4 && words.filter((word) => /^\p{Ll}/u.test(word)).length >= 2
}

/**
 * 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.
 */
export function problemsWith(
  original: readonly Wording[],
  translated: readonly Wording[],
  glossary: readonly string[]
): string[] {
  const problems: string[] = []
  const given = new Map(translated.map(({ key, text }) => [key, text]))

  for (const { key, text } of original) {
    const translation = given.get(key)
    if (translation === undefined || !translation.trim()) {
      problems.push(`${key}: missing. Translate every key you were given.`)
      continue
    }
    if (tagsOf(translation) !== tagsOf(text)) {
      problems.push(
        `${key}: the HTML tags changed. Keep exactly these, in order: ${tagsOf(text) || 'none'}.`
      )
    }
    if (linksOf(translation) !== linksOf(text)) {
      problems.push(`${key}: a link changed. Keep every href and src exactly as it was.`)
    }
    if (placeholdersOf(translation) !== placeholdersOf(text)) {
      const kept = placeholdersOf(text) || 'none'
      problems.push(`${key}: keep these exactly as written, the site fills them in: ${kept}.`)
    }
    for (const name of glossary) {
      if (text.includes(name) && !translation.includes(name)) {
        problems.push(`${key}: "${name}" is in the glossary. Keep it exactly as written.`)
      }
    }
    if (leftAsIs(text, translation, glossary)) {
      problems.push(`${key}: still in the original language. Translate it.`)
    }
  }
  const known = new Set(original.map(({ key }) => key))
  for (const key of given.keys()) {
    if (!known.has(key)) {
      problems.push(`${key}: not one of the keys you were given. Leave it out.`)
    }
  }
  return problems
}

/**
 * The agent's one tool: check a whole translation and, once it passes, save
 * it to the language's draft with `save`. Nothing is saved until it passes.
 */
export function translationTools(
  original: readonly Wording[],
  glossary: readonly string[],
  save: (translated: readonly Wording[]) => Promise<void>,
  run: TranslationRun
) {
  const saveTranslation = tool({
    name: 'save_translation',
    description:
      'Check the whole translation and save it as the draft in the new language. It names every problem to fix; call it again with all the texts once they are fixed.',
    inputSchema: z.object({
      texts: z
        .array(z.object({ key: z.string(), text: z.string() }))
        .describe('Every key you were given, each with its translated text'),
    }),
    execute: async ({ texts }) => {
      if (run.saved) {
        return { error: 'Already saved. Stop here.' }
      }
      const problems = problemsWith(original, texts, glossary)
      run.problems = problems
      if (problems.length) {
        return { saved: false, problems }
      }
      await save(texts)
      run.saved = true
      return { saved: true }
    },
  })

  return [saveTranslation] as const
}
```

**Why:** the check runs in your code, not in the model's judgment. `save_translation` writes
nothing until every key is there, translated, and every tag, link, glossary name and
`{year}`-style word the site fills in is kept (a sentence handed back as it was counts as
untranslated; a name or an address never does);
otherwise it
sends the problems back, and the agent fixes them and tries again.

**11. Write the translator**

```ts app/services/site_translator.ts theme={"dark"}
import { readFile } from 'node:fs/promises'
import { inject } from '@adonisjs/core'
import app from '@adonisjs/core/services/app'
import { maxCost, OpenRouter, stepCountIs } from '@openrouter/agent'
import { SiteClient } from '@fetchski/attract-cms-sdk'
import type openrouterConfig from '#config/openrouter'
import {
  type DocRef,
  isMedia,
  isUpToDate,
  linksTo,
  pathOf,
  relatedTo,
  SiteDocuments,
  type StoredDoc,
  type Wording,
  wordingOf,
} from '#services/site_documents'
import { translationTools, type TranslationRun } from '#services/translation_tools'

const INSTRUCTIONS = `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.
  Translate every text into the language and call save_translation once with
  every key.
- 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.
- If save_translation names problems, fix them and call it again with every key.`

/** One document's outcome, for the report. */
export interface Translated {
  title: string
  kind: 'page' | 'blog post' | 'FAQ' | 'site settings' | 'shown on them'
  /** The draft on the site, for anyone with the link, without signing in. */
  previewUrl: string | null
  /** Why it was skipped or failed; null once saved. */
  problem: string | null
}

export interface TranslationReport {
  added: boolean
  documents: Translated[]
  upToDate: number
  inputTokens: number
  outputTokens: number
  costUsd: number
  published: boolean
}

interface Job extends DocRef {
  kind: Translated['kind']
  share?: (id: string) => Promise<{ aposShareKey?: string | null }>
  /** The only fields with words to translate (the site settings': the rest are shared). */
  fields?: string[]
  /** Links pointed at the language's pages, saved with the translation. */
  links?: Wording[]
}

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

@inject()
export class SiteTranslator {
  protected docs: SiteDocuments

  constructor(
    protected site: SiteClient,
    protected openrouter: OpenRouter
  ) {
    this.docs = new SiteDocuments(site)
  }

  /**
   * Translates the site's pages, blog posts and FAQs (and what they show), and
   * its header, footer and cookie banner, into `code`, adding the language
   * first if the site lacks it. Every translation is a draft; `publish` makes
   * the language public, nothing else.
   */
  async translate(code: string, name: string, publish = false): Promise<TranslationReport> {
    const added = await this.ensureLanguage(code, name, publish)
    const main = await this.mainLocale(code)
    const glossary = await this.glossary()
    const report: TranslationReport = {
      added,
      documents: [],
      upToDate: 0,
      inputTokens: 0,
      outputTokens: 0,
      costUsd: 0,
      published: publish,
    }

    // 1. What needs translating: never copied, or changed since its copy.
    const { all, paths } = await this.listAll(main, code)
    const jobs: Job[] = []
    for (const listed of all) {
      if (listed.upToDate) {
        report.upToDate++
      } else {
        jobs.push(listed.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.
    const shown: Job[] = []
    const seen = new Set<string>()
    const copyShown = async (doc: DocRef) => {
      const stored = await this.docs.get(doc, main)
      for (const related of stored ? relatedTo(stored) : []) {
        if (seen.has(related.aposDocId)) {
          continue
        }
        seen.add(related.aposDocId)
        const copy = await 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 && (isMedia(related) || isUpToDate(await this.docs.get(related, main), copy))) {
          continue
        }
        await this.docs.copy(related, main, code, copy !== null)
        if (!isMedia(related)) {
          shown.push({ ...related, kind: 'shown on them' })
        }
        await copyShown(related)
      }
    }
    for (const { job } of all) {
      await copyShown(job)
    }
    for (const job of jobs) {
      await this.docs.copy(job, main, code, true)
    }
    // The settings' links to pages point at the language's pages.
    const settings = all.find(({ job }) => job.kind === 'site settings')
    if (settings?.job.fields && !settings.upToDate) {
      settings.job.links = linksTo(
        settings.original,
        settings.job.fields,
        await this.pathsIn(code, paths)
      )
    }

    // 3. Translate each copy and send it for review, within the run's budget.
    const config = app.config.get<typeof openrouterConfig>('openrouter')
    for (const job of [...jobs, ...shown]) {
      if (report.costUsd >= config.maxCostPerRunUsd) {
        report.documents.push(
          this.outcome(
            job,
            null,
            `Not translated: the run reached $${config.maxCostPerRunUsd}. Run it again to go on.`
          )
        )
        continue
      }
      report.documents.push(await this.translateOne(job, code, name, glossary, report))
    }
    return report
  }

  /** Adds the language, not public, when the site lacks it; `publish` makes it public. */
  protected async ensureLanguage(code: string, name: string, publish: boolean) {
    const lists = await this.site.languages().listSiteLanguages()
    const [list] = lists.results
    if (!list) {
      throw new Error(
        'The site has no language list: open Languages in the CMS once, then run this again.'
      )
    }
    const languages = list.languages ?? []
    const known = languages.find((language) => language.code === code)
    if (known && (!publish || known.public)) {
      return false
    }
    await this.site.languages().updateSiteLanguages({
      id: list.id,
      languages: known
        ? languages.map((language) =>
            language.code === code ? { ...language, public: true } : language
          )
        : [...languages, { name, code, public: publish }],
    })
    return !known
  }

  /** The site's main language: the one without an address prefix. Checks `code` is one of its languages now. */
  protected async mainLocale(code: string) {
    const locales = await this.site.internationalization().getLocales()
    const main = Object.keys(locales).find((locale) => !locales[locale]?.prefix)
    if (!main || !locales[code] || code === main) {
      throw new Error(
        `"${code}" isn't one of the site's further languages: the site has ${Object.keys(locales).join(', ')}.`
      )
    }
    return main
  }

  /** The names never translated, one per line in storage/glossary.txt. */
  protected async glossary() {
    const text = await readFile(app.makePath('storage/glossary.txt'), 'utf8').catch(() => '')
    return text
      .split('\n')
      .map((line) => line.trim())
      .filter((line) => line && !line.startsWith('#'))
  }

  /**
   * 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.
   */
  protected async listAll(main: string, code: string) {
    const kinds = [
      {
        kind: 'page' as const,
        type: '@apostrophecms/page',
        query: { all: 1, flat: 1 },
        share: (id: string) => this.site.pages().sharePage({ id, share: true }),
      },
      {
        kind: 'blog post' as const,
        type: '@apostrophecms/blog',
        share: (id: string) => this.site.blogPosts().shareBlog({ id, share: true }),
      },
      { kind: 'FAQ' as const, type: 'faq-item' },
    ]
    const out: { job: Job; original: StoredDoc; upToDate: boolean }[] = []
    const paths = new Map<string, string>()

    for (const { kind, type, query, share } of kinds) {
      const copyList = await this.docs.list(type, code, query)
      const copies = new Map(copyList.map((copy) => [copy.aposDocId, copy]))
      const originalList = await this.docs.list(type, main, query)
      const originals = originalList.toSorted(
        (a, b) =>
          Number(a.level ?? 0) - Number(b.level ?? 0) || Number(a.rank ?? 0) - Number(b.rank ?? 0)
      )
      for (const original of originals) {
        const path = pathOf(original._url)
        if (path && kind !== 'FAQ') {
          paths.set(original.aposDocId, path)
        }
        let copy = copies.get(original.aposDocId)
        // The page tree's list sends a child page's main fields only: read it whole.
        if (copy && !('localizedAt' in copy)) {
          copy = (await this.docs.get(copy, code)) ?? undefined
        }
        out.push({
          job: {
            aposDocId: original.aposDocId,
            type: original.type,
            slug: original.slug,
            title: original.title,
            kind,
            share,
          },
          original,
          upToDate: isUpToDate(original, copy ?? null),
        })
      }
    }

    // The header, footer and cookie banner: the settings' fields the site says
    // each language translates.
    const [settings] = await this.docs.list(SETTINGS, main)
    if (settings) {
      const upToDate = isUpToDate(settings, await this.docs.get(settings, code))
      const { translated } = upToDate
        ? { translated: [] }
        : await this.site.languages().getGlobalTranslatedFields()
      out.push({
        job: {
          aposDocId: settings.aposDocId,
          type: settings.type,
          slug: settings.slug,
          title: 'Header, footer and cookie banner',
          kind: 'site settings',
          fields: [...translated],
        },
        original: settings,
        upToDate,
      })
    }
    return { all: out, paths }
  }

  /** Each page's and post's path → its path in the language (`/about-us` → `/fr/about-us`). */
  protected async pathsIn(code: string, paths: ReadonlyMap<string, string>) {
    const pointTo = new Map<string, string>()
    for (const [type, query] of [
      ['@apostrophecms/page', { all: 1, flat: 1 }],
      ['@apostrophecms/blog', {}],
    ] as const) {
      for (const copy of await this.docs.list(type, code, query)) {
        const from = paths.get(copy.aposDocId)
        const to = pathOf(copy._url)
        if (from && to) {
          pointTo.set(from, to)
        }
      }
    }
    return pointTo
  }

  /** Has the agent translate one copy, saves it, and sends it for review. */
  protected async translateOne(
    job: Job,
    code: string,
    name: string,
    glossary: string[],
    report: TranslationReport
  ) {
    const config = app.config.get<typeof openrouterConfig>('openrouter')
    const copy = await this.docs.get(job, code)
    const texts = copy ? wordingOf(copy, job.fields) : []
    if (!copy || !texts.length) {
      return this.outcome(job, null, copy ? 'Nothing to translate.' : `No copy in ${code}.`)
    }

    const run: TranslationRun = { saved: false, problems: [] }
    let failure: string | null = null
    for (let attempt = 1; attempt <= config.attempts && !run.saved; attempt++) {
      const result = this.openrouter.callModel(
        {
          model: config.model,
          instructions: INSTRUCTIONS,
          input: JSON.stringify({ language: `${name} (${code})`, glossary, texts }),
          tools: translationTools(
            texts,
            glossary,
            (translated) => this.docs.write(job, code, [...translated, ...(job.links ?? [])]),
            run
          ),
          stopWhen: [
            stepCountIs(config.maxSteps),
            maxCost(config.maxCostPerDocumentUsd),
            () => run.saved,
          ],
        },
        { timeoutMs: config.timeoutSeconds * 1000 }
      )
      try {
        await result.getText() // throws when the model never answered
        failure = null
        break
      } catch (error) {
        // The model didn't answer: a timeout, an outage, a rate limit. Try again.
        failure = (error as Error).message.replace(/\.$/, '')
      } finally {
        // Every attempt counts toward the run's budget, a failed one too.
        const usage = await result.getUsage()
        report.inputTokens += usage.inputTokens
        report.outputTokens += usage.outputTokens
        report.costUsd += usage.cost ?? 0
      }
    }

    if (!run.saved) {
      const problem = failure
        ? `Not translated: the model didn't answer (${failure}). Run it again to go on.`
        : `Not saved: ${run.problems.slice(0, 3).join(' ') || 'the model stopped before saving.'}`
      return this.outcome(job, null, problem)
    }
    await this.docs.submit(job, code)
    const shared = job.share ? await job.share(`${job.aposDocId}:${code}:draft`) : null
    const url = typeof copy._url === 'string' ? copy._url : null
    const previewUrl =
      url && shared?.aposShareKey
        ? `${url}?${new URLSearchParams({ aposShareKey: shared.aposShareKey, aposShareId: `${job.aposDocId}:${code}:draft` })}`
        : null
    return this.outcome(job, previewUrl, null)
  }

  protected outcome(job: Job, previewUrl: string | null, problem: string | null): Translated {
    return { title: job.title ?? job.slug ?? job.aposDocId, kind: job.kind, previewUrl, 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 `callModel()` per document, stopped by 6 steps, \$0.50 or the save. Each call may take
up to `timeoutSeconds` (ten minutes: room for a long page on a slow model, so it only ends a
call that hangs); a call the model doesn't answer is tried again, up to `attempts`, rather
than stopping the run. Each saved draft is sent for review and, for pages and posts, shared
for its preview link.

**12. Add the command**

```bash Terminal theme={"dark"}
node ace make:command site_translate
```

Replace the file it made:

```ts commands/site_translate.ts theme={"dark"}
import { inject } from '@adonisjs/core'
import { args, BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { Forbidden } from '@fetchski/attract-cms-sdk'
import { SiteTranslator, type TranslationReport } from '#services/site_translator'

export default class SiteTranslate extends BaseCommand {
  static commandName = 'site:translate'
  static description =
    'Translate the site’s pages, blog posts and FAQs into a language, as drafts for review'
  static options: CommandOptions = { startApp: true }

  @args.string({ description: 'The language code, such as fr or pt-br' })
  declare code: string

  @args.string({ description: 'The language as its speakers write it, such as "Français"' })
  declare name: string

  @flags.boolean({ description: 'Also make the language public, once its drafts are published' })
  declare publish: boolean

  @inject()
  async run(translator: SiteTranslator) {
    if (!/^[a-z]{2,3}(-[a-z]{4})?(-([a-z]{2}|[0-9]{3}))?$/.test(this.code)) {
      this.logger.error(`"${this.code}" isn't a language code: use lowercase, such as fr or pt-br`)
      this.exitCode = 1
      return
    }

    let report: TranslationReport
    try {
      report = await translator.translate(this.code, this.name, this.publish)
    } catch (error) {
      // A missing scope: the site's message names it. Nothing was overwritten.
      this.logger.error(error instanceof Forbidden ? error.siteMessage : (error as Error).message)
      this.exitCode = 1
      return
    }

    if (report.added) {
      this.logger.success(
        `Added ${this.name} (${this.code}) to the site, ${report.published ? 'public' : 'not public yet'}`,
      )
    }
    if (report.documents.length) {
      const table = this.ui.table().head(['', 'Document', 'Preview'])
      for (const doc of report.documents) {
        table.row([
          doc.kind,
          doc.title,
          doc.problem ?? doc.previewUrl ?? 'Submitted Drafts in the CMS',
        ])
      }
      table.render()
    }

    const failed = report.documents.filter(
      (doc) => doc.problem && doc.problem !== 'Nothing to translate.'
    )
    this.logger.info(
      `${report.documents.length - failed.length} translated, ${failed.length} not, ${report.upToDate} already up to date`
    )
    this.logger.info(
      `Tokens: ${report.inputTokens} in, ${report.outputTokens} out. Cost: $${report.costUsd.toFixed(4)}`
    )
    if (report.published) {
      this.logger.success(`${this.name} is public: visitors see each page you have published in it`)
    }
    if (failed.length) {
      this.exitCode = 1
    }
  }
}
```

**Why:** `startApp: true` boots the app, so the container has both clients; `@inject()` on `run()`
hands it the `SiteTranslator`. Any document left untranslated sets exit code `1`, so a script
around it can tell.

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

**13. Format, lint and type-check**

```bash Terminal theme={"dark"}
npm run format
node ace codegen
npm run lint
npm run typecheck
```

**Why:** `configure` and `make:provider` write `adonisrc.ts` in a shape Prettier changes, and
`codegen` writes the index files `tsc` reads.

**You should see:** `npm run lint` and `npm run typecheck` print only their own command lines
(`> eslint .`, `> tsc --noEmit`), no errors.

## Run it

**1. Translate**

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

```bash Terminal theme={"dark"}
node ace 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"}
[ success ] 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"}
node ace 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] A message 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] 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] Not saved: … in the table
>
> The agent couldn't make a translation pass its checks in 6 steps, and the words after `Not saved:`
> are the problems its last check found. Its draft is the untouched copy. Run the command again to
> retry just that one; if a name keeps changing, add it to `storage/glossary.txt`.

> [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 `maxCostPerRunUsd` in `config/openrouter.ts`.

> [accordion] Not translated: the model didn't answer (…)
>
> Every attempt at that document failed: the words in brackets say how (a timeout, 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 `timeoutSeconds` in
> `config/openrouter.ts`.

> [accordion] UnauthorizedResponseError: 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-`.

> [accordion] EnvValidationException: Missing environment variable
>
> `Missing environment variable "OPENROUTER_API_KEY"` (or another name): that line isn't in `.env`.
> Add it as in step 5 of Step by step. In tests, it's missing from `.env.test`.

> [accordion] npm install answers 401 Unauthorized
>
> Your site refused the key in `.npmrc`: it's mistyped or cut short, switched off, or past its
> **Expires** date. Paste the whole key again (step 2), and run the install from `site-translate`,
> where `.npmrc` is.

To see each call the app made to the site, run it with `LOG_LEVEL=debug` in front:
`LOG_LEVEL=debug node ace site:translate fr "Français"`.

## Test it

The tests fake both sides. A fake site keeps its documents in memory and answers the routes the
translator calls, on top of `fakeAttract()`, which answers every SDK method and records it. For the
model, a tiny server on `127.0.0.1` answers OpenRouter's requests in its own stream format: so the
real agent loop runs, with the real tool and its checks, and nothing is sent or spent.

**1. The fake model**

```ts tests/helpers/fake_model.ts theme={"dark"}
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { OpenRouter } from '@openrouter/agent'

/** One answer from the model: tool calls to make, its closing text, or an HTTP error status. */
export type FakeTurn =
  | { calls: { name: string; arguments: Record<string, unknown> }[] }
  | { text: string }
  | { fail: number }

/** Works out the next turn from the run's first message (the document) and how many requests came before. */
export type FakeScript = FakeTurn[] | ((input: string, request: number) => FakeTurn)

/** The text of a Responses API request's first user message. */
function firstInput(body: Record<string, unknown>) {
  const input = body.input
  if (typeof input === 'string') {
    return input
  }
  const first = Array.isArray(input) ? (input[0] as { content?: unknown }) : undefined
  if (typeof first?.content === 'string') {
    return first.content
  }
  const part = Array.isArray(first?.content) ? (first.content[0] as { text?: string }) : undefined
  return part?.text ?? ''
}

/**
 * A stand-in for OpenRouter on 127.0.0.1: it answers each request with the
 * next turn, in the Responses API's own stream format, and keeps every
 * request it was sent. Nothing leaves the machine and nothing is spent.
 */
export async function fakeModel(script: FakeScript) {
  const requests: Record<string, unknown>[] = []
  let inRun = 0

  const server = createServer(async (request, response) => {
    let body = ''
    for await (const chunk of request) {
      body += chunk
    }
    const sent = JSON.parse(body) as Record<string, unknown>
    requests.push(sent)

    // A request whose input is only the document starts a new run.
    const input = Array.isArray(sent.input) ? sent.input : [sent.input]
    inRun = input.length === 1 ? 1 : inRun + 1
    const turn =
      typeof script === 'function'
        ? script(firstInput(sent), inRun)
        : (script[requests.length - 1] ?? { text: 'Done.' })

    if ('fail' in turn) {
      response.writeHead(turn.fail, { 'content-type': 'application/json' })
      response.end(JSON.stringify({ error: { code: turn.fail, message: 'Rate limit exceeded' } }))
      return
    }

    const output =
      'calls' in turn
        ? turn.calls.map((call, index) => ({
            type: 'function_call',
            id: `fc_${requests.length}_${index}`,
            call_id: `call_${requests.length}_${index}`,
            name: call.name,
            arguments: JSON.stringify(call.arguments),
            status: 'completed',
          }))
        : [
            {
              type: 'message',
              id: `msg_${requests.length}`,
              role: 'assistant',
              status: 'completed',
              content: [{ type: 'output_text', text: turn.text, annotations: [] }],
            },
          ]

    const completed = {
      type: 'response.completed',
      sequence_number: 1,
      response: {
        id: `resp_${requests.length}`,
        object: 'response',
        created_at: 1,
        completed_at: 2,
        model: 'test/fake-model',
        status: 'completed',
        error: null,
        incomplete_details: null,
        instructions: null,
        metadata: {},
        parallel_tool_calls: true,
        frequency_penalty: 0,
        presence_penalty: 0,
        temperature: null,
        top_p: null,
        tools: [],
        tool_choice: 'auto',
        output,
        usage: {
          input_tokens: 1000,
          output_tokens: 200,
          total_tokens: 1200,
          cost: 0.001,
          input_tokens_details: { cached_tokens: 0 },
          output_tokens_details: { reasoning_tokens: 0 },
        },
      },
    }
    response.writeHead(200, { 'content-type': 'text/event-stream' })
    response.end(`event: response.completed\ndata: ${JSON.stringify(completed)}\n\n`)
  })

  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
  const { port } = server.address() as AddressInfo

  return {
    client: new OpenRouter({ apiKey: 'test', serverURL: `http://127.0.0.1:${port}/api/v1` }),
    requests,
    close: () => new Promise<void>((resolve) => server.close(() => resolve())),
  }
}

/**
 * A fake translator: it saves every text it is given with `[code]` in front of
 * its words (tags and links kept), then says it is done.
 */
export function fakeTranslator(input: string, request: number): FakeTurn {
  if (request > 1) {
    return { text: 'Done.' }
  }
  const { language, texts } = JSON.parse(input) as {
    language: string
    texts: { key: string; text: string }[]
  }
  const code = /\(([^)]+)\)$/.exec(language)?.[1] ?? '??'
  return {
    calls: [
      {
        name: 'save_translation',
        arguments: {
          texts: texts.map(({ key, text }) => ({
            key,
            // Mark the words, never the tags: "<p>Hi</p>" → "<p>[fr] Hi</p>".
            text: text.replace(
              /(^|>)([^<]*\p{L}[^<]*)/u,
              (_, before, words) => `${before}[${code}] ${words}`
            ),
          })),
        },
      },
    ],
  }
}
```

**Why:** `serverURL` points a real `OpenRouter` client at it, so the test swaps in that client
(Japa's `swap()`, restored after the test) and nothing else changes. `fakeTranslator` "translates"
by putting `[fr]` in front of the words, so a test can see what was written.

**2. The fake site**

```ts tests/helpers/fake_site.ts theme={"dark"}
import { factories, fakeAttract, notFound, siteError } from '@fetchski/attract-cms-adonis/testing'
import type { StoredDoc } from '#services/site_documents'

interface Route {
  method: string
  path: string
  query?: Record<string, unknown>
  body?: Record<string, unknown>
}

const PAGES = '@apostrophecms/page'

/**
 * 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 `fakeAttract` 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.
 */
export function fakeSite(stored: StoredDoc[], answers: Record<string, unknown> = {}) {
  const docs = new Map(stored.map((doc) => [`${doc.aposDocId}:en`, structuredClone(doc)]))
  const log: string[] = [] // each copy and write, in order: "copy <id> to fr", "write <id> in fr"
  const typeOf = (doc: StoredDoc) => (doc.slug?.startsWith('/') ? PAGES : doc.type)

  const route = ({ method, path, query, body }: Route) => {
    const [, type, ref, action] =
      /^\/(.+?)(?:\/([a-z0-9]{10,}(?::[a-z-]+:draft)?))?(?:\/(localize|submit))?$/.exec(path) ?? []
    const id = ref?.split(':')[0]
    const locale = String(query?.aposLocale ?? '')
    if (method === 'GET' && !id) {
      const results = [...docs.entries()]
        .filter(([key, doc]) => key.endsWith(`:${locale}`) && typeOf(doc) === type)
        .map(([, doc]) => doc)
      return { results, pages: 1 }
    }
    if (method === 'GET') {
      return docs.get(`${id}:${locale}`) ?? notFound()
    }
    if (action === 'localize') {
      const to = String(body?.toLocale)
      if (docs.has(`${id}:${to}`) && !body?.update) {
        return siteError(409)
      }
      log.push(`copy ${id} to ${to}`)
      const at = new Date().toISOString()
      const original = docs.get(`${id}:en`)!
      docs.set(`${id}:${to}`, {
        ...structuredClone(original),
        _id: `${id}:${to}:draft`,
        ...(original._url ? { _url: original._url.replace('.example/', `.example/${to}/`) } : {}),
        updatedAt: at,
        localizedAt: at,
      })
      return {}
    }
    if (method === 'PATCH') {
      log.push(`write ${id} in ${locale}`)
      const doc = docs.get(`${id}:${locale}`)!
      Object.assign(doc, body, { updatedAt: new Date(Date.now() + 1000).toISOString() })
      return doc
    }
    return {}
  }

  const site = fakeAttract({
    'languages.listSiteLanguages': {
      results: [factories.SiteLanguages({ 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': factories.Page({ aposShareKey: 'share-key' }),
    'blogPosts.shareBlog': factories.Blog({ aposShareKey: 'share-key' }),
    ...answers,
    'request': (args) => {
      const answer = route(args as unknown as Route)
      if (answer instanceof Error) {
        throw answer
      }
      return answer
    },
  })
  return { site, docs, log }
}
```

**3. The wording's tests**

```ts tests/unit/wording.spec.ts theme={"dark"}
import { test } from '@japa/runner'
import { relatedTo, wordingOf, type StoredDoc } from '#services/site_documents'
import { problemsWith } from '#services/translation_tools'

const PAGE: StoredDoc = {
  _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.group('wordingOf', () => {
  test('keeps the words a visitor reads, under the keys PATCH writes them back by', ({
    assert,
  }) => {
    assert.deepEqual(wordingOf(PAGE), [
      { 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.group('relatedTo', () => {
  test('lists what the page shows, never pages or a folder shared by every language', ({
    assert,
  }) => {
    assert.deepEqual(
      relatedTo(PAGE).map((doc) => doc.aposDocId),
      ['photo000001', 'author00001']
    )
  })
})

test.group('problemsWith', () => {
  const original = [
    { key: 'title', text: 'About Acme' },
    { key: '@t.content', text: '<p>We <a href="/contact">answer</a> fast.</p>' },
  ]

  test('passes a translation that keeps keys, tags, links and names', ({ assert }) => {
    const translated = [
      { key: 'title', text: 'À propos de Acme' },
      { key: '@t.content', text: '<p>Nous <a href="/contact">répondons</a> vite.</p>' },
    ]
    assert.deepEqual(problemsWith(original, translated, ['Acme']), [])
  })

  test('names every problem', ({ assert }) => {
    const 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' },
    ]
    assert.deepEqual(problemsWith(original, translated, ['Acme']), [
      '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('names a sentence sent back as it was; a short label, a name or an address may stay', ({
    assert,
  }) => {
    const sentences = [
      { 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>' },
    ]
    const 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>' },
    ]
    assert.deepEqual(problemsWith(sentences, translated, ['Acme Outfitters']), [
      '@t.content: still in the original language. Translate it.',
    ])
  })

  test('names a {placeholder} the site fills in that did not come back as it was', ({ assert }) => {
    const filledIn = [
      { key: 'footerCopyrightText', text: '© {year} {business}. All rights reserved.' },
      { key: 'footerNote', text: 'Since {year}' },
    ]
    const translated = [
      { key: 'footerCopyrightText', text: '{business} © {year}. Tous droits réservés.' },
      { key: 'footerNote', text: 'Depuis {année}' },
    ]
    assert.deepEqual(problemsWith(filledIn, translated, []), [
      'footerNote: keep these exactly as written, the site fills them in: {year}.',
    ])
  })
})
```

**4. The translator's tests**

```ts tests/unit/site_translator.spec.ts theme={"dark"}
import { test } from '@japa/runner'
import app from '@adonisjs/core/services/app'
import { OpenRouter } from '@openrouter/agent'
import type { StoredDoc } from '#services/site_documents'
import { SiteTranslator } from '#services/site_translator'
import { fakeModel, fakeTranslator } from '#tests/helpers/fake_model'
import { fakeSite } from '#tests/helpers/fake_site'

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

const HOME: StoredDoc = {
  _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: StoredDoc = {
  _id: 'photo000001:en:draft',
  aposDocId: 'photo000001',
  type: '@apostrophecms/image',
  slug: 'image-shop',
  title: 'Shop',
  updatedAt: OLD,
}
const FAQ: StoredDoc = {
  _id: 'faqitem0001:en:draft',
  aposDocId: 'faqitem0001',
  type: 'faq-item',
  slug: 'hours',
  title: 'When are you open?',
  updatedAt: OLD,
}

test.group('SiteTranslator', () => {
  test('adds the language, copies what pages show first, writes only drafts', async ({
    assert,
    swap,
    cleanup,
  }) => {
    const { site, docs, log } = fakeSite([HOME, PHOTO, FAQ])
    const model = await fakeModel(fakeTranslator)
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const translator = await app.container.make(SiteTranslator)
    const report = await translator.translate('fr', 'Français')

    assert.isTrue(report.added)
    site.assertCalled(
      'languages.updateSiteLanguages',
      (args) =>
        JSON.stringify(args.languages) ===
        JSON.stringify([{ name: 'Français', code: 'fr', public: false }])
    )
    // The photo is copied into French before the page that shows it is written.
    assert.deepEqual(log, [
      'copy photo000001 to fr',
      'copy homepage001 to fr',
      'copy faqitem0001 to fr',
      'write homepage001 in fr',
      'write faqitem0001 in fr',
    ])
    assert.equal(docs.get('homepage001:fr')?.['@herowidget1.heroHeadline'], '[fr] Welcome to Acme')
    assert.equal(docs.get('faqitem0001:fr')?.title, '[fr] When are you open?')
    assert.deepEqual(
      report.documents.map(({ title, problem }) => [title, problem]),
      [
        ['Home', null],
        ['When are you open?', null],
      ]
    )
    assert.equal(
      report.documents[0].previewUrl,
      'https://acme.example/fr/?aposShareKey=share-key&aposShareId=homepage001%3Afr%3Adraft'
    )
    assert.closeTo(report.costUsd, 0.004, 1e-9) // two documents, two model calls each
    site.assertNotCalled('pages.publishPage')
  })

  test('a translation already up to date is never redone', async ({ assert, swap, cleanup }) => {
    const { docs } = fakeSite([FAQ])
    docs.set('faqitem0001:fr', {
      ...FAQ,
      localizedAt: '2026-02-01T00:00:00.000Z',
      updatedAt: '2026-02-02T00:00:00.000Z',
    })
    const model = await fakeModel(fakeTranslator)
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const translator = await app.container.make(SiteTranslator)
    const report = await translator.translate('fr', 'Français')

    assert.equal(report.upToDate, 1)
    assert.lengthOf(report.documents, 0)
    assert.lengthOf(model.requests, 0)
  })

  test('a copy a stopped run left untranslated is translated by the next run', async ({
    assert,
    swap,
    cleanup,
  }) => {
    const category: StoredDoc = {
      _id: 'category001:en:draft',
      aposDocId: 'category001',
      type: 'faq-category',
      slug: 'visiting',
      title: 'Visiting',
      updatedAt: OLD,
    }
    const faq = {
      ...FAQ,
      _category: [{ _id: 'category001:en:draft', type: 'faq-category', metaType: 'doc' }],
    }
    const { docs, log } = fakeSite([faq, category])
    // The last run translated the FAQ, copied its category, then stopped before writing its words.
    docs.set('faqitem0001:fr', {
      ...faq,
      localizedAt: '2026-02-01T00:00:00.000Z',
      updatedAt: '2026-02-02T00:00:00.000Z',
    })
    docs.set('category001:fr', { ...category, localizedAt: OLD, updatedAt: OLD })
    const model = await fakeModel(fakeTranslator)
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const translator = await app.container.make(SiteTranslator)
    const report = await translator.translate('fr', 'Français')

    assert.deepEqual(log, ['copy category001 to fr', 'write category001 in fr'])
    assert.equal(docs.get('category001:fr')?.title, '[fr] Visiting')
    assert.equal(report.upToDate, 1)
  })

  test('translates the header, footer and cookie banner, their links pointed at the pages in the language', async ({
    assert,
    swap,
    cleanup,
  }) => {
    const about: StoredDoc = {
      _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',
    }
    const settings: StoredDoc = {
      _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' }],
        },
      ],
    }
    const { docs } = fakeSite([about, settings])
    const model = await fakeModel(fakeTranslator)
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const translator = await app.container.make(SiteTranslator)
    const report = await translator.translate('fr', 'Français')

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

  test('a translation that fails its checks is never written', async ({
    assert,
    swap,
    cleanup,
  }) => {
    const { docs } = fakeSite([FAQ])
    const model = await fakeModel([
      { calls: [{ name: 'save_translation', arguments: { texts: [] } }] },
      { text: 'I could not translate this.' },
    ])
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const translator = await app.container.make(SiteTranslator)
    const report = await translator.translate('fr', 'Français')

    assert.equal(
      report.documents[0].problem,
      'Not saved: title: missing. Translate every key you were given.'
    )
    assert.equal(docs.get('faqitem0001:fr')?.title, 'When are you open?') // the copy, untouched
  })

  test('a model call that fails is tried again', async ({ assert, swap, cleanup }) => {
    const { docs } = fakeSite([FAQ])
    let failed = false
    const model = await fakeModel((input, request) => {
      if (!failed) {
        failed = true
        return { fail: 429 }
      }
      return fakeTranslator(input, request)
    })
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const translator = await app.container.make(SiteTranslator)
    const report = await translator.translate('fr', 'Français')

    assert.isNull(report.documents[0].problem)
    assert.equal(docs.get('faqitem0001:fr')?.title, '[fr] When are you open?')
  })

  test('a model that never answers leaves only that document to the next run', async ({
    assert,
    swap,
    cleanup,
  }) => {
    const { docs } = fakeSite([HOME, PHOTO, FAQ])
    const model = await fakeModel((input, request) =>
      input.includes('When are you open?') ? { fail: 429 } : fakeTranslator(input, request)
    )
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const translator = await app.container.make(SiteTranslator)
    const report = await translator.translate('fr', 'Français')
    const problems = new Map(report.documents.map((doc) => [doc.title, doc.problem]))

    assert.match(
      problems.get('When are you open?') ?? '',
      /^Not translated: the model didn't answer \(.+\)\. Run it again to go on\.$/
    )
    assert.isNull(problems.get('Home'))
    assert.equal(docs.get('faqitem0001:fr')?.title, 'When are you open?') // the copy, untouched
  })
})
```

**5. The command's tests**

```ts tests/unit/site_translate.spec.ts theme={"dark"}
import { test } from '@japa/runner'
import ace from '@adonisjs/core/services/ace'
import { OpenRouter } from '@openrouter/agent'
import { fakeAttract, forbidden } from '@fetchski/attract-cms-adonis/testing'
import SiteTranslate from '../../commands/site_translate.js'
import { fakeModel, fakeTranslator } from '#tests/helpers/fake_model'

test.group('site:translate', (group) => {
  group.each.setup(() => {
    ace.ui.switchMode('raw')
    return () => ace.ui.switchMode('normal')
  })

  test('a code that isn’t a language code never reaches the site', async () => {
    const site = fakeAttract()

    const command = await ace.create(SiteTranslate, ['French', 'Français'])
    await command.exec()

    command.assertFailed()
    command.assertLogMatches(/isn't a language code/)
    site.assertNothingCalled()
  })

  test('a scope the key lacks fails with the site’s own message', async ({ swap, cleanup }) => {
    fakeAttract({ 'languages.listSiteLanguages': forbidden() })
    const model = await fakeModel(fakeTranslator)
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const command = await ace.create(SiteTranslate, ['fr', 'Français'])
    await command.exec()

    command.assertFailed()
    command.assertLogMatches(/forbidden|scope/i)
  })
})
```

**Why:** `ace.ui.switchMode('raw')` keeps the command's log lines, so the test can assert them.
Commands live outside `app/`, so the test imports this one by path.

**6. Run them**

```bash Terminal theme={"dark"}
npm run format
node ace test
```

**You should see:**

```text Terminal theme={"dark"}
[ info ] loading hooks...
[ info ] generating indexes...
[ info ] codegen: created 5 file(s)
[ info ] booting application to run tests...
✔✔✔✔✔✔✔✔✔✔✔✔✔✔✔

 PASSED

Tests  15 passed (15)
```

## 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/adonisjs/reference/languages/update-site-languages.md)
>
> The call that adds a language and makes it public

> [card] [Testing in AdonisJS](https://docs.fetchski-attract.com/adonisjs/testing.md)
>
> `fakeAttract()` and the assertions

> [card] [Turn a voice memo into a blog post draft](https://docs.fetchski-attract.com/recipes/adonis-agent-voice-memo-to-blog-post.md)
>
> The same kind of agent, writing a new post


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