From ca0678ca556d739bda9648edc1b79c764fdea851 Mon Sep 17 00:00:00 2001 From: Cabbage <1271246683@qq.com> Date: Tue, 30 Apr 2024 23:03:57 +0800 Subject: [PATCH] feat: Support object title for multiple language (#1620) Co-authored-by: liruifengv Co-authored-by: Chris Swithinbank --- .changeset/neat-flowers-move.md | 18 ++++++++++ docs/src/content/docs/guides/i18n.mdx | 28 +++++++++++++++ .../content/docs/reference/configuration.mdx | 14 +++++++- docs/src/content/docs/reference/overrides.md | 8 ++++- .../__tests__/basics/config-errors.test.ts | 14 +++++--- .../starlight/__tests__/basics/config.test.ts | 2 +- .../__tests__/basics/routing.test.ts | 2 +- .../starlight/__tests__/basics/schema.test.ts | 35 +++++++++++++++++++ .../config.test.ts | 2 +- .../__tests__/i18n-root-locale/config.test.ts | 2 +- .../i18n-root-locale/routing.test.ts | 2 +- .../starlight/__tests__/i18n/config.test.ts | 2 +- .../starlight/__tests__/i18n/routing.test.ts | 2 +- .../__tests__/plugins/config.test.ts | 2 +- packages/starlight/components/Head.astro | 6 ++-- packages/starlight/components/SiteTitle.astro | 3 +- packages/starlight/schemas/site-title.ts | 22 ++++++++++++ packages/starlight/utils/route-data.ts | 15 +++++++- packages/starlight/utils/user-config.ts | 33 ++++++++++------- 19 files changed, 180 insertions(+), 32 deletions(-) create mode 100644 .changeset/neat-flowers-move.md create mode 100644 packages/starlight/schemas/site-title.ts diff --git a/.changeset/neat-flowers-move.md b/.changeset/neat-flowers-move.md new file mode 100644 index 0000000000..3119314d60 --- /dev/null +++ b/.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 `` or `` 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; +--- +``` diff --git a/docs/src/content/docs/guides/i18n.mdx b/docs/src/content/docs/guides/i18n.mdx index 85d2f00621..2c7564ae96 100644 --- a/docs/src/content/docs/guides/i18n.mdx +++ b/docs/src/content/docs/guides/i18n.mdx @@ -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'; diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index 380fb9bf78..a940a844ea 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -25,10 +25,22 @@ You can pass the following options to the `starlight` integration. ### `title` (required) -**type:** `string` +**type:** `string | Record` 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` diff --git a/docs/src/content/docs/reference/overrides.md b/docs/src/content/docs/reference/overrides.md index 3d9e4a86de..b758b1eab0 100644 --- a/docs/src/content/docs/reference/overrides.md +++ b/docs/src/content/docs/reference/overrides.md @@ -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` @@ -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), [``](#search), [``](#socialicons), [``](#themeselect), and [``](#languageselect). +The default implementation displays [``](#sitetitle-1), [``](#search), [``](#socialicons), [``](#themeselect), and [``](#languageselect). #### `SiteTitle` diff --git a/packages/starlight/__tests__/basics/config-errors.test.ts b/packages/starlight/__tests__/basics/config-errors.test.ts index 36bbb03be5..2b9eab9303 100644 --- a/packages/starlight/__tests__/basics/config-errors.test.ts +++ b/packages/starlight/__tests__/basics/config-errors.test.ts @@ -66,7 +66,9 @@ test('parses valid config successfully', () => { "maxHeadingLevel": 3, "minHeadingLevel": 2, }, - "title": "", + "title": { + "en": "", + }, "titleDelimiter": "|", } `); @@ -80,12 +82,13 @@ 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( @@ -93,7 +96,8 @@ test('errors if title value is not a string', () => { "[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"\`" ` ); }); diff --git a/packages/starlight/__tests__/basics/config.test.ts b/packages/starlight/__tests__/basics/config.test.ts index 43293c825b..1d5852ac9e 100644 --- a/packages/starlight/__tests__/basics/config.test.ts +++ b/packages/starlight/__tests__/basics/config.test.ts @@ -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 ', () => { diff --git a/packages/starlight/__tests__/basics/routing.test.ts b/packages/starlight/__tests__/basics/routing.test.ts index a33dd44106..84c3ae5801 100644 --- a/packages/starlight/__tests__/basics/routing.test.ts +++ b/packages/starlight/__tests__/basics/routing.test.ts @@ -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', () => { diff --git a/packages/starlight/__tests__/basics/schema.test.ts b/packages/starlight/__tests__/basics/schema.test.ts index 85526db919..c3bd3530ce 100644 --- a/packages/starlight/__tests__/basics/schema.test.ts +++ b/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', () => { @@ -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(); + }); +}); diff --git a/packages/starlight/__tests__/i18n-non-root-single-locale/config.test.ts b/packages/starlight/__tests__/i18n-non-root-single-locale/config.test.ts index a7b7262eb3..934827d18b 100644 --- a/packages/starlight/__tests__/i18n-non-root-single-locale/config.test.ts +++ b/packages/starlight/__tests__/i18n-non-root-single-locale/config.test.ts @@ -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', () => { diff --git a/packages/starlight/__tests__/i18n-root-locale/config.test.ts b/packages/starlight/__tests__/i18n-root-locale/config.test.ts index 25e5ac32e1..fca94f3b68 100644 --- a/packages/starlight/__tests__/i18n-root-locale/config.test.ts +++ b/packages/starlight/__tests__/i18n-root-locale/config.test.ts @@ -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', () => { diff --git a/packages/starlight/__tests__/i18n-root-locale/routing.test.ts b/packages/starlight/__tests__/i18n-root-locale/routing.test.ts index 879a444e0f..dd5b450518 100644 --- a/packages/starlight/__tests__/i18n-root-locale/routing.test.ts +++ b/packages/starlight/__tests__/i18n-root-locale/routing.test.ts @@ -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', () => { diff --git a/packages/starlight/__tests__/i18n/config.test.ts b/packages/starlight/__tests__/i18n/config.test.ts index 0e8e9c8db2..193595bf61 100644 --- a/packages/starlight/__tests__/i18n/config.test.ts +++ b/packages/starlight/__tests__/i18n/config.test.ts @@ -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', () => { diff --git a/packages/starlight/__tests__/i18n/routing.test.ts b/packages/starlight/__tests__/i18n/routing.test.ts index 18d8ed8979..b854794b5e 100644 --- a/packages/starlight/__tests__/i18n/routing.test.ts +++ b/packages/starlight/__tests__/i18n/routing.test.ts @@ -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', () => { diff --git a/packages/starlight/__tests__/plugins/config.test.ts b/packages/starlight/__tests__/plugins/config.test.ts index 0b866b1dac..948c0726eb 100644 --- a/packages/starlight/__tests__/plugins/config.test.ts +++ b/packages/starlight/__tests__/plugins/config.test.ts @@ -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', () => { diff --git a/packages/starlight/components/Head.astro b/packages/starlight/components/Head.astro index ec2431fc1e..6aac6ec964 100644 --- a/packages/starlight/components/Head.astro +++ b/packages/starlight/components/Head.astro @@ -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; @@ -20,7 +20,7 @@ const headDefaults: z.input> = [ 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 } }, { @@ -42,7 +42,7 @@ const headDefaults: z.input> = [ { 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', diff --git a/packages/starlight/components/SiteTitle.astro b/packages/starlight/components/SiteTitle.astro index 6f6fc9ed92..1f09a7df1f 100644 --- a/packages/starlight/components/SiteTitle.astro +++ b/packages/starlight/components/SiteTitle.astro @@ -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 || '/'); --- @@ -32,7 +33,7 @@ const href = formatPath(Astro.props.locale || '/'); ) } - {config.title} + {siteTitle} diff --git a/packages/starlight/schemas/site-title.ts b/packages/starlight/schemas/site-title.ts new file mode 100644 index 0000000000..ad5e62b647 --- /dev/null +++ b/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; + }); diff --git a/packages/starlight/utils/route-data.ts b/packages/starlight/utils/route-data.ts index 1ad5c9ca07..f9c6717de9 100644 --- a/packages/starlight/utils/route-data.ts +++ b/packages/starlight/utils/route-data.ts @@ -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. */ @@ -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), @@ -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; +} diff --git a/packages/starlight/utils/user-config.ts b/packages/starlight/utils/user-config.ts index 8e566346b4..31f00077ca 100644 --- a/packages/starlight/utils/user-config.ts +++ b/packages/starlight/utils/user-config.ts @@ -8,6 +8,7 @@ import { LogoConfigSchema } from '../schemas/logo'; import { SidebarItemSchema } from '../schemas/sidebar'; import { SocialLinksSchema } from '../schemas/social'; import { TableOfContentsSchema } from '../schemas/tableOfContents'; +import { TitleConfigSchema, TitleTransformConfigSchema } from '../schemas/site-title'; const LocaleSchema = z.object({ /** The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`. */ @@ -33,9 +34,7 @@ const LocaleSchema = z.object({ const UserConfigSchema = z.object({ /** Title for your website. Will be used in metadata and as browser tab title. */ - title: z - .string() - .describe('Title for your website. Will be used in metadata and as browser tab title.'), + title: TitleConfigSchema(), /** Description metadata for your website. Can be used in page metadata. */ description: z @@ -211,7 +210,7 @@ const UserConfigSchema = z.object({ }); export const StarlightConfigSchema = UserConfigSchema.strict().transform( - ({ locales, defaultLocale, ...config }, ctx) => { + ({ title, locales, defaultLocale, ...config }, ctx) => { const configuredLocales = Object.keys(locales ?? {}); // This is a multilingual site (more than one locale configured) or a monolingual site with @@ -236,8 +235,13 @@ export const StarlightConfigSchema = UserConfigSchema.strict().transform( return z.NEVER; } + // Transform the title + const TitleSchema = TitleTransformConfigSchema(defaultLocaleConfig.lang as string); + const parsedTitle = TitleSchema.parse(title); + return { ...config, + title: parsedTitle, /** Flag indicating if this site has multiple locales set up. */ isMultilingual: configuredLocales.length > 1, /** Full locale object for this site’s default language. */ @@ -248,18 +252,23 @@ export const StarlightConfigSchema = UserConfigSchema.strict().transform( // This is a monolingual site with no locales configured or only a root locale, so things are // pretty simple. + /** Full locale object for this site’s default language. */ + const defaultLocaleConfig = { + label: 'English', + lang: 'en', + dir: 'ltr', + locale: undefined, + ...locales?.root, + }; + /** Transform the title */ + const TitleSchema = TitleTransformConfigSchema(defaultLocaleConfig.lang); + const parsedTitle = TitleSchema.parse(title); return { ...config, + title: parsedTitle, /** Flag indicating if this site has multiple locales set up. */ isMultilingual: false, - /** Full locale object for this site’s default language. */ - defaultLocale: { - label: 'English', - lang: 'en', - dir: 'ltr', - locale: undefined, - ...locales?.root, - }, + defaultLocale: defaultLocaleConfig, locales: undefined, } as const; }