radiant-docs 0.1.61 → 0.1.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/template/astro.config.mjs +38 -27
  3. package/template/package-lock.json +2858 -1140
  4. package/template/package.json +18 -13
  5. package/template/scripts/generate-proxy-allowed-origins.mjs +10 -179
  6. package/template/scripts/publish-shiki-platform-assets.mjs +1177 -0
  7. package/template/src/components/Header.astro +6 -1
  8. package/template/src/components/NavigationTabList.astro +65 -0
  9. package/template/src/components/NavigationTabs.astro +109 -0
  10. package/template/src/components/OpenApiPage.astro +17 -1
  11. package/template/src/components/Sidebar.astro +2 -2
  12. package/template/src/components/SidebarDropdown.astro +105 -44
  13. package/template/src/components/SidebarMenu.astro +3 -0
  14. package/template/src/components/SidebarSegmented.astro +87 -52
  15. package/template/src/components/SidebarTabs.astro +86 -0
  16. package/template/src/components/chat/AssistantDocsWidget.tsx +127 -2
  17. package/template/src/components/chat/AssistantEmbedPanel.tsx +401 -283
  18. package/template/src/components/endpoint/PlaygroundForm.astro +69 -55
  19. package/template/src/components/endpoint/ResponseDisplay.astro +2 -2
  20. package/template/src/components/user/Accordion.astro +1 -1
  21. package/template/src/components/user/Callout.astro +2 -2
  22. package/template/src/components/user/CodeBlock.astro +58 -7
  23. package/template/src/components/user/CodeGroup.astro +52 -1
  24. package/template/src/components/user/Column.astro +1 -1
  25. package/template/src/components/user/Step.astro +1 -1
  26. package/template/src/components/user/Tabs.astro +1 -1
  27. package/template/src/generated/shiki-platform-assets.json +24 -0
  28. package/template/src/layouts/Layout.astro +111 -8
  29. package/template/src/lib/assistant-panel-config.ts +4 -0
  30. package/template/src/lib/assistant-shiki-client.ts +522 -0
  31. package/template/src/lib/client-shiki-config.ts +60 -0
  32. package/template/src/lib/dev-playground-proxy.mjs +597 -0
  33. package/template/src/lib/mdx/remark-resolve-internal-links.ts +334 -17
  34. package/template/src/lib/proxy-allowed-origins.mjs +189 -0
  35. package/template/src/lib/routes.ts +66 -24
  36. package/template/src/styles/global.css +16 -4
  37. package/template/src/components/ui/demo/CodeDemo.astro +0 -15
  38. package/template/src/components/ui/demo/Demo.astro +0 -3
  39. package/template/src/components/ui/demo/UiDisplay.astro +0 -13
@@ -162,7 +162,12 @@ const navbarPrimaryHref = config.navbar?.primary
162
162
  <div
163
163
  class="min-w-0 w-full flex items-center justify-end md:justify-between gap-3 px-4 sm:px-6"
164
164
  >
165
- <div class="flex items-center gap-2 mx-0 md:mx-auto lg:mx-0">
165
+ <div
166
+ class:list={[
167
+ "flex items-center gap-2 mx-0",
168
+ "md:mx-auto lg:mx-0",
169
+ ]}
170
+ >
166
171
  <Search />
