@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.
- package/README.md +130 -73
- package/app/app.config.ts +14 -12
- package/app/app.vue +2 -2
- package/app/assets/css/prose.css +2203 -149
- package/app/assets/css/tailwind.css +60 -38
- package/app/assets/css/theme-palettes.css +309 -0
- package/app/components/content/DocumentPageShell.vue +1 -1
- package/app/components/content/Feedback.vue +12 -11
- package/app/components/content/ImageZoomDialog.vue +84 -0
- package/app/components/content/PageMarkdownCopy.vue +2 -0
- package/app/components/content/imageZoom.ts +25 -0
- package/app/components/mdc/MdcAccordion.vue +6 -3
- package/app/components/mdc/MdcAccordionItem.vue +7 -16
- package/app/components/mdc/MdcApi.vue +159 -0
- package/app/components/mdc/MdcAside.vue +6 -10
- package/app/components/mdc/MdcCallout.vue +9 -10
- package/app/components/mdc/MdcCard.vue +36 -47
- package/app/components/mdc/MdcCards.vue +30 -0
- package/app/components/mdc/MdcCodeGroup.vue +4 -0
- package/app/components/mdc/MdcCodeTree.vue +234 -0
- package/app/components/mdc/MdcCollapse.vue +61 -0
- package/app/components/mdc/MdcError.vue +8 -1
- package/app/components/mdc/MdcExcerpt.vue +26 -0
- package/app/components/mdc/MdcFigure.vue +54 -20
- package/app/components/mdc/MdcFiles.vue +120 -0
- package/app/components/mdc/MdcIdea.vue +2 -1
- package/app/components/mdc/MdcInfo.vue +2 -1
- package/app/components/mdc/MdcInlineToc.vue +51 -0
- package/app/components/{ui/dialog/DialogHeader.vue → mdc/MdcKbd.vue} +2 -4
- package/app/components/mdc/MdcNote.vue +2 -1
- package/app/components/mdc/MdcQuiz.vue +43 -83
- package/app/components/mdc/MdcQuizQuestion.vue +104 -109
- package/app/components/mdc/MdcReadMore.vue +57 -77
- package/app/components/mdc/MdcSteps.vue +45 -11
- package/app/components/mdc/MdcSuccess.vue +8 -1
- package/app/components/mdc/MdcTabs.vue +44 -26
- package/app/components/mdc/MdcTimeline.vue +4 -1
- package/app/components/mdc/MdcTimelineItem.vue +18 -27
- package/app/components/mdc/MdcWarning.vue +8 -1
- package/app/components/mdc/api.utils.ts +132 -0
- package/app/components/mdc/code-tree.utils.ts +22 -0
- package/app/components/mdc/quiz.utils.ts +38 -0
- package/app/components/prose/ProseBlockquote.vue +17 -1
- package/app/components/prose/ProseH1.vue +1 -1
- package/app/components/prose/ProseH2.vue +7 -2
- package/app/components/prose/ProseH3.vue +7 -2
- package/app/components/prose/ProseH4.vue +17 -2
- package/app/components/{ui/kbd/KbdGroup.vue → prose/ProseH5.vue} +4 -3
- package/app/components/{ui/card/CardTitle.vue → prose/ProseH6.vue} +4 -3
- package/app/components/prose/ProseImg.vue +19 -112
- package/app/components/prose/ProsePre.vue +63 -100
- package/app/components/prose/ProseTable.vue +5 -3
- package/app/components/site/ModeToggle.vue +12 -3
- package/app/components/site/SiteBanner.vue +43 -2
- package/app/components/site/SiteFooter.vue +3 -5
- package/app/components/site/SiteHeader.vue +255 -132
- package/app/components/site/SiteHeroCode.vue +164 -0
- package/app/components/site/SiteLocaleSwitcher.vue +22 -4
- package/app/components/ui/badge/Badge.vue +1 -1
- package/app/components/ui/button/Button.vue +1 -1
- package/app/components/ui/collapsible/CollapsibleContent.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +14 -4
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +1 -1
- package/app/components/ui/dropdown-menu/index.ts +0 -9
- package/app/components/ui/kbd/Kbd.vue +1 -1
- package/app/components/ui/kbd/index.ts +0 -1
- package/app/components/ui/scroll-area/ScrollArea.vue +1 -1
- package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
- package/app/components/ui/separator/Separator.vue +1 -1
- package/app/components/ui/sheet/SheetContent.vue +1 -1
- package/app/components/ui/sheet/SheetDescription.vue +1 -1
- package/app/components/ui/sheet/SheetOverlay.vue +1 -1
- package/app/components/ui/sheet/SheetTitle.vue +1 -1
- package/app/components/ui/sheet/index.ts +0 -3
- package/app/components/ui/tabs/Tabs.vue +1 -1
- package/app/components/ui/tabs/TabsList.vue +1 -1
- package/app/components/ui/tabs/TabsTrigger.vue +1 -1
- package/app/components/ui/tabs/index.ts +0 -1
- package/app/composables/site-navigation.utils.ts +29 -11
- package/app/composables/useContentRouteAlternates.ts +13 -21
- package/app/composables/useLocalizedRouteSwitch.ts +1 -1
- package/app/composables/useMetaKey.ts +18 -0
- package/app/composables/useProseAppearance.ts +24 -0
- package/app/composables/useSiteNavigation.ts +3 -18
- package/app/config/site.utils.ts +1 -1
- package/app/error.vue +1 -5
- package/app/features/docs/components/DocsBreadcrumb.vue +37 -0
- package/app/features/docs/components/DocsContributeLinks.vue +28 -18
- package/app/features/docs/components/DocsMobileToc.vue +12 -8
- package/app/features/docs/components/DocsPageContent.vue +84 -47
- package/app/features/docs/components/DocsPageNav.vue +1 -1
- package/app/features/docs/components/DocsSidebar.vue +30 -20
- package/app/features/docs/components/DocsSidebarItem.vue +53 -100
- package/app/features/docs/components/DocsSidebarRow.vue +27 -0
- package/app/features/docs/components/DocsSidebarTabs.vue +46 -22
- package/app/features/docs/components/DocsToc.vue +80 -34
- package/app/features/docs/composables/useDocsNavigation.ts +2 -2
- package/app/features/docs/composables/useDocsNavigationData.ts +4 -1
- package/app/features/docs/composables/useRevealActive.ts +58 -0
- package/app/features/docs/composables/useScrollspy.ts +87 -19
- package/app/features/docs/docs-navigation.ts +17 -30
- package/app/features/docs/toc-context.ts +6 -0
- package/app/features/search/command-center.ts +11 -90
- package/app/features/search/components/SiteCommandCenter.vue +58 -38
- package/app/features/search/useCommandCenter.ts +46 -46
- package/app/layouts/docs.vue +1 -1
- package/app/lib/errors.ts +2 -49
- package/app/pages/blog/[slug].vue +16 -8
- package/app/pages/blog/index.vue +26 -13
- package/app/pages/docs/index.vue +10 -14
- package/app/pages/index.vue +174 -59
- package/app/plugins/ginko-docs-theme.ts +12 -0
- package/app/utils/content.ts +11 -0
- package/app/utils/file-icons.ts +90 -0
- package/app/utils/index.ts +7 -1
- package/app/utils/repository.ts +20 -0
- package/content.js +23 -26
- package/content.ts +30 -28
- package/i18n/messages/global/blog.ts +4 -0
- package/i18n/messages/global/command.ts +6 -2
- package/i18n/messages/global/docs.ts +3 -0
- package/i18n/messages/global/nav.ts +6 -2
- package/i18n/messages/index.ts +3 -3
- package/icon-bundle.ts +5 -0
- package/index.d.ts +7 -1
- package/nuxt.config.ts +2 -2
- package/package.json +9 -6
- package/runtime/server/agent-markdown.ts +0 -2
- package/shared/theme-palettes.ts +37 -0
- package/shared/types/app-config.ts +63 -6
- package/tags.ts +47 -59
- package/app/components/mdc/MdcAlert.vue +0 -34
- package/app/components/mdc/MdcCardGroup.vue +0 -47
- package/app/components/mdc/MdcDocImg.vue +0 -25
- package/app/components/mdc/MdcField.vue +0 -62
- package/app/components/mdc/MdcFieldGroup.vue +0 -18
- package/app/components/mdc/MdcPassage.vue +0 -32
- package/app/components/mdc/MdcQuizOption.vue +0 -91
- package/app/components/mdc/MdcReadMoreGroup.vue +0 -18
- package/app/components/mdc/MdcShortcut.vue +0 -46
- package/app/components/mdc/MdcStep.vue +0 -42
- package/app/components/ui/accordion/Accordion.vue +0 -15
- package/app/components/ui/accordion/AccordionContent.vue +0 -23
- package/app/components/ui/accordion/AccordionItem.vue +0 -24
- package/app/components/ui/accordion/AccordionTrigger.vue +0 -35
- package/app/components/ui/accordion/index.ts +0 -4
- package/app/components/ui/alert/Alert.vue +0 -21
- package/app/components/ui/alert/AlertDescription.vue +0 -22
- package/app/components/ui/alert/AlertTitle.vue +0 -22
- package/app/components/ui/alert/index.ts +0 -34
- package/app/components/ui/card/Card.vue +0 -16
- package/app/components/ui/card/CardAction.vue +0 -17
- package/app/components/ui/card/CardContent.vue +0 -14
- package/app/components/ui/card/CardDescription.vue +0 -14
- package/app/components/ui/card/CardFooter.vue +0 -14
- package/app/components/ui/card/CardHeader.vue +0 -22
- package/app/components/ui/card/card-variants.ts +0 -15
- package/app/components/ui/card/index.ts +0 -8
- package/app/components/ui/checkbox/Checkbox.vue +0 -35
- package/app/components/ui/checkbox/index.ts +0 -1
- package/app/components/ui/dialog/Dialog.vue +0 -15
- package/app/components/ui/dialog/DialogClose.vue +0 -12
- package/app/components/ui/dialog/DialogContent.vue +0 -69
- package/app/components/ui/dialog/DialogDescription.vue +0 -21
- package/app/components/ui/dialog/DialogFooter.vue +0 -15
- package/app/components/ui/dialog/DialogOverlay.vue +0 -26
- package/app/components/ui/dialog/DialogPortal.vue +0 -12
- package/app/components/ui/dialog/DialogTitle.vue +0 -21
- package/app/components/ui/dialog/DialogTrigger.vue +0 -12
- package/app/components/ui/dialog/index.ts +0 -10
- package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +0 -36
- package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +0 -12
- package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +0 -25
- package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +0 -17
- package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +0 -15
- package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +0 -29
- package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +0 -31
- package/app/components/ui/input/Input.vue +0 -23
- package/app/components/ui/input/index.ts +0 -1
- package/app/components/ui/label/Label.vue +0 -14
- package/app/components/ui/label/index.ts +0 -1
- package/app/components/ui/sheet/SheetClose.vue +0 -12
- package/app/components/ui/sheet/SheetFooter.vue +0 -12
- package/app/components/ui/sheet/SheetHeader.vue +0 -12
- package/app/components/ui/switch/Switch.vue +0 -39
- package/app/components/ui/switch/index.ts +0 -1
- package/app/components/ui/tabs/TabsContent.vue +0 -21
- package/app/components/ui/textarea/Textarea.vue +0 -31
- package/app/components/ui/textarea/index.ts +0 -1
- package/app/features/docs/composables/useDocsEntryPath.ts +0 -13
- package/app/lib/utils.ts +0 -7
- package/i18n/messages/source.ts +0 -5
- package/runtime/markdown/inline-code-highlight.ts +0 -155
- package/runtime/utils.ts +0 -7
|
@@ -16,39 +16,24 @@ const { t } = useI18n();
|
|
|
16
16
|
const route = useRoute();
|
|
17
17
|
const inputRef = useTemplateRef<HTMLInputElement>("input");
|
|
18
18
|
const highlightedId = ref("");
|
|
19
|
-
const { open, query, groupedItems, selectItem } =
|
|
19
|
+
const { open, query, groupedItems, searchPending, searchError, searchIsEmpty, selectItem } =
|
|
20
|
+
await useCommandCenter();
|
|
20
21
|
|
|
21
22
|
const visibleItems = computed(() => groupedItems.value.flatMap((g) => g.items));
|
|
23
|
+
const isSearching = computed(() => query.value.trim().length > 0);
|
|
22
24
|
|
|
23
25
|
// ── text highlighting ──────────────────────────────────────────────────────
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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("");
|
|
26
|
+
function highlightTokens(text: string, q: string): { text: string; match: boolean }[] {
|
|
27
|
+
const terms = q.trim().split(/\s+/).filter(Boolean);
|
|
28
|
+
if (terms.length === 0) return [{ text, match: false }];
|
|
29
|
+
const pattern = terms.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
30
|
+
return text
|
|
31
|
+
.split(new RegExp(`(${pattern})`, "gi"))
|
|
32
|
+
.filter((part) => part !== "")
|
|
33
|
+
.map((part) => ({
|
|
34
|
+
text: part,
|
|
35
|
+
match: terms.some((term) => part.toLowerCase() === term.toLowerCase()),
|
|
36
|
+
}));
|
|
52
37
|
}
|
|
53
38
|
|
|
54
39
|
// ── keyboard navigation ────────────────────────────────────────────────────
|
|
@@ -181,8 +166,26 @@ onMounted(async () => {
|
|
|
181
166
|
|
|
182
167
|
<!-- Results -->
|
|
183
168
|
<div class="min-h-0 flex-1 overflow-y-auto overscroll-contain py-2">
|
|
169
|
+
<div
|
|
170
|
+
v-if="isSearching && searchPending"
|
|
171
|
+
class="flex items-center justify-center gap-2 px-6 py-12 text-sm text-muted-foreground"
|
|
172
|
+
role="status"
|
|
173
|
+
>
|
|
174
|
+
<Icon name="lucide:loader-circle" class="size-4 animate-spin" aria-hidden="true" />
|
|
175
|
+
{{ t("command.loading") }}
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<div
|
|
179
|
+
v-else-if="isSearching && searchError"
|
|
180
|
+
class="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center"
|
|
181
|
+
role="alert"
|
|
182
|
+
>
|
|
183
|
+
<Icon name="lucide:circle-alert" class="size-5 text-destructive" aria-hidden="true" />
|
|
184
|
+
<p class="max-w-sm text-sm font-medium text-foreground">{{ t("command.error") }}</p>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
184
187
|
<!-- Groups -->
|
|
185
|
-
<template v-if="visibleItems.length">
|
|
188
|
+
<template v-else-if="visibleItems.length">
|
|
186
189
|
<div v-for="group in groupedItems" :key="group.id" class="mb-1 px-2 last:mb-0">
|
|
187
190
|
<div class="flex items-center gap-2 px-2 pt-1.5 pb-1">
|
|
188
191
|
<span class="text-[11px] font-medium text-muted-foreground">{{ group.title }}</span>
|
|
@@ -225,11 +228,18 @@ onMounted(async () => {
|
|
|
225
228
|
<!-- Text -->
|
|
226
229
|
<div class="min-w-0 flex-1">
|
|
227
230
|
<div class="flex min-w-0 items-center gap-2">
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
<span class="min-w-0 truncate text-sm leading-5 font-medium">
|
|
232
|
+
<template
|
|
233
|
+
v-for="(token, tokenIndex) in highlightTokens(item.title, query)"
|
|
234
|
+
:key="tokenIndex"
|
|
235
|
+
>
|
|
236
|
+
<mark
|
|
237
|
+
v-if="token.match"
|
|
238
|
+
class="rounded-[3px] bg-primary/15 font-medium text-primary not-italic"
|
|
239
|
+
>{{ token.text }}</mark
|
|
240
|
+
><template v-else>{{ token.text }}</template>
|
|
241
|
+
</template>
|
|
242
|
+
</span>
|
|
233
243
|
<span
|
|
234
244
|
v-if="item.badge"
|
|
235
245
|
class="shrink-0 rounded-full bg-primary/10 px-1.5 py-px text-[10px] font-medium text-primary"
|
|
@@ -246,8 +256,18 @@ onMounted(async () => {
|
|
|
246
256
|
class="mt-1 size-3 shrink-0 opacity-50"
|
|
247
257
|
aria-hidden="true"
|
|
248
258
|
/>
|
|
249
|
-
<span class="line-clamp-2 min-w-0"
|
|
250
|
-
|
|
259
|
+
<span class="line-clamp-2 min-w-0">
|
|
260
|
+
<template
|
|
261
|
+
v-for="(token, tokenIndex) in highlightTokens(item.subtitle, query)"
|
|
262
|
+
:key="tokenIndex"
|
|
263
|
+
>
|
|
264
|
+
<mark
|
|
265
|
+
v-if="token.match"
|
|
266
|
+
class="rounded-[3px] bg-primary/15 font-medium text-primary not-italic"
|
|
267
|
+
>{{ token.text }}</mark
|
|
268
|
+
><template v-else>{{ token.text }}</template>
|
|
269
|
+
</template>
|
|
270
|
+
</span>
|
|
251
271
|
</p>
|
|
252
272
|
</div>
|
|
253
273
|
|
|
@@ -274,7 +294,7 @@ onMounted(async () => {
|
|
|
274
294
|
|
|
275
295
|
<!-- Empty state -->
|
|
276
296
|
<div
|
|
277
|
-
v-else
|
|
297
|
+
v-else-if="!isSearching || searchIsEmpty"
|
|
278
298
|
class="flex flex-col items-center justify-center gap-2 px-6 py-12 text-center"
|
|
279
299
|
>
|
|
280
300
|
<div
|
|
@@ -8,10 +8,8 @@ import {
|
|
|
8
8
|
getDocsNavigationGroups,
|
|
9
9
|
type DocsNavigationItem,
|
|
10
10
|
} from "#ginko-docs/features/docs/docs-navigation";
|
|
11
|
-
import { useDocsEntryPath } from "#ginko-docs/features/docs/composables/useDocsEntryPath";
|
|
12
11
|
import { useDocsNavigation } from "#ginko-docs/features/docs/composables/useDocsNavigation";
|
|
13
12
|
import {
|
|
14
|
-
contentSearchGroup,
|
|
15
13
|
dedupeCommandCenterItems,
|
|
16
14
|
groupCommandCenterItems,
|
|
17
15
|
PAGE_HIGHLIGHT_STORAGE_KEY,
|
|
@@ -21,14 +19,6 @@ import {
|
|
|
21
19
|
type StoredRecentItem,
|
|
22
20
|
} from "#ginko-docs/features/search/command-center";
|
|
23
21
|
|
|
24
|
-
type ContentSearchHit = {
|
|
25
|
-
collection: string;
|
|
26
|
-
path: string;
|
|
27
|
-
title: string;
|
|
28
|
-
excerpt?: string;
|
|
29
|
-
anchor?: string;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
22
|
const MAX_DEFAULT_PAGE_ITEMS = 6;
|
|
33
23
|
const MAX_DEFAULT_DOC_ITEMS = 3;
|
|
34
24
|
|
|
@@ -77,21 +67,27 @@ export async function useCommandCenter() {
|
|
|
77
67
|
} = useCommandCenterState();
|
|
78
68
|
const recentSelections = useLocalStorage<StoredRecentItem[]>("site-command-center-recent", []);
|
|
79
69
|
|
|
80
|
-
const { mainNav,
|
|
70
|
+
const { mainNav, socialLinks } = useSiteNavigation();
|
|
81
71
|
// Start every context-bound composable before the first await. Calling a
|
|
82
72
|
// Nuxt/i18n composable after an awaited operation loses the setup context.
|
|
83
|
-
const docsEntryPathResult = useDocsEntryPath();
|
|
84
73
|
const docsNavigationResult = useDocsNavigation();
|
|
85
74
|
const contentSearchResult = useContentSearch({
|
|
86
75
|
limit: 12,
|
|
87
76
|
locale: () => locale.value,
|
|
88
77
|
});
|
|
89
|
-
const [
|
|
90
|
-
|
|
78
|
+
const [
|
|
79
|
+
{ sections },
|
|
80
|
+
{
|
|
81
|
+
query: searchQuery,
|
|
82
|
+
results: searchResults,
|
|
83
|
+
pending: searchPending,
|
|
84
|
+
error: searchError,
|
|
85
|
+
isEmpty: searchIsEmpty,
|
|
86
|
+
},
|
|
87
|
+
] = await Promise.all([docsNavigationResult, contentSearchResult]);
|
|
91
88
|
|
|
92
89
|
const contentSearchItems = computed<CommandCenterItem[]>(() =>
|
|
93
|
-
searchResults.value.map((hit
|
|
94
|
-
const group = contentSearchGroup(hit.collection);
|
|
90
|
+
searchResults.value.map((hit) => {
|
|
95
91
|
const href = hit.anchor ? `${hit.path}#${hit.anchor}` : hit.path;
|
|
96
92
|
|
|
97
93
|
return {
|
|
@@ -99,10 +95,9 @@ export async function useCommandCenter() {
|
|
|
99
95
|
title: hit.title,
|
|
100
96
|
subtitle: hit.excerpt,
|
|
101
97
|
href,
|
|
102
|
-
group,
|
|
103
|
-
icon:
|
|
104
|
-
|
|
105
|
-
badge: group === "blog" ? t("nav.blog") : t("docs.label"),
|
|
98
|
+
group: "search",
|
|
99
|
+
icon: hit.collection === "blog" ? "lucide:newspaper" : "lucide:book-open",
|
|
100
|
+
badge: hit.collection === "blog" ? t("nav.blog") : t("docs.label"),
|
|
106
101
|
};
|
|
107
102
|
}),
|
|
108
103
|
);
|
|
@@ -130,11 +125,10 @@ export async function useCommandCenter() {
|
|
|
130
125
|
keywords: ["navigation", item.label],
|
|
131
126
|
}));
|
|
132
127
|
|
|
133
|
-
const
|
|
134
|
-
{ label: t("nav.documentation"), href:
|
|
135
|
-
...
|
|
136
|
-
]
|
|
137
|
-
const fromFooter = [...footerNav.value.product, ...footerResources, ...footerNav.value.company]
|
|
128
|
+
const fromFooter = [
|
|
129
|
+
{ label: t("nav.documentation"), href: localizedPath("docs") },
|
|
130
|
+
...socialLinks.value,
|
|
131
|
+
]
|
|
138
132
|
.filter((item) => !(item as { external?: boolean }).external)
|
|
139
133
|
.map<CommandCenterItem>((item) => ({
|
|
140
134
|
id: `page-footer-${item.href}`,
|
|
@@ -175,7 +169,7 @@ export async function useCommandCenter() {
|
|
|
175
169
|
title: section.title,
|
|
176
170
|
subtitle: t("command.pages.documentation"),
|
|
177
171
|
href: section.path,
|
|
178
|
-
group: "
|
|
172
|
+
group: "docs_nav",
|
|
179
173
|
icon: section.icon ?? "lucide:book-open",
|
|
180
174
|
keywords: [section.title],
|
|
181
175
|
},
|
|
@@ -204,30 +198,19 @@ export async function useCommandCenter() {
|
|
|
204
198
|
: [];
|
|
205
199
|
});
|
|
206
200
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
// (higher group priority) shadow duplicate search results while anchored
|
|
210
|
-
// hits (#section) stay distinct.
|
|
211
|
-
const allItems = computed(() =>
|
|
212
|
-
dedupeCommandCenterItems([
|
|
213
|
-
...pageItems.value,
|
|
214
|
-
...docsItems.value,
|
|
215
|
-
...contentSearchItems.value,
|
|
216
|
-
...actionItems.value,
|
|
217
|
-
]),
|
|
201
|
+
const canonicalItems = computed(() =>
|
|
202
|
+
dedupeCommandCenterItems([...pageItems.value, ...docsItems.value, ...actionItems.value]),
|
|
218
203
|
);
|
|
219
204
|
|
|
220
205
|
const recentItems = computed<CommandCenterItem[]>(() => {
|
|
221
206
|
if (query.value) return [];
|
|
222
207
|
|
|
223
|
-
return resolveRecentItems(recentSelections.value,
|
|
208
|
+
return resolveRecentItems(recentSelections.value, canonicalItems.value);
|
|
224
209
|
});
|
|
225
210
|
|
|
226
211
|
const defaultItems = computed<CommandCenterItem[]>(() => {
|
|
227
212
|
const recentSourceIds = new Set(recentItems.value.map((item) => item.sourceId));
|
|
228
|
-
const docSectionItems = docsItems.value
|
|
229
|
-
.filter((item) => item.id.startsWith("doc-switcher-"))
|
|
230
|
-
.slice(0, MAX_DEFAULT_DOC_ITEMS);
|
|
213
|
+
const docSectionItems = docsItems.value.slice(0, MAX_DEFAULT_DOC_ITEMS);
|
|
231
214
|
|
|
232
215
|
return dedupeCommandCenterItems([
|
|
233
216
|
...pageItems.value.slice(0, MAX_DEFAULT_PAGE_ITEMS),
|
|
@@ -237,16 +220,30 @@ export async function useCommandCenter() {
|
|
|
237
220
|
});
|
|
238
221
|
|
|
239
222
|
const groupedItems = computed(() => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
223
|
+
if (query.value.trim()) {
|
|
224
|
+
return contentSearchItems.value.length
|
|
225
|
+
? [
|
|
226
|
+
{
|
|
227
|
+
id: "search" as const,
|
|
228
|
+
title: t("command.groups.search"),
|
|
229
|
+
items: contentSearchItems.value,
|
|
230
|
+
},
|
|
231
|
+
]
|
|
232
|
+
: [];
|
|
233
|
+
}
|
|
234
|
+
return groupCommandCenterItems([...recentItems.value, ...defaultItems.value], (group) =>
|
|
244
235
|
t(`command.groups.${group}`),
|
|
245
236
|
);
|
|
246
237
|
});
|
|
247
238
|
|
|
248
239
|
function rememberSelection(item: CommandCenterItem) {
|
|
249
|
-
|
|
240
|
+
const canonicalItem =
|
|
241
|
+
item.group === "search"
|
|
242
|
+
? canonicalItems.value.find((candidate) => candidate.href === item.href)
|
|
243
|
+
: item;
|
|
244
|
+
if (canonicalItem) {
|
|
245
|
+
recentSelections.value = rememberRecentItem(canonicalItem, recentSelections.value);
|
|
246
|
+
}
|
|
250
247
|
}
|
|
251
248
|
|
|
252
249
|
function closeCommandCenter() {
|
|
@@ -293,6 +290,9 @@ export async function useCommandCenter() {
|
|
|
293
290
|
open,
|
|
294
291
|
query,
|
|
295
292
|
groupedItems,
|
|
293
|
+
searchPending,
|
|
294
|
+
searchError,
|
|
295
|
+
searchIsEmpty,
|
|
296
296
|
openCommandCenter,
|
|
297
297
|
closeCommandCenter,
|
|
298
298
|
selectItem,
|
package/app/layouts/docs.vue
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
<div class="mx-auto flex w-full max-w-screen-2xl min-w-0 flex-1">
|
|
8
8
|
<div
|
|
9
|
-
class="pointer-events-none sticky top-[var(--site-header-height)] z-20 hidden h-[calc(100dvh-var(--site-header-height))] w-[var(--docs-sidebar-width)] shrink-0 overflow-hidden *:pointer-events-auto md:block"
|
|
9
|
+
class="pointer-events-none sticky top-[var(--site-header-height)] z-20 hidden h-[calc(100dvh-var(--site-header-height)-var(--site-banner-height,0px))] w-[var(--docs-sidebar-width)] shrink-0 overflow-hidden *:pointer-events-auto md:block"
|
|
10
10
|
>
|
|
11
11
|
<DocsSidebar />
|
|
12
12
|
</div>
|
package/app/lib/errors.ts
CHANGED
|
@@ -2,31 +2,12 @@ export type ErrorKind = "accessDenied" | "notFound" | "server" | "unavailable";
|
|
|
2
2
|
|
|
3
3
|
export interface NormalizedAppError {
|
|
4
4
|
kind: ErrorKind;
|
|
5
|
-
message: string;
|
|
6
5
|
statusCode: number;
|
|
7
|
-
statusMessage: string;
|
|
8
6
|
}
|
|
9
7
|
|
|
10
8
|
type ErrorLike = {
|
|
11
|
-
data?: unknown;
|
|
12
|
-
fatal?: boolean;
|
|
13
|
-
message?: string;
|
|
14
|
-
name?: string;
|
|
15
9
|
status?: number;
|
|
16
10
|
statusCode?: number;
|
|
17
|
-
statusMessage?: string;
|
|
18
|
-
statusText?: string;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
type ContentNotFoundError = Error & {
|
|
22
|
-
data: {
|
|
23
|
-
kind: "content-not-found";
|
|
24
|
-
};
|
|
25
|
-
fatal: true;
|
|
26
|
-
status: 404;
|
|
27
|
-
statusCode: 404;
|
|
28
|
-
statusMessage: "Not Found";
|
|
29
|
-
statusText: "Not Found";
|
|
30
11
|
};
|
|
31
12
|
|
|
32
13
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
@@ -44,41 +25,13 @@ function toStatusCode(error: ErrorLike): number {
|
|
|
44
25
|
|
|
45
26
|
function errorKind(statusCode: number): ErrorKind {
|
|
46
27
|
if (statusCode === 404) return "notFound";
|
|
47
|
-
if (statusCode === 403) return "accessDenied";
|
|
28
|
+
if (statusCode === 401 || statusCode === 403) return "accessDenied";
|
|
48
29
|
if (statusCode >= 500) return "server";
|
|
49
30
|
return "unavailable";
|
|
50
31
|
}
|
|
51
32
|
|
|
52
|
-
function defaultStatusMessage(statusCode: number, kind: ErrorKind): string {
|
|
53
|
-
if (kind === "notFound") return "Not Found";
|
|
54
|
-
if (kind === "accessDenied") return "Forbidden";
|
|
55
|
-
if (kind === "server") return "Internal Server Error";
|
|
56
|
-
return statusCode >= 400 ? "Unavailable" : "Error";
|
|
57
|
-
}
|
|
58
|
-
|
|
59
33
|
export function normalizeAppError(error: unknown): NormalizedAppError {
|
|
60
34
|
const source = toErrorLike(error);
|
|
61
35
|
const statusCode = toStatusCode(source);
|
|
62
|
-
|
|
63
|
-
const statusMessage =
|
|
64
|
-
source.statusMessage ?? source.statusText ?? defaultStatusMessage(statusCode, kind);
|
|
65
|
-
const message = source.message ?? statusMessage;
|
|
66
|
-
|
|
67
|
-
return {
|
|
68
|
-
kind,
|
|
69
|
-
message,
|
|
70
|
-
statusCode,
|
|
71
|
-
statusMessage,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function createContentNotFoundError(): ContentNotFoundError {
|
|
76
|
-
const error = new Error("Content not found") as ContentNotFoundError;
|
|
77
|
-
error.data = { kind: "content-not-found" };
|
|
78
|
-
error.fatal = true;
|
|
79
|
-
error.status = 404;
|
|
80
|
-
error.statusCode = 404;
|
|
81
|
-
error.statusMessage = "Not Found";
|
|
82
|
-
error.statusText = "Not Found";
|
|
83
|
-
return error;
|
|
36
|
+
return { kind: errorKind(statusCode), statusCode };
|
|
84
37
|
}
|
|
@@ -2,13 +2,18 @@
|
|
|
2
2
|
import { Badge } from "#ginko-docs/components/ui/badge";
|
|
3
3
|
import ContentFeedback from "#ginko-docs/components/content/Feedback.vue";
|
|
4
4
|
import { Button } from "#ginko-docs/components/ui/button";
|
|
5
|
-
import { createContentNotFoundError } from "#ginko-docs/lib/errors";
|
|
6
5
|
import { createArticleSchema, createBreadcrumbSchema } from "#ginko-docs/lib/schema-org";
|
|
7
6
|
import { getLocalizedSiteText } from "#ginko-docs/config/site.utils";
|
|
8
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
filterTocByDepth,
|
|
9
|
+
flattenTocLinks,
|
|
10
|
+
formatContentDate,
|
|
11
|
+
getMarkdownTocLinks,
|
|
12
|
+
} from "#ginko-docs/utils/content";
|
|
9
13
|
import { computed, watch } from "vue";
|
|
10
14
|
import {
|
|
11
15
|
definePageMeta,
|
|
16
|
+
createError,
|
|
12
17
|
useAppConfig,
|
|
13
18
|
useContentPage,
|
|
14
19
|
useHead,
|
|
@@ -22,7 +27,7 @@ import { useGinkoOgImage } from "#ginko-docs/composables/useGinkoOgImage";
|
|
|
22
27
|
import { useSchemaJsonLd } from "#ginko-docs/composables/useSchemaJsonLd";
|
|
23
28
|
import DocumentPageShell from "#ginko-docs/components/content/DocumentPageShell.vue";
|
|
24
29
|
import PageMarkdownCopy from "#ginko-docs/components/content/PageMarkdownCopy.vue";
|
|
25
|
-
import {
|
|
30
|
+
import { useContentRouteAlternates } from "#ginko-docs/composables/useContentRouteAlternates";
|
|
26
31
|
|
|
27
32
|
definePageMeta({ layout: "blog" });
|
|
28
33
|
|
|
@@ -30,26 +35,26 @@ const { locale, t } = useI18n();
|
|
|
30
35
|
const config = useAppConfig().ginkoDocs;
|
|
31
36
|
const route = useRoute();
|
|
32
37
|
const localizedPath = useLocalizedPath();
|
|
38
|
+
const canonicalUrl = useCanonicalUrl();
|
|
39
|
+
const { sync: syncContentRouteAlternates } = useContentRouteAlternates();
|
|
33
40
|
const {
|
|
34
41
|
page: post,
|
|
35
42
|
previous,
|
|
36
43
|
next,
|
|
37
44
|
error,
|
|
38
45
|
} = await useContentPage("blog", {
|
|
39
|
-
locale: () => locale.value,
|
|
40
46
|
fallback: true,
|
|
41
47
|
populate: { author: "authors" },
|
|
42
48
|
surround: true,
|
|
43
49
|
});
|
|
44
50
|
if (error.value) throw error.value;
|
|
45
|
-
if (!post.value) throw
|
|
51
|
+
if (!post.value) throw createError({ statusCode: 404, statusMessage: "Not Found" });
|
|
46
52
|
syncContentRouteAlternates(post);
|
|
47
53
|
|
|
48
54
|
const pageTitle = computed(() => post.value?.title ?? t("blog.fallbackTitle"));
|
|
49
55
|
const pageDescription = computed(() => post.value?.description ?? t("blog.fallbackDescription"));
|
|
50
|
-
const canonicalUrl = useCanonicalUrl();
|
|
51
56
|
const tocItems = computed(() =>
|
|
52
|
-
filterTocByDepth(flattenTocLinks(post.value?.body
|
|
57
|
+
filterTocByDepth(flattenTocLinks(getMarkdownTocLinks(post.value?.body)), config.toc?.depth ?? 3),
|
|
53
58
|
);
|
|
54
59
|
const formattedDate = computed(() => formatContentDate(post.value?.date, locale.value));
|
|
55
60
|
const siteName = computed(() => getLocalizedSiteText(config.site.name, locale.value));
|
|
@@ -181,7 +186,10 @@ useSchemaJsonLd(() =>
|
|
|
181
186
|
<ContentRenderer :value="post" />
|
|
182
187
|
</div>
|
|
183
188
|
|
|
184
|
-
<ContentFeedback
|
|
189
|
+
<ContentFeedback
|
|
190
|
+
class="mt-14 border-y border-border py-5"
|
|
191
|
+
:label="t('feedback.articleLabel')"
|
|
192
|
+
/>
|
|
185
193
|
|
|
186
194
|
<div v-if="suggestions.length" class="mt-14">
|
|
187
195
|
<h2 class="mb-6 text-sm font-semibold tracking-wider text-muted-foreground uppercase">
|
package/app/pages/blog/index.vue
CHANGED
|
@@ -7,22 +7,12 @@ import { computed } from "vue";
|
|
|
7
7
|
import { definePageMeta, useAppConfig, useAsyncData, useHead, useI18n, useSeoMeta } from "#imports";
|
|
8
8
|
import { useCanonicalUrl } from "#ginko-docs/composables/useCanonicalUrl";
|
|
9
9
|
|
|
10
|
+
const MAX_BLOG_POSTS = 50;
|
|
11
|
+
|
|
10
12
|
definePageMeta({ layout: "blog" });
|
|
11
13
|
|
|
12
14
|
const { locale, t } = useI18n();
|
|
13
15
|
const postsKey = computed(() => `blog-posts:${locale.value}`);
|
|
14
|
-
const { data: posts } = await useAsyncData(
|
|
15
|
-
postsKey,
|
|
16
|
-
() =>
|
|
17
|
-
many("blog", {
|
|
18
|
-
locale: locale.value,
|
|
19
|
-
fallback: true,
|
|
20
|
-
populate: { author: "authors" },
|
|
21
|
-
sort: { date: "desc" },
|
|
22
|
-
limit: 50,
|
|
23
|
-
}),
|
|
24
|
-
{ watch: [locale] },
|
|
25
|
-
);
|
|
26
16
|
const canonicalUrl = useCanonicalUrl();
|
|
27
17
|
const config = useAppConfig().ginkoDocs;
|
|
28
18
|
const siteName = computed(() => getLocalizedSiteText(config.site.name, locale.value));
|
|
@@ -40,6 +30,26 @@ useSeoMeta({
|
|
|
40
30
|
useHead(() => ({
|
|
41
31
|
link: [{ key: "canonical", rel: "canonical", href: canonicalUrl.value }],
|
|
42
32
|
}));
|
|
33
|
+
|
|
34
|
+
const { data: queriedPosts, error } = await useAsyncData(
|
|
35
|
+
postsKey,
|
|
36
|
+
() =>
|
|
37
|
+
many("blog", {
|
|
38
|
+
locale: locale.value,
|
|
39
|
+
fallback: true,
|
|
40
|
+
populate: { author: "authors" },
|
|
41
|
+
sort: { date: "desc" },
|
|
42
|
+
limit: MAX_BLOG_POSTS + 1,
|
|
43
|
+
}),
|
|
44
|
+
{ watch: [locale] },
|
|
45
|
+
);
|
|
46
|
+
if (error.value) throw error.value;
|
|
47
|
+
if ((queriedPosts.value?.length ?? 0) > MAX_BLOG_POSTS) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`Blog supports at most ${MAX_BLOG_POSTS} posts; add pagination before publishing more`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const posts = computed(() => queriedPosts.value ?? []);
|
|
43
53
|
</script>
|
|
44
54
|
|
|
45
55
|
<template>
|
|
@@ -49,7 +59,10 @@ useHead(() => ({
|
|
|
49
59
|
<p class="mt-2 text-muted-foreground">{{ t("blog.description") }}</p>
|
|
50
60
|
</div>
|
|
51
61
|
|
|
52
|
-
<
|
|
62
|
+
<p v-if="posts.length === 0" class="py-12 text-center text-sm text-muted-foreground">
|
|
63
|
+
{{ t("blog.empty") }}
|
|
64
|
+
</p>
|
|
65
|
+
<div v-else class="divide-y divide-border">
|
|
53
66
|
<article v-for="post in posts" :key="post.route.resolvedPath" class="group py-8">
|
|
54
67
|
<NuxtLink
|
|
55
68
|
:to="post.route.resolvedPath"
|
package/app/pages/docs/index.vue
CHANGED
|
@@ -1,23 +1,19 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { findFirstNavigationPage } from "@lupinum/ginko-content/navigation";
|
|
3
|
+
import { createError, definePageMeta, navigateTo } from "#imports";
|
|
4
|
+
import { useDocsNavigationData } from "#ginko-docs/features/docs/composables/useDocsNavigationData";
|
|
4
5
|
|
|
5
6
|
definePageMeta({ layout: "docs" });
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
if (entryPath.value && entryPath.value !== route.path) {
|
|
13
|
-
await navigateTo(entryPath.value, { redirectCode: 302, replace: true });
|
|
8
|
+
const { data, error } = await useDocsNavigationData();
|
|
9
|
+
if (error.value) throw error.value;
|
|
10
|
+
const entryPath = findFirstNavigationPage(data.value ?? undefined)?.path;
|
|
11
|
+
if (!entryPath) {
|
|
12
|
+
throw createError({ statusCode: 404, statusMessage: "Documentation is empty" });
|
|
14
13
|
}
|
|
14
|
+
await navigateTo(entryPath, { redirectCode: 302, replace: true });
|
|
15
15
|
</script>
|
|
16
16
|
|
|
17
17
|
<template>
|
|
18
|
-
<main id="main-content"
|
|
19
|
-
<NuxtLink v-if="entryPath && entryPath !== route.path" :to="entryPath" class="underline">
|
|
20
|
-
{{ entryPath }}
|
|
21
|
-
</NuxtLink>
|
|
22
|
-
</main>
|
|
18
|
+
<main id="main-content" />
|
|
23
19
|
</template>
|