@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.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/app/app.config.ts +49 -0
- package/app/app.vue +71 -0
- package/app/assets/css/prose.css +1154 -0
- package/app/assets/css/tailwind.css +365 -0
- package/app/components/OgImage/GinkoDocs.satori.vue +117 -0
- package/app/components/content/DocumentPageShell.vue +55 -0
- package/app/components/content/Feedback.vue +97 -0
- package/app/components/content/PageMarkdownCopy.vue +102 -0
- package/app/components/mdc/MdcAccordion.vue +64 -0
- package/app/components/mdc/MdcAccordionItem.vue +51 -0
- package/app/components/mdc/MdcAlert.vue +34 -0
- package/app/components/mdc/MdcAside.vue +27 -0
- package/app/components/mdc/MdcCallout.vue +62 -0
- package/app/components/mdc/MdcCard.vue +186 -0
- package/app/components/mdc/MdcCardGroup.vue +47 -0
- package/app/components/mdc/MdcCenter.vue +71 -0
- package/app/components/mdc/MdcCodeGroup.vue +193 -0
- package/app/components/mdc/MdcColumn.vue +70 -0
- package/app/components/mdc/MdcDocImg.vue +25 -0
- package/app/components/mdc/MdcDropcap.vue +84 -0
- package/app/components/mdc/MdcError.vue +16 -0
- package/app/components/mdc/MdcField.vue +62 -0
- package/app/components/mdc/MdcFieldGroup.vue +18 -0
- package/app/components/mdc/MdcFigure.vue +51 -0
- package/app/components/mdc/MdcIdea.vue +16 -0
- package/app/components/mdc/MdcInfo.vue +16 -0
- package/app/components/mdc/MdcLayout.vue +57 -0
- package/app/components/mdc/MdcNote.vue +16 -0
- package/app/components/mdc/MdcPassage.vue +32 -0
- package/app/components/mdc/MdcQuiz.vue +281 -0
- package/app/components/mdc/MdcQuizOption.vue +91 -0
- package/app/components/mdc/MdcQuizQuestion.vue +226 -0
- package/app/components/mdc/MdcReadMore.vue +90 -0
- package/app/components/mdc/MdcReadMoreGroup.vue +18 -0
- package/app/components/mdc/MdcShortcut.vue +46 -0
- package/app/components/mdc/MdcStep.vue +42 -0
- package/app/components/mdc/MdcSteps.vue +25 -0
- package/app/components/mdc/MdcSuccess.vue +16 -0
- package/app/components/mdc/MdcTab.vue +17 -0
- package/app/components/mdc/MdcTabs.vue +132 -0
- package/app/components/mdc/MdcTimeline.vue +14 -0
- package/app/components/mdc/MdcTimelineItem.vue +49 -0
- package/app/components/mdc/MdcWarning.vue +16 -0
- package/app/components/mdc/icons.ts +10 -0
- package/app/components/prose/ProseA.vue +27 -0
- package/app/components/prose/ProseBlockquote.vue +14 -0
- package/app/components/prose/ProseCode.vue +14 -0
- package/app/components/prose/ProseEm.vue +14 -0
- package/app/components/prose/ProseH1.vue +18 -0
- package/app/components/prose/ProseH2.vue +24 -0
- package/app/components/prose/ProseH3.vue +24 -0
- package/app/components/prose/ProseH4.vue +14 -0
- package/app/components/prose/ProseHr.vue +12 -0
- package/app/components/prose/ProseImg.vue +120 -0
- package/app/components/prose/ProseLi.vue +14 -0
- package/app/components/prose/ProseOl.vue +14 -0
- package/app/components/prose/ProseP.vue +14 -0
- package/app/components/prose/ProsePre.vue +217 -0
- package/app/components/prose/ProseStrong.vue +14 -0
- package/app/components/prose/ProseTable.vue +16 -0
- package/app/components/prose/ProseTbody.vue +14 -0
- package/app/components/prose/ProseTd.vue +14 -0
- package/app/components/prose/ProseTh.vue +14 -0
- package/app/components/prose/ProseThead.vue +14 -0
- package/app/components/prose/ProseTr.vue +14 -0
- package/app/components/prose/ProseUl.vue +14 -0
- package/app/components/site/ModeToggle.vue +123 -0
- package/app/components/site/SiteBanner.vue +56 -0
- package/app/components/site/SiteFooter.vue +80 -0
- package/app/components/site/SiteHeader.vue +250 -0
- package/app/components/site/SiteInteractionLayer.vue +35 -0
- package/app/components/site/SiteLocaleSwitcher.vue +268 -0
- package/app/components/site/SiteLogoMark.vue +12 -0
- package/app/components/site/SiteSkipLink.vue +14 -0
- package/app/components/ui/accordion/Accordion.vue +15 -0
- package/app/components/ui/accordion/AccordionContent.vue +23 -0
- package/app/components/ui/accordion/AccordionItem.vue +24 -0
- package/app/components/ui/accordion/AccordionTrigger.vue +35 -0
- package/app/components/ui/accordion/index.ts +4 -0
- package/app/components/ui/alert/Alert.vue +21 -0
- package/app/components/ui/alert/AlertDescription.vue +22 -0
- package/app/components/ui/alert/AlertTitle.vue +22 -0
- package/app/components/ui/alert/index.ts +34 -0
- package/app/components/ui/badge/Badge.vue +28 -0
- package/app/components/ui/badge/index.ts +24 -0
- package/app/components/ui/button/Button.vue +31 -0
- package/app/components/ui/button/index.ts +35 -0
- package/app/components/ui/card/Card.vue +16 -0
- package/app/components/ui/card/CardAction.vue +17 -0
- package/app/components/ui/card/CardContent.vue +14 -0
- package/app/components/ui/card/CardDescription.vue +14 -0
- package/app/components/ui/card/CardFooter.vue +14 -0
- package/app/components/ui/card/CardHeader.vue +22 -0
- package/app/components/ui/card/CardTitle.vue +14 -0
- package/app/components/ui/card/card-variants.ts +15 -0
- package/app/components/ui/card/index.ts +8 -0
- package/app/components/ui/checkbox/Checkbox.vue +35 -0
- package/app/components/ui/checkbox/index.ts +1 -0
- package/app/components/ui/collapsible/Collapsible.vue +15 -0
- package/app/components/ui/collapsible/CollapsibleContent.vue +25 -0
- package/app/components/ui/collapsible/CollapsibleTrigger.vue +12 -0
- package/app/components/ui/collapsible/index.ts +3 -0
- package/app/components/ui/dialog/Dialog.vue +15 -0
- package/app/components/ui/dialog/DialogClose.vue +12 -0
- package/app/components/ui/dialog/DialogContent.vue +69 -0
- package/app/components/ui/dialog/DialogDescription.vue +21 -0
- package/app/components/ui/dialog/DialogFooter.vue +15 -0
- package/app/components/ui/dialog/DialogHeader.vue +12 -0
- package/app/components/ui/dialog/DialogOverlay.vue +26 -0
- package/app/components/ui/dialog/DialogPortal.vue +12 -0
- package/app/components/ui/dialog/DialogTitle.vue +21 -0
- package/app/components/ui/dialog/DialogTrigger.vue +12 -0
- package/app/components/ui/dialog/index.ts +10 -0
- package/app/components/ui/dropdown-menu/DropdownMenu.vue +15 -0
- package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +36 -0
- package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +40 -0
- package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +12 -0
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +41 -0
- package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +25 -0
- package/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +15 -0
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +37 -0
- package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +23 -0
- package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -0
- package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +15 -0
- package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +29 -0
- package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +31 -0
- package/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue +14 -0
- package/app/components/ui/dropdown-menu/index.ts +16 -0
- package/app/components/ui/input/Input.vue +23 -0
- package/app/components/ui/input/index.ts +1 -0
- package/app/components/ui/kbd/Kbd.vue +23 -0
- package/app/components/ui/kbd/KbdGroup.vue +14 -0
- package/app/components/ui/kbd/index.ts +22 -0
- package/app/components/ui/label/Label.vue +14 -0
- package/app/components/ui/label/index.ts +1 -0
- package/app/components/ui/scroll-area/ScrollArea.vue +39 -0
- package/app/components/ui/scroll-area/ScrollBar.vue +33 -0
- package/app/components/ui/scroll-area/index.ts +2 -0
- package/app/components/ui/separator/Separator.vue +27 -0
- package/app/components/ui/separator/index.ts +1 -0
- package/app/components/ui/sheet/Sheet.vue +15 -0
- package/app/components/ui/sheet/SheetClose.vue +12 -0
- package/app/components/ui/sheet/SheetContent.vue +59 -0
- package/app/components/ui/sheet/SheetDescription.vue +21 -0
- package/app/components/ui/sheet/SheetFooter.vue +12 -0
- package/app/components/ui/sheet/SheetHeader.vue +12 -0
- package/app/components/ui/sheet/SheetOverlay.vue +26 -0
- package/app/components/ui/sheet/SheetTitle.vue +21 -0
- package/app/components/ui/sheet/SheetTrigger.vue +12 -0
- package/app/components/ui/sheet/index.ts +8 -0
- package/app/components/ui/switch/Switch.vue +39 -0
- package/app/components/ui/switch/index.ts +1 -0
- package/app/components/ui/tabs/Tabs.vue +24 -0
- package/app/components/ui/tabs/TabsContent.vue +21 -0
- package/app/components/ui/tabs/TabsList.vue +26 -0
- package/app/components/ui/tabs/TabsTrigger.vue +28 -0
- package/app/components/ui/tabs/index.ts +4 -0
- package/app/components/ui/textarea/Textarea.vue +31 -0
- package/app/components/ui/textarea/index.ts +1 -0
- package/app/composables/site-navigation.utils.ts +80 -0
- package/app/composables/useCanonicalUrl.ts +9 -0
- package/app/composables/useContentRouteAlternates.ts +43 -0
- package/app/composables/useGinkoAnalytics.ts +37 -0
- package/app/composables/useGinkoDocsConfig.ts +6 -0
- package/app/composables/useGinkoOgImage.ts +20 -0
- package/app/composables/useLocalizedPath.ts +12 -0
- package/app/composables/useLocalizedRouteSwitch.ts +31 -0
- package/app/composables/usePageMarkdownActions.ts +79 -0
- package/app/composables/useSchemaJsonLd.ts +25 -0
- package/app/composables/useSiteNavigation.ts +86 -0
- package/app/config/site.utils.ts +36 -0
- package/app/error.vue +125 -0
- package/app/features/docs/components/DocsContributeLinks.vue +65 -0
- package/app/features/docs/components/DocsMobileToc.vue +104 -0
- package/app/features/docs/components/DocsPageContent.vue +188 -0
- package/app/features/docs/components/DocsPageNav.vue +57 -0
- package/app/features/docs/components/DocsSidebar.vue +129 -0
- package/app/features/docs/components/DocsSidebarDropdown.vue +70 -0
- package/app/features/docs/components/DocsSidebarItem.vue +169 -0
- package/app/features/docs/components/DocsSidebarList.vue +35 -0
- package/app/features/docs/components/DocsSidebarTabs.vue +35 -0
- package/app/features/docs/components/DocsToc.vue +60 -0
- package/app/features/docs/composables/useDocsEntryPath.ts +13 -0
- package/app/features/docs/composables/useDocsNavigation.ts +29 -0
- package/app/features/docs/composables/useDocsNavigationData.ts +15 -0
- package/app/features/docs/composables/useScrollspy.ts +67 -0
- package/app/features/docs/docs-navigation.ts +170 -0
- package/app/features/search/command-center.ts +156 -0
- package/app/features/search/components/SiteCommandCenter.vue +323 -0
- package/app/features/search/components/SiteSearchPageHighlight.vue +124 -0
- package/app/features/search/useCommandCenter.ts +300 -0
- package/app/layouts/blog.vue +15 -0
- package/app/layouts/default.vue +12 -0
- package/app/layouts/docs.vue +20 -0
- package/app/lib/errors.ts +84 -0
- package/app/lib/schema-org.ts +38 -0
- package/app/lib/utils.ts +7 -0
- package/app/pages/blog/[slug].vue +211 -0
- package/app/pages/blog/index.vue +106 -0
- package/app/pages/docs/[...slug].vue +10 -0
- package/app/pages/docs/index.vue +23 -0
- package/app/pages/index.vue +140 -0
- package/app/plugins/ssr-width.ts +6 -0
- package/app/utils/content.ts +56 -0
- package/app/utils/index.ts +1 -0
- package/components.ts +9 -0
- package/content.js +175 -0
- package/content.ts +148 -0
- package/i18n/i18n.config.ts +10 -0
- package/i18n/locales.ts +81 -0
- package/i18n/messages/global/banner.ts +8 -0
- package/i18n/messages/global/blog.ts +14 -0
- package/i18n/messages/global/command.ts +44 -0
- package/i18n/messages/global/docs.ts +32 -0
- package/i18n/messages/global/errors.ts +36 -0
- package/i18n/messages/global/feedback.ts +11 -0
- package/i18n/messages/global/index.ts +21 -0
- package/i18n/messages/global/nav.ts +18 -0
- package/i18n/messages/global/site.ts +4 -0
- package/i18n/messages/global/theme.ts +7 -0
- package/i18n/messages/index.ts +20 -0
- package/i18n/messages/localize.ts +35 -0
- package/i18n/messages/source.ts +5 -0
- package/i18n/routes.ts +33 -0
- package/icon-bundle.ts +141 -0
- package/index.d.ts +13 -0
- package/modules/feature-routing.ts +32 -0
- package/nuxt.config.ts +194 -0
- package/package.json +91 -0
- package/runtime/markdown/inline-code-highlight.ts +155 -0
- package/runtime/server/agent-markdown.ts +21 -0
- package/runtime/utils.ts +7 -0
- package/server/mcp/tools/get-page.ts +14 -0
- package/server/mcp/tools/list-pages.ts +10 -0
- package/server/plugins/agent-markdown.ts +6 -0
- package/shared/route-slugs.ts +5 -0
- package/shared/types/app-config.ts +100 -0
- 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, "&")
|
|
27
|
+
.replace(/</g, "<")
|
|
28
|
+
.replace(/>/g, ">")
|
|
29
|
+
.replace(/"/g, """);
|
|
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>
|