Skip to content

Commit 28b34eb

Browse files
feat(i18n): add native Lunaria key merging (#1777)
Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent 33e9814 commit 28b34eb

39 files changed

+156
-32102
lines changed

CONTRIBUTING.md

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -492,17 +492,8 @@ To add a new locale:
492492
},
493493
```
494494

495-
4. Copy your translation file to `lunaria/files/` for translation tracking:
496-
497-
```bash
498-
cp i18n/locales/uk-UA.json lunaria/files/uk-UA.json
499-
```
500-
501-
> **Important:**
502-
> This file must be committed. Lunaria uses git history to track translation progress, so the build will fail if this file is missing.
503-
504-
5. If the language is `right-to-left`, add `dir: 'rtl'` (see `ar-EG` in config for example)
505-
6. If the language requires special pluralization rules, add a `pluralRule` callback (see `ar-EG` or `ru-RU` in config for examples)
495+
4. If the language is `right-to-left`, add `dir: 'rtl'` (see `ar-EG` in config for example)
496+
5. If the language requires special pluralization rules, add a `pluralRule` callback (see `ar-EG` or `ru-RU` in config for examples)
506497

507498
Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essentials/pluralization#custom-pluralization) and [Plural Rules](https://cldr.unicode.org/index/cldr-spec/plural-rules#TOC-Determining-Plural-Categories) for more info.
508499

app/composables/useI18nStatus.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,13 @@ export function useI18nStatus() {
4343
})
4444

4545
/**
46-
* Whether the current locale is the source locale (English)
46+
* Whether the current locale is the source locale (English) or a variant of it.
47+
* The source locale is 'en' (base), but app-facing locale codes are 'en-US', 'en-GB', etc.
48+
* We check if the current locale starts with the source locale code to handle variants.
4749
*/
4850
const isSourceLocale = computed(() => {
49-
return locale.value === (status.value?.sourceLocale.lang ?? 'en-US')
51+
const sourceLang = status.value?.sourceLocale.lang ?? 'en'
52+
return locale.value === sourceLang || locale.value.startsWith(`${sourceLang}-`)
5053
})
5154

5255
/**

config/i18n.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -356,8 +356,6 @@ const locales: (LocaleObjectData | (Omit<LocaleObjectData, 'code'> & { code: str
356356
},
357357
]
358358

359-
const lunariaJSONFiles: Record<string, string> = {}
360-
361359
function buildLocales() {
362360
const useLocales = Object.values(locales).reduce((acc, data) => {
363361
const locales = countryLocaleVariants[data.code]
@@ -369,12 +367,10 @@ function buildLocales() {
369367
name: l.name,
370368
files: [data.file as string, `${l.code}.json`],
371369
}
372-
lunariaJSONFiles[l.code] = l.country ? (data.file as string) : `${l.code}.json`
373370
delete entry.file
374371
acc.push(entry)
375372
})
376373
} else {
377-
lunariaJSONFiles[data.code] = data.file as string
378374
acc.push(data as LocaleObjectData)
379375
}
380376
return acc
@@ -385,8 +381,6 @@ function buildLocales() {
385381

386382
export const currentLocales = buildLocales()
387383

388-
export { lunariaJSONFiles }
389-
390384
export const datetimeFormats = Object.values(currentLocales).reduce((acc, data) => {
391385
const dateTimeFormats = data.dateTimeFormats
392386
if (dateTimeFormats) {

knip.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ const config: KnipConfig = {
4848
/** Some components import types from here, but installing it directly could lead to a version mismatch */
4949
'vue-router',
5050

51+
/** Required by @nuxtjs/i18n at runtime but not directly imported in production code */
52+
'@intlify/shared',
53+
5154
/** Oxlint plugins don't get picked up yet */
5255
'@e18e/eslint-plugin',
5356
'eslint-plugin-regexp',

lunaria.config.ts

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,78 @@
11
import { defineConfig } from '@lunariajs/core/config'
2-
import { locales, sourceLocale } from './lunaria/prepare-json-files.ts'
2+
import type { Locale, Merge } from '@lunariajs/core'
3+
import { currentLocales, countryLocaleVariants } from './config/i18n.ts'
4+
5+
// The source locale is `en` (en.json contains all reference translation keys).
6+
// Country variants like `en-US` inherit from `en` via the merge config.
7+
const sourceLocale: Locale = { label: 'English', lang: 'en' }
8+
9+
// Build the list of Lunaria locales from currentLocales.
10+
// currentLocales has expanded codes (en-US, en-GB, ar-EG, es-ES, es-419, etc.)
11+
// but NOT the base codes (ar, es) that the variants inherit from.
12+
// We need to add those base codes as Lunaria locales too, so they can be
13+
// referenced in the merge config and tracked independently.
14+
const localeSet = new Set<string>()
15+
const locales: Locale[] = []
16+
17+
for (const l of currentLocales) {
18+
if (l.code === sourceLocale.lang || !l.name) continue
19+
if (!localeSet.has(l.code)) {
20+
localeSet.add(l.code)
21+
locales.push({ label: l.name, lang: l.code })
22+
}
23+
}
24+
25+
// Add base language codes (ar, es, etc.) that aren't already in the list.
26+
// These are the keys of countryLocaleVariants that aren't the source locale.
27+
for (const baseLang of Object.keys(countryLocaleVariants)) {
28+
if (baseLang === sourceLocale.lang) continue
29+
if (!localeSet.has(baseLang)) {
30+
// Use the first variant's name or the base code as label
31+
const variants = countryLocaleVariants[baseLang]!
32+
const label = variants[0]?.name ?? baseLang
33+
localeSet.add(baseLang)
34+
locales.push({ label, lang: baseLang })
35+
}
36+
}
37+
38+
if (locales.length === 0) {
39+
throw new Error('No locales found besides source locale')
40+
}
41+
42+
// Build merge config from countryLocaleVariants:
43+
// Each variant locale merges keys from its base locale, so keys present in
44+
// the base file count as covered for the variant.
45+
// e.g. { 'en-US': ['en'], 'en-GB': ['en'], 'ar-EG': ['ar'], 'es-ES': ['es'], 'es-419': ['es'] }
46+
const merge: Merge = {}
47+
for (const [baseLang, variants] of Object.entries(countryLocaleVariants)) {
48+
for (const variant of variants) {
49+
// Each variant merges from its base language and (if not the source) implicitly
50+
// from the source via normal Lunaria tracking.
51+
const existing = merge[variant.code]
52+
if (existing) {
53+
existing.push(baseLang)
54+
} else {
55+
merge[variant.code] = [baseLang]
56+
}
57+
}
58+
}
359

460
export default defineConfig({
561
repository: {
662
name: 'npmx-dev/npmx.dev',
763
},
864
sourceLocale,
9-
locales,
65+
locales: locales as [Locale, ...Locale[]],
1066
files: [
1167
{
12-
include: ['lunaria/files/en-US.json'],
13-
pattern: 'lunaria/files/@lang.json',
68+
include: ['i18n/locales/en.json'],
69+
pattern: 'i18n/locales/@lang.json',
1470
type: 'dictionary',
71+
merge,
72+
optionalKeys: {
73+
$schema: true,
74+
vacations: true,
75+
},
1576
},
1677
],
1778
tracking: {

lunaria/components.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ export const TableBody = (
250250
<tr>
251251
<td>${Link(links.source(file.source.path), collapsePath(file.source.path))}</td>
252252
${locales.map(({ lang }) => {
253-
return TableContentStatus(file.localizations, lang, lunaria)
253+
return TableContentStatus(file.localizations, lang, lunaria, file.type)
254254
})}
255255
</td>
256256
</tr>`,
@@ -263,10 +263,24 @@ export const TableContentStatus = (
263263
localizations: StatusEntry['localizations'],
264264
lang: string,
265265
lunaria: LunariaInstance,
266+
fileType?: string,
266267
): string => {
267268
const localization = localizations.find(localization => localization.lang === lang)!
268269
const isMissingKeys = 'missingKeys' in localization && localization.missingKeys.length > 0
269-
const status = isMissingKeys ? 'outdated' : localization.status
270+
// For dictionary files, status is determined solely by key completion:
271+
// if there are missing keys it's "outdated", if all keys are present it's "up-to-date",
272+
// regardless of git history. This prevents variants with merge coverage (e.g. en-US, en-GB)
273+
// from showing as outdated when their keys are fully covered by the base locale.
274+
const status =
275+
fileType === 'dictionary'
276+
? isMissingKeys
277+
? 'outdated'
278+
: localization.status === 'missing'
279+
? 'missing'
280+
: 'up-to-date'
281+
: isMissingKeys
282+
? 'outdated'
283+
: localization.status
270284
const links = lunaria.gitHostingLinks()
271285
const link =
272286
status === 'missing' ? links.create(localization.path) : links.source(localization.path)

0 commit comments

Comments
 (0)