@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,323 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from "vue";
3
+ import { useI18n, useRoute } from "#imports";
4
+ import { useCommandCenter } from "#ginko-docs/features/search/useCommandCenter";
5
+ import {
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogOverlay,
9
+ DialogPortal,
10
+ DialogRoot,
11
+ DialogTitle,
12
+ VisuallyHidden,
13
+ } from "reka-ui";
14
+
15
+ const { t } = useI18n();
16
+ const route = useRoute();
17
+ const inputRef = useTemplateRef<HTMLInputElement>("input");
18
+ const highlightedId = ref("");
19
+ const { open, query, groupedItems, selectItem } = await useCommandCenter();
20
+
21
+ const visibleItems = computed(() => groupedItems.value.flatMap((g) => g.items));
22
+
23
+ // ── text highlighting ──────────────────────────────────────────────────────
24
+ function escapeHtml(str: string) {
25
+ return str
26
+ .replace(/&/g, "&amp;")
27
+ .replace(/</g, "&lt;")
28
+ .replace(/>/g, "&gt;")
29
+ .replace(/"/g, "&quot;");
30
+ }
31
+
32
+ function highlight(text: string, q: string): string {
33
+ if (!q.trim()) return escapeHtml(text);
34
+ const pattern = q
35
+ .trim()
36
+ .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
37
+ .split(/\s+/)
38
+ .filter(Boolean)
39
+ .join("|");
40
+ const parts = text.split(new RegExp(`(${pattern})`, "gi"));
41
+ return parts
42
+ .map((part) =>
43
+ part.toLowerCase() === q.trim().toLowerCase() ||
44
+ q
45
+ .trim()
46
+ .split(/\s+/)
47
+ .some((t) => part.toLowerCase() === t.toLowerCase())
48
+ ? `<mark class="bg-primary/15 text-primary rounded-[3px] font-medium not-italic">${escapeHtml(part)}</mark>`
49
+ : escapeHtml(part),
50
+ )
51
+ .join("");
52
+ }
53
+
54
+ // ── keyboard navigation ────────────────────────────────────────────────────
55
+ function syncHighlight() {
56
+ const first = visibleItems.value[0]?.id ?? "";
57
+ if (!first) {
58
+ highlightedId.value = "";
59
+ return;
60
+ }
61
+ if (!visibleItems.value.some((i) => i.id === highlightedId.value)) {
62
+ highlightedId.value = first;
63
+ }
64
+ }
65
+
66
+ function moveHighlight(dir: 1 | -1) {
67
+ if (!visibleItems.value.length) return;
68
+ const idx = visibleItems.value.findIndex((i) => i.id === highlightedId.value);
69
+ const next = idx === -1 ? 0 : (idx + dir + visibleItems.value.length) % visibleItems.value.length;
70
+ highlightedId.value = visibleItems.value[next]?.id ?? "";
71
+ }
72
+
73
+ async function activateHighlighted() {
74
+ const item = visibleItems.value.find((i) => i.id === highlightedId.value);
75
+ if (item) await selectItem(item);
76
+ }
77
+
78
+ async function clearQuery() {
79
+ query.value = "";
80
+ await nextTick();
81
+ inputRef.value?.focus();
82
+ }
83
+
84
+ function itemLabel(item: { title: string; subtitle?: string; badge?: string }) {
85
+ return [item.title, item.badge, item.subtitle].filter(Boolean).join(", ");
86
+ }
87
+
88
+ function handleListNavigation(e: KeyboardEvent) {
89
+ if (e.key === "ArrowDown") {
90
+ e.preventDefault();
91
+ moveHighlight(1);
92
+ } else if (e.key === "ArrowUp") {
93
+ e.preventDefault();
94
+ moveHighlight(-1);
95
+ } else if (e.key === "Enter") {
96
+ e.preventDefault();
97
+ void activateHighlighted();
98
+ }
99
+ }
100
+
101
+ watch(open, async (isOpen) => {
102
+ if (!isOpen) {
103
+ highlightedId.value = "";
104
+ return;
105
+ }
106
+ await nextTick();
107
+ syncHighlight();
108
+ inputRef.value?.focus();
109
+ });
110
+
111
+ watch([query, groupedItems], async () => {
112
+ syncHighlight();
113
+ await nextTick();
114
+ if (!highlightedId.value || !import.meta.client) return;
115
+ document
116
+ .querySelector<HTMLElement>(`[data-item="${highlightedId.value}"]`)
117
+ ?.scrollIntoView({ block: "nearest" });
118
+ });
119
+
120
+ watch(
121
+ () => route.fullPath,
122
+ () => {
123
+ highlightedId.value = "";
124
+ },
125
+ );
126
+
127
+ onMounted(async () => {
128
+ await nextTick();
129
+ syncHighlight();
130
+ inputRef.value?.focus();
131
+ });
132
+ </script>
133
+
134
+ <template>
135
+ <DialogRoot v-model:open="open">
136
+ <DialogPortal>
137
+ <DialogOverlay
138
+ class="fixed inset-0 z-50 bg-overlay backdrop-blur-sm data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
139
+ />
140
+ <DialogContent
141
+ class="fixed top-[8vh] left-1/2 z-50 flex max-h-[84vh] w-[min(94vw,46rem)] -translate-x-1/2 flex-col overflow-hidden rounded-xl border border-border bg-background shadow-lg outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:top-[10vh]"
142
+ @keydown="handleListNavigation"
143
+ >
144
+ <VisuallyHidden>
145
+ <DialogTitle>{{ t("command.title") }}</DialogTitle>
146
+ <DialogDescription>{{ t("command.description") }}</DialogDescription>
147
+ </VisuallyHidden>
148
+
149
+ <!-- Search input -->
150
+ <div class="flex h-14 shrink-0 items-center gap-3 border-b border-border px-3 sm:px-4">
151
+ <Icon
152
+ name="lucide:search"
153
+ class="size-4 shrink-0 text-muted-foreground"
154
+ aria-hidden="true"
155
+ />
156
+ <input
157
+ ref="input"
158
+ v-model="query"
159
+ type="text"
160
+ autocomplete="off"
161
+ spellcheck="false"
162
+ :placeholder="t('command.placeholder')"
163
+ class="h-full min-w-0 flex-1 bg-transparent text-[16px] text-foreground outline-none placeholder:text-muted-foreground sm:text-sm"
164
+ />
165
+ <kbd
166
+ v-if="!query"
167
+ class="hidden items-center gap-1 rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground sm:flex"
168
+ >
169
+ Esc
170
+ </kbd>
171
+ <button
172
+ v-else
173
+ type="button"
174
+ :aria-label="t('command.clear')"
175
+ class="flex size-6 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground transition-colors hover:text-foreground"
176
+ @click="clearQuery"
177
+ >
178
+ <Icon name="lucide:x" class="size-3" aria-hidden="true" />
179
+ </button>
180
+ </div>
181
+
182
+ <!-- Results -->
183
+ <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain py-2">
184
+ <!-- Groups -->
185
+ <template v-if="visibleItems.length">
186
+ <div v-for="group in groupedItems" :key="group.id" class="mb-1 px-2 last:mb-0">
187
+ <div class="flex items-center gap-2 px-2 pt-1.5 pb-1">
188
+ <span class="text-[11px] font-medium text-muted-foreground">{{ group.title }}</span>
189
+ <div class="flex-1 border-t border-border/60" />
190
+ </div>
191
+
192
+ <button
193
+ v-for="item in group.items"
194
+ :key="item.id"
195
+ type="button"
196
+ :data-item="item.id"
197
+ :aria-label="itemLabel(item)"
198
+ class="group flex w-full items-start gap-3 rounded-lg px-2.5 py-2.5 text-left transition-colors outline-none"
199
+ :class="
200
+ highlightedId === item.id ? 'bg-accent text-accent-foreground' : 'text-foreground'
201
+ "
202
+ @mousemove="highlightedId = item.id"
203
+ @focus="highlightedId = item.id"
204
+ @click="void selectItem(item)"
205
+ >
206
+ <!-- Icon -->
207
+ <div
208
+ class="mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-md border border-border bg-muted/50 transition-colors"
209
+ :class="highlightedId === item.id ? 'border-border/80 bg-background' : ''"
210
+ >
211
+ <Icon
212
+ v-if="group.id === 'recent'"
213
+ name="lucide:clock"
214
+ class="size-3.5 text-muted-foreground"
215
+ aria-hidden="true"
216
+ />
217
+ <Icon
218
+ v-else
219
+ :name="item.icon ?? 'lucide:file-text'"
220
+ class="size-3.5 text-muted-foreground"
221
+ aria-hidden="true"
222
+ />
223
+ </div>
224
+
225
+ <!-- Text -->
226
+ <div class="min-w-0 flex-1">
227
+ <div class="flex min-w-0 items-center gap-2">
228
+ <!-- eslint-disable vue/no-v-html -->
229
+ <span
230
+ class="min-w-0 truncate text-sm leading-5 font-medium"
231
+ v-html="highlight(item.title, query)"
232
+ />
233
+ <span
234
+ v-if="item.badge"
235
+ class="shrink-0 rounded-full bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary"
236
+ >
237
+ {{ item.badge }}
238
+ </span>
239
+ </div>
240
+ <p
241
+ v-if="item.subtitle"
242
+ class="mt-0.5 flex min-w-0 items-start gap-1 text-xs leading-5 text-muted-foreground"
243
+ >
244
+ <Icon
245
+ name="lucide:hash"
246
+ class="mt-1 size-3 shrink-0 opacity-50"
247
+ aria-hidden="true"
248
+ />
249
+ <span class="line-clamp-2 min-w-0" v-html="highlight(item.subtitle, query)" />
250
+ <!-- eslint-enable vue/no-v-html -->
251
+ </p>
252
+ </div>
253
+
254
+ <!-- Right hint -->
255
+ <div class="mt-1 flex shrink-0 items-center gap-1.5 text-muted-foreground">
256
+ <Icon
257
+ v-if="item.external"
258
+ name="lucide:arrow-up-right"
259
+ class="size-3.5 opacity-0 transition-opacity"
260
+ :class="highlightedId === item.id ? 'opacity-100' : 'group-hover:opacity-60'"
261
+ aria-hidden="true"
262
+ />
263
+ <kbd
264
+ v-else
265
+ class="hidden items-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] transition-opacity"
266
+ :class="highlightedId === item.id ? 'flex opacity-100' : 'opacity-0'"
267
+ >
268
+
269
+ </kbd>
270
+ </div>
271
+ </button>
272
+ </div>
273
+ </template>
274
+
275
+ <!-- Empty state -->
276
+ <div
277
+ v-else
278
+ class="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center"
279
+ >
280
+ <div
281
+ class="flex size-10 items-center justify-center rounded-lg border border-border bg-muted/50"
282
+ >
283
+ <Icon name="lucide:search" class="size-4 text-muted-foreground" aria-hidden="true" />
284
+ </div>
285
+ <p class="text-sm font-medium text-foreground">
286
+ {{ t("command.noResults", { query }) }}
287
+ </p>
288
+ <p class="text-xs text-muted-foreground">
289
+ {{ t("command.noResultsHelp") }}
290
+ </p>
291
+ </div>
292
+ </div>
293
+
294
+ <!-- Footer -->
295
+ <div
296
+ class="flex shrink-0 items-center justify-between border-t border-border px-3 py-2.5 text-[11px] text-muted-foreground sm:px-4"
297
+ >
298
+ <div class="flex min-w-0 items-center gap-2 sm:gap-3">
299
+ <span class="flex items-center gap-1">
300
+ <kbd class="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px]"
301
+ >↑↓</kbd
302
+ >
303
+ {{ t("command.navigate") }}
304
+ </span>
305
+ <span class="hidden items-center gap-1 sm:flex">
306
+ <kbd class="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px]"
307
+ >↵</kbd
308
+ >
309
+ {{ t("command.open") }}
310
+ </span>
311
+ <span class="hidden items-center gap-1 sm:flex">
312
+ <kbd class="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px]"
313
+ >Esc</kbd
314
+ >
315
+ {{ t("command.close") }}
316
+ </span>
317
+ </div>
318
+ <span class="shrink-0 sm:hidden">{{ t("command.closeHint") }}</span>
319
+ </div>
320
+ </DialogContent>
321
+ </DialogPortal>
322
+ </DialogRoot>
323
+ </template>
@@ -0,0 +1,124 @@
1
+ <script setup lang="ts">
2
+ import { nextTick, onBeforeUnmount, watch } from "vue";
3
+ import { useRoute } from "#imports";
4
+
5
+ const STORAGE_KEY = "site-command-center-page-highlight";
6
+ const HIGHLIGHT_SELECTOR = "mark[data-search-page-highlight]";
7
+ const SKIP_SELECTOR = "script, style, textarea, input, select, button, mark";
8
+
9
+ const route = useRoute();
10
+
11
+ type StoredHighlight = {
12
+ href: string;
13
+ query: string;
14
+ };
15
+
16
+ function readStoredHighlight(): StoredHighlight | null {
17
+ try {
18
+ const value = sessionStorage.getItem(STORAGE_KEY);
19
+ if (!value) return null;
20
+ const parsed = JSON.parse(value) as Partial<StoredHighlight>;
21
+ if (typeof parsed.href !== "string" || typeof parsed.query !== "string") return null;
22
+ return { href: parsed.href, query: parsed.query };
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function removeHighlights() {
29
+ for (const mark of document.querySelectorAll(HIGHLIGHT_SELECTOR)) {
30
+ const parent = mark.parentNode;
31
+ if (!parent) continue;
32
+ mark.replaceWith(document.createTextNode(mark.textContent ?? ""));
33
+ parent.normalize();
34
+ }
35
+ }
36
+
37
+ function highlightPattern(root: Element, pattern: RegExp) {
38
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
39
+ acceptNode(node) {
40
+ const parent = node.parentElement;
41
+ if (!parent || parent.closest(SKIP_SELECTOR)) return NodeFilter.FILTER_REJECT;
42
+ if (!node.textContent?.trim() || !pattern.test(node.textContent)) {
43
+ pattern.lastIndex = 0;
44
+ return NodeFilter.FILTER_REJECT;
45
+ }
46
+ pattern.lastIndex = 0;
47
+ return NodeFilter.FILTER_ACCEPT;
48
+ },
49
+ });
50
+
51
+ const nodes: Text[] = [];
52
+ while (walker.nextNode()) nodes.push(walker.currentNode as Text);
53
+
54
+ for (const node of nodes) {
55
+ const text = node.textContent ?? "";
56
+ const fragment = document.createDocumentFragment();
57
+ let lastIndex = 0;
58
+
59
+ for (const match of text.matchAll(pattern)) {
60
+ const index = match.index ?? 0;
61
+ const value = match[0];
62
+ if (!value) continue;
63
+
64
+ fragment.append(document.createTextNode(text.slice(lastIndex, index)));
65
+
66
+ const mark = document.createElement("mark");
67
+ mark.dataset.searchPageHighlight = "";
68
+ mark.className = "rounded-[3px] bg-primary/15 px-0.5 text-primary";
69
+ mark.textContent = value;
70
+ fragment.append(mark);
71
+
72
+ lastIndex = index + value.length;
73
+ }
74
+
75
+ fragment.append(document.createTextNode(text.slice(lastIndex)));
76
+ node.replaceWith(fragment);
77
+ }
78
+ }
79
+
80
+ function escapePattern(value: string) {
81
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ }
83
+
84
+ function buildPatterns(query: string) {
85
+ const phrase = query.trim().replace(/\s+/g, " ");
86
+ const tokens = phrase.split(/\s+/).filter((token) => token.length > 2);
87
+ const phrasePattern = phrase.split(/\s+/).map(escapePattern).join("\\s+");
88
+
89
+ return [
90
+ new RegExp(phrasePattern, "gi"),
91
+ ...(tokens.length > 1 ? [new RegExp(tokens.map(escapePattern).join("|"), "gi")] : []),
92
+ ];
93
+ }
94
+
95
+ async function applyStoredHighlight() {
96
+ removeHighlights();
97
+
98
+ const stored = readStoredHighlight();
99
+ if (!stored) return;
100
+
101
+ const currentPath = `${route.path}${route.hash}`;
102
+ if (stored.href !== route.fullPath && stored.href !== currentPath && stored.href !== route.path) {
103
+ sessionStorage.removeItem(STORAGE_KEY);
104
+ return;
105
+ }
106
+
107
+ await nextTick();
108
+
109
+ const root = document.querySelector("#main-content") ?? document.querySelector("main");
110
+ if (!root) return;
111
+
112
+ for (const pattern of buildPatterns(stored.query)) {
113
+ highlightPattern(root, pattern);
114
+ if (root.querySelector(HIGHLIGHT_SELECTOR)) break;
115
+ }
116
+ }
117
+
118
+ watch(() => route.fullPath, applyStoredHighlight, { immediate: true });
119
+ onBeforeUnmount(removeHighlights);
120
+ </script>
121
+
122
+ <template>
123
+ <span hidden aria-hidden="true" />
124
+ </template>