@astrojs/starlight 0.21.5 → 0.22.1

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.
Files changed (65) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/__e2e__/fixtures/basics/astro.config.mjs +10 -0
  3. package/__e2e__/fixtures/basics/node_modules/.bin/astro +17 -0
  4. package/__e2e__/fixtures/basics/package.json +9 -0
  5. package/__e2e__/fixtures/basics/src/content/config.ts +6 -0
  6. package/__e2e__/fixtures/basics/src/content/docs/tabs-variable-height.mdx +151 -0
  7. package/__e2e__/fixtures/basics/src/content/docs/tabs.mdx +37 -0
  8. package/__e2e__/fixtures/basics/src/env.d.ts +2 -0
  9. package/__e2e__/tabs.test.ts +122 -0
  10. package/__e2e__/test-utils.ts +53 -0
  11. package/components/ContentNotice.astro +31 -0
  12. package/components/DraftContentNotice.astro +8 -0
  13. package/components/FallbackContentNotice.astro +2 -21
  14. package/components/Head.astro +3 -3
  15. package/components/Icons.ts +9 -0
  16. package/components/Page.astro +2 -0
  17. package/components/SiteTitle.astro +2 -1
  18. package/components/ThemeSelect.astro +36 -48
  19. package/global.d.ts +1 -1
  20. package/index.astro +1 -1
  21. package/integrations/expressive-code/hast.d.ts +5 -0
  22. package/integrations/expressive-code/hast.mjs +9 -0
  23. package/package.json +13 -3
  24. package/playwright.config.ts +15 -0
  25. package/schema.ts +16 -3
  26. package/schemas/components.ts +9 -0
  27. package/schemas/i18n.ts +18 -5
  28. package/schemas/site-title.ts +22 -0
  29. package/schemas/social.ts +2 -0
  30. package/style/props.css +1 -1
  31. package/translations/ar.json +1 -0
  32. package/translations/cs.json +1 -0
  33. package/translations/da.json +1 -0
  34. package/translations/de.json +1 -0
  35. package/translations/en.json +1 -0
  36. package/translations/es.json +1 -0
  37. package/translations/fa.json +8 -7
  38. package/translations/fr.json +1 -0
  39. package/translations/gl.json +1 -0
  40. package/translations/he.json +1 -0
  41. package/translations/hi.json +1 -0
  42. package/translations/id.json +1 -0
  43. package/translations/it.json +1 -0
  44. package/translations/ja.json +1 -0
  45. package/translations/ko.json +1 -0
  46. package/translations/nb.json +1 -0
  47. package/translations/nl.json +1 -0
  48. package/translations/pl.json +1 -0
  49. package/translations/pt.json +1 -0
  50. package/translations/ro.json +1 -0
  51. package/translations/ru.json +5 -1
  52. package/translations/sv.json +1 -0
  53. package/translations/tr.json +1 -0
  54. package/translations/uk.json +1 -0
  55. package/translations/vi.json +1 -0
  56. package/translations/zh-CN.json +1 -0
  57. package/translations/zh-TW.json +1 -0
  58. package/user-components/Tabs.astro +44 -3
  59. package/utils/createTranslationSystem.ts +10 -8
  60. package/utils/route-data.ts +14 -1
  61. package/utils/routing.ts +9 -6
  62. package/utils/starlight-page.ts +2 -1
  63. package/utils/translations.ts +6 -2
  64. package/utils/user-config.ts +21 -12
  65. package/virtual.d.ts +4 -0
@@ -2,11 +2,16 @@
2
2
  import Icon from './Icon.astro';
3
3
  import { processPanels } from './rehype-tabs';
4
4
 
5
+ interface Props {
6
+ syncKey?: string;
7
+ }
8
+
9
+ const { syncKey } = Astro.props;
5
10
  const panelHtml = await Astro.slots.render('default');
6
11
  const { html, panels } = processPanels(panelHtml);
7
12
  ---
8
13
 
