Skip to content

Commit

Permalink
Refactor translation system to be reusable in non-Astro code (#1003)
Browse files Browse the repository at this point in the history
  • Loading branch information
delucis committed Nov 1, 2023
1 parent 977fe13 commit f1fdb50
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 53 deletions.
5 changes: 5 additions & 0 deletions .changeset/large-squids-wash.md
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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
,
}
3 changes: 3 additions & 0 deletions packages/starlight/__tests__/i18n/src/content/i18n/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"page.editLink": "Make this page different"
}
76 changes: 76 additions & 0 deletions packages/starlight/__tests__/i18n/translations-fs.test.ts
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);
});
});
2 changes: 2 additions & 0 deletions packages/starlight/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/starlight/schemas/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { z } from 'astro/zod';
export function i18nSchema() {
return starlightI18nSchema().merge(pagefindI18nSchema());
}
export type i18nSchemaOutput = z.output<ReturnType<typeof i18nSchema>>;

export function builtinI18nSchema() {
return starlightI18nSchema().required().strict().merge(pagefindI18nSchema());
Expand Down
79 changes: 79 additions & 0 deletions packages/starlight/utils/createTranslationSystem.ts
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;
}
44 changes: 44 additions & 0 deletions packages/starlight/utils/translations-fs.ts
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鈥檚 i18n directory
const files = fs.readdirSync(i18nDir, 'utf-8');
// Load the user鈥檚 i18n collection and ignore the error if it doesn鈥檛 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鈥檛 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);
}
58 changes: 5 additions & 53 deletions packages/starlight/utils/translations.ts
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鈥檚 i18n collection and ignore the error if it doesn鈥檛 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);

0 comments on commit f1fdb50

Please sign in to comment.