starlight-sidebar-topics-dropdown 0.1.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 ADDED
@@ -0,0 +1,15 @@
1
+ # `starlight-sidebar-topics-dropdown`
2
+
3
+ Starlight plugin to split your docs page into multiple subpages and switch between them with a dropdown menu in the sidebar.
4
+
5
+ ## Documentation
6
+
7
+ Want to get started immediately?
8
+
9
+ Check out the `starlight-sidebar-topics-dropdown` getting started guide.
10
+
11
+ ## License
12
+
13
+ Licensed under the MIT License, Copyright © trueberryless-org.
14
+
15
+ See [LICENSE](https://github.com/trueberryless-org/starlight-sidebar-topics-dropdown/blob/main/LICENSE) for more information.
@@ -0,0 +1,253 @@
1
+ ---
2
+ import { Badge, Icon } from '@astrojs/starlight/components'
3
+ import { AstroError } from 'astro/errors'
4
+ import type { ComponentProps } from 'astro/types'
5
+ import { getRelativeLocaleUrl } from 'astro:i18n'
6
+ import starlightConfig from 'virtual:starlight/user-config'
7
+ import config from 'virtual:starlight-sidebar-topics-dropdown/config'
8
+
9
+ import type { StarlightSidebarTopicsDropdownSharedConfig } from '../lib/config'
10
+
11
+ interface Props {
12
+ current: StarlightSidebarTopicsDropdownSharedConfig[number]
13
+ }
14
+
15
+ const { current } = Astro.props
16
+
17
+ const defaultLang = starlightConfig.defaultLocale?.lang || starlightConfig.defaultLocale?.locale || 'en'
18
+
19
+ function getTranslation(translations: Record<string, string>, link: string, description: string) {
20
+ const defaultTranslation = translations[defaultLang]
21
+
22
+ if (!defaultTranslation) {
23
+ throw new AstroError(
24
+ `The ${description} for "${link}" must have a key for the default language "${defaultLang}".`,
25
+ 'Update the Starlight config to include a topic label for the default language.',
26
+ )
27
+ }
28
+
29
+ let translation = defaultTranslation
30
+
31
+ if (Astro.currentLocale) {
32
+ translation = translations[Astro.currentLocale] ?? defaultTranslation
33
+ }
34
+
35
+ return translation
36
+ }
37
+ ---
38
+
39
+ <div class="starlight-sidebar-topics-dropdown-dropdown">
40
+ <button id="starlight-sidebar-topics-dropdown-button" class="starlight-sidebar-topics-dropdown-button">
41
+ {current.icon && (
42
+ <div class="starlight-sidebar-topics-dropdown-button-icon">
43
+ <Icon name={current.icon as ComponentProps<typeof Icon>['name']} />
44
+ </div>
45
+ )}
46
+ <span class="starlight-sidebar-topics-dropdown-button-label">{typeof current.label === 'string' ? current.label : getTranslation(current.label, current.link, 'topic label')}</span>
47
+ {current.badge && (
48
+ <Badge
49
+ class="starlight-sidebar-topics-dropdown-badge"
50
+ text={
51
+ typeof current.badge.text === 'string'
52
+ ? current.badge.text
53
+ : getTranslation(current.badge.text, current.link, 'current badge text')
54
+ }
55
+ variant={current.badge.variant}
56
+ />
57
+ )}
58
+ <svg width="16" height="16" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 opacity-50" aria-hidden="true"><path d="M4.93179 5.43179C4.75605 5.60753 4.75605 5.89245 4.93179 6.06819C5.10753 6.24392 5.39245 6.24392 5.56819 6.06819L7.49999 4.13638L9.43179 6.06819C9.60753 6.24392 9.89245 6.24392 10.0682 6.06819C10.2439 5.89245 10.2439 5.60753 10.0682 5.43179L7.81819 3.18179C7.73379 3.0974 7.61933 3.04999 7.49999 3.04999C7.38064 3.04999 7.26618 3.0974 7.18179 3.18179L4.93179 5.43179ZM10.0682 9.56819C10.2439 9.39245 10.2439 9.10753 10.0682 8.93179C9.89245 8.75606 9.60753 8.75606 9.43179 8.93179L7.49999 10.8636L5.56819 8.93179C5.39245 8.75606 5.10753 8.75606 4.93179 8.93179C4.75605 9.10753 4.75605 9.39245 4.93179 9.56819L7.18179 11.8182C7.35753 11.9939 7.64245 11.9939 7.81819 11.8182L10.0682 9.56819Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>
59
+ </button>
60
+
61
+ <div id="starlight-sidebar-topics-dropdown-menu" class="starlight-sidebar-topics-dropdown-menu" hidden>
62
+ {
63
+ config.map((topic) => {
64
+ const isLinkTopic = topic.type === 'link'
65
+ const isCurrent = isLinkTopic ? false : topic.label === current.label && topic.link === current.link
66
+ const link =
67
+ !isLinkTopic && Astro.currentLocale ? getRelativeLocaleUrl(Astro.currentLocale, topic.link) : topic.link
68
+ const label =
69
+ typeof topic.label === 'string' ? topic.label : getTranslation(topic.label, topic.link, 'topic label')
70
+
71
+ return (
72
+ <a href={link} class:list={{ 'starlight-sidebar-topics-dropdown-item': true, 'starlight-sidebar-topics-dropdown-current-item': isCurrent }}>
73
+ {topic.icon && (
74
+ <div class="starlight-sidebar-topics-dropdown-icon">
75
+ <Icon name={topic.icon as ComponentProps<typeof Icon>['name']} />
76
+ </div>
77
+ )}
78
+ <span class="starlight-sidebar-topics-dropdown-label">{label}</span>
79
+ {topic.badge && (
80
+ <Badge
81
+ class="starlight-sidebar-topics-dropdown-badge"
82
+ text={
83
+ typeof topic.badge.text === 'string'
84
+ ? topic.badge.text
85
+ : getTranslation(topic.badge.text, topic.link, 'topic badge text')
86
+ }
87
+ variant={topic.badge.variant}
88
+ />
89
+ )}
90
+ </a>
91
+ )
92
+ })
93
+ }
94
+ </div>
95
+ </div>
96
+
97
+ <style>
98
+ .starlight-sidebar-topics-dropdown-dropdown {
99
+ position: relative;
100
+ display: block;
101
+ width: calc(100% - 8px - 2 * 2px);
102
+ margin-left: calc(8px + 2px);
103
+ margin-right: calc(2px);
104
+ }
105
+
106
+ .starlight-sidebar-topics-dropdown-button {
107
+ display: flex;
108
+ align-items: center;
109
+ background-color: var(--sl-color-gray-6);
110
+ border: none;
111
+ border-radius: 0.25rem;
112
+ color: var(--sl-color-white);
113
+ padding: 0.3rem 0.5rem;
114
+ cursor: pointer;
115
+ width: 100%;
116
+ }
117
+
118
+ .starlight-sidebar-topics-dropdown-button::before {
119
+ content: '';
120
+ background-color: var(--sl-color-gray-5);
121
+ border-radius: 0.25rem;
122
+ inset: -2px;
123
+ position: absolute;
124
+ z-index: -1;
125
+ }
126
+
127
+
128
+ .starlight-sidebar-topics-dropdown-button:has(.starlight-sidebar-topics-dropdown-button-icon)::before {
129
+ content: '';
130
+ background-image: linear-gradient(to right, var(--sl-color-text-accent) calc(1.4rem + 16px + 2px), var(--sl-color-gray-5) 0);
131
+ }
132
+
133
+ .starlight-sidebar-topics-dropdown-button:has(.starlight-sidebar-topics-dropdown-button-icon) {
134
+ background: linear-gradient(to right, var(--sl-color-text-accent) calc(1.4rem + 16px), var(--sl-color-gray-6) 0);
135
+ }
136
+
137
+ .starlight-sidebar-topics-dropdown-button-icon {
138
+ align-items: center;
139
+ border-radius: 0.25rem;
140
+ display: flex;
141
+ justify-content: center;
142
+ padding: 0.2rem;
143
+ background-color: var(--sl-color-text-accent);
144
+ color: var(--sl-color-text-invert);
145
+ }
146
+
147
+ .starlight-sidebar-topics-dropdown-button-label {
148
+ font-weight: 600;
149
+ margin-right: 0.5rem;
150
+ overflow: hidden;
151
+ text-overflow: ellipsis;
152
+ white-space: nowrap;
153
+ }
154
+
155
+ .starlight-sidebar-topics-dropdown-button:has(.starlight-sidebar-topics-dropdown-button-icon) .starlight-sidebar-topics-dropdown-button-label {
156
+ font-weight: 600;
157
+ margin-left: 1rem;
158
+ }
159
+
160
+ .starlight-sidebar-topics-dropdown-label {
161
+ font-weight: 600;
162
+ margin-right: 0.5rem;
163
+ overflow: hidden;
164
+ text-overflow: ellipsis;
165
+ white-space: nowrap;
166
+ }
167
+
168
+ .starlight-sidebar-topics-dropdown-item:has(.starlight-sidebar-topics-dropdown-icon) .starlight-sidebar-topics-dropdown-label {
169
+ margin-left: 0.5rem;
170
+ }
171
+
172
+ .starlight-sidebar-topics-dropdown-button>svg {
173
+ margin-left: auto;
174
+ font-size: 1rem;
175
+ color: var(--sl-color-white);
176
+ }
177
+
178
+ .starlight-sidebar-topics-dropdown-menu {
179
+ display: block;
180
+ position: absolute;
181
+ margin-top: 0.25rem;
182
+ width: 100%;
183
+ background-color: var(--sl-color-gray-6);
184
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
185
+ min-width: 200px;
186
+ padding: 0.5rem;
187
+ border-radius: 0.25rem;
188
+ z-index: 1000000;
189
+ }
190
+
191
+ .starlight-sidebar-topics-dropdown-menu[hidden] {
192
+ display: none;
193
+ }
194
+
195
+ .starlight-sidebar-topics-dropdown-item {
196
+ display: flex;
197
+ align-items: center;
198
+ padding: 0.3em;
199
+ color: var(--sl-color-white);
200
+ text-decoration: none;
201
+ font-weight: 600;
202
+ }
203
+
204
+ .starlight-sidebar-topics-dropdown-icon {
205
+ align-items: center;
206
+ border-radius: 0.25rem;
207
+ border: 1px solid var(--sl-color-gray-4);
208
+ display: flex;
209
+ justify-content: center;
210
+ padding: 0.25rem;
211
+ }
212
+
213
+ .starlight-sidebar-topics-dropdown-button:is(.starlight-sidebar-topics-dropdown-current, :hover, :focus-visible) .starlight-sidebar-topics-dropdown-icon {
214
+ background-color: var(--sl-color-text-accent);
215
+ border-color: var(--sl-color-text-accent);
216
+ color: var(--sl-color-text-invert);
217
+ }
218
+
219
+ .starlight-sidebar-topics-dropdown-item:is(:hover, .starlight-sidebar-topics-dropdown-current-item) .starlight-sidebar-topics-dropdown-icon {
220
+ background-color: var(--sl-color-text-accent);
221
+ border-color: var(--sl-color-text-accent);
222
+ color: var(--sl-color-text-invert);
223
+ }
224
+
225
+ .starlight-sidebar-topics-dropdown-badge {
226
+ margin-left: 0.25rem;
227
+ overflow: hidden;
228
+ text-overflow: ellipsis;
229
+ white-space: nowrap;
230
+ }
231
+ </style>
232
+
233
+ <script>
234
+ const dropdownButton = document.getElementById('starlight-sidebar-topics-dropdown-button');
235
+ const dropdownMenu = document.getElementById('starlight-sidebar-topics-dropdown-menu');
236
+
237
+ if (!dropdownButton || !dropdownMenu) {
238
+ throw new Error('Dropdown elements not found');
239
+ }
240
+
241
+ dropdownButton.addEventListener('click', () => {
242
+ const isOpen = !dropdownMenu.hidden;
243
+ dropdownMenu.hidden = isOpen;
244
+ });
245
+
246
+ function closeDropdown(event) {
247
+ if (dropdownMenu && !dropdownMenu.contains(event.target) && dropdownButton && !dropdownButton.contains(event.target)) {
248
+ dropdownMenu.hidden = true;
249
+ }
250
+ }
251
+
252
+ document.addEventListener('click', closeDropdown);
253
+ </script>
package/index.ts ADDED
@@ -0,0 +1,82 @@
1
+ import type { StarlightPlugin, StarlightUserConfig } from "@astrojs/starlight/types";
2
+
3
+ import {
4
+ StarlightSidebarTopicsDropdownConfigSchema,
5
+ type StarlightSidebarTopicsDropdownUserConfig,
6
+ } from "./lib/config";
7
+ import { overrideStarlightComponent, throwPluginError } from "./lib/plugin";
8
+ import { vitePluginStarlightSidebarTopicsDropdown } from "./lib/vite";
9
+
10
+ export type {
11
+ StarlightSidebarTopicsDropdownConfig,
12
+ StarlightSidebarTopicsDropdownUserConfig,
13
+ } from "./lib/config";
14
+
15
+ export default function starlightSidebarTopicsDropdownPlugin(
16
+ userConfig: StarlightSidebarTopicsDropdownUserConfig
17
+ ): StarlightPlugin {
18
+ const parsedConfig = StarlightSidebarTopicsDropdownConfigSchema.safeParse(userConfig);
19
+
20
+ if (!parsedConfig.success) {
21
+ throwPluginError(
22
+ `The provided plugin configuration is invalid.\n${parsedConfig.error.issues
23
+ .map((issue) => issue.message)
24
+ .join("\n")}`
25
+ );
26
+ }
27
+
28
+ const config = parsedConfig.data;
29
+
30
+ return {
31
+ name: "starlight-sidebar-topics-dropdown-plugin",
32
+ hooks: {
33
+ setup({ addIntegration, command, config: starlightConfig, logger, updateConfig }) {
34
+ if (command !== "dev" && command !== "build") return;
35
+
36
+ if (starlightConfig.sidebar) {
37
+ throwPluginError(
38
+ "It looks like you have a `sidebar` configured in your Starlight configuration. To use `starlight-sidebar-topics-dropdown`, create a new topic with your sidebar items.",
39
+ "Learn more about topic configuration at https://starlight-sidebar-topics-dropdown.netlify.app/docs/configuration/"
40
+ );
41
+ }
42
+
43
+ const sidebar: StarlightUserConfig["sidebar"] = [];
44
+
45
+ for (const [index, topic] of config.entries()) {
46
+ if ("items" in topic) {
47
+ sidebar.push({ label: String(index), items: topic.items });
48
+ }
49
+ }
50
+ updateConfig({
51
+ components: {
52
+ ...starlightConfig.components,
53
+ ...overrideStarlightComponent(
54
+ starlightConfig.components,
55
+ logger,
56
+ "Sidebar"
57
+ ),
58
+ ...overrideStarlightComponent(
59
+ starlightConfig.components,
60
+ logger,
61
+ "Pagination"
62
+ ),
63
+ },
64
+ sidebar,
65
+ });
66
+
67
+ addIntegration({
68
+ name: "starlight-sidebar-topics-dropdown-integration",
69
+ hooks: {
70
+ "astro:config:setup": ({ updateConfig }) => {
71
+ updateConfig({
72
+ vite: {
73
+ plugins: [vitePluginStarlightSidebarTopicsDropdown(config)],
74
+ },
75
+ });
76
+ },
77
+ },
78
+ });
79
+ },
80
+ },
81
+ };
82
+ }
package/lib/config.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { StarlightUserConfig } from "@astrojs/starlight/types";
2
+ import { z } from "astro/zod";
3
+
4
+ const sidebarTopicBadgeSchema = z.object({
5
+ text: z.union([z.string(), z.record(z.string())]),
6
+ variant: z.enum(["note", "danger", "success", "caution", "tip", "default"]).default("default"),
7
+ });
8
+
9
+ const sidebarTopicBaseSchema = z.object({
10
+ /**
11
+ * An optional badge to display next to the topic label.
12
+ *
13
+ * This option accepts the same configuration as the Starlight badge sidebar item configuration.
14
+ * @see https://starlight.astro.build/guides/sidebar/#badges
15
+ */
16
+ badge: z
17
+ .union([z.string(), sidebarTopicBadgeSchema])
18
+ .transform((badge) =>
19
+ typeof badge === "string" ? { variant: "default" as const, text: badge } : badge
20
+ )
21
+ .optional(),
22
+ /**
23
+ * The name of an optional icon to display before the topic label set to one of Starlight’s built-in icons.
24
+ * @see https://starlight.astro.build/reference/icons/#all-icons
25
+ */
26
+ icon: z.string().optional(),
27
+ /**
28
+ * The topic label visible at the top of the sidebar.
29
+ *
30
+ * The value can be a string, or for multilingual sites, an object with values for each different locale. When using
31
+ * the object form, the keys must be BCP-47 tags (e.g. en, fr, or zh-CN).
32
+ */
33
+ label: z.union([z.string(), z.record(z.string())]),
34
+ /**
35
+ * The link to the topic’s content which an be a relative link to local files or the full URL of an external page.
36
+ *
37
+ * For internal links, the link can either be a page included in the items array or a different page acting as the
38
+ * topic’s landing page.
39
+ */
40
+ link: z.string(),
41
+ });
42
+
43
+ const sidebarTopicLinkSchema = sidebarTopicBaseSchema;
44
+
45
+ const sidebarTopicGroupSchema = sidebarTopicBaseSchema.extend({
46
+ /**
47
+ * The sidebar items (links and subcategories) to display for this topic.
48
+ *
49
+ * The topic’s sidebar navigation items. This represents the sidebar displayed when the topic `link` page or any of
50
+ * the pages configured in the `items` array is the current page.
51
+ */
52
+ items: z.any().array() as z.Schema<NonNullable<StarlightUserConfig["sidebar"]>>,
53
+ });
54
+
55
+ export const StarlightSidebarTopicsDropdownConfigSchema = z
56
+ .union([sidebarTopicGroupSchema, sidebarTopicLinkSchema])
57
+ .array();
58
+
59
+ export type StarlightSidebarTopicsDropdownUserConfig = z.input<
60
+ typeof StarlightSidebarTopicsDropdownConfigSchema
61
+ >;
62
+ export type StarlightSidebarTopicsDropdownConfig = z.output<
63
+ typeof StarlightSidebarTopicsDropdownConfigSchema
64
+ >;
65
+
66
+ export type StarlightSidebarTopicsDropdownSharedConfig = (
67
+ | (z.output<typeof sidebarTopicLinkSchema> & { type: "link" })
68
+ | (Omit<z.output<typeof sidebarTopicGroupSchema>, "items"> & { type: "group" })
69
+ )[];
package/lib/i18n.ts ADDED
@@ -0,0 +1,22 @@
1
+ import starlightConfig from "virtual:starlight/user-config";
2
+
3
+ import { stripTrailingSlash } from "./pathname";
4
+
5
+ export function getLocalizedSlug(slug: string, locale: string | undefined): string {
6
+ const slugLocale = getLocaleFromSlug(slug);
7
+ if (slugLocale === locale) return slug;
8
+ locale ??= "";
9
+ if (slugLocale === slug) return locale;
10
+
11
+ if (slugLocale) {
12
+ return stripTrailingSlash(slug.replace(`${slugLocale}/`, locale ? `${locale}/` : ""));
13
+ }
14
+
15
+ return slug ? `${locale}/${slug}` : locale;
16
+ }
17
+
18
+ export function getLocaleFromSlug(slug: string): string | undefined {
19
+ const locales = Object.keys(starlightConfig.locales ?? {});
20
+ const baseSegment = slug.split("/")[0];
21
+ return baseSegment && locales.includes(baseSegment) ? baseSegment : undefined;
22
+ }
@@ -0,0 +1,17 @@
1
+ export function arePathnamesEqual(pathnameA: string, pathnameB: string) {
2
+ return stripLeadingAndTrailingSlashes(pathnameA) === stripLeadingAndTrailingSlashes(pathnameB);
3
+ }
4
+
5
+ export function stripLeadingAndTrailingSlashes(pathname: string): string {
6
+ return stripLeadingSlash(stripTrailingSlash(pathname));
7
+ }
8
+
9
+ export function stripLeadingSlash(pathname: string) {
10
+ if (pathname.startsWith("/")) pathname = pathname.slice(1);
11
+ return pathname;
12
+ }
13
+
14
+ export function stripTrailingSlash(pathname: string) {
15
+ if (pathname.endsWith("/")) pathname = pathname.slice(0, -1);
16
+ return pathname;
17
+ }
package/lib/plugin.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type { StarlightUserConfig } from "@astrojs/starlight/types";
2
+ import type { AstroIntegrationLogger } from "astro";
3
+ import { AstroError } from "astro/errors";
4
+
5
+ export function throwPluginError(message: string, hint?: string): never {
6
+ throw new AstroError(
7
+ message,
8
+ hint ??
9
+ `See the error report above for more informations.\n\nIf you believe this is a bug, please file an issue at https://github.com/HiDeoo/starlight-sidebar-topics-dropdown/issues/new/choose`
10
+ );
11
+ }
12
+
13
+ export function overrideStarlightComponent(
14
+ components: StarlightUserConfig["components"],
15
+ logger: AstroIntegrationLogger,
16
+ component: keyof NonNullable<StarlightUserConfig["components"]>
17
+ ) {
18
+ if (components?.[component]) {
19
+ logger.warn(
20
+ `It looks like you already have a \`${component}\` component override in your Starlight configuration.`
21
+ );
22
+ logger.warn(
23
+ `To use \`starlight-sidebar-topics-dropdown\`, either remove your override or update it to render the content from \`starlight-sidebar-topics-dropdown/overrides/${component}.astro\`.`
24
+ );
25
+
26
+ return {};
27
+ }
28
+
29
+ return {
30
+ [component]: `starlight-sidebar-topics-dropdown/overrides/${component}.astro`,
31
+ };
32
+ }
package/lib/sidebar.ts ADDED
@@ -0,0 +1,140 @@
1
+ import type { Props } from "@astrojs/starlight/props";
2
+
3
+ import type { StarlightSidebarTopicsDropdownSharedConfig } from "./config";
4
+ import { getLocaleFromSlug, getLocalizedSlug } from "./i18n";
5
+ import { arePathnamesEqual, stripLeadingAndTrailingSlashes } from "./pathname";
6
+
7
+ const absoluteLinkRegex = /^https?:\/\//;
8
+
9
+ export function getCurrentTopic(
10
+ config: StarlightSidebarTopicsDropdownSharedConfig,
11
+ sidebar: SidebarEntry[],
12
+ currentSlug: string
13
+ ): Topic | undefined {
14
+ // Start by checking if the current page is a topic root.
15
+ const topicFromSlug = getTopicFromSlug(config, sidebar, currentSlug);
16
+ if (topicFromSlug) return topicFromSlug;
17
+
18
+ // Otherwise, find the current topic by looking for the current page in the sidebar.
19
+ const currentSidebarTopic = getCurrentSidebarTopic(sidebar);
20
+ if (!currentSidebarTopic) return;
21
+
22
+ const currentTopicConfig = config[Number.parseInt(currentSidebarTopic.label, 10)];
23
+ if (!currentTopicConfig) return;
24
+
25
+ return { config: currentTopicConfig, sidebar: currentSidebarTopic.entries };
26
+ }
27
+
28
+ export function isTopicFirstPage(sidebar: SidebarEntry[], currentSlug: string): boolean {
29
+ const currentSidebarTopic = getCurrentSidebarTopic(sidebar);
30
+ if (!currentSidebarTopic) return false;
31
+
32
+ const firstPage = getSidebarFirstPage(currentSidebarTopic.entries);
33
+ if (!firstPage) return false;
34
+
35
+ return arePathnamesEqual(stripLeadingAndTrailingSlashes(firstPage.href), currentSlug);
36
+ }
37
+
38
+ export function isTopicLastPage(sidebar: SidebarEntry[], currentSlug: string): boolean {
39
+ const currentSidebarTopic = getCurrentSidebarTopic(sidebar);
40
+ if (!currentSidebarTopic) return false;
41
+
42
+ const lastPage = getSidebarLastPage(currentSidebarTopic.entries);
43
+ if (!lastPage) return false;
44
+
45
+ return arePathnamesEqual(stripLeadingAndTrailingSlashes(lastPage.href), currentSlug);
46
+ }
47
+
48
+ function getSidebarFirstPage(sidebar: SidebarEntry[]) {
49
+ const entry = sidebar[0];
50
+ if (!entry) return;
51
+ if (entry.type === "link") return entry;
52
+
53
+ return getSidebarFirstPage(entry.entries);
54
+ }
55
+
56
+ function getSidebarLastPage(sidebar: SidebarEntry[]) {
57
+ const entry = sidebar.at(-1);
58
+ if (!entry) return;
59
+ if (entry.type === "link") return entry;
60
+
61
+ return getSidebarLastPage(entry.entries);
62
+ }
63
+
64
+ function getTopicFromSlug(
65
+ config: StarlightSidebarTopicsDropdownSharedConfig,
66
+ sidebar: SidebarEntry[],
67
+ slug: string
68
+ ): Topic | undefined {
69
+ let topicConfig: Topic["config"] | undefined;
70
+ let topicSidebar: Topic["sidebar"] | undefined;
71
+
72
+ // Start by checking if the current page is a topic homepage.
73
+ let groupTopicIndex = -1;
74
+ const slugLocale = getLocaleFromSlug(slug);
75
+
76
+ for (const topic of config) {
77
+ if (topic.type === "group") groupTopicIndex++;
78
+
79
+ if (
80
+ !absoluteLinkRegex.test(topic.link) &&
81
+ arePathnamesEqual(
82
+ getLocalizedSlug(stripLeadingAndTrailingSlashes(topic.link), slugLocale),
83
+ slug
84
+ ) &&
85
+ groupTopicIndex !== -1
86
+ ) {
87
+ const sidebarTopic = sidebar[groupTopicIndex];
88
+
89
+ if (sidebarTopic?.type === "group") {
90
+ topicConfig = topic;
91
+ topicSidebar = sidebarTopic.entries;
92
+ }
93
+
94
+ break;
95
+ }
96
+ }
97
+
98
+ if (!topicConfig || !topicSidebar) return;
99
+
100
+ return { config: topicConfig, sidebar: topicSidebar };
101
+ }
102
+
103
+ function getCurrentSidebarTopic(sidebar: SidebarEntry[]): SidebarTopic | undefined {
104
+ let currentSidebarTopic: SidebarTopic | undefined;
105
+
106
+ for (const topic of sidebar) {
107
+ if (topic.type === "link") continue;
108
+
109
+ const currentSidebarEntry = getCurrentSidebarEntry(topic.entries);
110
+
111
+ if (currentSidebarEntry) {
112
+ currentSidebarTopic = topic;
113
+ break;
114
+ }
115
+ }
116
+
117
+ return currentSidebarTopic;
118
+ }
119
+
120
+ function getCurrentSidebarEntry(sidebar: SidebarEntry[]): SidebarEntry | undefined {
121
+ return sidebar.find((entry) => {
122
+ if (entry.type === "link") {
123
+ return entry.isCurrent;
124
+ }
125
+
126
+ return getCurrentSidebarEntry(entry.entries);
127
+ });
128
+ }
129
+
130
+ type SidebarEntry = Props["sidebar"][number];
131
+
132
+ interface SidebarTopic {
133
+ label: string;
134
+ entries: SidebarEntry[];
135
+ }
136
+
137
+ interface Topic {
138
+ config: StarlightSidebarTopicsDropdownSharedConfig[number];
139
+ sidebar: SidebarEntry[];
140
+ }
package/lib/vite.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { ViteUserConfig } from "astro";
2
+
3
+ import type {
4
+ StarlightSidebarTopicsDropdownConfig,
5
+ StarlightSidebarTopicsDropdownSharedConfig,
6
+ } from "./config";
7
+
8
+ const moduleId = "virtual:starlight-sidebar-topics-dropdown/config";
9
+
10
+ export function vitePluginStarlightSidebarTopicsDropdown(
11
+ config: StarlightSidebarTopicsDropdownConfig
12
+ ): VitePlugin {
13
+ const resolvedModuleId = `\0${moduleId}`;
14
+
15
+ const sharedConfig: StarlightSidebarTopicsDropdownSharedConfig = config.map((topic) => {
16
+ if (!("items" in topic)) return { ...topic, type: "link" };
17
+ const { items, ...topicWithoutItems } = topic;
18
+ return { ...topicWithoutItems, type: "group" };
19
+ });
20
+
21
+ const moduleContent = `export default ${JSON.stringify(sharedConfig)}`;
22
+
23
+ return {
24
+ name: "vite-plugin-starlight-sidebar-topics-dropdown",
25
+ load(id) {
26
+ return id === resolvedModuleId ? moduleContent : undefined;
27
+ },
28
+ resolveId(id) {
29
+ return id === moduleId ? resolvedModuleId : undefined;
30
+ },
31
+ };
32
+ }
33
+
34
+ type VitePlugin = NonNullable<ViteUserConfig["plugins"]>[number];
@@ -0,0 +1,19 @@
1
+ ---
2
+ import Default from '@astrojs/starlight/components/Pagination.astro'
3
+ import type { Props } from '@astrojs/starlight/props'
4
+
5
+ import { isTopicFirstPage, isTopicLastPage } from '../lib/sidebar'
6
+
7
+ const { pagination, sidebar, slug } = Astro.props
8
+ let { prev, next } = pagination
9
+
10
+ if (isTopicFirstPage(sidebar, slug)) {
11
+ prev = undefined
12
+ }
13
+
14
+ if (isTopicLastPage(sidebar, slug)) {
15
+ next = undefined
16
+ }
17
+ ---
18
+
19
+ <Default {...Astro.props} pagination={{ prev, next }}><slot /></Default>
@@ -0,0 +1,22 @@
1
+ ---
2
+ import Default from '@astrojs/starlight/components/Sidebar.astro'
3
+ import type { Props } from '@astrojs/starlight/props'
4
+ import config from 'virtual:starlight-sidebar-topics-dropdown/config'
5
+
6
+ import Topics from '../components/Topics.astro'
7
+ import { throwPluginError } from '../lib/plugin'
8
+ import { getCurrentTopic } from '../lib/sidebar'
9
+
10
+ const { entry, sidebar, slug } = Astro.props
11
+
12
+ let currentSidebar = sidebar
13
+ const currentTopic = getCurrentTopic(config, sidebar, slug)
14
+
15
+ if (entry['data'].template !== 'splash') {
16
+ if (!currentTopic) throwPluginError('Failed to find the topic for the current page.')
17
+ currentSidebar = currentTopic.sidebar
18
+ }
19
+ ---
20
+
21
+ {entry['data'].template !== 'splash' && currentTopic && <Topics current={currentTopic.config} />}
22
+ <Default {...Astro.props} sidebar={currentSidebar}><slot /></Default>
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "starlight-sidebar-topics-dropdown",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "Starlight plugin to split your docs page into multiple subpages and switch between them with a dropdown menu in the sidebar.",
6
+ "author": "trueberryless (https://trueberryless.org)",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./index.ts",
10
+ "./overrides/Pagination.astro": "./overrides/Pagination.astro",
11
+ "./overrides/Sidebar.astro": "./overrides/Sidebar.astro",
12
+ "./package.json": "./package.json"
13
+ },
14
+ "devDependencies": {
15
+ "@astrojs/starlight": "^0.28.3",
16
+ "astro": "^4.16.6"
17
+ },
18
+ "peerDependencies": {
19
+ "@astrojs/starlight": ">=0.28"
20
+ },
21
+ "engines": {
22
+ "node": "^18.17.1 || ^20.3.0 || >=21.0.0"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "keywords": [
28
+ "starlight",
29
+ "plugin",
30
+ "sidebar",
31
+ "subpages",
32
+ "sections",
33
+ "dropdown",
34
+ "documentation",
35
+ "astro"
36
+ ],
37
+ "homepage": "https://github.com/trueberryless-org/starlight-sidebar-topics-dropdown",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/trueberryless-org/starlight-sidebar-topics-dropdown.git",
41
+ "directory": "packages/starlight-sidebar-topics-dropdown"
42
+ },
43
+ "bugs": "https://github.com/trueberryless-org/starlight-sidebar-topics-dropdown/issues"
44
+ }
package/starlight.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ declare module "virtual:starlight/user-config" {
2
+ const Config: import("@astrojs/starlight/types").StarlightConfig;
3
+
4
+ export default Config;
5
+ }
package/virtual.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ declare module "virtual:starlight-sidebar-topics-dropdown/config" {
2
+ const StarlightSidebarTopicsDropdownConfig: import("./libs/config").StarlightSidebarTopicsDropdownSharedConfig;
3
+
4
+ export default StarlightSidebarTopicsDropdownConfig;
5
+ }