diff options
author | Chris Swithinbank | 2023-05-23 15:18:26 +0200 |
---|---|---|
committer | GitHub | 2023-05-23 15:18:26 +0200 |
commit | d3ee6fc643de7a320a6bb83432cdcfbb0a4e4289 (patch) | |
tree | 0de59dc1aa8754007ffa61ac95fcb2bc314ca171 | |
parent | 5e8207350dba0fce92fa101d311db627e2157654 (diff) | |
download | IT.starlight-d3ee6fc643de7a320a6bb83432cdcfbb0a4e4289.tar.gz IT.starlight-d3ee6fc643de7a320a6bb83432cdcfbb0a4e4289.tar.bz2 IT.starlight-d3ee6fc643de7a320a6bb83432cdcfbb0a4e4289.zip |
Implement UI string translation using data collections (#78)
37 files changed, 513 insertions, 80 deletions
diff --git a/.changeset/afraid-zoos-retire.md b/.changeset/afraid-zoos-retire.md new file mode 100644 index 00000000..e2a41c27 --- /dev/null +++ b/.changeset/afraid-zoos-retire.md @@ -0,0 +1,16 @@ +--- +'@astrojs/starlight': patch +--- + +Add support for customising and translating Starlight’s UI. + +Users can provide translations in JSON files in `src/content/i18n/` which is a data collection. For example, a `src/content/i18n/de.json` might translate the search UI: + +```json +{ + "search.label": "Suchen", + "search.shortcutLabel": "(Drücke / zum Suchen)" +} +``` + +This change also allows Starlight to provide built-in support for more languages than just English and adds German & Spanish support. diff --git a/.changeset/light-eggs-relax.md b/.changeset/light-eggs-relax.md new file mode 100644 index 00000000..f10a7525 --- /dev/null +++ b/.changeset/light-eggs-relax.md @@ -0,0 +1,5 @@ +--- +"@astrojs/starlight": patch +--- + +Require a minimum Astro version of 2.5.0 diff --git a/docs/package.json b/docs/package.json index 312abf25..a5b8663c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@astrojs/starlight": "workspace:*", - "astro": "^2.4.3" + "astro": "^2.5.0" }, "devDependencies": { "@size-limit/file": "^8.2.4", diff --git a/docs/src/content/config.ts b/docs/src/content/config.ts index 4cdd345e..02ea2ac0 100644 --- a/docs/src/content/config.ts +++ b/docs/src/content/config.ts @@ -1,8 +1,7 @@ import { defineCollection } from 'astro:content'; -import { docsSchema } from '@astrojs/starlight/schema'; +import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; export const collections = { - docs: defineCollection({ - schema: docsSchema(), - }), + docs: defineCollection({ schema: docsSchema() }), + i18n: defineCollection({ type: 'data', schema: i18nSchema() }), }; diff --git a/docs/src/content/docs/guides/i18n.md b/docs/src/content/docs/guides/i18n.md index 13fe9e3f..eb2afc76 100644 --- a/docs/src/content/docs/guides/i18n.md +++ b/docs/src/content/docs/guides/i18n.md @@ -92,8 +92,67 @@ When using a `root` locale, place pages for that language directly in `src/conte - zh/ - index.md +#### Monolingual sites + +If you have a single language site, you can set the root locale to configure its language. +This allows you to override Starlight’s default language, which is English, but won’t enable other internationalization features like the language picker. + ## Fallback content Starlight expects you to create equivalent pages in all your languages. For example, if you have an `en/about.md` file, you should create an `about.md` for each other language you support. 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 an about page and your default language is English, visitors to `/fr/about` will see the English content. This helps you add content in your default language and then progressively translate it when your translators have time. + +## Translate Starlight’s UI + +Some of Starlight’s default UI requires text labels to work. +For example, the table of contents on this page has an “On this page” heading in English. +We aim to ship these labels in as many languages as possible but currently only have support for English, German, and Spanish. + +You can provide translations for additional languages you support — or override our default labels — via the `i18n` data collection. + +1. Configure the `i18n` data collection in `src/content/config.ts` if it isn’t configured already: + + ```js + import { defineCollection } from 'astro:content'; + import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; + + export const collections = { + docs: defineCollection({ schema: docsSchema() }), + i18n: defineCollection({ type: 'data', schema: i18nSchema() }), + }; + ``` + +2. Create a JSON file in `src/content/i18n/` for each locale you want to translate Starlight’s UI for. + For example, this would add translation files for Arabic and Simplified Chinese: + + - src/ + - content/ + - i18n/ + - ar.json + - zh-CN.json + +3. Add translations for the keys you want to translate to the JSON files. You can use the English defaults to help you translate: + + ```json + { + "skipLink.label": "Skip to content", + "search.label": "Search", + "search.shortcutLabel": "(Press / to Search)", + "search.cancelLabel": "Cancel", + "themeSelect.accessibleLabel": "Select theme", + "themeSelect.dark": "Dark", + "themeSelect.light": "Light", + "themeSelect.auto": "Auto", + "languageSelect.accessibleLabel": "Select language", + "menuButton.accessibleLabel": "Menu", + "sidebarNav.accessibleLabel": "Main", + "tableOfContents.onThisPage": "On this page", + "tableOfContents.overview": "Overview", + "i18n.untranslatedContent": "This content is not available in your language yet.", + "page.editLink": "Edit page", + "page.lastUpdated": "Last updated:", + "page.previousLink": "Next", + "page.nextLink": "Previous" + } + ``` diff --git a/examples/basics/package.json b/examples/basics/package.json index c5d7a3b9..4700f196 100644 --- a/examples/basics/package.json +++ b/examples/basics/package.json @@ -12,6 +12,6 @@ }, "dependencies": { "@astrojs/starlight": "^0.0.8", - "astro": "^2.4.1" + "astro": "^2.5.0" } } diff --git a/examples/basics/src/content/config.ts b/examples/basics/src/content/config.ts index 4cdd345e..02ea2ac0 100644 --- a/examples/basics/src/content/config.ts +++ b/examples/basics/src/content/config.ts @@ -1,8 +1,7 @@ import { defineCollection } from 'astro:content'; -import { docsSchema } from '@astrojs/starlight/schema'; +import { docsSchema, i18nSchema } from '@astrojs/starlight/schema'; export const collections = { - docs: defineCollection({ - schema: docsSchema(), - }), + docs: defineCollection({ schema: docsSchema() }), + i18n: defineCollection({ type: 'data', schema: i18nSchema() }), }; diff --git a/package.json b/package.json index f4cb8e31..ef3d5816 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "devDependencies": { "@changesets/changelog-github": "^0.4.8", "@changesets/cli": "^2.26.1", - "astro": "^2.4.3" + "astro": "^2.5.0" }, "packageManager": "pnpm@8.2.0" } diff --git a/packages/starlight/404.astro b/packages/starlight/404.astro index de897a79..00d0cab0 100644 --- a/packages/starlight/404.astro +++ b/packages/starlight/404.astro @@ -30,8 +30,8 @@ const { lang = 'en', dir = 'ltr', locale } = config.defaultLocale || {}; </head> <body> <ThemeProvider /> - <PageFrame> - <Header slot="header" locale={locale} /> + <PageFrame {locale}> + <Header slot="header" {locale} /> <main> <MarkdownContent> <h1 id="starlight__overview">404</h1> diff --git a/packages/starlight/components/EditLink.astro b/packages/starlight/components/EditLink.astro index 833be123..c72c25ac 100644 --- a/packages/starlight/components/EditLink.astro +++ b/packages/starlight/components/EditLink.astro @@ -1,12 +1,15 @@ --- import type { CollectionEntry } from 'astro:content'; import config from 'virtual:starlight/user-config'; +import { useTranslations } from '../utils/translations'; interface Props { data: CollectionEntry<'docs'>['data']; id: CollectionEntry<'docs'>['id']; + locale: string | undefined; } +const t = useTranslations(Astro.props.locale); const { editUrl } = Astro.props.data; let { baseUrl } = config.editLink; @@ -20,4 +23,4 @@ const url = : undefined; --- -{editUrl !== false && url && <a href={url}>Edit this page</a>} +{editUrl !== false && url && <a href={url}>{t('page.editLink')}</a>} diff --git a/packages/starlight/components/FallbackContentNotice.astro b/packages/starlight/components/FallbackContentNotice.astro index fc05938d..0faf151b 100644 --- a/packages/starlight/components/FallbackContentNotice.astro +++ b/packages/starlight/components/FallbackContentNotice.astro @@ -1,5 +1,12 @@ --- +import { useTranslations } from '../utils/translations'; import Icon from './Icon.astro'; + +interface Props { + locale: string | undefined; +} + +const t = useTranslations(Astro.props.locale); --- <p> @@ -7,7 +14,7 @@ import Icon from './Icon.astro'; name={'warning'} size="1.5em" color="var(--sl-color-orange-high)" - /><span>This content is not available in your language yet.</span> + /><span>{t('i18n.untranslatedContent')}</span> </p> <style> diff --git a/packages/starlight/components/Header.astro b/packages/starlight/components/Header.astro index 13bf4ba3..1d80e49e 100644 --- a/packages/starlight/components/Header.astro +++ b/packages/starlight/components/Header.astro @@ -14,10 +14,10 @@ const { locale } = Astro.props <div class="header"> <SiteTitle {locale} /> - <Search /> + <Search {locale} /> <div class="hidden md:flex right-group"> <SocialIcons /> - <ThemeSelect /> + <ThemeSelect {locale} /> <LanguageSelect {locale}/> </div> </div> diff --git a/packages/starlight/components/LanguageSelect.astro b/packages/starlight/components/LanguageSelect.astro index ebdb0546..7529a744 100644 --- a/packages/starlight/components/LanguageSelect.astro +++ b/packages/starlight/components/LanguageSelect.astro @@ -1,6 +1,7 @@ --- import config from 'virtual:starlight/user-config'; import { localizedUrl } from '../utils/localizedUrl'; +import { useTranslations } from '../utils/translations'; import Select from './Select.astro'; interface Props { @@ -13,6 +14,8 @@ interface Props { function localizedPathname(locale: string | undefined): string { return localizedUrl(Astro.url, locale).pathname; } + +const t = useTranslations(Astro.props.locale); --- { @@ -20,7 +23,7 @@ function localizedPathname(locale: string | undefined): string { <starlight-lang-select> <Select icon="translate" - label="Select language" + label={t('languageSelect.accessibleLabel')} value={localizedPathname(Astro.props.locale)} options={Object.entries(config.locales).map(([code, locale]) => ({ value: localizedPathname(code), diff --git a/packages/starlight/components/LastUpdated.astro b/packages/starlight/components/LastUpdated.astro index 07e30031..349b190e 100644 --- a/packages/starlight/components/LastUpdated.astro +++ b/packages/starlight/components/LastUpdated.astro @@ -3,13 +3,16 @@ import type { CollectionEntry } from 'astro:content'; import { fileURLToPath } from 'node:url'; import project from 'virtual:starlight/project-context'; import { getFileCommitDate } from '../utils/git'; +import { useTranslations } from '../utils/translations'; interface Props { id: CollectionEntry<'docs'>['id']; lang: string; + locale: string | undefined; } -const { id, lang } = Astro.props; +const { id, lang, locale } = Astro.props; +const t = useTranslations(locale); const currentFilePath = fileURLToPath( new URL('src/content/docs/' + id, project.root) @@ -24,7 +27,7 @@ try { { date && ( <p> - Last updated:{' '} + {t('page.lastUpdated')} <time datetime={date.toISOString()}> {date.toLocaleDateString(lang, { dateStyle: 'medium' })} </time> diff --git a/packages/starlight/components/MobileMenuToggle.astro b/packages/starlight/components/MobileMenuToggle.astro index b0d77b84..e6092a50 100644 --- a/packages/starlight/components/MobileMenuToggle.astro +++ b/packages/starlight/components/MobileMenuToggle.astro @@ -1,11 +1,18 @@ --- import Icon from './Icon.astro'; +import { useTranslations } from '../utils/translations'; + +interface Props { + locale: string | undefined; +} + +const t = useTranslations(Astro.props.locale); --- <starlight-menu-button> <button aria-expanded="false" - aria-label="Menu" + aria-label={t('menuButton.accessibleLabel')} aria-controls="starlight__sidebar" class="md:hidden" > diff --git a/packages/starlight/components/PrevNextLinks.astro b/packages/starlight/components/PrevNextLinks.astro index 70dd4856..79b4f8b0 100644 --- a/packages/starlight/components/PrevNextLinks.astro +++ b/packages/starlight/components/PrevNextLinks.astro @@ -1,15 +1,18 @@ --- import type { Link } from '../utils/navigation'; +import { useTranslations } from '../utils/translations'; import Icon from './Icon.astro'; interface Props { prev: Link | undefined; next: Link | undefined; dir: 'ltr' | 'rtl'; + locale: string | undefined; } -const { prev, next, dir } = Astro.props; +const { prev, next, dir, locale } = Astro.props; const isRtl = dir === 'rtl'; +const t = useTranslations(locale); --- <div class="pagination-links" dir={dir}> @@ -18,7 +21,7 @@ const isRtl = dir === 'rtl'; <a href={prev.href} rel="prev"> <Icon name={isRtl ? 'right-arrow' : 'left-arrow'} size="1.5rem" /> <span> - Previous + {t('page.previousLink')} <br /> <span class="link-title">{prev.label}</span> </span> @@ -30,7 +33,7 @@ const isRtl = dir === 'rtl'; <a href={next.href} rel="next"> <Icon name={isRtl ? 'left-arrow' : 'right-arrow'} size="1.5rem" /> <span> - Next + {t('page.nextLink')} <br /> <span class="link-title">{next.label}</span> </span> diff --git a/packages/starlight/components/RightSidebar.astro b/packages/starlight/components/RightSidebar.astro index 3979e537..d9c73139 100644 --- a/packages/starlight/components/RightSidebar.astro +++ b/packages/starlight/components/RightSidebar.astro @@ -9,20 +9,21 @@ import TableOfContents from './TableOfContents.astro'; interface Props { entry: StarlightDocsEntry; headings: MarkdownHeading[]; + locale: string | undefined; } -const { entry, headings } = Astro.props; +const { entry, headings, locale } = Astro.props; --- <RightSidebarPanel> - <TableOfContents headings={headings} /> + <TableOfContents {headings} {locale} /> </RightSidebarPanel> <RightSidebarPanel> { config.editLink.baseUrl && ( <> <h2>Contribute</h2> - <EditLink data={entry.data} id={entry.id} /> + <EditLink data={entry.data} id={entry.id} {locale} /> </> ) } diff --git a/packages/starlight/components/Search.astro b/packages/starlight/components/Search.astro index ec51c448..a9c0ef73 100644 --- a/packages/starlight/components/Search.astro +++ b/packages/starlight/components/Search.astro @@ -1,22 +1,37 @@ --- import '@pagefind/default-ui/css/ui.css'; +import { useTranslations } from '../utils/translations'; import Icon from './Icon.astro'; + +interface Props { + locale: string | undefined; +} + +const t = useTranslations(Astro.props.locale); --- <site-search> <button data-open-modal disabled> - <Icon name="magnifier" label="Search" /> - <span class="hidden md:block" aria-hidden="true">Search</span> + { + /* The span is `aria-hidden` because it is not shown on small screens. Instead, the icon label is used for accessibility purposes. */ + } + <Icon name="magnifier" label={t('search.label')} /> + <span class="hidden md:block" aria-hidden="true">{t('search.label')}</span> <Icon name="forward-slash" class="hidden md:block" - label="(Press / to search)" + label={t('search.shortcutLabel')} /> </button> - <dialog style="padding:0" aria-label="Search the documentation"> + <dialog style="padding:0" aria-label={t('search.label')}> <div class="dialog-frame"> - <button data-close-modal class="flex md:hidden">Cancel</button> + { + /* TODO: Make the layout of this button flexible to accommodate different word lengths. Currently hard-coded for English: “Cancel” */ + } + <button data-close-modal class="flex md:hidden"> + {t('search.cancelLabel')} + </button> { import.meta.env.DEV ? ( <div style="margin: auto; text-align: center;"> diff --git a/packages/starlight/components/Sidebar.astro b/packages/starlight/components/Sidebar.astro index cd514ffc..4bb4b53c 100644 --- a/packages/starlight/components/Sidebar.astro +++ b/packages/starlight/components/Sidebar.astro @@ -8,13 +8,15 @@ interface Props { sidebar: ReturnType<typeof getSidebar>; locale: string | undefined; } + +const { sidebar, locale } = Astro.props; --- <div class="sidebar flex"> - <SidebarSublist sublist={Astro.props.sidebar} /> + <SidebarSublist sublist={sidebar} /> <div class="mobile-preferences flex md:hidden"> - <ThemeSelect /> - <LanguageSelect locale={Astro.props.locale} /> + <ThemeSelect {locale} /> + <LanguageSelect {locale} /> </div> </div> diff --git a/packages/starlight/components/SkipLink.astro b/packages/starlight/components/SkipLink.astro index 29120f89..31b38e70 100644 --- a/packages/starlight/components/SkipLink.astro +++ b/packages/starlight/components/SkipLink.astro @@ -1,4 +1,14 @@ -<a href="#starlight__overview">Skip to content</a> +--- +import { useTranslations } from '../utils/translations'; + +interface Props { + locale: string | undefined; +} + +const t = useTranslations(Astro.props.locale); +--- + +<a href="#starlight__overview">{t('skipLink.label')}</a> <style> a { diff --git a/packages/starlight/components/TableOfContents.astro b/packages/starlight/components/TableOfContents.astro index eff17903..f54483ce 100644 --- a/packages/starlight/components/TableOfContents.astro +++ b/packages/starlight/components/TableOfContents.astro @@ -1,14 +1,21 @@ --- import type { MarkdownHeading } from 'astro'; import config from 'virtual:starlight/user-config'; -import { generateToC } from './TableOfContents/generateToC'; +import { useTranslations } from '../utils/translations'; import TableOfContentsList from './TableOfContents/TableOfContentsList.astro'; +import { generateToC } from './TableOfContents/generateToC'; interface Props { headings: MarkdownHeading[]; + locale: string | undefined; } -const toc = generateToC(Astro.props.headings, config.tableOfContents); +const { locale, headings } = Astro.props; +const t = useTranslations(locale); +const toc = generateToC(headings, { + ...config.tableOfContents, + title: t('tableOfContents.overview'), +}); --- <starlight-toc @@ -16,7 +23,9 @@ const toc = generateToC(Astro.props.headings, config.tableOfContents); data-max-h={config.tableOfContents.maxHeadingLevel} > <nav aria-labelledby="starlight__on-this-page"> - <h2 id="starlight__on-this-page">On this page</h2> + <h2 id="starlight__on-this-page"> + {t('tableOfContents.onThisPage')} + </h2> <TableOfContentsList toc={toc} /> </nav> </starlight-toc> diff --git a/packages/starlight/components/TableOfContents/MobileTableOfContents.astro b/packages/starlight/components/TableOfContents/MobileTableOfContents.astro index 14efd35c..1ed6600d 100644 --- a/packages/starlight/components/TableOfContents/MobileTableOfContents.astro +++ b/packages/starlight/components/TableOfContents/MobileTableOfContents.astro @@ -1,15 +1,21 @@ --- import type { MarkdownHeading } from 'astro'; import config from 'virtual:starlight/user-config'; -import { generateToC } from './generateToC'; -import TableOfContentsList from './TableOfContentsList.astro'; +import { useTranslations } from '../../utils/translations'; import Icon from '../Icon.astro'; +import TableOfContentsList from './TableOfContentsList.astro'; +import { generateToC } from './generateToC'; interface Props { headings: MarkdownHeading[]; + locale: string | undefined; } -const toc = generateToC(Astro.props.headings, config.tableOfContents); +const t = useTranslations(Astro.props.locale); +const toc = generateToC(Astro.props.headings, { + ...config.tableOfContents, + title: t('tableOfContents.overview'), +}); --- <mobile-starlight-toc @@ -20,7 +26,7 @@ const toc = generateToC(Astro.props.headings, config.tableOfContents); <details id="starlight__mobile-toc"> <summary id="starlight__on-this-page--mobile" class="flex"> <div class="toggle flex"> - On this page + {t('tableOfContents.onThisPage')} <Icon name={'right-caret'} class="caret" size="1rem" /> </div> <span class="display-current">{toc[0]?.text}</span> diff --git a/packages/starlight/components/TableOfContents/TableOfContentsList.astro b/packages/starlight/components/TableOfContents/TableOfContentsList.astro index 83adf903..f8972d7d 100644 --- a/packages/starlight/components/TableOfContents/TableOfContentsList.astro +++ b/packages/starlight/components/TableOfContents/TableOfContentsList.astro @@ -1,8 +1,8 @@ --- -import type { generateToC } from './generateToC'; +import type { TocItem } from './generateToC'; interface Props { - toc: ReturnType<typeof generateToC>; + toc: TocItem[]; depth?: number; isMobile?: boolean; } diff --git a/packages/starlight/components/ThemeSelect.astro b/packages/starlight/components/ThemeSelect.astro index 246747bd..aa408512 100644 --- a/packages/starlight/components/ThemeSelect.astro +++ b/packages/starlight/components/ThemeSelect.astro @@ -1,18 +1,28 @@ --- +import { useTranslations } from '../utils/translations'; import Select from './Select.astro'; + +interface Props { + locale: string | undefined; +} + +const t = useTranslations(Astro.props.locale); --- <starlight-theme-select> + { + /* TODO: Can we give this select a width that works well for each language’s strings? */ + } <Select icon="laptop" - label="Select theme" + label={t('themeSelect.accessibleLabel')} value="auto" options={[ - { label: 'Dark', selected: false, value: 'dark' }, - { label: 'Light', selected: false, value: 'light' }, - { label: 'Auto', selected: true, value: 'auto' }, + { label: t('themeSelect.dark'), selected: false, value: 'dark' }, + { label: t('themeSelect.light'), selected: false, value: 'light' }, + { label: t('themeSelect.auto'), selected: true, value: 'auto' }, ]} - width="5.5em" + width="6.25em" /> </starlight-theme-select> diff --git a/packages/starlight/index.astro b/packages/starlight/index.astro index bf73a6f4..cea4d2e4 100644 --- a/packages/starlight/index.astro +++ b/packages/starlight/index.astro @@ -64,13 +64,13 @@ const prevNextLinks = getPrevNextLinks(sidebar); </head> <body> <ThemeProvider /> - <SkipLink /> - <PageFrame> - <Header slot="header" locale={locale} /> - <Sidebar slot="sidebar" sidebar={sidebar} locale={locale} /> - <MobileTableOfContents headings={headings} /> + <SkipLink {locale} /> + <PageFrame {locale}> + <Header slot="header" {locale} /> + <Sidebar slot="sidebar" {sidebar} {locale} /> + <MobileTableOfContents {headings} {locale} /> <TwoColumnContent> - <RightSidebar slot="right-sidebar" entry={entry} headings={headings} /> + <RightSidebar slot="right-sidebar" {entry} {headings} {locale} /> <main data-pagefind-body lang={entryMeta.lang} dir={entryMeta.dir}> <ContentPanel> <h1 @@ -79,13 +79,13 @@ const prevNextLinks = getPrevNextLinks(sidebar); > {entry.data.title} </h1> - {isFallback && <FallbackContentNotice />} + {isFallback && <FallbackContentNotice {locale} />} </ContentPanel> <ContentPanel> <MarkdownContent><Content /></MarkdownContent> <footer> - <LastUpdated id={entry.id} lang={lang} /> - <PrevNextLinks {...prevNextLinks} dir={dir} /> + <LastUpdated id={entry.id} {lang} {locale} /> + <PrevNextLinks {...prevNextLinks} {dir} {locale} /> </footer> </ContentPanel> </main> diff --git a/packages/starlight/layout/PageFrame.astro b/packages/starlight/layout/PageFrame.astro index b250175b..6aa7bc2c 100644 --- a/packages/starlight/layout/PageFrame.astro +++ b/packages/starlight/layout/PageFrame.astro @@ -1,5 +1,13 @@ --- import MobileMenuToggle from '../components/MobileMenuToggle.astro'; +import { useTranslations } from '../utils/translations'; + +interface Props { + locale: string | undefined; +} + +const { locale } = Astro.props; +const t = useTranslations(locale); const hasSidebar = Astro.slots.has('sidebar'); --- @@ -8,8 +16,8 @@ const hasSidebar = Astro.slots.has('sidebar'); <header class="header"><slot name="header" /></header> { hasSidebar && ( - <nav class="sidebar" aria-label="Main"> - <MobileMenuToggle /> + <nav class="sidebar" aria-label={t('sidebarNav.accessibleLabel')}> + <MobileMenuToggle {locale} /> <div id="starlight__sidebar" class="sidebar-pane"> <div class="sidebar-content"> <slot name="sidebar" /> diff --git a/packages/starlight/package.json b/packages/starlight/package.json index d2e9b294..a76bfa7e 100644 --- a/packages/starlight/package.json +++ b/packages/starlight/package.json @@ -28,11 +28,11 @@ "./404.astro": "./404.astro" }, "peerDependencies": { - "astro": "^2.4.3" + "astro": "^2.5.0" }, "devDependencies": { "@types/node": "^18.15.11", - "astro": "^2.4.3" + "astro": "^2.5.0" }, "dependencies": { "@astrojs/mdx": "^0.19.1", diff --git a/packages/starlight/schema.ts b/packages/starlight/schema.ts index 0da35089..451b0406 100644 --- a/packages/starlight/schema.ts +++ b/packages/starlight/schema.ts @@ -1,5 +1,6 @@ import { z } from 'astro/zod'; import { HeadConfigSchema } from './schemas/head'; +export { i18nSchema } from './schemas/i18n'; export function docsSchema() { return z.object({ diff --git a/packages/starlight/schemas/i18n.ts b/packages/starlight/schemas/i18n.ts new file mode 100644 index 00000000..60f76a8c --- /dev/null +++ b/packages/starlight/schemas/i18n.ts @@ -0,0 +1,91 @@ +import { z } from 'astro/zod'; + +export function i18nSchema() { + return z + .object({ + 'skipLink.label': z + .string() + .describe( + 'Text displayed in the accessible “Skip link” when a keyboard user first tabs into a page.' + ), + + 'search.label': z.string().describe('Text displayed in the search bar.'), + + 'search.shortcutLabel': z + .string() + .describe( + 'Accessible label for the shortcut key to open the search modal.' + ), + + 'search.cancelLabel': z + .string() + .describe('Text for the “Cancel” button that closes the search modal.'), + + 'themeSelect.accessibleLabel': z + .string() + .describe('Accessible label for the theme selection dropdown.'), + + 'themeSelect.dark': z.string().describe('Name of the dark color theme.'), + + 'themeSelect.light': z + .string() + .describe('Name of the light color theme.'), + + 'themeSelect.auto': z + .string() + .describe( + 'Name of the automatic color theme that syncs with system preferences.' + ), + + 'languageSelect.accessibleLabel': z + .string() + .describe('Accessible label for the language selection dropdown.'), + + 'menuButton.accessibleLabel': z + .string() + .describe('Accessible label for he mobile menu button.'), + + 'sidebarNav.accessibleLabel': z + .string() + .describe( + 'Accessible label for the main sidebar `<nav>` element to distinguish it fom other `<nav>` landmarks on the page.' + ), + + 'tableOfContents.onThisPage': z + .string() + .describe('Title for the table of contents component.'), + + 'tableOfContents.overview': z + .string() + .describe( + 'Label used for the first link in the table of contents, linking to the page title.' + ), + + 'i18n.untranslatedContent': z + .string() + .describe( + 'Notice informing users they are on a page that is not yet translated to their language.' + ), + + 'page.editLink': z.string().describe('Text for the link to edit a page.'), + + 'page.lastUpdated': z + .string() + .describe( + 'Text displayed in front of the last updated date in the page footer.' + ), + + 'page.previousLink': z + .string() + .describe( + 'Label shown on the “previous page” pagination arrow in the page footer.' + ), + + 'page.nextLink': z + .string() + .describe( + 'Label shown on the “next page” pagination arrow in the page footer.' + ), + }) + .partial(); +} diff --git a/packages/starlight/translations/de.json b/packages/starlight/translations/de.json new file mode 100644 index 00000000..ca4c4035 --- /dev/null +++ b/packages/starlight/translations/de.json @@ -0,0 +1,20 @@ +{ + "skipLink.label": "Zum Inhalt springen", + "search.label": "Suchen", + "search.shortcutLabel": "(Drücke / zum Suchen)", + "search.cancelLabel": "Abbrechen", + "themeSelect.accessibleLabel": "Farbschema wählen", + "themeSelect.dark": "Dunkel", + "themeSelect.light": "Hell", + "themeSelect.auto": "System", + "languageSelect.accessibleLabel": "Sprache wählen", + "menuButton.accessibleLabel": "Menü", + "sidebarNav.accessibleLabel": "Hauptnavigation", + "tableOfContents.onThisPage": "Auf dieser Seite", + "tableOfContents.overview": "Überblick", + "i18n.untranslatedContent": "Dieser Inhalt ist noch nicht in deiner Sprache verfügbar.", + "page.editLink": "Seite bearbeiten", + "page.lastUpdated": "Zuletzt bearbeitet:", + "page.previousLink": "Vorherige Seite", + "page.nextLink": "Nächste Seite" +} diff --git a/packages/starlight/translations/en.json b/packages/starlight/translations/en.json new file mode 100644 index 00000000..b8a74860 --- /dev/null +++ b/packages/starlight/translations/en.json @@ -0,0 +1,20 @@ +{ + "skipLink.label": "Skip to content", + "search.label": "Search", + "search.shortcutLabel": "(Press / to Search)", + "search.cancelLabel": "Cancel", + "themeSelect.accessibleLabel": "Select theme", + "themeSelect.dark": "Dark", + "themeSelect.light": "Light", + "themeSelect.auto": "Auto", + "languageSelect.accessibleLabel": "Select language", + "menuButton.accessibleLabel": "Menu", + "sidebarNav.accessibleLabel": "Main", + "tableOfContents.onThisPage": "On this page", + "tableOfContents.overview": "Overview", + "i18n.untranslatedContent": "This content is not available in your language yet.", + "page.editLink": "Edit page", + "page.lastUpdated": "Last updated:", + "page.previousLink": "Next", + "page.nextLink": "Previous" +} diff --git a/packages/starlight/translations/es.json b/packages/starlight/translations/es.json new file mode 100644 index 00000000..d92186eb --- /dev/null +++ b/packages/starlight/translations/es.json @@ -0,0 +1,20 @@ +{ + "skipLink.label": "Saltearse al contenido", + "search.label": "Buscar", + "search.shortcutLabel": "(Presiona / para buscar)", + "search.cancelLabel": "Interrumpir", + "themeSelect.accessibleLabel": "Seleccionar tema", + "themeSelect.dark": "Oscuro", + "themeSelect.light": "Claro", + "themeSelect.auto": "Automático", + "languageSelect.accessibleLabel": "Seleccionar idioma", + "menuButton.accessibleLabel": "Menú", + "sidebarNav.accessibleLabel": "Primario", + "tableOfContents.onThisPage": "En esta página", + "tableOfContents.overview": "Sinopsis", + "i18n.untranslatedContent": "Esta página aún no está disponible en tu idioma.", + "page.editLink": "Edita esta página", + "page.lastUpdated": "Última actualización:", + "page.previousLink": "Página anterior", + "page.nextLink": "Siguiente página" +} diff --git a/packages/starlight/translations/index.ts b/packages/starlight/translations/index.ts new file mode 100644 index 00000000..ceff3527 --- /dev/null +++ b/packages/starlight/translations/index.ts @@ -0,0 +1,10 @@ +import { i18nSchema } from '../schemas/i18n'; +import en from './en.json'; +import es from './es.json'; +import de from './de.json'; + +const parse = i18nSchema().required().strict().parse; + +export default Object.fromEntries( + Object.entries({ en, es, de }).map(([key, dict]) => [key, parse(dict)]) +); diff --git a/packages/starlight/utils/slugs.ts b/packages/starlight/utils/slugs.ts index 65b98e63..9fce40e6 100644 --- a/packages/starlight/utils/slugs.ts +++ b/packages/starlight/utils/slugs.ts @@ -32,11 +32,13 @@ export function slugToLocaleData(slug: string): LocaleData { * 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): string { +export function localeToLang(locale: string | undefined): string { const lang = locale ? config.locales?.[locale]?.lang : config.locales?.root?.lang; - return lang || 'en'; + const defaultLang = + config.defaultLocale?.lang || config.defaultLocale?.locale; + return lang || defaultLang || 'en'; } /** diff --git a/packages/starlight/utils/translations.ts b/packages/starlight/utils/translations.ts new file mode 100644 index 00000000..96ef28ab --- /dev/null +++ b/packages/starlight/utils/translations.ts @@ -0,0 +1,58 @@ +import { CollectionEntry, 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'; + +/** All translation data from the i18n collection, keyed by `id`, which matches locale. */ +let userTranslations: Record<string, CollectionEntry<'i18n'>['data']> = {}; +try { + // Load the user’s i18n collection and ignore the error if it doesn’t 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], + 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' + */ +export function useTranslations(locale: string | undefined) { + // TODO: Use better mapping, e.g. so that `en-GB` matches `en`. + const lang = localeToLang(locale); + const dictionary = buildDictionary( + defaults, + builtinTranslations[lang], + userTranslations[lang] + ); + return (key: keyof typeof dictionary) => dictionary[key]; +} + +/** 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; +} diff --git a/packages/starlight/utils/user-config.ts b/packages/starlight/utils/user-config.ts index 0b38dd67..9aea7325 100644 --- a/packages/starlight/utils/user-config.ts +++ b/packages/starlight/utils/user-config.ts @@ -273,7 +273,12 @@ export const StarlightConfigSchema = UserConfigSchema.strict().transform( /** Flag indicating if this site has multiple locales set up. */ isMultilingual: false, /** Full locale object for this site’s default language. */ - defaultLocale: undefined, + defaultLocale: { + label: 'English', + lang: 'en', + dir: 'ltr', + ...locales?.root, + }, locales: undefined, } as const; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bf4f06a..640dd460 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,8 +11,8 @@ importers: specifier: ^2.26.1 version: 2.26.1 astro: - specifier: ^2.4.3 - version: 2.4.3(@types/node@18.15.11) + specifier: ^2.5.0 + version: 2.5.0(@types/node@18.15.11) docs: dependencies: @@ -20,8 +20,8 @@ importers: specifier: workspace:* version: link:../packages/starlight astro: - specifier: ^2.4.3 - version: 2.4.3(@types/node@18.15.11) + specifier: ^2.5.0 + version: 2.5.0(@types/node@18.15.11) devDependencies: '@size-limit/file': specifier: ^8.2.4 @@ -40,7 +40,7 @@ importers: dependencies: '@astrojs/mdx': specifier: ^0.19.1 - version: 0.19.1(astro@2.4.3)(rollup@3.21.5) + version: 0.19.1(astro@2.5.0)(rollup@3.21.5) '@astrojs/sitemap': specifier: ^1.3.1 version: 1.3.1 @@ -85,8 +85,8 @@ importers: specifier: ^18.15.11 version: 18.15.11 astro: - specifier: ^2.4.3 - version: 2.4.3(@types/node@18.15.11) + specifier: ^2.5.0 + version: 2.5.0(@types/node@18.15.11) packages: @@ -118,13 +118,36 @@ packages: vscode-languageserver-types: 3.17.3 vscode-uri: 3.0.7 - /@astrojs/markdown-remark@2.2.0(astro@2.4.3): + /@astrojs/markdown-remark@2.2.0(astro@2.5.0): resolution: {integrity: sha512-4M1+GzQwDqF0KfX9Ahug43b0avorcK+iTapEaVuNnaCUVS6sZKRkztT3g6hmXiFmGHSL8qYaS9IVEmKtP6hYmw==} peerDependencies: astro: ^2.4.0 dependencies: '@astrojs/prism': 2.1.1 - astro: 2.4.3(@types/node@18.15.11) + astro: 2.5.0(@types/node@18.15.11) + github-slugger: 1.5.0 + import-meta-resolve: 2.2.2 + rehype-raw: 6.1.1 + rehype-stringify: 9.0.3 + remark-gfm: 3.0.1 + remark-parse: 10.0.1 + remark-rehype: 10.1.0 + remark-smartypants: 2.0.0 + shiki: 0.14.2 + unified: 10.1.2 + unist-util-visit: 4.1.2 + vfile: 5.3.7 + transitivePeerDependencies: + - supports-color + dev: false + + /@astrojs/markdown-remark@2.2.1(astro@2.5.0): + resolution: {integrity: sha512-VF0HRv4GpC1XEMLnsKf6jth7JSmlt9qpqP0josQgA2eSpCIAC/Et+y94mgdBIZVBYH/yFnMoIxgKVe93xfO2GA==} + peerDependencies: + astro: ^2.5.0 + dependencies: + '@astrojs/prism': 2.1.2 + astro: 2.5.0(@types/node@18.15.11) github-slugger: 1.5.0 import-meta-resolve: 2.2.2 rehype-raw: 6.1.1 @@ -140,11 +163,11 @@ packages: transitivePeerDependencies: - supports-color - /@astrojs/mdx@0.19.1(astro@2.4.3)(rollup@3.21.5): + /@astrojs/mdx@0.19.1(astro@2.5.0)(rollup@3.21.5): resolution: {integrity: sha512-9GNNZbGT+lGvbRkQK/NaEJcnjj1T94/ne0KwPjJgNCBQrJuskX5IW1hKiE5bRSOFvkAOrBGneYKg0GXYArBOQQ==} engines: {node: '>=16.12.0'} dependencies: - '@astrojs/markdown-remark': 2.2.0(astro@2.4.3) + '@astrojs/markdown-remark': 2.2.0(astro@2.5.0) '@astrojs/prism': 2.1.1 '@mdx-js/mdx': 2.3.0 '@mdx-js/rollup': 2.3.0(rollup@3.21.5) @@ -173,6 +196,13 @@ packages: engines: {node: '>=16.12.0'} dependencies: prismjs: 1.29.0 + dev: false + + /@astrojs/prism@2.1.2: + resolution: {integrity: sha512-3antim1gb34689GHRQFJ88JEo93HuZKQBnmxDT5W/nxiNz1p/iRxnCTEhIbJhqMOTRbbo5h2ldm5qSxx+TMFQA==} + engines: {node: '>=16.12.0'} + dependencies: + prismjs: 1.29.0 /@astrojs/sitemap@1.3.1: resolution: {integrity: sha512-4ZBug4ml+2Nl5/Uh4VSja8Kij/DU7/RaNMciXCNm1EzQkP/jm+nqMG1liDDcQK5zXPAoLeaat06IbhNlruvQjg==} @@ -1196,6 +1226,9 @@ packages: dependencies: sprintf-js: 1.0.3 + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + /array-buffer-byte-length@1.0.0: resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} dependencies: @@ -1243,8 +1276,8 @@ packages: hasBin: true dev: false - /astro@2.4.3(@types/node@18.15.11): - resolution: {integrity: sha512-WU7sMkgFNQs4WZzEmpjOYZthcT8+LSmwIR0GvWzVYlb+dIMfFCQyg99LNHdhg/XZKi08ztaHmRf4ZBjJvZHsgA==} + /astro@2.5.0(@types/node@18.15.11): + resolution: {integrity: sha512-dZZuK2vEpfinbVALthUW31NOVUFPobgyi0+2PR3FH3diy6X9HBw1PLbS5wRsWOKaEXRbzxBkXc39Rbm0yRzKaA==} engines: {node: '>=16.12.0', npm: '>=6.14.0'} hasBin: true peerDependencies: @@ -1255,7 +1288,7 @@ packages: dependencies: '@astrojs/compiler': 1.4.1 '@astrojs/language-server': 1.0.4 - '@astrojs/markdown-remark': 2.2.0(astro@2.4.3) + '@astrojs/markdown-remark': 2.2.1(astro@2.5.0) '@astrojs/telemetry': 2.1.1 '@astrojs/webapi': 2.1.1 '@babel/core': 7.21.3 @@ -1277,12 +1310,14 @@ packages: devalue: 4.3.0 diff: 5.1.0 es-module-lexer: 1.2.0 + esbuild: 0.17.18 estree-walker: 3.0.0 execa: 6.1.0 fast-glob: 3.2.12 github-slugger: 2.0.0 gray-matter: 4.0.3 html-escaper: 3.0.3 + js-yaml: 4.1.0 kleur: 4.1.5 magic-string: 0.27.0 mime: 3.0.0 @@ -2930,6 +2965,12 @@ packages: argparse: 1.0.10 esprima: 4.0.1 + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + /jsesc@2.5.2: resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} engines: {node: '>=4'} |