@lupinum/ginko-docs 0.2.2 → 0.2.3

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 (196) hide show
  1. package/README.md +130 -73
  2. package/app/app.config.ts +14 -12
  3. package/app/app.vue +2 -2
  4. package/app/assets/css/prose.css +2203 -149
  5. package/app/assets/css/tailwind.css +60 -38
  6. package/app/assets/css/theme-palettes.css +309 -0
  7. package/app/components/content/DocumentPageShell.vue +1 -1
  8. package/app/components/content/Feedback.vue +12 -11
  9. package/app/components/content/ImageZoomDialog.vue +84 -0
  10. package/app/components/content/PageMarkdownCopy.vue +2 -0
  11. package/app/components/content/imageZoom.ts +25 -0
  12. package/app/components/mdc/MdcAccordion.vue +6 -3
  13. package/app/components/mdc/MdcAccordionItem.vue +7 -16
  14. package/app/components/mdc/MdcApi.vue +159 -0
  15. package/app/components/mdc/MdcAside.vue +6 -10
  16. package/app/components/mdc/MdcCallout.vue +9 -10
  17. package/app/components/mdc/MdcCard.vue +36 -47
  18. package/app/components/mdc/MdcCards.vue +30 -0
  19. package/app/components/mdc/MdcCodeGroup.vue +4 -0
  20. package/app/components/mdc/MdcCodeTree.vue +234 -0
  21. package/app/components/mdc/MdcCollapse.vue +61 -0
  22. package/app/components/mdc/MdcError.vue +8 -1
  23. package/app/components/mdc/MdcExcerpt.vue +26 -0
  24. package/app/components/mdc/MdcFigure.vue +54 -20
  25. package/app/components/mdc/MdcFiles.vue +120 -0
  26. package/app/components/mdc/MdcIdea.vue +2 -1
  27. package/app/components/mdc/MdcInfo.vue +2 -1
  28. package/app/components/mdc/MdcInlineToc.vue +51 -0
  29. package/app/components/{ui/dialog/DialogHeader.vue → mdc/MdcKbd.vue} +2 -4
  30. package/app/components/mdc/MdcNote.vue +2 -1
  31. package/app/components/mdc/MdcQuiz.vue +43 -83
  32. package/app/components/mdc/MdcQuizQuestion.vue +104 -109
  33. package/app/components/mdc/MdcReadMore.vue +57 -77
  34. package/app/components/mdc/MdcSteps.vue +45 -11
  35. package/app/components/mdc/MdcSuccess.vue +8 -1
  36. package/app/components/mdc/MdcTabs.vue +44 -26
  37. package/app/components/mdc/MdcTimeline.vue +4 -1
  38. package/app/components/mdc/MdcTimelineItem.vue +18 -27
  39. package/app/components/mdc/MdcWarning.vue +8 -1
  40. package/app/components/mdc/api.utils.ts +132 -0
  41. package/app/components/mdc/code-tree.utils.ts +22 -0
  42. package/app/components/mdc/quiz.utils.ts +38 -0
  43. package/app/components/prose/ProseBlockquote.vue +17 -1
  44. package/app/components/prose/ProseH1.vue +1 -1
  45. package/app/components/prose/ProseH2.vue +7 -2
  46. package/app/components/prose/ProseH3.vue +7 -2
  47. package/app/components/prose/ProseH4.vue +17 -2
  48. package/app/components/{ui/kbd/KbdGroup.vue → prose/ProseH5.vue} +4 -3
  49. package/app/components/{ui/card/CardTitle.vue → prose/ProseH6.vue} +4 -3
  50. package/app/components/prose/ProseImg.vue +19 -112
  51. package/app/components/prose/ProsePre.vue +63 -100
  52. package/app/components/prose/ProseTable.vue +5 -3
  53. package/app/components/site/ModeToggle.vue +12 -3
  54. package/app/components/site/SiteBanner.vue +43 -2
  55. package/app/components/site/SiteFooter.vue +3 -5
  56. package/app/components/site/SiteHeader.vue +255 -132
  57. package/app/components/site/SiteHeroCode.vue +164 -0
  58. package/app/components/site/SiteLocaleSwitcher.vue +22 -4
  59. package/app/components/ui/badge/Badge.vue +1 -1
  60. package/app/components/ui/button/Button.vue +1 -1
  61. package/app/components/ui/collapsible/CollapsibleContent.vue +1 -1
  62. package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +14 -4
  63. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  64. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +1 -1
  65. package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +1 -1
  66. package/app/components/ui/dropdown-menu/index.ts +0 -9
  67. package/app/components/ui/kbd/Kbd.vue +1 -1
  68. package/app/components/ui/kbd/index.ts +0 -1
  69. package/app/components/ui/scroll-area/ScrollArea.vue +1 -1
  70. package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
  71. package/app/components/ui/separator/Separator.vue +1 -1
  72. package/app/components/ui/sheet/SheetContent.vue +1 -1
  73. package/app/components/ui/sheet/SheetDescription.vue +1 -1
  74. package/app/components/ui/sheet/SheetOverlay.vue +1 -1
  75. package/app/components/ui/sheet/SheetTitle.vue +1 -1
  76. package/app/components/ui/sheet/index.ts +0 -3
  77. package/app/components/ui/tabs/Tabs.vue +1 -1
  78. package/app/components/ui/tabs/TabsList.vue +1 -1
  79. package/app/components/ui/tabs/TabsTrigger.vue +1 -1
  80. package/app/components/ui/tabs/index.ts +0 -1
  81. package/app/composables/site-navigation.utils.ts +29 -11
  82. package/app/composables/useContentRouteAlternates.ts +13 -21
  83. package/app/composables/useLocalizedRouteSwitch.ts +1 -1
  84. package/app/composables/useMetaKey.ts +18 -0
  85. package/app/composables/useProseAppearance.ts +24 -0
  86. package/app/composables/useSiteNavigation.ts +3 -18
  87. package/app/config/site.utils.ts +1 -1
  88. package/app/error.vue +1 -5
  89. package/app/features/docs/components/DocsBreadcrumb.vue +37 -0
  90. package/app/features/docs/components/DocsContributeLinks.vue +28 -18
  91. package/app/features/docs/components/DocsMobileToc.vue +12 -8
  92. package/app/features/docs/components/DocsPageContent.vue +84 -47
  93. package/app/features/docs/components/DocsPageNav.vue +1 -1
  94. package/app/features/docs/components/DocsSidebar.vue +30 -20
  95. package/app/features/docs/components/DocsSidebarItem.vue +53 -100
  96. package/app/features/docs/components/DocsSidebarRow.vue +27 -0
  97. package/app/features/docs/components/DocsSidebarTabs.vue +46 -22
  98. package/app/features/docs/components/DocsToc.vue +80 -34
  99. package/app/features/docs/composables/useDocsNavigation.ts +2 -2
  100. package/app/features/docs/composables/useDocsNavigationData.ts +4 -1
  101. package/app/features/docs/composables/useRevealActive.ts +58 -0
  102. package/app/features/docs/composables/useScrollspy.ts +87 -19
  103. package/app/features/docs/docs-navigation.ts +17 -30
  104. package/app/features/docs/toc-context.ts +6 -0
  105. package/app/features/search/command-center.ts +11 -90
  106. package/app/features/search/components/SiteCommandCenter.vue +58 -38
  107. package/app/features/search/useCommandCenter.ts +46 -46
  108. package/app/layouts/docs.vue +1 -1
  109. package/app/lib/errors.ts +2 -49
  110. package/app/pages/blog/[slug].vue +16 -8
  111. package/app/pages/blog/index.vue +26 -13
  112. package/app/pages/docs/index.vue +10 -14
  113. package/app/pages/index.vue +174 -59
  114. package/app/plugins/ginko-docs-theme.ts +12 -0
  115. package/app/utils/content.ts +11 -0
  116. package/app/utils/file-icons.ts +90 -0
  117. package/app/utils/index.ts +7 -1
  118. package/app/utils/repository.ts +20 -0
  119. package/content.js +23 -26
  120. package/content.ts +30 -28
  121. package/i18n/messages/global/blog.ts +4 -0
  122. package/i18n/messages/global/command.ts +6 -2
  123. package/i18n/messages/global/docs.ts +3 -0
  124. package/i18n/messages/global/nav.ts +6 -2
  125. package/i18n/messages/index.ts +3 -3
  126. package/icon-bundle.ts +5 -0
  127. package/index.d.ts +7 -1
  128. package/nuxt.config.ts +2 -2
  129. package/package.json +9 -6
  130. package/runtime/server/agent-markdown.ts +0 -2
  131. package/shared/theme-palettes.ts +37 -0
  132. package/shared/types/app-config.ts +63 -6
  133. package/tags.ts +47 -59
  134. package/app/components/mdc/MdcAlert.vue +0 -34
  135. package/app/components/mdc/MdcCardGroup.vue +0 -47
  136. package/app/components/mdc/MdcDocImg.vue +0 -25
  137. package/app/components/mdc/MdcField.vue +0 -62
  138. package/app/components/mdc/MdcFieldGroup.vue +0 -18
  139. package/app/components/mdc/MdcPassage.vue +0 -32
  140. package/app/components/mdc/MdcQuizOption.vue +0 -91
  141. package/app/components/mdc/MdcReadMoreGroup.vue +0 -18
  142. package/app/components/mdc/MdcShortcut.vue +0 -46
  143. package/app/components/mdc/MdcStep.vue +0 -42
  144. package/app/components/ui/accordion/Accordion.vue +0 -15
  145. package/app/components/ui/accordion/AccordionContent.vue +0 -23
  146. package/app/components/ui/accordion/AccordionItem.vue +0 -24
  147. package/app/components/ui/accordion/AccordionTrigger.vue +0 -35
  148. package/app/components/ui/accordion/index.ts +0 -4
  149. package/app/components/ui/alert/Alert.vue +0 -21
  150. package/app/components/ui/alert/AlertDescription.vue +0 -22
  151. package/app/components/ui/alert/AlertTitle.vue +0 -22
  152. package/app/components/ui/alert/index.ts +0 -34
  153. package/app/components/ui/card/Card.vue +0 -16
  154. package/app/components/ui/card/CardAction.vue +0 -17
  155. package/app/components/ui/card/CardContent.vue +0 -14
  156. package/app/components/ui/card/CardDescription.vue +0 -14
  157. package/app/components/ui/card/CardFooter.vue +0 -14
  158. package/app/components/ui/card/CardHeader.vue +0 -22
  159. package/app/components/ui/card/card-variants.ts +0 -15
  160. package/app/components/ui/card/index.ts +0 -8
  161. package/app/components/ui/checkbox/Checkbox.vue +0 -35
  162. package/app/components/ui/checkbox/index.ts +0 -1
  163. package/app/components/ui/dialog/Dialog.vue +0 -15
  164. package/app/components/ui/dialog/DialogClose.vue +0 -12
  165. package/app/components/ui/dialog/DialogContent.vue +0 -69
  166. package/app/components/ui/dialog/DialogDescription.vue +0 -21
  167. package/app/components/ui/dialog/DialogFooter.vue +0 -15
  168. package/app/components/ui/dialog/DialogOverlay.vue +0 -26
  169. package/app/components/ui/dialog/DialogPortal.vue +0 -12
  170. package/app/components/ui/dialog/DialogTitle.vue +0 -21
  171. package/app/components/ui/dialog/DialogTrigger.vue +0 -12
  172. package/app/components/ui/dialog/index.ts +0 -10
  173. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +0 -36
  174. package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +0 -12
  175. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +0 -25
  176. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +0 -17
  177. package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +0 -15
  178. package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +0 -29
  179. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +0 -31
  180. package/app/components/ui/input/Input.vue +0 -23
  181. package/app/components/ui/input/index.ts +0 -1
  182. package/app/components/ui/label/Label.vue +0 -14
  183. package/app/components/ui/label/index.ts +0 -1
  184. package/app/components/ui/sheet/SheetClose.vue +0 -12
  185. package/app/components/ui/sheet/SheetFooter.vue +0 -12
  186. package/app/components/ui/sheet/SheetHeader.vue +0 -12
  187. package/app/components/ui/switch/Switch.vue +0 -39
  188. package/app/components/ui/switch/index.ts +0 -1
  189. package/app/components/ui/tabs/TabsContent.vue +0 -21
  190. package/app/components/ui/textarea/Textarea.vue +0 -31
  191. package/app/components/ui/textarea/index.ts +0 -1
  192. package/app/features/docs/composables/useDocsEntryPath.ts +0 -13
  193. package/app/lib/utils.ts +0 -7
  194. package/i18n/messages/source.ts +0 -5
  195. package/runtime/markdown/inline-code-highlight.ts +0 -155
  196. package/runtime/utils.ts +0 -7
