starlight-theme-celestia 0.2.5 → 0.3.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.
- package/README.md +9 -7
- package/package.json +3 -4
- package/src/components/MultiSidebarPanel.astro +18 -0
- package/src/components/MultiSidebarSelect.astro +72 -0
- package/src/components/MultiSidebarTabs.astro +84 -0
- package/src/components/Sidebar.astro +121 -0
- package/src/components/multi-sidebar.test.ts +47 -0
- package/src/components/multi-sidebar.ts +64 -0
- package/src/index.test.ts +48 -0
- package/src/index.ts +8 -2
- package/src/user-options.ts +24 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ A clean, modern theme for [Astro Starlight](https://starlight.astro.build/), for
|
|
|
9
9
|
- **Wider content** — 55rem content width, 17rem sidebars
|
|
10
10
|
- **3-way theme toggle** — cycles through Auto, Light, and Dark
|
|
11
11
|
- **Warm accent colors** — `#fb923c` (dark) / `#c2410c` (light)
|
|
12
|
-
- **Multi-sidebar
|
|
12
|
+
- **Multi-sidebar** — built-in switchable sidebars, one per top-level `sidebar` group
|
|
13
13
|
- **Version dropdown** — built-in `starlight-versions` integration with auto-detected header dropdown
|
|
14
14
|
|
|
15
15
|
## Installation
|
|
@@ -54,20 +54,22 @@ starlightCelestiaTheme({
|
|
|
54
54
|
|
|
55
55
|
### Multi-Sidebar
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
Add it alongside the theme plugin:
|
|
57
|
+
Set the `multiSidebar` option to turn every top-level entry of the Starlight `sidebar` config into its own sidebar, with a switcher to move between them:
|
|
59
58
|
|
|
60
59
|
```js
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
// Inside starlight({ plugins: [...] })
|
|
64
|
-
starlightUtils({
|
|
60
|
+
starlightCelestiaTheme({
|
|
65
61
|
multiSidebar: {
|
|
62
|
+
// 'horizontalList' (default), 'dropdown', or 'hidden'
|
|
66
63
|
switcherStyle: "horizontalList",
|
|
67
64
|
},
|
|
68
65
|
});
|
|
69
66
|
```
|
|
70
67
|
|
|
68
|
+
Every top-level entry must be a group or autogenerated when this is set.
|
|
69
|
+
|
|
70
|
+
This replaces `@lorenzo_lewis/starlight-utils`, which is deprecated on npm.
|
|
71
|
+
Unlike that plugin, the grouping happens while the sidebar renders rather than in a route middleware, so it also works alongside `starlight-versions` regardless of plugin order.
|
|
72
|
+
|
|
71
73
|
### Versioning
|
|
72
74
|
|
|
73
75
|
The theme ships `starlight-versions` as a dependency. When configured, a version dropdown automatically appears next to the site title:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "starlight-theme-celestia",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.3.1",
|
|
5
5
|
"description": "A clean, modern theme for Astro Starlight",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -29,7 +29,6 @@
|
|
|
29
29
|
"@aria-ui/core": "^0.2.0",
|
|
30
30
|
"@aria-ui/utils": "^0.1.5",
|
|
31
31
|
"@fontsource/inter": "^5.0.0",
|
|
32
|
-
"@lorenzo_lewis/starlight-utils": "^0.3.0",
|
|
33
32
|
"@pagefind/default-ui": "^1.5.2",
|
|
34
33
|
"@shikijs/transformers": "^4.0.2",
|
|
35
34
|
"@shikijs/twoslash": "^4.0.2",
|
|
@@ -39,7 +38,7 @@
|
|
|
39
38
|
"rehype": "^13.0.2",
|
|
40
39
|
"remark-custom-header-id": "^1.0.0",
|
|
41
40
|
"shiki-twoslash-renderer": "^0.3.3",
|
|
42
|
-
"starlight-versions": "^0.
|
|
41
|
+
"starlight-versions": "^0.10.0"
|
|
43
42
|
},
|
|
44
43
|
"peerDependencies": {
|
|
45
44
|
"@astrojs/starlight": "*",
|
|
@@ -54,7 +53,7 @@
|
|
|
54
53
|
}
|
|
55
54
|
},
|
|
56
55
|
"devDependencies": {
|
|
57
|
-
"@astrojs/starlight": "^0.
|
|
56
|
+
"@astrojs/starlight": "^0.42.0",
|
|
58
57
|
"@tailwindcss/cli": "^4.2.2",
|
|
59
58
|
"@tailwindcss/vite": "^4.2.2",
|
|
60
59
|
"@types/node": "^26.0.0",
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Renders one sidebar of a multi-sidebar setup.
|
|
4
|
+
*
|
|
5
|
+
* Starlight's default `Sidebar` reads `Astro.locals.starlightRoute.sidebar` and takes no props, so
|
|
6
|
+
* the entries of the sidebar being rendered are assigned onto the route data first.
|
|
7
|
+
*/
|
|
8
|
+
import Default from "@astrojs/starlight/components/Sidebar.astro";
|
|
9
|
+
import type { StarlightRouteData } from "@astrojs/starlight/route-data";
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
sidebar: StarlightRouteData["sidebar"];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Astro.locals.starlightRoute.sidebar = Astro.props.sidebar;
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<Default />
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* `dropdown` switcher: a compact select listing every sidebar.
|
|
4
|
+
*/
|
|
5
|
+
import { Icon } from "@astrojs/starlight/components";
|
|
6
|
+
|
|
7
|
+
import type { MultiSidebarGroup } from "./multi-sidebar";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
groups: MultiSidebarGroup[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { groups } = Astro.props;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<label>
|
|
17
|
+
<span class="sr-only">{Astro.locals.t("sidebarNav.accessibleLabel")}</span>
|
|
18
|
+
<select autocomplete="off">
|
|
19
|
+
{
|
|
20
|
+
groups.map((group) => (
|
|
21
|
+
<option value={group.panelId} selected={group.isCurrent}>
|
|
22
|
+
{group.label}
|
|
23
|
+
</option>
|
|
24
|
+
))
|
|
25
|
+
}
|
|
26
|
+
</select>
|
|
27
|
+
<Icon name="down-caret" class="caret-icon" />
|
|
28
|
+
</label>
|
|
29
|
+
|
|
30
|
+
<style>
|
|
31
|
+
label {
|
|
32
|
+
--celestia-multi-sidebar-caret-size: 1rem;
|
|
33
|
+
|
|
34
|
+
position: relative;
|
|
35
|
+
display: flex;
|
|
36
|
+
align-items: center;
|
|
37
|
+
margin-block-end: 0.75rem;
|
|
38
|
+
color: var(--sl-color-gray-2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
label:hover {
|
|
42
|
+
color: var(--sl-color-white);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.caret-icon {
|
|
46
|
+
position: absolute;
|
|
47
|
+
top: 50%;
|
|
48
|
+
inset-inline-end: 0.5rem;
|
|
49
|
+
transform: translateY(-50%);
|
|
50
|
+
pointer-events: none;
|
|
51
|
+
font-size: var(--celestia-multi-sidebar-caret-size);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
select {
|
|
55
|
+
appearance: none;
|
|
56
|
+
width: 100%;
|
|
57
|
+
background-color: var(--sl-color-gray-6);
|
|
58
|
+
border: 0;
|
|
59
|
+
border-radius: 0.375rem;
|
|
60
|
+
color: inherit;
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
font-size: var(--sl-text-sm);
|
|
63
|
+
padding-block: 0.35rem;
|
|
64
|
+
padding-inline: 0.5rem calc(var(--celestia-multi-sidebar-caret-size) + 0.5rem);
|
|
65
|
+
text-overflow: ellipsis;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
option {
|
|
69
|
+
background-color: var(--sl-color-black);
|
|
70
|
+
color: var(--sl-color-gray-1);
|
|
71
|
+
}
|
|
72
|
+
</style>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* `horizontalList` switcher: one tab per sidebar, rendered above the sidebar entries.
|
|
4
|
+
*/
|
|
5
|
+
import { Badge } from "@astrojs/starlight/components";
|
|
6
|
+
|
|
7
|
+
import type { MultiSidebarGroup } from "./multi-sidebar";
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
groups: MultiSidebarGroup[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const { groups } = Astro.props;
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
<div class="tablist-wrapper celestia-scroll-container not-content">
|
|
17
|
+
<ul role="tablist">
|
|
18
|
+
{
|
|
19
|
+
groups.map((group) => (
|
|
20
|
+
<li role="presentation">
|
|
21
|
+
<a
|
|
22
|
+
role="tab"
|
|
23
|
+
id={group.tabId}
|
|
24
|
+
href={`#${group.panelId}`}
|
|
25
|
+
aria-selected={group.isCurrent ? "true" : "false"}
|
|
26
|
+
aria-controls={group.panelId}
|
|
27
|
+
tabindex={group.isCurrent ? 0 : -1}
|
|
28
|
+
>
|
|
29
|
+
<span>{group.label}</span>
|
|
30
|
+
{group.badge && (
|
|
31
|
+
<Badge variant={group.badge.variant} class={group.badge.class} text={group.badge.text} />
|
|
32
|
+
)}
|
|
33
|
+
</a>
|
|
34
|
+
</li>
|
|
35
|
+
))
|
|
36
|
+
}
|
|
37
|
+
</ul>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<style>
|
|
41
|
+
.tablist-wrapper {
|
|
42
|
+
margin-block-end: 0.75rem;
|
|
43
|
+
overflow-x: auto;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
[role="tablist"] {
|
|
47
|
+
display: grid;
|
|
48
|
+
grid-auto-flow: column;
|
|
49
|
+
grid-auto-columns: 1fr;
|
|
50
|
+
list-style: none;
|
|
51
|
+
margin: 0;
|
|
52
|
+
padding: 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
[role="tab"] {
|
|
56
|
+
display: flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
justify-content: center;
|
|
59
|
+
gap: 0.25em;
|
|
60
|
+
/*
|
|
61
|
+
* `ThemeProvider` already underlines every tab of the theme with `!important`, so this only
|
|
62
|
+
* matters if that global styling is ever dropped.
|
|
63
|
+
*/
|
|
64
|
+
box-shadow: inset 0 -1px 0 var(--sl-color-gray-5);
|
|
65
|
+
padding: 0.25rem 0.5rem 0.4rem;
|
|
66
|
+
color: var(--sl-color-gray-3);
|
|
67
|
+
font-size: var(--sl-text-sm);
|
|
68
|
+
line-height: 1.4;
|
|
69
|
+
text-align: center;
|
|
70
|
+
text-decoration: none;
|
|
71
|
+
white-space: nowrap;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
[role="tab"]:hover,
|
|
75
|
+
[role="tab"]:focus-visible {
|
|
76
|
+
color: var(--sl-color-white);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
[role="tab"][aria-selected="true"] {
|
|
80
|
+
box-shadow: inset 0 -2px 0 var(--sl-color-accent-high);
|
|
81
|
+
color: var(--sl-color-white);
|
|
82
|
+
font-weight: 600;
|
|
83
|
+
}
|
|
84
|
+
</style>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Multi-sidebar `Sidebar` override.
|
|
4
|
+
*
|
|
5
|
+
* Registered by the theme only when the `multiSidebar` option is set. It renders one switchable
|
|
6
|
+
* sidebar per top-level group of the Starlight `sidebar` config, replacing
|
|
7
|
+
* `@lorenzo_lewis/starlight-utils`, which is deprecated on npm.
|
|
8
|
+
*/
|
|
9
|
+
import config from "virtual:starlight-theme-celestia/user-config";
|
|
10
|
+
|
|
11
|
+
import MultiSidebarPanel from "./MultiSidebarPanel.astro";
|
|
12
|
+
import MultiSidebarSelect from "./MultiSidebarSelect.astro";
|
|
13
|
+
import MultiSidebarTabs from "./MultiSidebarTabs.astro";
|
|
14
|
+
import { getMultiSidebarGroups } from "./multi-sidebar";
|
|
15
|
+
|
|
16
|
+
const switcherStyle = config.multiSidebar?.switcherStyle ?? "horizontalList";
|
|
17
|
+
const groups = getMultiSidebarGroups(Astro.locals.starlightRoute.sidebar);
|
|
18
|
+
|
|
19
|
+
// Without a switcher there is nothing to switch to, so the other sidebars are never rendered.
|
|
20
|
+
const panels = switcherStyle === "hidden" ? groups.filter(({ isCurrent }) => isCurrent) : groups;
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
<celestia-multi-sidebar>
|
|
24
|
+
{switcherStyle === "horizontalList" && <MultiSidebarTabs groups={groups} />}
|
|
25
|
+
{switcherStyle === "dropdown" && <MultiSidebarSelect groups={groups} />}
|
|
26
|
+
{
|
|
27
|
+
panels.map((group) =>
|
|
28
|
+
switcherStyle === "horizontalList" ? (
|
|
29
|
+
<section id={group.panelId} role="tabpanel" aria-labelledby={group.tabId} hidden={!group.isCurrent}>
|
|
30
|
+
<MultiSidebarPanel sidebar={group.entries} />
|
|
31
|
+
</section>
|
|
32
|
+
) : (
|
|
33
|
+
<section id={group.panelId} aria-label={group.label} hidden={!group.isCurrent}>
|
|
34
|
+
<MultiSidebarPanel sidebar={group.entries} />
|
|
35
|
+
</section>
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
</celestia-multi-sidebar>
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
/*
|
|
43
|
+
* The switcher and the sidebars have to sit in the sidebar flex column of `PageFrame`, so the
|
|
44
|
+
* custom element adds no box of its own and the selected sidebar reproduces that column for the
|
|
45
|
+
* entries it wraps. `hidden` still wins for the sidebars that are not selected, because the rule
|
|
46
|
+
* below only applies to the selected one.
|
|
47
|
+
*/
|
|
48
|
+
celestia-multi-sidebar {
|
|
49
|
+
display: contents;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
section:not([hidden]) {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex: 1;
|
|
55
|
+
flex-direction: column;
|
|
56
|
+
min-height: max-content;
|
|
57
|
+
}
|
|
58
|
+
</style>
|
|
59
|
+
|
|
60
|
+
<script>
|
|
61
|
+
class CelestiaMultiSidebar extends HTMLElement {
|
|
62
|
+
#panels: HTMLElement[];
|
|
63
|
+
#tabs: HTMLAnchorElement[];
|
|
64
|
+
|
|
65
|
+
constructor() {
|
|
66
|
+
super();
|
|
67
|
+
|
|
68
|
+
this.#panels = [...this.querySelectorAll<HTMLElement>(":scope > section")];
|
|
69
|
+
this.#tabs = [...this.querySelectorAll<HTMLAnchorElement>('[role="tab"]')];
|
|
70
|
+
|
|
71
|
+
for (const [index, tab] of this.#tabs.entries()) {
|
|
72
|
+
tab.addEventListener("click", (event) => {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
this.#select(index);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
tab.addEventListener("keydown", (event) => {
|
|
78
|
+
const nextIndex =
|
|
79
|
+
event.key === "ArrowLeft"
|
|
80
|
+
? index - 1
|
|
81
|
+
: event.key === "ArrowRight"
|
|
82
|
+
? index + 1
|
|
83
|
+
: event.key === "Home"
|
|
84
|
+
? 0
|
|
85
|
+
: event.key === "End"
|
|
86
|
+
? this.#tabs.length - 1
|
|
87
|
+
: null;
|
|
88
|
+
|
|
89
|
+
if (nextIndex === null || !this.#tabs[nextIndex]) return;
|
|
90
|
+
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
this.#select(nextIndex);
|
|
93
|
+
this.#tabs[nextIndex]?.focus();
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const select = this.querySelector("select");
|
|
98
|
+
select?.addEventListener("change", () => {
|
|
99
|
+
this.#select(this.#panels.findIndex((panel) => panel.id === select.value));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
#select(index: number) {
|
|
104
|
+
if (index < 0) return;
|
|
105
|
+
|
|
106
|
+
for (const [panelIndex, panel] of this.#panels.entries()) {
|
|
107
|
+
panel.hidden = panelIndex !== index;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const [tabIndex, tab] of this.#tabs.entries()) {
|
|
111
|
+
const isSelected = tabIndex === index;
|
|
112
|
+
tab.setAttribute("aria-selected", isSelected ? "true" : "false");
|
|
113
|
+
tab.setAttribute("tabindex", isSelected ? "0" : "-1");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!customElements.get("celestia-multi-sidebar")) {
|
|
119
|
+
customElements.define("celestia-multi-sidebar", CelestiaMultiSidebar);
|
|
120
|
+
}
|
|
121
|
+
</script>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { StarlightRouteData } from "@astrojs/starlight/route-data";
|
|
2
|
+
import { expect, test } from "bun:test";
|
|
3
|
+
|
|
4
|
+
import { getMultiSidebarGroups } from "./multi-sidebar";
|
|
5
|
+
|
|
6
|
+
type SidebarEntry = StarlightRouteData["sidebar"][number];
|
|
7
|
+
|
|
8
|
+
function link(label: string, isCurrent = false): SidebarEntry {
|
|
9
|
+
return { type: "link", label, href: `/${label}/`, isCurrent, badge: undefined, attrs: {} };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function group(label: string, entries: SidebarEntry[]): SidebarEntry {
|
|
13
|
+
return { type: "group", label, entries: entries as never, collapsed: false, badge: undefined };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
test("every top-level group becomes a sidebar", () => {
|
|
17
|
+
const groups = getMultiSidebarGroups([group("Guide", [link("start")]), group("Reference", [link("config")])]);
|
|
18
|
+
|
|
19
|
+
expect(groups.map(({ label }) => label)).toEqual(["Guide", "Reference"]);
|
|
20
|
+
expect(groups.map(({ tabId }) => tabId)).toEqual(["celestia-sidebar-tab-0", "celestia-sidebar-tab-1"]);
|
|
21
|
+
expect(groups.map(({ panelId }) => panelId)).toEqual(["celestia-sidebar-panel-0", "celestia-sidebar-panel-1"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("the sidebar holding the current page is selected, however deeply it is nested", () => {
|
|
25
|
+
const groups = getMultiSidebarGroups([
|
|
26
|
+
group("Guide", [link("start")]),
|
|
27
|
+
group("Reference", [group("Nested", [group("Deeper", [link("config", true)])])]),
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
expect(groups.map(({ isCurrent }) => isCurrent)).toEqual([false, true]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("pages outside every sidebar fall back to the first one", () => {
|
|
34
|
+
const groups = getMultiSidebarGroups([group("Guide", [link("start")]), group("Reference", [link("config")])]);
|
|
35
|
+
|
|
36
|
+
expect(groups.map(({ isCurrent }) => isCurrent)).toEqual([true, false]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a top-level entry that is not a group is rejected", () => {
|
|
40
|
+
expect(() => getMultiSidebarGroups([group("Guide", [link("start")]), link("Blog")])).toThrow(
|
|
41
|
+
/`Blog` cannot be used with multi-sidebar/,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("an empty sidebar yields no sidebars", () => {
|
|
46
|
+
expect(getMultiSidebarGroups([])).toEqual([]);
|
|
47
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { StarlightRouteData } from "@astrojs/starlight/route-data";
|
|
2
|
+
import { AstroError } from "astro/errors";
|
|
3
|
+
|
|
4
|
+
type SidebarEntry = StarlightRouteData["sidebar"][number];
|
|
5
|
+
type SidebarGroup = Extract<SidebarEntry, { type: "group" }>;
|
|
6
|
+
|
|
7
|
+
export interface MultiSidebarGroup {
|
|
8
|
+
/** Label of the top-level group backing this sidebar. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Optional badge rendered next to the label in the switcher. */
|
|
11
|
+
badge: SidebarGroup["badge"];
|
|
12
|
+
/** Entries rendered when this sidebar is selected. */
|
|
13
|
+
entries: SidebarEntry[];
|
|
14
|
+
/** Whether this sidebar contains the page being rendered. */
|
|
15
|
+
isCurrent: boolean;
|
|
16
|
+
/** DOM id of the switcher control for this sidebar. */
|
|
17
|
+
tabId: string;
|
|
18
|
+
/** DOM id of the panel holding this sidebar's entries. */
|
|
19
|
+
panelId: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Recursively check whether a sidebar entry contains the page being rendered. */
|
|
23
|
+
function containsCurrentPage(entry: SidebarEntry): boolean {
|
|
24
|
+
return entry.type === "link" ? entry.isCurrent : entry.entries.some(containsCurrentPage);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Turn the route's sidebar into one switchable sidebar per top-level group.
|
|
29
|
+
*
|
|
30
|
+
* Unlike `@lorenzo_lewis/starlight-utils`, this runs while the `Sidebar` component renders instead
|
|
31
|
+
* of in a route middleware, so it always sees the sidebar every other plugin has finished building.
|
|
32
|
+
* That matters for `starlight-versions`, whose middleware narrows the sidebar to the current
|
|
33
|
+
* version and cannot be ordered against a second sidebar-rewriting middleware.
|
|
34
|
+
*/
|
|
35
|
+
export function getMultiSidebarGroups(sidebar: StarlightRouteData["sidebar"]): MultiSidebarGroup[] {
|
|
36
|
+
const groups = sidebar.map((entry, index) => {
|
|
37
|
+
if (entry.type !== "group") {
|
|
38
|
+
throw new AstroError(
|
|
39
|
+
`\`${entry.label}\` cannot be used with multi-sidebar.`,
|
|
40
|
+
"Each top-level `sidebar` item in the Starlight config must be a group or autogenerated when the " +
|
|
41
|
+
"`multiSidebar` option of `starlight-theme-celestia` is enabled.\n" +
|
|
42
|
+
"See https://starlight.astro.build/guides/sidebar/#groups and " +
|
|
43
|
+
"https://starlight.astro.build/guides/sidebar/#autogenerated-groups for more details.",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
label: entry.label,
|
|
49
|
+
badge: entry.badge,
|
|
50
|
+
entries: entry.entries,
|
|
51
|
+
isCurrent: containsCurrentPage(entry),
|
|
52
|
+
tabId: `celestia-sidebar-tab-${index}`,
|
|
53
|
+
panelId: `celestia-sidebar-panel-${index}`,
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Pages outside every sidebar, such as the site index, fall back to the first sidebar.
|
|
58
|
+
const first = groups[0];
|
|
59
|
+
if (first && !groups.some((group) => group.isCurrent)) {
|
|
60
|
+
first.isCurrent = true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return groups;
|
|
64
|
+
}
|
package/src/index.test.ts
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
|
+
import type { StarlightPlugin, StarlightUserConfig } from "@astrojs/starlight/types";
|
|
1
2
|
import { expect, test } from "bun:test";
|
|
2
3
|
|
|
3
4
|
import starlightCelestiaTheme from "./index";
|
|
5
|
+
import type { ThemeCelestiaOptions } from "./user-options";
|
|
6
|
+
|
|
7
|
+
type ConfigSetupHook = NonNullable<StarlightPlugin["hooks"]["config:setup"]>;
|
|
8
|
+
type ConfigSetupOptions = Parameters<ConfigSetupHook>[0];
|
|
9
|
+
|
|
10
|
+
/** Run the `config:setup` hook of the theme and return the config it asks Starlight to apply. */
|
|
11
|
+
async function setupConfig(
|
|
12
|
+
options: ThemeCelestiaOptions = {},
|
|
13
|
+
components: StarlightUserConfig["components"] = {},
|
|
14
|
+
): Promise<Partial<StarlightUserConfig>> {
|
|
15
|
+
const hook = starlightCelestiaTheme(options).hooks["config:setup"];
|
|
16
|
+
|
|
17
|
+
if (!hook) {
|
|
18
|
+
throw new Error("the theme no longer registers a `config:setup` hook");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let updated: Partial<StarlightUserConfig> = {};
|
|
22
|
+
|
|
23
|
+
await hook({
|
|
24
|
+
config: { components },
|
|
25
|
+
updateConfig: (config: Partial<StarlightUserConfig>) => {
|
|
26
|
+
updated = config;
|
|
27
|
+
},
|
|
28
|
+
addIntegration: () => {},
|
|
29
|
+
astroConfig: { root: new URL("file:///project/"), vite: {} },
|
|
30
|
+
} as unknown as ConfigSetupOptions);
|
|
31
|
+
|
|
32
|
+
return updated;
|
|
33
|
+
}
|
|
4
34
|
|
|
5
35
|
test("default factory returns a valid Starlight plugin", () => {
|
|
6
36
|
const plugin = starlightCelestiaTheme();
|
|
@@ -9,3 +39,21 @@ test("default factory returns a valid Starlight plugin", () => {
|
|
|
9
39
|
expect(plugin.hooks).toHaveProperty("config:setup");
|
|
10
40
|
expect(typeof plugin.hooks["config:setup"]).toBe("function");
|
|
11
41
|
});
|
|
42
|
+
|
|
43
|
+
test("Starlight's own sidebar is kept when multi-sidebar is not configured", async () => {
|
|
44
|
+
const config = await setupConfig();
|
|
45
|
+
|
|
46
|
+
expect(config.components).not.toHaveProperty("Sidebar");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("the sidebar is overridden when multi-sidebar is configured", async () => {
|
|
50
|
+
const config = await setupConfig({ multiSidebar: { switcherStyle: "dropdown" } });
|
|
51
|
+
|
|
52
|
+
expect(config.components?.Sidebar).toBe("starlight-theme-celestia/components/Sidebar.astro");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("a user override of the sidebar wins over the multi-sidebar one", async () => {
|
|
56
|
+
const config = await setupConfig({ multiSidebar: {} }, { Sidebar: "./src/components/Sidebar.astro" });
|
|
57
|
+
|
|
58
|
+
expect(config.components?.Sidebar).toBe("./src/components/Sidebar.astro");
|
|
59
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { AstroConfig } from "astro";
|
|
|
3
3
|
import remarkCustomHeaderId from "remark-custom-header-id";
|
|
4
4
|
|
|
5
5
|
import { createShikiConfig } from "./shiki-config";
|
|
6
|
-
import type { ThemeCelestiaOptions } from "./user-options";
|
|
6
|
+
import type { MultiSidebarOptions, ThemeCelestiaOptions } from "./user-options";
|
|
7
7
|
import { vitePluginUserConfig } from "./virtual-user-config";
|
|
8
8
|
|
|
9
9
|
const components = {
|
|
@@ -25,7 +25,9 @@ const components = {
|
|
|
25
25
|
Head: "starlight-theme-celestia/components/Head.astro",
|
|
26
26
|
} as const satisfies Partial<StarlightConfig["components"]>;
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
const sidebarComponent = "starlight-theme-celestia/components/Sidebar.astro";
|
|
29
|
+
|
|
30
|
+
export type { MultiSidebarOptions, ThemeCelestiaOptions };
|
|
29
31
|
|
|
30
32
|
export default function starlightCelestiaTheme(options: ThemeCelestiaOptions = {}): StarlightPlugin {
|
|
31
33
|
return {
|
|
@@ -57,6 +59,9 @@ export default function starlightCelestiaTheme(options: ThemeCelestiaOptions = {
|
|
|
57
59
|
].filter(Boolean),
|
|
58
60
|
components: {
|
|
59
61
|
...components,
|
|
62
|
+
// Only override the sidebar when multi-sidebar is enabled, so Starlight's default keeps
|
|
63
|
+
// rendering for every other site.
|
|
64
|
+
...(options.multiSidebar ? { Sidebar: sidebarComponent } : {}),
|
|
60
65
|
...config.components,
|
|
61
66
|
},
|
|
62
67
|
expressiveCode: config.expressiveCode ?? false,
|
|
@@ -77,6 +82,7 @@ export default function starlightCelestiaTheme(options: ThemeCelestiaOptions = {
|
|
|
77
82
|
plugins: [
|
|
78
83
|
vitePluginUserConfig({
|
|
79
84
|
nav: options.nav,
|
|
85
|
+
multiSidebar: options.multiSidebar,
|
|
80
86
|
rootHref: astroConfig.root.toString(),
|
|
81
87
|
}),
|
|
82
88
|
],
|
package/src/user-options.ts
CHANGED
|
@@ -11,9 +11,32 @@ export interface NavItem {
|
|
|
11
11
|
href: string | Record<string, string>;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
export interface MultiSidebarOptions {
|
|
15
|
+
/**
|
|
16
|
+
* How the sidebar switcher is rendered.
|
|
17
|
+
*
|
|
18
|
+
* - `horizontalList`: one tab per sidebar above the sidebar entries.
|
|
19
|
+
* - `dropdown`: a compact select listing every sidebar.
|
|
20
|
+
* - `hidden`: no switcher, only the sidebar containing the current page is rendered.
|
|
21
|
+
*
|
|
22
|
+
* @default "horizontalList"
|
|
23
|
+
*/
|
|
24
|
+
switcherStyle?: "horizontalList" | "dropdown" | "hidden";
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
export interface ThemeCelestiaOptions {
|
|
15
28
|
nav?: NavItem[];
|
|
16
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Turn every top-level entry of the Starlight `sidebar` config into its own sidebar, with a
|
|
32
|
+
* switcher to move between them.
|
|
33
|
+
*
|
|
34
|
+
* Every top-level entry must be a group or autogenerated when this is set.
|
|
35
|
+
*
|
|
36
|
+
* @default undefined
|
|
37
|
+
*/
|
|
38
|
+
multiSidebar?: MultiSidebarOptions;
|
|
39
|
+
|
|
17
40
|
/**
|
|
18
41
|
* `starlight-theme-celestia` supports two styling systems: pure CSS and Tailwind
|
|
19
42
|
* CSS.
|
|
@@ -33,5 +56,6 @@ export interface ThemeCelestiaOptions {
|
|
|
33
56
|
*/
|
|
34
57
|
export interface ConfigSerialized {
|
|
35
58
|
nav?: NavItem[];
|
|
59
|
+
multiSidebar?: MultiSidebarOptions;
|
|
36
60
|
rootHref: string;
|
|
37
61
|
}
|