@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,7 +1,12 @@
1
1
  <script setup lang="ts">
2
- import type { DocsNavigationSection } from "#ginko-docs/features/docs/docs-navigation";
2
+ import { computed } from "vue";
3
+ import {
4
+ resolveDocsSectionTargetPath,
5
+ type DocsNavigationSection,
6
+ } from "#ginko-docs/features/docs/docs-navigation";
7
+ import { Tabs, TabsList, TabsTrigger } from "#ginko-docs/components/ui/tabs";
3
8
 
4
- defineProps<{
9
+ const props = defineProps<{
5
10
  sections: Array<DocsNavigationSection & { title: string }>;
6
11
  activeId: string;
7
12
  }>();
@@ -9,27 +14,46 @@ defineProps<{
9
14
  const emit = defineEmits<{
10
15
  "update:activeId": [id: string];
11
16
  }>();
17
+
18
+ const tabs = computed(() =>
19
+ props.sections.map((section) => ({
20
+ id: section.id,
21
+ title: section.title,
22
+ target: resolveDocsSectionTargetPath(section),
23
+ })),
24
+ );
25
+
26
+ function onTabClick(id: string, event: MouseEvent) {
27
+ // Re-clicking the active section would otherwise navigate away from the
28
+ // current page to the section's first page.
29
+ if (id === props.activeId) event.preventDefault();
30
+ }
12
31
  </script>
13
32
 
14
33
  <template>
15
- <div
16
- class="inline-flex h-9 w-full items-center justify-center overflow-hidden rounded-lg bg-muted p-[3px] text-muted-foreground"
17
- role="tablist"
18
- aria-orientation="horizontal"
19
- data-slot="docs-sidebar-tabs"
20
- >
21
- <button
22
- v-for="section in sections"
23
- :key="section.id"
24
- type="button"
25
- role="tab"
26
- :title="section.title"
27
- :aria-selected="activeId === section.id"
28
- :data-state="activeId === section.id ? 'active' : 'inactive'"
29
- class="inline-flex h-[calc(100%-1px)] min-w-0 flex-1 basis-0 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring data-[state=active]:bg-background data-[state=active]:shadow-sm dark:text-muted-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground"
30
- @click="emit('update:activeId', section.id)"
31
- >
32
- {{ section.title }}
33
- </button>
34
- </div>
34
+ <Tabs :model-value="activeId" activation-mode="manual" data-slot="docs-sidebar-tabs">
35
+ <TabsList class="w-full">
36
+ <template v-for="tab in tabs" :key="tab.id">
37
+ <TabsTrigger v-if="tab.target" :value="tab.id" as-child>
38
+ <NuxtLink
39
+ :to="tab.target"
40
+ :title="tab.title"
41
+ class="min-w-0 flex-1 basis-0"
42
+ @click="onTabClick(tab.id, $event)"
43
+ >
44
+ <span class="truncate">{{ tab.title }}</span>
45
+ </NuxtLink>
46
+ </TabsTrigger>
47
+ <TabsTrigger
48
+ v-else
49
+ :value="tab.id"
50
+ :title="tab.title"
51
+ class="min-w-0 flex-1 basis-0"
52
+ @click="emit('update:activeId', tab.id)"
53
+ >
54
+ <span class="truncate">{{ tab.title }}</span>
55
+ </TabsTrigger>
56
+ </template>
57
+ </TabsList>
58
+ </Tabs>
35
59
  </template>
@@ -1,60 +1,106 @@
1
1
  <script setup lang="ts">
2
2
  import type { HTMLAttributes } from "vue";
3
- import { cn } from "#ginko-docs/lib/utils";
3
+ import { nextTick, onMounted, ref, watch } from "vue";
4
+ import { useEventListener } from "@vueuse/core";
5
+ import { cn } from "#ginko-docs/utils";
6
+ import type { FlatTocItem } from "#ginko-docs/utils/content";
4
7
  import { useI18n } from "#imports";
5
8
 
6
- export interface TocItem {
7
- id: string;
8
- label: string;
9
- depth?: number; // 2 = h2, 3 = h3
10
- }
11
-
12
9
  const props = withDefaults(
13
10
  defineProps<{
14
- items: TocItem[];
15
- activeId?: string;
11
+ items: FlatTocItem[];
12
+ activeIds?: string[];
16
13
  showTitle?: boolean;
17
14
  class?: HTMLAttributes["class"];
18
15
  }>(),
19
16
  {
20
17
  showTitle: true,
18
+ activeIds: () => [],
21
19
  },
22
20
  );
23
21
  const { t } = useI18n();
22
+
23
+ // The sliding indicator spans the active rows. Measure the rendered rows
24
+ // instead of assuming a fixed row height, so wrapped or restyled rows keep
25
+ // the indicator aligned.
26
+ const listRef = ref<HTMLElement | null>(null);
27
+ const indicatorStyle = ref({ opacity: "0", top: "0px", height: "0px" });
28
+
29
+ function measureIndicator() {
30
+ const list = listRef.value;
31
+ if (!list) return;
32
+ const active = list.querySelectorAll<HTMLElement>("[data-toc-active]");
33
+ const first = active[0];
34
+ const last = active[active.length - 1];
35
+ if (!first || !last) {
36
+ indicatorStyle.value = { ...indicatorStyle.value, opacity: "0" };
37
+ return;
38
+ }
39
+ indicatorStyle.value = {
40
+ opacity: "1",
41
+ top: `${first.offsetTop}px`,
42
+ height: `${last.offsetTop + last.offsetHeight - first.offsetTop}px`,
43
+ };
44
+ }
45
+
46
+ watch(
47
+ () => [props.activeIds, props.items],
48
+ () => nextTick(measureIndicator),
49
+ { deep: true },
50
+ );
51
+ onMounted(measureIndicator);
52
+ useEventListener("resize", measureIndicator, { passive: true });
53
+
54
+ function scrollToHeading(id: string) {
55
+ const element = document.getElementById(id);
56
+ if (!element) return;
57
+ // scrollIntoView ignores the global reduced-motion CSS override, so check
58
+ // explicitly. `scroll-margin-top` on headings handles the sticky offset.
59
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
60
+ element.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "start" });
61
+ // Sync the hash without router involvement — router-driven hash navigation
62
+ // would scroll-restore and fight the smooth scroll above.
63
+ history.replaceState(history.state, "", `#${encodeURIComponent(id)}`);
64
+ }
24
65
  </script>
