From f1fdb50daebe79548c7789d3f7dd968b261d2da7 Mon Sep 17 00:00:00 2001 From: Chris Swithinbank Date: Wed, 1 Nov 2023 23:31:53 +0100 Subject: Refactor translation system to be reusable in non-Astro code (#1003) --- .changeset/large-squids-wash.md | 5 ++ .../__tests__/i18n/empty-src/content/i18n/.gitkeep | 0 .../i18n/malformed-src/content/i18n/en.json | 3 + .../__tests__/i18n/src/content/i18n/en.json | 3 + .../__tests__/i18n/translations-fs.test.ts | 76 +++++++++++++++++++++ packages/starlight/index.ts | 2 + packages/starlight/schemas/i18n.ts | 1 + .../starlight/utils/createTranslationSystem.ts | 79 ++++++++++++++++++++++ packages/starlight/utils/translations-fs.ts | 44 ++++++++++++ packages/starlight/utils/translations.ts | 58 ++-------------- 10 files changed, 218 insertions(+), 53 deletions(-) create mode 100644 .changeset/large-squids-wash.md create mode 100644 packages/starlight/__tests__/i18n/empty-src/content/i18n/.gitkeep create mode 100644 packages/starlight/__tests__/i18n/malformed-src/content/i18n/en.json create mode 100644 packages/starlight/__tests__/i18n/src/content/i18n/en.json create mode 100644 packages/starlight/__tests__/i18n/translations-fs.test.ts create mode 100644 packages/starlight/utils/createTranslationSystem.ts create mode 100644 packages/starlight/utils/translations-fs.ts diff --git a/.changeset/large-squids-wash.md b/.changeset/large-squids-wash.md new file mode 100644 index 00000000..924b70dc --- /dev/null +++ b/.changeset/large-squids-wash.md @@ -0,0 +1,5 @@ +--- +'@astrojs/starlight': patch +--- + +Internal: refactor translation string loading to make translations available to Starlight integration code diff --git a/packages/starlight/__tests__/i18n/empty-src/content/i18n/.gitkeep b/packages/starlight/__tests__/i18n/empty-src/content/i18n/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/starlight/__tests__/i18n/malformed-src/content/i18n/en.json b/packages/starlight/__tests__/i18n/malformed-src/content/i18n/en.json new file mode 100644 index 00000000..6c916955 --- /dev/null +++ b/packages/starlight/__tests__/i18n/malformed-src/content/i18n/en.json @@ -0,0 +1,3 @@ +{ +, +} diff --git a/packages/starlight/__tests__/i18n/src/content/i18n/en.json b/packages/starlight/__tests__/i18n/src/content/i18n/en.json new file mode 100644 index 00000000..099e4c00 --- /dev/null +++ b/packages/starlight/__tests__/i18n/src/content/i18n/en.json @@ -0,0 +1,3 @@ +{ + "page.editLink": "Make this page different" +} diff --git a/packages/starlight/__tests__/i18n/translations-fs.test.ts b/packages/starlight/__tests__/i18n/translations-fs.test.ts new file mode 100644 index 00000000..5b9025aa --- /dev/null +++ b/packages/starlight/__tests__/i18n/translations-fs.test.ts @@ -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); + }); +}); diff --git a/packages/starlight/index.ts b/packages/starlight/index.ts index a52b994f..53ffb2e2 100644 --- a/packages/starlight/index.ts +++ b/packages/starlight/index.ts @@ -9,6 +9,7 @@ import { vitePluginStarlightUserConfig } from './integrations/virtual-user-confi import { errorMap } from './utils/error-map'; import { StarlightConfigSchema, type StarlightUserConfig } from './utils/user-config'; import { rehypeRtlCodeSupport } from './integrations/code-rtl-support'; +import { createTranslationSystemFromFs } from './utils/translations-fs'; export default function StarlightIntegration(opts: StarlightUserConfig): AstroIntegration { const parsedConfig = StarlightConfigSchema.safeParse(opts, { errorMap }); @@ -26,6 +27,7 @@ export default function StarlightIntegration(opts: StarlightUserConfig): AstroIn name: '@astrojs/starlight', hooks: { 'astro:config:setup': ({ config, injectRoute, updateConfig }) => { + const useTranslations = createTranslationSystemFromFs(userConfig, config); injectRoute({ pattern: '404', entryPoint: '@astrojs/starlight/404.astro', diff --git a/packages/starlight/schemas/i18n.ts b/packages/starlight/schemas/i18n.ts index 07003412..eb26304d 100644 --- a/packages/starlight/schemas/i18n.ts +++ b/packages/starlight/schemas/i18n.ts @@ -3,6 +3,7 @@ import { z } from 'astro/zod'; export function i18nSchema() { return starlightI18nSchema().merge(pagefindI18nSchema()); } +export type i18nSchemaOutput = z.output>; export function builtinI18nSchema() { return starlightI18nSchema().required().strict().merge(pagefindI18nSchema()); diff --git a/packages/starlight/utils/createTranslationSystem.ts b/packages/starlight/utils/createTranslationSystem.ts new file mode 100644 index 00000000..97a79e3a --- /dev/null +++ b/packages/starlight/utils/createTranslationSystem.ts @@ -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, + config: Pick +) { + /** 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 = (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; +} diff --git a/packages/starlight/utils/translations-fs.ts b/packages/starlight/utils/translations-fs.ts new file mode 100644 index 00000000..d218f19e --- /dev/null +++ b/packages/starlight/utils/translations-fs.ts @@ -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, + { srcDir }: Pick +) { + /** All translation data from the i18n collection, keyed by `id`, which matches locale. */ + let userTranslations: Record = {}; + 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); +} diff --git a/packages/starlight/utils/translations.ts b/packages/starlight/utils/translations.ts index b4c6da4b..1dbc8561 100644 --- a/packages/starlight/utils/translations.ts +++ b/packages/starlight/utils/translations.ts @@ -1,13 +1,10 @@ -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['data']> = {}; +let userTranslations: Record = {}; try { // Load the user’s i18n collection and ignore the error if it doesn’t exist. userTranslations = Object.fromEntries( @@ -15,24 +12,6 @@ try { ); } 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] @@ -40,31 +19,4 @@ export function stripLangRegion(lang: string) { * 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 = (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); -- cgit