Appearance
Languages
Translations of posts, terms and options into the site's languages; the original stays the default language.
kitloom/languages · admins: kitloom/languages-filament, kitloom/languages-nova
Video lessons
Recorded in the Filament admin; the Nova admin works the same way.
Your site in more than one language. Every page, post, product and category is written once, in the site's default language, and edited as usual; a translation into another language is laid over it. A visitor who reads the site in that language sees the translated title and text, and the original wherever nothing is translated yet.
There is one record per page or post, not a copy per language: its address, author, date, categories, featured image and custom fields are shared by every language.
The admin is kitloom/languages-filament or kitloom/languages-nova.
The default language
The default language is the one your records are written in. Whatever you type into a post's or a page's own form — its title, its text, its excerpt — is the default language, and the site shows it at the plain addresses (/about/). Every other language is a translation of it.
Choose it once, before translating: translations belong to the record as it is written, so changing the default language later does not move the texts around — a site written in English with English set as default should stay that way. Once anything is translated the default is locked: Default language on the settings page can no longer be changed (it says why) — nor, while none is named, can reordering the list put another language first — and a language that has translations cannot be removed from the list — the page says how many it has; delete them first. The same holds for the console, languages:convert and an agent, and for deleting the options (SettingWriter::forget()): without them the first language would be the default and the list empty, so that is refused just the same.
Adding a language
Each language has:
| Field | What |
|---|---|
| Slug | a short code — fr, es, pt-br. It starts the language's addresses (/fr/…) and marks its translations, so it is best left alone once there are any. |
| Name | as the language calls itself — Français, Español. Visitors see it in the language switcher. |
| Locale | as WordPress names it — fr_CA, en_CA, pt_BR. The site's own texts and dates follow it, and it is what search engines are told: hreflang="fr-CA" and <html lang="fr-CA">, with the region the locale names (fr alone for a locale without one). |
| Right to left | for Arabic, Hebrew and other languages written that way. |
| Order | where it stands in the switcher. |
A slug that is not a language code, a locale WordPress would not know or a language listed twice is refused.
The languages are kept in two options, so a seeder or the console can set them too:
php
use Kitloom\Languages\LanguagesOptions;
use Kitloom\Settings\SettingWriter;
app(SettingWriter::class)->put(LanguagesOptions::LANGUAGES, [
['slug' => 'en', 'name' => 'English', 'locale' => 'en_CA', 'rtl' => false, 'order' => 0],
['slug' => 'fr', 'name' => 'Français', 'locale' => 'fr_CA', 'rtl' => false, 'order' => 1],
]);
app(SettingWriter::class)->put(LanguagesOptions::DEFAULT, 'en');What gets translated
| What | Translated |
|---|---|
| A post, a page, a product — any type | the title, the text, the excerpt |
| A category, a tag — any taxonomy | the name, the description |
| A user — an author | the biography (Biographical Info), which the author's page shows, and the texts of their custom fields |
| The site's title and tagline | the text |
| Custom fields of a record or of the options pages | the texts: text, textarea, wysiwyg and url fields, a link's text and address — in groups, repeaters and flexible content too; numbers, choices, pictures and relations are the same in every language |
| A theme's or a module's own texts — a button, a heading over a list | the text |
| A contact form (kitloom/forms) | the placeholders, the choices of a list, the acceptance sentence, the buttons, the text around the fields and the messages a visitor gets after sending — on the form's Translations tab; the mail to you stays as you wrote it |
A field left empty is not translated: the original shows there. So a translation can start with the title alone and get its text later.
Menus are the same in every language. An item titled as the page or category it opens — or without a title — shows that record's translation; an item with a title of its own is translated as itself, and keeps the original title until it is.
Published and draft
A translation is published — shown on the site — or a draft: kept and editable, while the site goes on showing the original. A new translation is published unless you say otherwise; save it as a draft while it is not ready.
When there is no translation
The visitor sees the original. A page nobody translated into French appears in French menus and lists with its English title and text, at its French address — nothing goes missing and nothing turns into a "not found". The same goes for a draft, for a field left empty, and for a site title nobody translated.
Who may translate
Translating a record is editing it: whoever may edit a post may translate it, and the same for a term and for a user (everyone may edit themselves). The site's title and tagline and the theme's texts are settings of the site: they take the right to manage options (administrators).
A record deleted for good takes its translations with it; the trash keeps them, and a restored record comes back translated.
Translations in the admin
Languages → Settings lists the site's languages: the slug (in addresses and in the translations table), the name as the language calls itself, the locale as WordPress names it (empty — the slug), and whether it is written right to left. Drag the rows to put them in order. Default language is the one the pages, posts and categories are written in; every other language is added to them as a translation. It can be changed only on a site without translations yet — the records are not rewritten — and is locked after that.
Every page, post, product and category form then has a Translations block with a tab per language: Title, Slug, Content, Excerpt and Status for a post; Name, Slug, Description and Status for a term. A field left empty is not translated — the site shows the original there; a draft is kept but not shown; a tab emptied completely removes its translation (a slug alone does not keep it). The Slug is the record's address in the language — a-propos makes /fr/a-propos/ of /about/; left empty it is made from the title (À propos → a-propos), and one already taken there — by another page under the same parent, of the same type or category in the taxonomy, in the original or in this language — gets a number (a-propos-2). Anyone who may edit the record may translate it.
Each tab starts with Address in Français (the language's name): where the record is in that language — a link that opens it in a new tab and a button that copies it, as Permalink under the slug is the original's. A published translation is at its own slug (/fr/a-propos/); a draft is marked Draft: the page shows the original until it is published and shows the address it will have — its slug under the prefix, or the original's while it has none; a record not translated is marked Not translated yet and shows the original under the prefix (/fr/about/), which is what a visitor finds there. A record without a page — in the trash, a draft without a slug — has no address, and a site in one language no line at all. In Nova the record's page also has View in…: pick a language, and the record opens on the site as a visitor gets it now (a draft's language — the original under the prefix).
A record with custom fields gets them in each tab too: every text of its fields — a repeater's rows each — drawn as the field is on the record form, the saved text as the placeholder. Left empty, the field shows the original.
In Filament the user's form and Users → Profile get a Translations block too, once the user is saved: the biography and the texts of the user's custom fields, a tab per language. Nova's user form has no blocks of modules; there a user's translation is written by an AI agent or from the console (Translations::save('user', $id, 'fr', ['content' => …], $actor)).
The lists of posts and terms get a Translations column: into how many languages the record is translated and shown (2/3); hover it for each language's state.
Translate with AI
With AI Writer (kitloom/ai-writer) set up — the owner's provider, key and monthly budget — the site's own AI model can write the translations for a person to read. Without it, or once the month's budget is spent, none of the buttons below is there.
- A record's tab: Translate with AI at the top of each language's tab (Filament); in Nova, the box Translate with AI when saved in the language's part of the form — Nova draws no buttons inside a form, so the record is saved first and the saved original is translated right after.
- The lists: select posts, pages, products or categories, then Translate with AI into… and the language (the bulk menu in Filament, the actions in Nova). They are translated in the background, one after another; you are told when it is done — "3 translated, 1 failed" and why — in Nova's notifications, and in Filament on the next page you open (or the bell, when the panel keeps database notifications).
- Site Texts: Fill empty with AI (Filament: at the top of the page; Nova: a box per language, acting when the page is saved) sends every text with nothing in the language — the site's title, menu items, theme texts, attribute names, the options fields — to the model in the background. Publish drafts then shows them on the site, once you have read them; a draft says so beside its field.
What comes back is always a draft: the site goes on showing the original until someone sets the translation to Published and saves. A draft in the language is written anew from the original, its slug made from the new title. A published translation is not rewritten: the button says Translate empty fields and fills only those — they show at once, as the rest of that translation does — and its address stays.
What the model gets: the title, the content, the excerpt (a term's name and description, a user's biography) and the texts of the custom fields — not their addresses (url fields, a link's address). It is told to keep the HTML, the editor's block comments, shortcodes, addresses, numbers and the names of brands and people as they are, and to add nothing. Languages → Settings → Instructions for the AI model adds the owner's own words to every question: a glossary, the tone ("Address the reader as vous"), names to keep. A Canadian French language (fr_CA) is written as in Quebec. A long page goes in pieces of at most 8,000 characters, cut between the editor's top-level blocks (or after paragraphs, headings, lists and tables in classic content) and put together again exactly; a single block bigger than that is cut between the blocks inside it, never inside a paragraph. If the model fails on any piece, nothing of that record is written; in the background a record the provider failed on is asked again twice, a few minutes apart. Every question is a row of AI Writer → Usage (languages.translate), with who asked.
No key on the site? An AI agent connected through kitloom/ai translates just as well, paid on its side: it reads the record with languages_get_translations, translates, and saves a draft with languages_save_translation (status: draft). With AI Writer set up it can also ask the site's model with languages_translate.
The module's screens, the Translations block and the module's refusals speak French (Canadian) to an owner whose admin is in French — Langues, Langue par défaut, Traductions, Textes du site — from lang/fr.json of kitloom/languages, kitloom/languages-filament and kitloom/languages-nova. languages:convert and languages:demo speak to the console and stay English.
Addresses and the switcher
A page has one address in every language: the default language's at the plain path (/about/), the others under the language's slug (/fr/about/, /fr/category/news/, /fr/page/2/) — with the record's slug in that language where its published translation has one (/fr/a-propos/, /fr/a-propos/equipe/, /fr/category/nouvelles/), as Polylang and WPML give each language its own. The original's slug under the prefix (/fr/about/) sends the visitor to the language's own address; a language's slug is no address without its prefix (/a-propos/ is not found), nor under another language's. A draft translation gives no address. The prefix picks the language the page is shown in; the default language's own prefix (/en/about/ on an English site) sends the visitor to the plain address. Every link the site writes — in lists, menus, breadcrumbs, pagination, the feed of comments — carries the language of the page it is on, and an old address under a prefix (a changed slug) is redirected within that language. A post's slug changed in a language is kept the way WordPress keeps a changed slug (_wp_old_slug): /fr/tenir-un-journal-de-lecture/ sends the visitor to /fr/journal-de-lecture/ once that is the French slug, for posts of every type but pages, as WordPress does; only a published translation of a published post had an address to keep. A slug the post gets back leaves the list, and an old slug another post takes in that language is that post's address. A link in a text, a block or a menu to another language's address (/fr/a-propos/ on an English page) is the record it names there — kitloom/internal-links counts it for that record.
The theme's templates stay the original's in every language: page-about draws /about/ and /fr/a-propos/ alike, and slug in the page's JSON is the stored one — the theme is one for all languages, and the page's address in the language is its link.
The page's <head> tells search engines where the page is in each language (hreflang, named by the locale: en-CA, fr-CA): the default language, every language with a published translation of this record, and x-default for the original; lists and archives count as present in every language. A page shown in a language it is not translated into points its canonical address to the original, so no address is announced twice. kitloom/seo's sitemap says the same: each record with the same links, and its translations as entries of their own, each dated by when the record or its translation last changed, whichever was later; a translation saved or a language added builds the cached sitemap and pages again.
The language switcher is a page part (languages: the current language, the default one and each language with its name, its address for this page and whether the page is translated into it), a widget (Language switcher) and a shortcode ([language_switcher]). A theme draws it where it wants; a theme that lists its page parts adds languages to them. Hiding the languages a page is not translated into is the theme's choice: every entry says translated. Of the themes here, Pantry draws it in its header (the others not yet), with the untranslated languages dimmed.
The site's search under a prefix (/fr/?s=journal) finds a post by its published translation into that language as well as by its original, and lists it translated; a draft is not searched. Results come in the same order as without languages, matches in the original title first. With kitloom/search's index the search page reads the index's rows of that language instead: each post as it is shown in the language, its translation where it has one — so under a prefix a translated post is found by its translation, not by the original words the translation replaces. A translation saved or removed counts as its post (or term) saved, so the index follows; a language added or removed is caught up by the index itself, on the queue, the first time someone searches (see kitloom/search's README).
The site's scripts call the REST root (/wp-json/ — the cart, a booking, a contact form) from a page in French with ?lang=fr on the address the page handed them, and the answer is French too: the cart's messages, the products' names, the links (see kitloom/site's README, "REST root"). The site's own forms — signing in, up and out, a lost password, the account details, the second step of signing in — work the same way: the account page in French posts to /login?lang=fr, a wrong password is told with this module's lang/fr/auth.php, and signing in goes on to /fr/ (see kitloom/site's README, "Signing in"). Signing in with Google or GitHub from a French page comes back to /fr/ as well, and a booking made in French opens its emailed links in French (kitloom/social-accounts, kitloom/bookings).
To show the site in a language without a request (the console, an agent): app(Site::class)->resolve('/fr/about/') or ->render('/fr/about/') does. The first address read gives the site its language, as a visitor's request does, so the result's records read French after it; a later call for an address of another language (/de/about/) finds and draws the page in German and leaves the site in French.
The site's title and tagline
The site's title (blogname) and tagline (blogdescription) are translated like a record — a translation each, per language — and every page shown in that language reads them translated: the header, the page's <title>. The options themselves keep the default language; the admin's General settings always show and save that.
The date and time formats of Settings → General (date_format, time_format) are translated the same way: French writes j F Y — "15 octobre 2026" — where English writes F j, Y. The names of the months and days come from the language's locale, whatever the format.
They are translated on Languages → Site Texts, the one place for everything the site shows that is no page, post or category. Each original has a field for every language but the default; a filled field is published at once, an emptied one removes its translation, and the site shows the original there. Three tabs, and a fourth on a site with a shop:
- Site — the site's title and tagline, the date and time formats, and any other option the configuration names.
- Menus — every menu with its items, indented under their parents. An item titled as the page or category it opens (or with no title) says which one it follows and has no fields: it shows that record's translation. An item with a title of its own is translated here.
- Theme Texts — the texts the theme and the modules declare (see below), grouped by who declared them.
- Options Fields — on a site whose field groups are placed on an options page (
Fields::option()), the texts of those fields. - Shop Attributes — with kitloom/shop, the names of the products' attributes ("Weight" → "Poids"), shown on a product page and in the catalog's filters. Their values ("250 g") are terms: they are translated with the categories and tags, on their own screens.
The screen needs the right to manage the site's options (manage_options); a menu item also needs the right to edit menus (edit_theme_options). Until the site has a language besides the default one, the screen only points to Languages → Settings.
Other options are left as they are. A developer can add texts the site shows to kitloom-languages.translated_options in the config (a footer line kept in an option, say); never lists or switches.
A theme's own texts
Two kinds of words, two places. The labels of a theme's interface — "Sign in", "Add to cart", "Search the shop…" — are the theme developer's: they ship in the theme's lang/<language>.json, keyed by the English text as Laravel's JSON translations are, and follow the locale the page's language sets (fr_CA reads fr_CA.json, else fr.json — a locale with a region falls back to the language's file). With the module, Laravel's own __() does the same for every package and the application: on a page in fr_CA a line of fr.json (or lang/fr/*.php) is found, fr_CA.json winning where it has one; without it Laravel reads the locale's own file and then the fallback locale only. A Blade theme draws them with __(); a Vue theme gets the file of the page's language as a prop (see Pantry's ThemeTranslations and resources/js/i18n.js). The site's owner does not edit them; the application's own lang/<locale>.json can still reword one. Strings is for words the owner writes and translates in the admin — a heading a theme lets them set, a module's notice.
Laravel's own lines come with the module in French (Canadian): the validation messages ("Le champ :attribute est obligatoire."), a refused sign-in, the password reset broker's answers and the pagination links — lang/fr/validation.php, auth.php, passwords.php, pagination.php. The validation file names the fields the site's and the modules' forms send (attributes: email is "courriel", postcode "code postal", state "province"), so a message reads "Le champ courriel est obligatoire."; a form that names its fields itself, as the shop's checkout does, keeps its names. They lie under everything else: an application's own lang/fr/validation.php wins line by line, and a fr_CA file of its own over that.
A theme or a module declares the texts it draws, under its own name (the context), and draws them through Strings:
php
use Kitloom\Languages\Facades\Strings;
// In the theme's or the module's boot(): the admin lists them for translation.
Strings::register('bookshelf', 'Read more');
// Where it is drawn: the translation in the visitor's language, the text itself otherwise.
Strings::translate('Read more', 'bookshelf');A text is known by its context and its words as written in the default language: change the words, and it is a new text to translate. Each translation is a row of the translations table — type string, the key Strings::key($context, $text) (bookshelf:Read more, or a hash for a text too long for the key), the translated text in title (up to 255 characters; a longer one in content), and for a hashed key the context and the original in fields (context, original), so an admin can show what it translates. The texts of a language are read in one query, the first time a page draws one.
Widget titles are not translated yet: a widget's title is a setting of that widget, and the site draws it without asking for a translation.
Converting from Polylang or WPML
Polylang and WPML keep a separate post (and a separate category, tag, …) for every language and tie them into groups. This module keeps one record, written in the default language, and lays the other languages over it. languages:convert turns the first into the second.
Make a copy of the database first — it is the only complete way back. Then see what would happen:
bash
php artisan languages:convert --dry-runand run it:
bash
php artisan languages:convert # the plugin found in the database
php artisan languages:convert --from=wpml # when both left data behind
php artisan languages:convert --default=en # the language the records are written in, if not the plugin's defaultWhat it does:
- Languages. The plugin's languages (name, locale, right-to-left, order) are added to Settings → Languages; the plugin's default language becomes the default one. Languages the site already has stay as they are.
- Posts. In every group the post in the default language stays. The others become its translations — title, content, excerpt and slug; a published post gives a published translation, anything else a draft one. If a group has no post in the default language, its oldest post stays and the command tells you which.
- Custom fields. The texts of a translation's fields — text, textarea, wysiwyg and url fields, a link's text and address, in groups and rows too — that differ from the original's become the translation's field texts. The fields are known by the field groups in the database; values of fields kept only in a theme's JSON or PHP are listed and not carried over.
- Comments of the translations move to the original, and stay marked with the post they came from. Attachments uploaded to a translation, and child pages that were in no group, move to the original too.
- Categories, tags and other terms are merged the same way: the name becomes the translation's title, the description its content. Posts filed under a translated term are filed under the original.
- Strings translated in Polylang's or WPML's string translation become the module's string translations; the site title and tagline become translations of those options.
- Polylang's own terms (languages and groups) are removed at the end and the term counts written anew. Its
polylangoption stays. WPML's tables are left as they are.
What is not carried over:
- The translations' own dates, authors, featured images, meta and the custom fields that are not text (numbers, choices, pictures, the rows of a repeater) — the same in every language here; they stay on the translated post in the trash, and the command names each field whose value differed from the original's.
- Menus: each language had its own; they are left as they are, set up the menus anew.
- Translated attachments (Polylang's media translation): their copies share one file with the original, so they are not merged.
- Posts in a language switched off in WPML, and posts that were already in the trash.
The translated posts and terms are in the trash, not deleted, each marked with _kitloom_languages_merged_into (the original) and _kitloom_languages_lang (its language). Check the site in every language, then empty the trash. A post in another language that was a translation of nothing stays as a record of the default language, marked with _kitloom_languages_lang; the command lists them, and you decide whether to translate, move or delete them.
Running the command again changes nothing that is already converted. To undo a conversion, restore the database copy. Without one: restore the posts and terms from the trash, delete their translations from the originals, and move back the comments marked _kitloom_languages_moved_from — the plugin's groups are gone with its terms, so it cannot be done in one step.
Try it: the demo
bash
php artisan languages:demoOn an English site, it sets the site up as a Canadian one: English (en_CA) as the default language and French (fr_CA, under /fr/) beside it — languages the site has already are left as they are. It adds a category, three posts and two pages of a small neighbourhood library, translated into French:
- the category, one post and the "About the library" page, published;
- one post as a draft — the site shows the English text until it is published;
- one post and the "Opening hours" page not at all, to show the original in their place;
- the site's title and tagline, and the date format (
j F Yin French), unless they are translated already. The names and taglines kitloom's own demos give a site are translated (Pantry's "Green Pantry" is "Garde-manger vert" in French); any other title gets the language's name after it ("Bookshelf · Français") — a name cannot be translated without knowing what it means — and any other tagline is left to you.
The demo writes its own records, never made-up translations of yours. Run it again and it finds them: nothing is added twice. On a site whose default language is not English it stops without writing anything.
A shop to try it on: php artisan pantry:demo (kitloom/pantry-theme) sets the same two languages on a site that has none and makes its whole demo store bilingual — products, categories, pages, the storefront, French addresses (/fr/boutique/) and a French customer with an order. See the Pantry theme's README.
For developers
A module's texts
A module that keeps texts of its records where this module does not look — a contact form's placeholders and messages in its meta — offers them with Kitloom\WpSchema\Contracts\TranslatableTexts, tagged TranslatableTexts::TAG: the post type, the record's texts by key, a label for each, and whether the record's title and content are no texts of their own. They are shown on the record's Translations tab with its custom field texts, translated with AI with them, kept in the translation's fields under @ and the key (@form.your-name.placeholder), and carried over from Polylang or WPML. The module reads them back through Kitloom\WpSchema\Contracts\TextTranslator::translate($post, $key, $original), which this module binds; without it every text is the original. Neither package requires the other.
How the site shows translations
The language a request shows the site in is Kitloom\Languages\CurrentLanguage (set(), get(), isDefault(), translating()). While it is a language other than the default, $post->title, $post->content, $post->excerpt and $term->name read the published translation, and so does Settings::get() for the translated options. The columns — post_title, post_content, the term's own name in terms — are always the record as stored, and so is everything while no language is set: the admin panels, the console and the queue see the originals.
The translations of a list are read in one query: the posts a page retrieves are noted, and the first title read loads them all. For records read some other way, Translations::preload('post', $ids) does the same.
php
use Kitloom\Languages\Facades\Languages;
use Kitloom\Languages\Translations;
Languages::all(); // slug => Language, in their order
Languages::default(); // the language the records are written in
Languages::find('fr')?->w3c(); // 'fr-CA' for fr_CA — for lang and hreflang
app(Translations::class)->save('post', $post->ID, 'fr', [
'title' => 'Bonjour',
'content' => '<p>Bonjour tout le monde!</p>',
'status' => 'published',
], auth()->user());
app(Translations::class)->save('option', 'blogname', 'fr', ['title' => 'Le Rayon des livres'], $admin);A record's address in a language, for an admin or a tool: app(Site\TranslationAddresses::class)->of($post, Languages::find('fr')) — its url and state (published, draft, missing), and note() for the words the admins show; built inside Localization::showingIn() the language.
Saving announces Kitloom\Languages\Events\TranslationSaved, removing one TranslationDeleted, each with the user. The default language cannot be translated — it is the record itself.
Custom fields are translated through kitloom/custom-fields' FieldTranslator: the module binds Fields\TranslatedFields, which lays the texts of a published translation's fields — keyed by path, subtitle, benefits.0.title — over what $post->field(), $term->field(), $user->field() and Fields::option() read; an option's are in the translation of type option, key options. A user's biography is $user->description (kitloom/wp-schema reads it from the meta, as WordPress's $user->description does); the module lays the content of the user's translation over it. The translations come with the record's others, in one query for a list. Fields::all(), the forms and every write keep the saved values.
php
app(Translations::class)->save('post', $page->ID, 'fr', ['fields' => [
'banner_button_label' => 'Magasiner',
'benefits.0.title' => 'Bon pour vous',
]]);The site's options are translated through kitloom/settings' SettingTranslator: the module binds Options\TranslatedSettings, which answers Settings::get() for the listed options. The settings snapshot, its cache, Settings::array() and every write keep the saved value.
A shop's attribute labels are laid over the shop's model by its registry key (wc_attribute_taxonomy, Content\TranslatedAttributes): attribute_label reads the published translation of type attribute (the key is the attribute's ID, which stays when its slug is changed) while a language other than the default is shown. The module does not require kitloom/shop; without it there is nothing to lay over.
Where things are kept
| What | Where |
|---|---|
| Translations | table content_translations (the site's table prefix in front), a row per record and language: object_type (post, term, user, option, string, attribute), object_key (the post ID, the term's term_taxonomy_id, the user ID, the option's name, Strings::key(), a shop attribute's attribute_id; compared by case — utf8mb4_bin on MySQL and MariaDB — so «Read more» and «Read More» are two texts), lang, title, content, excerpt, slug (the record's address in the language; indexed with the type and the language), status, and fields (JSON: custom field texts by path; a theme text's context and original); model Kitloom\Languages\Models\Translation, key translation in the model registry |
| A post's old slugs in a language | the post's meta _translation_old_slug_<language> (_translation_old_slug_fr), a row per old slug as _wp_old_slug has one (Content\OldSlugs): deleted with the post, kept when its translation is removed — the old address then leads to the original's under the prefix |
| Languages | the options kitloom_languages (JSON) and kitloom_languages_default |
| Instructions for the AI model | the option kitloom_languages_ai_instructions |
AI agents
With kitloom/ai installed, an agent lists the languages (languages_list), reads a post, a term or a user with its translations, the texts of its custom fields by path and the languages it still lacks (languages_get_translations), and writes a translation — its slug and its custom field texts too (languages_save_translation, fields by path; only the paths sent change) — as its user — who needs to be allowed to edit that record. languages_translate has the site's own model (AI Writer) translate a record into a draft; without one it says so. describe_site tells the agent the languages, what a translation replaces and whether a model is there (machine_translation). kitloom-languages.ai.register false — no language tools.
Machine translation
Ai\MachineTranslation::translate($type, $key, $lang, $actor) asks Kitloom\WpSchema\Contracts\TextGenerator — never AI Writer by name — with the fields the original has as a schema, and writes through Translations::save() as the actor, whom it asks first (Translations::authorize()), so a refused question costs nothing. It throws TextGenerationFailed with words to show; nothing is written then. Ai\TranslateRecords is the queued job (site_texts with <type>:<key> keys for Site Texts); when it is done it dispatches Events\MachineTranslationFinished, which the admins turn into notifications.
Not yet
- Meta outside custom fields, and custom field types a module adds: the same in every language.
- Media per language: an image's alt text and caption are the same in every language.
- Widget titles and texts.
Configuration
config/kitloom-languages.php (php artisan vendor:publish --tag=kitloom-languages-config): the admin sections, the AI tools and the language switcher on or off, the options to translate (translated_options).
Tests
From the application that installs the package: php vendor/bin/phpunit --testsuite Languages. The test client starts each request after the first with the scoped instances forgotten, as Octane does (kitloom/site's Testing\RequestScope), so a test sends / and then /fr/ without resetting the language by hand.