@astrojs/starlight 0.25.4 → 0.26.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,107 @@
1
1
  # @astrojs/starlight
2
2
 
3
+ ## 0.26.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#1784](https://github.com/withastro/starlight/pull/1784) [`68f56a7`](https://github.com/withastro/starlight/commit/68f56a7ffd314b760443a057db9ed1a982dbe191) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Adds `<LinkButton>` component for visually distinct and emphasized call to action links
8
+
9
+ - [#2150](https://github.com/withastro/starlight/pull/2150) [`9368494`](https://github.com/withastro/starlight/commit/9368494210dbcd80ada5b410340814fe36c4eb6c) Thanks [@delucis](https://github.com/delucis)! - Adds state persistence across page navigations to the main site sidebar
10
+
11
+ - [#2087](https://github.com/withastro/starlight/pull/2087) [`caa84ea`](https://github.com/withastro/starlight/commit/caa84eaa7dc653d27d539fd3a93df346a9f0f149) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Adds persistence to synced `<Tabs>` so that a user's choices are reflected across page navigations.
12
+
13
+ - [#2051](https://github.com/withastro/starlight/pull/2051) [`ec3b579`](https://github.com/withastro/starlight/commit/ec3b5794cac55a5755620fa5e205f0d54c9e343b) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Adds a guideline to the last step of the `<Steps>` component.
14
+
15
+ If you want to preserve the previous behaviour and hide the guideline on final steps, you can add the following custom CSS to your site:
16
+
17
+ ```css
18
+ /* Hide the guideline for the final step in <Steps> lists. */
19
+ .sl-steps > li:last-of-type::after {
20
+ background: transparent;
21
+ }
22
+ ```
23
+
24
+ - [#1784](https://github.com/withastro/starlight/pull/1784) [`68f56a7`](https://github.com/withastro/starlight/commit/68f56a7ffd314b760443a057db9ed1a982dbe191) Thanks [@HiDeoo](https://github.com/HiDeoo)! - Changes the hero component action button default [variant](https://starlight.astro.build/reference/frontmatter/#heroconfig) from `minimal` to `primary`.
25
+
26
+ ⚠️ **BREAKING CHANGE:** If you want to preserve the previous appearance, hero component action buttons previously declared without a `variant` will need to be updated to include the `variant` property with the value `minimal`.
27
+
28
+ ```diff
29
+ hero:
30
+ actions:
31
+ - text: View on GitHub
32
+ link: https://github.com/astronaut/my-project
33
+ icon: external
34
+ + variant: minimal
35
+ ```
36
+
37
+ - [#2168](https://github.com/withastro/starlight/pull/2168) [`e044fee`](https://github.com/withastro/starlight/commit/e044feeae9a336a87db526107e5772b54ddc567f) Thanks [@HiDeoo](https://github.com/HiDeoo)! - ⚠️ **BREAKING CHANGE:** Updates the `<StarlightPage />` component `sidebar` prop to accept an array of [`SidebarItem`](https://starlight.astro.build/reference/configuration/#sidebaritem)s like the main Starlight `sidebar` configuration in `astro.config.mjs`.
38
+
39
+ This change simplifies the definition of sidebar items in the `<StarlightPage />` component, allows for shared sidebar configuration between the global `sidebar` option and `<StarlightPage />` component, and also enables the usage of autogenerated sidebar groups with the `<StarlightPage />` component.
40
+ If you are using the `<StarlightPage />` component with a custom `sidebar` configuration, you will need to update the `sidebar` prop to an array of [`SidebarItem`](https://starlight.astro.build/reference/configuration/#sidebaritem) objects.
41
+
42
+ For example, the following custom page with a custom `sidebar` configuration defines a “Resources” group with a “New” badge, a link to the “Showcase” page which is part of the `docs` content collection, and a link to the Starlight website:
43
+
44
+ ```astro
45
+ ---
46
+ // src/pages/custom-page/example.astro
47
+ ---
48
+
49
+ <StarlightPage
50
+ frontmatter={{ title: 'My custom page' }}
51
+ sidebar={[
52
+ {
53
+ type: 'group',
54
+ label: 'Resources',
55
+ badge: { text: 'New' },
56
+ items: [
57
+ { type: 'link', label: 'Showcase', href: '/showcase/' },
58
+ {
59
+ type: 'link',
60
+ label: 'Starlight',
61
+ href: 'https://starlight.astro.build/',
62
+ },
63
+ ],
64
+ },
65
+ ]}
66
+ >
67
+ <p>This is a custom page with a custom component.</p>
68
+ </StarlightPage>
69
+ ```
70
+
71
+ This configuration will now need to be updated to the following:
72
+
73
+ ```astro
74
+ ---
75
+ // src/pages/custom-page/example.astro
76
+ ---
77
+
78
+ <StarlightPage
79
+ frontmatter={{ title: 'My custom page' }}
80
+ sidebar={[
81
+ {
82
+ label: 'Resources',
83
+ badge: { text: 'New' },
84
+ items: [
85
+ 'showcase',
86
+ { label: 'Starlight', link: 'https://starlight.astro.build/' },
87
+ ],
88
+ },
89
+ ]}
90
+ >
91
+ <p>This is a custom page with a custom component.</p>
92
+ </StarlightPage>
93
+ ```
94
+
95
+ See the [“Sidebar Navigation”](https://starlight.astro.build/guides/sidebar/) guide to learn more about the available options for customizing the sidebar.
96
+
97
+ ## 0.25.5
98
+
99
+ ### Patch Changes
100
+
101
+ - [#2171](https://github.com/withastro/starlight/pull/2171) [`c8258d7`](https://github.com/withastro/starlight/commit/c8258d7ef9264a0f85710d463a83d16013dc1934) Thanks [@delucis](https://github.com/delucis)! - Improves build performance slightly for bigger sites
102
+
103
+ - [#2199](https://github.com/withastro/starlight/pull/2199) [`91557fd`](https://github.com/withastro/starlight/commit/91557fd73a43ab596f0fefb7c9aa0218b9a0b208) Thanks [@connorjs](https://github.com/connorjs)! - Adds Azure DevOps (`azureDevOps`) icon for use in social links.
104
+
3
105
  ## 0.25.4
4
106
 
5
107
  ### Patch Changes
@@ -0,0 +1,21 @@
1
+ ---
2
+ title: Tabs unsynced
3
+ ---
4
+
5
+ import { Tabs, TabItem } from '@astrojs/starlight/components';
6
+
7
+ A basic set of tabs.
8
+
9
+ <Tabs>
10
+ <TabItem label="npm">npm command</TabItem>
11
+ <TabItem label="pnpm">pnpm command</TabItem>
12
+ <TabItem label="yarn">yarn command</TabItem>
13
+ </Tabs>
14
+
15
+ Another basic set of tabs.
16
+
17
+ <Tabs>
18
+ <TabItem label="one">tab 1</TabItem>
19
+ <TabItem label="two">tab 2</TabItem>
20
+ <TabItem label="three">tab 3</TabItem>
21
+ </Tabs>
@@ -52,3 +52,19 @@ Another set of tabs using the `pkg` sync key and using icons.
52
52
  another yarn command
53
53
  </TabItem>
54
54
  </Tabs>
55
+
56
+ A set of tabs using the `os` sync key.
57
+
58
+ <Tabs syncKey="os">
59
+ <TabItem label="macos">macOS</TabItem>
60
+ <TabItem label="windows">Windows</TabItem>
61
+ <TabItem label="linux">GNU/Linux</TabItem>
62
+ </Tabs>
63
+
64
+ Another set of tabs using the `os` sync key.
65
+
66
+ <Tabs syncKey="os">
67
+ <TabItem label="macos">ls</TabItem>
68
+ <TabItem label="windows">Get-ChildItem</TabItem>
69
+ <TabItem label="linux">ls</TabItem>
70
+ </Tabs>
@@ -52,12 +52,16 @@ test('syncs only tabs using the same sync key', async ({ page, starlight }) => {
52
52
  const pkgTabsA = tabs.nth(0);
53
53
  const unsyncedTabs = tabs.nth(1);
54
54
  const styleTabs = tabs.nth(3);
55
+ const osTabsA = tabs.nth(5);
56
+ const osTabsB = tabs.nth(6);
55
57
 
56
58
  // Select the pnpm tab in the set of tabs synced with the 'pkg' key.
57
59
  await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();
58
60
 
59
61
  await expectSelectedTab(unsyncedTabs, 'one', 'tab 1');
60
62
  await expectSelectedTab(styleTabs, 'css', 'css code');
63
+ await expectSelectedTab(osTabsA, 'macos', 'macOS');
64
+ await expectSelectedTab(osTabsB, 'macos', 'ls');
61
65
  });
62
66
 
63
67
  test('supports synced tabs with different tab items', async ({ page, starlight }) => {
@@ -139,6 +143,156 @@ test('syncs tabs with the same sync key if they do not consistenly use icons', a
139
143
  await expectSelectedTab(pkgTabsA, 'yarn', 'yarn command');
140
144
  });
141
145
 
146
+ test('restores tabs only for synced tabs with a persisted state', async ({ page, starlight }) => {
147
+ await starlight.goto('/tabs');
148
+
149
+ const tabs = page.locator('starlight-tabs');
150
+ const pkgTabsA = tabs.nth(0);
151
+ const pkgTabsB = tabs.nth(2);
152
+ const pkgTabsC = tabs.nth(4);
153
+ const unsyncedTabs = tabs.nth(1);
154
+ const styleTabs = tabs.nth(3);
155
+ const osTabsA = tabs.nth(5);
156
+ const osTabsB = tabs.nth(6);
157
+
158
+ // Select the pnpm tab in the set of tabs synced with the 'pkg' key.
159
+ await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();
160
+
161
+ await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
162
+ await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
163
+ await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');
164
+
165
+ page.reload();
166
+
167
+ // The synced tabs with a persisted state should be restored.
168
+ await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
169
+ await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
170
+ await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');
171
+
172
+ // Other tabs should not be affected.
173
+ await expectSelectedTab(unsyncedTabs, 'one', 'tab 1');
174
+ await expectSelectedTab(styleTabs, 'css', 'css code');
175
+ await expectSelectedTab(osTabsA, 'macos', 'macOS');
176
+ await expectSelectedTab(osTabsB, 'macos', 'ls');
177
+ });
178
+
179
+ test('restores tabs for a single set of synced tabs with a persisted state', async ({
180
+ page,
181
+ starlight,
182
+ }) => {
183
+ await starlight.goto('/tabs');
184
+
185
+ const tabs = page.locator('starlight-tabs');
186
+ const styleTabs = tabs.nth(3);
187
+
188
+ // Select the tailwind tab in the set of tabs synced with the 'style' key.
189
+ await styleTabs.getByRole('tab').filter({ hasText: 'tailwind' }).click();
190
+
191
+ await expectSelectedTab(styleTabs, 'tailwind', 'tailwind code');
192
+
193
+ page.reload();
194
+
195
+ // The synced tabs with a persisted state should be restored.
196
+ await expectSelectedTab(styleTabs, 'tailwind', 'tailwind code');
197
+ });
198
+
199
+ test('restores tabs for multiple synced tabs with different sync keys', async ({
200
+ page,
201
+ starlight,
202
+ }) => {
203
+ await starlight.goto('/tabs');
204
+
205
+ const tabs = page.locator('starlight-tabs');
206
+ const pkgTabsA = tabs.nth(0);
207
+ const pkgTabsB = tabs.nth(2);
208
+ const pkgTabsC = tabs.nth(4);
209
+ const osTabsA = tabs.nth(5);
210
+ const osTabsB = tabs.nth(6);
211
+
212
+ // Select the pnpm tab in the set of tabs synced with the 'pkg' key.
213
+ await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();
214
+
215
+ await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
216
+ await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
217
+ await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');
218
+
219
+ // Select the windows tab in the set of tabs synced with the 'os' key.
220
+ await osTabsB.getByRole('tab').filter({ hasText: 'windows' }).click();
221
+
222
+ page.reload();
223
+
224
+ // The synced tabs with a persisted state for the `pkg` sync key should be restored.
225
+ await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
226
+ await expectSelectedTab(pkgTabsB, 'pnpm', 'another pnpm command');
227
+ await expectSelectedTab(pkgTabsC, 'pnpm', 'another pnpm command');
228
+
229
+ // The synced tabs with a persisted state for the `os` sync key should be restored.
230
+ await expectSelectedTab(osTabsA, 'windows', 'Windows');
231
+ await expectSelectedTab(osTabsB, 'windows', 'Get-ChildItem');
232
+ });
233
+
234
+ test('includes the `<starlight-tabs-restore>` element only for synced tabs', async ({
235
+ page,
236
+ starlight,
237
+ }) => {
238
+ await starlight.goto('/tabs');
239
+
240
+ // The page includes 7 sets of tabs.
241
+ await expect(page.locator('starlight-tabs')).toHaveCount(7);
242
+ // Only 6 sets of tabs are synced.
243
+ await expect(page.locator('starlight-tabs-restore')).toHaveCount(6);
244
+ });
245
+
246
+ test('includes the synced tabs restore script only when needed and at most once', async ({
247
+ page,
248
+ starlight,
249
+ }) => {
250
+ const syncedTabsRestoreScriptRegex = /customElements\.define\('starlight-tabs-restore',/g;
251
+
252
+ await starlight.goto('/tabs');
253
+
254
+ // The page includes at least one set of synced tabs.
255
+ expect((await page.content()).match(syncedTabsRestoreScriptRegex)?.length).toBe(1);
256
+
257
+ await starlight.goto('/tabs-unsynced');
258
+
259
+ // The page includes no set of synced tabs.
260
+ expect((await page.content()).match(syncedTabsRestoreScriptRegex)).toBeNull();
261
+ });
262
+
263
+ test('gracefully handles invalid persisted state for synced tabs', async ({ page, starlight }) => {
264
+ await starlight.goto('/tabs');
265
+
266
+ const tabs = page.locator('starlight-tabs');
267
+ const pkgTabsA = tabs.nth(0);
268
+
269
+ // Select the pnpm tab in the set of tabs synced with the 'pkg' key.
270
+ await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();
271
+
272
+ await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
273
+
274
+ // Replace the persisted state with a new invalid value.
275
+ await page.evaluate(
276
+ (value) => localStorage.setItem('starlight-synced-tabs__pkg', value),
277
+ 'invalid-value'
278
+ );
279
+
280
+ page.reload();
281
+
282
+ // The synced tabs should not be restored due to the invalid persisted state.
283
+ await expectSelectedTab(pkgTabsA, 'npm', 'npm command');
284
+
285
+ // Select the pnpm tab in the set of tabs synced with the 'pkg' key.
286
+ await pkgTabsA.getByRole('tab').filter({ hasText: 'pnpm' }).click();
287
+
288
+ await expectSelectedTab(pkgTabsA, 'pnpm', 'pnpm command');
289
+
290
+ // The synced tabs should be restored with the new valid persisted state.
291
+ expect(await page.evaluate(() => localStorage.getItem('starlight-synced-tabs__pkg'))).toBe(
292
+ 'pnpm'
293
+ );
294
+ });
295
+
142
296
  async function expectSelectedTab(tabs: Locator, label: string, panel: string) {
143
297
  expect((await tabs.getByRole('tab', { selected: true }).textContent())?.trim()).toBe(label);
144
298
  expect((await tabs.getByRole('tabpanel').textContent())?.trim()).toBe(panel);
@@ -5,6 +5,7 @@ import { build, preview } from 'astro';
5
5
  export { expect, type Locator } from '@playwright/test';
6
6
 
7
7
  process.env.ASTRO_TELEMETRY_DISABLED = 'true';
8
+ process.env.ASTRO_DISABLE_UPDATE_CHECK = 'true';
8
9
 
9
10
  // Setup a test environment that will build and start a preview server for a given fixture path and
10
11
  // provide a Starlight Playwright fixture accessible from within all tests.
@@ -2,7 +2,7 @@
2
2
  import { Image } from 'astro:assets';
3
3
  import { PAGE_TITLE_ID } from '../constants';
4
4
  import type { Props } from '../props';
5
- import CallToAction from './CallToAction.astro';
5
+ import LinkButton from '../user-components/LinkButton.astro';
6
6
 
7
7
  const { data } = Astro.props.entry;
8
8
  const { title = data.title, tagline, image, actions = [] } = data.hero || {};
@@ -50,8 +50,11 @@ if (image) {
50
50
  {
51
51
  actions.length > 0 && (
52
52
  <div class="sl-flex actions">
53
- {actions.map(({ text, ...attrs }) => (
54
- <CallToAction {...attrs} set:html={text} />
53
+ {actions.map(({ attrs, icon, link: href, text, variant }) => (
54
+ <LinkButton {href} {variant} icon={icon?.name} {...attrs}>
55
+ {text}
56
+ {icon?.html && <Fragment set:html={icon.html} />}
57
+ </LinkButton>
55
58
  ))}
56
59
  </div>
57
60
  )
@@ -92,6 +92,9 @@ export const BuiltInIcons = {
92
92
  '<path d="M20.47 2H3.53a1.45 1.45 0 0 0-1.47 1.43v17.14A1.45 1.45 0 0 0 3.53 22h16.94a1.45 1.45 0 0 0 1.47-1.43V3.43A1.45 1.45 0 0 0 20.47 2ZM8.09 18.74h-3v-9h3v9ZM6.59 8.48a1.56 1.56 0 0 1 0-3.12 1.57 1.57 0 1 1 0 3.12Zm12.32 10.26h-3v-4.83c0-1.21-.43-2-1.52-2A1.65 1.65 0 0 0 12.85 13a2 2 0 0 0-.1.73v5h-3v-9h3V11a3 3 0 0 1 2.71-1.5c2 0 3.45 1.29 3.45 4.06v5.18Z"/>',
93
93
  twitch:
94
94
  '<path d="M2.5 1 1 4.8v15.4h5.5V23h3.1l3-2.8H17l6-5.7V1H2.6ZM21 13.5l-3.4 3.3H12l-3 2.8v-2.8H4.5V3H21v10.5Zm-3.4-6.8v5.8h-2V6.7h2Zm-5.5 0v5.8h-2V6.7h2Z"/>',
95
+ azureDevOps:
96
+ '<path d="M17,4v9.74l-4,3.28-6.2-2.26V17L3.29,12.41l10.23.8V4.44Zm-3.41.49L7.85,1V3.29L2.58,4.84,1,6.87v4.61l2.26,1V6.57Z"/>',
97
+
95
98
  microsoftTeams:
96
99
  '<path d="M13.78 7.2a3.63 3.63 0 1 0-4.3-3.68h1.78a2.52 2.52 0 0 1 2.52 2.53V7.2zM7.34 18.8h3.92a2.52 2.52 0 0 0 2.52-2.52V8.37h4.17c.58.01 1.04.5 1.03 1.07v6.45a6.3 6.3 0 0 1-6.14 6.43 6.3 6.3 0 0 1-5.5-3.52zm16.1-14.06a2.51 2.51 0 1 1-5.02 0 2.51 2.51 0 0 1 5.02 0zm-3.36 14.24h-.17c.4-1 .59-2.05.57-3.11V9.46c0-.38-.07-.75-.23-1.09h2.69c.58 0 1.06.48 1.06 1.06v5.65a3.9 3.9 0 0 1-3.9 3.9h-.02z"/><path d="M1.02 5.02h10.24c.56 0 1.02.46 1.02 1.03v10.23a1.02 1.02 0 0 1-1.02 1.02H1.02A1.02 1.02 0 0 1 0 16.28V6.04c0-.56.46-1.02 1.02-1.02zm7.81 3.9V7.84H3.45v1.08h2.03v5.57h1.3V8.92h2.05z"/>',
97
100
  instagram:
@@ -80,6 +80,7 @@ const pagefindEnabled =
80
80
  <PageFrame {...Astro.props}>
81
81
  <Header slot="header" {...Astro.props} />
82
82
  {Astro.props.hasSidebar && <Sidebar slot="sidebar" {...Astro.props} />}
83
+ <script src="./SidebarPersistState"></script>
83
84
  <TwoColumnContent {...Astro.props}>
84
85
  <PageSidebar slot="right-sidebar" {...Astro.props} />
85
86
  <main
@@ -2,12 +2,46 @@
2
2
  import type { Props } from '../props';
3
3
 
4
4
  import MobileMenuFooter from 'virtual:starlight/components/MobileMenuFooter';
5
+ import { getSidebarHash } from '../utils/navigation';
5
6
  import SidebarSublist from './SidebarSublist.astro';
6
7
 
7
8
  const { sidebar } = Astro.props;
9
+ const hash = getSidebarHash(sidebar);
8
10
  ---
9
11
 
10
- <SidebarSublist sublist={sidebar} />
12
+ <sl-sidebar-state-persist data-hash={hash}>
13
+ <SidebarSublist sublist={sidebar} />
14
+ </sl-sidebar-state-persist>
11
15
  <div class="md:sl-hidden">
12
16
  <MobileMenuFooter {...Astro.props} />
13
17
  </div>
18
+
19
+ {
20
+ /*
21
+ Inline script to restore sidebar state as soon as possible.
22
+ - On smaller viewports, restoring state is skipped as the sidebar is collapsed inside a menu.
23
+ - The state is parsed from session storage and restored.
24
+ - This is a progressive enhancement, so any errors are swallowed silently.
25
+ */
26
+ }
27
+ <script is:inline>
28
+ (() => {
29
+ try {
30
+ if (!matchMedia('(min-width: 50em)').matches) return;
31
+ const scroller = document.getElementById('starlight__sidebar');
32
+ /** @type {HTMLElement | null} */
33
+ const target = document.querySelector('sl-sidebar-state-persist');
34
+ const state = JSON.parse(sessionStorage.getItem('sl-sidebar-state') || '0');
35
+ if (!scroller || !target || !state || target.dataset.hash !== state.hash) return;
36
+ target
37
+ .querySelectorAll('details')
38
+ .forEach((el, idx) => typeof state.open[idx] === 'boolean' && (el.open = state.open[idx]));
39
+ scroller.scrollTop = state.scroll;
40
+ } catch {}
41
+ })();
42
+ </script>
43
+ <style>
44
+ sl-sidebar-state-persist {
45
+ display: contents;
46
+ }
47
+ </style>
@@ -0,0 +1,70 @@
1
+ // Collect required elements from the DOM.
2
+ const scroller = document.getElementById('starlight__sidebar');
3
+ const target = scroller?.querySelector<HTMLElement>('sl-sidebar-state-persist');
4
+ const details = [...(target?.querySelectorAll('details') || [])];
5
+
6
+ /** Starlight uses this key to store sidebar state in `sessionStorage`. */
7
+ const storageKey = 'sl-sidebar-state';
8
+
9
+ /** The shape used to persist sidebar state across a user’s session. */
10
+ interface SidebarState {
11
+ hash: string;
12
+ open: Array<boolean | null>;
13
+ scroll: number;
14
+ }
15
+
16
+ /**
17
+ * Get the current sidebar state.
18
+ *
19
+ * The `open` state is loaded from session storage, while `scroll` and `hash` are read from the current page.
20
+ */
21
+ const getState = (): SidebarState => {
22
+ let open = [];
23
+ try {
24
+ const rawStoredState = sessionStorage.getItem(storageKey);
25
+ const storedState = JSON.parse(rawStoredState || '{}');
26
+ if (Array.isArray(storedState.open)) open = storedState.open;
27
+ } catch {}
28
+ return {
29
+ hash: target?.dataset.hash || '',
30
+ open,
31
+ scroll: scroller?.scrollTop || 0,
32
+ };
33
+ };
34
+
35
+ /** Store the passed sidebar state in session storage. */
36
+ const storeState = (state: SidebarState): void => {
37
+ try {
38
+ sessionStorage.setItem(storageKey, JSON.stringify(state));
39
+ } catch {}
40
+ };
41
+
42
+ /** Updates sidebar state in session storage without modifying `open` state. */
43
+ const updateState = (): void => storeState(getState());
44
+
45
+ /** Updates sidebar state in session storage to include a new value for a specific `<details>` element. */
46
+ const setToggleState = (open: boolean, detailsIndex: number): void => {
47
+ const state = getState();
48
+ state.open[detailsIndex] = open;
49
+ storeState(state);
50
+ };
51
+
52
+ // Store the current `open` state whenever a user interacts with one of the `<details>` groups.
53
+ target?.addEventListener('click', (event) => {
54
+ if (!(event.target instanceof Element)) return;
55
+ // Query for the nearest `<summary>` and then its parent `<details>`.
56
+ // This excludes clicks outside of the `<summary>`, which don’t trigger toggles.
57
+ const toggledDetails = event.target.closest('summary')?.closest('details');
58
+ if (!toggledDetails) return;
59
+ const index = details.indexOf(toggledDetails);
60
+ if (index === -1) return;
61
+ setToggleState(!toggledDetails.open, index);
62
+ });
63
+
64
+ // Store sidebar state before navigating. These will also store it on tab blur etc.,
65
+ // but avoid using the `beforeunload` event, which can cause issues with back/forward cache
66
+ // on some browsers.
67
+ addEventListener('visibilitychange', () => {
68
+ if (document.visibilityState === 'hidden') updateState();
69
+ });
70
+ addEventListener('pageHide', updateState);
package/components.ts CHANGED
@@ -8,4 +8,5 @@ export { default as TabItem } from './user-components/TabItem.astro';
8
8
  export { default as LinkCard } from './user-components/LinkCard.astro';
9
9
  export { default as Steps } from './user-components/Steps.astro';
10
10
  export { default as FileTree } from './user-components/FileTree.astro';
11
+ export { default as LinkButton } from './user-components/LinkButton.astro';
11
12
  export { Code } from 'astro-expressive-code/components';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrojs/starlight",
3
- "version": "0.25.4",
3
+ "version": "0.26.0",
4
4
  "description": "Build beautiful, high-performance documentation websites with Astro",
5
5
  "keywords": [
6
6
  "docs",
@@ -42,10 +42,6 @@
42
42
  "types": "./components/Sidebar.astro.tsx",
43
43
  "import": "./components/Sidebar.astro"
44
44
  },
45
- "./components/CallToAction.astro": {
46
- "types": "./components/CallToAction.astro.tsx",
47
- "import": "./components/CallToAction.astro"
48
- },
49
45
  "./components/MarkdownContent.astro": {
50
46
  "types": "./components/MarkdownContent.astro.tsx",
51
47
  "import": "./components/MarkdownContent.astro"
package/schemas/hero.ts CHANGED
@@ -49,8 +49,8 @@ export const HeroSchema = ({ image }: SchemaContext) =>
49
49
  text: z.string(),
50
50
  /** Value for the link’s `href` attribute, e.g. `/page` or `https://mysite.com`. */
51
51
  link: z.string(),
52
- /** Button style to use. One of `primary`, `secondary`, or `minimal` (the default). */
53
- variant: z.enum(['primary', 'secondary', 'minimal']).default('minimal'),
52
+ /** Button style to use. One of `primary` (the default), `secondary`, or `minimal`. */
53
+ variant: z.enum(['primary', 'secondary', 'minimal']).default('primary'),
54
54
  /**
55
55
  * An optional icon to display alongside the link text.
56
56
  * Can be an inline `<svg>` or the name of one of Starlight’s built-in icons.
package/schemas/social.ts CHANGED
@@ -14,6 +14,7 @@ export const socialLinks = [
14
14
  'threads',
15
15
  'linkedin',
16
16
  'twitch',
17
+ 'azureDevOps',
17
18
  'microsoftTeams',
18
19
  'instagram',
19
20
  'stackOverflow',
@@ -62,6 +63,7 @@ export const SocialLinksSchema = () =>
62
63
  threads: 'Threads',
63
64
  linkedin: 'LinkedIn',
64
65
  twitch: 'Twitch',
66
+ azureDevOps: 'Azure DevOps',
65
67
  microsoftTeams: 'Microsoft Teams',
66
68
  instagram: 'Instagram',
67
69
  stackOverflow: 'Stack Overflow',
@@ -1,9 +1,10 @@
1
1
  ---
2
+ import { stripLeadingAndTrailingSlashes } from '../utils/path';
2
3
  import { slugToLocaleData } from '../utils/slugs';
3
4
  import { useTranslations } from '../utils/translations';
4
5
  import { processFileTree } from './rehype-file-tree';
5
6
 
6
- const slug = Astro.url.pathname.replace(/^\//, '').replace(/\/$/, '');
7
+ const slug = stripLeadingAndTrailingSlashes(Astro.url.pathname);
7
8
  const t = useTranslations(slugToLocaleData(slug).locale);
8
9
 
9
10
  const fileTreeHtml = await Astro.slots.render('default');
@@ -0,0 +1,76 @@
1
+ ---
2
+ import type { HTMLAttributes } from 'astro/types';
3
+ import { Icons } from '../components/Icons';
4
+ import Icon from './Icon.astro';
5
+
6
+ interface Props extends Omit<HTMLAttributes<'a'>, 'href'> {
7
+ href: string | URL;
8
+ icon?: keyof typeof Icons | undefined;
9
+ iconPlacement?: 'start' | 'end' | undefined;
10
+ variant?: 'primary' | 'secondary' | 'minimal';
11
+ }
12
+
13
+ const {
14
+ class: className,
15
+ icon,
16
+ iconPlacement = 'end',
17
+ variant = 'primary',
18
+ ...attrs
19
+ } = Astro.props;
20
+ ---
21
+
22
+ <a class:list={['sl-link-button not-content', variant, className]} {...attrs}>
23
+ {icon && iconPlacement === 'start' && <Icon name={icon} size="1.5rem" />}
24
+ <slot />
25
+ {icon && iconPlacement === 'end' && <Icon name={icon} size="1.5rem" />}
26
+ </a>
27
+
28
+ <style>
29
+ .sl-link-button {
30
+ align-items: center;
31
+ border: 1px solid transparent;
32
+ border-radius: 999rem;
33
+ display: inline-flex;
34
+ font-size: var(--sl-text-sm);
35
+ gap: 0.5em;
36
+ line-height: 1.1875;
37
+ outline-offset: 0.25rem;
38
+ padding: 0.4375rem 1.125rem;
39
+ text-decoration: none;
40
+ }
41
+
42
+ .sl-link-button.primary {
43
+ background: var(--sl-color-text-accent);
44
+ border-color: var(--sl-color-text-accent);
45
+ color: var(--sl-color-black);
46
+ }
47
+ .sl-link-button.primary:hover {
48
+ color: var(--sl-color-black);
49
+ }
50
+ .sl-link-button.secondary {
51
+ border-color: inherit;
52
+ color: var(--sl-color-white);
53
+ }
54
+ .sl-link-button.minimal {
55
+ color: var(--sl-color-white);
56
+ padding-inline: 0;
57
+ }
58
+
59
+ .sl-link-button :global(svg) {
60
+ flex-shrink: 0;
61
+ }
62
+
63
+ @media (min-width: 50rem) {
64
+ .sl-link-button {
65
+ font-size: var(--sl-text-base);
66
+ padding: 0.9375rem 1.25rem;
67
+ }
68
+ }
69
+
70
+ :global(.sl-markdown-content) .sl-link-button {
71
+ margin-inline-end: 1rem;
72
+ }
73
+ :global(.sl-markdown-content) .sl-link-button:not(:where(p *)) {
74
+ margin-block: 1rem;
75
+ }
76
+ </style>
@@ -51,7 +51,7 @@ const { html } = processSteps(content);
51
51
  }
52
52
 
53
53
  /* Vertical guideline linking list numbers. */
54
- .sl-steps > li:not(:last-of-type)::after {
54
+ .sl-steps > li::after {
55
55
  --guide-width: 1px;
56
56
  content: '';
57
57
  position: absolute;
@@ -9,8 +9,67 @@ interface Props {
9
9
  const { syncKey } = Astro.props;
10
10
  const panelHtml = await Astro.slots.render('default');
11
11
  const { html, panels } = processPanels(panelHtml);
12
+
13
+ /**
14
+ * Synced tabs are persisted across page using `localStorage`. The script used to restore the
15
+ * active tab for a given sync key has a few requirements:
16
+ *
17
+ * - The script should only be included when at least one set of synced tabs is present on the page.
18
+ * - The script should be inlined to avoid a flash of invalid active tab.
19
+ * - The script should only be included once per page.
20
+ *
21
+ * To do so, we keep track of whether the script has been rendered using a variable stored using
22
+ * `Astro.locals` which will be reset for each new page. The value is tracked using an untyped
23
+ * symbol on purpose to avoid Starlight users to get autocomplete for it and avoid potential
24
+ * clashes with user-defined variables.
25
+ *
26
+ * The restore script defines a custom element `starlight-tabs-restore` that will be included in
27
+ * each set of synced tabs to restore the active tab based on the persisted value using the
28
+ * `connectedCallback` lifecycle method. To ensure this callback can access all tabs and panels for
29
+ * the current set of tabs, the script should be rendered before the tabs themselves.
30
+ */
31
+ const isSynced = syncKey !== undefined;
32
+ const didRenderSyncedTabsRestoreScriptSymbol = Symbol.for('starlight:did-render-synced-tabs-restore-script');
33
+ // @ts-expect-error - See above
34
+ const shouldRenderSyncedTabsRestoreScript = isSynced && Astro.locals[didRenderSyncedTabsRestoreScriptSymbol] !== true;
35
+
36
+ if (isSynced) {
37
+ // @ts-expect-error - See above
38
+ Astro.locals[didRenderSyncedTabsRestoreScriptSymbol] = true
39
+ }
12
40
  ---
13
41
 
42
+ {/* Inlined to avoid a flash of invalid active tab. */}
43
+ {shouldRenderSyncedTabsRestoreScript && <script is:inline>
44
+ (() => {
45
+ class StarlightTabsRestore extends HTMLElement {
46
+ connectedCallback() {
47
+ const starlightTabs = this.closest('starlight-tabs');
48
+ if (!(starlightTabs instanceof HTMLElement) || typeof localStorage === 'undefined') return;
49
+ const syncKey = starlightTabs.dataset.syncKey;
50
+ if (!syncKey) return;
51
+ const label = localStorage.getItem(`starlight-synced-tabs__${syncKey}`);
52
+ if (!label) return;
53
+ const tabs = [...starlightTabs?.querySelectorAll('[role="tab"]')];
54
+ const tabIndexToRestore = tabs.findIndex(
55
+ (tab) => tab instanceof HTMLAnchorElement && tab.textContent?.trim() === label
56
+ );
57
+ const panels = starlightTabs?.querySelectorAll('[role="tabpanel"]');
58
+ const newTab = tabs[tabIndexToRestore];
59
+ const newPanel = panels[tabIndexToRestore];
60
+ if (tabIndexToRestore < 1 || !newTab || !newPanel) return;
61
+ tabs[0]?.setAttribute('aria-selected', 'false');
62
+ tabs[0]?.setAttribute('tabindex', '-1');
63
+ panels?.[0]?.setAttribute('hidden', 'true');
64
+ newTab.removeAttribute('tabindex');
65
+ newTab.setAttribute('aria-selected', 'true');
66
+ newPanel.removeAttribute('hidden');
67
+ }
68
+ }
69
+ customElements.define('starlight-tabs-restore', StarlightTabsRestore);
70
+ })()
71
+ </script>}
72
+
14
73
  <starlight-tabs data-sync-key={syncKey}>
15
74
  {
16
75
  panels && (
@@ -35,6 +94,7 @@ const { html, panels } = processPanels(panelHtml);
35
94
  )
36
95
  }
37
96
  <Fragment set:html={html} />
97
+ {isSynced && <starlight-tabs-restore />}
38
98
  </starlight-tabs>
39
99
 
40
100
  <style>
@@ -86,6 +146,8 @@ const { html, panels } = processPanels(panelHtml);
86
146
  tabs: HTMLAnchorElement[];
87
147
  panels: HTMLElement[];
88
148
  #syncKey: string | undefined;
149
+ // The storage key prefix should be in sync with the one used in the restore script.
150
+ #storageKeyPrefix = 'starlight-synced-tabs__';
89
151
 
90
152
  constructor() {
91
153
  super();
@@ -159,25 +221,41 @@ const { html, panels } = processPanels(panelHtml);
159
221
  newTab.setAttribute('aria-selected', 'true');
160
222
  if (shouldSync) {
161
223
  newTab.focus();
162
- StarlightTabs.#syncTabs(this, newTab.innerText);
224
+ StarlightTabs.#syncTabs(this, newTab);
163
225
  window.scrollTo({
164
226
  top: window.scrollY + (this.getBoundingClientRect().top - previousTabsOffset),
165
227
  });
166
228
  }
167
229
  }
168
230
 
169
- static #syncTabs(emitter: StarlightTabs, label: string | null) {
231
+ #persistSyncedTabs(label: string) {
232
+ if (!this.#syncKey || typeof localStorage === 'undefined') return;
233
+ localStorage.setItem(this.#storageKeyPrefix + this.#syncKey, label);
234
+ }
235
+
236
+ static #syncTabs(emitter: StarlightTabs, newTab: HTMLAnchorElement) {
170
237
  const syncKey = emitter.#syncKey;
238
+ const label = StarlightTabs.#getTabLabel(newTab);
171
239
  if (!syncKey || !label) return;
172
240
  const syncedTabs = StarlightTabs.#syncedTabs.get(syncKey);
173
241
  if (!syncedTabs) return;
174
242
 
175
243
  for (const receiver of syncedTabs) {
176
244
  if (receiver === emitter) continue;
177
- const labelIndex = receiver.tabs.findIndex((tab) => tab.innerText === label);
245
+ const labelIndex = receiver.tabs.findIndex((tab) => StarlightTabs.#getTabLabel(tab) === label);
178
246
  if (labelIndex === -1) continue;
179
247
  receiver.switchTab(receiver.tabs[labelIndex], labelIndex, false);
180
248
  }
249
+
250
+ emitter.#persistSyncedTabs(label);
251
+ }
252
+
253
+ static #getTabLabel(tab: HTMLAnchorElement) {
254
+ // `textContent` returns the content of all elements. In the case of a tab with an icon, this
255
+ // could potentially include extra spaces due to the presence of the SVG icon.
256
+ // To sync tabs with the same sync key and label, no matter the presence of an icon, we trim
257
+ // these extra spaces.
258
+ return tab.textContent?.trim();
181
259
  }
182
260
  }
183
261
 
@@ -1,4 +1,5 @@
1
1
  import config from 'virtual:starlight/user-config';
2
+ import { stripTrailingSlash } from './path';
2
3
 
3
4
  /**
4
5
  * Get the equivalent of the passed URL for the passed locale.
@@ -12,7 +13,7 @@ export function localizedUrl(url: URL, locale: string | undefined): URL {
12
13
  }
13
14
  if (locale === 'root') locale = '';
14
15
  /** Base URL with trailing `/` stripped. */
15
- const base = import.meta.env.BASE_URL.replace(/\/$/, '');
16
+ const base = stripTrailingSlash(import.meta.env.BASE_URL);
16
17
  const hasBase = url.pathname.startsWith(base);
17
18
  // Temporarily remove base to simplify
18
19
  if (hasBase) url.pathname = url.pathname.replace(base, '');
@@ -15,6 +15,7 @@ import { pickLang } from './i18n';
15
15
  import { ensureLeadingSlash, ensureTrailingSlash, stripLeadingAndTrailingSlashes } from './path';
16
16
  import { getLocaleRoutes, routes, type Route } from './routing';
17
17
  import { localeToLang, slugToPathname } from './slugs';
18
+ import type { StarlightConfig } from './user-config';
18
19
 
19
20
  const DirKey = Symbol('DirKey');
20
21
  const SlugKey = Symbol('SlugKey');
@@ -139,10 +140,10 @@ function linkFromInternalSidebarLinkItem(
139
140
  locale: string | undefined,
140
141
  currentPathname: string
141
142
  ) {
142
- let slugWithLocale = locale ? locale + '/' + item.slug : item.slug;
143
143
  // Astro passes root `index.[md|mdx]` entries with a slug of `index`
144
- slugWithLocale = slugWithLocale.replace(/\/?index$/, '');
145
- const entry = routes.find((entry) => slugWithLocale === entry.slug);
144
+ const slug = item.slug === 'index' ? '' : item.slug;
145
+ const localizedSlug = locale ? (slug ? locale + '/' + slug : locale) : slug;
146
+ const entry = routes.find((entry) => localizedSlug === entry.slug);
146
147
  if (!entry) {
147
148
  const hasExternalSlashes = item.slug.at(0) === '/' || item.slug.at(-1) === '/';
148
149
  if (hasExternalSlashes) {
@@ -333,17 +334,48 @@ function sidebarFromDir(
333
334
  );
334
335
  }
335
336
 
336
- /** Get the sidebar for the current page. */
337
+ /** Get the sidebar for the current page using the global config. */
337
338
  export function getSidebar(pathname: string, locale: string | undefined): SidebarEntry[] {
339
+ return getSidebarFromConfig(config.sidebar, pathname, locale);
340
+ }
341
+
342
+ /** Get the sidebar for the current page using the specified sidebar config. */
343
+ export function getSidebarFromConfig(
344
+ sidebarConfig: StarlightConfig['sidebar'],
345
+ pathname: string,
346
+ locale: string | undefined
347
+ ): SidebarEntry[] {
338
348
  const routes = getLocaleRoutes(locale);
339
- if (config.sidebar) {
340
- return config.sidebar.map((group) => configItemToEntry(group, pathname, locale, routes));
349
+ if (sidebarConfig) {
350
+ return sidebarConfig.map((group) => configItemToEntry(group, pathname, locale, routes));
341
351
  } else {
342
352
  const tree = treeify(routes, locale || '');
343
353
  return sidebarFromDir(tree, pathname, locale, false);
344
354
  }
345
355
  }
346
356
 
357
+ /** Generates a deterministic string based on the content of the passed sidebar. */
358
+ export function getSidebarHash(sidebar: SidebarEntry[]): string {
359
+ let hash = 0;
360
+ const sidebarIdentity = recursivelyBuildSidebarIdentity(sidebar);
361
+ for (let i = 0; i < sidebarIdentity.length; i++) {
362
+ const char = sidebarIdentity.charCodeAt(i);
363
+ hash = (hash << 5) - hash + char;
364
+ }
365
+ return (hash >>> 0).toString(36).padStart(7, '0');
366
+ }
367
+
368
+ /** Recurses through a sidebar tree to generate a string concatenating labels and link hrefs. */
369
+ function recursivelyBuildSidebarIdentity(sidebar: SidebarEntry[]): string {
370
+ return sidebar
371
+ .flatMap((entry) =>
372
+ entry.type === 'group'
373
+ ? entry.label + recursivelyBuildSidebarIdentity(entry.entries)
374
+ : entry.label + entry.href
375
+ )
376
+ .join('');
377
+ }
378
+
347
379
  /** Turn the nested tree structure of a sidebar into a flat list of all the links. */
348
380
  export function flattenSidebar(sidebar: SidebarEntry[]): Link[] {
349
381
  return sidebar.flatMap((entry) =>
@@ -410,4 +442,7 @@ function applyPrevNextLinkConfig(
410
442
  }
411
443
 
412
444
  /** Remove the extension from a path. */
413
- const stripExtension = (path: string) => path.replace(/\.\w+$/, '');
445
+ function stripExtension(path: string) {
446
+ const periodIndex = path.lastIndexOf('.');
447
+ return path.slice(0, periodIndex > -1 ? periodIndex : undefined);
448
+ }
package/utils/slugs.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import config from 'virtual:starlight/user-config';
2
2
  import { BuiltInDefaultLocale } from './i18n';
3
+ import { stripTrailingSlash } from './path';
3
4
 
4
5
  export interface LocaleData {
5
6
  /** Writing direction. */
@@ -52,7 +53,7 @@ export function slugToParam(slug: string): string | undefined {
52
53
  return slug === 'index' || slug === ''
53
54
  ? undefined
54
55
  : slug.endsWith('/index')
55
- ? slug.replace(/\/index$/, '')
56
+ ? slug.slice(0, -6)
56
57
  : slug;
57
58
  }
58
59
 
@@ -77,7 +78,7 @@ export function localizedSlug(slug: string, locale: string | undefined): string
77
78
  locale = locale || '';
78
79
  if (slugLocale === slug) return locale;
79
80
  if (slugLocale) {
80
- return slug.replace(slugLocale + '/', locale ? locale + '/' : '').replace(/\/$/, '');
81
+ return stripTrailingSlash(slug.replace(slugLocale + '/', locale ? locale + '/' : ''));
81
82
  }
82
83
  return slug ? locale + '/' + slug : locale;
83
84
  }
@@ -106,7 +107,7 @@ export function localizedId(id: string, locale: string | undefined): string {
106
107
  /** Extract the slug from a URL. */
107
108
  export function urlToSlug(url: URL): string {
108
109
  let pathname = url.pathname;
109
- const base = import.meta.env.BASE_URL.replace(/\/$/, '');
110
+ const base = stripTrailingSlash(import.meta.env.BASE_URL);
110
111
  if (pathname.startsWith(base)) pathname = pathname.replace(base, '');
111
112
  const segments = pathname.split('/');
112
113
  const htmlExt = '.html';
@@ -12,11 +12,11 @@ import {
12
12
  } from './route-data';
13
13
  import type { StarlightDocsEntry } from './routing';
14
14
  import { slugToLocaleData, urlToSlug } from './slugs';
15
- import { getPrevNextLinks, getSidebar } from './navigation';
15
+ import { getPrevNextLinks, getSidebarFromConfig } from './navigation';
16
16
  import { useTranslations } from './translations';
17
17
  import { docsSchema } from '../schema';
18
- import { BadgeConfigSchema } from '../schemas/badge';
19
- import { SidebarLinkItemHTMLAttributesSchema } from '../schemas/sidebar';
18
+ import { SidebarItemSchema } from '../schemas/sidebar';
19
+ import type { StarlightConfig, StarlightUserConfig } from './user-config';
20
20
 
21
21
  /**
22
22
  * The frontmatter schema for Starlight pages derived from the default schema for Starlight’s
@@ -64,88 +64,12 @@ type StarlightPageFrontmatter = Omit<
64
64
  'editUrl' | 'sidebar'
65
65
  > & { editUrl?: string | false };
66
66
 
67
- /**
68
- * Link configuration schema for `<StarlightPage>`.
69
- * Sets default values where possible to be more user friendly than raw `SidebarEntry` type.
70
- */
71
- const LinkSchema = z
72
- .object({
73
- /** @deprecated Specifying `type` is no longer required. */
74
- type: z.literal('link').default('link'),
75
- label: z.string(),
76
- href: z.string(),
77
- isCurrent: z.boolean().default(false),
78
- badge: BadgeConfigSchema(),
79
- attrs: SidebarLinkItemHTMLAttributesSchema(),
80
- })
81
- // Make sure badge is in the object even if undefined — Zod doesn’t seem to have a way to set `undefined` as a default.
82
- .transform((item) => ({ badge: undefined, ...item }));
83
-
84
- /** Base schema for link groups without the recursive `items` array. */
85
- const LinkGroupBase = z.object({
86
- /** @deprecated Specifying `type` is no longer required. */
87
- type: z.literal('group').default('group'),
88
- label: z.string(),
89
- collapsed: z.boolean().default(false),
90
- badge: BadgeConfigSchema(),
91
- });
92
-
93
- // These manual types are needed to correctly type the recursive link group type.
94
- type ManualLinkGroupInput = Prettify<
95
- z.input<typeof LinkGroupBase> &
96
- // The original implementation of `<StarlightPage>` in v0.19.0 used `entries`.
97
- // We want to use `items` so it matches the sidebar config in `astro.config.mjs`.
98
- // Keeping `entries` support for now to not break anyone.
99
- // TODO: warn about `entries` usage in a future version
100
- // TODO: remove support for `entries` in a future version
101
- (| {
102
- /** Array of links and subcategories to display in this category. */
103
- items: Array<z.input<typeof LinkSchema> | ManualLinkGroupInput>;
104
- }
105
- | {
106
- /**
107
- * @deprecated Use `items` instead of `entries`.
108
- * Support for `entries` will be removed in a future version of Starlight.
109
- */
110
- entries: Array<z.input<typeof LinkSchema> | ManualLinkGroupInput>;
111
- }
112
- )
113
- >;
114
- type ManualLinkGroupOutput = z.output<typeof LinkGroupBase> & {
115
- entries: Array<z.output<typeof LinkSchema> | ManualLinkGroupOutput>;
116
- badge: z.output<typeof LinkGroupBase>['badge'];
117
- };
118
- type LinkGroupSchemaType = z.ZodType<ManualLinkGroupOutput, z.ZodTypeDef, ManualLinkGroupInput>;
119
- /**
120
- * Link group configuration schema for `<StarlightPage>`.
121
- * Sets default values where possible to be more user friendly than raw `SidebarEntry` type.
122
- */
123
- const LinkGroupSchema: LinkGroupSchemaType = z.preprocess(
124
- // Map `items` to `entries` as expected by the `SidebarEntry` type.
125
- (arg) => {
126
- if (arg && typeof arg === 'object' && 'items' in arg) {
127
- const { items, ...rest } = arg;
128
- return { ...rest, entries: items };
129
- }
130
- return arg;
131
- },
132
- LinkGroupBase.extend({
133
- entries: z.lazy(() => z.union([LinkSchema, LinkGroupSchema]).array()),
134
- })
135
- // Make sure badge is in the object even if undefined.
136
- .transform((item) => ({ badge: undefined, ...item }))
137
- ) as LinkGroupSchemaType;
138
-
139
- /** Sidebar configuration schema for `<StarlightPage>` */
140
- const StarlightPageSidebarSchema = z.union([LinkSchema, LinkGroupSchema]).array();
141
- type StarlightPageSidebarUserConfig = z.input<typeof StarlightPageSidebarSchema>;
142
-
143
- /** Parse sidebar prop to ensure all required defaults are in place. */
144
- const normalizeSidebarProp = (
145
- sidebarProp: StarlightPageSidebarUserConfig
146
- ): StarlightRouteData['sidebar'] => {
67
+ /** Parse sidebar prop to ensure it's valid. */
68
+ const validateSidebarProp = (
69
+ sidebarProp: StarlightUserConfig['sidebar']
70
+ ): StarlightConfig['sidebar'] => {
147
71
  return parseWithFriendlyErrors(
148
- StarlightPageSidebarSchema,
72
+ SidebarItemSchema.array().optional(),
149
73
  sidebarProp,
150
74
  'Invalid sidebar prop passed to the `<StarlightPage/>` component.'
151
75
  );
@@ -159,7 +83,7 @@ export type StarlightPageProps = Prettify<
159
83
  Partial<Omit<RemoveIndexSignature<PageProps>, 'entry' | 'entryMeta' | 'id' | 'locale' | 'slug'>> &
160
84
  // Add the sidebar definitions for a Starlight page.
161
85
  Partial<Pick<StarlightRouteData, 'hasSidebar'>> & {
162
- sidebar?: StarlightPageSidebarUserConfig;
86
+ sidebar?: StarlightUserConfig['sidebar'];
163
87
  // And finally add the Starlight page frontmatter properties in a `frontmatter` property.
164
88
  frontmatter: StarlightPageFrontmatter;
165
89
  }
@@ -190,9 +114,11 @@ export async function generateStarlightPageRouteData({
190
114
  const pageFrontmatter = await getStarlightPageFrontmatter(frontmatter);
191
115
  const id = `${stripLeadingAndTrailingSlashes(slug)}.md`;
192
116
  const localeData = slugToLocaleData(slug);
193
- const sidebar = props.sidebar
194
- ? normalizeSidebarProp(props.sidebar)
195
- : getSidebar(url.pathname, localeData.locale);
117
+ const sidebar = getSidebarFromConfig(
118
+ props.sidebar ? validateSidebarProp(props.sidebar) : config.sidebar,
119
+ url.pathname,
120
+ localeData.locale
121
+ );
196
122
  const headings = props.headings ?? [];
197
123
  const pageDocsEntry: StarlightPageDocsEntry = {
198
124
  id,
@@ -1,51 +0,0 @@
1
- ---
2
- import type { HTMLAttributes } from 'astro/types';
3
- import Icon from '../user-components/Icon.astro';
4
- import type { Icons } from './Icons';
5
-
6
- interface Props {
7
- variant: 'primary' | 'secondary' | 'minimal';
8
- link: string;
9
- icon?: undefined | { type: 'icon'; name: keyof typeof Icons } | { type: 'raw'; html: string };
10
- attrs?: Omit<HTMLAttributes<'a'>, 'href'> | undefined;
11
- }
12
-
13
- const { link, variant, icon } = Astro.props;
14
- const { class: customClass, ...attrs } = Astro.props.attrs || {};
15
- ---
16
-
17
- <a class:list={['sl-flex action', variant, customClass]} href={link} {...attrs}>
18
- <slot />
19
- {icon?.type === 'icon' && <Icon name={icon.name} size="1.5rem" />}
20
- {icon?.type === 'raw' && <Fragment set:html={icon.html} />}
21
- </a>
22
-
23
- <style>
24
- .action {
25
- gap: 0.5em;
26
- align-items: center;
27
- border-radius: 999rem;
28
- padding: 0.5rem 1.125rem;
29
- color: var(--sl-color-white);
30
- line-height: 1.1875;
31
- text-decoration: none;
32
- font-size: var(--sl-text-sm);
33
- }
34
- .action.primary {
35
- background: var(--sl-color-text-accent);
36
- color: var(--sl-color-black);
37
- }
38
- .action.secondary {
39
- border: 1px solid;
40
- }
41
- .action.minimal {
42
- padding-inline: 0;
43
- }
44
-
45
- @media (min-width: 50rem) {
46
- .action {
47
- font-size: var(--sl-text-base);
48
- padding: 1rem 1.25rem;
49
- }
50
- }
51
- </style>