25
66
 
26
67
  <template>
27
- <div v-if="items.length" :class="cn('w-full', props.class)">
28
- <h3 v-if="showTitle" class="mb-4 flex items-center gap-2 text-sm font-semibold text-foreground">
68
+ <nav v-if="items.length" :class="cn('w-full', props.class)" :aria-label="t('docs.toc')">
69
+ <h3
70
+ v-if="showTitle"
71
+ class="mb-4 flex items-center gap-2 text-xs font-semibold tracking-wider text-muted-foreground uppercase"
72
+ >
29
73
  <Icon name="lucide:list" class="size-3.5 shrink-0" aria-hidden="true" />
30
74
  {{ t("docs.toc") }}
31
75
  </h3>
32
76
 
33
- <div class="relative space-y-1 border-l border-border pl-3 text-[13px]">
34
- <!-- Animated active indicator -->
77
+ <div class="relative border-l border-border pl-3">
35
78
  <div
36
- class="absolute -left-px w-0.5 rounded-full bg-primary transition-all duration-200"
37
- :style="
38
- activeId && items.findIndex((i) => i.id === activeId) !== -1
39
- ? `top: ${items.findIndex((i) => i.id === activeId) * 26 + 2}px; height: 20px; opacity: 1;`
40
- : 'top: 2px; height: 20px; opacity: 0;'
41
- "
79
+ class="pointer-events-none absolute -left-px w-0.5 rounded-full bg-primary transition-all duration-200 ease-out"
80
+ :style="indicatorStyle"
42
81
  />
43
82
 
