@lupinum/ginko-docs 0.2.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +130 -73
  2. package/app/app.config.ts +14 -12
  3. package/app/app.vue +2 -2
  4. package/app/assets/css/prose.css +2203 -149
  5. package/app/assets/css/tailwind.css +60 -38
  6. package/app/assets/css/theme-palettes.css +309 -0
  7. package/app/components/content/DocumentPageShell.vue +1 -1
  8. package/app/components/content/Feedback.vue +12 -11
  9. package/app/components/content/ImageZoomDialog.vue +84 -0
  10. package/app/components/content/PageMarkdownCopy.vue +2 -0
  11. package/app/components/content/imageZoom.ts +25 -0
  12. package/app/components/mdc/MdcAccordion.vue +6 -3
  13. package/app/components/mdc/MdcAccordionItem.vue +7 -16
  14. package/app/components/mdc/MdcApi.vue +159 -0
  15. package/app/components/mdc/MdcAside.vue +6 -10
  16. package/app/components/mdc/MdcCallout.vue +9 -10
  17. package/app/components/mdc/MdcCard.vue +36 -47
  18. package/app/components/mdc/MdcCards.vue +30 -0
  19. package/app/components/mdc/MdcCodeGroup.vue +4 -0
  20. package/app/components/mdc/MdcCodeTree.vue +234 -0
  21. package/app/components/mdc/MdcCollapse.vue +61 -0
  22. package/app/components/mdc/MdcError.vue +8 -1
  23. package/app/components/mdc/MdcExcerpt.vue +26 -0
  24. package/app/components/mdc/MdcFigure.vue +54 -20
  25. package/app/components/mdc/MdcFiles.vue +120 -0
  26. package/app/components/mdc/MdcIdea.vue +2 -1
  27. package/app/components/mdc/MdcInfo.vue +2 -1
  28. package/app/components/mdc/MdcInlineToc.vue +51 -0
  29. package/app/components/{ui/dialog/DialogHeader.vue → mdc/MdcKbd.vue} +2 -4
  30. package/app/components/mdc/MdcNote.vue +2 -1
  31. package/app/components/mdc/MdcQuiz.vue +43 -83
  32. package/app/components/mdc/MdcQuizQuestion.vue +104 -109
  33. package/app/components/mdc/MdcReadMore.vue +57 -77
  34. package/app/components/mdc/MdcSteps.vue +45 -11
  35. package/app/components/mdc/MdcSuccess.vue +8 -1
  36. package/app/components/mdc/MdcTabs.vue +44 -26
  37. package/app/components/mdc/MdcTimeline.vue +4 -1
  38. package/app/components/mdc/MdcTimelineItem.vue +18 -27
  39. package/app/components/mdc/MdcWarning.vue +8 -1
  40. package/app/components/mdc/api.utils.ts +132 -0
  41. package/app/components/mdc/code-tree.utils.ts +22 -0
  42. package/app/components/mdc/quiz.utils.ts +38 -0
  43. package/app/components/prose/ProseBlockquote.vue +17 -1
  44. package/app/components/prose/ProseH1.vue +1 -1
  45. package/app/components/prose/ProseH2.vue +7 -2
  46. package/app/components/prose/ProseH3.vue +7 -2
  47. package/app/components/prose/ProseH4.vue +17 -2
  48. package/app/components/{ui/kbd/KbdGroup.vue → prose/ProseH5.vue} +4 -3
  49. package/app/components/{ui/card/CardTitle.vue → prose/ProseH6.vue} +4 -3
  50. package/app/components/prose/ProseImg.vue +19 -112
  51. package/app/components/prose/ProsePre.vue +63 -100
  52. package/app/components/prose/ProseTable.vue +5 -3
  53. package/app/components/site/ModeToggle.vue +12 -3
  54. package/app/components/site/SiteBanner.vue +43 -2
  55. package/app/components/site/SiteFooter.vue +3 -5
  56. package/app/components/site/SiteHeader.vue +255 -132
  57. package/app/components/site/SiteHeroCode.vue +164 -0
  58. package/app/components/site/SiteLocaleSwitcher.vue +22 -4
  59. package/app/components/ui/badge/Badge.vue +1 -1
  60. package/app/components/ui/button/Button.vue +1 -1
  61. package/app/components/ui/collapsible/CollapsibleContent.vue +1 -1
  62. package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +14 -4
  63. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  64. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +1 -1
  65. package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +1 -1
  66. package/app/components/ui/dropdown-menu/index.ts +0 -9
  67. package/app/components/ui/kbd/Kbd.vue +1 -1
  68. package/app/components/ui/kbd/index.ts +0 -1
  69. package/app/components/ui/scroll-area/ScrollArea.vue +1 -1
  70. package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
  71. package/app/components/ui/separator/Separator.vue +1 -1
  72. package/app/components/ui/sheet/SheetContent.vue +1 -1
  73. package/app/components/ui/sheet/SheetDescription.vue +1 -1
  74. package/app/components/ui/sheet/SheetOverlay.vue +1 -1
  75. package/app/components/ui/sheet/SheetTitle.vue +1 -1
  76. package/app/components/ui/sheet/index.ts +0 -3
  77. package/app/components/ui/tabs/Tabs.vue +1 -1
  78. package/app/components/ui/tabs/TabsList.vue +1 -1
  79. package/app/components/ui/tabs/TabsTrigger.vue +1 -1
  80. package/app/components/ui/tabs/index.ts +0 -1
  81. package/app/composables/site-navigation.utils.ts +29 -11
  82. package/app/composables/useContentRouteAlternates.ts +13 -21
  83. package/app/composables/useLocalizedRouteSwitch.ts +1 -1
  84. package/app/composables/useMetaKey.ts +18 -0
  85. package/app/composables/useProseAppearance.ts +24 -0
  86. package/app/composables/useSiteNavigation.ts +3 -18
  87. package/app/config/site.utils.ts +1 -1
  88. package/app/error.vue +1 -5
  89. package/app/features/docs/components/DocsBreadcrumb.vue +37 -0
  90. package/app/features/docs/components/DocsContributeLinks.vue +28 -18
  91. package/app/features/docs/components/DocsMobileToc.vue +12 -8
  92. package/app/features/docs/components/DocsPageContent.vue +84 -47
  93. package/app/features/docs/components/DocsPageNav.vue +1 -1
  94. package/app/features/docs/components/DocsSidebar.vue +30 -20
  95. package/app/features/docs/components/DocsSidebarItem.vue +53 -100
  96. package/app/features/docs/components/DocsSidebarRow.vue +27 -0
  97. package/app/features/docs/components/DocsSidebarTabs.vue +46 -22
  98. package/app/features/docs/components/DocsToc.vue +80 -34
  99. package/app/features/docs/composables/useDocsNavigation.ts +2 -2
  100. package/app/features/docs/composables/useDocsNavigationData.ts +4 -1
  101. package/app/features/docs/composables/useRevealActive.ts +58 -0
  102. package/app/features/docs/composables/useScrollspy.ts +87 -19
  103. package/app/features/docs/docs-navigation.ts +17 -30
  104. package/app/features/docs/toc-context.ts +6 -0
  105. package/app/features/search/command-center.ts +11 -90
  106. package/app/features/search/components/SiteCommandCenter.vue +58 -38
  107. package/app/features/search/useCommandCenter.ts +46 -46
  108. package/app/layouts/docs.vue +1 -1
  109. package/app/lib/errors.ts +2 -49
  110. package/app/pages/blog/[slug].vue +16 -8
  111. package/app/pages/blog/index.vue +26 -13
  112. package/app/pages/docs/index.vue +10 -14
  113. package/app/pages/index.vue +174 -59
  114. package/app/plugins/ginko-docs-theme.ts +12 -0
  115. package/app/utils/content.ts +11 -0
  116. package/app/utils/file-icons.ts +90 -0
  117. package/app/utils/index.ts +7 -1
  118. package/app/utils/repository.ts +20 -0
  119. package/content.js +23 -26
  120. package/content.ts +30 -28
  121. package/i18n/messages/global/blog.ts +4 -0
  122. package/i18n/messages/global/command.ts +6 -2
  123. package/i18n/messages/global/docs.ts +3 -0
  124. package/i18n/messages/global/nav.ts +6 -2
  125. package/i18n/messages/index.ts +3 -3
  126. package/icon-bundle.ts +5 -0
  127. package/index.d.ts +7 -1
  128. package/nuxt.config.ts +2 -2
  129. package/package.json +9 -6
  130. package/runtime/server/agent-markdown.ts +0 -2
  131. package/shared/theme-palettes.ts +37 -0
  132. package/shared/types/app-config.ts +63 -6
  133. package/tags.ts +47 -59
  134. package/app/components/mdc/MdcAlert.vue +0 -34
  135. package/app/components/mdc/MdcCardGroup.vue +0 -47
  136. package/app/components/mdc/MdcDocImg.vue +0 -25
  137. package/app/components/mdc/MdcField.vue +0 -62
  138. package/app/components/mdc/MdcFieldGroup.vue +0 -18
  139. package/app/components/mdc/MdcPassage.vue +0 -32
  140. package/app/components/mdc/MdcQuizOption.vue +0 -91
  141. package/app/components/mdc/MdcReadMoreGroup.vue +0 -18
  142. package/app/components/mdc/MdcShortcut.vue +0 -46
  143. package/app/components/mdc/MdcStep.vue +0 -42
  144. package/app/components/ui/accordion/Accordion.vue +0 -15
  145. package/app/components/ui/accordion/AccordionContent.vue +0 -23
  146. package/app/components/ui/accordion/AccordionItem.vue +0 -24
  147. package/app/components/ui/accordion/AccordionTrigger.vue +0 -35
  148. package/app/components/ui/accordion/index.ts +0 -4
  149. package/app/components/ui/alert/Alert.vue +0 -21
  150. package/app/components/ui/alert/AlertDescription.vue +0 -22
  151. package/app/components/ui/alert/AlertTitle.vue +0 -22
  152. package/app/components/ui/alert/index.ts +0 -34
  153. package/app/components/ui/card/Card.vue +0 -16
  154. package/app/components/ui/card/CardAction.vue +0 -17
  155. package/app/components/ui/card/CardContent.vue +0 -14
  156. package/app/components/ui/card/CardDescription.vue +0 -14
  157. package/app/components/ui/card/CardFooter.vue +0 -14
  158. package/app/components/ui/card/CardHeader.vue +0 -22
  159. package/app/components/ui/card/card-variants.ts +0 -15
  160. package/app/components/ui/card/index.ts +0 -8
  161. package/app/components/ui/checkbox/Checkbox.vue +0 -35
  162. package/app/components/ui/checkbox/index.ts +0 -1
  163. package/app/components/ui/dialog/Dialog.vue +0 -15
  164. package/app/components/ui/dialog/DialogClose.vue +0 -12
  165. package/app/components/ui/dialog/DialogContent.vue +0 -69
  166. package/app/components/ui/dialog/DialogDescription.vue +0 -21
  167. package/app/components/ui/dialog/DialogFooter.vue +0 -15
  168. package/app/components/ui/dialog/DialogOverlay.vue +0 -26
  169. package/app/components/ui/dialog/DialogPortal.vue +0 -12
  170. package/app/components/ui/dialog/DialogTitle.vue +0 -21
  171. package/app/components/ui/dialog/DialogTrigger.vue +0 -12
  172. package/app/components/ui/dialog/index.ts +0 -10
  173. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +0 -36
  174. package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +0 -12
  175. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +0 -25
  176. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +0 -17
  177. package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +0 -15
  178. package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +0 -29
  179. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +0 -31
  180. package/app/components/ui/input/Input.vue +0 -23
  181. package/app/components/ui/input/index.ts +0 -1
  182. package/app/components/ui/label/Label.vue +0 -14
  183. package/app/components/ui/label/index.ts +0 -1
  184. package/app/components/ui/sheet/SheetClose.vue +0 -12
  185. package/app/components/ui/sheet/SheetFooter.vue +0 -12
  186. package/app/components/ui/sheet/SheetHeader.vue +0 -12
  187. package/app/components/ui/switch/Switch.vue +0 -39
  188. package/app/components/ui/switch/index.ts +0 -1
  189. package/app/components/ui/tabs/TabsContent.vue +0 -21
  190. package/app/components/ui/textarea/Textarea.vue +0 -31
  191. package/app/components/ui/textarea/index.ts +0 -1
  192. package/app/features/docs/composables/useDocsEntryPath.ts +0 -13
  193. package/app/lib/utils.ts +0 -7
  194. package/i18n/messages/source.ts +0 -5
  195. package/runtime/markdown/inline-code-highlight.ts +0 -155
  196. package/runtime/utils.ts +0 -7