9
- <starlight-tabs>
14
+ <starlight-tabs data-sync-key={syncKey}>
10
15
  {
11
16
  panels && (
12
17
  <div class="tablist-wrapper not-content">
@@ -74,14 +79,25 @@ const { html, panels } = processPanels(panelHtml);
74
79
 
75
80
  <script>
76
81
  class StarlightTabs extends HTMLElement {
82
+ // A map of sync keys to all tabs that are synced to that key.
83
+ static #syncedTabs = new Map<string, StarlightTabs[]>();
84
+
77
85
  tabs: HTMLAnchorElement[];
78
86
  panels: HTMLElement[];
87
+ #syncKey: string | undefined;
79
88
 
80
89
  constructor() {
81
90
  super();
82
91
  const tablist = this.querySelector<HTMLUListElement>('[role="tablist"]')!;
83
92
  this.tabs = [...tablist.querySelectorAll<HTMLAnchorElement>('[role="tab"]')];
84
93
  this.panels = [...this.querySelectorAll<HTMLElement>(':scope > [role="tabpanel"]')];
94
+ this.#syncKey = this.dataset.syncKey;
95
+
96
+ if (this.#syncKey) {
97
+ const syncedTabs = StarlightTabs.#syncedTabs.get(this.#syncKey) ?? [];
98
+ syncedTabs.push(this);
99
+ StarlightTabs.#syncedTabs.set(this.#syncKey, syncedTabs);
100
+ }
85
101
 
86
102
  this.tabs.forEach((tab, i) => {
87
103
  // Handle clicks for mouse users
@@ -117,9 +133,14 @@ const { html, panels } = processPanels(panelHtml);
117
133
  });
118
134
  }
119
135
 
120
- switchTab(newTab: HTMLAnchorElement | null | undefined, index: number) {
136
+ switchTab(newTab: HTMLAnchorElement | null | undefined, index: number, shouldSync = true) {
121
137
  if (!newTab) return;
122
138
 
139
+ // If tabs should be synced, we store the current position so we can restore it after
140
+ // switching tabs to prevent the page from jumping when the new tab content is of a different
141
+ // height than the previous tab.
142
+ const previousTabsOffset = shouldSync ? this.getBoundingClientRect().top : 0;
143
+
123
144
  // Mark all tabs as unselected and hide all tab panels.
124
145
  this.tabs.forEach((tab) => {
125
146
  tab.setAttribute('aria-selected', 'false');
@@ -135,7 +156,27 @@ const { html, panels } = processPanels(panelHtml);
135
156
  // Restore active tab to the default tab order.
136
157
  newTab.removeAttribute('tabindex');
137
158
  newTab.setAttribute('aria-selected', 'true');
138
- newTab.focus();
159
+ if (shouldSync) {
160
+ newTab.focus();
161
+ StarlightTabs.#syncTabs(this, newTab.textContent);
162
+ window.scrollTo({
163
+ top: window.scrollY + (this.getBoundingClientRect().top - previousTabsOffset),
164
+ });
165
+ }
166
+ }
167
+
168
+ static #syncTabs(emitter: StarlightTabs, label: string | null) {
169
+ const syncKey = emitter.#syncKey;
170
+ if (!syncKey || !label) return;
171
+ const syncedTabs = StarlightTabs.#syncedTabs.get(syncKey);
172
+ if (!syncedTabs) return;
173
+
174
+ for (const receiver of syncedTabs) {
175
+ if (receiver === emitter) continue;
176
+ const labelIndex = receiver.tabs.findIndex((tab) => tab.textContent === label);
177
+ if (labelIndex === -1) continue;
178
+ receiver.switchTab(receiver.tabs[labelIndex], labelIndex, false);
179
+ }
139
180
  }
140
181
  }
141
182
 
@@ -2,8 +2,8 @@ import type { i18nSchemaOutput } from '../schemas/i18n';
2
2
  import builtinTranslations from '../translations/index';
3
3
  import type { StarlightConfig } from './user-config';
4
4
 
5
- export function createTranslationSystem(
6
- userTranslations: Record<string, i18nSchemaOutput>,
5
+ export function createTranslationSystem<T extends i18nSchemaOutput>(
6
+ userTranslations: Record<string, T>,
7
7
  config: Pick<StarlightConfig, 'defaultLocale' | 'locales'>
8
8
  ) {
9
9
  /** User-configured default locale. */
@@ -67,18 +67,20 @@ function localeToLang(
67
67
  return lang || defaultLang || 'en';
68
68
  }
69
69
 
70
+ type BuiltInStrings = (typeof builtinTranslations)['en'];
71
+
70
72
  /** Build a dictionary by layering preferred translation sources. */
71
- function buildDictionary(
72
- base: (typeof builtinTranslations)[string],
73
- ...dictionaries: (i18nSchemaOutput | undefined)[]
74
- ) {
73
+ function buildDictionary<T extends Record<string, string | undefined>>(
74
+ base: BuiltInStrings,
75
+ ...dictionaries: (T | BuiltInStrings | undefined)[]
76
+ ): BuiltInStrings & T {
75
77
  const dictionary = { ...base };
76
78
  // Iterate over alternate dictionaries to avoid overwriting preceding values with `undefined`.
77
79
  for (const dict of dictionaries) {
78
80
  for (const key in dict) {
79
- const value = dict[key];
81
+ const value = dict[key as keyof typeof dict];
80
82
  if (value) dictionary[key as keyof typeof dictionary] = value;
81
83
  }
82
84
  }
83
- return dictionary;
85
+ return dictionary as BuiltInStrings & T;
84
86
  }
@@ -15,6 +15,8 @@ export interface PageProps extends Route {
15
15
  }
16
16
 
17
17
  export interface StarlightRouteData extends Route {
18
+ /** Title of the site. */
19
+ siteTitle: string;
18
20
  /** Array of Markdown headings extracted from the current page. */
19
21
  headings: MarkdownHeading[];
20
22
  /** Site navigation sidebar entries for this page. */
@@ -40,10 +42,12 @@ export function generateRouteData({
40
42
  props: PageProps;
41
43
  url: URL;
42
44
  }): StarlightRouteData {
43
- const { entry, locale } = props;
45
+ const { entry, locale, lang } = props;
44
46
  const sidebar = getSidebar(url.pathname, locale);
47
+ const siteTitle = getSiteTitle(lang);
45
48
  return {
46
49
  ...props,
50
+ siteTitle,
47
51
  sidebar,
48
52
  hasSidebar: entry.data.template !== 'splash',
49
53
  pagination: getPrevNextLinks(sidebar, config.pagination, entry.data),
@@ -105,3 +109,12 @@ function getEditUrl({ entry, id, isFallback }: PageProps): URL | undefined {
105
109
  }
106
110
  return url ? new URL(url) : undefined;
107
111
  }
112
+
113
+ /** Get the site title for a given language. **/
114
+ export function getSiteTitle(lang: string): string {
115
+ const defaultLang = config.defaultLocale.lang as string;
116
+ if (lang && config.title[lang]) {
117
+ return config.title[lang] as string;
118
+ }
119
+ return config.title[defaultLang] as string;
120
+ }
package/utils/routing.ts CHANGED
@@ -45,12 +45,15 @@ interface Path extends GetStaticPathsItem {
45
45
  const normalizeIndexSlug = (slug: string) => (slug === 'index' ? '' : slug);
46
46
 
47
47
  /** All entries in the docs content collection. */
48
- const docs: StarlightDocsEntry[] = ((await getCollection('docs')) ?? []).map(
49
- ({ slug, ...entry }) => ({
50
- ...entry,
51
- slug: normalizeIndexSlug(slug),
52
- })
53
- );
48
+ const docs: StarlightDocsEntry[] = (
49
+ (await getCollection('docs', ({ data }) => {
50
+ // In production, filter out drafts.
51
+ return import.meta.env.MODE !== 'production' || data.draft === false;
52
+ })) ?? []
53
+ ).map(({ slug, ...entry }) => ({
54
+ ...entry,
55
+ slug: normalizeIndexSlug(slug),
56
+ }));
54
57
 
55
58
  function getRoutes(): Route[] {
56
59
  const routes: Route[] = docs.map((entry) => ({
@@ -3,7 +3,7 @@ import { type ContentConfig, type SchemaContext } from 'astro:content';
3
3
  import config from 'virtual:starlight/user-config';
4
4
  import { parseWithFriendlyErrors } from './error-map';
5
5
  import { stripLeadingAndTrailingSlashes } from './path';
6
- import { getToC, type PageProps, type StarlightRouteData } from './route-data';
6
+ import { getSiteTitle, getToC, type PageProps, type StarlightRouteData } from './route-data';
7
7
  import type { StarlightDocsEntry } from './routing';
8
8
  import { slugToLocaleData, urlToSlug } from './slugs';
9
9
  import { getPrevNextLinks, getSidebar } from './navigation';
@@ -223,6 +223,7 @@ export async function generateStarlightPageRouteData({
223
223
  lastUpdated,
224
224
  pagination: getPrevNextLinks(sidebar, config.pagination, entry.data),
225
225
  sidebar,
226
+ siteTitle: getSiteTitle(localeData.lang),
226
227
  slug,
227
228
  toc: getToC({
228
229
  ...routeProps,
@@ -1,11 +1,15 @@
1
- import { getCollection } from 'astro:content';
1
+ import { getCollection, type CollectionEntry, type DataCollectionKey } from 'astro:content';
2
2
  import config from 'virtual:starlight/user-config';
3
3
  import type { i18nSchemaOutput } from '../schemas/i18n';
4
4
  import { createTranslationSystem } from './createTranslationSystem';
5
5
 
6
+ type UserI18nSchema = 'i18n' extends DataCollectionKey
7
+ ? CollectionEntry<'i18n'>['data']
8
+ : i18nSchemaOutput;
9
+
6
10
  /** Get all translation data from the i18n collection, keyed by `id`, which matches locale. */
7
11
  async function loadTranslations() {
8
- let userTranslations: Record<string, i18nSchemaOutput> = {};
12
+ let userTranslations: Record<string, UserI18nSchema> = {};
9
13
  // Briefly override `console.warn()` to silence logging when a project has no i18n collection.
10
14
  const warn = console.warn;
11
15
  console.warn = () => {};
@@ -8,6 +8,7 @@ import { LogoConfigSchema } from '../schemas/logo';
8
8
  import { SidebarItemSchema } from '../schemas/sidebar';
9
9
  import { SocialLinksSchema } from '../schemas/social';
10
10
  import { TableOfContentsSchema } from '../schemas/tableOfContents';
11
+ import { TitleConfigSchema, TitleTransformConfigSchema } from '../schemas/site-title';
11
12
 
12
13
  const LocaleSchema = z.object({
13
14
  /** The label for this language to show in UI, e.g. `"English"`, `"العربية"`, or `"简体中文"`. */
@@ -33,9 +34,7 @@ const LocaleSchema = z.object({
33
34
 
34
35
  const UserConfigSchema = z.object({
35
36
  /** Title for your website. Will be used in metadata and as browser tab title. */
36
- title: z
37
- .string()
38
- .describe('Title for your website. Will be used in metadata and as browser tab title.'),
37
+ title: TitleConfigSchema(),
39
38
 
40
39
  /** Description metadata for your website. Can be used in page metadata. */
41
40
  description: z
@@ -211,7 +210,7 @@ const UserConfigSchema = z.object({
211
210
  });
212
211
 
213
212
  export const StarlightConfigSchema = UserConfigSchema.strict().transform(
214
- ({ locales, defaultLocale, ...config }, ctx) => {
213
+ ({ title, locales, defaultLocale, ...config }, ctx) => {
215
214
  const configuredLocales = Object.keys(locales ?? {});
216
215
 
217
216
  // 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(
236
235
  return z.NEVER;
237
236
  }
238
237
 
238
+ // Transform the title
239
+ const TitleSchema = TitleTransformConfigSchema(defaultLocaleConfig.lang as string);
240
+ const parsedTitle = TitleSchema.parse(title);
241
+
239
242
  return {
240
243
  ...config,
244
+ title: parsedTitle,
241
245
  /** Flag indicating if this site has multiple locales set up. */
242
246
  isMultilingual: configuredLocales.length > 1,
243
247
  /** Full locale object for this site’s default language. */
@@ -248,18 +252,23 @@ export const StarlightConfigSchema = UserConfigSchema.strict().transform(
248
252
 
249
253
  // This is a monolingual site with no locales configured or only a root locale, so things are
250
254
  // pretty simple.
255
+ /** Full locale object for this site’s default language. */
256
+ const defaultLocaleConfig = {
257
+ label: 'English',
258
+ lang: 'en',
259
+ dir: 'ltr',
260
+ locale: undefined,
261
+ ...locales?.root,
262
+ };
263
+ /** Transform the title */
264
+ const TitleSchema = TitleTransformConfigSchema(defaultLocaleConfig.lang);
265
+ const parsedTitle = TitleSchema.parse(title);
251
266
  return {
252
267
  ...config,
268
+ title: parsedTitle,
253
269
  /** Flag indicating if this site has multiple locales set up. */
254
270
  isMultilingual: false,
255
- /** Full locale object for this site’s default language. */
256
- defaultLocale: {
257
- label: 'English',
258
- lang: 'en',
259
- dir: 'ltr',
260
- locale: undefined,
261
- ...locales?.root,
262
- },
271
+ defaultLocale: defaultLocaleConfig,
263
272
  locales: undefined,
264
273
  } as const;
265
274
  }
package/virtual.d.ts CHANGED
@@ -44,6 +44,10 @@ declare module 'virtual:starlight/components/FallbackContentNotice' {
44
44
  const FallbackContentNotice: typeof import('./components/FallbackContentNotice.astro').default;
45
45
  export default FallbackContentNotice;
46
46
  }
47
+ declare module 'virtual:starlight/components/DraftContentNotice' {
48
+ const DraftContentNotice: typeof import('./components/DraftContentNotice.astro').default;
49
+ export default DraftContentNotice;
50
+ }
47
51
 
48
52
  declare module 'virtual:starlight/components/Footer' {
49
53
  const Footer: typeof import('./components/Footer.astro').default;