@astrojs/starlight 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # @astrojs/starlight
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#995](https://github.com/withastro/starlight/pull/995) [`5bf4457`](https://github.com/withastro/starlight/commit/5bf44577634935b9fa6d50b040abcd680035075f) Thanks [@kevinzunigacuellar](https://github.com/kevinzunigacuellar)! - Adds support for adding sidebar badges to group headings
8
+
9
+ - [#988](https://github.com/withastro/starlight/pull/988) [`977fe13`](https://github.com/withastro/starlight/commit/977fe135a74661300589898abe98aec73cad9ed3) Thanks [@magicDGS](https://github.com/magicDGS)! - Include social icon links in mobile menu
10
+
11
+ - [#280](https://github.com/withastro/starlight/pull/280) [`72cca2d`](https://github.com/withastro/starlight/commit/72cca2d07644f00595da6ebf7d603adb282f359d) Thanks [@cbontems](https://github.com/cbontems)! - Support light & dark variants of the hero image.
12
+
13
+ ⚠️ **Potentially breaking change:** The `hero.image` schema is now slightly stricter than previously.
14
+
15
+ The `hero.image.html` property can no longer be used alongside the `hero.image.alt` or `hero.image.file` properties.
16
+ Previously, `html` was ignored when used with `file` and `alt` was ignored when used with `html`.
17
+ Now, those combinations will throw errors.
18
+ If you encounter errors, remove the `image.hero` property that is not in use.
19
+
20
+ ### Patch Changes
21
+
22
+ - [#1004](https://github.com/withastro/starlight/pull/1004) [`7f92213`](https://github.com/withastro/starlight/commit/7f92213a0b93de5a844816841a6bc9cdd371de0c) Thanks [@nunhes](https://github.com/nunhes)! - Add Galician language support
23
+
24
+ - [#1003](https://github.com/withastro/starlight/pull/1003) [`f1fdb50`](https://github.com/withastro/starlight/commit/f1fdb50daebe79548c7789d3f7dd968b261d2da7) Thanks [@delucis](https://github.com/delucis)! - Internal: refactor translation string loading to make translations available to Starlight integration code
25
+
3
26
  ## 0.11.2
4
27
 
5
28
  ### Patch Changes
@@ -14,20 +14,34 @@ const imageAttrs = {
14
14
  height: 400,
15
15
  alt: image?.alt || '',
16
16
  };
17
+
18
+ let darkImage: ImageMetadata | undefined;
19
+ let lightImage: ImageMetadata | undefined;
20
+ let rawHtml: string | undefined;
21
+ if (image) {
22
+ if ('file' in image) {
23
+ darkImage = image.file;
24
+ } else if ('dark' in image) {
25
+ darkImage = image.dark;
26
+ lightImage = image.light;
27
+ } else {
28
+ rawHtml = image.html;
29
+ }
30
+ }
17
31
  ---
18
32
 
19
33
  <div class="hero">
20
34
  {
21
- image?.file ? (
22
- image.file.format === 'svg' ? (
23
- <img src={image.file.src} {...imageAttrs} />
24
- ) : (
25
- <Image src={image.file} {...imageAttrs} />
26
- )
27
- ) : (
28
- image?.html && <div class="hero-html sl-flex" set:html={image.html} />
35
+ darkImage && (
36
+ <Image
37
+ src={darkImage}
38
+ {...imageAttrs}
39
+ class:list={{ 'light:sl-hidden': Boolean(lightImage) }}
40
+ />
29
41
  )
30
42
  }
43
+ {lightImage && <Image src={lightImage} {...imageAttrs} class="dark:sl-hidden" />}
44
+ {rawHtml && <div class="hero-html sl-flex" set:html={rawHtml} />}
31
45
  <div class="sl-flex stack">
32
46
  <div class="sl-flex copy">
33
47
  <h1 id={PAGE_TITLE_ID} data-page-title set:html={title} />
@@ -1,17 +1,31 @@
1
1
  ---
2
- import { LanguageSelect, ThemeSelect } from 'virtual:starlight/components';
2
+ import { LanguageSelect, ThemeSelect, SocialIcons } from 'virtual:starlight/components';
3
3
  import type { Props } from '../props';
4
4
  ---
5
5
 
6
6
  <div class="mobile-preferences sl-flex">
7
+ <div class="sl-flex social-icons">
8
+ <SocialIcons {...Astro.props} />
9
+ </div>
7
10
  <ThemeSelect {...Astro.props} />
8
11
  <LanguageSelect {...Astro.props} />
9
12
  </div>
10
13
 
11
14
  <style>
15
+ .social-icons {
16
+ margin-inline-end: auto;
17
+ gap: 1rem;
18
+ align-items: center;
19
+ padding-block: 1rem;
20
+ }
21
+ .social-icons:empty {
22
+ display: none;
23
+ }
12
24
  .mobile-preferences {
13
25
  justify-content: space-between;
26
+ flex-wrap: wrap;
14
27
  border-top: 1px solid var(--sl-color-gray-6);
28
+ column-gap: 1rem;
15
29
  padding: 0.5rem 0;
16
30
  }
17
31
  </style>
@@ -36,7 +36,15 @@ interface Props {
36
36
  open={flattenSidebar(entry.entries).some((i) => i.isCurrent) || !entry.collapsed}
37
37
  >
38
38
  <summary>
39
- <span class="large">{entry.label}</span>
39
+ <div class="group-label">
40
+ <span class="large">{entry.label}</span>
41
+ {entry.badge && (
42
+ <>
43
+ {' '}
44
+ <Badge text={entry.badge.text} variant={entry.badge.variant} />
45
+ </>
46
+ )}
47
+ </div>
40
48
  <Icon name="right-caret" class="caret" size="1.25rem" />
41
49
  </summary>
42
50
  <Astro.self sublist={entry.entries} nested />
@@ -78,7 +86,8 @@ interface Props {
78
86
  display: flex;
79
87
  align-items: center;
80
88
  justify-content: space-between;
81
- padding-inline: var(--sl-sidebar-item-padding-inline);
89
+ padding: 0.2em var(--sl-sidebar-item-padding-inline);
90
+ line-height: 1.4;
82
91
  cursor: pointer;
83
92
  user-select: none;
84
93
  }
@@ -120,7 +129,8 @@ interface Props {
120
129
  background-color: var(--sl-color-text-accent);
121
130
  }
122
131
 
123
- a > *:not(:last-child) {
132
+ a > *:not(:last-child),
133
+ .group-label > *:not(:last-child) {
124
134
  margin-inline-end: 0.25em;
125
135
  }
126
136
 
@@ -12,7 +12,7 @@ const href = pathWithBase(Astro.props.locale || '/');
12
12
  config.logo && logos.dark && (
13
13
  <>
14
14
  <img
15
- class:list={{ 'dark-only': !('src' in config.logo) }}
15
+ class:list={{ 'light:sl-hidden': !('src' in config.logo) }}
16
16
  alt={config.logo.alt}
17
17
  src={logos.dark.src}
18
18
  width={logos.dark.width}
@@ -21,7 +21,7 @@ const href = pathWithBase(Astro.props.locale || '/');
21
21
  {/* Show light alternate if a user configure both light and dark logos. */}
22
22
  {!('src' in config.logo) && (
23
23
  <img
24
- class="light-only"
24
+ class="dark:sl-hidden"
25
25
  alt={config.logo.alt}
26
26
  src={logos.light?.src}
27
27
  width={logos.light?.width}
@@ -56,10 +56,4 @@ const href = pathWithBase(Astro.props.locale || '/');
56
56
  object-fit: contain;
57
57
  object-position: 0 50%;
58
58
  }
59
- :global([data-theme='light']) .dark-only {
60
- display: none;
61
- }
62
- :global([data-theme='dark']) .light-only {
63
- display: none;
64
- }
65
59
  </style>
package/index.ts CHANGED
@@ -9,6 +9,7 @@ import { vitePluginStarlightUserConfig } from './integrations/virtual-user-confi
9
9
  import { errorMap } from './utils/error-map';
10
10
  import { StarlightConfigSchema, type StarlightUserConfig } from './utils/user-config';
11
11
  import { rehypeRtlCodeSupport } from './integrations/code-rtl-support';
12
+ import { createTranslationSystemFromFs } from './utils/translations-fs';
12
13
 
13
14
  export default function StarlightIntegration(opts: StarlightUserConfig): AstroIntegration {
14
15
  const parsedConfig = StarlightConfigSchema.safeParse(opts, { errorMap });
@@ -26,6 +27,7 @@ export default function StarlightIntegration(opts: StarlightUserConfig): AstroIn
26
27
  name: '@astrojs/starlight',
27
28
  hooks: {
28
29
  'astro:config:setup': ({ config, injectRoute, updateConfig }) => {
30
+ const useTranslations = createTranslationSystemFromFs(userConfig, config);
29
31
  injectRoute({
30
32
  pattern: '404',
31
33
  entryPoint: '@astrojs/starlight/404.astro',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrojs/starlight",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Build beautiful, high-performance documentation websites with Astro",
5
5
  "keywords": [
6
6
  "docs",
package/schema.ts CHANGED
@@ -3,16 +3,13 @@ import type { SchemaContext } from 'astro:content';
3
3
  import { HeadConfigSchema } from './schemas/head';
4
4
  import { PrevNextLinkConfigSchema } from './schemas/prevNextLink';
5
5
  import { TableOfContentsSchema } from './schemas/tableOfContents';
6
- import { Icons } from './components/Icons';
7
6
  import { BadgeConfigSchema } from './schemas/badge';
7
+ import { HeroSchema } from './schemas/hero';
8
8
  import { SidebarLinkItemHTMLAttributesSchema } from './schemas/sidebar';
9
9
  export { i18nSchema } from './schemas/i18n';
10
10
 
11
- type IconName = keyof typeof Icons;
12
- const iconNames = Object.keys(Icons) as [IconName, ...IconName[]];
13
-
14
11
  export function docsSchema() {
15
- return ({ image }: SchemaContext) =>
12
+ return (context: SchemaContext) =>
16
13
  z.object({
17
14
  /** The title of the current page. Required. */
18
15
  title: z.string(),
@@ -45,56 +42,7 @@ export function docsSchema() {
45
42
  template: z.enum(['doc', 'splash']).default('doc'),
46
43
 
47
44
  /** Display a hero section on this page. */
48
- hero: z
49
- .object({
50
- /**
51
- * The large title text to show. If not provided, will default to the top-level `title`.
52
- * Can include HTML.
53
- */
54
- title: z.string().optional(),
55
- /**
56
- * A short bit of text about your project.
57
- * Will be displayed in a smaller size below the title.
58
- */
59
- tagline: z.string().optional(),
60
- /** The image to use in the hero. You can provide either a relative `file` path or raw `html`. */
61
- image: z
62
- .object({
63
- /** Alt text for screenreaders and other assistive technologies describing your hero image. */
64
- alt: z.string().default(''),
65
- /** Relative path to an image file in your repo, e.g. `../../assets/hero.png`. */
66
- file: image().optional(),
67
- /** Raw HTML string instead of an image file. Useful for inline SVGs or more complex hero content. */
68
- html: z.string().optional(),
69
- })
70
- .optional(),
71
- /** An array of call-to-action links displayed at the bottom of the hero. */
72
- actions: z
73
- .object({
74
- /** Text label displayed in the link. */
75
- text: z.string(),
76
- /** Value for the link’s `href` attribute, e.g. `/page` or `https://mysite.com`. */
77
- link: z.string(),
78
- /** Button style to use. One of `primary`, `secondary`, or `minimal` (the default). */
79
- variant: z.enum(['primary', 'secondary', 'minimal']).default('minimal'),
80
- /**
81
- * An optional icon to display alongside the link text.
82
- * Can be an inline `<svg>` or the name of one of Starlight’s built-in icons.
83
- */
84
- icon: z
85
- .union([z.enum(iconNames), z.string().startsWith('<svg')])
86
- .transform((icon) => {
87
- const parsedIcon = z.enum(iconNames).safeParse(icon);
88
- return parsedIcon.success
89
- ? ({ type: 'icon', name: parsedIcon.data } as const)
90
- : ({ type: 'raw', html: icon } as const);
91
- })
92
- .optional(),
93
- })
94
- .array()
95
- .default([]),
96
- })
97
- .optional(),
45
+ hero: HeroSchema(context).optional(),
98
46
 
99
47
  /**
100
48
  * The last update date of the current page.
@@ -0,0 +1,70 @@
1
+ import { z } from 'astro/zod';
2
+ import type { SchemaContext } from 'astro:content';
3
+ import { Icons } from '../components/Icons';
4
+
5
+ type IconName = keyof typeof Icons;
6
+ const iconNames = Object.keys(Icons) as [IconName, ...IconName[]];
7
+
8
+ export const HeroSchema = ({ image }: SchemaContext) =>
9
+ z.object({
10
+ /**
11
+ * The large title text to show. If not provided, will default to the top-level `title`.
12
+ * Can include HTML.
13
+ */
14
+ title: z.string().optional(),
15
+ /**
16
+ * A short bit of text about your project.
17
+ * Will be displayed in a smaller size below the title.
18
+ */
19
+ tagline: z.string().optional(),
20
+ /** The image to use in the hero. You can provide either a relative `file` path or raw `html`. */
21
+ image: z
22
+ .union([
23
+ z.object({
24
+ /** Alt text for screenreaders and other assistive technologies describing your hero image. */
25
+ alt: z.string().default(''),
26
+ /** Relative path to an image file in your repo, e.g. `../../assets/hero.png`. */
27
+ file: image(),
28
+ }),
29
+ z.object({
30
+ /** Alt text for screenreaders and other assistive technologies describing your hero image. */
31
+ alt: z.string().default(''),
32
+ /** Relative path to an image file in your repo to use in dark mode, e.g. `../../assets/hero-dark.png`. */
33
+ dark: image(),
34
+ /** Relative path to an image file in your repo to use in light mode, e.g. `../../assets/hero-light.png`. */
35
+ light: image(),
36
+ }),
37
+ z
38
+ .object({
39
+ /** Raw HTML string instead of an image file. Useful for inline SVGs or more complex hero content. */
40
+ html: z.string(),
41
+ })
42
+ .transform(({ html }) => ({ html, alt: '' })),
43
+ ])
44
+ .optional(),
45
+ /** An array of call-to-action links displayed at the bottom of the hero. */
46
+ actions: z
47
+ .object({
48
+ /** Text label displayed in the link. */
49
+ text: z.string(),
50
+ /** Value for the link’s `href` attribute, e.g. `/page` or `https://mysite.com`. */
51
+ link: z.string(),
52
+ /** Button style to use. One of `primary`, `secondary`, or `minimal` (the default). */
53
+ variant: z.enum(['primary', 'secondary', 'minimal']).default('minimal'),
54
+ /**
55
+ * An optional icon to display alongside the link text.
56
+ * Can be an inline `<svg>` or the name of one of Starlight’s built-in icons.
57
+ */
58
+ icon: z
59
+ .union([z.enum(iconNames), z.string().startsWith('<svg')])
60
+ .transform((icon) => {
61
+ const parsedIcon = z.enum(iconNames).safeParse(icon);
62
+ return parsedIcon.success
63
+ ? ({ type: 'icon', name: parsedIcon.data } as const)
64
+ : ({ type: 'raw', html: icon } as const);
65
+ })
66
+ .optional(),
67
+ })
68
+ .array()
69
+ .default([]),
70
+ });
package/schemas/i18n.ts CHANGED
@@ -3,6 +3,7 @@ import { z } from 'astro/zod';
3
3
  export function i18nSchema() {
4
4
  return starlightI18nSchema().merge(pagefindI18nSchema());
5
5
  }
6
+ export type i18nSchemaOutput = z.output<ReturnType<typeof i18nSchema>>;
6
7
 
7
8
  export function builtinI18nSchema() {
8
9
  return starlightI18nSchema().required().strict().merge(pagefindI18nSchema());
@@ -9,6 +9,8 @@ const SidebarBaseSchema = z.object({
9
9
  label: z.string(),
10
10
  /** Translations of the `label` for each supported language. */
11
11
  translations: z.record(z.string()).default({}),
12
+ /** Adds a badge to the link item */
13
+ badge: BadgeConfigSchema(),
12
14
  });
13
15
 
14
16
  const SidebarGroupSchema = SidebarBaseSchema.extend({
@@ -29,8 +31,6 @@ export const SidebarLinkItemHTMLAttributesSchema = () => linkHTMLAttributesSchem
29
31
  const SidebarLinkItemSchema = SidebarBaseSchema.extend({
30
32
  /** The link to this item’s content. Can be a relative link to local files or the full URL of an external page. */
31
33
  link: z.string(),
32
- /** Adds a badge to the link item */
33
- badge: BadgeConfigSchema(),
34
34
  /** HTML attributes to add to the link item. */
35
35
  attrs: SidebarLinkItemHTMLAttributesSchema(),
36
36
  });
package/style/util.css CHANGED
@@ -41,6 +41,12 @@
41
41
  display: block;
42
42
  }
43
43
  }
44
+ [data-theme='light'] .light\:sl-hidden {
45
+ display: none;
46
+ }
47
+ [data-theme='dark'] .dark\:sl-hidden {
48
+ display: none;
49
+ }
44
50
 
45
51
  /*
46
52
  Flip an element around the y-axis when in an RTL context.
@@ -0,0 +1,22 @@
1
+ {
2
+ "skipLink.label": "Ir ao contido",
3
+ "search.label": "Busca",
4
+ "search.shortcutLabel": "(Preme / para Busca)",
5
+ "search.cancelLabel": "Deixar",
6
+ "search.devWarning": "A busca só está dispoñible nas versións de producción. \nTrata de construir e ollear o sitio para probalo localmente.",
7
+ "themeSelect.accessibleLabel": "Seleciona tema",
8
+ "themeSelect.dark": "Escuro",
9
+ "themeSelect.light": "Claro",
10
+ "themeSelect.auto": "Auto",
11
+ "languageSelect.accessibleLabel": "Seleciona linguaxe",
12
+ "menuButton.accessibleLabel": "Menú",
13
+ "sidebarNav.accessibleLabel": "Principal",
14
+ "tableOfContents.onThisPage": "Nesta paxina",
15
+ "tableOfContents.overview": "Sinopse",
16
+ "i18n.untranslatedContent": "Este contido aínda non está dispoñible no teu idioma.",
17
+ "page.editLink": "Editar paxina",
18
+ "page.lastUpdated": "Última actualización:",
19
+ "page.previousLink": "Anterior",
20
+ "page.nextLink": "Seguinte",
21
+ "404.text": "Paxina non atopada. Comproba a URL ou intenta usar a barra de busca."
22
+ }
@@ -7,6 +7,7 @@ import ja from './ja.json';
7
7
  import pt from './pt.json';
8
8
  import fa from './fa.json';
9
9
  import fr from './fr.json';
10
+ import gl from './gl.json';
10
11
  import he from './he.json';
11
12
  import id from './id.json';
12
13
  import it from './it.json';
@@ -34,6 +35,7 @@ export default Object.fromEntries(
34
35
  pt,
35
36
  fa,
36
37
  fr,
38
+ gl,
37
39
  he,
38
40
  id,
39
41
  it,
@@ -0,0 +1,79 @@
1
+ import type { i18nSchemaOutput } from '../schemas/i18n';
2
+ import builtinTranslations from '../translations';
3
+ import type { StarlightConfig } from './user-config';
4
+
5
+ export function createTranslationSystem(
6
+ userTranslations: Record<string, i18nSchemaOutput>,
7
+ config: Pick<StarlightConfig, 'defaultLocale' | 'locales'>
8
+ ) {
9
+ /** User-configured default locale. */
10
+ const defaultLocale = config.defaultLocale?.locale || 'root';
11
+
12
+ /** Default map of UI strings based on Starlight and user-configured defaults. */
13
+ const defaults = buildDictionary(
14
+ builtinTranslations.en!,
15
+ userTranslations.en,
16
+ builtinTranslations[defaultLocale] || builtinTranslations[stripLangRegion(defaultLocale)],
17
+ userTranslations[defaultLocale]
18
+ );
19
+
20
+ /**
21
+ * Generate a utility function that returns UI strings for the given `locale`.
22
+ * @param {string | undefined} [locale]
23
+ * @example
24
+ * const t = useTranslations('en');
25
+ * const label = t('search.label'); // => 'Search'
26
+ */
27
+ return function useTranslations(locale: string | undefined) {
28
+ const lang = localeToLang(locale, config.locales, config.defaultLocale);
29
+ const dictionary = buildDictionary(
30
+ defaults,
31
+ builtinTranslations[lang] || builtinTranslations[stripLangRegion(lang)],
32
+ userTranslations[lang]
33
+ );
34
+ const t = <K extends keyof typeof dictionary>(key: K) => dictionary[key];
35
+ t.pick = (startOfKey: string) =>
36
+ Object.fromEntries(Object.entries(dictionary).filter(([k]) => k.startsWith(startOfKey)));
37
+ return t;
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Strips the region subtag from a BCP-47 lang string.
43
+ * @param {string} [lang]
44
+ * @example
45
+ * const lang = stripLangRegion('en-GB'); // => 'en'
46
+ */
47
+ function stripLangRegion(lang: string) {
48
+ return lang.replace(/-[a-zA-Z]{2}/, '');
49
+ }
50
+
51
+ /**
52
+ * Get the BCP-47 language tag for the given locale.
53
+ * @param locale Locale string or `undefined` for the root locale.
54
+ */
55
+ function localeToLang(
56
+ locale: string | undefined,
57
+ locales: StarlightConfig['locales'],
58
+ defaultLocale: StarlightConfig['defaultLocale']
59
+ ): string {
60
+ const lang = locale ? locales?.[locale]?.lang : locales?.root?.lang;
61
+ const defaultLang = defaultLocale?.lang || defaultLocale?.locale;
62
+ return lang || defaultLang || 'en';
63
+ }
64
+
65
+ /** Build a dictionary by layering preferred translation sources. */
66
+ function buildDictionary(
67
+ base: (typeof builtinTranslations)[string],
68
+ ...dictionaries: (i18nSchemaOutput | undefined)[]
69
+ ) {
70
+ const dictionary = { ...base };
71
+ // Iterate over alternate dictionaries to avoid overwriting preceding values with `undefined`.
72
+ for (const dict of dictionaries) {
73
+ for (const key in dict) {
74
+ const value = dict[key as keyof typeof dict];
75
+ if (value) dictionary[key as keyof typeof dict] = value;
76
+ }
77
+ }
78
+ return dictionary;
79
+ }
@@ -30,6 +30,7 @@ interface Group {
30
30
  label: string;
31
31
  entries: (Link | Group)[];
32
32
  collapsed: boolean;
33
+ badge: Badge | undefined;
33
34
  }
34
35
 
35
36
  export type SidebarEntry = Link | Group;
@@ -75,6 +76,7 @@ function configItemToEntry(
75
76
  label: pickLang(item.translations, localeToLang(locale)) || item.label,
76
77
  entries: item.items.map((i) => configItemToEntry(i, currentPathname, locale, routes)),
77
78
  collapsed: item.collapsed,
79
+ badge: item.badge,
78
80
  };
79
81
  }
80
82
  }
@@ -101,6 +103,7 @@ function groupFromAutogenerateConfig(
101
103
  label: pickLang(item.translations, localeToLang(locale)) || item.label,
102
104
  entries: sidebarFromDir(tree, currentPathname, locale, subgroupCollapsed ?? item.collapsed),
103
105
  collapsed: item.collapsed,
106
+ badge: item.badge,
104
107
  };
105
108
  }
106
109
 
@@ -231,6 +234,7 @@ function groupFromDir(
231
234
  label: dirName,
232
235
  entries,
233
236
  collapsed,
237
+ badge: undefined,
234
238
  };
235
239
  }
236
240
 
@@ -0,0 +1,44 @@
1
+ import fs from 'node:fs';
2
+ import type { i18nSchemaOutput } from '../schemas/i18n';
3
+ import { createTranslationSystem } from './createTranslationSystem';
4
+ import type { StarlightConfig } from './user-config';
5
+ import type { AstroConfig } from 'astro';
6
+
7
+ /**
8
+ * Loads and creates a translation system from the file system.
9
+ * Only for use in integration code.
10
+ * In modules loaded by Vite/Astro, import [`useTranslations`](./translations.ts) instead.
11
+ *
12
+ * @see [`./translations.ts`](./translations.ts)
13
+ */
14
+ export function createTranslationSystemFromFs(
15
+ opts: Pick<StarlightConfig, 'defaultLocale' | 'locales'>,
16
+ { srcDir }: Pick<AstroConfig, 'srcDir'>
17
+ ) {
18
+ /** All translation data from the i18n collection, keyed by `id`, which matches locale. */
19
+ let userTranslations: Record<string, i18nSchemaOutput> = {};
20
+ try {
21
+ const i18nDir = new URL('content/i18n/', srcDir);
22
+ // Load the user’s i18n directory
23
+ const files = fs.readdirSync(i18nDir, 'utf-8');
24
+ // Load the user’s i18n collection and ignore the error if it doesn’t exist.
25
+ userTranslations = Object.fromEntries(
26
+ files
27
+ .filter((file) => file.endsWith('.json'))
28
+ .map((file) => {
29
+ const id = file.slice(0, -5);
30
+ const data = JSON.parse(fs.readFileSync(new URL(file, i18nDir), 'utf-8'));
31
+ return [id, data] as const;
32
+ })
33
+ );
34
+ } catch (e: unknown) {
35
+ if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
36
+ // i18nDir doesn’t exist, so we ignore the error.
37
+ } else {
38
+ // Other errors may be meaningful, e.g. JSON syntax errors, so should be thrown.
39
+ throw e;
40
+ }
41
+ }
42
+
43
+ return createTranslationSystem(userTranslations, opts);
44
+ }
@@ -1,13 +1,10 @@
1
- import { type CollectionEntry, getCollection } from 'astro:content';
1
+ import { getCollection } from 'astro:content';
2
2
  import config from 'virtual:starlight/user-config';
3
- import builtinTranslations from '../translations';
4
- import { localeToLang } from './slugs';
5
-
6
- /** User-configured default locale. */
7
- const defaultLocale = config.defaultLocale?.locale || 'root';
3
+ import type { i18nSchemaOutput } from '../schemas/i18n';
4
+ import { createTranslationSystem } from './createTranslationSystem';
8
5
 
9
6
  /** All translation data from the i18n collection, keyed by `id`, which matches locale. */
10
- let userTranslations: Record<string, CollectionEntry<'i18n'>['data']> = {};
7
+ let userTranslations: Record<string, i18nSchemaOutput> = {};
11
8
  try {
12
9
  // Load the user’s i18n collection and ignore the error if it doesn’t exist.
13
10
  userTranslations = Object.fromEntries(
@@ -15,24 +12,6 @@ try {
15
12
  );
16
13
  } catch {}
17
14
 
18
- /** Default map of UI strings based on Starlight and user-configured defaults. */
19
- const defaults = buildDictionary(
20
- builtinTranslations.en!,
21
- userTranslations.en,
22
- builtinTranslations[defaultLocale] || builtinTranslations[stripLangRegion(defaultLocale)],
23
- userTranslations[defaultLocale]
24
- );
25
-
26
- /**
27
- * Strips the region subtag from a BCP-47 lang string.
28
- * @param {string} [lang]
29
- * @example
30
- * const lang = stripLangRegion('en-GB'); // => 'en'
31
- */
32
- export function stripLangRegion(lang: string) {
33
- return lang.replace(/-[a-zA-Z]{2}/, '');
34
- }
35
-
36
15
  /**
37
16
  * Generate a utility function that returns UI strings for the given `locale`.
38
17
  * @param {string | undefined} [locale]
@@ -40,31 +19,4 @@ export function stripLangRegion(lang: string) {
40
19
  * const t = useTranslations('en');
41
20
  * const label = t('search.label'); // => 'Search'
42
21
  */
43
- export function useTranslations(locale: string | undefined) {
44
- const lang = localeToLang(locale);
45
- const dictionary = buildDictionary(
46
- defaults,
47
- builtinTranslations[lang] || builtinTranslations[stripLangRegion(lang)],
48
- userTranslations[lang]
49
- );
50
- const t = <K extends keyof typeof dictionary>(key: K) => dictionary[key];
51
- t.pick = (startOfKey: string) =>
52
- Object.fromEntries(Object.entries(dictionary).filter(([k]) => k.startsWith(startOfKey)));
53
- return t;
54
- }
55
-
56
- /** Build a dictionary by layering preferred translation sources. */
57
- function buildDictionary(
58
- base: (typeof builtinTranslations)[string],
59
- ...dictionaries: (CollectionEntry<'i18n'>['data'] | undefined)[]
60
- ) {
61
- const dictionary = { ...base };
62
- // Iterate over alternate dictionaries to avoid overwriting preceding values with `undefined`.
63
- for (const dict of dictionaries) {
64
- for (const key in dict) {
65
- const value = dict[key as keyof typeof dict];
66
- if (value) dictionary[key as keyof typeof dict] = value;
67
- }
68
- }
69
- return dictionary;
70
- }
22
+ export const useTranslations = createTranslationSystem(userTranslations, config);