@lupinum/ginko-docs 0.2.2 → 0.2.5

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 (206) hide show
  1. package/README.md +130 -73
  2. package/app/app.config.ts +15 -13
  3. package/app/app.vue +4 -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 +34 -99
  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 +266 -132
  57. package/app/components/site/SiteHeroCode.vue +164 -0
  58. package/app/components/site/SiteLocaleSwitcher.vue +25 -9
  59. package/app/components/site/SiteSocialLinks.vue +31 -0
  60. package/app/components/ui/badge/Badge.vue +1 -1
  61. package/app/components/ui/button/Button.vue +1 -1
  62. package/app/components/ui/collapsible/CollapsibleContent.vue +1 -1
  63. package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +14 -4
  64. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  65. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +1 -1
  66. package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +1 -1
  67. package/app/components/ui/dropdown-menu/index.ts +0 -9
  68. package/app/components/ui/kbd/Kbd.vue +1 -1
  69. package/app/components/ui/kbd/index.ts +0 -1
  70. package/app/components/ui/scroll-area/ScrollArea.vue +1 -1
  71. package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
  72. package/app/components/ui/separator/Separator.vue +1 -1
  73. package/app/components/ui/sheet/SheetContent.vue +1 -1
  74. package/app/components/ui/sheet/SheetDescription.vue +1 -1
  75. package/app/components/ui/sheet/SheetOverlay.vue +1 -1
  76. package/app/components/ui/sheet/SheetTitle.vue +1 -1
  77. package/app/components/ui/sheet/index.ts +0 -3
  78. package/app/components/ui/tabs/Tabs.vue +1 -1
  79. package/app/components/ui/tabs/TabsList.vue +1 -1
  80. package/app/components/ui/tabs/TabsTrigger.vue +1 -1
  81. package/app/components/ui/tabs/index.ts +0 -1
  82. package/app/composables/site-navigation.utils.ts +69 -12
  83. package/app/composables/useContentRouteAlternates.ts +13 -21
  84. package/app/composables/useLocalizedRouteSwitch.ts +1 -1
  85. package/app/composables/useMetaKey.ts +18 -0
  86. package/app/composables/useProseAppearance.ts +24 -0
  87. package/app/composables/useSiteNavigation.ts +10 -40
  88. package/app/config/site.utils.ts +1 -1
  89. package/app/error.vue +1 -5
  90. package/app/features/docs/components/DocsBreadcrumb.vue +37 -0
  91. package/app/features/docs/components/DocsContributeLinks.vue +28 -18
  92. package/app/features/docs/components/DocsMobileToc.vue +12 -8
  93. package/app/features/docs/components/DocsPageContent.vue +93 -47
  94. package/app/features/docs/components/DocsPageNav.vue +1 -1
  95. package/app/features/docs/components/DocsSidebar.vue +37 -21
  96. package/app/features/docs/components/DocsSidebarItem.vue +53 -100
  97. package/app/features/docs/components/DocsSidebarRow.vue +27 -0
  98. package/app/features/docs/components/DocsSidebarTabs.vue +46 -22
  99. package/app/features/docs/components/DocsToc.vue +80 -34
  100. package/app/features/docs/composables/useDocsNavigation.ts +2 -2
  101. package/app/features/docs/composables/useDocsNavigationData.ts +4 -1
  102. package/app/features/docs/composables/useRevealActive.ts +58 -0
  103. package/app/features/docs/composables/useScrollspy.ts +87 -19
  104. package/app/features/docs/docs-navigation.ts +17 -30
  105. package/app/features/docs/toc-context.ts +6 -0
  106. package/app/features/search/command-center.ts +11 -90
  107. package/app/features/search/components/SiteCommandCenter.vue +58 -38
  108. package/app/features/search/useCommandCenter.ts +48 -48
  109. package/app/layouts/docs.vue +1 -1
  110. package/app/lib/errors.ts +2 -49
  111. package/app/pages/blog/[slug].vue +26 -9
  112. package/app/pages/blog/index.vue +38 -14
  113. package/app/pages/docs/index.vue +10 -14
  114. package/app/pages/index.vue +180 -61
  115. package/app/plugins/ginko-docs-theme.ts +12 -0
  116. package/app/utils/content.ts +11 -0
  117. package/app/utils/file-icons.ts +90 -0
  118. package/app/utils/index.ts +7 -1
  119. package/app/utils/repository.ts +20 -0
  120. package/content.js +68 -50
  121. package/content.ts +51 -32
  122. package/i18n/messages/global/blog.ts +4 -0
  123. package/i18n/messages/global/command.ts +6 -2
  124. package/i18n/messages/global/docs.ts +4 -0
  125. package/i18n/messages/global/nav.ts +6 -3
  126. package/i18n/messages/index.ts +3 -3
  127. package/icon-bundle.ts +9 -56
  128. package/index.d.ts +7 -1
  129. package/modules/feature-routing.ts +15 -0
  130. package/nuxt.config.ts +21 -7
  131. package/package.json +29 -26
  132. package/runtime/server/agent-markdown.ts +0 -2
  133. package/server/api/_ginko-docs/redirects.get.ts +28 -0
  134. package/server/middleware/redirects.ts +22 -0
  135. package/server/routes/[locale]/blog/rss.xml.get.ts +13 -0
  136. package/server/routes/blog/rss.xml.get.ts +5 -0
  137. package/server/utils/blog-feed.ts +49 -0
  138. package/server/utils/feed.ts +70 -0
  139. package/server/utils/redirects.ts +38 -0
  140. package/server/utils/redirects.utils.ts +58 -0
  141. package/shared/theme-palettes.ts +37 -0
  142. package/shared/types/app-config.ts +75 -10
  143. package/tags.ts +47 -59
  144. package/app/components/mdc/MdcAlert.vue +0 -34
  145. package/app/components/mdc/MdcCardGroup.vue +0 -47
  146. package/app/components/mdc/MdcDocImg.vue +0 -25
  147. package/app/components/mdc/MdcField.vue +0 -62
  148. package/app/components/mdc/MdcFieldGroup.vue +0 -18
  149. package/app/components/mdc/MdcPassage.vue +0 -32
  150. package/app/components/mdc/MdcQuizOption.vue +0 -91
  151. package/app/components/mdc/MdcReadMoreGroup.vue +0 -18
  152. package/app/components/mdc/MdcShortcut.vue +0 -46
  153. package/app/components/mdc/MdcStep.vue +0 -42
  154. package/app/components/ui/accordion/Accordion.vue +0 -15
  155. package/app/components/ui/accordion/AccordionContent.vue +0 -23
  156. package/app/components/ui/accordion/AccordionItem.vue +0 -24
  157. package/app/components/ui/accordion/AccordionTrigger.vue +0 -35
  158. package/app/components/ui/accordion/index.ts +0 -4
  159. package/app/components/ui/alert/Alert.vue +0 -21
  160. package/app/components/ui/alert/AlertDescription.vue +0 -22
  161. package/app/components/ui/alert/AlertTitle.vue +0 -22
  162. package/app/components/ui/alert/index.ts +0 -34
  163. package/app/components/ui/card/Card.vue +0 -16
  164. package/app/components/ui/card/CardAction.vue +0 -17
  165. package/app/components/ui/card/CardContent.vue +0 -14
  166. package/app/components/ui/card/CardDescription.vue +0 -14
  167. package/app/components/ui/card/CardFooter.vue +0 -14
  168. package/app/components/ui/card/CardHeader.vue +0 -22
  169. package/app/components/ui/card/card-variants.ts +0 -15
  170. package/app/components/ui/card/index.ts +0 -8
  171. package/app/components/ui/checkbox/Checkbox.vue +0 -35
  172. package/app/components/ui/checkbox/index.ts +0 -1
  173. package/app/components/ui/dialog/Dialog.vue +0 -15
  174. package/app/components/ui/dialog/DialogClose.vue +0 -12
  175. package/app/components/ui/dialog/DialogContent.vue +0 -69
  176. package/app/components/ui/dialog/DialogDescription.vue +0 -21
  177. package/app/components/ui/dialog/DialogFooter.vue +0 -15
  178. package/app/components/ui/dialog/DialogOverlay.vue +0 -26
  179. package/app/components/ui/dialog/DialogPortal.vue +0 -12
  180. package/app/components/ui/dialog/DialogTitle.vue +0 -21
  181. package/app/components/ui/dialog/DialogTrigger.vue +0 -12
  182. package/app/components/ui/dialog/index.ts +0 -10
  183. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +0 -36
  184. package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +0 -12
  185. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +0 -25
  186. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +0 -17
  187. package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +0 -15
  188. package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +0 -29
  189. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +0 -31
  190. package/app/components/ui/input/Input.vue +0 -23
  191. package/app/components/ui/input/index.ts +0 -1
  192. package/app/components/ui/label/Label.vue +0 -14
  193. package/app/components/ui/label/index.ts +0 -1
  194. package/app/components/ui/sheet/SheetClose.vue +0 -12
  195. package/app/components/ui/sheet/SheetFooter.vue +0 -12
  196. package/app/components/ui/sheet/SheetHeader.vue +0 -12
  197. package/app/components/ui/switch/Switch.vue +0 -39
  198. package/app/components/ui/switch/index.ts +0 -1
  199. package/app/components/ui/tabs/TabsContent.vue +0 -21
  200. package/app/components/ui/textarea/Textarea.vue +0 -31
  201. package/app/components/ui/textarea/index.ts +0 -1
  202. package/app/features/docs/composables/useDocsEntryPath.ts +0 -13
  203. package/app/lib/utils.ts +0 -7
  204. package/i18n/messages/source.ts +0 -5
  205. package/runtime/markdown/inline-code-highlight.ts +0 -155
  206. package/runtime/utils.ts +0 -7
