Languages
Arc CMS has two translation systems: one for the public site, where visitors read content in the language of the URL, and one for the admin interface, where each person picks their own language. They are independent, so a site that publishes in Hindi can be run in English.
What it is
| Public site | Admin interface | |
|---|---|---|
| Who reads it | Visitors | Admins, and signed-in members unless the app gives them languages of their own |
| What it translates | Content, content type names, and the words in templates and partials | Buttons, labels, menus, messages |
| Language list | Admin, Settings, Localization (Settings/localization) | ADMIN_LANGUAGES in src/app/core/i18n/admin-languages.ts |
| Chosen by | The URL | The language picker in the page header (in the admin area only, when the app declares member languages) |
| Mechanism | Translation documents, plus data-arc-t and the site's strings files (/_site/strings/{lang}.json) | Transloco, with src/assets/i18n/{lang}.json |
Arc CMS ships English and Hindi for both. An app can give its members, and people on the sign-in page, a language Arc CMS does not ship, such as German, while the admin keeps English and Hindi: see Member languages.
Emails are English only, and so are the unsubscribe and email preferences pages. Nothing in the email engine reads a language.
URLs
The default language keeps unprefixed URLs. Every other language gets its code as a prefix, with the same slug:
/articles/my-post default language
/hi/articles/my-post Hindi
The URL alone decides the language. A prefix only works for an enabled language that is not the default, so /en/articles does not exist on an English site.
How content is translated
A translation is a document beside the entry, holding only the fields that differ:
arc_articles/{id} the default language
arc_articles/{id}/translations/hi Hindi: only what is translated
A page in Hindi is the default entry with the Hindi fields laid over it, so anything untranslated shows the default text and a half-translated page still works. Title, body, summary, SEO title, meta description, text and rich text fields, and the words inside repeating fields (such as info cards and gallery captions) can be translated. The URL, cover image, tags, dates, numbers, images and dropdown values are shared by every language. Untranslated entries still appear in list pages, with their default-language card.
Turn it on or off
Languages are always on and are not a switchable feature. A site with one language shows no language switcher and no prefixes. Content translation lives in the content editor, so it goes with Content.
Set it up
Add a public language
For example French (fr):
Enable it. Admin, Settings, Localization, Add a language, then Save Languages. Pick it from the list, or choose Other and enter a code such as
sworpt-br. A code that a page already has as its address, such aspaywhen the app has/pay, is refused. The editor offers a French tab straight away. The same page sets the default language and the order of the switcher.Translate the words in templates. Create
src/custom/site/strings/fr.json, usingpublic/_site/strings/hi.jsonas the list of keys. A partial file is fine: a missing key keeps the English.Translate content type names. Content, Content types, edit each type and use its French tab: name, singular name, description and custom field labels.
Translate the content. Open each entry, pick the French tab, fill in what matters and publish. Save and Publish apply to every language at once.
Deploy the website. Run
npm run deployand choose Website or Everything, sofr.jsonis on the live site. The deploy then publishes every page again, and each content page, list page and the home page gains its French version. Publishing an item later republishes that item's pages; Republish website (Content, Content types) rebuilds them all.
Optional, a translated home page: write src/custom/site/home.fr.html as a whole page in French; without it, home.html is published in French through its data-arc-t keys. Either way the home page exists in every enabled language, at /fr. See Build your home page.
Removing a language stops new pages being published for it. Pages already on Hosting stay until the content is republished or unpublished.
Add an admin language
Create
src/assets/i18n/fr.json: copysrc/assets/i18n/en.jsonwithout_conventionsand translate the values. It must have every key.Add
{ code: 'fr', label: 'Français' }toADMIN_LANGUAGESinsrc/app/core/i18n/admin-languages.ts.Add the import to
TRANSLATIONSinsrc/app/core/i18n/translation.loader.ts, written out in full (a bundler only follows an import it can see), and toCUSTOM_TRANSLATIONSwhensrc/custom/i18n/fr.jsonexists.Register the locale in
src/app/core/i18n/admin-locale.provider.tsso dates and numbers format correctly.Add the file to the list in
src/test/i18n-parity.spec.ts, then run the tests.
In an app built on a copy of Arc CMS, the files for a new admin language are core files (see Core and custom): a new admin language belongs in Arc CMS itself. An app's own admin words go in src/custom/i18n/ (below). A public language is the app's own: its strings file and translated home page go in src/custom/site/, as in the steps above.
Use it
Words in public templates
For short text in templates, the header and the footer, mark the element and leave the English inside it. The English is the fallback.
<span data-arc-t="read_more">Read Article</span>
<input data-arc-t-attr="placeholder:search_placeholder,title:search_title">
<span data-arc-t="min_read">{{ readTime }} min read</span>
<span data-arc-t="seats_left" data-arc-t-params='{"count": 5}'>5 seats left</span>
Then add each key to the language's strings file, src/custom/site/strings/{lang}.json, a flat JSON object of "key": "text" pairs. Arc CMS ships one for Hindi (public/_site/strings/hi.json); an app's file for a language Arc CMS ships is merged over it key by key, so it holds only the keys the app adds or rewords, and keeps receiving Arc CMS's new keys. For any other language the app's file is the whole list. A file for the default language works the same way on every page in that language, static pages included, and in the app; Arc CMS ships none, so the wording written in the HTML stands until a site adds one. An empty value counts as untranslated. Values may use {{ }} template values ("min_read": "{{ readTime }} मिनट का पठन"), because strings are applied before the template is filled, or values from data-arc-t-params ("seats_left": "{{ count }} सीटें बाकी"). data-arc-t replaces everything inside the element, so keep icons outside it. The same three attributes work on published pages and in the Angular app. Published pages bake the strings in at publish time, and the functions cache the strings files for five minutes. The website walkthrough is Write a strings file.
A few keys are read by publishing as well as by data-arc-t: min_read becomes {{ readingTime }} ("5 min read"), latest_of_type the default heading of a card block, the signup_* keys the signup panels, the contact_* keys the contact form's messages, content_not_found_*, type_not_found_* and go_home what a content or list page says when there is nothing to show, and legal_* and contact_notice the notices under the forms (see public/_site/strings/hi.json for the full list). A <time data-arc-bind> date is written in the page's language.
The language switcher
The site header's <arc-language-switcher> becomes the switcher: markup on published pages, a component in the app. It offers only the languages the current page exists in, never every enabled language, so it never links to a missing page. A page in the app declares its languages with LocalizationService.languageVariants.set([...]) and clears it with set(null) when it leaves; a page that sets nothing shows no switcher. The switcher stores the visitor's choice in localStorage as arc-lang, but nothing reads it: a visitor landing on / gets the default language.
Links keep the visitor's language
The header and footer are one file for every language, so their links are written root-relative (/articles) and rewritten with the page's prefix: by prefixAnchorHrefs in functions/src/shared/language-links.ts on published pages, and by renderSiteFragment in src/app/pages/page.parts/site-fragment.ts in the app, which also applies the strings (src/app/core/i18n/apply-strings-dom.ts). A fragment can follow a language of its own instead of the page's (its language option, with strings from UiStringsService.load()): the sign-in panel does, in the language a member chose (see Member languages). Content templates build prefixed links with {{ langPrefix }} and {{ url }}.
Only addresses that exist in every language take the prefix (isLocalizedPath): the home page, search, and the list and item pages of a content type with public pages. Sign-in, the admin, the member area, an app's own pages, static pages and files exist once, so /signup stays /signup on a Hindi page. The publish functions read the public content types with publicContentTypeSlugs (functions/src/shared/public-content-types.ts), the app with PublicContentTypesService (src/app/core/site/public-content-types.ts); until the app has the list, its header leaves links unprefixed.
In the app, /{lang}/{type} matches only a public content type (publicContentTypeGuard), and any other address under a language prefix redirects to the same address without it (languageRedirect in src/app/guards/language.guard.ts), so a stray /hi/signup lands on /signup.
The language routes come before the app's own routes, so every address passes them, and they never make a page wait for the server when they need not (languageRouteGuard):
- A first segment that cannot be a language code, such as
signuporadmin, or that a route with a literal path already uses, such as an app's/pay, is passed on at once. A language code is 2 or 3 letters with an optional region, such asdeorpt-br. - Any other first segment is checked against the list this browser kept from its last read of
Settings/localization, and the list is read again in the background. If the fresh list changes the answer for the page on screen, the page opens again. - Only a first visit, with no list kept, waits for the read, and only for an address such as
/dethat could be a language and that no route uses. A single-language site, once its list is kept, never waits.
So the language routes can only claim a first segment that is a language code, enabled, not the default, and not used by any route. The Localization settings page refuses a code that a route uses, and npm run dev and npm run build warn when an app page's address is a language code in the catalogue (scripts/vite-language-routes.ts).
A translated page links back to English. The directive only covers the header and footer. A new component that links to a public page must add the prefix itself, taking the language from the route's :lang or from UiStringsService.activeLang(), with withLangPrefix(href, prefix, contentTypes) so a page that exists once keeps its address.
What search engines see
Each published page sets <html lang> (and dir="rtl" for a right-to-left language) and, when it exists in more than one language, one <link rel="alternate" hreflang> per language plus x-default for the default. The sitemap lists the same alternates. See SEO and discoverability. Search indexes each language separately and searches the language being viewed; see Search.
Admin interface text
Each person picks a language in the page header. It is saved on their user record and in the browser, so it follows them between machines and shows without a flash of English. Dates and numbers follow the new language from the next page load.
Keys live in src/assets/i18n/en.json first: common.* for words used everywhere (Save, Cancel, table chrome), admin.nav.* for the menu, admin.{area}.* per admin page, user.* for the member area. Name keys by role, not by text: common.actions.save. After editing en.json, regenerate the typed key list:
npm run i18n:keys
This writes src/app/core/i18n/translation-keys.ts. src/test/i18n-parity.spec.ts fails when you forget, when a translation misses a key or has one English does not, or when ADMIN_LANGUAGES offers a language with no file. At run time a missing key falls back to English.
| Where | How |
|---|---|
| Template | {{ 'admin.users.page_title' | transloco }}, with TranslocoPipe in the component's imports |
| TypeScript | this.t('key', params) in a BaseComponent, or private t = injectT() from src/app/core/i18n/inject-t.ts. Both check the key at compile time. |
| Toast | this.notify.success('key') from src/shared/services/notify.service.ts. A toast shows its message as plain text, so a file name or a server's words cannot inject HTML; keep markup out of toast keys. |
| Table column or action | Pass the key as header or label; the table translates it |
| A field that holds a key or plain text | The translatable pipe, src/app/core/i18n/translatable.pipe.ts |
Keep a sentence in one key, with {{ params }} and any markup inside the value (rendered with [innerHTML]), so a translator can reorder it.
Table headings show as ADMIN.CONTENTS.LIST.COL_TITLE. Column definitions run before the translation file loads, so translate() there returns the key. Pass the key itself as header.
Extend it
An app adds its own admin words, and rewords core ones, in src/custom/i18n/en.json and src/custom/i18n/hi.json. Each is laid over the core file key by key when the language loads:
{ "custom": { "nav": { "lessons": "Lessons" } }, "admin": { "nav": { "users": "Parents" } } }
Put the words an admin sees in both files. A test checks the ones it can tell are admin words, the labels of your admin menu items and admin menu changes and the core admin words you reword, and that hi.json has no key en.json lacks. Words only members see need Hindi only when Hindi is offered to members (Member languages); a missing one shows in English. Give a custom menu item a labelKey from these files (see Change the admin menu). Custom keys are not in the generated key list, so use them through the transloco pipe or TranslocoService.translate().
For a website, new public words are new data-arc-t keys in your templates and strings files (above).
What it stores
| Where | What |
|---|---|
Settings/localization | defaultLanguage and enabledLanguages (code, label, native label, right-to-left), default first. |
arc_{slug}_drafts/{id}/translations/{lang} | What the editor saves per language. |
arc_{slug}/{id}/translations/{lang} | The published translations, copied from the drafts on publish. |
ContentTypes | nameTranslations (name, singular name, description) and fieldLabelTranslations per language. |
users | preferredLanguage: the person's admin language. |
| Browser storage | arc-admin-lang (admin language), arc-lang (the switcher's last choice) and arc-site-languages (the copy of Settings/localization last read, so the language routes need not wait). |
| Firebase Hosting | A page per language at /{lang}/{type}/{slug}, written by the publish queue. |
There are no language Cloud Functions or Storage paths: the publish queue builds one page per language the entry has.
Who can do what
| Action | Who |
|---|---|
Read Settings/localization | Anyone: the switcher needs it before sign-in. |
| Enable languages, change the default | Admins. |
| Read and write draft translations | Admins (and the reserved editor role). |
| Read published translations | Anyone. Only the publish queue writes them. |
| Choose an admin language | Each signed-in person, for themselves. |
See Users and roles.
Troubleshooting
A published page is still in English. Pages are built when they are published, from the strings files on the live site. The guided npm run deploy (Website or Everything) publishes every page again after the website deploy. The plain npm run deploy -- --only hosting does not: run npm run seed:dev (or seed:prod) after it, or use Republish website.
A missing page returns HTTP 200. The app's fallback page answers every path, so check a page by its text, not its status code.
| Symptom | Fix |
|---|---|
| The switcher is missing | The page exists in one language only, or it is an app page that does not set languageVariants. |
/fr/... shows not found | French is not enabled, or it is the default language (the default has no prefix), or a page has the address /fr, which no language can take over. |
| A language just enabled shows not found for a moment, then appears | The browser answered from the list it kept, then read the new one and opened the page again. Nothing to fix. |
| A template word stays English | The key is missing or empty in that language's strings file, or the page was published before the file was deployed. |
An admin key shows as ADMIN.FOO.BAR | A typo in a template key. Use this.t() or injectT() in TypeScript to catch it at compile time. |
The tests fail after editing en.json | Run npm run i18n:keys and add the new keys to every translation. |
For a website walkthrough, see Publish in more than one language.