radiant-docs 0.1.60 → 0.1.62

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 (41) hide show
  1. package/package.json +1 -1
  2. package/template/package-lock.json +10 -4
  3. package/template/package.json +11 -2
  4. package/template/scripts/generate-proxy-allowed-origins.mjs +14 -6
  5. package/template/scripts/publish-shiki-platform-assets.mjs +1151 -0
  6. package/template/src/components/Header.astro +6 -1
  7. package/template/src/components/NavigationTabList.astro +65 -0
  8. package/template/src/components/NavigationTabs.astro +109 -0
  9. package/template/src/components/OpenApiPage.astro +178 -14
  10. package/template/src/components/Sidebar.astro +2 -2
  11. package/template/src/components/SidebarDropdown.astro +105 -44
  12. package/template/src/components/SidebarMenu.astro +3 -0
  13. package/template/src/components/SidebarSegmented.astro +87 -52
  14. package/template/src/components/SidebarTabs.astro +86 -0
  15. package/template/src/components/chat/AssistantDocsWidget.tsx +127 -2
  16. package/template/src/components/chat/AssistantEmbedPanel.tsx +287 -290
  17. package/template/src/components/endpoint/PlaygroundBar.astro +54 -9
  18. package/template/src/components/endpoint/PlaygroundForm.astro +1 -1
  19. package/template/src/components/endpoint/RequestSnippets.astro +6 -1
  20. package/template/src/components/endpoint/ResponseFieldTree.astro +17 -13
  21. package/template/src/components/endpoint/ResponseFields.astro +4 -6
  22. package/template/src/components/endpoint/ResponseSnippets.astro +6 -1
  23. package/template/src/components/sidebar/SidebarEndpointLink.astro +9 -12
  24. package/template/src/components/sidebar/SidebarOpenApi.astro +3 -9
  25. package/template/src/components/ui/Field.astro +18 -15
  26. package/template/src/components/ui/Tag.astro +16 -2
  27. package/template/src/components/user/Accordion.astro +1 -1
  28. package/template/src/components/user/Callout.astro +2 -2
  29. package/template/src/components/user/CodeBlock.astro +58 -7
  30. package/template/src/components/user/CodeGroup.astro +52 -1
  31. package/template/src/components/user/Column.astro +1 -1
  32. package/template/src/components/user/Step.astro +1 -1
  33. package/template/src/components/user/Tabs.astro +1 -1
  34. package/template/src/generated/shiki-platform-assets.json +24 -0
  35. package/template/src/layouts/Layout.astro +111 -8
  36. package/template/src/lib/assistant-panel-config.ts +59 -0
  37. package/template/src/lib/assistant-shiki-client.ts +506 -0
  38. package/template/src/lib/mdx/remark-resolve-internal-links.ts +334 -17
  39. package/template/src/lib/routes.ts +66 -24
  40. package/template/src/lib/utils.ts +11 -0
  41. package/template/src/styles/global.css +12 -0
@@ -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
@@ -111,13 +127,29 @@ const formattedBodyDescription = bodyDescription
111
127
  />
112
128
  )
113
129
  }
114
- <div class="xl:hidden space-y-6 mt-6">
115
- <RequestSnippets
116
- api={api}
117
- method={route.openApiMethod}
118
- path={route.openApiPath}
119
- />
120
- {responses && <ResponseSnippets responses={responses} />}
130
+ <div data-inline-snippet-stack class="xl:hidden space-y-6 mt-6">
131
+ <div
132
+ data-snippet-slot
133
+ data-inline-snippet-slot
134
+ class="min-h-0 min-w-0 overflow-hidden transition-[height,max-height] duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
135
+ >
136
+ <RequestSnippets
137
+ api={api}
138
+ method={route.openApiMethod}
139
+ path={route.openApiPath}
140
+ />
141
+ </div>
142
+ {
143
+ responses && (
144
+ <div
145
+ data-snippet-slot
146
+ data-inline-snippet-slot
147
+ class="min-h-0 min-w-0 overflow-hidden transition-[height,max-height] duration-[360ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
148
+ >
149
+ <ResponseSnippets responses={responses} />
150
+ </div>
151
+ )
152
+ }
121
153
  </div>
