[{"data":1,"prerenderedAt":1707},["ShallowReactive",2],{"search-en":3,"content-en-developer\u002Fui-localization":4,"surround-en-\u002Fdeveloper\u002Fui-localization":1698},[],{"id":5,"title":6,"body":7,"description":1691,"extension":1692,"meta":1693,"navigation":837,"path":1694,"seo":1695,"stem":1696,"__hash__":1697},"content_en\u002F3.developer\u002F23.ui-localization.md","UI localization — adding a language",{"type":8,"value":9,"toc":1673},"minimark",[10,14,18,85,102,106,113,123,126,132,139,167,172,197,229,233,257,260,271,291,297,303,314,384,405,409,416,472,486,490,511,515,748,752,763,767,776,872,888,914,993,999,1024,1038,1089,1103,1117,1123,1174,1200,1315,1336,1358,1409,1413,1419,1476,1482,1494,1501,1509,1542,1549,1571,1602,1633,1669],[11,12,6],"h1",{"id":13},"ui-localization-adding-a-language",[15,16,17],"p",{},"Owlat has two independent translation systems. Do not confuse them:",[19,20,21,37],"table",{},[22,23,24],"thead",{},[25,26,27,31,34],"tr",{},[28,29,30],"th",{},"System",[28,32,33],{},"What it translates",[28,35,36],{},"Where it lives",[38,39,40,64],"tbody",{},[25,41,42,49,56],{},[43,44,45],"td",{},[46,47,48],"strong",{},"Email content translations",[43,50,51,52],{},"The campaigns, templates and blocks ",[53,54,55],"em",{},"you send",[43,57,58,59],{},"Per-workspace, stored in Convex — see ",[60,61,63],"a",{"href":62},"\u002Fguide\u002Ftranslations","Translations in the product guide",[25,65,66,72,78],{},[43,67,68,71],{},[46,69,70],{},"UI localization"," (this page)",[43,73,74,75],{},"The Owlat interface ",[53,76,77],{},"your team and your recipients read",[43,79,80,81],{},"One catalog per app plus one for the shared UI layer, shipped with the code — see ",[60,82,84],{"href":83},"#where-things-live","Where things live",[15,86,87,88,97,98,101],{},"The UI layer is ",[60,89,93],{"href":90,"rel":91},"https:\u002F\u002Fi18n.nuxtjs.org",[92],"nofollow",[94,95,96],"code",{},"@nuxtjs\u002Fi18n",". ",[94,99,100],{},"en"," is the default locale and the fallback for every other one, so an untranslated key renders its English text rather than a raw key path.",[103,104,84],"h2",{"id":105},"where-things-live",[15,107,108,109,112],{},"There are ",[46,110,111],{},"four catalogs",", one per Nuxt app plus one for the layer they all extend. Each is a directory of its own, and each app registers its own locale list:",[114,115,120],"pre",{"className":116,"code":118,"language":119},[117],"language-text","apps\u002Fweb\u002Fi18n\u002Flocales\u002F{en,de}.json        # the product: dashboard, Postbox, admin, auth, recipient pages\napps\u002Fmarketing\u002Fi18n\u002Flocales\u002F{en,de}.json  # the public site (owlat.app)\napps\u002Fdocs\u002Fi18n\u002Flocales\u002F{en,de}.json       # this site's chrome — its PAGES live in content\u002F{en,de}\u002F\npackages\u002Fui\u002Fi18n\u002Flocales\u002F{en,de}.json     # the shared UI layer (`ui.*`), inherited by all three\n","text",[94,121,118],{"__ignoreMap":122},"",[15,124,125],{},"An individual app looks like this:",[114,127,130],{"className":128,"code":129,"language":119},[117],"apps\u002Fweb\u002F\n├── nuxt.config.ts          # the `i18n` block: locale list, strategy, detection\n└── i18n\u002F\n    ├── i18n.config.ts      # vue-i18n runtime options (fallbackLocale, warnings)\n    └── locales\u002F\n        ├── en.json         # source of truth\n        └── de.json         # a translation\n",[94,131,129],{"__ignoreMap":122},[15,133,134,135,138],{},"Locale files are ",[46,136,137],{},"lazy-loaded",": a visitor downloads only the catalog for the locale they are actually shown.",[15,140,141,142,145,146,149,150,153,154,157,158,161,162,166],{},"In the app, routes are ",[46,143,144],{},"not"," prefixed (",[94,147,148],{},"strategy: 'no_prefix'","). Every Owlat URL there is either a dashboard route or a token link that was already printed inside a sent email (",[94,151,152],{},"\u002Funsubscribe?token=…","), so a ",[94,155,156],{},"\u002Fde\u002F"," segment would break live links. The marketing site and this documentation site prefix instead (",[94,159,160],{},"prefix_except_default","); the reasoning is in ",[60,163,165],{"href":164},"#this-documentation-site","This documentation site"," below.",[168,169,171],"h3",{"id":170},"the-shared-ui-layer","The shared UI layer",[15,173,174,177,178,181,182,185,186,189,190,193,194,196],{},[94,175,176],{},"packages\u002Fui"," is a Nuxt layer, so its copy (",[94,179,180],{},"ui.*"," — modal close buttons, alert headings, \"Try again\") cannot live in any one app's catalog. It contributes its own ",[94,183,184],{},"i18n.locales"," from ",[94,187,188],{},"packages\u002Fui\u002Fnuxt.config.ts",", and @nuxtjs\u002Fi18n merges the per-locale file lists with the ",[46,191,192],{},"layer's file first and the app's last"," — so an app can override any ",[94,195,180],{}," message by redeclaring the key in its own catalog.",[15,198,199,200,202,203,205,206,209,210,213,214,217,218,221,222,224,225,228],{},"The layer does ",[46,201,144],{}," list ",[94,204,96],{}," in its ",[94,207,208],{},"modules",": the module is installed by the consuming apps, and a layer may not impose it on an app that has not adopted i18n. That is why layer components translate through ",[94,211,212],{},"useUiI18n()"," (",[94,215,216],{},"packages\u002Fui\u002Fcomposables\u002FuseUiI18n.ts",") rather than ",[94,219,220],{},"useI18n()"," directly — it falls back to the bundled English catalog when there is no vue-i18n instance, or when the instance's messages carry no ",[94,223,180],{}," (which is what an app's own component test looks like). The cost of that fallback: a broken layer hookup reads as untranslated English, not as visible key paths, so verify the wiring ",[46,226,227],{},"on the German copy"," — the modal close button must say \"Dialog schließen\".",[168,230,232],{"id":231},"the-marketing-site","The marketing site",[15,234,235,238,239,241,242,244,245,248,249,252,253,256],{},[94,236,237],{},"apps\u002Fmarketing"," follows the same rules and registers ",[94,240,96],{}," first in ",[94,243,208],{},", so the SEO modules (sitemap, robots, og-image) see the locale list while they set themselves up and emit per-locale entries. ",[94,246,247],{},"i18n.baseUrl"," is the canonical site URL, which is what makes the ",[94,250,251],{},"hreflang"," alternates absolute. Browser detection is deliberately off there: the language is chosen through the header switcher, search engines are pointed at the right variant by the alternates, and auto-redirecting on ",[94,254,255],{},"Accept-Language"," would bounce a German-configured browser away from a link that was explicitly shared as the English page.",[103,258,165],{"id":259},"this-documentation-site",[15,261,262,263,266,267,270],{},"The docs (",[94,264,265],{},"apps\u002Fdocs",") follow the same rules with two additions, because they translate ",[53,268,269],{},"pages",", not just chrome.",[15,272,273,213,276,279,280,283,284,287,288,290],{},[46,274,275],{},"URLs are prefixed",[94,277,278],{},"strategy: 'prefix_except_default'","). English keeps the URLs it has always published — ",[94,281,282],{},"\u002Fguide\u002Fquick-start"," — and German lives at ",[94,285,286],{},"\u002Fde\u002Fguide\u002Fquick-start",". Nothing here was ever printed inside an email, so a locale segment is free, and it is what lets a German page be shared, indexed and given its own ",[94,289,251],{}," alternate.",[15,292,293,296],{},[46,294,295],{},"The markdown tree is mirrored per locale",", one directory and one content collection each:",[114,298,301],{"className":299,"code":300,"language":119},[117],"apps\u002Fdocs\u002F\n├── content.config.ts       # one page collection per locale\n├── i18n\u002F\n│   ├── i18n.config.ts\n│   └── locales\u002F{en,de}.json\n└── content\u002F\n    ├── en\u002F1.guide\u002F2.quick-start.md   # → \u002Fguide\u002Fquick-start\n    └── de\u002F1.guide\u002F2.quick-start.md   # → \u002Fde\u002Fguide\u002Fquick-start\n",[94,302,300],{"__ignoreMap":122},[15,304,305,306,309,310,313],{},"Each collection strips its own locale directory (",[94,307,308],{},"source: { include: 'de\u002F**\u002F*.md', prefix: '' }","), so ",[46,311,312],{},"every collection publishes the same locale-free paths",". A query never names a locale in the path — it picks the collection instead:",[114,315,319],{"className":316,"code":317,"language":318,"meta":122,"style":122},"language-ts shiki shiki-themes github-light github-dark-dimmed","const { locale } = useI18n();\nconst page = await queryDocsPage(docsCollection(locale.value), '\u002Fguide\u002Fquick-start');\n","ts",[94,320,321,351],{"__ignoreMap":122},[322,323,326,330,334,338,341,344,348],"span",{"class":324,"line":325},"line",1,[322,327,329],{"class":328},"s7YZ4","const",[322,331,333],{"class":332},"sYgZi"," { ",[322,335,337],{"class":336},"sviXB","locale",[322,339,340],{"class":332}," } ",[322,342,343],{"class":328},"=",[322,345,347],{"class":346},"sPO5f"," useI18n",[322,349,350],{"class":332},"();\n",[322,352,354,356,359,362,365,368,371,374,377,381],{"class":324,"line":353},2,[322,355,329],{"class":328},[322,357,358],{"class":336}," page",[322,360,361],{"class":328}," =",[322,363,364],{"class":328}," await",[322,366,367],{"class":346}," queryDocsPage",[322,369,370],{"class":332},"(",[322,372,373],{"class":346},"docsCollection",[322,375,376],{"class":332},"(locale.value), ",[322,378,380],{"class":379},"s-HuK","'\u002Fguide\u002Fquick-start'",[322,382,383],{"class":332},");\n",[15,385,386,388,389,392,393,396,397,400,401,404],{},[94,387,373],{},", ",[94,390,391],{},"contentPath"," (which strips the ",[94,394,395],{},"\u002Fde"," a router path carries) and the query helpers all live in ",[94,398,399],{},"app\u002Fcomposables\u002FuseDocsContent.ts",". Use them rather than ",[94,402,403],{},"queryCollection"," directly — they are where the fallback lives.",[168,406,408],{"id":407},"a-translation-may-lag-a-page-may-not","A translation may lag; a page may not",[15,410,411,412,415],{},"The German tree is complete today, and it stays shippable while it is not: ",[46,413,414],{},"a missing page falls back to its English source instead of 404ing",", which is what lets a new English page land without blocking on its translation. The rule holds everywhere a page is read:",[19,417,418,428],{},[22,419,420],{},[25,421,422,425],{},[28,423,424],{},"Surface",[28,426,427],{},"Behaviour",[38,429,430,438,446,458],{},[25,431,432,435],{},[43,433,434],{},"Page body",[43,436,437],{},"The locale's page, else the English page — at the German URL, inside German chrome",[25,439,440,443],{},[43,441,442],{},"Search",[43,444,445],{},"The locale's pages, topped up with English pages that have no translation yet",[25,447,448,451],{},[43,449,450],{},"Prev \u002F next",[43,452,453,454,457],{},"Reading order ",[46,455,456],{},"always"," from English (the only complete tree); titles localized where they exist",[25,459,460,463],{},[43,461,462],{},"Sidebar, breadcrumb, in-page links",[43,464,465,466,469,470],{},"Locale-free paths run through ",[94,467,468],{},"localePath",", so a reader stays in ",[94,471,395],{},[15,473,474,475,477,478,481,482,485],{},"The one place this does ",[53,476,144],{}," apply is a wrong URL: ",[94,479,480],{},"\u002Fde\u002Fnonsense"," is a genuine 404 and renders ",[94,483,484],{},"app\u002Ferror.vue",".",[168,487,489],{"id":488},"sidebar-labels","Sidebar labels",[15,491,492,495,496,499,500,388,503,506,507,510],{},[94,493,494],{},"app\u002Futils\u002FsidebarConfig.ts"," stays the single source of truth for the nav, English labels included. Translations are looked up under a key ",[46,497,498],{},"derived"," from the entry — ",[94,501,502],{},"sidebar.groups.\u003Csection>-\u003Clabel>",[94,504,505],{},"sidebar.items.\u003Cpath>",", both slugified — and fall back to the config's label, so adding a page without touching the catalog renders English rather than a raw key path. ",[94,508,509],{},"__tests__\u002FlocaleCatalogs.test.ts"," fails on that drift, on an orphaned key, and on two entries that would collapse onto one key.",[103,512,514],{"id":513},"adding-a-locale","Adding a locale",[516,517,518,543,672,682,742],"ol",{},[519,520,521,524],"li",{},[46,522,523],{},"Copy the source catalog.",[114,525,529],{"className":526,"code":527,"language":528,"meta":122,"style":122},"language-sh shiki shiki-themes github-light github-dark-dimmed","cp apps\u002Fweb\u002Fi18n\u002Flocales\u002Fen.json apps\u002Fweb\u002Fi18n\u002Flocales\u002Ffr.json\n","sh",[94,530,531],{"__ignoreMap":122},[322,532,533,537,540],{"class":324,"line":325},[322,534,536],{"class":535},"sOLd2","cp",[322,538,539],{"class":379}," apps\u002Fweb\u002Fi18n\u002Flocales\u002Fen.json",[322,541,542],{"class":379}," apps\u002Fweb\u002Fi18n\u002Flocales\u002Ffr.json\n",[519,544,545,548,549,552,553,651,654,656,657,660,661,664,665,667,668,671],{},[46,546,547],{},"Register it"," in ",[94,550,551],{},"apps\u002Fweb\u002Fnuxt.config.ts",":",[114,554,556],{"className":316,"code":555,"language":318,"meta":122,"style":122},"locales: [\n  { code: 'en', language: 'en-US', name: 'English', file: 'en.json' },\n  { code: 'de', language: 'de-DE', name: 'Deutsch', file: 'de.json' },\n  { code: 'fr', language: 'fr-FR', name: 'Français', file: 'fr.json' },\n],\n",[94,557,558,566,595,620,645],{"__ignoreMap":122},[322,559,560,563],{"class":324,"line":325},[322,561,562],{"class":535},"locales",[322,564,565],{"class":332},": [\n",[322,567,568,571,574,577,580,583,586,589,592],{"class":324,"line":353},[322,569,570],{"class":332},"  { code: ",[322,572,573],{"class":379},"'en'",[322,575,576],{"class":332},", language: ",[322,578,579],{"class":379},"'en-US'",[322,581,582],{"class":332},", name: ",[322,584,585],{"class":379},"'English'",[322,587,588],{"class":332},", file: ",[322,590,591],{"class":379},"'en.json'",[322,593,594],{"class":332}," },\n",[322,596,598,600,603,605,608,610,613,615,618],{"class":324,"line":597},3,[322,599,570],{"class":332},[322,601,602],{"class":379},"'de'",[322,604,576],{"class":332},[322,606,607],{"class":379},"'de-DE'",[322,609,582],{"class":332},[322,611,612],{"class":379},"'Deutsch'",[322,614,588],{"class":332},[322,616,617],{"class":379},"'de.json'",[322,619,594],{"class":332},[322,621,623,625,628,630,633,635,638,640,643],{"class":324,"line":622},4,[322,624,570],{"class":332},[322,626,627],{"class":379},"'fr'",[322,629,576],{"class":332},[322,631,632],{"class":379},"'fr-FR'",[322,634,582],{"class":332},[322,636,637],{"class":379},"'Français'",[322,639,588],{"class":332},[322,641,642],{"class":379},"'fr.json'",[322,644,594],{"class":332},[322,646,648],{"class":324,"line":647},5,[322,649,650],{"class":332},"],\n",[652,653],"br",{},[94,655,94],{}," is the identifier the app switches on, ",[94,658,659],{},"language"," is the BCP 47 tag written into ",[94,662,663],{},"lang","\u002F",[94,666,251],{},", and ",[94,669,670],{},"name"," is what the language picker shows. The picker reads this list, so a registered locale appears there with no further work.",[519,673,674,677,678,681],{},[46,675,676],{},"Translate the values, never the keys."," Keep the object shape identical to ",[94,679,680],{},"en.json"," — the catalog guard test compares the two key sets and fails on a missing or invented key.",[519,683,684,687,733,735,736,738,739,485],{},[46,685,686],{},"Run the guards.",[114,688,690],{"className":526,"code":689,"language":528,"meta":122,"style":122},"cd apps\u002Fweb && npx vitest run app\u002F__tests__\u002FlocaleCatalogs.test.ts\ncd apps\u002Fdocs && npx vitest run __tests__\u002FlocaleCatalogs.test.ts\n",[94,691,692,715],{"__ignoreMap":122},[322,693,694,697,700,703,706,709,712],{"class":324,"line":325},[322,695,696],{"class":336},"cd",[322,698,699],{"class":379}," apps\u002Fweb",[322,701,702],{"class":332}," && ",[322,704,705],{"class":535},"npx",[322,707,708],{"class":379}," vitest",[322,710,711],{"class":379}," run",[322,713,714],{"class":379}," app\u002F__tests__\u002FlocaleCatalogs.test.ts\n",[322,716,717,719,722,724,726,728,730],{"class":324,"line":353},[322,718,696],{"class":336},[322,720,721],{"class":379}," apps\u002Fdocs",[322,723,702],{"class":332},[322,725,705],{"class":535},[322,727,708],{"class":379},[322,729,711],{"class":379},[322,731,732],{"class":379}," __tests__\u002FlocaleCatalogs.test.ts\n",[652,734],{},"This checks, for every locale: full key coverage against ",[94,737,100],{},", identical placeholder names per message, no markup, and no unescaped ",[94,740,741],{},"@",[519,743,744,747],{},[46,745,746],{},"Check the long strings in the UI."," German and French run 20–35% longer than English; buttons, switch rows and card headers are where that shows first.",[168,749,751],{"id":750},"pick-a-register-and-hold-it","Pick a register and hold it",[15,753,754,755,758,759,762],{},"Each locale should read as one voice. The shipped ",[94,756,757],{},"de"," catalog uses formal ",[53,760,761],{},"Sie"," throughout, because the most-read surfaces (unsubscribe, preference centre, double opt-in) are read by strangers who never chose Owlat. Follow the existing catalog rather than the habits of one file.",[103,764,766],{"id":765},"writing-translatable-strings","Writing translatable strings",[15,768,769,775],{},[46,770,771,772,485],{},"Every visible string goes through ",[94,773,774],{},"t()"," Import it once per component:",[114,777,781],{"className":778,"code":779,"language":780,"meta":122,"style":122},"language-vue shiki shiki-themes github-light github-dark-dimmed","\u003Cscript setup lang=\"ts\">\nconst { t } = useI18n();\n\u003C\u002Fscript>\n\n\u003Ctemplate>\n  \u003Cp>{{ t('recipient.preferences.noTopics') }}\u003C\u002Fp>\n\u003C\u002Ftemplate>\n","vue",[94,782,783,807,824,833,839,848,863],{"__ignoreMap":122},[322,784,785,788,792,796,799,801,804],{"class":324,"line":325},[322,786,787],{"class":332},"\u003C",[322,789,791],{"class":790},"sIAta","script",[322,793,795],{"class":794},"sMOND"," setup",[322,797,798],{"class":794}," lang",[322,800,343],{"class":332},[322,802,803],{"class":379},"\"ts\"",[322,805,806],{"class":332},">\n",[322,808,809,811,813,816,818,820,822],{"class":324,"line":353},[322,810,329],{"class":328},[322,812,333],{"class":332},[322,814,815],{"class":336},"t",[322,817,340],{"class":332},[322,819,343],{"class":328},[322,821,347],{"class":346},[322,823,350],{"class":332},[322,825,826,829,831],{"class":324,"line":597},[322,827,828],{"class":332},"\u003C\u002F",[322,830,791],{"class":790},[322,832,806],{"class":332},[322,834,835],{"class":324,"line":622},[322,836,838],{"emptyLinePlaceholder":837},true,"\n",[322,840,841,843,846],{"class":324,"line":647},[322,842,787],{"class":332},[322,844,845],{"class":790},"template",[322,847,806],{"class":332},[322,849,851,854,856,859,861],{"class":324,"line":850},6,[322,852,853],{"class":332},"  \u003C",[322,855,15],{"class":790},[322,857,858],{"class":332},">{{ t('recipient.preferences.noTopics') }}\u003C\u002F",[322,860,15],{"class":790},[322,862,806],{"class":332},[322,864,866,868,870],{"class":324,"line":865},7,[322,867,828],{"class":332},[322,869,845],{"class":790},[322,871,806],{"class":332},[15,873,874,875,878,879,664,882,885,886,485],{},"Script-side strings — validation messages, error copy, ",[94,876,877],{},"useBackendOperation"," labels, ",[94,880,881],{},"useHead",[94,883,884],{},"useSeoMeta"," titles — go through the same ",[94,887,774],{},[15,889,890,893,894,897,898,664,900,902,903,905,906,909,910,913],{},[46,891,892],{},"Anything captured once at setup takes a getter, not a value."," A bare ",[94,895,896],{},"t('…')"," in an options object is evaluated while the component is being set up and then frozen there, so it keeps the locale that happened to be active at mount. ",[94,899,881],{},[94,901,884],{}," and ",[94,904,877],{},"'s ",[94,907,908],{},"label"," all accept ",[94,911,912],{},"() => t('…')"," and read it when they actually use it:",[114,915,917],{"className":316,"code":916,"language":318,"meta":122,"style":122},"useHead({ title: () => t('auth.login.pageTitle') });\n\nconst { run } = useBackendOperation(api.auth.userProfiles.create, {\n  label: () => t('auth.register.createProfileOperation'),\n});\n",[94,918,919,946,950,969,988],{"__ignoreMap":122},[322,920,921,923,926,929,932,935,938,940,943],{"class":324,"line":325},[322,922,881],{"class":346},[322,924,925],{"class":332},"({ ",[322,927,928],{"class":346},"title",[322,930,931],{"class":332},": () ",[322,933,934],{"class":328},"=>",[322,936,937],{"class":346}," t",[322,939,370],{"class":332},[322,941,942],{"class":379},"'auth.login.pageTitle'",[322,944,945],{"class":332},") });\n",[322,947,948],{"class":324,"line":353},[322,949,838],{"emptyLinePlaceholder":837},[322,951,952,954,956,959,961,963,966],{"class":324,"line":597},[322,953,329],{"class":328},[322,955,333],{"class":332},[322,957,958],{"class":336},"run",[322,960,340],{"class":332},[322,962,343],{"class":328},[322,964,965],{"class":346}," useBackendOperation",[322,967,968],{"class":332},"(api.auth.userProfiles.create, {\n",[322,970,971,974,976,978,980,982,985],{"class":324,"line":622},[322,972,973],{"class":346},"  label",[322,975,931],{"class":332},[322,977,934],{"class":328},[322,979,937],{"class":346},[322,981,370],{"class":332},[322,983,984],{"class":379},"'auth.register.createProfileOperation'",[322,986,987],{"class":332},"),\n",[322,989,990],{"class":324,"line":647},[322,991,992],{"class":332},"});\n",[15,994,995,998],{},[46,996,997],{},"Interpolate values, never concatenate sentences."," Word order differs per language, so a string split into a prefix and a suffix around a value is untranslatable:",[114,1000,1004],{"className":1001,"code":1002,"language":1003,"meta":122,"style":122},"language-json shiki shiki-themes github-light github-dark-dimmed","{ \"intro\": \"Update your email preferences for {organization}.\" }\n","json",[94,1005,1006],{"__ignoreMap":122},[322,1007,1008,1011,1015,1018,1021],{"class":324,"line":325},[322,1009,1010],{"class":332},"{ ",[322,1012,1014],{"class":1013},"snmFh","\"intro\"",[322,1016,1017],{"class":332},": ",[322,1019,1020],{"class":379},"\"Update your email preferences for {organization}.\"",[322,1022,1023],{"class":332}," }\n",[15,1025,1026,1033,1034,1037],{},[46,1027,1028,1029,1032],{},"When part of a sentence carries markup, use ",[94,1030,1031],{},"\u003CI18nT>"," with a named slot"," — not ",[94,1035,1036],{},"v-html",", which would render an unescaped contact name or organization name straight into the page:",[114,1039,1041],{"className":778,"code":1040,"language":780,"meta":122,"style":122},"\u003CI18nT keypath=\"recipient.preferences.intro\" tag=\"span\" scope=\"global\">\n  \u003Ctemplate #organization>\u003Cstrong>{{ contactInfo.teamName }}\u003C\u002Fstrong>\u003C\u002Ftemplate>\n\u003C\u002FI18nT>\n",[94,1042,1043,1076,1081],{"__ignoreMap":122},[322,1044,1045,1047,1050,1053,1055,1058,1061,1063,1066,1069,1071,1074],{"class":324,"line":325},[322,1046,787],{"class":332},[322,1048,1049],{"class":790},"I18nT",[322,1051,1052],{"class":794}," keypath",[322,1054,343],{"class":332},[322,1056,1057],{"class":379},"\"recipient.preferences.intro\"",[322,1059,1060],{"class":794}," tag",[322,1062,343],{"class":332},[322,1064,1065],{"class":379},"\"span\"",[322,1067,1068],{"class":794}," scope",[322,1070,343],{"class":332},[322,1072,1073],{"class":379},"\"global\"",[322,1075,806],{"class":332},[322,1077,1078],{"class":324,"line":353},[322,1079,1080],{"class":332},"  \u003Ctemplate #organization>\u003Cstrong>{{ contactInfo.teamName }}\u003C\u002Fstrong>\u003C\u002Ftemplate>\n",[322,1082,1083,1085,1087],{"class":324,"line":597},[322,1084,828],{"class":332},[322,1086,1049],{"class":790},[322,1088,806],{"class":332},[15,1090,1091,1096,1097,1099,1100,485],{},[46,1092,1093,1094,485],{},"Escape a literal ",[94,1095,741],{}," The message compiler reads ",[94,1098,741],{}," as a linked-message marker, so an email placeholder is written ",[94,1101,1102],{},"you{'@'}example.com",[15,1104,1105,1108,1109,1112,1113,1116],{},[46,1106,1107],{},"No HTML in message values."," ",[94,1110,1111],{},"compilation.strictMessage"," rejects it at build time, so a smuggled ",[94,1114,1115],{},"\u003Cstrong>"," is a failed build rather than a broken string.",[15,1118,1119,1122],{},[46,1120,1121],{},"Format dates and numbers against the active locale",", not a pinned one:",[114,1124,1126],{"className":316,"code":1125,"language":318,"meta":122,"style":122},"const { t, locale } = useI18n();\nnew Intl.DateTimeFormat(locale.value, { dateStyle: 'long' }).format(sentAt);\n",[94,1127,1128,1148],{"__ignoreMap":122},[322,1129,1130,1132,1134,1136,1138,1140,1142,1144,1146],{"class":324,"line":325},[322,1131,329],{"class":328},[322,1133,333],{"class":332},[322,1135,815],{"class":336},[322,1137,388],{"class":332},[322,1139,337],{"class":336},[322,1141,340],{"class":332},[322,1143,343],{"class":328},[322,1145,347],{"class":346},[322,1147,350],{"class":332},[322,1149,1150,1153,1156,1159,1162,1165,1168,1171],{"class":324,"line":353},[322,1151,1152],{"class":328},"new",[322,1154,1155],{"class":332}," Intl.",[322,1157,1158],{"class":346},"DateTimeFormat",[322,1160,1161],{"class":332},"(locale.value, { dateStyle: ",[322,1163,1164],{"class":379},"'long'",[322,1166,1167],{"class":332}," }).",[322,1169,1170],{"class":346},"format",[322,1172,1173],{"class":332},"(sentAt);\n",[15,1175,1176,1182,1183,213,1186,388,1189,388,1192,1195,1196,1199],{},[46,1177,1178,1179,1181],{},"A module that cannot call ",[94,1180,774],{}," returns a key, not a sentence."," The vocabulary tables under ",[94,1184,1185],{},"app\u002Futils",[94,1187,1188],{},"deliverabilityRamp",[94,1190,1191],{},"deliverabilityMeasurement",[94,1193,1194],{},"breadcrumbRoutes",", …) are pure modules with no component instance, so they hand back a catalog key — or ",[94,1197,1198],{},"{ key, params }"," where the sentence interpolates figures — and whoever renders it turns it into words:",[114,1201,1203],{"className":316,"code":1202,"language":318,"meta":122,"style":122},"type LocalizedText = string | { key: string; params?: Record\u003Cstring, unknown> };\nconst localized = (value: LocalizedText) =>\n  typeof value === 'string' ? t(value) : t(value.key, value.params ?? {});\n",[94,1204,1205,1256,1280],{"__ignoreMap":122},[322,1206,1207,1210,1213,1215,1218,1221,1223,1227,1229,1231,1234,1237,1240,1243,1245,1248,1250,1253],{"class":324,"line":325},[322,1208,1209],{"class":328},"type",[322,1211,1212],{"class":535}," LocalizedText",[322,1214,361],{"class":328},[322,1216,1217],{"class":336}," string",[322,1219,1220],{"class":328}," |",[322,1222,333],{"class":332},[322,1224,1226],{"class":1225},"stnAF","key",[322,1228,552],{"class":328},[322,1230,1217],{"class":336},[322,1232,1233],{"class":332},"; ",[322,1235,1236],{"class":1225},"params",[322,1238,1239],{"class":328},"?:",[322,1241,1242],{"class":535}," Record",[322,1244,787],{"class":332},[322,1246,1247],{"class":336},"string",[322,1249,388],{"class":332},[322,1251,1252],{"class":336},"unknown",[322,1254,1255],{"class":332},"> };\n",[322,1257,1258,1260,1263,1265,1267,1270,1272,1274,1277],{"class":324,"line":353},[322,1259,329],{"class":328},[322,1261,1262],{"class":346}," localized",[322,1264,361],{"class":328},[322,1266,213],{"class":332},[322,1268,1269],{"class":1225},"value",[322,1271,552],{"class":328},[322,1273,1212],{"class":535},[322,1275,1276],{"class":332},") ",[322,1278,1279],{"class":328},"=>\n",[322,1281,1282,1285,1288,1291,1294,1297,1299,1302,1304,1306,1309,1312],{"class":324,"line":597},[322,1283,1284],{"class":328},"  typeof",[322,1286,1287],{"class":332}," value ",[322,1289,1290],{"class":328},"===",[322,1292,1293],{"class":379}," 'string'",[322,1295,1296],{"class":328}," ?",[322,1298,937],{"class":346},[322,1300,1301],{"class":332},"(value) ",[322,1303,552],{"class":328},[322,1305,937],{"class":346},[322,1307,1308],{"class":332},"(value.key, value.params ",[322,1310,1311],{"class":328},"??",[322,1313,1314],{"class":332}," {});\n",[15,1316,1317,1318,1321,1322,1325,1326,213,1329,1332,1333,485],{},"Dropping one of those values straight into a template paints ",[94,1319,1320],{},"[object Object]"," or a raw key path at the reader, so every consumer is a render boundary — including the Nitro routes under ",[94,1323,1324],{},"server\u002F",", which have no vue-i18n and use ",[94,1327,1328],{},"localizeEn()",[94,1330,1331],{},"server\u002Futils\u002FlocalizedText.ts",") to render the same keys in English for a script, a log line or ",[94,1334,1335],{},"curl",[15,1337,1338,1349,1350,1353,1354,1357],{},[46,1339,1340,1341,1344,1345,1348],{},"Registries outside ",[94,1342,1343],{},"apps\u002Fweb"," use the ",[94,1346,1347],{},"sharedPkg.*"," namespace"," — ",[94,1351,1352],{},"packages\u002Fshared"," vocabularies and Convex read models keep their keys under ",[94,1355,1356],{},"sharedPkg.\u003CmoduleName>.*"," so they can never collide with the file-derived namespaces above. Two patterns exist, chosen by who else reads the copy:",[1359,1360,1361,1382],"ul",{},[519,1362,1363,213,1366,388,1369,388,1372,1375,1376,1379,1380,485],{},[46,1364,1365],{},"Web-only copy stores keys outright",[94,1367,1368],{},"operatingModes",[94,1370,1371],{},"snoozePresets",[94,1373,1374],{},"sendProviderCatalog"," credential fields): the module carries the key, the web component renders ",[94,1377,1378],{},"t(key)"," — the same rule as ",[94,1381,1185],{},[519,1383,1384,213,1387,1390,1391,1394,1395,1398,1399,1401,1402,388,1405,1408],{},[46,1385,1386],{},"Copy with a non-web consumer keeps its English",[94,1388,1389],{},"featureFlags"," — the setup CLI prints it; ",[94,1392,1393],{},"deliverabilityChecklist"," — Convex stores and mails it; ",[94,1396,1397],{},"adaptiveDashboard"," cards — a served read model): the module stays English, and the web resolves a ",[53,1400,498],{}," key (",[94,1403,1404],{},"useFeatureCopy()",[94,1406,1407],{},"useDashboardCardCopy()",") with the registry's own English as the fallback for entries no shipped catalog can name, such as plugin-minted flags. A catalog test pins the two copies of the English together so they cannot drift.",[103,1410,1412],{"id":1411},"what-is-extracted","What is extracted",[15,1414,1415,1418],{},[46,1416,1417],{},"Everything."," The extraction landed surface by surface and is finished:",[19,1420,1421,1430],{},[22,1422,1423],{},[25,1424,1425,1427],{},[28,1426,424],{},[28,1428,1429],{},"State",[38,1431,1432,1442,1451,1464],{},[25,1433,1434,1439],{},[43,1435,1436,1438],{},[94,1437,1343],{}," — dashboard, Postbox, delivery and admin, setup and desktop flows, auth, welcome, recipient-facing pages",[43,1440,1441],{},"Extracted",[25,1443,1444,1449],{},[43,1445,1446,1448],{},[94,1447,237],{}," — the public site",[43,1450,1441],{},[25,1452,1453,1461],{},[43,1454,1455,1457,1458],{},[94,1456,265],{}," — this site's chrome, and the pages themselves under ",[94,1459,1460],{},"content\u002F{en,de}\u002F",[43,1462,1463],{},"Extracted and translated",[25,1465,1466,1474],{},[43,1467,1468,1470,1471,1473],{},[94,1469,176],{}," — the shared layer's own copy (",[94,1472,180],{},")",[43,1475,1441],{},[15,1477,1478,1479,1481],{},"A new string that does not go through ",[94,1480,774],{}," is a review comment, not a follow-up: a surface is either fully extracted or it produces a sentence in two languages.",[15,1483,1484,1485,1488,1489,902,1491,1493],{},"The docs tree is the one place where a translation may legitimately lag, and it degrades on purpose — see ",[60,1486,408],{"href":1487},"#a-translation-may-lag-a-page-may-not",". Everywhere else ",[94,1490,100],{},[94,1492,757],{}," are held at parity by the catalog guards, which fail on a missing key rather than letting an English line surface mid-page.",[168,1495,1497,1498],{"id":1496},"detection-the-picker-and-html-lang","Detection, the picker, and ",[94,1499,1500],{},"\u003Chtml lang>",[15,1502,1503,1504,548,1507,552],{},"Browser-language detection is ",[46,1505,1506],{},"on",[94,1508,1343],{},[114,1510,1512],{"className":316,"code":1511,"language":318,"meta":122,"style":122},"detectBrowserLanguage: { useCookie: true, cookieKey: 'owlat-locale' },\n",[94,1513,1514],{"__ignoreMap":122},[322,1515,1516,1519,1522,1525,1527,1530,1532,1535,1537,1540],{"class":324,"line":325},[322,1517,1518],{"class":535},"detectBrowserLanguage",[322,1520,1521],{"class":332},": { ",[322,1523,1524],{"class":535},"useCookie",[322,1526,1017],{"class":332},[322,1528,1529],{"class":336},"true",[322,1531,388],{"class":332},[322,1533,1534],{"class":535},"cookieKey",[322,1536,1017],{"class":332},[322,1538,1539],{"class":379},"'owlat-locale'",[322,1541,594],{"class":332},[15,1543,1544,1545,1548],{},"A first-time visitor is served the locale their browser asks for. The cookie is the load-bearing half: with ",[94,1546,1547],{},"no_prefix"," the URL carries no locale, so without it every reload would re-run detection and take the language back from anyone whose browser disagrees with their choice.",[15,1550,1551,1552,1555,1556,213,1559,1562,1563,1566,1567,1570],{},"That choice is made in ",[94,1553,1554],{},"app\u002Fcomponents\u002FLanguagePicker.vue",", on ",[46,1557,1558],{},"Preferences",[94,1560,1561],{},"\u002Fdashboard\u002Fpreferences","), beside Appearance — the same kind of setting: per-person, per-device, kept in this browser rather than on the account, so switching it on a phone leaves the desktop session alone. The picker lists the locales the module is configured with and switches through ",[94,1564,1565],{},"setLocale",", which loads the target catalog, swaps the active locale and writes ",[94,1568,1569],{},"owlat-locale",". The marketing site and this site have their own switchers in the header, where a prefixed URL makes the switch a navigation.",[15,1572,1573,1575,1576,1579,1580,1582,1583,1586,1587,1590,1591,1594,1595,1598,1599,1601],{},[94,1574,1500],{}," follows the active locale. ",[94,1577,1578],{},"app.head.htmlAttrs.lang"," is ",[46,1581,144],{}," pinned in ",[94,1584,1585],{},"nuxt.config.ts"," — a value set there is baked into every page and would leave a German reader on a document that still claims ",[94,1588,1589],{},"lang=\"en\"",", which is what a screen reader picks its voice from (WCAG 3.1.1). ",[94,1592,1593],{},"app\u002Fapp.vue"," writes it from ",[94,1596,1597],{},"useLocaleHead()"," instead, through a ",[94,1600,881],{}," getter so it is re-evaluated when the picker switches locales rather than frozen at first render:",[114,1603,1605],{"className":316,"code":1604,"language":318,"meta":122,"style":122},"const localeHead = useLocaleHead();\nuseHead(() => ({ htmlAttrs: localeHead.value.htmlAttrs }));\n",[94,1606,1607,1621],{"__ignoreMap":122},[322,1608,1609,1611,1614,1616,1619],{"class":324,"line":325},[322,1610,329],{"class":328},[322,1612,1613],{"class":336}," localeHead",[322,1615,361],{"class":328},[322,1617,1618],{"class":346}," useLocaleHead",[322,1620,350],{"class":332},[322,1622,1623,1625,1628,1630],{"class":324,"line":353},[322,1624,881],{"class":346},[322,1626,1627],{"class":332},"(() ",[322,1629,934],{"class":328},[322,1631,1632],{"class":332}," ({ htmlAttrs: localeHead.value.htmlAttrs }));\n",[15,1634,1635,1636,1639,1640,1642,1643,1645,1646,1648,1649,1651,1652,902,1655,1658,1659,1661,1662,1665,1666,1668],{},"Only ",[94,1637,1638],{},"htmlAttrs"," is taken in ",[94,1641,1343],{},": with ",[94,1644,1547],{}," every locale shares one URL, so the ",[94,1647,251],{}," alternates ",[94,1650,1597],{}," can also emit would all point at the same page. The marketing and docs apps take ",[94,1653,1654],{},"link",[94,1656,1657],{},"meta"," from it too, because there the alternates are real URLs. And because ",[94,1660,1343],{}," runs ",[94,1663,1664],{},"ssr: false",", its ",[94,1667,663],{}," is written when the app boots rather than into the static shell.",[1670,1671,1672],"style",{},"html pre.shiki code .s7YZ4, html code.shiki .s7YZ4{--shiki-default:#D73A49;--shiki-dark:#F47067}html pre.shiki code .sYgZi, html code.shiki .sYgZi{--shiki-default:#24292E;--shiki-dark:#ADBAC7}html pre.shiki code .sviXB, html code.shiki .sviXB{--shiki-default:#005CC5;--shiki-dark:#6CB6FF}html pre.shiki code .sPO5f, html code.shiki .sPO5f{--shiki-default:#6F42C1;--shiki-dark:#DCBDFB}html pre.shiki code .s-HuK, html code.shiki .s-HuK{--shiki-default:#032F62;--shiki-dark:#96D0FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sOLd2, html code.shiki .sOLd2{--shiki-default:#6F42C1;--shiki-dark:#F69D50}html pre.shiki code .snmFh, html code.shiki .snmFh{--shiki-default:#005CC5;--shiki-dark:#8DDB8C}html pre.shiki code .stnAF, html code.shiki .stnAF{--shiki-default:#E36209;--shiki-dark:#F69D50}html pre.shiki code .sIAta, html code.shiki .sIAta{--shiki-default:#22863A;--shiki-dark:#8DDB8C}html pre.shiki code .sMOND, html code.shiki .sMOND{--shiki-default:#6F42C1;--shiki-dark:#6CB6FF}",{"title":122,"searchDepth":353,"depth":353,"links":1674},[1675,1679,1683,1686,1687],{"id":105,"depth":353,"text":84,"children":1676},[1677,1678],{"id":170,"depth":597,"text":171},{"id":231,"depth":597,"text":232},{"id":259,"depth":353,"text":165,"children":1680},[1681,1682],{"id":407,"depth":597,"text":408},{"id":488,"depth":597,"text":489},{"id":513,"depth":353,"text":514,"children":1684},[1685],{"id":750,"depth":597,"text":751},{"id":765,"depth":353,"text":766},{"id":1411,"depth":353,"text":1412,"children":1688},[1689],{"id":1496,"depth":597,"text":1690},"Detection, the picker, and \u003Chtml lang>","How Owlat's UI translations work: where the four message catalogs live, how to add a locale, the rules for writing translatable strings, and how a visitor's language is detected, switched and declared.","md",{},"\u002Fdeveloper\u002Fui-localization",{"title":6,"description":1691},"3.developer\u002F23.ui-localization","KB1LJc7uOKyoGdnzTwQg24soJRjXXHzPghhSMCkflvA",[1699,1703],{"title":1700,"path":1701,"stem":1702,"children":-1},"Transport Security","\u002Fdeveloper\u002Ftransport-security","3.developer\u002F22.transport-security",{"title":1704,"path":1705,"stem":1706,"children":-1},"Scopes","\u002Fdeveloper\u002Fscopes","3.developer\u002F3.scopes",1786915090666]