@lupinum/ginko-docs 0.2.2

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 (240) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/app/app.config.ts +49 -0
  4. package/app/app.vue +71 -0
  5. package/app/assets/css/prose.css +1154 -0
  6. package/app/assets/css/tailwind.css +365 -0
  7. package/app/components/OgImage/GinkoDocs.satori.vue +117 -0
  8. package/app/components/content/DocumentPageShell.vue +55 -0
  9. package/app/components/content/Feedback.vue +97 -0
  10. package/app/components/content/PageMarkdownCopy.vue +102 -0
  11. package/app/components/mdc/MdcAccordion.vue +64 -0
  12. package/app/components/mdc/MdcAccordionItem.vue +51 -0
  13. package/app/components/mdc/MdcAlert.vue +34 -0
  14. package/app/components/mdc/MdcAside.vue +27 -0
  15. package/app/components/mdc/MdcCallout.vue +62 -0
  16. package/app/components/mdc/MdcCard.vue +186 -0
  17. package/app/components/mdc/MdcCardGroup.vue +47 -0
  18. package/app/components/mdc/MdcCenter.vue +71 -0
  19. package/app/components/mdc/MdcCodeGroup.vue +193 -0
  20. package/app/components/mdc/MdcColumn.vue +70 -0
  21. package/app/components/mdc/MdcDocImg.vue +25 -0
  22. package/app/components/mdc/MdcDropcap.vue +84 -0
  23. package/app/components/mdc/MdcError.vue +16 -0
  24. package/app/components/mdc/MdcField.vue +62 -0
  25. package/app/components/mdc/MdcFieldGroup.vue +18 -0
  26. package/app/components/mdc/MdcFigure.vue +51 -0
  27. package/app/components/mdc/MdcIdea.vue +16 -0
  28. package/app/components/mdc/MdcInfo.vue +16 -0
  29. package/app/components/mdc/MdcLayout.vue +57 -0
  30. package/app/components/mdc/MdcNote.vue +16 -0
  31. package/app/components/mdc/MdcPassage.vue +32 -0
  32. package/app/components/mdc/MdcQuiz.vue +281 -0
  33. package/app/components/mdc/MdcQuizOption.vue +91 -0
  34. package/app/components/mdc/MdcQuizQuestion.vue +226 -0
  35. package/app/components/mdc/MdcReadMore.vue +90 -0
  36. package/app/components/mdc/MdcReadMoreGroup.vue +18 -0
  37. package/app/components/mdc/MdcShortcut.vue +46 -0
  38. package/app/components/mdc/MdcStep.vue +42 -0
  39. package/app/components/mdc/MdcSteps.vue +25 -0
  40. package/app/components/mdc/MdcSuccess.vue +16 -0
  41. package/app/components/mdc/MdcTab.vue +17 -0
  42. package/app/components/mdc/MdcTabs.vue +132 -0
  43. package/app/components/mdc/MdcTimeline.vue +14 -0
  44. package/app/components/mdc/MdcTimelineItem.vue +49 -0
  45. package/app/components/mdc/MdcWarning.vue +16 -0
  46. package/app/components/mdc/icons.ts +10 -0
  47. package/app/components/prose/ProseA.vue +27 -0
  48. package/app/components/prose/ProseBlockquote.vue +14 -0
  49. package/app/components/prose/ProseCode.vue +14 -0
  50. package/app/components/prose/ProseEm.vue +14 -0
  51. package/app/components/prose/ProseH1.vue +18 -0
  52. package/app/components/prose/ProseH2.vue +24 -0
  53. package/app/components/prose/ProseH3.vue +24 -0
  54. package/app/components/prose/ProseH4.vue +14 -0
  55. package/app/components/prose/ProseHr.vue +12 -0
  56. package/app/components/prose/ProseImg.vue +120 -0
  57. package/app/components/prose/ProseLi.vue +14 -0
  58. package/app/components/prose/ProseOl.vue +14 -0
  59. package/app/components/prose/ProseP.vue +14 -0
  60. package/app/components/prose/ProsePre.vue +217 -0
  61. package/app/components/prose/ProseStrong.vue +14 -0
  62. package/app/components/prose/ProseTable.vue +16 -0
  63. package/app/components/prose/ProseTbody.vue +14 -0
  64. package/app/components/prose/ProseTd.vue +14 -0
  65. package/app/components/prose/ProseTh.vue +14 -0
  66. package/app/components/prose/ProseThead.vue +14 -0
  67. package/app/components/prose/ProseTr.vue +14 -0
  68. package/app/components/prose/ProseUl.vue +14 -0
  69. package/app/components/site/ModeToggle.vue +123 -0
  70. package/app/components/site/SiteBanner.vue +56 -0
  71. package/app/components/site/SiteFooter.vue +80 -0
  72. package/app/components/site/SiteHeader.vue +250 -0
  73. package/app/components/site/SiteInteractionLayer.vue +35 -0
  74. package/app/components/site/SiteLocaleSwitcher.vue +268 -0
  75. package/app/components/site/SiteLogoMark.vue +12 -0
  76. package/app/components/site/SiteSkipLink.vue +14 -0
  77. package/app/components/ui/accordion/Accordion.vue +15 -0
  78. package/app/components/ui/accordion/AccordionContent.vue +23 -0
  79. package/app/components/ui/accordion/AccordionItem.vue +24 -0
  80. package/app/components/ui/accordion/AccordionTrigger.vue +35 -0
  81. package/app/components/ui/accordion/index.ts +4 -0
  82. package/app/components/ui/alert/Alert.vue +21 -0
  83. package/app/components/ui/alert/AlertDescription.vue +22 -0
  84. package/app/components/ui/alert/AlertTitle.vue +22 -0
  85. package/app/components/ui/alert/index.ts +34 -0
  86. package/app/components/ui/badge/Badge.vue +28 -0
  87. package/app/components/ui/badge/index.ts +24 -0
  88. package/app/components/ui/button/Button.vue +31 -0
  89. package/app/components/ui/button/index.ts +35 -0
  90. package/app/components/ui/card/Card.vue +16 -0
  91. package/app/components/ui/card/CardAction.vue +17 -0
  92. package/app/components/ui/card/CardContent.vue +14 -0
  93. package/app/components/ui/card/CardDescription.vue +14 -0
  94. package/app/components/ui/card/CardFooter.vue +14 -0
  95. package/app/components/ui/card/CardHeader.vue +22 -0
  96. package/app/components/ui/card/CardTitle.vue +14 -0
  97. package/app/components/ui/card/card-variants.ts +15 -0
  98. package/app/components/ui/card/index.ts +8 -0
  99. package/app/components/ui/checkbox/Checkbox.vue +35 -0
  100. package/app/components/ui/checkbox/index.ts +1 -0
  101. package/app/components/ui/collapsible/Collapsible.vue +15 -0
  102. package/app/components/ui/collapsible/CollapsibleContent.vue +25 -0
  103. package/app/components/ui/collapsible/CollapsibleTrigger.vue +12 -0
  104. package/app/components/ui/collapsible/index.ts +3 -0
  105. package/app/components/ui/dialog/Dialog.vue +15 -0
  106. package/app/components/ui/dialog/DialogClose.vue +12 -0
  107. package/app/components/ui/dialog/DialogContent.vue +69 -0
  108. package/app/components/ui/dialog/DialogDescription.vue +21 -0
  109. package/app/components/ui/dialog/DialogFooter.vue +15 -0
  110. package/app/components/ui/dialog/DialogHeader.vue +12 -0
  111. package/app/components/ui/dialog/DialogOverlay.vue +26 -0
  112. package/app/components/ui/dialog/DialogPortal.vue +12 -0
  113. package/app/components/ui/dialog/DialogTitle.vue +21 -0
  114. package/app/components/ui/dialog/DialogTrigger.vue +12 -0
  115. package/app/components/ui/dialog/index.ts +10 -0
  116. package/app/components/ui/dropdown-menu/DropdownMenu.vue +15 -0
  117. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +36 -0
  118. package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +40 -0
  119. package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +12 -0
  120. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +41 -0
  121. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +25 -0
  122. package/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +15 -0
  123. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +37 -0
  124. package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +23 -0
  125. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -0
  126. package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +15 -0
  127. package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +29 -0
  128. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +31 -0
  129. package/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue +14 -0
  130. package/app/components/ui/dropdown-menu/index.ts +16 -0
  131. package/app/components/ui/input/Input.vue +23 -0
  132. package/app/components/ui/input/index.ts +1 -0
  133. package/app/components/ui/kbd/Kbd.vue +23 -0
  134. package/app/components/ui/kbd/KbdGroup.vue +14 -0
  135. package/app/components/ui/kbd/index.ts +22 -0
  136. package/app/components/ui/label/Label.vue +14 -0
  137. package/app/components/ui/label/index.ts +1 -0
  138. package/app/components/ui/scroll-area/ScrollArea.vue +39 -0
  139. package/app/components/ui/scroll-area/ScrollBar.vue +33 -0
  140. package/app/components/ui/scroll-area/index.ts +2 -0
  141. package/app/components/ui/separator/Separator.vue +27 -0
  142. package/app/components/ui/separator/index.ts +1 -0
  143. package/app/components/ui/sheet/Sheet.vue +15 -0
  144. package/app/components/ui/sheet/SheetClose.vue +12 -0
  145. package/app/components/ui/sheet/SheetContent.vue +59 -0
  146. package/app/components/ui/sheet/SheetDescription.vue +21 -0
  147. package/app/components/ui/sheet/SheetFooter.vue +12 -0
  148. package/app/components/ui/sheet/SheetHeader.vue +12 -0
  149. package/app/components/ui/sheet/SheetOverlay.vue +26 -0
  150. package/app/components/ui/sheet/SheetTitle.vue +21 -0
  151. package/app/components/ui/sheet/SheetTrigger.vue +12 -0
  152. package/app/components/ui/sheet/index.ts +8 -0
  153. package/app/components/ui/switch/Switch.vue +39 -0
  154. package/app/components/ui/switch/index.ts +1 -0
  155. package/app/components/ui/tabs/Tabs.vue +24 -0
  156. package/app/components/ui/tabs/TabsContent.vue +21 -0
  157. package/app/components/ui/tabs/TabsList.vue +26 -0
  158. package/app/components/ui/tabs/TabsTrigger.vue +28 -0
  159. package/app/components/ui/tabs/index.ts +4 -0
  160. package/app/components/ui/textarea/Textarea.vue +31 -0
  161. package/app/components/ui/textarea/index.ts +1 -0
  162. package/app/composables/site-navigation.utils.ts +80 -0
  163. package/app/composables/useCanonicalUrl.ts +9 -0
  164. package/app/composables/useContentRouteAlternates.ts +43 -0
  165. package/app/composables/useGinkoAnalytics.ts +37 -0
  166. package/app/composables/useGinkoDocsConfig.ts +6 -0
  167. package/app/composables/useGinkoOgImage.ts +20 -0
  168. package/app/composables/useLocalizedPath.ts +12 -0
  169. package/app/composables/useLocalizedRouteSwitch.ts +31 -0
  170. package/app/composables/usePageMarkdownActions.ts +79 -0
  171. package/app/composables/useSchemaJsonLd.ts +25 -0
  172. package/app/composables/useSiteNavigation.ts +86 -0
  173. package/app/config/site.utils.ts +36 -0
  174. package/app/error.vue +125 -0
  175. package/app/features/docs/components/DocsContributeLinks.vue +65 -0
  176. package/app/features/docs/components/DocsMobileToc.vue +104 -0
  177. package/app/features/docs/components/DocsPageContent.vue +188 -0
  178. package/app/features/docs/components/DocsPageNav.vue +57 -0
  179. package/app/features/docs/components/DocsSidebar.vue +129 -0
  180. package/app/features/docs/components/DocsSidebarDropdown.vue +70 -0
  181. package/app/features/docs/components/DocsSidebarItem.vue +169 -0
  182. package/app/features/docs/components/DocsSidebarList.vue +35 -0
  183. package/app/features/docs/components/DocsSidebarTabs.vue +35 -0
  184. package/app/features/docs/components/DocsToc.vue +60 -0
  185. package/app/features/docs/composables/useDocsEntryPath.ts +13 -0
  186. package/app/features/docs/composables/useDocsNavigation.ts +29 -0
  187. package/app/features/docs/composables/useDocsNavigationData.ts +15 -0
  188. package/app/features/docs/composables/useScrollspy.ts +67 -0
  189. package/app/features/docs/docs-navigation.ts +170 -0
  190. package/app/features/search/command-center.ts +156 -0
  191. package/app/features/search/components/SiteCommandCenter.vue +323 -0
  192. package/app/features/search/components/SiteSearchPageHighlight.vue +124 -0
  193. package/app/features/search/useCommandCenter.ts +300 -0
  194. package/app/layouts/blog.vue +15 -0
  195. package/app/layouts/default.vue +12 -0
  196. package/app/layouts/docs.vue +20 -0
  197. package/app/lib/errors.ts +84 -0
  198. package/app/lib/schema-org.ts +38 -0
  199. package/app/lib/utils.ts +7 -0
  200. package/app/pages/blog/[slug].vue +211 -0
  201. package/app/pages/blog/index.vue +106 -0
  202. package/app/pages/docs/[...slug].vue +10 -0
  203. package/app/pages/docs/index.vue +23 -0
  204. package/app/pages/index.vue +140 -0
  205. package/app/plugins/ssr-width.ts +6 -0
  206. package/app/utils/content.ts +56 -0
  207. package/app/utils/index.ts +1 -0
  208. package/components.ts +9 -0
  209. package/content.js +175 -0
  210. package/content.ts +148 -0
  211. package/i18n/i18n.config.ts +10 -0
  212. package/i18n/locales.ts +81 -0
  213. package/i18n/messages/global/banner.ts +8 -0
  214. package/i18n/messages/global/blog.ts +14 -0
  215. package/i18n/messages/global/command.ts +44 -0
  216. package/i18n/messages/global/docs.ts +32 -0
  217. package/i18n/messages/global/errors.ts +36 -0
  218. package/i18n/messages/global/feedback.ts +11 -0
  219. package/i18n/messages/global/index.ts +21 -0
  220. package/i18n/messages/global/nav.ts +18 -0
  221. package/i18n/messages/global/site.ts +4 -0
  222. package/i18n/messages/global/theme.ts +7 -0
  223. package/i18n/messages/index.ts +20 -0
  224. package/i18n/messages/localize.ts +35 -0
  225. package/i18n/messages/source.ts +5 -0
  226. package/i18n/routes.ts +33 -0
  227. package/icon-bundle.ts +141 -0
  228. package/index.d.ts +13 -0
  229. package/modules/feature-routing.ts +32 -0
  230. package/nuxt.config.ts +194 -0
  231. package/package.json +91 -0
  232. package/runtime/markdown/inline-code-highlight.ts +155 -0
  233. package/runtime/server/agent-markdown.ts +21 -0
  234. package/runtime/utils.ts +7 -0
  235. package/server/mcp/tools/get-page.ts +14 -0
  236. package/server/mcp/tools/list-pages.ts +10 -0
  237. package/server/plugins/agent-markdown.ts +6 -0
  238. package/shared/route-slugs.ts +5 -0
  239. package/shared/types/app-config.ts +100 -0
  240. package/tags.ts +204 -0