@@ -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 } = await useCommandCenter();
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 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("");
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
- <!-- 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
- />
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" v-html="highlight(item.subtitle, query)" />
250
- <!-- eslint-enable vue/no-v-html -->
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, footerNav, socialLinks } = useSiteNavigation();
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 [docsEntryPath, { sections }, { query: searchQuery, results: searchResults }] =
90
- await Promise.all([docsEntryPathResult, docsNavigationResult, contentSearchResult]);
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: ContentSearchHit) => {
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: group === "blog" ? "lucide:newspaper" : "lucide:book-open",
104
- keywords: [group, hit.title, hit.excerpt ?? ""],
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 footerResources = [
134
- { label: t("nav.documentation"), href: docsEntryPath.value },
135
- ...footerNav.value.resources,
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: "docs",
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
- // Cross-group dedupe: a page reachable via nav can also appear as a content
208
- // search hit with the same href. First occurrence wins, so nav entries
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, allItems.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
- const candidates = query.value.trim()
241
- ? allItems.value
242
- : [...recentItems.value, ...defaultItems.value];
243
- return groupCommandCenterItems(candidates, query.value, (group) =>
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
- recentSelections.value = rememberRecentItem(item, recentSelections.value);
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,
@@ -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
- const kind = errorKind(statusCode);
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 { filterTocByDepth, flattenTocLinks, formatContentDate } from "#ginko-docs/utils/content";
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 { syncContentRouteAlternates } from "#ginko-docs/composables/useContentRouteAlternates";
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 createContentNotFoundError();
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?.toc?.links), config.toc?.depth ?? 3),
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 :label="t('feedback.articleLabel')" />
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">
@@ -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
- <div class="divide-y divide-border">
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"
@@ -1,23 +1,19 @@
1
1
  <script setup lang="ts">
2
- import { definePageMeta, navigateTo, useRoute } from "#imports";
3
- import { useDocsEntryPath } from "#ginko-docs/features/docs/composables/useDocsEntryPath";
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 route = useRoute();
8
- const entryPath = await useDocsEntryPath();
9
-
10
- // Redirect the bare docs root to the first documentation page. Guard against
11
- // self-redirects when no docs pages exist (entry path falls back to the root).
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" class="flex-1 px-4 py-12 md:px-6">
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>