@@ -1,79 +1,124 @@
1
1
  <script setup lang="ts">
2
- import { useScrollLock, onKeyStroke } from "@vueuse/core";
3
2
  import { Button } from "#ginko-docs/components/ui/button";
4
3
  import { Kbd } from "#ginko-docs/components/ui/kbd";
5
4
  import { Separator } from "#ginko-docs/components/ui/separator";
6
- import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
5
+ import {
6
+ Sheet,
7
+ SheetContent,
8
+ SheetDescription,
9
+ SheetTitle,
10
+ SheetTrigger,
11
+ } from "#ginko-docs/components/ui/sheet";
12
+ import { computed, nextTick, ref, watch } from "vue";
13
+ import {
14
+ findFirstNavigationPage,
15
+ navigationItemContainsPath,
16
+ normalizeNavigationPath,
17
+ } from "@lupinum/ginko-content/navigation";
7
18
  import { useI18n, useRoute } from "#imports";
8
19
  import { useLocalizedPath } from "#ginko-docs/composables/useLocalizedPath";
9
20
  import { useSiteNavigation } from "#ginko-docs/composables/useSiteNavigation";
10
21
  import { useCommandCenterState } from "#ginko-docs/features/search/useCommandCenter";
22
+ import { useMetaKey } from "#ginko-docs/composables/useMetaKey";
23
+ import { useDocsNavigationData } from "#ginko-docs/features/docs/composables/useDocsNavigationData";
24
+ import {
25
+ getDocsNavigationSections,
26
+ normalizeDocsNavigationItem,
27
+ } from "#ginko-docs/features/docs/docs-navigation";
11
28
  import ModeToggle from "#ginko-docs/components/site/ModeToggle.vue";