@@ -0,0 +1,60 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from "vue";
3
+ import { cn } from "#ginko-docs/lib/utils";
4
+ import { useI18n } from "#imports";
5
+
6
+ export interface TocItem {
7
+ id: string;
8
+ label: string;
9
+ depth?: number; // 2 = h2, 3 = h3
10
+ }
11
+
12
+ const props = withDefaults(
13
+ defineProps<{
14
+ items: TocItem[];
15
+ activeId?: string;
16
+ showTitle?: boolean;
17
+ class?: HTMLAttributes["class"];
18
+ }>(),
19
+ {
20
+ showTitle: true,
21
+ },
22
+ );
23
+ const { t } = useI18n();
24
+ </script>
25
+
26
+ <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">
29
+ <Icon name="lucide:list" class="size-3.5 shrink-0" aria-hidden="true" />
30
+ {{ t("docs.toc") }}
31
+ </h3>
32
+
33
+ <div class="relative space-y-1 border-l border-border pl-3 text-[13px]">
34
+ <!-- Animated active indicator -->
35
+ <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
+ "
42
+ />
43
+
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>
58
+ </div>
59
+ </div>
60
+ </template>
@@ -0,0 +1,13 @@
1
+ import { computed } from "vue";
2
+ import { findFirstNavigationPage } from "@lupinum/ginko-content/client";
3
+ import { useLocalizedPath } from "#ginko-docs/composables/useLocalizedPath";
4
+ import { useDocsNavigationData } from "./useDocsNavigationData";
5
+
6
+ export async function useDocsEntryPath() {
7
+ const localizedPath = useLocalizedPath();
8
+ const { data } = await useDocsNavigationData();
9
+
10
+ return computed(
11
+ () => findFirstNavigationPage(data.value ?? undefined)?.path ?? localizedPath("docs"),
12
+ );
13
+ }
@@ -0,0 +1,29 @@
1
+ import { computed } from "vue";
2
+ import { useRoute } from "#imports";
3
+ import {
4
+ findDocsNavigationTrail,
5
+ getDocsNavigationSections,
6
+ normalizeDocsNavigationItem,
7
+ type DocsNavigationSection,
8
+ } from "../docs-navigation";
9
+ import { useDocsNavigationData } from "./useDocsNavigationData";
10
+
11
+ export async function useDocsNavigation() {
12
+ const route = useRoute();
13
+ const { data } = await useDocsNavigationData();
14
+
15
+ const roots = computed(() => {
16
+ return (data.value ?? []).map((item, index) => normalizeDocsNavigationItem(item, index));
17
+ });
18
+
19
+ const sections = computed<DocsNavigationSection[]>(() => {
20
+ return getDocsNavigationSections(roots.value);
21
+ });
22
+
23
+ const trail = computed(() => findDocsNavigationTrail(roots.value, route.path));
24
+
25
+ return {
26
+ sections,
27
+ trail,
28
+ };
29
+ }
@@ -0,0 +1,15 @@
1
+ import { computed } from "vue";
2
+ import { navigation } from "@lupinum/ginko-content/client";
3
+ import { useAsyncData, useI18n } from "#imports";
4
+
5
+ export async function useDocsNavigationData() {
6
+ const { locale } = useI18n();
7
+ const navigationKey = computed(() => `docs-navigation:${locale.value}`);
8
+
9
+ return useAsyncData(navigationKey, () =>
10
+ navigation("docs", {
11
+ locale: locale.value,
12
+ select: ["icon", "badge", "sidebar"],
13
+ }),
14
+ );
15
+ }
@@ -0,0 +1,67 @@
1
+ import {
2
+ nextTick,
3
+ onBeforeUnmount,
4
+ onMounted,
5
+ ref,
6
+ toValue,
7
+ watch,
8
+ type MaybeRefOrGetter,
9
+ } from "vue";
10
+
11
+ export function useScrollspy(ids: MaybeRefOrGetter<string[]>, offset = 112) {
12
+ const activeId = ref("");
13
+ let observed: HTMLElement[] = [];
14
+
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;
20
+
21
+ function updateFromScroll() {
22
+ const candidates = observed
23
+ .map((element) => ({
24
+ id: element.id,
25
+ top: element.getBoundingClientRect().top,
26
+ }))
27
+ .filter((item) => item.top <= offset + tolerance)
28
+ .sort((a, b) => b.top - a.top);
29
+
30
+ activeId.value = candidates[0]?.id ?? observed[0]?.id ?? "";
31
+ }
32
+
33
+ async function refresh() {
34
+ if (!import.meta.client) return;
35
+
36
+ await nextTick();
37
+ observed = toValue(ids)
38
+ .map((id) => document.getElementById(id))
39
+ .filter((element): element is HTMLElement => Boolean(element));
40
+
41
+ updateFromScroll();
42
+ }
43
+
44
+ onMounted(() => {
45
+ void refresh();
46
+ window.addEventListener("scroll", updateFromScroll, { passive: true });
47
+ });
48
+
49
+ onBeforeUnmount(() => {
50
+ observed = [];
51
+ if (import.meta.client) {
52
+ window.removeEventListener("scroll", updateFromScroll);
53
+ }
54
+ });
55
+
56
+ watch(
57
+ () => toValue(ids).join("|"),
58
+ () => {
59
+ void refresh();
60
+ },
61
+ );
62
+
63
+ return {
64
+ activeId,
65
+ refresh,
66
+ };
67
+ }
@@ -0,0 +1,170 @@
1
+ import type { ContentNavigationTreeItem } from "@lupinum/ginko-content/client";
2
+
3
+ export type RawDocsTreeItem = Omit<ContentNavigationTreeItem, "children"> & {
4
+ icon?: unknown;
5
+ badge?: unknown;
6
+ sidebar?: unknown;
7
+ children?: RawDocsTreeItem[];
8
+ };
9
+
10
+ export type DocsNavigationItem = {
11
+ id: string;
12
+ title: string;
13
+ path?: string;
14
+ icon?: string;
15
+ badge?: string;
16
+ sidebar?: "section" | "group";
17
+ children: DocsNavigationItem[];
18
+ };
19
+
20
+ export type DocsNavigationSection = {
21
+ id: string;
22
+ title?: string;
23
+ path?: string;
24
+ icon?: string;
25
+ items: DocsNavigationItem[];
26
+ };
27
+
28
+ export type DocsNavigationGroup = {
29
+ id: string;
30
+ title?: string;
31
+ icon?: string;
32
+ items: DocsNavigationItem[];
33
+ };
34
+
35
+ function itemId(item: RawDocsTreeItem, position: string): string {
36
+ return item.path ?? `docs:${position}:${item.title}`;
37
+ }
38
+
39
+ export function normalizeDocsNavigationItem(
40
+ item: RawDocsTreeItem,
41
+ position: string | number = 0,
42
+ ): DocsNavigationItem {
43
+ const positionKey = String(position);
44
+ return {
45
+ id: itemId(item, positionKey),
46
+ title: item.title,
47
+ path: item.path,
48
+ icon: typeof item.icon === "string" ? item.icon : undefined,
49
+ badge: typeof item.badge === "string" ? item.badge : undefined,
50
+ sidebar: item.sidebar === "section" || item.sidebar === "group" ? item.sidebar : undefined,
51
+ children: (item.children ?? []).map((child, index) =>
52
+ normalizeDocsNavigationItem(child, `${positionKey}.${index}`),
53
+ ),
54
+ };
55
+ }
56
+
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
+ export function docsNavigationSectionContainsPath(
70
+ section: DocsNavigationSection,
71
+ path: string,
72
+ ): boolean {
73
+ const normalizedPath = normalizeDocsNavigationPath(path);
74
+ return (
75
+ (section.path !== undefined && normalizeDocsNavigationPath(section.path) === normalizedPath) ||
76
+ section.items.some((item) => docsNavigationItemContainsPath(item, normalizedPath))
77
+ );
78
+ }
79
+
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 [];
91
+ }
92
+
93
+ export function getDocsNavigationSections(items: DocsNavigationItem[]): DocsNavigationSection[] {
94
+ if (!items.some((item) => item.sidebar === "section")) {
95
+ return [{ id: "docs:main", items: [...items] }];
96
+ }
97
+
98
+ const sections: DocsNavigationSection[] = [];
99
+ let current: DocsNavigationSection | undefined;
100
+
101
+ for (const item of items) {
102
+ if (item.sidebar === "section") {
103
+ current = {
104
+ id: item.id,
105
+ title: item.title,
106
+ path: item.path,
107
+ icon: item.icon,
108
+ items: [...item.children],
109
+ };
110
+ sections.push(current);
111
+ continue;
112
+ }
113
+
114
+ if (!current) {
115
+ current = { id: "docs:main", items: [] };
116
+ sections.push(current);
117
+ }
118
+ current.items.push(item);
119
+ }
120
+
121
+ return sections;
122
+ }
123
+
124
+ export function getDocsNavigationGroups(section: DocsNavigationSection): DocsNavigationGroup[] {
125
+ const groups: DocsNavigationGroup[] = [];
126
+ const ungrouped: DocsNavigationItem[] = [];
127
+
128
+ if (section.path) {
129
+ ungrouped.push({
130
+ id: section.id,
131
+ title: section.title ?? "Overview",
132
+ path: section.path,
133
+ icon: section.icon,
134
+ children: [],
135
+ });
136
+ }
137
+
138
+ for (const child of section.items) {
139
+ if (child.sidebar !== "group") {
140
+ ungrouped.push(child);
141
+ continue;
142
+ }
143
+
144
+ const items = child.path
145
+ ? [
146
+ {
147
+ ...child,
148
+ children: [],
149
+ },
150
+ ...child.children,
151
+ ]
152
+ : child.children;
153
+
154
+ groups.push({
155
+ id: child.id,
156
+ title: child.title,
157
+ icon: child.icon,
158
+ items,
159
+ });
160
+ }
161
+
162
+ if (ungrouped.length) {
163
+ groups.unshift({
164
+ id: `${section.id}:main`,
165
+ items: ungrouped,
166
+ });
167
+ }
168
+
169
+ return groups.filter((group) => group.items.length > 0);
170
+ }
@@ -0,0 +1,156 @@
1
+ export type CommandCenterGroup = "recent" | "pages" | "docs_nav" | "blog" | "docs" | "actions";
2
+
3
+ export interface CommandCenterItem {
4
+ id: string;
5
+ sourceId?: string;
6
+ title: string;
7
+ subtitle?: string;
8
+ href?: string;
9
+ external?: boolean;
10
+ group: CommandCenterGroup;
11
+ icon?: string;
12
+ keywords?: string[];
13
+ badge?: string;
14
+ }
15
+
16
+ export type StoredRecentItem = string;
17
+
18
+ export interface CommandCenterGroupResult {
19
+ id: CommandCenterGroup;
20
+ title: string;
21
+ items: CommandCenterItem[];
22
+ }
23
+
24
+ export const MAX_RECENT_ITEMS = 5;
25
+ export const PAGE_HIGHLIGHT_STORAGE_KEY = "site-command-center-page-highlight";
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
+ export function dedupeCommandCenterItems(items: CommandCenterItem[]) {
41
+ const seen = new Set<string>();
42
+ return items.filter((item) => {
43
+ const key = item.href ?? item.sourceId ?? item.id;
44
+ if (seen.has(key)) return false;
45
+ seen.add(key);
46
+ return true;
47
+ });
48
+ }
49
+
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
+ export function groupCommandCenterItems(
89
+ items: CommandCenterItem[],
90
+ query: string,
91
+ groupTitle: (group: CommandCenterGroup) => string,
92
+ ): 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);
114
+ }
115
+
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);
128
+ }
129
+
130
+ export function resolveRecentItems(
131
+ storedItems: StoredRecentItem[],
132
+ currentItems: CommandCenterItem[],
133
+ ): CommandCenterItem[] {
134
+ return storedItems
135
+ .map((id) => currentItems.find((item) => item.id === id))
136
+ .filter((item): item is CommandCenterItem => Boolean(item))
137
+ .slice(0, MAX_RECENT_ITEMS)
138
+ .map((item) => ({
139
+ ...item,
140
+ id: `recent-${item.id}`,
141
+ sourceId: item.id,
142
+ group: "recent",
143
+ }));
144
+ }
145
+
146
+ export function rememberRecentItem(
147
+ selectedItem: CommandCenterItem,
148
+ storedItems: StoredRecentItem[],
149
+ ): StoredRecentItem[] {
150
+ const id = selectedItem.sourceId ?? selectedItem.id;
151
+ return [id, ...storedItems.filter((item) => item !== id)].slice(0, MAX_RECENT_ITEMS);
152
+ }
153
+
154
+ export function contentSearchGroup(collection: string): "blog" | "docs" {
155
+ return collection === "blog" ? "blog" : "docs";
156
+ }