Skip to content

Commit

Permalink
feat: Support object title for multiple language (#1620)
Browse files Browse the repository at this point in the history
Co-authored-by: liruifengv <liruifeng1024@gmail.com>
Co-authored-by: Chris Swithinbank <swithinbank@gmail.com>
  • Loading branch information
3 people committed Apr 30, 2024
1 parent bcadd25 commit ca0678c
Show file tree
Hide file tree
Showing 19 changed files with 180 additions and 32 deletions.
18 changes: 18 additions & 0 deletions .changeset/neat-flowers-move.md
@@ -0,0 +1,18 @@
---
'@astrojs/starlight': minor
---

Adds support for translating the site title

⚠️ **Potentially breaking change:** The shape of the `title` field on Starlight’s internal config object has changed. This used to be a string, but is now an object.

If you are relying on `config.title` (for example in a custom `<SiteTitle>` or `<Head>` component), you will need to update your code. We recommend using the new [`siteTitle` prop](https://starlight.astro.build/reference/overrides/#sitetitle) available to component overrides:

```astro
---
import type { Props } from '@astrojs/starlight/props';
// The site title for this page’s language:
const { siteTitle } = Astro.props;
---
```
28 changes: 28 additions & 0 deletions docs/src/content/docs/guides/i18n.mdx
Expand Up @@ -143,6 +143,34 @@ Starlight expects you to create equivalent pages in all your languages. For exam

If a translation is not yet available for a language, Starlight will show readers the content for that page in the default language (set via `defaultLocale`). For example, if you have not yet created a French version of your About page and your default language is English, visitors to `/fr/about` will see the English content from `/en/about` with a notice that this page has not yet been translated. This helps you add content in your default language and then progressively translate it when your translators have time.

## Translate the site title

By default, Astro will use the same site title for all languages.
If you need to customize the title for each locale, you can pass an object to [`title`](/reference/configuration/#title-required) in Starlight’s options:

```diff lang="js"
// astro.config.mjs
import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';

export default defineConfig({
integrations: [
starlight({
- title: 'My Docs',
+ title: {
+ en: 'My Docs',
+ 'zh-CN': '我的文档',
+ },
defaultLocale: 'en',
locales: {
en: { label: 'English' },
'zh-cn': { label: '简体中文', lang: 'zh-CN' },
},
}),
],
});
```

## Translate Starlight's UI

import LanguagesList from '~/components/languages-list.astro';
Expand Down
14 changes: 13 additions & 1 deletion docs/src/content/docs/reference/configuration.mdx
Expand Up @@ -25,10 +25,22 @@ You can pass the following options to the `starlight` integration.

### `title` (required)

**type:** `string`
**type:** `string | Record<string, string>`

Set the title for your website. Will be used in metadata and in the browser tab title.

The value can be a string, or for multilingual sites, an object with values for each different locale.
When using the object form, the keys must be BCP-47 tags (e.g. `en`, `ar`, or `zh-CN`):

```ts
starlight({
title: {
en: 'My delightful docs site',
de: 'Meine bezaubernde Dokumentationsseite',
},
});
```

### `description`

**type:** `string`
Expand Down
8 changes: 7 additions & 1 deletion docs/src/content/docs/reference/overrides.md
Expand Up @@ -50,6 +50,12 @@ BCP-47 language tag for this page’s locale, e.g. `en`, `zh-CN`, or `pt-BR`.

The base path at which a language is served. `undefined` for root locale slugs.

#### `siteTitle`

**Type:** `string`

The site title for this page’s locale.

#### `slug`

**Type:** `string`
Expand Down Expand Up @@ -218,7 +224,7 @@ These components render Starlight’s top navigation bar.
**Default component:** [`Header.astro`](https://github.com/withastro/starlight/blob/main/packages/starlight/components/Header.astro)

Header component displayed at the top of every page.
The default implementation displays [`<SiteTitle />`](#sitetitle), [`<Search />`](#search), [`<SocialIcons />`](#socialicons), [`<ThemeSelect />`](#themeselect), and [`<LanguageSelect />`](#languageselect).
The default implementation displays [`<SiteTitle />`](#sitetitle-1), [`<Search />`](#search), [`<SocialIcons />`](#socialicons), [`<ThemeSelect />`](#themeselect), and [`<LanguageSelect />`](#languageselect).

#### `SiteTitle`

Expand Down
14 changes: 9 additions & 5 deletions packages/starlight/__tests__/basics/config-errors.test.ts
Expand Up @@ -66,7 +66,9 @@ test('parses valid config successfully', () => {
"maxHeadingLevel": 3,
"minHeadingLevel": 2,
},
"title": "",
"title": {
"en": "",
},
"titleDelimiter": "|",
}
`);
Expand All @@ -80,20 +82,22 @@ test('errors if title is missing', () => {
"[AstroUserError]:
Invalid config passed to starlight integration
Hint:
**title**: Required"
`
**title**: Did not match union.
> Required"
`
);
});

test('errors if title value is not a string', () => {
test('errors if title value is not a string or an Object', () => {
expect(() =>
parseStarlightConfigWithFriendlyErrors({ title: 5 } as any)
).toThrowErrorMatchingInlineSnapshot(
`
"[AstroUserError]:
Invalid config passed to starlight integration
Hint:
**title**: Expected type \`"string"\`, received \`"number"\`"
**title**: Did not match union.
> Expected type \`"string" | "object"\`, received \`"number"\`"
`
);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/starlight/__tests__/basics/config.test.ts
Expand Up @@ -2,7 +2,7 @@ import config from 'virtual:starlight/user-config';
import { expect, test } from 'vitest';

test('test suite is using correct env', () => {
expect(config.title).toBe('Basics');
expect(config.title).toMatchObject({ en: 'Basics' });
});

test('isMultilingual is false when no locales configured ', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/starlight/__tests__/basics/routing.test.ts
Expand Up @@ -14,7 +14,7 @@ vi.mock('astro:content', async () =>
);

test('test suite is using correct env', () => {
expect(config.title).toBe('Basics');
expect(config.title).toMatchObject({ en: 'Basics' });
});

test('route slugs are normalized', () => {
Expand Down
35 changes: 35 additions & 0 deletions packages/starlight/__tests__/basics/schema.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test } from 'vitest';
import { FaviconSchema } from '../../schemas/favicon';
import { TitleTransformConfigSchema } from '../../schemas/site-title';

describe('FaviconSchema', () => {
test('returns the proper href and type attributes', () => {
Expand All @@ -15,3 +16,37 @@ describe('FaviconSchema', () => {
expect(() => FaviconSchema().parse('/favicon.pdf')).toThrow();
});
});

describe('TitleTransformConfigSchema', () => {
test('title can be a string', () => {
const title = 'My Site';
const defaultLang = 'en';

const siteTitle = TitleTransformConfigSchema(defaultLang).parse(title);

expect(siteTitle).toEqual({
en: title,
});
});

test('title can be an object', () => {
const title = {
en: 'My Site',
es: 'Mi Sitio',
};
const defaultLang = 'en';

const siteTitle = TitleTransformConfigSchema(defaultLang).parse(title);

expect(siteTitle).toEqual(title);
});

test('throws on missing default language key', () => {
const title = {
es: 'Mi Sitio',
};
const defaultLang = 'en';

expect(() => TitleTransformConfigSchema(defaultLang).parse(title)).toThrow();
});
});
Expand Up @@ -2,7 +2,7 @@ import config from 'virtual:starlight/user-config';
import { expect, test } from 'vitest';

test('test suite is using correct env', () => {
expect(config.title).toBe('i18n with a non-root single locale');
expect(config.title).toMatchObject({ fr: 'i18n with a non-root single locale' });
});

test('config.isMultilingual is false with a single locale', () => {
Expand Down
Expand Up @@ -2,7 +2,7 @@ import config from 'virtual:starlight/user-config';
import { expect, test } from 'vitest';

test('test suite is using correct env', () => {
expect(config.title).toBe('i18n with root locale');
expect(config.title).toMatchObject({ fr: 'i18n with root locale' });
});

test('config.isMultilingual is true with multiple locales', () => {
Expand Down
Expand Up @@ -22,7 +22,7 @@ vi.mock('astro:content', async () =>
);

test('test suite is using correct env', () => {
expect(config.title).toBe('i18n with root locale');
expect(config.title).toMatchObject({ fr: 'i18n with root locale' });
});

test('routes includes fallback entries for untranslated pages', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/starlight/__tests__/i18n/config.test.ts
Expand Up @@ -2,7 +2,7 @@ import config from 'virtual:starlight/user-config';
import { expect, test } from 'vitest';

test('test suite is using correct env', () => {
expect(config.title).toBe('i18n with no root locale');
expect(config.title).toMatchObject({ 'en-US': 'i18n with no root locale' });
});

test('config.isMultilingual is true with multiple locales', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/starlight/__tests__/i18n/routing.test.ts
Expand Up @@ -19,7 +19,7 @@ vi.mock('astro:content', async () =>
);

test('test suite is using correct env', () => {
expect(config.title).toBe('i18n with no root locale');
expect(config.title).toMatchObject({ 'en-US': 'i18n with no root locale' });
});

test('routes includes fallback entries for untranslated pages', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/starlight/__tests__/plugins/config.test.ts
Expand Up @@ -5,7 +5,7 @@ import { runPlugins } from '../../utils/plugins';
import { createTestPluginContext } from '../test-plugin-utils';

test('reads and updates a configuration option', () => {
expect(config.title).toBe('Plugins - Custom');
expect(config.title).toMatchObject({ en: 'Plugins - Custom' });
});

test('overwrites a configuration option', () => {
Expand Down
6 changes: 3 additions & 3 deletions packages/starlight/components/Head.astro
Expand Up @@ -8,7 +8,7 @@ import { createHead } from '../utils/head';
import { localizedUrl } from '../utils/localizedUrl';
import type { Props } from '../props';
const { entry, lang } = Astro.props;
const { entry, lang, siteTitle } = Astro.props;
const { data } = entry;
const canonical = Astro.site ? new URL(Astro.url.pathname, Astro.site) : undefined;
Expand All @@ -20,7 +20,7 @@ const headDefaults: z.input<ReturnType<typeof HeadConfigSchema>> = [
tag: 'meta',
attrs: { name: 'viewport', content: 'width=device-width, initial-scale=1' },
},
{ tag: 'title', content: `${data.title} ${config.titleDelimiter} ${config.title}` },
{ tag: 'title', content: `${data.title} ${config.titleDelimiter} ${siteTitle}` },
{ tag: 'link', attrs: { rel: 'canonical', href: canonical?.href } },
{ tag: 'meta', attrs: { name: 'generator', content: Astro.generator } },
{
Expand All @@ -42,7 +42,7 @@ const headDefaults: z.input<ReturnType<typeof HeadConfigSchema>> = [
{ tag: 'meta', attrs: { property: 'og:url', content: canonical?.href } },
{ tag: 'meta', attrs: { property: 'og:locale', content: lang } },
{ tag: 'meta', attrs: { property: 'og:description', content: description } },
{ tag: 'meta', attrs: { property: 'og:site_name', content: config.title } },
{ tag: 'meta', attrs: { property: 'og:site_name', content: siteTitle } },
// Twitter Tags
{
tag: 'meta',
Expand Down
3 changes: 2 additions & 1 deletion packages/starlight/components/SiteTitle.astro
Expand Up @@ -4,6 +4,7 @@ import config from 'virtual:starlight/user-config';
import type { Props } from '../props';
import { formatPath } from '../utils/format-path';
const { siteTitle } = Astro.props;
const href = formatPath(Astro.props.locale || '/');
---

Expand Down Expand Up @@ -32,7 +33,7 @@ const href = formatPath(Astro.props.locale || '/');
)
}
<span class:list={{ 'sr-only': config.logo?.replacesTitle }}>
{config.title}
{siteTitle}
</span>
</a>

Expand Down
22 changes: 22 additions & 0 deletions packages/starlight/schemas/site-title.ts
@@ -0,0 +1,22 @@
import { z } from 'astro/zod';

export const TitleConfigSchema = () =>
z
.union([z.string(), z.record(z.string())])
.describe('Title for your website. Will be used in metadata and as browser tab title.');

// transform the title for runtime use
export const TitleTransformConfigSchema = (defaultLang: string) =>
TitleConfigSchema().transform((title, ctx) => {
if (typeof title === 'string') {
return { [defaultLang]: title };
}
if (!title[defaultLang] && title[defaultLang] !== '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Title must have a key for the default language "${defaultLang}"`,
});
return z.NEVER;
}
return title;
});
15 changes: 14 additions & 1 deletion packages/starlight/utils/route-data.ts
Expand Up @@ -15,6 +15,8 @@ export interface PageProps extends Route {
}

export interface StarlightRouteData extends Route {
/** Title of the site. */
siteTitle: string;
/** Array of Markdown headings extracted from the current page. */
headings: MarkdownHeading[];
/** Site navigation sidebar entries for this page. */
Expand All @@ -40,10 +42,12 @@ export function generateRouteData({
props: PageProps;
url: URL;
}): StarlightRouteData {
const { entry, locale } = props;
const { entry, locale, lang } = props;
const sidebar = getSidebar(url.pathname, locale);
const siteTitle = getSiteTitle(lang);
return {
...props,
siteTitle,
sidebar,
hasSidebar: entry.data.template !== 'splash',
pagination: getPrevNextLinks(sidebar, config.pagination, entry.data),
Expand Down Expand Up @@ -105,3 +109,12 @@ function getEditUrl({ entry, id, isFallback }: PageProps): URL | undefined {
}
return url ? new URL(url) : undefined;
}

/** Get the site title for a given language. **/
function getSiteTitle(lang: string): string {
const defaultLang = config.defaultLocale.lang as string;
if (lang && config.title[lang]) {
return config.title[lang] as string;
}
return config.title[defaultLang] as string;
}

0 comments on commit ca0678c

Please sign in to comment.