@astrojs/starlight 0.21.4 → 0.22.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +69 -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 +10 -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/integrations/shared/pathToLocale.ts +2 -1
  24. package/package.json +13 -3
  25. package/playwright.config.ts +15 -0
  26. package/schema.ts +16 -3
  27. package/schemas/components.ts +9 -0
  28. package/schemas/i18n.ts +18 -5
  29. package/schemas/site-title.ts +22 -0
  30. package/schemas/social.ts +2 -0
  31. package/style/props.css +1 -1
  32. package/translations/ar.json +1 -0
  33. package/translations/cs.json +1 -0
  34. package/translations/da.json +1 -0
  35. package/translations/de.json +1 -0
  36. package/translations/en.json +1 -0
  37. package/translations/es.json +1 -0
  38. package/translations/fa.json +8 -7
  39. package/translations/fr.json +1 -0
  40. package/translations/gl.json +1 -0
  41. package/translations/he.json +1 -0
  42. package/translations/hi.json +1 -0
  43. package/translations/id.json +1 -0
  44. package/translations/it.json +1 -0
  45. package/translations/ja.json +1 -0
  46. package/translations/ko.json +1 -0
  47. package/translations/nb.json +1 -0
  48. package/translations/nl.json +1 -0
  49. package/translations/pl.json +1 -0
  50. package/translations/pt.json +1 -0
  51. package/translations/ro.json +1 -0
  52. package/translations/ru.json +5 -1
  53. package/translations/sv.json +1 -0
  54. package/translations/tr.json +1 -0
  55. package/translations/uk.json +1 -0
  56. package/translations/vi.json +1 -0
  57. package/translations/zh-CN.json +1 -0
  58. package/translations/zh-TW.json +1 -0
  59. package/user-components/Tabs.astro +48 -7
  60. package/user-components/file-tree-icons.ts +1 -0
  61. package/user-components/rehype-file-tree.ts +1 -1
  62. package/utils/createTranslationSystem.ts +10 -8
  63. package/utils/route-data.ts +14 -1
  64. package/utils/routing.ts +9 -6
  65. package/utils/translations.ts +6 -2
  66. package/utils/user-config.ts +33 -19
  67. package/virtual.d.ts +4 -0
@@ -18,6 +18,7 @@
18
18
  "page.lastUpdated": "Cập nhật lần cuối:",
19
19
  "page.previousLink": "Tiếp",
20
20
  "page.nextLink": "Trước",
21
+ "page.draft": "This content is a draft and will not be included in production builds.",
21
22
  "404.text": "Không tìm thấy trang. Kiểm tra URL hoặc thử sử dụng thanh tìm kiếm.",
22
23
  "aside.note": "Ghi chú",
23
24
  "aside.tip": "Mẹo",
@@ -18,6 +18,7 @@
18
18
  "page.lastUpdated": "最近更新:",
19
19
  "page.previousLink": "上一页",
20
20
  "page.nextLink": "下一页",
21
+ "page.draft": "此内容为草稿,不会包含在生产版本中。",
21
22
  "404.text": "页面未找到。检查 URL 或尝试使用搜索栏。",
22
23
  "aside.note": "注意",
23
24
  "aside.tip": "提示",
@@ -18,6 +18,7 @@
18
18
  "page.lastUpdated": "最後更新於:",
19
19
  "page.previousLink": "前一則",
20
20
  "page.nextLink": "下一則",
21
+ "page.draft": "This content is a draft and will not be included in production builds.",
21
22
  "404.text": "找不到頁面。請檢查網址或改用搜尋功能。",
22
23
  "aside.note": "注意",
23
24
  "aside.tip": "提示",
@@ -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">
@@ -17,7 +22,7 @@ const { html, panels } = processPanels(panelHtml);
17
22
  role="tab"
18
23
  href={'#' + panelId}
19
24
  id={tabId}
20
- aria-selected={idx === 0 && 'true'}
25
+ aria-selected={idx === 0 ? 'true' : 'false'}
21
26
  tabindex={idx !== 0 ? -1 : 0}
22
27
  >
23
28
  {icon && <Icon name={icon} />}
@@ -61,7 +66,7 @@ const { html, panels } = processPanels(panelHtml);
61
66
  color: var(--sl-color-gray-3);
62
67
  outline-offset: var(--sl-outline-offset-inside);
63
68
  }