44
- <a
45
- v-for="item in items"
46
- :key="item.id"
47
- :href="`#${item.id}`"
48
- class="block truncate py-0.5 leading-5 transition-colors"
49
- :class="[
50
- item.depth === 3 ? 'pl-3' : (item.depth ?? 2) >= 4 ? 'pl-6' : '',
51
- activeId === item.id
52
- ? 'font-medium text-primary'
53
- : 'text-muted-foreground hover:text-foreground',
54
- ]"
55
- >
56
- {{ item.label }}
57
- </a>
83
+ <ul ref="listRef" class="space-y-1">
84
+ <li v-for="item in items" :key="item.id">
85
+ <a
86
+ :href="`#${item.id}`"
87
+ :title="item.label"
88
+ :data-toc-active="activeIds.includes(item.id) ? 'true' : undefined"
89
+ class="block truncate rounded-sm py-0.5 text-[13px] leading-5 transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
90
+ :class="
91
+ cn(
92
+ item.depth === 3 ? 'pl-3' : (item.depth ?? 2) >= 4 ? 'pl-6' : '',
93
+ activeIds.includes(item.id)
94
+ ? 'font-medium text-primary'
95
+ : 'text-muted-foreground hover:text-foreground',
96
+ )
97
+ "
98
+ @click.prevent="scrollToHeading(item.id)"
99
+ >
100
+ {{ item.label }}
101
+ </a>
102
+ </li>
103
+ </ul>
58
104
  </div>
59
- </div>
105
+ </nav>
60
106
  </template>
@@ -1,7 +1,7 @@
1
1
  import { computed } from "vue";
2
+ import { findNavigationTrail } from "@lupinum/ginko-content/navigation";
2
3
  import { useRoute } from "#imports";
3
4
  import {
4
- findDocsNavigationTrail,
5
5
  getDocsNavigationSections,
6
6
  normalizeDocsNavigationItem,
7
7
  type DocsNavigationSection,
@@ -20,7 +20,7 @@ export async function useDocsNavigation() {
20
20
  return getDocsNavigationSections(roots.value);
21
21
  });
22
22
 
23
- const trail = computed(() => findDocsNavigationTrail(roots.value, route.path));
23
+ const trail = computed(() => findNavigationTrail(roots.value, route.path));
24
24
 