122
154
  <div class="mt-10">
123
155
  <!-- Request -->
@@ -157,10 +189,9 @@ const formattedBodyDescription = bodyDescription
157
189
  />
158
190
  )}
159
191
  {hasCommonFields && (
160
- <div x-data="{ expanded: false }" class="mt-4">
192
+ <div x-data="{ expanded: true }" class="mt-4">
161
193
  <div
162
- class="w-full overflow-hidden rounded-xl border border-neutral-200 bg-white transition-colors duration-200 dark:border-neutral-800 dark:bg-(--rd-code-surface)"
163
- x-bind:class="expanded ? 'border-neutral-300 dark:border-neutral-700' : 'border-neutral-200 dark:border-neutral-800'"
194
+ class="w-full overflow-hidden rounded-xl border-[0.5px] border-neutral-900/8 bg-white shadow-[0_.5px_1px_rgba(0,0,0,0.15),0_5px_12px_-6px_rgba(0,0,0,0.08)] transition-colors duration-200 dark:border-white/6 dark:bg-(--rd-code-surface) dark:shadow-[0_-.5px_1px_rgba(255,255,255,0.15),0_5px_12px_-6px_rgba(0,0,0,0.2)]"
164
195
  >
165
196
  <button
166
197
  type="button"
@@ -201,10 +232,9 @@ const formattedBodyDescription = bodyDescription
201
232
  <div class="mb-2 text-xs font-medium text-neutral-600 dark:text-neutral-400">
202
233
  {variant.label}
203
234
  </div>
204
- <div x-data="{ expanded: false }">
235
+ <div x-data="{ expanded: true }">
205
236
  <div
206
- class="w-full overflow-hidden rounded-lg border border-neutral-200 bg-white transition-colors duration-200 dark:border-neutral-800 dark:bg-(--rd-code-surface)"
207
- x-bind:class="expanded ? 'border-neutral-300 dark:border-neutral-700' : 'border-neutral-200 dark:border-neutral-800'"
237
+ class="w-full overflow-hidden rounded-lg border-[0.5px] border-neutral-900/8 bg-white shadow-[0_.5px_1px_rgba(0,0,0,0.15),0_5px_12px_-6px_rgba(0,0,0,0.08)] transition-colors duration-200 dark:border-white/6 dark:bg-(--rd-code-surface) dark:shadow-[0_-.5px_1px_rgba(255,255,255,0.15),0_5px_12px_-6px_rgba(0,0,0,0.2)]"
208
238
  >
209
239
  <button
210
240
  type="button"
@@ -485,4 +515,138 @@ const formattedBodyDescription = bodyDescription
485
515
  );
486
516
  applySlotHeights();
487
517
  }