@@ -1,79 +1,126 @@
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";
29
+ import { useGinkoDocsConfig } from "#ginko-docs/composables/useGinkoDocsConfig";
12
30
 
13
- const { mainNav } = useSiteNavigation();
31
+ const { mainNav, socialLinks } = useSiteNavigation();
14
32
  const { openCommandCenter } = useCommandCenterState();
33
+ const config = useGinkoDocsConfig();
15
34
  const { t } = useI18n();
16
35
  const route = useRoute();
17
36
  const isMobileMenuOpen = ref(false);
18
- const headerElement = ref<HTMLElement | null>(null);
19
- const mobileMenuTop = ref("var(--site-header-height)");
20
37
  const localizedPath = useLocalizedPath();
21
38
 
22
39
  const isActive = (href: string) => route.path === href || route.path.startsWith(href + "/");
23
40
 
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
- }
41
+ const metaKey = useMetaKey();
42
+ const searchShortcut = computed(() => (metaKey.value === "" ? "⌘K" : "Ctrl K"));
43
+ const homePath = computed(() => localizedPath("home"));
44
+ const docsPath = computed(() => localizedPath("docs"));
45
+
46
+ // The mobile menu has two levels: the root nav in hero-scale type, and a docs
47
+ // level with the section entry links. The docs nav link drills in; every other
48
+ // link navigates directly.
49
+ const { data: docsNavData } = useDocsNavigationData();
50
+ const docsSections = computed(() =>
51
+ getDocsNavigationSections(
52
+ (docsNavData.value ?? []).map((item, index) => normalizeDocsNavigationItem(item, index)),
53
+ ),
54
+ );
55
+ const docsMenuSections = computed(() => {
56
+ const currentPath = normalizeNavigationPath(route.path);
57
+
58
+ return docsSections.value
59
+ .map((section) => ({
60
+ id: section.id,
61
+ title: section.title,
62
+ links: section.items
63
+ .map((item) => ({
64
+ id: item.id,
65
+ label: item.title,
66
+ href: item.path ?? findFirstNavigationPage(item.children)?.path,
67
+ active: navigationItemContainsPath(item, currentPath),
68
+ }))
69
+ .filter((link): link is typeof link & { href: string } => Boolean(link.href)),
70
+ }))
71
+ .filter((section) => section.links.length > 0);
72
+ });
73
+ const hasDocsLevel = computed(() => docsMenuSections.value.length > 0);
29
74
 