25
25
  return {
26
26
  sections,
@@ -2,7 +2,10 @@ import { computed } from "vue";
2
2
  import { navigation } from "@lupinum/ginko-content/client";
3
3
  import { useAsyncData, useI18n } from "#imports";
4
4
 
5
- export async function useDocsNavigationData() {
5
+ // Sync on purpose: the returned AsyncData is itself awaitable, so async callers
6
+ // still `await` it, while sync callers (e.g. the site header) read `data`
7
+ // reactively without suspending.
8
+ export function useDocsNavigationData() {
6
9
  const { locale } = useI18n();
7
10
  const navigationKey = computed(() => `docs-navigation:${locale.value}`);
8
11
 
@@ -0,0 +1,58 @@
1
+ import type { Ref } from "vue";
2
+
3
+ export interface RevealActiveOptions {
4
+ /** Band padding in px; the active element is left alone inside the band. */
5
+ topPad?: number;
6
+ bottomPad?: number;
7
+ }
8
+
9
+ export interface RevealMetrics {
10
+ scrollTop: number;
11
+ containerHeight: number;
12
+ /** Active element's offset from the container's visible top edge. */
13
+ activeTop: number;
14
+ activeHeight: number;
15
+ topPad: number;
16
+ bottomPad: number;
17
+ }
18
+
19
+ /** Returns the scrollTop that centers the active element, or null if it is
20
+ * already inside the visible band. */
21
+ export function computeRevealScrollTop(metrics: RevealMetrics): number | null {
22
+ const { scrollTop, containerHeight, activeTop, activeHeight, topPad, bottomPad } = metrics;
23
+ const visible = activeTop >= topPad && activeTop + activeHeight <= containerHeight - bottomPad;
24
+ if (visible) return null;
25
+ return scrollTop + activeTop - containerHeight / 2 + activeHeight / 2;
26
+ }
27
+
28
+ /**
29
+ * Keeps the active element of an overflowing container visible by centering
30
+ * it when it drifts outside the visible band. Scrolling is instant on
31
+ * purpose: this is positioning, not motion, and must not compete with the
32
+ * page scroll. Callers decide when to schedule `reveal` (nextTick, rAF, …).
33
+ */
34
+ export function useRevealActive(
35
+ container: Ref<HTMLElement | null> | (() => HTMLElement | null | undefined),
36
+ selector: string,
37
+ options: RevealActiveOptions = {},
38
+ ) {
39
+ function reveal() {
40
+ const el = typeof container === "function" ? container() : container.value;
41
+ if (!el || el.scrollHeight <= el.clientHeight) return;
42
+ const active = el.querySelector<HTMLElement>(selector);
43
+ if (!active) return;
44
+ const containerRect = el.getBoundingClientRect();
45
+ const activeRect = active.getBoundingClientRect();
46
+ const next = computeRevealScrollTop({
47
+ scrollTop: el.scrollTop,
48
+ containerHeight: el.clientHeight,
49
+ activeTop: activeRect.top - containerRect.top,
50
+ activeHeight: activeRect.height,
51
+ topPad: options.topPad ?? 8,
52
+ bottomPad: options.bottomPad ?? 8,
53
+ });
54
+ if (next !== null) el.scrollTop = next;
55
+ }
56
+
57
+ return { reveal };
58
+ }
@@ -8,26 +8,91 @@ import {
8
8
  type MaybeRefOrGetter,
9
9
  } from "vue";
10
10
 
11
- export function useScrollspy(ids: MaybeRefOrGetter<string[]>, offset = 112) {
12
- const activeId = ref("");
13
- let observed: HTMLElement[] = [];
11
+ export type ScrollspyHeading = {
12
+ id: string;
13
+ /** Viewport-relative top, in document order. */
14
+ top: number;
15
+ };
16
+
17
+ export type ScrollspyZone = {
18
+ zoneTop: number;
19
+ zoneBottom: number;
20
+ /** Viewport-relative bottom of the document (scrollHeight - scrollY). */
21
+ docEnd: number;
22
+ /** When the page is scrolled to the very bottom, the zone extends to docEnd
23
+ * so a short final section still activates. */
24
+ atBottom: boolean;
25
+ };
26
+
27
+ // Headings use `scroll-margin-top: 7rem` (= ZONE_TOP), so after an anchor jump
28
+ // the clicked heading sits exactly at the zone top and becomes the first
29
+ // active entry. The tolerance absorbs sub-pixel layout drift.
30
+ const ZONE_TOP = 112;
31
+ const TOLERANCE = 8;
32
+
33
+ /**
34
+ * A heading is active while any part of its section (heading → next heading)
35
+ * intersects the reading zone. Pure so the zone model is unit-testable.
36
+ */
37
+ export function computeActiveIds(headings: ScrollspyHeading[], zone: ScrollspyZone): string[] {
38
+ if (headings.length === 0) return [];
39
+
40
+ const zoneTop = zone.zoneTop + TOLERANCE;
41
+ const zoneBottom = zone.atBottom ? zone.docEnd : zone.zoneBottom;
42
+
43
+ const active: string[] = [];
44
+ for (let index = 0; index < headings.length; index++) {
45
+ const sectionTop = headings[index]!.top;
46
+ const sectionBottom = index < headings.length - 1 ? headings[index + 1]!.top : zone.docEnd;
47
+ if (sectionBottom > zoneTop && sectionTop < zoneBottom) {
48
+ active.push(headings[index]!.id);
49
+ }
50
+ }
51
+ if (active.length > 0) return active;
14
52
 
15
- // Headings use `scroll-margin-top: 7rem` (= offset), so after an anchor jump
16
- // a heading sits at exactly `offset` with sub-pixel layout it can land a
17
- // fraction below. The tolerance keeps that heading active instead of the
18
- // previous section.
19
- const tolerance = 8;
53
+ // No section in the zone (e.g. a long passage between headings): fall back
54
+ // to the last heading scrolled past the zone top, then to the first heading.
55
+ for (let index = headings.length - 1; index >= 0; index--) {
56
+ if (headings[index]!.top <= zoneTop) return [headings[index]!.id];
57
+ }
58
+ return [headings[0]!.id];
59
+ }
20
60
 
21
- function updateFromScroll() {
22
- const candidates = observed
23
- .map((element) => ({
61
+ export function useScrollspy(ids: MaybeRefOrGetter<string[]>) {
62
+ const activeIds = ref<string[]>([]);
63
+ let observed: HTMLElement[] = [];
64
+ let rafId: number | null = null;
65
+
66
+ function update() {
67
+ const scrollY = window.scrollY;
68
+ const innerHeight = window.innerHeight;
69
+ const scrollHeight = document.documentElement.scrollHeight;
70
+ const next = computeActiveIds(
71
+ observed.map((element) => ({
24
72
  id: element.id,
25
73
  top: element.getBoundingClientRect().top,
26
- }))
27
- .filter((item) => item.top <= offset + tolerance)
28
- .sort((a, b) => b.top - a.top);
74
+ })),
75
+ {
76
+ zoneTop: ZONE_TOP,
77
+ zoneBottom: innerHeight * 0.8,
78
+ docEnd: scrollHeight - scrollY,
79
+ atBottom: scrollY + innerHeight >= scrollHeight - 2,
80
+ },
81
+ );
82
+ if (
83
+ next.length !== activeIds.value.length ||
84
+ next.some((id, index) => id !== activeIds.value[index])
85
+ ) {
86
+ activeIds.value = next;
87
+ }
88
+ }
29
89
 
30
- activeId.value = candidates[0]?.id ?? observed[0]?.id ?? "";
90
+ function scheduleUpdate() {
91
+ if (rafId !== null) return;
92
+ rafId = requestAnimationFrame(() => {
93
+ rafId = null;
94
+ update();
95
+ });
31
96
  }
32
97
 
33
98
  async function refresh() {
@@ -38,18 +103,21 @@ export function useScrollspy(ids: MaybeRefOrGetter<string[]>, offset = 112) {
38
103
  .map((id) => document.getElementById(id))
39
104
  .filter((element): element is HTMLElement => Boolean(element));
40
105
 
41
- updateFromScroll();
106
+ update();
42
107
  }
43
108
 
44
109
  onMounted(() => {
45
110
  void refresh();
46
- window.addEventListener("scroll", updateFromScroll, { passive: true });
111
+ window.addEventListener("scroll", scheduleUpdate, { passive: true });
112
+ window.addEventListener("resize", scheduleUpdate, { passive: true });
47
113
  });
48
114
 
49
115
  onBeforeUnmount(() => {
50
116
  observed = [];
51
117
  if (import.meta.client) {
52
- window.removeEventListener("scroll", updateFromScroll);
118
+ window.removeEventListener("scroll", scheduleUpdate);
119
+ window.removeEventListener("resize", scheduleUpdate);
120
+ if (rafId !== null) cancelAnimationFrame(rafId);
53
121
  }
54
122
  });
55
123
 
@@ -61,7 +129,7 @@ export function useScrollspy(ids: MaybeRefOrGetter<string[]>, offset = 112) {
61
129
  );
62
130
 
63
131
  return {
64
- activeId,
132
+ activeIds,
65
133
  refresh,
66
134
  };
67
135
  }
@@ -1,9 +1,14 @@
1
- import type { ContentNavigationTreeItem } from "@lupinum/ginko-content/client";
1
+ import {
2
+ findFirstNavigationPage,
3
+ navigationItemContainsPath,
4
+ normalizeNavigationPath,
5
+ type ContentNavigationTreeItem,
6
+ type NavigationSidebar,
7
+ } from "@lupinum/ginko-content/navigation";
2
8
 
3
9
  export type RawDocsTreeItem = Omit<ContentNavigationTreeItem, "children"> & {
4
10
  icon?: unknown;
5
11
  badge?: unknown;
6
- sidebar?: unknown;
7
12
  children?: RawDocsTreeItem[];
8
13
  };
9
14
 
@@ -13,7 +18,7 @@ export type DocsNavigationItem = {
13
18
  path?: string;
14
19
  icon?: string;
15
20
  badge?: string;
16
- sidebar?: "section" | "group";
21
+ sidebar?: NavigationSidebar;
17
22
  children: DocsNavigationItem[];
18
23
  };
19
24
 
@@ -47,47 +52,29 @@ export function normalizeDocsNavigationItem(
47
52
  path: item.path,
48
53
  icon: typeof item.icon === "string" ? item.icon : undefined,
49
54
  badge: typeof item.badge === "string" ? item.badge : undefined,
50
- sidebar: item.sidebar === "section" || item.sidebar === "group" ? item.sidebar : undefined,
55
+ sidebar: item.sidebar,
51
56
  children: (item.children ?? []).map((child, index) =>
52
57
  normalizeDocsNavigationItem(child, `${positionKey}.${index}`),
53
58
  ),
54
59
  };
55
60
  }
56
61
 
57
- export function normalizeDocsNavigationPath(path: string): string {
58
- return path.length > 1 ? path.replace(/\/+$/, "") : path;
59
- }
60
-
61
- export function docsNavigationItemContainsPath(item: DocsNavigationItem, path: string): boolean {
62
- const normalizedPath = normalizeDocsNavigationPath(path);
63
- return (
64
- (item.path !== undefined && normalizeDocsNavigationPath(item.path) === normalizedPath) ||
65
- item.children.some((child) => docsNavigationItemContainsPath(child, normalizedPath))
66
- );
67
- }
68
-
69
62
  export function docsNavigationSectionContainsPath(
70
63
  section: DocsNavigationSection,
71
64
  path: string,
72
65
  ): boolean {
73
- const normalizedPath = normalizeDocsNavigationPath(path);
66
+ const normalizedPath = normalizeNavigationPath(path);
74
67
  return (
75
- (section.path !== undefined && normalizeDocsNavigationPath(section.path) === normalizedPath) ||
76
- section.items.some((item) => docsNavigationItemContainsPath(item, normalizedPath))
68
+ (section.path !== undefined && normalizeNavigationPath(section.path) === normalizedPath) ||
69
+ section.items.some((item) => navigationItemContainsPath(item, normalizedPath))
77
70
  );
78
71
  }
79
72
 
80
- export function findDocsNavigationTrail(
81
- items: DocsNavigationItem[],
82
- path: string,
83
- ): DocsNavigationItem[] {
84
- const normalizedPath = normalizeDocsNavigationPath(path);
85
- for (const item of items) {
86
- if (item.path && normalizeDocsNavigationPath(item.path) === normalizedPath) return [item];
87
- const descendants = findDocsNavigationTrail(item.children, normalizedPath);
88
- if (descendants.length > 0) return [item, ...descendants];
89
- }
90
- return [];
73
+ // Structural sections have no page of their own — they resolve to their first
74
+ // navigable page so switching to them lands somewhere instead of only swapping
75
+ // the sidebar contents.
76
+ export function resolveDocsSectionTargetPath(section: DocsNavigationSection): string | undefined {
77
+ return section.path ?? findFirstNavigationPage(section.items)?.path;
91
78
  }
92
79
 
93
80
  export function getDocsNavigationSections(items: DocsNavigationItem[]): DocsNavigationSection[] {
@@ -0,0 +1,6 @@
1
+ import type { ComputedRef, InjectionKey } from "vue";
2
+ import type { FlatTocItem } from "#ginko-docs/utils/content";
3
+
4
+ /** Current page TOC, provided by the docs page shell so in-content components
5
+ * (e.g. the `::toc` inline table of contents) can render it. */
6
+ export const docsTocKey: InjectionKey<ComputedRef<FlatTocItem[]>> = Symbol("ginko-docs-toc");
@@ -1,4 +1,4 @@
1
- export type CommandCenterGroup = "recent" | "pages" | "docs_nav" | "blog" | "docs" | "actions";
1
+ export type CommandCenterGroup = "recent" | "pages" | "docs_nav" | "actions" | "search";
2
2
 
3
3
  export interface CommandCenterItem {
4
4
  id: string;
@@ -24,19 +24,6 @@ export interface CommandCenterGroupResult {
24
24
  export const MAX_RECENT_ITEMS = 5;
25
25
  export const PAGE_HIGHLIGHT_STORAGE_KEY = "site-command-center-page-highlight";
26
26
 
27
- const GROUP_PRIORITY: Record<CommandCenterGroup, number> = {
28
- recent: 0,
29
- pages: 1,
30
- docs_nav: 2,
31
- blog: 3,
32
- docs: 4,
33
- actions: 5,
34
- };
35
-
36
- function normalizeValue(value: string) {
37
- return value.toLocaleLowerCase().trim();
38
- }
39
-
40
27
  export function dedupeCommandCenterItems(items: CommandCenterItem[]) {
41
28
  const seen = new Set<string>();
42
29
  return items.filter((item) => {
@@ -47,84 +34,22 @@ export function dedupeCommandCenterItems(items: CommandCenterItem[]) {
47
34
  });
48
35
  }
49
36
 
50
- export function scoreCommandCenterItem(item: CommandCenterItem, query: string) {
51
- if (!query.trim()) return 1;
52
-
53
- const normalizedQuery = normalizeValue(query);
54
- const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
55
- const title = normalizeValue(item.title);
56
- const subtitle = normalizeValue(item.subtitle ?? "");
57
- const keywords = (item.keywords ?? []).map(normalizeValue);
58
- const haystacks = [title, subtitle, ...keywords].filter(Boolean);
59
- const isAnchoredResult = item.href?.includes("#") ?? false;
60
-
61
- if (!tokens.every((token) => haystacks.some((value) => value.includes(token)))) return 0;
62
-
63
- let score = 0;
64
- if (title === normalizedQuery) score += isAnchoredResult ? 48 : 140;
65
- if (title.startsWith(normalizedQuery)) score += isAnchoredResult ? 40 : 110;
66
- if (title.includes(normalizedQuery)) score += isAnchoredResult ? 32 : 80;
67
- if (subtitle.includes(normalizedQuery)) score += 48;
68
-
69
- for (const keyword of keywords) {
70
- if (keyword === normalizedQuery) score += 90;
71
- else if (keyword.startsWith(normalizedQuery)) score += 56;
72
- else if (keyword.includes(normalizedQuery)) score += 32;
73
- }
74
-
75
- for (const token of tokens) {
76
- if (title.startsWith(token)) score += 18;
77
- if (title.includes(token)) score += 12;
78
- if (subtitle.includes(token)) score += 8;
79
- if (keywords.some((keyword) => keyword.startsWith(token))) score += 10;
80
- }
81
-
82
- if (item.href && !isAnchoredResult) score += 16;
83
- if (isAnchoredResult) score -= 12;
84
- if (item.group === "pages") score += 20;
85
- return score;
86
- }
87
-
88
37
  export function groupCommandCenterItems(
89
38
  items: CommandCenterItem[],
90
- query: string,
91
39
  groupTitle: (group: CommandCenterGroup) => string,
92
40
  ): CommandCenterGroupResult[] {
93
- const isSearching = query.trim().length > 0;
94
- const ranked = items
95
- .map((item) => ({
96
- item,
97
- score: item.group === "recent" && !isSearching ? 999 : scoreCommandCenterItem(item, query),
98
- }))
99
- .filter(({ score }) => score > 0)
100
- .sort((left, right) => {
101
- if (isSearching && left.score !== right.score) return right.score - left.score;
102
- if (left.item.group !== right.item.group) {
103
- return GROUP_PRIORITY[left.item.group] - GROUP_PRIORITY[right.item.group];
104
- }
105
- return isSearching ? left.item.title.localeCompare(right.item.title) : 0;
106
- })
107
- .slice(0, isSearching ? 12 : undefined);
108
-
109
- const groups = new Map<CommandCenterGroup, Array<{ item: CommandCenterItem; score: number }>>();
110
- for (const entry of ranked) {
111
- const group = groups.get(entry.item.group) ?? [];
112
- group.push(entry);
113
- groups.set(entry.item.group, group);
41
+ const groups = new Map<CommandCenterGroup, CommandCenterItem[]>();
42
+ for (const item of items) {
43
+ const group = groups.get(item.group) ?? [];
44
+ group.push(item);
45
+ groups.set(item.group, group);
114
46
  }
115
47
 
116
- return [...groups.entries()]
117
- .map(([id, entries]) => ({
118
- id,
119
- title: groupTitle(id),
120
- maxScore: Math.max(...entries.map((entry) => entry.score)),
121
- items: entries.slice(0, 5).map((entry) => entry.item),
122
- }))
123
- .sort((left, right) => {
124
- if (isSearching && left.maxScore !== right.maxScore) return right.maxScore - left.maxScore;
125
- return GROUP_PRIORITY[left.id] - GROUP_PRIORITY[right.id];
126
- })
127
- .map(({ maxScore: _maxScore, ...group }) => group);
48
+ return [...groups].map(([id, groupItems]) => ({
49
+ id,
50
+ title: groupTitle(id),
51
+ items: groupItems.slice(0, 5),
52
+ }));
128
53
  }
129
54
 
130
55
  export function resolveRecentItems(
@@ -150,7 +75,3 @@ export function rememberRecentItem(
150
75
  const id = selectedItem.sourceId ?? selectedItem.id;
151
76
  return [id, ...storedItems.filter((item) => item !== id)].slice(0, MAX_RECENT_ITEMS);
152
77
  }
153
-
154
- export function contentSearchGroup(collection: string): "blog" | "docs" {
155
- return collection === "blog" ? "blog" : "docs";
156
- }