167
172
  {
168
173
  showAssistantNavbarButton ? (
@@ -0,0 +1,65 @@
1
+ ---
2
+ import type { NavTabs } from "../lib/validation";
3
+ import Icon from "./ui/Icon.astro";
4
+
5
+ type NavTabItem = NavTabs["items"][number];
6
+
7
+ interface Props {
8
+ items: NavTabItem[];
9
+ currentIndex: number;
10
+ hrefs?: string[];
11
+ mode?: "link" | "button";
12
+ stateName?: string;
13
+ }
14
+
15
+ const {
16
+ items,
17
+ currentIndex,
18
+ hrefs = [],
19
+ mode = "link",
20
+ stateName = "selectedTabIndex",
21
+ } = Astro.props;
22
+
23
+ const listClass = [
24
+ "flex h-full w-max min-w-full gap-6",
25
+ "items-end px-3 sm:px-4",
26
+ ];
27
+ const itemClass = "h-full min-w-max";
28
+ const controlBaseClass =
29
+ "flex h-full items-center gap-1.5 border-b-2 text-[13px] font-medium transition-colors duration-150 whitespace-nowrap";
30
+ const activeClass = "border-primary text-neutral-950 dark:text-white";
31
+ const inactiveClass =
32
+ "border-transparent text-neutral-500 hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-100";
33
+ ---
34
+
35
+ <ul class:list={listClass}>
36
+ {
37
+ items.map((item, index) => (
38
+ <li class={itemClass}>
39
+ {mode === "button" ? (
40
+ <button
41
+ type="button"
42
+ class={controlBaseClass}
43
+ x-bind:class={`${stateName} === ${index} ? '${activeClass}' : '${inactiveClass}'`}
44
+ x-bind:aria-selected={`${stateName} === ${index}`}
45
+ x-on:click={`${stateName} = ${index}`}
46
+ >
47
+ {item.icon && <Icon class="size-4 shrink-0" name={item.icon} />}
48
+ <span>{item.label}</span>
49
+ </button>
50
+ ) : (
51
+ <a
52
+ class:list={[
53
+ controlBaseClass,
54
+ index === currentIndex ? activeClass : inactiveClass,
55
+ ]}
56
+ href={hrefs[index] ?? "/"}
57
+ >
58
+ {item.icon && <Icon class="size-4 shrink-0" name={item.icon} />}
59
+ <span>{item.label}</span>
60
+ </a>
61
+ )}
62
+ </li>
63
+ ))
64
+ }
65
+ </ul>
@@ -0,0 +1,109 @@
1
+ ---
2
+ import { getConfig, type NavTabs } from "../lib/validation";
3
+ import { prependBasePath, stripBasePath } from "../lib/base-path";
4
+ import { getAllRoutes } from "../lib/routes";
5
+ import { slugify } from "../lib/utils";
6
+ import NavigationTabList from "./NavigationTabList.astro";
7
+
8
+ type TabsPresentation = "topbar" | "sidebar";
9
+
10
+ interface Props {
11
+ tabs: NavTabs;
12
+ presentation?: TabsPresentation;
13
+ }
14
+
15
+ const { tabs, presentation: presentationOverride } = Astro.props;
16
+ const tabItems = tabs.items ?? [];
17
+ const tabsConfig = tabs as NavTabs & { presentation?: TabsPresentation };
18
+ const presentation =
19
+ presentationOverride ?? tabsConfig.presentation ?? "topbar";
20
+ const isSidebarPresentation = presentation === "sidebar";
21
+ const pathname = stripBasePath(Astro.url.pathname);
22
+ const config = await getConfig();
23
+ const shouldUseBlurredTopbar =
24
+ config.navbar?.blur === true && !isSidebarPresentation;
25
+ const routes = (await getAllRoutes()).filter((route) => !route.hidden);
26
+ const normalizedPathParts = pathname
27
+ .replace(/^\/+/, "")
28
+ .replace(/\/+$/, "")
29
+ .split("/")
30
+ .filter(Boolean);
31
+
32
+ function getTabSlug(tab: (typeof tabItems)[number]): string {
33
+ return slugify(tab.slug || tab.label);
34
+ }
35
+
36
+ function getTabPrefix(tab: (typeof tabItems)[number]): string {
37
+ return getTabSlug(tab);
38
+ }
39
+
40
+ let currentTabIndex = tabItems.findIndex(
41
+ (item) => getTabSlug(item) === normalizedPathParts[0],
42
+ );
43
+
44
+ if (pathname === "/" && config.home) {
45
+ const homeRoute = routes.find(
46
+ (route) => route.type === "mdx" && route.filePath === config.home,
47
+ );
48
+
49
+ if (homeRoute) {
50
+ const homeTabSegment = homeRoute.slug.split("/").filter(Boolean)[0];
51
+ const homeTabIndex = tabItems.findIndex(
52
+ (item) => getTabSlug(item) === homeTabSegment,
53
+ );
54
+ if (homeTabIndex !== -1) {
55
+ currentTabIndex = homeTabIndex;
56
+ }
57
+ }
58
+ }
59
+
60
+ currentTabIndex = currentTabIndex === -1 ? 0 : currentTabIndex;
61
+
62
+ const firstHrefOfTabs = tabItems.map((item) => {
63
+ const itemPrefix = getTabPrefix(item);
64
+ const firstRoute = routes.find(
65
+ (route) =>
66
+ route.slug === itemPrefix || route.slug.startsWith(`${itemPrefix}/`),
67
+ );
68
+
69
+ if (firstRoute) {
70
+ return firstRoute.type === "mdx" &&
71
+ config.home &&
72
+ firstRoute.filePath === config.home
73
+ ? prependBasePath("/")
74
+ : prependBasePath(`/${firstRoute.slug}`);
75
+ }
76
+
77
+ return prependBasePath("/");
78
+ });
79
+ ---
80
+
81
+ {
82
+ tabItems.length > 0 ? (
83
+ <div
84
+ class:list={[
85
+ "fixed z-40 top-[68px] hidden h-11 border-x border-b border-border-light bg-background lg:block",
86
+ shouldUseBlurredTopbar &&
87
+ "sm:bg-background/85 sm:backdrop-blur-[18px] sm:backdrop-saturate-50 sm:border-b-neutral-100/85 sm:dark:border-neutral-800/85 sm:bg-clip-padding",
88
+ isSidebarPresentation
89
+ ? "inset-x-1 lg:left-[5px] lg:right-auto lg:w-[283px]"
90
+ : "inset-x-1",
91
+ ]}
92
+ data-pagefind-ignore
93
+ >
94
+ <nav
95
+ class:list={[
96
+ "h-full overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
97
+ isSidebarPresentation && "lg:border-r lg:border-r-border-light",
98
+ ]}
99
+ aria-label="Documentation sections"
100
+ >
101
+ <NavigationTabList
102
+ items={tabItems}
103
+ currentIndex={currentTabIndex}
104
+ hrefs={firstHrefOfTabs}
105
+ />
106
+ </nav>
107
+ </div>
108
+ ) : null
109
+ }
@@ -10,6 +10,7 @@ import ResponseFieldTree from "./endpoint/ResponseFieldTree.astro";
10
10
  import PlaygroundBar from "./endpoint/PlaygroundBar.astro";
11
11
  import PlaygroundForm from "./endpoint/PlaygroundForm.astro";
12
12
  import PlaygroundButton from "./endpoint/PlaygroundButton.astro";
13
+ import { getConfig } from "../lib/validation";
13
14
  import {
14
15
  getOpenApiOperationDoc,
15
16
  OPENAPI_REQUEST_SECTION_LABELS,
@@ -21,8 +22,10 @@ interface Props {
21
22
  }
22
23
 
23
24
  type RequestFields = OpenApiRequestFields;
25
+ type TabsPresentation = "topbar" | "sidebar";
24
26
 
25
27
  const { route } = Astro.props;
28
+ const config = await getConfig();
26
29
  const operationDoc = await getOpenApiOperationDoc(route);
27
30
  const {
28
31
  api,
@@ -42,6 +45,16 @@ const formattedDescription = description
42
45
  const formattedBodyDescription = bodyDescription
43
46
  ? await renderMarkdown(bodyDescription)
44
47
  : null;
48
+ const navigationTabs = config.navigation.tabs as
49
+ | (typeof config.navigation.tabs & { presentation?: TabsPresentation })
50
+ | undefined;
51
+ const hasTopbarNavigationTabs =
52
+ Array.isArray(navigationTabs?.items) &&
53
+ navigationTabs.items.length > 0 &&
54
+ (navigationTabs.presentation ?? "topbar") === "topbar";
55
+ const snippetStickyClass = hasTopbarNavigationTabs
56
+ ? "top-[136px] max-h-[calc(100vh-136px)]"
57
+ : "top-[92px] max-h-[calc(100vh-92px)]";
45
58
  ---
46
59
 
47
60
  <Layout pageTitle={title}>
@@ -55,7 +68,10 @@ const formattedBodyDescription = bodyDescription
55
68
  <aside class="flex-1 min-w-0 hidden xl:block">
56
69
  <div
57
70
  data-snippet-stack-host
58
- class="sticky top-[92px] max-h-[calc(100vh-92px)] w-full min-w-0 overflow-hidden"
71
+ class:list={[
72
+ "sticky w-full min-w-0 overflow-hidden",
73
+ snippetStickyClass,
74
+ ]}
59
75
  >
60
76
  <div
61
77
  data-snippet-stack
@@ -14,13 +14,13 @@ const config: DocsConfig = await getConfig();
14
14
 
15
15
  <aside class="flex flex-col h-full">
16
16
  <nav
17
- class="overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
17
+ class="min-h-0 flex-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
18
18
  >
19
19
  <SidebarMenu navigation={config.navigation} />
20
20
  </nav>
21
21
  <div
22
22
  class:list={[
23
- "mt-auto bg-background z-10 p-3 border-t border-t-border-light flex gap-1.5 items-center",
23
+ "bg-background z-10 px-3 pt-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] border-t border-t-border-light flex gap-1.5 items-center",
24
24
  askAiEnabled ? "justify-start" : "justify-end",
25
25
  ]}
26
26
  >
@@ -16,9 +16,15 @@ const { menu, parentSlug = "" } = Astro.props;
16
16
  const pathname = stripBasePath(Astro.url.pathname);
17
17
  const config = await getConfig();
18
18
  const routes = (await getAllRoutes()).filter((route) => !route.hidden);
19
+ const normalizedPathParts = pathname
20
+ .replace(/^\/+/, "")
21
+ .replace(/\/+$/, "")
22
+ .split("/")
23
+ .filter(Boolean);
24
+ const parentParts = parentSlug.split("/").filter(Boolean);
19
25
 
20
26
  let currentMenuIndex = menu.items.findIndex(
21
- (i) => slugify(i.label) === pathname.split("/")[1],
27
+ (i) => slugify(i.label) === normalizedPathParts[parentParts.length],
22
28
  );
23
29
 
24
30
  if (pathname === "/" && config.home) {
@@ -27,9 +33,10 @@ if (pathname === "/" && config.home) {
27
33
  );
28
34
 
29
35
  if (homeRoute) {
30
- const homeTopLevelSegment = homeRoute.slug.split("/")[0];
36
+ const homeRouteParts = homeRoute.slug.split("/").filter(Boolean);
37
+ const homeMenuSegment = homeRouteParts[parentParts.length];
31
38
  const homeMenuIndex = menu.items.findIndex(
32
- (item) => slugify(item.label) === homeTopLevelSegment,
39
+ (item) => slugify(item.label) === homeMenuSegment,
33
40
  );
34
41
  if (homeMenuIndex !== -1) {
35
42
  currentMenuIndex = homeMenuIndex;
@@ -39,37 +46,44 @@ if (pathname === "/" && config.home) {
39
46
 
40
47
  currentMenuIndex = currentMenuIndex === -1 ? 0 : currentMenuIndex;
41
48
 
42
- let firstHrefOfMenuItems: string[] = [];
43
-
44
- const seen = new Set();
45
-
46
- for (const route of routes) {
47
- const slug = route.slug;
49
+ function getMenuItemPrefix(label: string): string {
50
+ const menuSlug = slugify(label);
51
+ return parentSlug ? `${parentSlug}/${menuSlug}` : menuSlug;
52
+ }
48
53
 
49
- const parts = slug.split("/");
50
- const topLevel = parts[0];
54
+ let firstHrefOfMenuItems = menu.items.map((item) => {
55
+ const itemPrefix = getMenuItemPrefix(item.label);
56
+ const firstRoute = routes.find(
57
+ (route) => route.slug === itemPrefix || route.slug.startsWith(`${itemPrefix}/`),
58
+ );
51
59
 
52
- if (!seen.has(topLevel)) {
53
- seen.add(topLevel);
54
- const href =
55
- route.type === "mdx" && config.home && route.filePath === config.home
56
- ? prependBasePath("/")
57
- : prependBasePath(`/${slug}`);
58
- firstHrefOfMenuItems.push(href);
60
+ if (firstRoute) {
61
+ return firstRoute.type === "mdx" &&
62
+ config.home &&
63
+ firstRoute.filePath === config.home
64
+ ? prependBasePath("/")
65
+ : prependBasePath(`/${firstRoute.slug}`);
59
66
  }
60
- }
67
+
68
+ return prependBasePath("/");
69
+ });
61
70
 
62
71
  // Calculate the parentSlug for the currently selected menu item
63
72
  const selectedMenuItem = menu.items[currentMenuIndex];
64
- const menuItemSlug = slugify(selectedMenuItem.label);
65
- const currentPrefix = parentSlug
66
- ? `${parentSlug}/${menuItemSlug}`
67
- : menuItemSlug;
73
+ const currentPrefix = getMenuItemPrefix(selectedMenuItem.label);
74
+
75
+ const menuItemBaseClass =
76
+ "items-center px-3 py-2 cursor-pointer text-sm text-neutral-700 dark:text-neutral-300 relative z-0 before:-z-10 before:absolute before:inset-x-1 before:inset-y-px before:rounded-md before:duration-150";
77
+ const activeMenuItemClass =
78
+ "before:bg-neutral-200/50 dark:before:bg-neutral-700/50 text-neutral-900 dark:text-white";
79
+ const inactiveMenuItemClass =
80
+ "hover:before:bg-neutral-100/70 dark:hover:before:bg-neutral-700/30";
68
81
  ---
69
82
 
70
- <div x-data=`{
71
- open: false,
72
- }`>
83
+ <div
84
+ x-data={`{ dropdownOpen: false, selectedMenuIndex: ${currentMenuIndex} }`}
85
+ x-init={`$watch('open', (value) => { if (value) selectedMenuIndex = ${currentMenuIndex} })`}
86
+ >
73
87
  <div class="mt-3 mx-2 z-10 relative dark:bg-neutral-900 rounded-lg">
74
88
  <div
75
89
  class:list={[
@@ -91,22 +105,34 @@ const currentPrefix = parentSlug
91
105
  "flex items-center w-full text-sm text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-700/30 border-t border-x border-neutral-200/70 dark:border-neutral-700/40 dark:border-b rounded-lg shadow-xs px-3 py-2 cursor-pointer",
92
106
  menu.label ? "" : "border-b",
93
107
  ]}
94
- x-on:click="open = true"
108
+ x-on:click="dropdownOpen = true"
95
109
  aria-haspopup="menu"
96
- aria-expanded
110
+ x-bind:aria-expanded="dropdownOpen"
97
111
  >
98
112
  {
99
- menu.items[currentMenuIndex].icon && (
100
- <Icon
101
- class="mr-2 size-4 opacity-80"
102
- name={menu.items[currentMenuIndex].icon}
103
- />
104
- )
113
+ menu.items.map((item, index) => (
114
+ <span
115
+ class="flex min-w-0 items-center"
116
+ style={index === currentMenuIndex ? undefined : "display: none;"}
117
+ x-show={`selectedMenuIndex === ${index}`}
118
+ >
119
+ {item.icon && (
120
+ <Icon
121
+ class="mr-2 size-4 opacity-80"
122
+ name={item.icon}
123
+ />
124
+ )}
125
+ <span class="font-medium">{item.label}</span>
126
+ </span>
127
+ ))
105
128
  }
106
- <span class="font-medium">{menu.items[currentMenuIndex].label}</span>
107
129
  <Icon class="ml-auto" name="lucide:chevrons-up-down" />
108
130
  </button>
109
- <div class="fixed inset-0 z-50" x-show="open" x-on:click="open = false">
131
+ <div
132
+ class="fixed inset-0 z-50"
133
+ x-show="dropdownOpen"
134
+ x-on:click="dropdownOpen = false"
135
+ >
110
136
  </div>
111
137
  <ul
112
138
  class:list={[
@@ -115,7 +141,7 @@ const currentPrefix = parentSlug
115
141
  ]}
116
142
  x-init
117
143
  role="menu"
118
- x-show="open"
144
+ x-show="dropdownOpen"
119
145
  x-transition.origin.top
120
146
  x-cloak
121
147
  >
@@ -129,11 +155,12 @@ const currentPrefix = parentSlug
129
155
  role="menuitem"
130
156
  >
131
157
  <a
132
- class="flex items-center px-3 py-2 cursor-pointer text-sm text-neutral-700 dark:text-neutral-300 relative z-0 before:-z-10 before:absolute before:inset-x-1 before:inset-y-px before:rounded-md before:duration-150"
133
158
  class:list={[
159
+ menuItemBaseClass,
160
+ "hidden lg:flex",
134
161
  index === currentMenuIndex
135
- ? "before:bg-neutral-200/50 dark:before:bg-neutral-700/50 text-neutral-900 dark:text-white"
136
- : "hover:before:bg-neutral-100/70 dark:hover:before:bg-neutral-700/30",
162
+ ? activeMenuItemClass
163
+ : inactiveMenuItemClass,
137
164
  ]}
138
165
  href={firstHrefOfMenuItems[index] ?? prependBasePath("/")}
139
166
  >
@@ -152,6 +179,31 @@ const currentPrefix = parentSlug
152
179
  />
153
180
  )}
154
181
  </a>
182
+ <button
183
+ type="button"
184
+ class:list={[menuItemBaseClass, "flex lg:hidden w-full"]}
185
+ x-bind:class={`selectedMenuIndex === ${index} ? '${activeMenuItemClass}' : '${inactiveMenuItemClass}'`}
186
+ x-on:click={`selectedMenuIndex = ${index}; dropdownOpen = false`}
187
+ >
188
+ {menu.items[index].icon && (
189
+ <Icon
190
+ class="mr-2 size-4 opacity-75"
191
+ name={menu.items[index].icon}
192
+ />
193
+ )}
194
+ {label}
195
+ <span
196
+ class="ml-auto"
197
+ style={index === currentMenuIndex ? undefined : "display: none;"}
198
+ x-show={`selectedMenuIndex === ${index}`}
199
+ >
200
+ <Icon
201
+ class="text-primary [&_path]:stroke-3"
202
+ name="lucide:check"
203
+ stroke-width={10}
204
+ />
205
+ </span>
206
+ </button>
155
207
  </li>
156
208
  );
157
209
  })
@@ -168,9 +220,18 @@ const currentPrefix = parentSlug
168
220
  : "h-[calc(100vh-4px-64px-12px-38px-51px+8px)]",
169
221
  ]}
170
222
  >
171
- <SidebarMenu
172
- navigation={menu.items[currentMenuIndex].submenu}
173
- parentSlug={currentPrefix}
174
- />
223
+ <div class="hidden lg:block">
224
+ <SidebarMenu navigation={selectedMenuItem} parentSlug={currentPrefix} />
225
+ </div>
226
+ <div class="lg:hidden">
227
+ {menu.items.map((item, index) => (
228
+ <div x-show={`selectedMenuIndex === ${index}`} x-cloak>
229
+ <SidebarMenu
230
+ navigation={item}
231
+ parentSlug={getMenuItemPrefix(item.label)}
232
+ />
233
+ </div>
234
+ ))}
235
+ </div>
175
236
  </div>
176
237
  </div>
@@ -9,6 +9,7 @@ import SidebarOpenApi from "./sidebar/SidebarOpenApi.astro";
9
9
  import SidebarOpenApiPageLink from "./sidebar/SidebarOpenApiPageLink.astro";
10
10
  import SidebarDropdown from "./SidebarDropdown.astro";
11
11
  import SidebarSegmented from "./SidebarSegmented.astro";
12
+ import SidebarTabs from "./SidebarTabs.astro";
12
13
  import SidebarGroup from "./SidebarGroup.astro";
13
14
  import SidebarLink from "./SidebarLink.astro";
14
15
 
@@ -58,5 +59,7 @@ let { navigation, parentSlug = "" } = Astro.props;
58
59
  ) : null
59
60
  ) : navigation.openapi ? (
60
61
  <SidebarOpenApi openapi={navigation.openapi} parentSlug={parentSlug} />
62
+ ) : navigation.tabs ? (
63
+ <SidebarTabs tabs={navigation.tabs} parentSlug={parentSlug} />
61
64
  ) : null