12
29
 
13
- const { mainNav } = useSiteNavigation();
30
+ const { mainNav, socialLinks } = useSiteNavigation();
14
31
  const { openCommandCenter } = useCommandCenterState();
15
32
  const { t } = useI18n();
16
33
  const route = useRoute();
17
34
  const isMobileMenuOpen = ref(false);
18
- const headerElement = ref<HTMLElement | null>(null);
19
- const mobileMenuTop = ref("var(--site-header-height)");
20
35
  const localizedPath = useLocalizedPath();
21
36
 
22
37
  const isActive = (href: string) => route.path === href || route.path.startsWith(href + "/");
23
38
 
24
- function mobileNavIcon(href: string) {
25
- if (href.includes("blog")) return "lucide:file-text";
26
- if (href.includes("dok") || href.includes("doc")) return "lucide:book-open";
27
- return "lucide:circle";
28
- }
39
+ const metaKey = useMetaKey();
40
+ const searchShortcut = computed(() => (metaKey.value === "" ? "⌘K" : "Ctrl K"));
41
+ const homePath = computed(() => localizedPath("home"));
42
+ const docsPath = computed(() => localizedPath("docs"));
43
+
44
+ // The mobile menu has two levels: the root nav in hero-scale type, and a docs
45
+ // level with the section entry links. The docs nav link drills in; every other
46
+ // link navigates directly.
47
+ const { data: docsNavData } = useDocsNavigationData();
48
+ const docsSections = computed(() =>
49
+ getDocsNavigationSections(
50
+ (docsNavData.value ?? []).map((item, index) => normalizeDocsNavigationItem(item, index)),
51
+ ),
52
+ );
53
+ const docsMenuSections = computed(() => {
54
+ const currentPath = normalizeNavigationPath(route.path);
55
+
56
+ return docsSections.value
57
+ .map((section) => ({
58
+ id: section.id,
59
+ title: section.title,
60
+ links: section.items
61
+ .map((item) => ({
62
+ id: item.id,
63
+ label: item.title,
64
+ href: item.path ?? findFirstNavigationPage(item.children)?.path,
65
+ active: navigationItemContainsPath(item, currentPath),
66
+ }))
67
+ .filter((link): link is typeof link & { href: string } => Boolean(link.href)),
68
+ }))
69
+ .filter((section) => section.links.length > 0);
70
+ });
71
+ const hasDocsLevel = computed(() => docsMenuSections.value.length > 0);
29
72
 