518
+
519
+ const inlineStack = document.querySelector("[data-inline-snippet-stack]");
520
+
521
+ if (inlineStack instanceof HTMLElement) {
522
+ const inlineSlots = Array.from(
523
+ inlineStack.querySelectorAll("[data-inline-snippet-slot]"),
524
+ ).filter((slot) => slot instanceof HTMLElement);
525
+
526
+ const getInlineViewportHeight = () => {
527
+ if (window.visualViewport?.height) {
528
+ return Math.floor(window.visualViewport.height);
529
+ }
530
+ return Math.floor(window.innerHeight);
531
+ };
532
+
533
+ const getInlineSlotHeightCap = () => {
534
+ const viewportCap = Math.floor(getInlineViewportHeight() * 0.7);
535
+ return Math.max(0, Math.min(448, viewportCap));
536
+ };
537
+
538
+ const inlinePrefersReducedMotion =
539
+ typeof window.matchMedia === "function" &&
540
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
541
+ const inlineSlotHeightTransitionMs = 360;
542
+ let isAnimatingInlineSlotHeights = false;
543
+ let inlineSlotHeightAnimationTimeoutId = null;
544
+
545
+ const resetInlineSlotHeights = () => {
546
+ for (const slot of inlineSlots) {
547
+ slot.style.height = "";
548
+ slot.style.maxHeight = "";
549
+ }
550
+ };
551
+
552
+ const applyInlineSlotHeights = ({ animate = false } = {}) => {
553
+ if (!inlineSlots.length) return;
554
+ if (isAnimatingInlineSlotHeights && !animate) return;
555
+
556
+ if (window.getComputedStyle(inlineStack).display === "none") {
557
+ resetInlineSlotHeights();
558
+ return;
559
+ }
560
+
561
+ const shouldAnimate = animate && !inlinePrefersReducedMotion;
562
+ const previousHeights = shouldAnimate
563
+ ? inlineSlots.map((slot) =>
564
+ Math.ceil(slot.getBoundingClientRect().height),
565
+ )
566
+ : [];
567
+ const previousTransitions = shouldAnimate
568
+ ? inlineSlots.map((slot) => slot.style.transition)
569
+ : [];
570
+ const heightCap = getInlineSlotHeightCap();
571
+
572
+ if (shouldAnimate) {
573
+ for (const slot of inlineSlots) {
574
+ slot.style.transition = "none";
575
+ }
576
+ }
577
+
578
+ for (const slot of inlineSlots) {
579
+ slot.style.height = "auto";
580
+ slot.style.maxHeight = "none";
581
+ }
582
+
583
+ const targetHeights = inlineSlots.map((slot) => {
584
+ const naturalHeight = Math.ceil(slot.scrollHeight);
585
+ return Math.min(naturalHeight, heightCap);
586
+ });
587
+
588
+ const setInlineSlotHeights = () => {
589
+ for (let i = 0; i < inlineSlots.length; i += 1) {
590
+ const height = targetHeights[i];
591
+ inlineSlots[i].style.height = `${height}px`;
592
+ inlineSlots[i].style.maxHeight = `${height}px`;
593
+ }
594
+ };
595
+
596
+ if (!shouldAnimate) {
597
+ setInlineSlotHeights();
598
+ return;
599
+ }
600
+
601
+ isAnimatingInlineSlotHeights = true;
602
+ if (inlineSlotHeightAnimationTimeoutId) {
603
+ window.clearTimeout(inlineSlotHeightAnimationTimeoutId);
604
+ }
605
+
606
+ for (let i = 0; i < inlineSlots.length; i += 1) {
607
+ const height = previousHeights[i];
608
+ inlineSlots[i].style.height = `${height}px`;
609
+ inlineSlots[i].style.maxHeight = `${height}px`;
610
+ }
611
+
612
+ void inlineStack.offsetHeight;
613
+
614
+ for (let i = 0; i < inlineSlots.length; i += 1) {
615
+ inlineSlots[i].style.transition = previousTransitions[i];
616
+ }
617
+
618
+ window.requestAnimationFrame(setInlineSlotHeights);
619
+
620
+ inlineSlotHeightAnimationTimeoutId = window.setTimeout(() => {
621
+ isAnimatingInlineSlotHeights = false;
622
+ inlineSlotHeightAnimationTimeoutId = null;
623
+ applyInlineSlotHeights();
624
+ }, inlineSlotHeightTransitionMs + 80);
625
+ };
626
+
627
+ let inlineSlotAnimationFrameId = null;
628
+ const scheduleInlineSlotHeights = (options = {}) => {
629
+ if (inlineSlotAnimationFrameId) {
630
+ window.cancelAnimationFrame(inlineSlotAnimationFrameId);
631
+ }
632
+
633
+ inlineSlotAnimationFrameId = window.requestAnimationFrame(() => {
634
+ inlineSlotAnimationFrameId = window.requestAnimationFrame(() => {
635
+ inlineSlotAnimationFrameId = null;
636
+ applyInlineSlotHeights(options);
637
+ });
638
+ });
639
+ };
640
+
641
+ const inlineResizeObserver = new ResizeObserver(scheduleInlineSlotHeights);
642
+ inlineResizeObserver.observe(inlineStack);
643
+
644
+ window.addEventListener("resize", scheduleInlineSlotHeights);
645
+ window.visualViewport?.addEventListener("resize", scheduleInlineSlotHeights);
646
+ window.addEventListener(
647
+ "rd:snippet-content-change",
648
+ () => scheduleInlineSlotHeights({ animate: true }),
649
+ );
650
+ scheduleInlineSlotHeights();
651
+ }
488
652
  </script>
@@ -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>