starlight-theme-celestia 0.2.4 → 0.3.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/README.md +9 -7
- package/lib/tailwind.gen.css +157 -290
- package/package.json +9 -6
- 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/shiki-config.test.ts +22 -0
- package/src/shiki-config.ts +51 -13
- package/src/user-options.ts +24 -0
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.0",
|
|
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",
|
|
@@ -42,19 +41,23 @@
|
|
|
42
41
|
"starlight-versions": "^0.9.0"
|
|
43
42
|
},
|
|
44
43
|
"peerDependencies": {
|
|
45
|
-
"@astrojs/starlight": "*"
|
|
44
|
+
"@astrojs/starlight": "*",
|
|
45
|
+
"typescript": "^5.5.0 || ^6.0.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependenciesMeta": {
|
|
48
48
|
"@astrojs/starlight": {
|
|
49
49
|
"optional": true
|
|
50
|
+
},
|
|
51
|
+
"typescript": {
|
|
52
|
+
"optional": true
|
|
50
53
|
}
|
|
51
54
|
},
|
|
52
55
|
"devDependencies": {
|
|
53
|
-
"@astrojs/starlight": "^0.
|
|
56
|
+
"@astrojs/starlight": "^0.41.0",
|
|
54
57
|
"@tailwindcss/cli": "^4.2.2",
|
|
55
58
|
"@tailwindcss/vite": "^4.2.2",
|
|
56
|
-
"@types/node": "^
|
|
57
|
-
"astro": "^
|
|
59
|
+
"@types/node": "^26.0.0",
|
|
60
|
+
"astro": "^7.0.2",
|
|
58
61
|
"tailwindcss": "^4.2.2",
|
|
59
62
|
"typescript": "^6.0.0"
|
|
60
63
|
}
|
|
@@ -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
|
],
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { createShikiConfig, isTwoslashCompatibleTsModule } from "./shiki-config";
|
|
4
|
+
|
|
5
|
+
test("a TypeScript module exposing the Node system API is usable for Twoslash", () => {
|
|
6
|
+
expect(isTwoslashCompatibleTsModule({ sys: { readFile: () => undefined } })).toBe(true);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("a missing or incompatible TypeScript module is rejected", () => {
|
|
10
|
+
expect(isTwoslashCompatibleTsModule(undefined)).toBe(false);
|
|
11
|
+
expect(isTwoslashCompatibleTsModule(null)).toBe(false);
|
|
12
|
+
// TypeScript 7 ships a native compiler without the `ts.sys` API.
|
|
13
|
+
expect(isTwoslashCompatibleTsModule({ version: "7.0.2" })).toBe(false);
|
|
14
|
+
expect(isTwoslashCompatibleTsModule({ sys: {} })).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("the Twoslash transformer is only added when requested", () => {
|
|
18
|
+
const withTwoslash = createShikiConfig({ twoslash: true }).transformers ?? [];
|
|
19
|
+
const withoutTwoslash = createShikiConfig({ twoslash: false }).transformers ?? [];
|
|
20
|
+
|
|
21
|
+
expect(withTwoslash.length).toBe(withoutTwoslash.length + 1);
|
|
22
|
+
});
|
package/src/shiki-config.ts
CHANGED
|
@@ -9,10 +9,13 @@ import {
|
|
|
9
9
|
import { transformerTwoslash } from "@shikijs/twoslash";
|
|
10
10
|
import type { ShikiTransformer } from "@shikijs/types";
|
|
11
11
|
import type { ShikiConfig } from "astro";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
12
13
|
import { createRenderer } from "shiki-twoslash-renderer";
|
|
13
14
|
|
|
14
15
|
import { transformerContainer } from "./shiki-transformer-container";
|
|
15
16
|
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
|
|
16
19
|
export function createShikiConfig(options: { twoslash: boolean }): ShikiConfig {
|
|
17
20
|
const transformers: ShikiTransformer[] = [
|
|
18
21
|
transformerMetaHighlight(),
|
|
@@ -23,19 +26,7 @@ export function createShikiConfig(options: { twoslash: boolean }): ShikiConfig {
|
|
|
23
26
|
transformerRemoveNotationEscape(),
|
|
24
27
|
|
|
25
28
|
transformerContainer(),
|
|
26
|
-
...(options.twoslash
|
|
27
|
-
? [
|
|
28
|
-
transformerTwoslash({
|
|
29
|
-
renderer: createRenderer(),
|
|
30
|
-
explicitTrigger: true,
|
|
31
|
-
twoslashOptions: {
|
|
32
|
-
compilerOptions: {
|
|
33
|
-
noUncheckedSideEffectImports: false,
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
}),
|
|
37
|
-
]
|
|
38
|
-
: []),
|
|
29
|
+
...(options.twoslash ? createTwoslashTransformers() : []),
|
|
39
30
|
];
|
|
40
31
|
|
|
41
32
|
return {
|
|
@@ -47,3 +38,50 @@ export function createShikiConfig(options: { twoslash: boolean }): ShikiConfig {
|
|
|
47
38
|
transformers: transformers,
|
|
48
39
|
};
|
|
49
40
|
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Twoslash needs the Node API surface of TypeScript, `ts.sys` in particular.
|
|
44
|
+
* Projects that do not pin `typescript` themselves can end up with the native
|
|
45
|
+
* TypeScript 7 package, which does not expose that API, so probe the resolved
|
|
46
|
+
* module before handing it to Twoslash.
|
|
47
|
+
*
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
export function isTwoslashCompatibleTsModule(tsModule: unknown): boolean {
|
|
51
|
+
const sys = (tsModule as { sys?: { readFile?: unknown } } | null | undefined)?.sys;
|
|
52
|
+
|
|
53
|
+
return typeof sys?.readFile === "function";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type TwoslashOptions = NonNullable<NonNullable<Parameters<typeof transformerTwoslash>[0]>["twoslashOptions"]>;
|
|
57
|
+
|
|
58
|
+
function createTwoslashTransformers(): ShikiTransformer[] {
|
|
59
|
+
let tsModule: unknown;
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
tsModule = require("typescript");
|
|
63
|
+
} catch {
|
|
64
|
+
tsModule = undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!isTwoslashCompatibleTsModule(tsModule)) {
|
|
68
|
+
console.warn(
|
|
69
|
+
"[starlight-theme-celestia] Twoslash code blocks are disabled because no compatible `typescript` installation was found. Add `typescript` (^5.5.0 || ^6.0.0) to your project to enable them.",
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return [
|
|
76
|
+
transformerTwoslash({
|
|
77
|
+
renderer: createRenderer(),
|
|
78
|
+
explicitTrigger: true,
|
|
79
|
+
twoslashOptions: {
|
|
80
|
+
tsModule: tsModule as TwoslashOptions["tsModule"],
|
|
81
|
+
compilerOptions: {
|
|
82
|
+
noUncheckedSideEffectImports: false,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
];
|
|
87
|
+
}
|