@lupinum/ginko-docs 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (240) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/app/app.config.ts +49 -0
  4. package/app/app.vue +71 -0
  5. package/app/assets/css/prose.css +1154 -0
  6. package/app/assets/css/tailwind.css +365 -0
  7. package/app/components/OgImage/GinkoDocs.satori.vue +117 -0
  8. package/app/components/content/DocumentPageShell.vue +55 -0
  9. package/app/components/content/Feedback.vue +97 -0
  10. package/app/components/content/PageMarkdownCopy.vue +102 -0
  11. package/app/components/mdc/MdcAccordion.vue +64 -0
  12. package/app/components/mdc/MdcAccordionItem.vue +51 -0
  13. package/app/components/mdc/MdcAlert.vue +34 -0
  14. package/app/components/mdc/MdcAside.vue +27 -0
  15. package/app/components/mdc/MdcCallout.vue +62 -0
  16. package/app/components/mdc/MdcCard.vue +186 -0
  17. package/app/components/mdc/MdcCardGroup.vue +47 -0
  18. package/app/components/mdc/MdcCenter.vue +71 -0
  19. package/app/components/mdc/MdcCodeGroup.vue +193 -0
  20. package/app/components/mdc/MdcColumn.vue +70 -0
  21. package/app/components/mdc/MdcDocImg.vue +25 -0
  22. package/app/components/mdc/MdcDropcap.vue +84 -0
  23. package/app/components/mdc/MdcError.vue +16 -0
  24. package/app/components/mdc/MdcField.vue +62 -0
  25. package/app/components/mdc/MdcFieldGroup.vue +18 -0
  26. package/app/components/mdc/MdcFigure.vue +51 -0
  27. package/app/components/mdc/MdcIdea.vue +16 -0
  28. package/app/components/mdc/MdcInfo.vue +16 -0
  29. package/app/components/mdc/MdcLayout.vue +57 -0
  30. package/app/components/mdc/MdcNote.vue +16 -0
  31. package/app/components/mdc/MdcPassage.vue +32 -0
  32. package/app/components/mdc/MdcQuiz.vue +281 -0
  33. package/app/components/mdc/MdcQuizOption.vue +91 -0
  34. package/app/components/mdc/MdcQuizQuestion.vue +226 -0
  35. package/app/components/mdc/MdcReadMore.vue +90 -0
  36. package/app/components/mdc/MdcReadMoreGroup.vue +18 -0
  37. package/app/components/mdc/MdcShortcut.vue +46 -0
  38. package/app/components/mdc/MdcStep.vue +42 -0
  39. package/app/components/mdc/MdcSteps.vue +25 -0
  40. package/app/components/mdc/MdcSuccess.vue +16 -0
  41. package/app/components/mdc/MdcTab.vue +17 -0
  42. package/app/components/mdc/MdcTabs.vue +132 -0
  43. package/app/components/mdc/MdcTimeline.vue +14 -0
  44. package/app/components/mdc/MdcTimelineItem.vue +49 -0
  45. package/app/components/mdc/MdcWarning.vue +16 -0
  46. package/app/components/mdc/icons.ts +10 -0
  47. package/app/components/prose/ProseA.vue +27 -0
  48. package/app/components/prose/ProseBlockquote.vue +14 -0
  49. package/app/components/prose/ProseCode.vue +14 -0
  50. package/app/components/prose/ProseEm.vue +14 -0
  51. package/app/components/prose/ProseH1.vue +18 -0
  52. package/app/components/prose/ProseH2.vue +24 -0
  53. package/app/components/prose/ProseH3.vue +24 -0
  54. package/app/components/prose/ProseH4.vue +14 -0
  55. package/app/components/prose/ProseHr.vue +12 -0
  56. package/app/components/prose/ProseImg.vue +120 -0
  57. package/app/components/prose/ProseLi.vue +14 -0
  58. package/app/components/prose/ProseOl.vue +14 -0
  59. package/app/components/prose/ProseP.vue +14 -0
  60. package/app/components/prose/ProsePre.vue +217 -0
  61. package/app/components/prose/ProseStrong.vue +14 -0
  62. package/app/components/prose/ProseTable.vue +16 -0
  63. package/app/components/prose/ProseTbody.vue +14 -0
  64. package/app/components/prose/ProseTd.vue +14 -0
  65. package/app/components/prose/ProseTh.vue +14 -0
  66. package/app/components/prose/ProseThead.vue +14 -0
  67. package/app/components/prose/ProseTr.vue +14 -0
  68. package/app/components/prose/ProseUl.vue +14 -0
  69. package/app/components/site/ModeToggle.vue +123 -0
  70. package/app/components/site/SiteBanner.vue +56 -0
  71. package/app/components/site/SiteFooter.vue +80 -0
  72. package/app/components/site/SiteHeader.vue +250 -0
  73. package/app/components/site/SiteInteractionLayer.vue +35 -0
  74. package/app/components/site/SiteLocaleSwitcher.vue +268 -0
  75. package/app/components/site/SiteLogoMark.vue +12 -0
  76. package/app/components/site/SiteSkipLink.vue +14 -0
  77. package/app/components/ui/accordion/Accordion.vue +15 -0
  78. package/app/components/ui/accordion/AccordionContent.vue +23 -0
  79. package/app/components/ui/accordion/AccordionItem.vue +24 -0
  80. package/app/components/ui/accordion/AccordionTrigger.vue +35 -0
  81. package/app/components/ui/accordion/index.ts +4 -0
  82. package/app/components/ui/alert/Alert.vue +21 -0
  83. package/app/components/ui/alert/AlertDescription.vue +22 -0
  84. package/app/components/ui/alert/AlertTitle.vue +22 -0
  85. package/app/components/ui/alert/index.ts +34 -0
  86. package/app/components/ui/badge/Badge.vue +28 -0
  87. package/app/components/ui/badge/index.ts +24 -0
  88. package/app/components/ui/button/Button.vue +31 -0
  89. package/app/components/ui/button/index.ts +35 -0
  90. package/app/components/ui/card/Card.vue +16 -0
  91. package/app/components/ui/card/CardAction.vue +17 -0
  92. package/app/components/ui/card/CardContent.vue +14 -0
  93. package/app/components/ui/card/CardDescription.vue +14 -0
  94. package/app/components/ui/card/CardFooter.vue +14 -0
  95. package/app/components/ui/card/CardHeader.vue +22 -0
  96. package/app/components/ui/card/CardTitle.vue +14 -0
  97. package/app/components/ui/card/card-variants.ts +15 -0
  98. package/app/components/ui/card/index.ts +8 -0
  99. package/app/components/ui/checkbox/Checkbox.vue +35 -0
  100. package/app/components/ui/checkbox/index.ts +1 -0
  101. package/app/components/ui/collapsible/Collapsible.vue +15 -0
  102. package/app/components/ui/collapsible/CollapsibleContent.vue +25 -0
  103. package/app/components/ui/collapsible/CollapsibleTrigger.vue +12 -0
  104. package/app/components/ui/collapsible/index.ts +3 -0
  105. package/app/components/ui/dialog/Dialog.vue +15 -0
  106. package/app/components/ui/dialog/DialogClose.vue +12 -0
  107. package/app/components/ui/dialog/DialogContent.vue +69 -0
  108. package/app/components/ui/dialog/DialogDescription.vue +21 -0
  109. package/app/components/ui/dialog/DialogFooter.vue +15 -0
  110. package/app/components/ui/dialog/DialogHeader.vue +12 -0
  111. package/app/components/ui/dialog/DialogOverlay.vue +26 -0
  112. package/app/components/ui/dialog/DialogPortal.vue +12 -0
  113. package/app/components/ui/dialog/DialogTitle.vue +21 -0
  114. package/app/components/ui/dialog/DialogTrigger.vue +12 -0
  115. package/app/components/ui/dialog/index.ts +10 -0
  116. package/app/components/ui/dropdown-menu/DropdownMenu.vue +15 -0
  117. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +36 -0
  118. package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +40 -0
  119. package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +12 -0
  120. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +41 -0
  121. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +25 -0
  122. package/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +15 -0
  123. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +37 -0
  124. package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +23 -0
  125. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -0
  126. package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +15 -0
  127. package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +29 -0
  128. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +31 -0
  129. package/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue +14 -0
  130. package/app/components/ui/dropdown-menu/index.ts +16 -0
  131. package/app/components/ui/input/Input.vue +23 -0
  132. package/app/components/ui/input/index.ts +1 -0
  133. package/app/components/ui/kbd/Kbd.vue +23 -0
  134. package/app/components/ui/kbd/KbdGroup.vue +14 -0
  135. package/app/components/ui/kbd/index.ts +22 -0
  136. package/app/components/ui/label/Label.vue +14 -0
  137. package/app/components/ui/label/index.ts +1 -0
  138. package/app/components/ui/scroll-area/ScrollArea.vue +39 -0
  139. package/app/components/ui/scroll-area/ScrollBar.vue +33 -0
  140. package/app/components/ui/scroll-area/index.ts +2 -0
  141. package/app/components/ui/separator/Separator.vue +27 -0
  142. package/app/components/ui/separator/index.ts +1 -0
  143. package/app/components/ui/sheet/Sheet.vue +15 -0
  144. package/app/components/ui/sheet/SheetClose.vue +12 -0
  145. package/app/components/ui/sheet/SheetContent.vue +59 -0
  146. package/app/components/ui/sheet/SheetDescription.vue +21 -0
  147. package/app/components/ui/sheet/SheetFooter.vue +12 -0
  148. package/app/components/ui/sheet/SheetHeader.vue +12 -0
  149. package/app/components/ui/sheet/SheetOverlay.vue +26 -0
  150. package/app/components/ui/sheet/SheetTitle.vue +21 -0
  151. package/app/components/ui/sheet/SheetTrigger.vue +12 -0
  152. package/app/components/ui/sheet/index.ts +8 -0
  153. package/app/components/ui/switch/Switch.vue +39 -0
  154. package/app/components/ui/switch/index.ts +1 -0
  155. package/app/components/ui/tabs/Tabs.vue +24 -0
  156. package/app/components/ui/tabs/TabsContent.vue +21 -0
  157. package/app/components/ui/tabs/TabsList.vue +26 -0
  158. package/app/components/ui/tabs/TabsTrigger.vue +28 -0
  159. package/app/components/ui/tabs/index.ts +4 -0
  160. package/app/components/ui/textarea/Textarea.vue +31 -0
  161. package/app/components/ui/textarea/index.ts +1 -0
  162. package/app/composables/site-navigation.utils.ts +80 -0
  163. package/app/composables/useCanonicalUrl.ts +9 -0
  164. package/app/composables/useContentRouteAlternates.ts +43 -0
  165. package/app/composables/useGinkoAnalytics.ts +37 -0
  166. package/app/composables/useGinkoDocsConfig.ts +6 -0
  167. package/app/composables/useGinkoOgImage.ts +20 -0
  168. package/app/composables/useLocalizedPath.ts +12 -0
  169. package/app/composables/useLocalizedRouteSwitch.ts +31 -0
  170. package/app/composables/usePageMarkdownActions.ts +79 -0
  171. package/app/composables/useSchemaJsonLd.ts +25 -0
  172. package/app/composables/useSiteNavigation.ts +86 -0
  173. package/app/config/site.utils.ts +36 -0
  174. package/app/error.vue +125 -0
  175. package/app/features/docs/components/DocsContributeLinks.vue +65 -0
  176. package/app/features/docs/components/DocsMobileToc.vue +104 -0
  177. package/app/features/docs/components/DocsPageContent.vue +188 -0
  178. package/app/features/docs/components/DocsPageNav.vue +57 -0
  179. package/app/features/docs/components/DocsSidebar.vue +129 -0
  180. package/app/features/docs/components/DocsSidebarDropdown.vue +70 -0
  181. package/app/features/docs/components/DocsSidebarItem.vue +169 -0
  182. package/app/features/docs/components/DocsSidebarList.vue +35 -0
  183. package/app/features/docs/components/DocsSidebarTabs.vue +35 -0
  184. package/app/features/docs/components/DocsToc.vue +60 -0
  185. package/app/features/docs/composables/useDocsEntryPath.ts +13 -0
  186. package/app/features/docs/composables/useDocsNavigation.ts +29 -0
  187. package/app/features/docs/composables/useDocsNavigationData.ts +15 -0
  188. package/app/features/docs/composables/useScrollspy.ts +67 -0
  189. package/app/features/docs/docs-navigation.ts +170 -0
  190. package/app/features/search/command-center.ts +156 -0
  191. package/app/features/search/components/SiteCommandCenter.vue +323 -0
  192. package/app/features/search/components/SiteSearchPageHighlight.vue +124 -0
  193. package/app/features/search/useCommandCenter.ts +300 -0
  194. package/app/layouts/blog.vue +15 -0
  195. package/app/layouts/default.vue +12 -0
  196. package/app/layouts/docs.vue +20 -0
  197. package/app/lib/errors.ts +84 -0
  198. package/app/lib/schema-org.ts +38 -0
  199. package/app/lib/utils.ts +7 -0
  200. package/app/pages/blog/[slug].vue +211 -0
  201. package/app/pages/blog/index.vue +106 -0
  202. package/app/pages/docs/[...slug].vue +10 -0
  203. package/app/pages/docs/index.vue +23 -0
  204. package/app/pages/index.vue +140 -0
  205. package/app/plugins/ssr-width.ts +6 -0
  206. package/app/utils/content.ts +56 -0
  207. package/app/utils/index.ts +1 -0
  208. package/components.ts +9 -0
  209. package/content.js +175 -0
  210. package/content.ts +148 -0
  211. package/i18n/i18n.config.ts +10 -0
  212. package/i18n/locales.ts +81 -0
  213. package/i18n/messages/global/banner.ts +8 -0
  214. package/i18n/messages/global/blog.ts +14 -0
  215. package/i18n/messages/global/command.ts +44 -0
  216. package/i18n/messages/global/docs.ts +32 -0
  217. package/i18n/messages/global/errors.ts +36 -0
  218. package/i18n/messages/global/feedback.ts +11 -0
  219. package/i18n/messages/global/index.ts +21 -0
  220. package/i18n/messages/global/nav.ts +18 -0
  221. package/i18n/messages/global/site.ts +4 -0
  222. package/i18n/messages/global/theme.ts +7 -0
  223. package/i18n/messages/index.ts +20 -0
  224. package/i18n/messages/localize.ts +35 -0
  225. package/i18n/messages/source.ts +5 -0
  226. package/i18n/routes.ts +33 -0
  227. package/icon-bundle.ts +141 -0
  228. package/index.d.ts +13 -0
  229. package/modules/feature-routing.ts +32 -0
  230. package/nuxt.config.ts +194 -0
  231. package/package.json +91 -0
  232. package/runtime/markdown/inline-code-highlight.ts +155 -0
  233. package/runtime/server/agent-markdown.ts +21 -0
  234. package/runtime/utils.ts +7 -0
  235. package/server/mcp/tools/get-page.ts +14 -0
  236. package/server/mcp/tools/list-pages.ts +10 -0
  237. package/server/plugins/agent-markdown.ts +6 -0
  238. package/shared/route-slugs.ts +5 -0
  239. package/shared/types/app-config.ts +100 -0
  240. package/tags.ts +204 -0