30
- const mobileNavItems = computed(() =>
75
+ const rootNavItems = computed(() =>
31
76
  mainNav.value.map((item) => ({
32
- label: item.label,
33
- href: item.href,
34
- icon: mobileNavIcon(item.href),
35
- children: [] as { label: string; href: string }[],
77
+ ...item,
78
+ drill: hasDocsLevel.value && item.href === docsPath.value,
36
79
  })),
37
80
  );
38
- const homePath = computed(() => localizedPath("home"));
81
+ const docsLevelTitle = computed(
82
+ () => rootNavItems.value.find((item) => item.drill)?.label ?? t("nav.documentation"),
83
+ );
39
84
 
40
- // Scroll lock when menu is open
41
- const scrollLock = useScrollLock(import.meta.client ? document.body : null);
85
+ type MenuLevel = "root" | "docs";
86
+ const menuLevel = ref<MenuLevel>("root");
87
+ const docsDrillButton = ref<HTMLButtonElement | null>(null);
88
+ const docsBackButton = ref<HTMLButtonElement | null>(null);
42
89
 
43
- watch(isMobileMenuOpen, (open) => {
44
- scrollLock.value = open;
45
- if (open) {
46
- void nextTick(updateMobileMenuTop);
47
- }
48
- });
90
+ function setDrillButtonRef(el: unknown) {
91
+ docsDrillButton.value = el instanceof HTMLButtonElement ? el : null;
92
+ }
49
93
 
50
- function updateMobileMenuTop() {
51
- if (!import.meta.client || !headerElement.value) return;
52
- mobileMenuTop.value = `${Math.max(0, headerElement.value.getBoundingClientRect().bottom)}px`;
94
+ async function drillIntoDocs() {
95
+ menuLevel.value = "docs";
96
+ await nextTick();
97
+ docsBackButton.value?.focus({ preventScroll: true });
53
98
  }
54
99
 
55
- function handleResize() {
56
- if (isMobileMenuOpen.value) {
57
- updateMobileMenuTop();
58
- }
100
+ async function backToRoot() {
101
+ menuLevel.value = "root";
102
+ await nextTick();
103
+ docsDrillButton.value?.focus({ preventScroll: true });
59
104
  }
60
105
 
61
- onMounted(() => window.addEventListener("resize", handleResize));
62
- onBeforeUnmount(() => window.removeEventListener("resize", handleResize));
106
+ function closeMenu() {
107
+ isMobileMenuOpen.value = false;
108
+ }
63
109
 
110
+ // Scroll lock, Escape handling, and focus trapping come from the Sheet
111
+ // (reka-ui Dialog); only route-driven closing is ours.
64
112
  function openSearch() {
65
113
  isMobileMenuOpen.value = false;
66
114
  openCommandCenter();
67
115
  }
68
116
 
69
- // Close on Escape
70
- onKeyStroke("Escape", () => {
71
- if (isMobileMenuOpen.value) {
72
- isMobileMenuOpen.value = false;
117
+ watch(isMobileMenuOpen, (open) => {
118
+ if (open) {
119
+ // Context-aware entry: docs readers land on their section links first.
120
+ menuLevel.value = hasDocsLevel.value && isActive(docsPath.value) ? "docs" : "root";
73
121
  }
74
122
  });
75
123
 
76
- // Close menu on navigation
77
124
  watch(
78
125
  () => route.path,
79
126
  () => {
@@ -84,7 +131,6 @@ watch(
84
131
 
85
132
  <template>
86
133
  <header
87
- ref="headerElement"
88
134
  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
135
  >
90
136
  <div class="mx-auto flex w-full max-w-screen-2xl flex-1 items-center gap-4 px-4 md:px-6">
@@ -120,7 +166,7 @@ watch(
120
166
  >
121
167
  <Icon name="lucide:search" class="size-4 shrink-0" aria-hidden="true" />
122
168
  <span class="flex-1 text-left">{{ t("nav.search") }}...</span>
123
- <Kbd class="h-5 bg-background text-[10px]">⌘K</Kbd>
169
+ <Kbd class="h-5 bg-background text-[10px]">{{ searchShortcut }}</Kbd>
124
170
  </Button>
125
171
  <Button
126
172
  variant="ghost"
@@ -148,103 +194,191 @@ watch(
148
194
  </template>
149
195
  </ClientOnly>
150
196
 
197
+ <div v-if="config.nav.socialIcons" class="hidden md:block">
198
+ <SiteSocialLinks />
199
+ </div>
200
+
151
201
  <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
202
+ <Sheet v-model:open="isMobileMenuOpen">
203
+ <SheetTrigger as-child>
204
+ <Button
205
+ variant="ghost"
206
+ size="icon"
207
+ class="md:hidden"
208
+ :aria-label="t('nav.openMenu')"
221
209
  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
210
  >
225
- <span
226
- class="flex size-11 shrink-0 items-center justify-center rounded-lg bg-muted/60 text-foreground"
211
+ <Icon name="lucide:menu" class="size-5" aria-hidden="true" />
212
+ </Button>
213
+ </SheetTrigger>
214
+ <SheetContent
215
+ side="top"
216
+ class="inset-0 h-dvh gap-0 overflow-hidden border-b-0 bg-background/95 p-0 backdrop-blur-md md:hidden"
217
+ >
218
+ <SheetTitle class="sr-only">{{ t("nav.mobile") }}</SheetTitle>
219
+ <SheetDescription class="sr-only">{{ t("nav.main") }}</SheetDescription>
220
+ <div class="flex h-full min-h-0 flex-col pt-16">
221
+ <div
222
+ 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"
223
+ :class="menuLevel === 'docs' ? '-translate-x-1/2' : ''"
227
224
  >
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
- />
225
+ <nav
226
+ class="flex min-h-0 w-1/2 flex-col overflow-x-hidden overflow-y-auto px-6"
227
+ :aria-label="t('nav.mobile')"
228
+ :inert="menuLevel !== 'root'"
229
+ >
230
+ <div class="m-auto w-full space-y-8 py-6">
231
+ <template v-for="item in rootNavItems" :key="item.href">
232
+ <button
233
+ v-if="item.drill"
234
+ :ref="setDrillButtonRef"
235
+ type="button"
236
+ class="group block w-full text-start"
237
+ @click="drillIntoDocs"
238
+ >
239
+ <span
240
+ class="flex items-center gap-2.5 text-[2.5rem] leading-[1.05] font-semibold tracking-[-0.03em] text-balance"
241
+ :class="
242
+ isActive(item.href)
243
+ ? 'text-foreground'
244
+ : 'text-foreground/70 transition-colors group-hover:text-foreground'
245
+ "
246
+ >
247
+ {{ item.label }}
248
+ <Icon
249
+ name="lucide:chevron-right"
250
+ class="size-6 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
251
+ aria-hidden="true"
252
+ />
253
+ </span>
254
+ <span
255
+ v-if="item.description"
256
+ class="mt-1.5 block text-sm text-muted-foreground"
257
+ >
258
+ {{ item.description }}
259
+ </span>
260
+ </button>
261
+ <NuxtLink v-else :to="item.href" class="group block" @click="closeMenu">
262
+ <span
263
+ class="block text-[2.5rem] leading-[1.05] font-semibold tracking-[-0.03em] text-balance"
264
+ :class="
265
+ isActive(item.href)
266
+ ? 'text-foreground'
267
+ : 'text-foreground/70 transition-colors group-hover:text-foreground'
268
+ "
269
+ >
270
+ {{ item.label }}
271
+ </span>
272
+ <span
273
+ v-if="item.description"
274
+ class="mt-1.5 block text-sm text-muted-foreground"
275
+ >
276
+ {{ item.description }}
277
+ </span>
278
+ </NuxtLink>
279
+ </template>
280
+ </div>
281
+ </nav>
282
+
283
+ <nav
284
+ v-if="hasDocsLevel"
285
+ class="flex min-h-0 w-1/2 flex-col px-6"
286
+ :aria-label="docsLevelTitle"
287
+ :inert="menuLevel !== 'docs'"
288
+ >
289
+ <button
290
+ ref="docsBackButton"
291
+ type="button"
292
+ class="flex items-center gap-1 self-start py-2 text-sm font-semibold text-muted-foreground transition-colors hover:text-foreground"
293
+ @click="backToRoot"
294
+ >
295
+ <Icon name="lucide:chevron-left" class="size-4" aria-hidden="true" />
296
+ {{ t("nav.menu") }}
297
+ </button>
298
+ <p
299
+ class="mt-1 text-[2rem] leading-[1.1] font-semibold tracking-[-0.03em] text-foreground"
300
+ >
301
+ {{ docsLevelTitle }}
302
+ </p>
303
+ <div class="-mx-2 mt-3 min-h-0 flex-1 overflow-x-hidden overflow-y-auto pb-4">
304
+ <template v-for="section in docsMenuSections" :key="section.id">
305
+ <p
306
+ v-if="section.title"
307
+ class="mt-6 mb-1 px-2 text-xs font-semibold tracking-[0.08em] text-muted-foreground uppercase first:mt-2"
308
+ >
309
+ {{ section.title }}
310
+ </p>
311
+ <NuxtLink
312
+ v-for="link in section.links"
313
+ :key="link.id"
314
+ :to="link.href"
315
+ class="flex items-center rounded-md px-2 py-2 text-lg font-semibold transition-colors"
316
+ :class="
317
+ link.active
318
+ ? 'bg-primary/10 text-primary'
319
+ : 'text-foreground hover:bg-muted/60'
320
+ "
321
+ :aria-current="link.active ? 'true' : undefined"
322
+ @click="closeMenu"
323
+ >
324
+ {{ link.label }}
325
+ <span
326
+ v-if="link.active"
327
+ class="mr-1 ml-auto size-1.5 rounded-full bg-primary"
328
+ aria-hidden="true"
329
+ />
330
+ </NuxtLink>
331
+ </template>
332
+ </div>
333
+ </nav>
334
+ </div>
335
+
336
+ <div class="shrink-0 space-y-3.5 px-6 pt-4 pb-8">
337
+ <button
338
+ type="button"
339
+ 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"
340
+ @click="openSearch"
341
+ >
342
+ <Icon name="lucide:search" class="size-4" aria-hidden="true" />
343
+ {{ t("nav.search") }}...
344
+ <Kbd class="ml-auto h-5 bg-background text-[10px]">{{ searchShortcut }}</Kbd>
345
+ </button>
346
+ <div class="flex flex-wrap items-center gap-2">
347
+ <ClientOnly>
348
+ <ModeToggle variant="pill" />
349
+ <template #fallback>
350
+ <span
351
+ class="h-9 w-24 rounded-full border border-border bg-background"
352
+ aria-hidden="true"
353
+ />
354
+ </template>
355
+ </ClientOnly>
356
+ <SiteLocaleSwitcher variant="pill" class="flex" @navigate="closeMenu" />
357
+ <SiteSocialLinks
358
+ v-if="config.nav.socialIcons"
359
+ class="ml-auto"
360
+ @navigate="closeMenu"
361
+ />
362
+ <span v-else-if="socialLinks.length" class="ml-auto flex items-center gap-2">
363
+ <NuxtLink
364
+ v-for="link in socialLinks"
365
+ :key="link.href"
366
+ :to="link.href"
367
+ target="_blank"
368
+ rel="noopener noreferrer"
369
+ 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"
370
+ @click="closeMenu"
371
+ >
372
+ <Icon v-if="link.icon" :name="link.icon" class="size-4" aria-hidden="true" />
373
+ {{ link.label }}
374
+ </NuxtLink>
375
+ </span>
376
+ </div>
377
+ </div>
244
378
  </div>
245
- </div>
246
- </div>
379
+ </SheetContent>
380
+ </Sheet>
247
381
  </div>
248
382
  </div>
249
- </Transition>
383
+ </header>
250
384
  </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>