64
- .tab [role='tab'][aria-selected] {
69
+ .tab [role='tab'][aria-selected='true'] {
65
70
  color: var(--sl-color-white);
66
71
  border-color: var(--sl-color-text-accent);
67
72
  font-weight: 600;
@@ -74,20 +79,31 @@ 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
88
104
  tab.addEventListener('click', (e) => {
89
105
  e.preventDefault();
90
- const currentTab = tablist.querySelector('[aria-selected]');
106
+ const currentTab = tablist.querySelector('[aria-selected="true"]');
91
107
  if (e.currentTarget !== currentTab) {
92
108
  this.switchTab(e.currentTarget as HTMLAnchorElement, i);
93
109
  }
@@ -117,12 +133,17 @@ 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
- tab.removeAttribute('aria-selected');
146
+ tab.setAttribute('aria-selected', 'false');
126
147
  tab.setAttribute('tabindex', '-1');
127
148
  });
128
149
  this.panels.forEach((oldPanel) => {
@@ -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
 
@@ -113,6 +113,7 @@ export const definitions: Definitions = {
113
113
  extensions: {
114
114
  '.astro': 'astro',
115
115
  '.mdx': 'mdx',
116
+ '.pkl': 'pkl',
116
117
  '.bsl': 'seti:bsl',
117
118
  '.mdo': 'seti:mdo',
118
119
  '.cls': 'seti:salesforce',
@@ -168,7 +168,7 @@ function getFileIcon(fileName: string) {
168
168
 
169
169
  /** Return the icon name for a file based on its file name. */
170
170
  function getFileIconName(fileName: string) {
171
- let icon = definitions.files[fileName];
171
+ let icon: string | undefined = definitions.files[fileName];
172
172
  if (icon) return icon;
173
173
  icon = getFileIconTypeFromExtension(fileName);
174
174
  if (icon) return icon;
@@ -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
+ 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) => ({
@@ -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,17 +210,21 @@ const UserConfigSchema = z.object({
211
210
  });
212
211
 
213
212
  export const StarlightConfigSchema = UserConfigSchema.strict().transform(
214
- ({ locales, defaultLocale, ...config }, ctx) => {
215
- if (locales !== undefined && Object.keys(locales).length > 1) {
216
- // This is a multilingual site (more than one locale configured).
213
+ ({ title, locales, defaultLocale, ...config }, ctx) => {
214
+ const configuredLocales = Object.keys(locales ?? {});
215
+
216
+ // This is a multilingual site (more than one locale configured) or a monolingual site with
217
+ // only one locale configured (not a root locale).
218
+ // Monolingual sites with only one non-root locale needs their configuration to be defined in
219
+ // `config.locales` so that slugs can be correctly generated by taking into consideration the
220
+ // base path at which a language is served which is the key of the `config.locales` object.
221
+ if (locales !== undefined && configuredLocales.length >= 1) {
217
222
  // Make sure we can find the default locale and if not, help the user set it.
218
223
  // We treat the root locale as the default if present and no explicit default is set.
219
224
  const defaultLocaleConfig = locales[defaultLocale || 'root'];
220
225
 
221
226
  if (!defaultLocaleConfig) {
222
- const availableLocales = Object.keys(locales)
223
- .map((l) => `"${l}"`)
224
- .join(', ');
227
+ const availableLocales = configuredLocales.map((l) => `"${l}"`).join(', ');
225
228
  ctx.addIssue({
226
229
  code: 'custom',
227
230
  message:
@@ -232,29 +235,40 @@ export const StarlightConfigSchema = UserConfigSchema.strict().transform(
232
235
  return z.NEVER;
233
236
  }
234
237
 
238
+ // Transform the title
239
+ const TitleSchema = TitleTransformConfigSchema(defaultLocaleConfig.lang as string);
240
+ const parsedTitle = TitleSchema.parse(title);
241
+
235
242
  return {
236
243
  ...config,
244
+ title: parsedTitle,
237
245
  /** Flag indicating if this site has multiple locales set up. */
238
- isMultilingual: true,
246
+ isMultilingual: configuredLocales.length > 1,
239
247
  /** Full locale object for this site’s default language. */
240
248
  defaultLocale: { ...defaultLocaleConfig, locale: defaultLocale },
241
249
  locales,
242
250
  } as const;
243
251
  }
244
252
 
245
- // This is a monolingual site, so things are pretty simple.
253
+ // This is a monolingual site with no locales configured or only a root locale, so things are
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);
246
266
  return {
247
267
  ...config,
268
+ title: parsedTitle,
248
269
  /** Flag indicating if this site has multiple locales set up. */
249
270
  isMultilingual: false,
250
- /** Full locale object for this site’s default language. */
251
- defaultLocale: {
252
- label: 'English',
253
- lang: 'en',
254
- dir: 'ltr',
255
- locale: undefined,
256
- ...locales?.root,
257
- },
271
+ defaultLocale: defaultLocaleConfig,
258
272
  locales: undefined,
259
273
  } as const;
260
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;