30
- const mobileNavItems = computed(() =>
73
+ const rootNavItems = computed(() =>
31
74
  mainNav.value.map((item) => ({
32
- label: item.label,
33
- href: item.href,
34
- icon: mobileNavIcon(item.href),
35
- children: [] as { label: string; href: string }[],
75
+ ...item,
76
+ drill: hasDocsLevel.value && item.href === docsPath.value,
36
77
  })),
37
78
  );
38
- const homePath = computed(() => localizedPath("home"));
79
+ const docsLevelTitle = computed(
80
+ () => rootNavItems.value.find((item) => item.drill)?.label ?? t("nav.documentation"),
81
+ );
39
82
 
40
- // Scroll lock when menu is open
41
- const scrollLock = useScrollLock(import.meta.client ? document.body : null);
83
+ type MenuLevel = "root" | "docs";
84
+ const menuLevel = ref<MenuLevel>("root");
85
+ const docsDrillButton = ref<HTMLButtonElement | null>(null);
86
+ const docsBackButton = ref<HTMLButtonElement | null>(null);
42
87
 
43
- watch(isMobileMenuOpen, (open) => {
44
- scrollLock.value = open;
45
- if (open) {
46
- void nextTick(updateMobileMenuTop);
47
- }
48
- });
88
+ function setDrillButtonRef(el: unknown) {
89
+ docsDrillButton.value = el instanceof HTMLButtonElement ? el : null;
90
+ }
49
91
 
50
- function updateMobileMenuTop() {
51
- if (!import.meta.client || !headerElement.value) return;
52
- mobileMenuTop.value = `${Math.max(0, headerElement.value.getBoundingClientRect().bottom)}px`;
92
+ async function drillIntoDocs() {
93
+ menuLevel.value = "docs";
94
+ await nextTick();
95
+ docsBackButton.value?.focus({ preventScroll: true });
53
96
  }
54
97
 
55
- function handleResize() {
56
- if (isMobileMenuOpen.value) {
57
- updateMobileMenuTop();
58
- }
98
+ async function backToRoot() {
99
+ menuLevel.value = "root";
100
+ await nextTick();
101
+ docsDrillButton.value?.focus({ preventScroll: true });
59
102
  }
60
103
 
61
- onMounted(() => window.addEventListener("resize", handleResize));
62
- onBeforeUnmount(() => window.removeEventListener("resize", handleResize));
104
+ function closeMenu() {
105
+ isMobileMenuOpen.value = false;
106
+ }
63
107
 
108
+ // Scroll lock, Escape handling, and focus trapping come from the Sheet
109
+ // (reka-ui Dialog); only route-driven closing is ours.
64
110
  function openSearch() {
65
111
  isMobileMenuOpen.value = false;
66
112
  openCommandCenter();
67
113
  }
68
114
 
69
- // Close on Escape
70
- onKeyStroke("Escape", () => {
71
- if (isMobileMenuOpen.value) {
72
- isMobileMenuOpen.value = false;
115
+ watch(isMobileMenuOpen, (open) => {
116
+ if (open) {
117
+ // Context-aware entry: docs readers land on their section links first.
118
+ menuLevel.value = hasDocsLevel.value && isActive(docsPath.value) ? "docs" : "root";
73
119
  }
74
120
  });
75
121
 
76
- // Close menu on navigation
77
122
  watch(
78
123
  () => route.path,
79
124
  () => {
@@ -84,7 +129,6 @@ watch(
84
129
 
85
130
  <template>
86
131
  <header
87
- ref="headerElement"
88
132
  class="sticky top-0 z-50 flex h-14 w-full shrink-0 items-center border-b border-border bg-background/90 backdrop-blur-md"
89
133
  >
90
134
  <div class="mx-auto flex w-full max-w-screen-2xl flex-1 items-center gap-4 px-4 md:px-6">
@@ -120,7 +164,7 @@ watch(
120
164
  >
121
165
  <Icon name="lucide:search" class="size-4 shrink-0" aria-hidden="true" />
122
166
  <span class="flex-1 text-left">{{ t("nav.search") }}...</span>
123
- <Kbd class="h-5 bg-background text-[10px]">⌘K</Kbd>
167
+ <Kbd class="h-5 bg-background text-[10px]">{{ searchShortcut }}</Kbd>
124
168
  </Button>
125
169
  <Button
126
170
  variant="ghost"
@@ -149,102 +193,181 @@ watch(
149
193
  </ClientOnly>
150
194
 
151
195
  <SiteLocaleSwitcher class="hidden md:flex" />
152
- <Button
153
- variant="ghost"
154
- size="icon"
155
- class="md:hidden"
156
- :aria-label="isMobileMenuOpen ? t('nav.closeMenu') : t('nav.openMenu')"
157
- :aria-expanded="isMobileMenuOpen"
158
- type="button"
159
- @click="isMobileMenuOpen = !isMobileMenuOpen"
160
- >
161
- <Icon
162
- :name="isMobileMenuOpen ? 'lucide:x' : 'lucide:menu'"
163
- class="size-5"
164
- aria-hidden="true"
165
- />
166
- </Button>
167
- </div>
168
- </div>
169
- </header>
170
- <Transition
171
- enter-active-class="transition-all duration-200 ease-out"
172
- enter-from-class="-translate-y-4 opacity-0"
173
- enter-to-class="translate-y-0 opacity-100"
174
- leave-active-class="transition-all duration-200 ease-in"
175
- leave-from-class="translate-y-0 opacity-100"
176
- leave-to-class="-translate-y-4 opacity-0"
177
- >
178
- <div
179
- v-if="isMobileMenuOpen"
180
- class="fixed inset-x-0 bottom-0 z-40 [scrollbar-width:none] overflow-y-auto bg-background/95 backdrop-blur-md md:hidden [&::-webkit-scrollbar]:hidden"
181
- :style="{ top: mobileMenuTop }"
182
- >
183
- <div class="px-5 pt-9 pb-12">
184
- <nav v-if="mobileNavItems.length" class="space-y-4" :aria-label="t('nav.mobile')">
185
- <p class="px-3 text-xs font-bold tracking-[0.16em] text-muted-foreground uppercase">
186
- {{ t("nav.navigationSection") }}
187
- </p>
188
- <div class="overflow-hidden rounded-xl border border-border bg-background shadow-xs">
189
- <NuxtLink
190
- v-for="(group, index) in mobileNavItems"
191
- :key="group.href"
192
- :to="group.href"
193
- class="flex h-[4.25rem] items-center gap-4 px-5 text-base font-semibold transition-colors hover:bg-muted/50"
194
- :class="[
195
- isActive(group.href) ? 'text-primary' : 'text-foreground',
196
- index > 0 && 'border-t border-border',
197
- ]"
198
- @click="isMobileMenuOpen = false"
199
- >
200
- <span
201
- class="flex size-11 shrink-0 items-center justify-center rounded-lg bg-muted/60 text-foreground"
202
- >
203
- <Icon :name="group.icon" class="size-5" aria-hidden="true" />
204
- </span>
205
- <span class="min-w-0 flex-1 truncate">{{ group.label }}</span>
206
- <Icon
207
- name="lucide:chevron-right"
208
- class="size-5 shrink-0 text-muted-foreground"
209
- aria-hidden="true"
210
- />
211
- </NuxtLink>
212
- </div>
213
- </nav>
214
-
215
- <div class="space-y-4" :class="mobileNavItems.length ? 'mt-9' : ''">
216
- <p class="px-3 text-xs font-bold tracking-[0.16em] text-muted-foreground uppercase">
217
- {{ t("nav.settingsHelp") }}
218
- </p>
219
- <div class="overflow-hidden rounded-xl border border-border bg-background shadow-xs">
220
- <button
196
+ <Sheet v-model:open="isMobileMenuOpen">
197
+ <SheetTrigger as-child>
198
+ <Button
199
+ variant="ghost"
200
+ size="icon"
201
+ class="md:hidden"
202
+ :aria-label="t('nav.openMenu')"
221
203
  type="button"
222
- class="flex h-[4.25rem] w-full items-center gap-4 px-5 text-start text-base font-semibold transition-colors hover:bg-muted/50"
223
- @click="openSearch"
224
204
  >
225
- <span
226
- class="flex size-11 shrink-0 items-center justify-center rounded-lg bg-muted/60 text-foreground"
205
+ <Icon name="lucide:menu" class="size-5" aria-hidden="true" />
206
+ </Button>
207
+ </SheetTrigger>
208
+ <SheetContent
209
+ side="top"
210
+ class="inset-0 h-dvh gap-0 overflow-hidden border-b-0 bg-background/95 p-0 backdrop-blur-md md:hidden"
211
+ >
212
+ <SheetTitle class="sr-only">{{ t("nav.mobile") }}</SheetTitle>
213
+ <SheetDescription class="sr-only">{{ t("nav.main") }}</SheetDescription>
214
+ <div class="flex h-full min-h-0 flex-col pt-16">
215
+ <div
216
+ class="flex min-h-0 w-[200%] flex-1 transition-transform duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none"
217
+ :class="menuLevel === 'docs' ? '-translate-x-1/2' : ''"
227
218
  >
228
- <Icon name="lucide:search" class="size-5" aria-hidden="true" />
229
- </span>
230
- <span class="min-w-0 flex-1 truncate">{{ t("nav.search") }}</span>
231
- <Icon
232
- name="lucide:chevron-right"
233
- class="size-5 shrink-0 text-muted-foreground"
234
- aria-hidden="true"
235
- />
236
- </button>
237
- <div class="grid grid-cols-2 border-t border-border">
238
- <ModeToggle variant="menu-tile" class="border-r border-border" />
239
- <SiteLocaleSwitcher
240
- variant="menu-tile"
241
- class="flex"
242
- @navigate="isMobileMenuOpen = false"
243
- />
219
+ <nav
220
+ class="flex min-h-0 w-1/2 flex-col overflow-x-hidden overflow-y-auto px-6"
221
+ :aria-label="t('nav.mobile')"
222
+ :inert="menuLevel !== 'root'"
223
+ >
224
+ <div class="m-auto w-full space-y-8 py-6">
225
+ <template v-for="item in rootNavItems" :key="item.href">
226
+ <button
227
+ v-if="item.drill"
228
+ :ref="setDrillButtonRef"
229
+ type="button"
230
+ class="group block w-full text-start"
231
+ @click="drillIntoDocs"
232
+ >
233
+ <span
234
+ class="flex items-center gap-2.5 text-[2.5rem] leading-[1.05] font-semibold tracking-[-0.03em] text-balance"
235
+ :class="
236
+ isActive(item.href)
237
+ ? 'text-foreground'
238
+ : 'text-foreground/70 transition-colors group-hover:text-foreground'
239
+ "
240
+ >
241
+ {{ item.label }}
242
+ <Icon
243
+ name="lucide:chevron-right"
244
+ class="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
245
+ aria-hidden="true"
246
+ />
247
+ </span>
248
+ <span
249
+ v-if="item.description"
250
+ class="mt-1.5 block text-sm text-muted-foreground"
251
+ >
252
+ {{ item.description }}
253
+ </span>
254
+ </button>
255
+ <NuxtLink v-else :to="item.href" class="group block" @click="closeMenu">
256
+ <span
257
+ class="block text-[2.5rem] leading-[1.05] font-semibold tracking-[-0.03em] text-balance"
258
+ :class="
259
+ isActive(item.href)
260
+ ? 'text-foreground'
261
+ : 'text-foreground/70 transition-colors group-hover:text-foreground'
262
+ "
263
+ >
264
+ {{ item.label }}
265
+ </span>
266
+ <span
267
+ v-if="item.description"
268
+ class="mt-1.5 block text-sm text-muted-foreground"
269
+ >
270
+ {{ item.description }}
271
+ </span>
272
+ </NuxtLink>
273
+ </template>
274
+ </div>
275
+ </nav>
276
+
277
+ <nav
278
+ v-if="hasDocsLevel"
279
+ class="flex min-h-0 w-1/2 flex-col px-6"
280
+ :aria-label="docsLevelTitle"
281
+ :inert="menuLevel !== 'docs'"
282
+ >
283
+ <button
284
+ ref="docsBackButton"
285
+ type="button"
286
+ class="flex items-center gap-1 self-start py-2 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
287
+ @click="backToRoot"
288
+ >
289
+ <Icon name="lucide:chevron-left" class="size-4" aria-hidden="true" />
290
+ {{ t("nav.menu") }}
291
+ </button>
292
+ <p
293
+ class="mt-1 text-[2rem] leading-[1.1] font-semibold tracking-[-0.03em] text-foreground"
294
+ >
295
+ {{ docsLevelTitle }}
296
+ </p>
297
+ <div class="-mx-2 mt-3 min-h-0 flex-1 overflow-x-hidden overflow-y-auto pb-4">
298
+ <template v-for="section in docsMenuSections" :key="section.id">
299
+ <p
300
+ v-if="section.title"
301
+ class="mt-6 mb-1 px-2 text-xs font-semibold tracking-[0.08em] text-muted-foreground uppercase first:mt-2"
302
+ >
303
+ {{ section.title }}
304
+ </p>
305
+ <NuxtLink
306
+ v-for="link in section.links"
307
+ :key="link.id"
308
+ :to="link.href"
309
+ class="flex items-center rounded-md px-2 py-2 text-lg font-semibold transition-colors"
310
+ :class="
311
+ link.active
312
+ ? 'bg-primary/10 text-primary'
313
+ : 'text-foreground hover:bg-muted/60'
314
+ "
315
+ :aria-current="link.active ? 'true' : undefined"
316
+ @click="closeMenu"
317
+ >
318
+ {{ link.label }}
319
+ <span
320
+ v-if="link.active"
321
+ class="mr-1 ml-auto size-1.5 rounded-full bg-primary"
322
+ aria-hidden="true"
323
+ />
324
+ </NuxtLink>
325
+ </template>
326
+ </div>
327
+ </nav>
328
+ </div>
329
+
330
+ <div class="shrink-0 space-y-3.5 px-6 pt-4 pb-8">
331
+ <button
332
+ type="button"
333
+ class="flex h-11 w-full items-center gap-2.5 rounded-md border border-border bg-muted/40 px-4 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted"
334
+ @click="openSearch"
335
+ >
336
+ <Icon name="lucide:search" class="size-4" aria-hidden="true" />
337
+ {{ t("nav.search") }}...
338
+ <Kbd class="ml-auto h-5 bg-background text-[10px]">{{ searchShortcut }}</Kbd>
339
+ </button>
340
+ <div class="flex flex-wrap items-center gap-2">
341
+ <ClientOnly>
342
+ <ModeToggle variant="pill" />
343
+ <template #fallback>
344
+ <span
345
+ class="h-9 w-24 rounded-full border border-border bg-background"
346
+ aria-hidden="true"
347
+ />
348
+ </template>
349
+ </ClientOnly>
350
+ <SiteLocaleSwitcher variant="pill" class="flex" @navigate="closeMenu" />
351
+ <span v-if="socialLinks.length" class="ml-auto flex items-center gap-2">
352
+ <NuxtLink
353
+ v-for="link in socialLinks"
354
+ :key="link.href"
355
+ :to="link.href"
356
+ target="_blank"
357
+ rel="noopener noreferrer"
358
+ class="inline-flex h-9 items-center gap-2 rounded-full border border-border px-4 text-sm font-semibold text-foreground transition-colors hover:bg-muted"
359
+ @click="closeMenu"
360
+ >
361
+ <Icon v-if="link.icon" :name="link.icon" class="size-4" aria-hidden="true" />
362
+ {{ link.label }}
363
+ </NuxtLink>
364
+ </span>
365
+ </div>
366
+ </div>
244
367
  </div>
245
- </div>
246
- </div>
368
+ </SheetContent>
369
+ </Sheet>
247
370
  </div>
248
371
  </div>
249
- </Transition>
372
+ </header>
250
373
  </template>
@@ -0,0 +1,164 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref, useId } from "vue";
3
+ import { useAsyncData, useI18n } from "#imports";
4
+ import { cn } from "../../utils";
5
+ import { filenameExtension, resolveFileIcon } from "../../utils/file-icons";
6
+
7
+ export type SiteHeroCodeTab = {
8
+ label: string;
9
+ icon?: string;
10
+ filename?: string;
11
+ language?: string;
12
+ code: string;
13
+ };
14
+
15
+ const props = defineProps<{ tabs: SiteHeroCodeTab[] }>();
16
+
17
+ const { t } = useI18n();
18
+ const regionId = useId();
19
+ const activeIndex = ref(0);
20
+
21
+ const activeTab = computed(() => props.tabs[activeIndex.value] ?? props.tabs[0]);
22
+
23
+ function tabIcon(tab: SiteHeroCodeTab): string {
24
+ return tab.icon ?? resolveFileIcon(tab.filename);
25
+ }
26
+
27
+ const HTML_ESCAPES: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;" };
28
+
29
+ function escapeHtml(input: string): string {
30
+ return input.replace(/[&<>]/g, (char) => HTML_ESCAPES[char] ?? char);
31
+ }
32
+
33
+ function plainLines(code: string): string {
34
+ return code
35
+ .split("\n")
36
+ .map((line) => `<span class="line">${escapeHtml(line)}</span>`)
37
+ .join("");
38
+ }
39
+
40
+ // Config code arrives as raw strings, so it skips the build-time content
41
+ // pipeline; highlight here with the same Shiki themes the docs use.
42
+ const { data: highlighted } = await useAsyncData(`site-hero-code-${regionId}`, async () => {
43
+ const { codeToHast, hastToHtml } = await import("shiki");
44
+
45
+ return Promise.all(
46
+ props.tabs.map(async (tab) => {
47
+ const lang = tab.language ?? filenameExtension(tab.filename) ?? "text";
48
+
49
+ try {
50
+ const root = await codeToHast(tab.code, {
51
+ lang,
52
+ themes: { light: "light-plus", dark: "dark-plus" },
53
+ });
54
+ const pre = root.children.find((node) => node.type === "element");
55
+ const code = pre?.children.find((node) => node.type === "element");
56
+ if (!code) return plainLines(tab.code);
57
+
58
+ return hastToHtml({ type: "root" as const, children: code.children });
59
+ } catch {
60
+ return plainLines(tab.code);
61
+ }
62
+ }),
63
+ );
64
+ });
65
+ </script>
66
+
67
+ <template>
68
+ <div class="site-hero-code content-codegroup not-prose group my-0 shadow-md">
69
+ <div class="content-codegroup-header">
70
+ <div v-if="tabs.length > 1" class="content-codegroup-tabs" role="tablist">
71
+ <button
72
+ v-for="(tab, index) in tabs"
73
+ :key="tab.label"
74
+ type="button"
75
+ role="tab"
76
+ :aria-selected="activeIndex === index ? 'true' : 'false'"
77
+ :class="
78
+ cn('content-codegroup-tab', activeIndex === index && 'content-codegroup-tab-active')
79
+ "
80
+ @click="activeIndex = index"
81
+ >
82
+ <Icon :name="tabIcon(tab)" class="content-codegroup-tab-icon" aria-hidden="true" />
83
+ {{ tab.label }}
84
+ </button>
85
+ </div>
86
+ <div
87
+ v-else-if="activeTab"
88
+ class="flex min-w-0 items-center gap-2 text-sm font-medium text-muted-foreground"
89
+ >
90
+ <Icon :name="tabIcon(activeTab)" class="content-codegroup-tab-icon" aria-hidden="true" />
91
+ <span class="truncate">{{ activeTab.label }}</span>
92
+ </div>
93
+ </div>
94
+ <div class="content-codegroup-panels grid">
95
+ <div
96
+ v-for="(tab, index) in tabs"
97
+ :key="tab.label"
98
+ role="tabpanel"
99
+ class="col-start-1 row-start-1 min-w-0"
100
+ :class="activeIndex === index ? '' : 'invisible'"
101
+ :inert="activeIndex !== index"
102
+ >
103
+ <figure
104
+ dir="ltr"
105
+ data-fd-codeblock
106
+ class="content-codeblock group shiki not-prose content-codeblock-in-group relative"
107
+ >
108
+ <div
109
+ role="region"
110
+ tabindex="0"
111
+ :aria-label="`${t('docs.codeSample')}: ${tab.label} (${regionId}-${index})`"
112
+ class="fd-scroll-container max-h-[600px] overflow-auto focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none focus-visible:ring-inset"
113
+ >
114
+ <!-- Shiki output over site-owner config code, never end-user input. -->
115
+ <!-- eslint-disable vue/no-v-html -->
116
+ <pre
117
+ class="w-max min-w-full"
118
+ ><code v-html="highlighted?.[index] ?? plainLines(tab.code)" /></pre>
119
+ <!-- eslint-enable vue/no-v-html -->
120
+ </div>
121
+ </figure>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ </template>
126
+
127
+ <style scoped>
128
+ /* Hero scale: same chrome as docs code groups, one step larger. */
129
+ .site-hero-code {
130
+ padding: 0.25rem 0.375rem 0.375rem;
131
+ }
132
+
133
+ .site-hero-code .content-codegroup-header {
134
+ padding: 0.375rem 0.375rem 0.25rem 0.625rem;
135
+ }
136
+
137
+ .site-hero-code .content-codegroup-tabs {
138
+ gap: 0.375rem;
139
+ }
140
+
141
+ .site-hero-code .content-codegroup-tab {
142
+ height: 2.125rem;
143
+ gap: 0.5rem;
144
+ padding: 0 0.625rem 0.4375rem;
145
+ font-size: 0.9375rem;
146
+ }
147
+
148
+ .site-hero-code .content-codegroup-tab-icon {
149
+ width: 1rem;
150
+ height: 1rem;
151
+ }
152
+
153
+ .site-hero-code .content-codeblock pre {
154
+ padding-top: 1.125rem;
155
+ padding-bottom: 1.125rem;
156
+ font-size: 0.875rem;
157
+ line-height: 1.75;
158
+ }
159
+
160
+ .site-hero-code :deep([data-fd-codeblock] pre code .line) {
161
+ padding-right: 1.5rem;
162
+ padding-left: 1.5rem;
163
+ }
164
+ </style>
@@ -7,13 +7,13 @@ import {
7
7
  DropdownMenuItem,
8
8
  DropdownMenuTrigger,
9
9
  } from "#ginko-docs/components/ui/dropdown-menu";
10
- import { cn } from "#ginko-docs/lib/utils";
10
+ import { cn } from "#ginko-docs/utils";
11
11
  import { computed } from "vue";
12
12
  import { useI18n } from "#imports";
13
13
  import { useLocalizedRouteSwitch } from "#ginko-docs/composables/useLocalizedRouteSwitch";
14
14
  import { locales as configuredLocales } from "../../../i18n/locales";
15
15
 
16
- type LocaleSwitcherVariant = "dropdown" | "menu-row" | "menu-tile" | "segmented";
16
+ type LocaleSwitcherVariant = "dropdown" | "menu-row" | "menu-tile" | "segmented" | "pill";
17
17
 
18
18
  type LocaleEntry = {
19
19
  code: string;
@@ -160,12 +160,30 @@ function trackLocaleNavigation(_entry: { code: string; current: boolean; to: unk
160
160
  </div>
161
161
 
162
162
  <div
163
- v-else-if="localeLinks.length > 1 && (variant === 'menu-row' || variant === 'menu-tile')"
163
+ v-else-if="
164
+ localeLinks.length > 1 &&
165
+ (variant === 'menu-row' || variant === 'menu-tile' || variant === 'pill')
166
+ "
164
167
  :class="cn('items-center', props.class)"
165
168
  >
166
169
  <DropdownMenu>
167
170
  <DropdownMenuTrigger as-child>
168
171
  <Button
172
+ v-if="variant === 'pill'"
173
+ variant="outline"
174
+ class="h-9 gap-2 rounded-full px-4 text-sm font-semibold"
175
+ :aria-label="t('nav.language')"
176
+ >
177
+ <Icon
178
+ v-if="currentLocale?.flagIcon"
179
+ :name="currentLocale.flagIcon"
180
+ class="size-4 rounded-full"
181
+ aria-hidden="true"
182
+ />
183
+ <span>{{ currentLocale?.shortLabel }}</span>
184
+ </Button>
185
+ <Button
186
+ v-else
169
187
  variant="ghost"
170
188
  :class="
171
189
  cn(
@@ -204,7 +222,7 @@ function trackLocaleNavigation(_entry: { code: string; current: boolean; to: unk
204
222
  />
205
223
  </Button>
206
224
  </DropdownMenuTrigger>
207
- <DropdownMenuContent align="end" class="w-44">
225
+ <DropdownMenuContent align="end" class="w-44" portal-disabled>
208
226
  <DropdownMenuItem v-for="entry in localeLinks" :key="entry.code" as-child>
209
227
  <NuxtLink
210
228
  :to="entry.to"