-
-
Notifications
You must be signed in to change notification settings - Fork 550
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Refactor translation system to be reusable in non-Astro code (#1003)
- Loading branch information
Showing
10 changed files
with
218 additions
and
53 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@astrojs/starlight': patch | ||
--- | ||
|
||
Internal: refactor translation string loading to make translations available to Starlight integration code |
Empty file.
3 changes: 3 additions & 0 deletions
3
packages/starlight/__tests__/i18n/malformed-src/content/i18n/en.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
{ | ||
"page.editLink": "Make this page different" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
import { describe, expect, test } from 'vitest'; | ||
import { createTranslationSystemFromFs } from '../../utils/translations-fs'; | ||
|
||
describe('createTranslationSystemFromFs', () => { | ||
test('creates a translation system that returns default strings', () => { | ||
const useTranslations = createTranslationSystemFromFs( | ||
{ | ||
locales: { en: { label: 'English', dir: 'ltr' } }, | ||
defaultLocale: { label: 'English', locale: 'en', dir: 'ltr' }, | ||
}, | ||
// Using non-existent `_src/` to ignore custom files in this test fixture. | ||
{ srcDir: new URL('./_src/', import.meta.url) } | ||
); | ||
const t = useTranslations('en'); | ||
expect(t('page.editLink')).toMatchInlineSnapshot('"Edit page"'); | ||
}); | ||
|
||
test('creates a translation system that uses custom strings', () => { | ||
const useTranslations = createTranslationSystemFromFs( | ||
{ | ||
locales: { en: { label: 'English', dir: 'ltr' } }, | ||
defaultLocale: { label: 'English', locale: 'en', dir: 'ltr' }, | ||
}, | ||
// Using `src/` to load custom files in this test fixture. | ||
{ srcDir: new URL('./src/', import.meta.url) } | ||
); | ||
const t = useTranslations('en'); | ||
expect(t('page.editLink')).toMatchInlineSnapshot('"Make this page different"'); | ||
}); | ||
|
||
test('supports root locale', () => { | ||
const useTranslations = createTranslationSystemFromFs( | ||
{ | ||
locales: { root: { label: 'English', dir: 'ltr', lang: 'en' } }, | ||
defaultLocale: { label: 'English', locale: 'root', lang: 'en', dir: 'ltr' }, | ||
}, | ||
// Using `src/` to load custom files in this test fixture. | ||
{ srcDir: new URL('./src/', import.meta.url) } | ||
); | ||
const t = useTranslations(undefined); | ||
expect(t('page.editLink')).toMatchInlineSnapshot('"Make this page different"'); | ||
}); | ||
|
||
test('returns translation for unknown language', () => { | ||
const useTranslations = createTranslationSystemFromFs( | ||
{ | ||
locales: { root: { label: 'English', dir: 'ltr', lang: 'en' } }, | ||
defaultLocale: { label: 'English', locale: undefined, dir: 'ltr' }, | ||
}, | ||
// Using `src/` to load custom files in this test fixture. | ||
{ srcDir: new URL('./src/', import.meta.url) } | ||
); | ||
const t = useTranslations('fr'); | ||
expect(t('page.editLink')).toMatchInlineSnapshot('"Make this page different"'); | ||
}); | ||
|
||
test('handles empty i18n directory', () => { | ||
const useTranslations = createTranslationSystemFromFs( | ||
{ locales: {}, defaultLocale: { label: 'English', locale: 'en', dir: 'ltr' } }, | ||
// Using `empty-src/` to emulate empty `src/content/i18n/` directory. | ||
{ srcDir: new URL('./empty-src/', import.meta.url) } | ||
); | ||
const t = useTranslations('en'); | ||
expect(t('page.editLink')).toMatchInlineSnapshot('"Edit page"'); | ||
}); | ||
|
||
test('throws on malformed i18n JSON', () => { | ||
expect(() => | ||
createTranslationSystemFromFs( | ||
{ locales: {}, defaultLocale: { label: 'English', locale: 'en', dir: 'ltr' } }, | ||
// Using `malformed-src/` to trigger syntax error in bad JSON file. | ||
{ srcDir: new URL('./malformed-src/', import.meta.url) } | ||
) | ||
).toThrow(SyntaxError); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import type { i18nSchemaOutput } from '../schemas/i18n'; | ||
import builtinTranslations from '../translations'; | ||
import type { StarlightConfig } from './user-config'; | ||
|
||
export function createTranslationSystem( | ||
userTranslations: Record<string, i18nSchemaOutput>, | ||
config: Pick<StarlightConfig, 'defaultLocale' | 'locales'> | ||
) { | ||
/** User-configured default locale. */ | ||
const defaultLocale = config.defaultLocale?.locale || 'root'; | ||
|
||
/** Default map of UI strings based on Starlight and user-configured defaults. */ | ||
const defaults = buildDictionary( | ||
builtinTranslations.en!, | ||
userTranslations.en, | ||
builtinTranslations[defaultLocale] || builtinTranslations[stripLangRegion(defaultLocale)], | ||
userTranslations[defaultLocale] | ||
); | ||
|
||
/** | ||
* Generate a utility function that returns UI strings for the given `locale`. | ||
* @param {string | undefined} [locale] | ||
* @example | ||
* const t = useTranslations('en'); | ||
* const label = t('search.label'); // => 'Search' | ||
*/ | ||
return function useTranslations(locale: string | undefined) { | ||
const lang = localeToLang(locale, config.locales, config.defaultLocale); | ||
const dictionary = buildDictionary( | ||
defaults, | ||
builtinTranslations[lang] || builtinTranslations[stripLangRegion(lang)], | ||
userTranslations[lang] | ||
); | ||
const t = <K extends keyof typeof dictionary>(key: K) => dictionary[key]; | ||
t.pick = (startOfKey: string) => | ||
Object.fromEntries(Object.entries(dictionary).filter(([k]) => k.startsWith(startOfKey))); | ||
return t; | ||
}; | ||
} | ||
|
||
/** | ||
* Strips the region subtag from a BCP-47 lang string. | ||
* @param {string} [lang] | ||
* @example | ||
* const lang = stripLangRegion('en-GB'); // => 'en' | ||
*/ | ||
function stripLangRegion(lang: string) { | ||
return lang.replace(/-[a-zA-Z]{2}/, ''); | ||
} | ||
|
||
/** | ||
* Get the BCP-47 language tag for the given locale. | ||
* @param locale Locale string or `undefined` for the root locale. | ||
*/ | ||
function localeToLang( | ||
locale: string | undefined, | ||
locales: StarlightConfig['locales'], | ||
defaultLocale: StarlightConfig['defaultLocale'] | ||
): string { | ||
const lang = locale ? locales?.[locale]?.lang : locales?.root?.lang; | ||
const defaultLang = defaultLocale?.lang || defaultLocale?.locale; | ||
return lang || defaultLang || 'en'; | ||
} | ||
|
||
/** Build a dictionary by layering preferred translation sources. */ | ||
function buildDictionary( | ||
base: (typeof builtinTranslations)[string], | ||
...dictionaries: (i18nSchemaOutput | undefined)[] | ||
) { | ||
const dictionary = { ...base }; | ||
// Iterate over alternate dictionaries to avoid overwriting preceding values with `undefined`. | ||
for (const dict of dictionaries) { | ||
for (const key in dict) { | ||
const value = dict[key as keyof typeof dict]; | ||
if (value) dictionary[key as keyof typeof dict] = value; | ||
} | ||
} | ||
return dictionary; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import fs from 'node:fs'; | ||
import type { i18nSchemaOutput } from '../schemas/i18n'; | ||
import { createTranslationSystem } from './createTranslationSystem'; | ||
import type { StarlightConfig } from './user-config'; | ||
import type { AstroConfig } from 'astro'; | ||
|
||
/** | ||
* Loads and creates a translation system from the file system. | ||
* Only for use in integration code. | ||
* In modules loaded by Vite/Astro, import [`useTranslations`](./translations.ts) instead. | ||
* | ||
* @see [`./translations.ts`](./translations.ts) | ||
*/ | ||
export function createTranslationSystemFromFs( | ||
opts: Pick<StarlightConfig, 'defaultLocale' | 'locales'>, | ||
{ srcDir }: Pick<AstroConfig, 'srcDir'> | ||
) { | ||
/** All translation data from the i18n collection, keyed by `id`, which matches locale. */ | ||
let userTranslations: Record<string, i18nSchemaOutput> = {}; | ||
try { | ||
const i18nDir = new URL('content/i18n/', srcDir); | ||
// Load the user’s i18n directory | ||
const files = fs.readdirSync(i18nDir, 'utf-8'); | ||
// Load the user’s i18n collection and ignore the error if it doesn’t exist. | ||
userTranslations = Object.fromEntries( | ||
files | ||
.filter((file) => file.endsWith('.json')) | ||
.map((file) => { | ||
const id = file.slice(0, -5); | ||
const data = JSON.parse(fs.readFileSync(new URL(file, i18nDir), 'utf-8')); | ||
return [id, data] as const; | ||
}) | ||
); | ||
} catch (e: unknown) { | ||
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') { | ||
// i18nDir doesn’t exist, so we ignore the error. | ||
} else { | ||
// Other errors may be meaningful, e.g. JSON syntax errors, so should be thrown. | ||
throw e; | ||
} | ||
} | ||
|
||
return createTranslationSystem(userTranslations, opts); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,70 +1,22 @@ | ||
import { type CollectionEntry, getCollection } from 'astro:content'; | ||
import { getCollection } from 'astro:content'; | ||
import config from 'virtual:starlight/user-config'; | ||
import builtinTranslations from '../translations'; | ||
import { localeToLang } from './slugs'; | ||
|
||
/** User-configured default locale. */ | ||
const defaultLocale = config.defaultLocale?.locale || 'root'; | ||
import type { i18nSchemaOutput } from '../schemas/i18n'; | ||
import { createTranslationSystem } from './createTranslationSystem'; | ||
|
||
/** All translation data from the i18n collection, keyed by `id`, which matches locale. */ | ||
let userTranslations: Record<string, CollectionEntry<'i18n'>['data']> = {}; | ||
let userTranslations: Record<string, i18nSchemaOutput> = {}; | ||
try { | ||
// Load the user’s i18n collection and ignore the error if it doesn’t exist. | ||
userTranslations = Object.fromEntries( | ||
(await getCollection('i18n')).map(({ id, data }) => [id, data] as const) | ||
); | ||
} catch {} | ||
|
||
/** Default map of UI strings based on Starlight and user-configured defaults. */ | ||
const defaults = buildDictionary( | ||
builtinTranslations.en!, | ||
userTranslations.en, | ||
builtinTranslations[defaultLocale] || builtinTranslations[stripLangRegion(defaultLocale)], | ||
userTranslations[defaultLocale] | ||
); | ||
|
||
/** | ||
* Strips the region subtag from a BCP-47 lang string. | ||
* @param {string} [lang] | ||
* @example | ||
* const lang = stripLangRegion('en-GB'); // => 'en' | ||
*/ | ||
export function stripLangRegion(lang: string) { | ||
return lang.replace(/-[a-zA-Z]{2}/, ''); | ||
} | ||
|
||
/** | ||
* Generate a utility function that returns UI strings for the given `locale`. | ||
* @param {string | undefined} [locale] | ||
* @example | ||
* const t = useTranslations('en'); | ||
* const label = t('search.label'); // => 'Search' | ||
*/ | ||
export function useTranslations(locale: string | undefined) { | ||
const lang = localeToLang(locale); | ||
const dictionary = buildDictionary( | ||
defaults, | ||
builtinTranslations[lang] || builtinTranslations[stripLangRegion(lang)], | ||
userTranslations[lang] | ||
); | ||
const t = <K extends keyof typeof dictionary>(key: K) => dictionary[key]; | ||
t.pick = (startOfKey: string) => | ||
Object.fromEntries(Object.entries(dictionary).filter(([k]) => k.startsWith(startOfKey))); | ||
return t; | ||
} | ||
|
||
/** Build a dictionary by layering preferred translation sources. */ | ||
function buildDictionary( | ||
base: (typeof builtinTranslations)[string], | ||
...dictionaries: (CollectionEntry<'i18n'>['data'] | undefined)[] | ||
) { | ||
const dictionary = { ...base }; | ||
// Iterate over alternate dictionaries to avoid overwriting preceding values with `undefined`. | ||
for (const dict of dictionaries) { | ||
for (const key in dict) { | ||
const value = dict[key as keyof typeof dict]; | ||
if (value) dictionary[key as keyof typeof dict] = value; | ||
} | ||
} | ||
return dictionary; | ||
} | ||
export const useTranslations = createTranslationSystem(userTranslations, config); |