@@ -0,0 +1,300 @@
1
+ import { useLocalStorage } from "@vueuse/core";
2
+ import { useContentSearch } from "@lupinum/ginko-content/client";
3
+ import { computed, watch } from "vue";
4
+ import { navigateTo, useI18n, useRoute, useState } from "#imports";
5
+ import { useLocalizedPath } from "#ginko-docs/composables/useLocalizedPath";
6
+ import { useSiteNavigation } from "#ginko-docs/composables/useSiteNavigation";
7
+ import {
8
+ getDocsNavigationGroups,
9
+ type DocsNavigationItem,
10
+ } from "#ginko-docs/features/docs/docs-navigation";
11
+ import { useDocsEntryPath } from "#ginko-docs/features/docs/composables/useDocsEntryPath";
12
+ import { useDocsNavigation } from "#ginko-docs/features/docs/composables/useDocsNavigation";
13
+ import {
14
+ contentSearchGroup,
15
+ dedupeCommandCenterItems,
16
+ groupCommandCenterItems,
17
+ PAGE_HIGHLIGHT_STORAGE_KEY,
18
+ rememberRecentItem,
19
+ resolveRecentItems,
20
+ type CommandCenterItem,
21
+ type StoredRecentItem,
22
+ } from "#ginko-docs/features/search/command-center";
23
+
24
+ type ContentSearchHit = {
25
+ collection: string;
26
+ path: string;
27
+ title: string;
28
+ excerpt?: string;
29
+ anchor?: string;
30
+ };
31
+
32
+ const MAX_DEFAULT_PAGE_ITEMS = 6;
33
+ const MAX_DEFAULT_DOC_ITEMS = 3;
34
+
35
+ export function useCommandCenterState() {
36
+ const open = useState<boolean>("site-command-center-open", () => false);
37
+ const query = useState<string>("site-command-center-query", () => "");
38
+
39
+ function closeCommandCenter() {
40
+ open.value = false;
41
+ query.value = "";
42
+ }
43
+
44
+ function openCommandCenter(initialQuery = "") {
45
+ query.value = initialQuery;
46
+ open.value = true;
47
+ }
48
+
49
+ return {
50
+ open,
51
+ query,
52
+ openCommandCenter,
53
+ closeCommandCenter,
54
+ };
55
+ }
56
+
57
+ function flattenNavItems(items: DocsNavigationItem[]): DocsNavigationItem[] {
58
+ const output: DocsNavigationItem[] = [];
59
+ for (const item of items) {
60
+ output.push(item);
61
+ if (item.children.length > 0) {
62
+ output.push(...flattenNavItems(item.children));
63
+ }
64
+ }
65
+ return output;
66
+ }
67
+
68
+ export async function useCommandCenter() {
69
+ const route = useRoute();
70
+ const { locale, t } = useI18n();
71
+ const localizedPath = useLocalizedPath();
72
+ const {
73
+ open,
74
+ query,
75
+ openCommandCenter,
76
+ closeCommandCenter: closeState,
77
+ } = useCommandCenterState();
78
+ const recentSelections = useLocalStorage<StoredRecentItem[]>("site-command-center-recent", []);
79
+
80
+ const { mainNav, footerNav, socialLinks } = useSiteNavigation();
81
+ // Start every context-bound composable before the first await. Calling a
82
+ // Nuxt/i18n composable after an awaited operation loses the setup context.
83
+ const docsEntryPathResult = useDocsEntryPath();
84
+ const docsNavigationResult = useDocsNavigation();
85
+ const contentSearchResult = useContentSearch({
86
+ limit: 12,
87
+ locale: () => locale.value,
88
+ });
89
+ const [docsEntryPath, { sections }, { query: searchQuery, results: searchResults }] =
90
+ await Promise.all([docsEntryPathResult, docsNavigationResult, contentSearchResult]);
91
+
92
+ const contentSearchItems = computed<CommandCenterItem[]>(() =>
93
+ searchResults.value.map((hit: ContentSearchHit) => {
94
+ const group = contentSearchGroup(hit.collection);
95
+ const href = hit.anchor ? `${hit.path}#${hit.anchor}` : hit.path;
96
+
97
+ return {
98
+ id: `search-${href}`,
99
+ title: hit.title,
100
+ subtitle: hit.excerpt,
101
+ 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"),
106
+ };
107
+ }),
108
+ );
109
+
110
+ const pageItems = computed<CommandCenterItem[]>(() => {
111
+ const featured: CommandCenterItem[] = [
112
+ {
113
+ id: "page-home",
114
+ title: t("command.pages.home.title"),
115
+ subtitle: t("command.pages.home.description"),
116
+ href: localizedPath("home"),
117
+ group: "pages",
118
+ icon: "lucide:home",
119
+ keywords: ["landing", "overview", "hero"],
120
+ },
121
+ ];
122
+
123
+ const fromMainNav = mainNav.value.map<CommandCenterItem>((item) => ({
124
+ id: `page-main-${item.href}`,
125
+ title: item.label,
126
+ subtitle: t("command.pages.primary"),
127
+ href: item.href,
128
+ group: "pages",
129
+ icon: item.href === localizedPath("blog") ? "lucide:newspaper" : "lucide:file-text",
130
+ keywords: ["navigation", item.label],
131
+ }));
132
+
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]
138
+ .filter((item) => !(item as { external?: boolean }).external)
139
+ .map<CommandCenterItem>((item) => ({
140
+ id: `page-footer-${item.href}`,
141
+ title: item.label,
142
+ subtitle: t("command.pages.secondary"),
143
+ href: item.href,
144
+ group: "pages",
145
+ icon: "lucide:file-text",
146
+ keywords: ["footer", item.label],
147
+ }));
148
+
149
+ return dedupeCommandCenterItems([...featured, ...fromMainNav, ...fromFooter]);
150
+ });
151
+
152
+ const docsItems = computed<CommandCenterItem[]>(() => {
153
+ const navigationEntries = sections.value.flatMap((section) =>
154
+ getDocsNavigationGroups(section).flatMap((group) =>
155
+ flattenNavItems(group.items)
156
+ .filter((item) => item.path)
157
+ .map<CommandCenterItem>((item) => ({
158
+ id: `doc-${item.path}`,
159
+ title: item.title,
160
+ subtitle: group.title ?? section.title ?? t("docs.label"),
161
+ href: item.path,
162
+ group: "docs_nav",
163
+ icon: item.icon ?? "lucide:book-open",
164
+ keywords: [section.title ?? "", group.title ?? "", item.badge ?? "", item.title],
165
+ badge: item.badge,
166
+ })),
167
+ ),
168
+ );
169
+
170
+ const sectionIndexEntries = sections.value.flatMap<CommandCenterItem>((section) =>
171
+ section.path && section.title
172
+ ? [
173
+ {
174
+ id: `doc-section-${section.path}`,
175
+ title: section.title,
176
+ subtitle: t("command.pages.documentation"),
177
+ href: section.path,
178
+ group: "docs",
179
+ icon: section.icon ?? "lucide:book-open",
180
+ keywords: [section.title],
181
+ },
182
+ ]
183
+ : [],
184
+ );
185
+
186
+ return dedupeCommandCenterItems([...sectionIndexEntries, ...navigationEntries]);
187
+ });
188
+
189
+ const actionItems = computed<CommandCenterItem[]>(() => {
190
+ const github = socialLinks.value.find((item) => item.icon === "lucide:github");
191
+ return github
192
+ ? [
193
+ {
194
+ id: "action-github",
195
+ title: t("command.actions.github.title"),
196
+ subtitle: t("command.actions.github.description"),
197
+ href: github.href,
198
+ external: true,
199
+ group: "actions" as const,
200
+ icon: "lucide:github",
201
+ keywords: ["repo", "source", "code"],
202
+ },
203
+ ]
204
+ : [];
205
+ });
206
+
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
+ ]),
218
+ );
219
+
220
+ const recentItems = computed<CommandCenterItem[]>(() => {
221
+ if (query.value) return [];
222
+
223
+ return resolveRecentItems(recentSelections.value, allItems.value);
224
+ });
225
+
226
+ const defaultItems = computed<CommandCenterItem[]>(() => {
227
+ 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);
231
+
232
+ return dedupeCommandCenterItems([
233
+ ...pageItems.value.slice(0, MAX_DEFAULT_PAGE_ITEMS),
234
+ ...docSectionItems,
235
+ ...actionItems.value,
236
+ ]).filter((item) => !recentSourceIds.has(item.id));
237
+ });
238
+
239
+ const groupedItems = computed(() => {
240
+ const candidates = query.value.trim()
241
+ ? allItems.value
242
+ : [...recentItems.value, ...defaultItems.value];
243
+ return groupCommandCenterItems(candidates, query.value, (group) =>
244
+ t(`command.groups.${group}`),
245
+ );
246
+ });
247
+
248
+ function rememberSelection(item: CommandCenterItem) {
249
+ recentSelections.value = rememberRecentItem(item, recentSelections.value);
250
+ }
251
+
252
+ function closeCommandCenter() {
253
+ closeState();
254
+ searchQuery.value = "";
255
+ }
256
+
257
+ async function selectItem(item: CommandCenterItem) {
258
+ const submittedQuery = query.value.trim();
259
+ rememberSelection(item);
260
+ closeCommandCenter();
261
+
262
+ if (!item.href) return;
263
+
264
+ if (item.external) {
265
+ if (import.meta.client) {
266
+ window.open(item.href, "_blank", "noopener,noreferrer");
267
+ }
268
+ return;
269
+ }
270
+
271
+ if (submittedQuery && import.meta.client) {
272
+ sessionStorage.setItem(
273
+ PAGE_HIGHLIGHT_STORAGE_KEY,
274
+ JSON.stringify({ href: item.href, query: submittedQuery }),
275
+ );
276
+ }
277
+
278
+ await navigateTo(item.href);
279
+ }
280
+
281
+ watch(query, (value) => {
282
+ searchQuery.value = value;
283
+ });
284
+
285
+ watch(
286
+ () => route.fullPath,
287
+ () => {
288
+ closeCommandCenter();
289
+ },
290
+ );
291
+
292
+ return {
293
+ open,
294
+ query,
295
+ groupedItems,
296
+ openCommandCenter,
297
+ closeCommandCenter,
298
+ selectItem,
299
+ };
300
+ }
@@ -0,0 +1,15 @@
1
+ <template>
2
+ <div class="flex min-h-dvh flex-col bg-background text-foreground selection:bg-primary/15">
3
+ <SiteSkipLink />
4
+ <SiteBanner />
5
+ <SiteHeader />
6
+ <main
7
+ id="main-content"
8
+ class="mx-auto w-full max-w-screen-2xl flex-1 px-4 pt-6 pb-12 md:px-6 md:pt-8 md:pb-16"
9
+ >
10
+ <slot />
11
+ </main>
12
+ <SiteFooter />
13
+ <SiteInteractionLayer />
14
+ </div>
15
+ </template>
@@ -0,0 +1,12 @@
1
+ <template>
2
+ <div class="flex min-h-dvh flex-col bg-background text-foreground">
3
+ <SiteSkipLink />
4
+ <SiteBanner landing />
5
+ <SiteHeader />
6
+ <main id="main-content" class="flex-1">
7
+ <slot />
8
+ </main>
9
+ <SiteFooter />
10
+ <SiteInteractionLayer />
11
+ </div>
12
+ </template>
@@ -0,0 +1,20 @@
1
+ <template>
2
+ <div class="flex min-h-dvh flex-col bg-background text-foreground selection:bg-primary/15">
3
+ <SiteSkipLink />
4
+ <SiteBanner />
5
+ <SiteHeader />
6
+
7
+ <div class="mx-auto flex w-full max-w-screen-2xl min-w-0 flex-1">
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"
10
+ >
11
+ <DocsSidebar />
12
+ </div>
13
+
14
+ <slot />
15
+ </div>
16
+
17
+ <SiteFooter />
18
+ <SiteInteractionLayer />
19
+ </div>
20
+ </template>
@@ -0,0 +1,84 @@
1
+ export type ErrorKind = "accessDenied" | "notFound" | "server" | "unavailable";
2
+
3
+ export interface NormalizedAppError {
4
+ kind: ErrorKind;
5
+ message: string;
6
+ statusCode: number;
7
+ statusMessage: string;
8
+ }
9
+
10
+ type ErrorLike = {
11
+ data?: unknown;
12
+ fatal?: boolean;
13
+ message?: string;
14
+ name?: string;
15
+ status?: number;
16
+ 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
+ };
31
+
32
+ function isObject(value: unknown): value is Record<string, unknown> {
33
+ return Boolean(value && typeof value === "object");
34
+ }
35
+
36
+ function toErrorLike(error: unknown): ErrorLike {
37
+ return isObject(error) ? error : {};
38
+ }
39
+
40
+ function toStatusCode(error: ErrorLike): number {
41
+ const status = error.statusCode ?? error.status;
42
+ return typeof status === "number" && Number.isInteger(status) ? status : 500;
43
+ }
44
+
45
+ function errorKind(statusCode: number): ErrorKind {
46
+ if (statusCode === 404) return "notFound";
47
+ if (statusCode === 403) return "accessDenied";
48
+ if (statusCode >= 500) return "server";
49
+ return "unavailable";
50
+ }
51
+
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
+ export function normalizeAppError(error: unknown): NormalizedAppError {
60
+ const source = toErrorLike(error);
61
+ 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;
84
+ }
@@ -0,0 +1,38 @@
1
+ import type { LocaleCode } from "../../i18n/locales";
2
+
3
+ function absoluteUrl(path: string, siteUrl: string) {
4
+ return new URL(path, siteUrl).toString();
5
+ }
6
+
7
+ export function createBreadcrumbSchema(
8
+ items: Array<{ name: string; path: string }>,
9
+ siteUrl: string,
10
+ ) {
11
+ return {
12
+ "@type": "BreadcrumbList",
13
+ itemListElement: items.map((item, index) => ({
14
+ "@type": "ListItem",
15
+ position: index + 1,
16
+ name: item.name,
17
+ item: absoluteUrl(item.path, siteUrl),
18
+ })),
19
+ };
20
+ }
21
+
22
+ export function createArticleSchema(
23
+ article: { date: string; description: string; title: string },
24
+ url: string,
25
+ authorName: string,
26
+ locale: LocaleCode,
27
+ ) {
28
+ return {
29
+ "@type": "TechArticle",
30
+ headline: article.title,
31
+ description: article.description,
32
+ datePublished: article.date,
33
+ dateModified: article.date,
34
+ url,
35
+ inLanguage: locale,
36
+ author: authorName ? { name: authorName } : undefined,
37
+ };
38
+ }
@@ -0,0 +1,7 @@
1
+ import type { ClassValue } from "clsx";
2
+ import { clsx } from "clsx";
3
+ import { twMerge } from "tailwind-merge";
4
+
5
+ export function cn(...inputs: ClassValue[]) {
6
+ return twMerge(clsx(inputs));
7
+ }
@@ -0,0 +1,211 @@
1
+ <script setup lang="ts">
2
+ import { Badge } from "#ginko-docs/components/ui/badge";
3
+ import ContentFeedback from "#ginko-docs/components/content/Feedback.vue";
4
+ import { Button } from "#ginko-docs/components/ui/button";
5
+ import { createContentNotFoundError } from "#ginko-docs/lib/errors";
6
+ import { createArticleSchema, createBreadcrumbSchema } from "#ginko-docs/lib/schema-org";
7
+ import { getLocalizedSiteText } from "#ginko-docs/config/site.utils";
8
+ import { filterTocByDepth, flattenTocLinks, formatContentDate } from "#ginko-docs/utils/content";
9
+ import { computed, watch } from "vue";
10
+ import {
11
+ definePageMeta,
12
+ useAppConfig,
13
+ useContentPage,
14
+ useHead,
15
+ useI18n,
16
+ useRoute,
17
+ useSeoMeta,
18
+ } from "#imports";
19
+ import { useLocalizedPath } from "#ginko-docs/composables/useLocalizedPath";
20
+ import { useCanonicalUrl } from "#ginko-docs/composables/useCanonicalUrl";
21
+ import { useGinkoOgImage } from "#ginko-docs/composables/useGinkoOgImage";
22
+ import { useSchemaJsonLd } from "#ginko-docs/composables/useSchemaJsonLd";
23
+ import DocumentPageShell from "#ginko-docs/components/content/DocumentPageShell.vue";
24
+ import PageMarkdownCopy from "#ginko-docs/components/content/PageMarkdownCopy.vue";
25
+ import { syncContentRouteAlternates } from "#ginko-docs/composables/useContentRouteAlternates";
26
+
27
+ definePageMeta({ layout: "blog" });
28
+
29
+ const { locale, t } = useI18n();
30
+ const config = useAppConfig().ginkoDocs;
31
+ const route = useRoute();
32
+ const localizedPath = useLocalizedPath();
33
+ const {
34
+ page: post,
35
+ previous,
36
+ next,
37
+ error,
38
+ } = await useContentPage("blog", {
39
+ locale: () => locale.value,
40
+ fallback: true,
41
+ populate: { author: "authors" },
42
+ surround: true,
43
+ });
44
+ if (error.value) throw error.value;
45
+ if (!post.value) throw createContentNotFoundError();
46
+ syncContentRouteAlternates(post);
47
+
48
+ const pageTitle = computed(() => post.value?.title ?? t("blog.fallbackTitle"));
49
+ const pageDescription = computed(() => post.value?.description ?? t("blog.fallbackDescription"));
50
+ const canonicalUrl = useCanonicalUrl();
51
+ const tocItems = computed(() =>
52
+ filterTocByDepth(flattenTocLinks(post.value?.body?.toc?.links), config.toc?.depth ?? 3),
53
+ );
54
+ const formattedDate = computed(() => formatContentDate(post.value?.date, locale.value));
55
+ const siteName = computed(() => getLocalizedSiteText(config.site.name, locale.value));
56
+ const articleAuthor = computed(() => post.value?.author?.name ?? siteName.value);
57
+ const suggestions = computed(() => [previous.value, next.value].filter((entry) => entry !== null));
58
+
59
+ useSeoMeta({
60
+ title: computed(() => `${pageTitle.value} - ${siteName.value}`),
61
+ description: pageDescription,
62
+ ogTitle: computed(() => `${pageTitle.value} - ${siteName.value}`),
63
+ ogDescription: pageDescription,
64
+ ogUrl: canonicalUrl,
65
+ twitterCard: "summary_large_image",
66
+ twitterTitle: computed(() => `${pageTitle.value} - ${siteName.value}`),
67
+ twitterDescription: pageDescription,
68
+ });
69
+
70
+ // Social card with the raw post title (no site-name suffix baked in).
71
+ useGinkoOgImage({
72
+ title: pageTitle.value,
73
+ description: pageDescription.value,
74
+ locale: locale.value,
75
+ });
76
+
77
+ useHead(() => ({
78
+ link: [{ key: "canonical", rel: "canonical", href: canonicalUrl.value }],
79
+ meta: [{ property: "og:type", content: "article" }],
80
+ }));
81
+
82
+ useSchemaJsonLd(() =>
83
+ post.value
84
+ ? [
85
+ createBreadcrumbSchema(
86
+ [
87
+ { name: t("blog.title"), path: localizedPath("blog") },
88
+ { name: post.value.title, path: post.value.route.resolvedPath },
89
+ ],
90
+ config.site.url,
91
+ ),
92
+ createArticleSchema(
93
+ {
94
+ date: post.value.date,
95
+ description: post.value.description,
96
+ title: post.value.title,
97
+ },
98
+ canonicalUrl.value,
99
+ articleAuthor.value,
100
+ locale.value,
101
+ ),
102
+ ]
103
+ : [],
104
+ );
105
+ </script>
106
+
107
+ <template>
108
+ <DocumentPageShell :padded="false">
109
+ <Button as-child variant="ghost" class="mb-8 -ml-2 text-muted-foreground">
110
+ <NuxtLink :to="localizedPath('blog')">
111
+ <Icon name="lucide:arrow-left" class="size-4" aria-hidden="true" />
112
+ {{ t("blog.back") }}
113
+ </NuxtLink>
114
+ </Button>
115
+
116
+ <div v-if="post" class="mb-12">
117
+ <div class="mb-8 flex items-center justify-between gap-4">
118
+ <div class="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
119
+ <Badge v-if="post.badge" variant="secondary">{{ post.badge }}</Badge>
120
+ <span class="text-sm text-muted-foreground"
121
+ >{{ formattedDate
122
+ }}<template v-if="post.readingTime"> · {{ post.readingTime }}</template></span
123
+ >
124
+ <span class="hidden text-sm text-muted-foreground sm:inline" aria-hidden="true"> · </span>
125
+ <span class="inline-flex items-center gap-2 text-sm text-muted-foreground">
126
+ <img
127
+ v-if="post.author?.avatar"
128
+ :src="post.author.avatar"
129
+ :alt="articleAuthor"
130
+ class="size-5 shrink-0 rounded-full border border-border object-cover"
131
+ loading="lazy"
132
+ />
133
+ {{ articleAuthor }}
134
+ </span>
135
+ </div>
136
+ <PageMarkdownCopy />
137
+ </div>
138
+ <h1
139
+ class="font-serif text-3xl leading-tight font-semibold tracking-tight text-foreground sm:text-4xl md:text-5xl"
140
+ >
141
+ {{ post.title }}
142
+ </h1>
143
+ <p class="mt-6 text-lg leading-8 text-muted-foreground sm:text-xl">{{ post.description }}</p>
144
+ </div>
145
+
146
+ <nav
147
+ v-if="tocItems.length"
148
+ :aria-label="t('blog.toc')"
149
+ class="not-prose mx-auto mb-12 max-w-[44rem] border-y border-border py-7"
150
+ >
151
+ <p class="mb-6 text-sm font-semibold text-foreground">{{ t("blog.toc") }}</p>
152
+ <ol class="grid gap-x-12 gap-y-2 sm:grid-cols-2" role="list">
153
+ <li v-for="(item, index) in tocItems" :key="item.id">
154
+ <a
155
+ :href="`#${item.id}`"
156
+ class="group grid grid-cols-[2rem_minmax(0,1fr)] gap-3 rounded-md py-1.5 transition-colors hover:bg-accent/45 sm:-mx-2 sm:px-2"
157
+ >
158
+ <span
159
+ class="pt-0.5 font-mono text-xs leading-6 text-muted-foreground tabular-nums transition-colors group-hover:text-foreground/70"
160
+ >
161
+ {{ String(index + 1).padStart(2, "0") }}
162
+ </span>
163
+ <span
164
+ class="text-sm leading-6 text-foreground/80 transition-colors group-hover:text-foreground"
165
+ :class="
166
+ item.depth === 3
167
+ ? 'pl-3 text-muted-foreground'
168
+ : (item.depth ?? 2) >= 4
169
+ ? 'pl-6 text-muted-foreground'
170
+ : ''
171
+ "
172
+ >
173
+ {{ item.label }}
174
+ </span>
175
+ </a>
176
+ </li>
177
+ </ol>
178
+ </nav>
179
+
180
+ <div v-if="post" class="content-prose mx-auto max-w-[42rem]">
181
+ <ContentRenderer :value="post" />
182
+ </div>
183
+
184
+ <ContentFeedback :label="t('feedback.articleLabel')" />
185
+
186
+ <div v-if="suggestions.length" class="mt-14">
187
+ <h2 class="mb-6 text-sm font-semibold tracking-wider text-muted-foreground uppercase">
188
+ {{ t("blog.moreArticles") }}
189
+ </h2>
190
+ <div class="divide-y divide-border border-y border-border">
191
+ <NuxtLink
192
+ v-for="p in suggestions"
193
+ :key="p.path"
194
+ :to="p.path"
195
+ class="group grid grid-cols-[minmax(0,1fr)_1.5rem] items-center gap-4 py-6 transition-colors hover:bg-accent/35 sm:px-2"
196
+ >
197
+ <h3
198
+ class="font-serif text-lg leading-snug font-semibold text-foreground transition-colors group-hover:text-primary"
199
+ >
200
+ {{ p.title }}
201
+ </h3>
202
+ <Icon
203
+ name="lucide:arrow-right"
204
+ class="hidden size-4 self-center text-muted-foreground transition-all group-hover:translate-x-0.5 group-hover:text-primary sm:block"
205
+ aria-hidden="true"
206
+ />
207
+ </NuxtLink>
208
+ </div>
209
+ </div>
210
+ </DocumentPageShell>
211
+ </template>