summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChris Swithinbank2023-11-01 23:31:53 +0100
committerGitHub2023-11-01 23:31:53 +0100
commitf1fdb50daebe79548c7789d3f7dd968b261d2da7 (patch)
treebbb48eaa6d37745c0ae2c9066e4f8b8bb270d77c
parent977fe135a74661300589898abe98aec73cad9ed3 (diff)
downloadIT.starlight-f1fdb50daebe79548c7789d3f7dd968b261d2da7.tar.gz
IT.starlight-f1fdb50daebe79548c7789d3f7dd968b261d2da7.tar.bz2
IT.starlight-f1fdb50daebe79548c7789d3f7dd968b261d2da7.zip
Refactor translation system to be reusable in non-Astro code (#1003)
-rw-r--r--.changeset/large-squids-wash.md5
-rw-r--r--packages/starlight/__tests__/i18n/empty-src/content/i18n/.gitkeep0
-rw-r--r--packages/starlight/__tests__/i18n/malformed-src/content/i18n/en.json3
-rw-r--r--packages/starlight/__tests__/i18n/src/content/i18n/en.json3
-rw-r--r--packages/starlight/__tests__/i18n/translations-fs.test.ts76
-rw-r--r--packages/starlight/index.ts2
-rw-r--r--packages/starlight/schemas/i18n.ts1
-rw-r--r--packages/starlight/utils/createTranslationSystem.ts79
-rw-r--r--packages/starlight/utils/translations-fs.ts44
-rw-r--r--packages/starlight/utils/translations.ts58
10 files changed, 218 insertions, 53 deletions
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
--- /dev/null
+++ b/packages/starlight/__tests__/i18n/empty-src/content/i18n/.gitkeep
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<ReturnType<typeof i18nSchema>>;
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<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;
+}
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<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);
+}
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<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(
@@ -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 = <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);