62
65
  }
@@ -16,9 +16,15 @@ const { menu, parentSlug = "" } = Astro.props;
16
16
  const pathname = stripBasePath(Astro.url.pathname);
17
17
  const config = await getConfig();
18
18
  const routes = (await getAllRoutes()).filter((route) => !route.hidden);
19
+ const normalizedPathParts = pathname
20
+ .replace(/^\/+/, "")
21
+ .replace(/\/+$/, "")
22
+ .split("/")
23
+ .filter(Boolean);
24
+ const parentParts = parentSlug.split("/").filter(Boolean);
19
25
 
20
26
  let currentMenuIndex = menu.items.findIndex(
21
- (i) => slugify(i.label) === pathname.split("/")[1],
27
+ (i) => slugify(i.label) === normalizedPathParts[parentParts.length],
22
28
  );
23
29
 
24
30
  if (pathname === "/" && config.home) {
@@ -27,9 +33,10 @@ if (pathname === "/" && config.home) {
27
33
  );
28
34
 
29
35
  if (homeRoute) {
30
- const homeTopLevelSegment = homeRoute.slug.split("/")[0];
36
+ const homeRouteParts = homeRoute.slug.split("/").filter(Boolean);
37
+ const homeMenuSegment = homeRouteParts[parentParts.length];
31
38
  const homeMenuIndex = menu.items.findIndex(
32
- (item) => slugify(item.label) === homeTopLevelSegment,
39
+ (item) => slugify(item.label) === homeMenuSegment,
33
40
  );
34
41
  if (homeMenuIndex !== -1) {
35
42
  currentMenuIndex = homeMenuIndex;
@@ -39,62 +46,90 @@ if (pathname === "/" && config.home) {
39
46
 
40
47
  currentMenuIndex = currentMenuIndex === -1 ? 0 : currentMenuIndex;
41
48
 
42
- let firstHrefOfMenuItems: string[] = [];
43
-
44
- const seen = new Set();
45
-
46
- for (const route of routes) {
47
- const slug = route.slug;
49
+ function getMenuItemPrefix(label: string): string {
50
+ const menuSlug = slugify(label);
51
+ return parentSlug ? `${parentSlug}/${menuSlug}` : menuSlug;
52
+ }
48
53
 
49
- const parts = slug.split("/");
50
- const topLevel = parts[0];
54
+ let firstHrefOfMenuItems = menu.items.map((item) => {
55
+ const itemPrefix = getMenuItemPrefix(item.label);
56
+ const firstRoute = routes.find(
57
+ (route) => route.slug === itemPrefix || route.slug.startsWith(`${itemPrefix}/`),
58
+ );
51
59
 
52
- if (!seen.has(topLevel)) {
53
- seen.add(topLevel);
54
- const href =
55
- route.type === "mdx" && config.home && route.filePath === config.home
56
- ? prependBasePath("/")
57
- : prependBasePath(`/${slug}`);
58
- firstHrefOfMenuItems.push(href);
60
+ if (firstRoute) {
61
+ return firstRoute.type === "mdx" &&
62
+ config.home &&
63
+ firstRoute.filePath === config.home
64
+ ? prependBasePath("/")
65
+ : prependBasePath(`/${firstRoute.slug}`);
59
66
  }
60
- }
67
+
68
+ return prependBasePath("/");
69
+ });
61
70
 
62
71
  const selectedMenuItem = menu.items[currentMenuIndex];
63
- const menuItemSlug = slugify(selectedMenuItem.label);
64
- const currentPrefix = parentSlug
65
- ? `${parentSlug}/${menuItemSlug}`
66
- : menuItemSlug;
72
+ const currentPrefix = getMenuItemPrefix(selectedMenuItem.label);
73
+
74
+ const controlBaseClass =
75
+ "items-center px-3 py-1.5 cursor-pointer text-sm rounded-md border-x border-t transition-colors duration-150";
76
+ const activeControlClass =
77
+ "bg-white dark:bg-neutral-700/30 border-neutral-200/70 dark:border-neutral-700/40 dark:border-b shadow-sm shadow-neutral-200/80 dark:shadow-neutral-950/20 text-neutral-900 dark:text-white";
78
+ const inactiveControlClass =
79
+ "text-neutral-600 dark:text-neutral-300 border-transparent hover:text-primary dark:hover:text-primary";
67
80
  ---
68
81
 
69
- <div class="mt-3 mx-2 z-10 relative dark:bg-neutral-900 rounded-lg">
70
- {
71
- menu.label && (
72
- <label class="font-semibold text-xs px-2 pb-1 block">{menu.label}</label>
73
- )
74
- }
75
- <ul class="rounded-lg bg-neutral-100 dark:bg-neutral-800/60 p-[3px]">
82
+ <div
83
+ x-data={`{ selectedMenuIndex: ${currentMenuIndex} }`}
84
+ x-init={`$watch('open', (value) => { if (value) selectedMenuIndex = ${currentMenuIndex} })`}
85
+ >
86
+ <div class="mt-3 mx-2 z-10 relative dark:bg-neutral-900 rounded-lg">
76
87
  {
77
- menu.items.map((item, index) => (
78
- <li>
79
- <a
80
- class="flex items-center px-3 py-1.5 cursor-pointer text-sm rounded-md border-x border-t transition-colors duration-150"
81
- class:list={[
82
- index === currentMenuIndex
83
- ? "bg-white dark:bg-neutral-700/30 border-neutral-200/70 dark:border-neutral-700/40 dark:border-b shadow-sm shadow-neutral-200/80 dark:shadow-neutral-950/20 text-neutral-900 dark:text-white"
84
- : "text-neutral-600 dark:text-neutral-300 border-transparent hover:text-primary dark:hover:text-primary",
85
- ]}
86
- href={firstHrefOfMenuItems[index] ?? prependBasePath("/")}
87
- >
88
- {item.icon && <Icon class="mr-2 size-4" name={item.icon} />}
89
- {item.label}
90
- </a>
91
- </li>
92
- ))
88
+ menu.label && (
89
+ <label class="font-semibold text-xs px-2 pb-1 block">{menu.label}</label>
90
+ )
93
91
  }
94
- </ul>
95
- </div>
92
+ <ul class="rounded-lg bg-neutral-100 dark:bg-neutral-800/60 p-[3px]">
93
+ {
94
+ menu.items.map((item, index) => (
95
+ <li>
96
+ <a
97
+ class:list={[
98
+ controlBaseClass,
99
+ "hidden lg:flex",
100
+ index === currentMenuIndex
101
+ ? activeControlClass
102
+ : inactiveControlClass,
103
+ ]}
104
+ href={firstHrefOfMenuItems[index] ?? prependBasePath("/")}
105
+ >
106
+ {item.icon && <Icon class="mr-2 size-4" name={item.icon} />}
107
+ {item.label}
108
+ </a>
109
+ <button
110
+ type="button"
111
+ class:list={[controlBaseClass, "flex lg:hidden w-full"]}
112
+ x-bind:class={`selectedMenuIndex === ${index} ? '${activeControlClass}' : '${inactiveControlClass}'`}
113
+ x-bind:aria-selected={`selectedMenuIndex === ${index}`}
114
+ x-on:click={`selectedMenuIndex = ${index}`}
115
+ >
116
+ {item.icon && <Icon class="mr-2 size-4" name={item.icon} />}
117
+ {item.label}
118
+ </button>
119
+ </li>
120
+ ))
121
+ }
122
+ </ul>
123
+ </div>
96
124
 
97
- <SidebarMenu
98
- navigation={menu.items[currentMenuIndex].submenu}
99
- parentSlug={currentPrefix}
100
- />
125
+ <div class="hidden lg:block">
126
+ <SidebarMenu navigation={selectedMenuItem} parentSlug={currentPrefix} />
127
+ </div>
128
+ <div class="lg:hidden">
129
+ {menu.items.map((item, index) => (
130
+ <div x-show={`selectedMenuIndex === ${index}`} x-cloak>
131
+ <SidebarMenu navigation={item} parentSlug={getMenuItemPrefix(item.label)} />
132
+ </div>
133
+ ))}
134
+ </div>
135
+ </div>