@lupinum/ginko-docs 0.4.0-rc.8 → 0.4.0

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 (296) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +1 -1
  3. package/README.md +206 -54
  4. package/app/app.config.ts +42 -55
  5. package/app/assets/docs.css +555 -0
  6. package/app/components/DocsBanner.vue +29 -0
  7. package/app/components/DocsExample.vue +43 -0
  8. package/app/components/DocsFooter.vue +19 -0
  9. package/app/components/DocsHeader.vue +112 -0
  10. package/app/components/DocsLanding.vue +55 -0
  11. package/app/components/DocsLanguage.vue +29 -0
  12. package/app/components/DocsNavTree.vue +38 -0
  13. package/app/components/DocsPage.vue +84 -0
  14. package/app/components/DocsPageActions.vue +47 -0
  15. package/app/components/DocsPost.vue +58 -0
  16. package/app/components/DocsSearch.vue +134 -0
  17. package/app/components/DocsShell.vue +20 -0
  18. package/app/components/DocsSidebar.vue +18 -0
  19. package/app/components/DocsTable.vue +11 -0
  20. package/app/components/DocsThemeToggle.vue +45 -0
  21. package/app/components/DocsToc.vue +73 -0
  22. package/app/composables/useDocs.ts +52 -0
  23. package/app/composables/useDocsBlog.ts +32 -0
  24. package/app/composables/useDocsCopy.ts +17 -0
  25. package/app/composables/useDocsPopover.ts +36 -0
  26. package/app/composables/useDocsSite.ts +10 -0
  27. package/app/docs-pages/blog.vue +47 -0
  28. package/app/docs-pages/changelog.vue +27 -0
  29. package/app/docs-pages/doc.vue +14 -0
  30. package/app/docs-pages/docs.vue +9 -0
  31. package/app/docs-pages/index.vue +12 -0
  32. package/app/docs-pages/post.vue +8 -0
  33. package/app/error.vue +23 -115
  34. package/app/layouts/default.vue +25 -7
  35. package/app/lib/blogTypes.ts +8 -0
  36. package/app/lib/docsFirst.ts +11 -0
  37. package/app/lib/docsInit.ts +18 -0
  38. package/app/lib/docsNav.ts +36 -0
  39. package/app/lib/docsPage.ts +44 -0
  40. package/app/lib/markdownComponents.ts +16 -0
  41. package/app/lib/previewToc.ts +18 -0
  42. package/dist/config.d.mts +232 -0
  43. package/dist/config.d.ts +232 -0
  44. package/dist/config.mjs +114 -0
  45. package/dist/pages.d.mts +5 -0
  46. package/dist/pages.d.ts +5 -0
  47. package/dist/pages.mjs +36 -0
  48. package/dist/shared/ginko-docs.BwbF7JBR.mjs +4 -0
  49. package/i18n/de.json +1 -0
  50. package/i18n/en.json +1 -0
  51. package/icons/README.md +2 -0
  52. package/icons/bluesky.svg +1 -0
  53. package/icons/claude.svg +1 -0
  54. package/icons/discord.svg +1 -0
  55. package/icons/github.svg +1 -0
  56. package/icons/linkedin.svg +1 -0
  57. package/icons/mastodon.svg +1 -0
  58. package/icons/openai.svg +1 -0
  59. package/icons/x.svg +1 -0
  60. package/icons/youtube.svg +1 -0
  61. package/nuxt.config.ts +34 -209
  62. package/package.json +43 -76
  63. package/public/_docs/fonts/LICENSE +188 -0
  64. package/public/_docs/fonts/inter-latin-wght-normal.woff2 +0 -0
  65. package/public/_docs/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  66. package/public/_docs/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  67. package/server/feeds/blog-locale.get.ts +9 -0
  68. package/server/feeds/blog.get.ts +4 -0
  69. package/server/lib/blogFeed.ts +35 -0
  70. package/server/lib/rss.ts +57 -0
  71. package/server/mcp/handler.ts +108 -0
  72. package/server/routes/__ginko/docs-mcp.ts +35 -0
  73. package/shared/paths.ts +4 -0
  74. package/app/app.vue +0 -98
  75. package/app/assets/css/prose.css +0 -3240
  76. package/app/assets/css/tailwind.css +0 -394
  77. package/app/assets/css/theme-palettes.css +0 -309
  78. package/app/assets/css/theme-presets.css +0 -141
  79. package/app/components/OgImage/GinkoDocs.satori.vue +0 -238
  80. package/app/components/content/DocumentPageShell.vue +0 -55
  81. package/app/components/content/Feedback.vue +0 -102
  82. package/app/components/content/ImageZoomDialog.vue +0 -84
  83. package/app/components/content/PageMarkdownCopy.vue +0 -104
  84. package/app/components/content/imageZoom.ts +0 -25
  85. package/app/components/mdc/MdcAccordion.vue +0 -67
  86. package/app/components/mdc/MdcAccordionItem.vue +0 -42
  87. package/app/components/mdc/MdcApi.vue +0 -159
  88. package/app/components/mdc/MdcAside.vue +0 -23
  89. package/app/components/mdc/MdcCallout.vue +0 -61
  90. package/app/components/mdc/MdcCard.vue +0 -175
  91. package/app/components/mdc/MdcCards.vue +0 -30
  92. package/app/components/mdc/MdcCenter.vue +0 -71
  93. package/app/components/mdc/MdcCodeGroup.vue +0 -197
  94. package/app/components/mdc/MdcCodeTree.vue +0 -234
  95. package/app/components/mdc/MdcCollapse.vue +0 -61
  96. package/app/components/mdc/MdcColumn.vue +0 -70
  97. package/app/components/mdc/MdcDropcap.vue +0 -84
  98. package/app/components/mdc/MdcError.vue +0 -23
  99. package/app/components/mdc/MdcExcerpt.vue +0 -26
  100. package/app/components/mdc/MdcFigure.vue +0 -85
  101. package/app/components/mdc/MdcFiles.vue +0 -120
  102. package/app/components/mdc/MdcIdea.vue +0 -17
  103. package/app/components/mdc/MdcInfo.vue +0 -17
  104. package/app/components/mdc/MdcInlineToc.vue +0 -51
  105. package/app/components/mdc/MdcKbd.vue +0 -10
  106. package/app/components/mdc/MdcLayout.vue +0 -57
  107. package/app/components/mdc/MdcNote.vue +0 -17
  108. package/app/components/mdc/MdcQuiz.vue +0 -241
  109. package/app/components/mdc/MdcQuizQuestion.vue +0 -221
  110. package/app/components/mdc/MdcReadMore.vue +0 -70
  111. package/app/components/mdc/MdcSteps.vue +0 -59
  112. package/app/components/mdc/MdcSuccess.vue +0 -23
  113. package/app/components/mdc/MdcTab.vue +0 -17
  114. package/app/components/mdc/MdcTabs.vue +0 -150
  115. package/app/components/mdc/MdcTimeline.vue +0 -17
  116. package/app/components/mdc/MdcTimelineItem.vue +0 -40
  117. package/app/components/mdc/MdcWarning.vue +0 -23
  118. package/app/components/mdc/api.utils.ts +0 -132
  119. package/app/components/mdc/code-tree.utils.ts +0 -22
  120. package/app/components/mdc/icons.ts +0 -10
  121. package/app/components/mdc/quiz.utils.ts +0 -38
  122. package/app/components/prose/ProseA.vue +0 -27
  123. package/app/components/prose/ProseBlockquote.vue +0 -30
  124. package/app/components/prose/ProseCode.vue +0 -14
  125. package/app/components/prose/ProseEm.vue +0 -14
  126. package/app/components/prose/ProseH1.vue +0 -18
  127. package/app/components/prose/ProseH2.vue +0 -29
  128. package/app/components/prose/ProseH3.vue +0 -29
  129. package/app/components/prose/ProseH4.vue +0 -29
  130. package/app/components/prose/ProseH5.vue +0 -15
  131. package/app/components/prose/ProseH6.vue +0 -15
  132. package/app/components/prose/ProseHr.vue +0 -12
  133. package/app/components/prose/ProseImg.vue +0 -55
  134. package/app/components/prose/ProseLi.vue +0 -14
  135. package/app/components/prose/ProseOl.vue +0 -14
  136. package/app/components/prose/ProseP.vue +0 -14
  137. package/app/components/prose/ProsePre.vue +0 -180
  138. package/app/components/prose/ProseStrong.vue +0 -14
  139. package/app/components/prose/ProseTable.vue +0 -18
  140. package/app/components/prose/ProseTbody.vue +0 -14
  141. package/app/components/prose/ProseTd.vue +0 -14
  142. package/app/components/prose/ProseTh.vue +0 -14
  143. package/app/components/prose/ProseThead.vue +0 -14
  144. package/app/components/prose/ProseTr.vue +0 -14
  145. package/app/components/prose/ProseUl.vue +0 -14
  146. package/app/components/site/ModeToggle.vue +0 -81
  147. package/app/components/site/SiteBanner.vue +0 -97
  148. package/app/components/site/SiteFooter.vue +0 -90
  149. package/app/components/site/SiteHeader.vue +0 -371
  150. package/app/components/site/SiteHeroCode.vue +0 -173
  151. package/app/components/site/SiteInteractionLayer.vue +0 -42
  152. package/app/components/site/SiteLocaleSwitcher.vue +0 -190
  153. package/app/components/site/SiteLogoMark.vue +0 -12
  154. package/app/components/site/SiteSkipLink.vue +0 -14
  155. package/app/components/site/SiteSocialLinks.vue +0 -33
  156. package/app/components/site/header-utils.ts +0 -3
  157. package/app/components/site/mode-toggle.utils.ts +0 -18
  158. package/app/components/ui/badge/Badge.vue +0 -28
  159. package/app/components/ui/badge/index.ts +0 -24
  160. package/app/components/ui/button/Button.vue +0 -31
  161. package/app/components/ui/button/index.ts +0 -35
  162. package/app/components/ui/collapsible/Collapsible.vue +0 -15
  163. package/app/components/ui/collapsible/CollapsibleContent.vue +0 -25
  164. package/app/components/ui/collapsible/CollapsibleTrigger.vue +0 -12
  165. package/app/components/ui/collapsible/index.ts +0 -3
  166. package/app/components/ui/dropdown-menu/DropdownMenu.vue +0 -15
  167. package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +0 -50
  168. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +0 -41
  169. package/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +0 -15
  170. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +0 -37
  171. package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +0 -23
  172. package/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue +0 -14
  173. package/app/components/ui/dropdown-menu/index.ts +0 -7
  174. package/app/components/ui/kbd/Kbd.vue +0 -23
  175. package/app/components/ui/kbd/index.ts +0 -21
  176. package/app/components/ui/scroll-area/ScrollArea.vue +0 -39
  177. package/app/components/ui/scroll-area/ScrollBar.vue +0 -33
  178. package/app/components/ui/scroll-area/index.ts +0 -2
  179. package/app/components/ui/separator/Separator.vue +0 -27
  180. package/app/components/ui/separator/index.ts +0 -1
  181. package/app/components/ui/sheet/Sheet.vue +0 -15
  182. package/app/components/ui/sheet/SheetContent.vue +0 -59
  183. package/app/components/ui/sheet/SheetDescription.vue +0 -21
  184. package/app/components/ui/sheet/SheetOverlay.vue +0 -26
  185. package/app/components/ui/sheet/SheetTitle.vue +0 -21
  186. package/app/components/ui/sheet/SheetTrigger.vue +0 -12
  187. package/app/components/ui/sheet/index.ts +0 -5
  188. package/app/components/ui/tabs/Tabs.vue +0 -24
  189. package/app/components/ui/tabs/TabsList.vue +0 -26
  190. package/app/components/ui/tabs/TabsTrigger.vue +0 -28
  191. package/app/components/ui/tabs/index.ts +0 -3
  192. package/app/composables/site-navigation.utils.ts +0 -133
  193. package/app/composables/useCanonicalUrl.ts +0 -9
  194. package/app/composables/useContentRouteAlternates.ts +0 -35
  195. package/app/composables/useGinkoAnalytics.ts +0 -52
  196. package/app/composables/useGinkoDocsConfig.ts +0 -6
  197. package/app/composables/useGinkoOgImage.ts +0 -20
  198. package/app/composables/useLocalizedPath.ts +0 -12
  199. package/app/composables/useLocalizedRouteSwitch.ts +0 -31
  200. package/app/composables/useMetaKey.ts +0 -18
  201. package/app/composables/usePageMarkdownActions.ts +0 -79
  202. package/app/composables/useProseAppearance.ts +0 -24
  203. package/app/composables/useSchemaJsonLd.ts +0 -25
  204. package/app/composables/useSiteNavigation.ts +0 -64
  205. package/app/config/site.utils.ts +0 -36
  206. package/app/features/docs/components/DocsBreadcrumb.vue +0 -37
  207. package/app/features/docs/components/DocsContributeLinks.vue +0 -75
  208. package/app/features/docs/components/DocsMobileToc.vue +0 -108
  209. package/app/features/docs/components/DocsPageContent.vue +0 -234
  210. package/app/features/docs/components/DocsPageNav.vue +0 -57
  211. package/app/features/docs/components/DocsSidebar.vue +0 -145
  212. package/app/features/docs/components/DocsSidebarDropdown.vue +0 -70
  213. package/app/features/docs/components/DocsSidebarItem.vue +0 -122
  214. package/app/features/docs/components/DocsSidebarList.vue +0 -35
  215. package/app/features/docs/components/DocsSidebarRow.vue +0 -27
  216. package/app/features/docs/components/DocsSidebarTabs.vue +0 -59
  217. package/app/features/docs/components/DocsToc.vue +0 -106
  218. package/app/features/docs/composables/useDocsNavigation.ts +0 -29
  219. package/app/features/docs/composables/useDocsNavigationData.ts +0 -18
  220. package/app/features/docs/composables/useRevealActive.ts +0 -58
  221. package/app/features/docs/composables/useScrollspy.ts +0 -135
  222. package/app/features/docs/docs-navigation.ts +0 -157
  223. package/app/features/docs/toc-context.ts +0 -6
  224. package/app/features/search/command-center.ts +0 -110
  225. package/app/features/search/components/SiteCommandCenter.vue +0 -359
  226. package/app/features/search/components/SiteSearchPageHighlight.vue +0 -126
  227. package/app/features/search/useCommandCenter.ts +0 -292
  228. package/app/layouts/blog.vue +0 -15
  229. package/app/layouts/docs.vue +0 -20
  230. package/app/lib/errors.ts +0 -37
  231. package/app/lib/schema-org.ts +0 -38
  232. package/app/pages/blog/[slug].vue +0 -228
  233. package/app/pages/blog/index.vue +0 -130
  234. package/app/pages/docs/[...slug].vue +0 -10
  235. package/app/pages/docs/index.vue +0 -19
  236. package/app/pages/index.vue +0 -278
  237. package/app/plugins/ginko-docs-theme.ts +0 -14
  238. package/app/plugins/ssr-width.ts +0 -6
  239. package/app/utils/content.ts +0 -67
  240. package/app/utils/file-icons.ts +0 -90
  241. package/app/utils/index.ts +0 -7
  242. package/app/utils/repository.ts +0 -20
  243. package/components.ts +0 -9
  244. package/content-collections.ts +0 -89
  245. package/content.js +0 -200
  246. package/content.ts +0 -104
  247. package/i18n/i18n.config.ts +0 -10
  248. package/i18n/locales.ts +0 -81
  249. package/i18n/messages/global/banner.ts +0 -8
  250. package/i18n/messages/global/blog.ts +0 -18
  251. package/i18n/messages/global/command.ts +0 -49
  252. package/i18n/messages/global/docs.ts +0 -36
  253. package/i18n/messages/global/errors.ts +0 -36
  254. package/i18n/messages/global/feedback.ts +0 -11
  255. package/i18n/messages/global/index.ts +0 -21
  256. package/i18n/messages/global/nav.ts +0 -26
  257. package/i18n/messages/global/site.ts +0 -4
  258. package/i18n/messages/global/theme.ts +0 -9
  259. package/i18n/messages/index.ts +0 -20
  260. package/i18n/messages/localize.ts +0 -35
  261. package/i18n/routes.ts +0 -33
  262. package/icon-bundle.ts +0 -116
  263. package/index.d.ts +0 -37
  264. package/modules/analytics-boundary.ts +0 -23
  265. package/modules/feature-routing.ts +0 -68
  266. package/modules/syntax-highlighting.ts +0 -120
  267. package/public/fonts/public-sans/LICENSE +0 -93
  268. package/public/fonts/public-sans/README.md +0 -12
  269. package/public/fonts/public-sans/public-sans-400-normal-latin.woff +0 -0
  270. package/public/fonts/public-sans/public-sans-500-normal-latin.woff +0 -0
  271. package/public/fonts/public-sans/public-sans-600-normal-latin.woff +0 -0
  272. package/public/fonts/public-sans/public-sans-700-normal-latin.woff +0 -0
  273. package/runtime/generate-favicons.mjs +0 -124
  274. package/runtime/server/agent-markdown.ts +0 -19
  275. package/server/api/_ginko-docs/redirects.get.ts +0 -28
  276. package/server/mcp/tools/get-page.ts +0 -14
  277. package/server/mcp/tools/list-pages.ts +0 -10
  278. package/server/middleware/redirects.ts +0 -22
  279. package/server/plugins/agent-markdown.ts +0 -6
  280. package/server/plugins/sitemap-policy.ts +0 -12
  281. package/server/routes/.well-known/ai-catalog.json.get.ts +0 -10
  282. package/server/routes/[locale]/blog/rss.xml.get.ts +0 -13
  283. package/server/routes/blog/rss.xml.get.ts +0 -5
  284. package/server/routes/mcp/server-card.get.ts +0 -14
  285. package/server/utils/blog-feed.ts +0 -51
  286. package/server/utils/feed.ts +0 -70
  287. package/server/utils/mcp-server-card.ts +0 -45
  288. package/server/utils/redirects.ts +0 -38
  289. package/server/utils/redirects.utils.ts +0 -58
  290. package/server/utils/sitemap.ts +0 -45
  291. package/shared/route-slugs.ts +0 -5
  292. package/shared/theme-palettes.ts +0 -37
  293. package/shared/theme-presets.ts +0 -3
  294. package/shared/types/app-config.ts +0 -172
  295. package/shared/types/nuxt-config.ts +0 -14
  296. package/tags.ts +0 -192
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ import { previewToc } from '../lib/previewToc'
3
+ import { markdownComponents } from '../lib/markdownComponents'
4
+ // A ```md preview fence (ginko-content parses it into `example`): what the Markdown renders, then the Markdown
5
+ // itself, as two tabs in one frame. The preview arrives as a parsed tree in a prop; the code block is the slot.
6
+ // One copy button serves both tabs (the code block's own is hidden in CSS), so it sits in the same place in each.
7
+ import type { MarkdownTree } from '@lupinum/ginko'
8
+ import { Tab, Tabs } from '@lupinum/ginko-components/components'
9
+
10
+ const props = defineProps<{ preview: MarkdownTree['nodes'] }>()
11
+ const { t } = useI18n()
12
+ const { t: tc } = useI18n('components') // the code block's own texts: "Copy code", "Copied"
13
+ const tree = computed<MarkdownTree>(() => ({ nodes: props.preview, toc: previewToc(props.preview) }))
14
+
15
+ const root = useTemplateRef<HTMLElement>('root')
16
+ const copied = ref(false)
17
+ let timer: ReturnType<typeof setTimeout> | undefined
18
+ async function copy() {
19
+ const text = (root.value?.querySelector('pre')?.textContent ?? '').replace(/\n$/, '')
20
+ try { await navigator.clipboard.writeText(text) } catch { return }
21
+ copied.value = true
22
+ clearTimeout(timer)
23
+ timer = setTimeout(() => { copied.value = false }, 2000)
24
+ }
25
+ onBeforeUnmount(() => clearTimeout(timer))
26
+ </script>
27
+
28
+ <template>
29
+ <div ref="root" class="docs-example">
30
+ <Tabs variant="line">
31
+ <Tab :title="t('docs.example.preview')">
32
+ <GinkoMarkdown :value="tree" :components="markdownComponents" class="docs-example-preview" />
33
+ </Tab>
34
+ <Tab :title="t('docs.example.source')">
35
+ <slot />
36
+ </Tab>
37
+ </Tabs>
38
+ <button type="button" class="ginko-code-copy docs-example-copy" :aria-label="tc('code.copy')" @click="copy">
39
+ <Icon :name="copied ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
40
+ <span class="ginko-code-status" aria-live="polite">{{ copied ? tc('code.copied') : '' }}</span>
41
+ </button>
42
+ </div>
43
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ // Site links (texts: docs.footer.<id>) and the "Built with Ginko" line. Renders nothing when both are off.
3
+ const { t } = useI18n()
4
+ const { text, href } = useDocsSite()
5
+ const footer = useAppConfig().docs?.footer
6
+ const links = footer?.links ?? []
7
+ const attribution = footer?.attribution ?? true
8
+ </script>
9
+
10
+ <template>
11
+ <footer v-if="links.length || attribution" class="docs-footer">
12
+ <div class="docs-footer-inner">
13
+ <nav v-if="links.length" :aria-label="t('docs.footerNav')">
14
+ <NuxtLink v-for="l in links" :key="l.id" :to="href(l.href)">{{ text(`docs.footer.${l.id}`) }}</NuxtLink>
15
+ </nav>
16
+ <a v-if="attribution" href="https://github.com/lupinum-dev/ginko" rel="noopener">{{ t('docs.builtWith') }}</a>
17
+ </div>
18
+ </footer>
19
+ </template>
@@ -0,0 +1,112 @@
1
+ <script setup lang="ts">
2
+ // The standalone header: brand, the site's sections (Docs, Blog, Changelog and the site's own links), search,
3
+ // language, light/dark and social links. On phones the menu button opens one full-screen menu: the docs
4
+ // navigation (on docs pages), the site links and social link, the language and the light/dark switch.
5
+ const { t, localePath } = useI18n()
6
+ const cfg = useAppConfig().docs ?? {}
7
+ const title = cfg.title ?? useGinkoSite().name
8
+ const { text, href } = useDocsSite()
9
+ const docs = useDocs()
10
+ const blog = useDocsBlog()
11
+ const route = useRoute()
12
+
13
+ const within = (base: string | undefined) => !!base && (route.path === base || route.path.startsWith(`${base}/`))
14
+ const links = computed(() => [
15
+ ...(docs.first.value ? [{ key: 'docs', label: t('docs.docs'), to: docs.first.value, active: within(docs.base.value) }] : []),
16
+ ...(blog.hasPosts ? [{ key: 'blog', label: t('docs.blog'), to: blog.blogPath.value, active: within(blog.blogPath.value) }] : []),
17
+ ...(blog.hasChangelog ? [{ key: 'changelog', label: t('docs.changelog'), to: blog.changelogPath.value, active: within(blog.changelogPath.value) }] : []),
18
+ ...(cfg.nav ?? []).map((l) => ({ key: l.id, label: text(`docs.nav.${l.id}`), to: href(l.href), active: within(href(l.href)) })),
19
+ ])
20
+
21
+ // The phone menu. Where the docs navigation shows: on the docs pages only (not the blog or the start page).
22
+ const menu = ref(false)
23
+ const inDocs = computed(() => within(docs.base.value))
24
+ const button = useTemplateRef<HTMLButtonElement>('button')
25
+ const panel = useTemplateRef<HTMLElement>('panel')
26
+
27
+ function close(refocus = false) {
28
+ if (!menu.value) return
29
+ menu.value = false
30
+ if (refocus) button.value?.focus()
31
+ }
32
+ function onKey(e: KeyboardEvent) {
33
+ // An open language popover takes the first Escape.
34
+ if (e.key === 'Escape' && !panel.value?.querySelector('.docs-popover')) close(true)
35
+ }
36
+ const wide = import.meta.client ? matchMedia('(min-width: 64rem)') : undefined
37
+ const onWide = () => { if (wide?.matches) close() }
38
+
39
+ // While open: the page behind does not scroll, Escape closes, and focus moves in (and back to the button on close).
40
+ watch(menu, async (open) => {
41
+ document.documentElement.style.overflow = open ? 'hidden' : ''
42
+ if (open) {
43
+ document.addEventListener('keydown', onKey)
44
+ wide?.addEventListener('change', onWide)
45
+ await nextTick()
46
+ panel.value?.focus()
47
+ } else {
48
+ document.removeEventListener('keydown', onKey)
49
+ wide?.removeEventListener('change', onWide)
50
+ }
51
+ })
52
+ watch(() => route.fullPath, () => close())
53
+ onBeforeUnmount(() => {
54
+ document.documentElement.style.overflow = ''
55
+ document.removeEventListener('keydown', onKey)
56
+ wide?.removeEventListener('change', onWide)
57
+ })
58
+ </script>
59
+
60
+ <template>
61
+ <header class="docs-header">
62
+ <div class="docs-header-inner">
63
+ <NuxtLink :to="localePath('/')" class="docs-brand">
64
+ <template v-if="cfg.logo">
65
+ <img :class="{ 'docs-when-light': cfg.logo.dark }" :src="cfg.logo.light" :alt="cfg.logo.wordmark ? title : ''">
66
+ <img v-if="cfg.logo.dark" class="docs-when-dark" :src="cfg.logo.dark" alt="">
67
+ </template>
68
+ <span v-if="!cfg.logo?.wordmark">{{ title }}</span>
69
+ </NuxtLink>
70
+ <nav v-if="links.length > 1 || cfg.nav?.length" class="docs-nav-links" :aria-label="t('docs.siteLinks')">
71
+ <NuxtLink v-for="l in links" :key="l.key" :to="l.to" :aria-current="l.active ? 'page' : undefined">{{ l.label }}</NuxtLink>
72
+ </nav>
73
+ <div class="docs-header-end">
74
+ <DocsSearch />
75
+ <DocsLanguage />
76
+ <DocsThemeToggle />
77
+ <template v-if="cfg.social?.length">
78
+ <span class="docs-header-sep" aria-hidden="true" />
79
+ <div class="docs-social">
80
+ <a v-for="s in cfg.social" :key="s.href" class="docs-icon-button" :href="s.href" target="_blank" rel="noopener" :aria-label="s.label" :title="s.label">
81
+ <Icon :name="s.icon" class="docs-icon" />
82
+ </a>
83
+ </div>
84
+ </template>
85
+ <button
86
+ ref="button" type="button" class="docs-icon-button docs-header-menu" :aria-expanded="menu" aria-controls="docs-menu"
87
+ :aria-label="menu ? t('docs.close') : t('docs.siteMenu')" @click="menu = !menu"
88
+ >
89
+ <Icon :name="menu ? 'lucide:x' : 'lucide:menu'" class="docs-icon" />
90
+ </button>
91
+ </div>
92
+ </div>
93
+ <!-- Teleported: the header's backdrop blur would otherwise make it the containing block of the fixed menu. -->
94
+ <Teleport to="body">
95
+ <div v-if="menu" id="docs-menu" ref="panel" class="docs-menu" role="dialog" tabindex="-1" :aria-label="t('docs.siteMenu')">
96
+ <div v-if="inDocs" class="docs-menu-scroll">
97
+ <DocsSidebar />
98
+ </div>
99
+ <div class="docs-menu-more">
100
+ <nav class="docs-menu-links" :aria-label="t('docs.siteLinks')">
101
+ <NuxtLink v-for="l in links" :key="l.key" :to="l.to" :aria-current="l.active ? 'page' : undefined">{{ l.label }}</NuxtLink>
102
+ <a v-for="s in cfg.social ?? []" :key="s.href" :href="s.href" target="_blank" rel="noopener">{{ s.label }}</a>
103
+ </nav>
104
+ </div>
105
+ <div class="docs-menu-foot">
106
+ <DocsLanguage />
107
+ <DocsThemeToggle segmented />
108
+ </div>
109
+ </div>
110
+ </Teleport>
111
+ </header>
112
+ </template>
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ import { markdownComponents } from '../lib/markdownComponents'
3
+ // The start page from the site's `home` brick (docsHome): hero with title, description, links and an install
4
+ // command, the feature grid, then the free text. Everything is content, so it is translated like the docs.
5
+ import type { ItemOf } from '@lupinum/ginko'
6
+ import type { docsHome } from '@lupinum/ginko-docs/config'
7
+
8
+ type Home = Pick<ItemOf<ReturnType<typeof docsHome>['fields']>, 'data'>
9
+ // The layer cannot know the site's bricks at compile time; index.vue renders this only when `home` exists.
10
+ const home = (useGinko as unknown as (b: string) => ComputedRef<Home>)('home')
11
+ const data = computed(() => home.value.data)
12
+ const { t } = useI18n()
13
+ useGinkoSeo(() => ({ title: data.value.title, description: data.value.description }))
14
+
15
+ const { state, copy } = useDocsCopy()
16
+ const external = (href: string) => /^https?:\/\//.test(href)
17
+ </script>
18
+
19
+ <template>
20
+ <div class="docs-landing">
21
+ <div class="docs-landing-inner">
22
+ <section class="docs-hero">
23
+ <h1>{{ data.title }}</h1>
24
+ <p v-if="data.description">{{ data.description }}</p>
25
+ <div v-if="data.links.length" class="docs-hero-links">
26
+ <NuxtLink
27
+ v-for="(l, i) in data.links" :key="l.href" :to="l.href" :target="external(l.href) ? '_blank' : undefined"
28
+ class="docs-button is-large" :class="i === 0 ? 'is-primary' : 'is-soft'"
29
+ >
30
+ {{ l.label }}<Icon v-if="i === 0" name="lucide:arrow-right" class="docs-icon" />
31
+ </NuxtLink>
32
+ </div>
33
+ <div v-if="data.install" class="docs-install">
34
+ <code tabindex="0" role="region" :aria-label="t('docs.installCommand')">{{ data.install }}</code>
35
+ <button type="button" class="docs-icon-button" :aria-label="state === 'copied' ? t('docs.copied') : state === 'failed' ? t('docs.copyFailed') : t('docs.copyCommand')" @click="copy(data.install ?? '')">
36
+ <Icon :name="state === 'copied' ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
37
+ </button>
38
+ </div>
39
+ </section>
40
+ <ul v-if="data.features.length" class="docs-features">
41
+ <li v-for="f in data.features" :key="f.title" class="docs-feature">
42
+ <div v-if="f.icon" class="docs-feature-icon">
43
+ <Icon :name="f.icon" class="docs-icon" />
44
+ </div>
45
+ <h2>
46
+ <NuxtLink v-if="f.href" :to="f.href">{{ f.title }}</NuxtLink>
47
+ <template v-else>{{ f.title }}</template>
48
+ </h2>
49
+ <p>{{ f.description }}</p>
50
+ </li>
51
+ </ul>
52
+ <GinkoMarkdown v-if="data.body" :value="data.body" :components="markdownComponents" class="docs-prose docs-landing-body" />
53
+ </div>
54
+ </div>
55
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ // The languages of this page (ginko-i18n alternates, so a doc links to its own translation). A menu in the
3
+ // header and in the phone menu. Each language is named in itself ("Deutsch").
4
+ const { t, alternates } = useI18n()
5
+ const { open, toggle } = useDocsPopover()
6
+ const current = computed(() => alternates.value.find((a) => a.current))
7
+ </script>
8
+
9
+ <template>
10
+ <template v-if="alternates.length > 1">
11
+ <div ref="popover" class="docs-lang">
12
+ <button
13
+ type="button" class="docs-icon-button" :aria-expanded="open" aria-controls="docs-lang-menu"
14
+ :aria-label="`${t('docs.language')}: ${current?.name ?? ''}`" @click="toggle"
15
+ >
16
+ <Icon name="lucide:languages" class="docs-icon" />
17
+ <span aria-hidden="true">{{ current?.code.toUpperCase() }}</span>
18
+ </button>
19
+ <ul v-if="open" id="docs-lang-menu" class="docs-popover">
20
+ <li v-for="a in alternates" :key="a.code">
21
+ <NuxtLink :to="a.path" :hreflang="a.language" :lang="a.language" :aria-current="a.current ? 'page' : undefined">
22
+ {{ a.name }}
23
+ <Icon v-if="a.current" name="lucide:check" class="docs-icon" />
24
+ </NuxtLink>
25
+ </li>
26
+ </ul>
27
+ </div>
28
+ </template>
29
+ </template>
@@ -0,0 +1,38 @@
1
+ <script setup lang="ts">
2
+ // One level of the side navigation. A node with children gets a toggle button; the branch of the current page
3
+ // starts open, and a reader's own choice holds until the page changes.
4
+ import type { DocNode } from '../lib/docsNav'
5
+
6
+ const props = defineProps<{ nodes: DocNode[] }>()
7
+ const { t } = useI18n()
8
+ const route = useRoute()
9
+ const choice = reactive(new Map<string, boolean>())
10
+ watch(() => route.path, () => choice.clear())
11
+
12
+ const holdsCurrent = (node: DocNode): boolean => node.path === route.path || node.children.some(holdsCurrent)
13
+ const isOpen = (node: DocNode) => choice.get(node.path) ?? holdsCurrent(node)
14
+ const toggle = (node: DocNode) => choice.set(node.path, !isOpen(node))
15
+ </script>
16
+
17
+ <template>
18
+ <ul class="docs-nav-list">
19
+ <li v-for="node in props.nodes" :key="node.path">
20
+ <div class="docs-nav-item">
21
+ <NuxtLink :to="node.path" :aria-current="node.path === route.path ? 'page' : undefined">
22
+ <Icon v-if="node.icon" :name="node.icon" class="docs-icon" />
23
+ <span class="docs-nav-title">{{ node.title }}</span>
24
+ <span v-if="node.badge" class="docs-badge">{{ node.badge }}</span>
25
+ </NuxtLink>
26
+ <button
27
+ v-if="node.children.length" type="button" class="docs-nav-toggle"
28
+ :aria-expanded="isOpen(node)" :aria-label="t('docs.toggle', { title: node.title })" @click="toggle(node)"
29
+ >
30
+ <Icon name="lucide:chevron-right" class="docs-icon" />
31
+ </button>
32
+ </div>
33
+ <div v-if="node.children.length" v-show="isOpen(node)" class="docs-nav-children">
34
+ <DocsNavTree :nodes="node.children" />
35
+ </div>
36
+ </li>
37
+ </ul>
38
+ </template>
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ import { docsEditUrl, docsIssueUrl, docsStudioUrl } from '../lib/docsPage'
3
+ import { markdownComponents } from '../lib/markdownComponents'
4
+ // One doc: breadcrumbs, title, lead and page actions, the text with the layer's Markdown components, the edit
5
+ // link and date, previous/next; the table of contents beside it (or in the docs bar, see doc.vue).
6
+ import type { ItemOf } from '@lupinum/ginko'
7
+ import { articleLd, breadcrumbLd } from '@lupinum/ginko/internal'
8
+ import type { docsCollection } from '@lupinum/ginko-docs/config'
9
+
10
+ // `source` (the page's repository path) is in the bundle because this layer asks for it (hook `ginko:files`, nuxt.config.ts).
11
+ const props = defineProps<{ doc: ItemOf<ReturnType<typeof docsCollection>['fields']> & { source?: string } }>()
12
+ const { t, format, language } = useI18n()
13
+ const { crumbs } = useDocs()
14
+ const neighbors = useGinkoNeighbors('docs', () => props.doc.id)
15
+ const site = useGinkoSite()
16
+ // The Studio link needs the platform address and the site name: internal keys of the Ginko module, read by its own layer.
17
+ const { platform, site: siteName } = useRuntimeConfig().public.ginko as { site: string, platform: string }
18
+ const route = useRoute()
19
+ useGinkoSeo(() => ({
20
+ title: props.doc.data.title,
21
+ description: props.doc.data.description,
22
+ section: props.doc.data.section,
23
+ type: 'article',
24
+ jsonLd: [
25
+ // articleLd fixes the type to Article; docs are TechArticle.
26
+ { ...articleLd({ title: props.doc.data.title, url: new URL(route.path, site.url).href, description: props.doc.data.description, modified: props.doc.updatedAt, lang: language.value }), '@type': 'TechArticle' },
27
+ ...(crumbs.value.length > 1 ? [breadcrumbLd(crumbs.value.map((c) => ({ ...c, children: [] })), new URL(site.url).origin)] : []),
28
+ ],
29
+ }))
30
+
31
+ const repository = useAppConfig().docs?.repository
32
+ // A page from a file is edited in the repository; one stored on the platform in the Studio.
33
+ const editUrl = computed(() => (props.doc.source ? docsEditUrl(repository, props.doc.source) : docsStudioUrl(platform, siteName, 'docs', props.doc.id)))
34
+ const issueUrl = computed(() => docsIssueUrl(repository, props.doc.data.title, new URL(route.path, site.url).href))
35
+ const updated = computed(() => props.doc.updatedAt && {
36
+ iso: props.doc.updatedAt,
37
+ text: format.date(new Date(props.doc.updatedAt), 'long'),
38
+ })
39
+ const toc = computed(() => props.doc.data.body.toc?.links ?? [])
40
+ const top = () => window.scrollTo({ top: 0, behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
41
+ </script>
42
+
43
+ <template>
44
+ <div class="docs-page">
45
+ <article class="docs-article">
46
+ <nav v-if="crumbs.length > 1" class="docs-crumbs" :aria-label="t('docs.breadcrumb')">
47
+ <ol>
48
+ <li v-for="(c, i) in crumbs" :key="c.path + i">
49
+ <Icon v-if="i" name="lucide:chevron-right" class="docs-icon" />
50
+ <span v-if="i === crumbs.length - 1" aria-current="page">{{ c.title }}</span>
51
+ <NuxtLink v-else :to="c.path">{{ c.title }}</NuxtLink>
52
+ </li>
53
+ </ol>
54
+ </nav>
55
+ <header class="docs-page-head">
56
+ <h1>{{ doc.data.title }}</h1>
57
+ <p v-if="doc.data.description" class="docs-lead">{{ doc.data.description }}</p>
58
+ <DocsPageActions :path="route.path" :origin="site.url" />
59
+ </header>
60
+ <GinkoMarkdown :value="doc.data.body" :components="markdownComponents" class="docs-prose" />
61
+ <footer>
62
+ <div v-if="editUrl || updated" class="docs-page-foot">
63
+ <a v-if="editUrl" :href="editUrl" target="_blank" rel="noopener"><Icon name="lucide:pencil" class="docs-icon" />{{ t('docs.edit') }}</a>
64
+ <span v-if="updated">{{ t('docs.updated') }} <time :datetime="updated.iso">{{ updated.text }}</time></span>
65
+ </div>
66
+ <nav v-if="neighbors.prev || neighbors.next" class="docs-pager" :aria-label="t('docs.pager')">
67
+ <NuxtLink v-if="neighbors.prev" :to="neighbors.prev.url" rel="prev">
68
+ <small><Icon name="lucide:arrow-left" class="docs-icon" />{{ t('docs.previous') }}</small>{{ neighbors.prev.data.title }}
69
+ </NuxtLink>
70
+ <NuxtLink v-if="neighbors.next" :to="neighbors.next.url" rel="next" class="is-next">
71
+ <small>{{ t('docs.next') }}<Icon name="lucide:arrow-right" class="docs-icon" /></small>{{ neighbors.next.data.title }}
72
+ </NuxtLink>
73
+ </nav>
74
+ </footer>
75
+ </article>
76
+ <DocsToc v-if="toc.length" :links="toc" variant="aside">
77
+ <div class="docs-toc-more">
78
+ <a v-if="editUrl" :href="editUrl" target="_blank" rel="noopener"><Icon name="lucide:pencil" class="docs-icon" />{{ t('docs.edit') }}</a>
79
+ <a v-if="issueUrl" :href="issueUrl" target="_blank" rel="noopener"><Icon name="lucide:circle-alert" class="docs-icon" />{{ t('docs.issue') }}</a>
80
+ <button type="button" @click="top"><Icon name="lucide:arrow-up" class="docs-icon" />{{ t('docs.top') }}</button>
81
+ </div>
82
+ </DocsToc>
83
+ </div>
84
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import { docsAskLinks, docsMarkdownPath, type DocsAssistant } from '../lib/docsPage'
3
+ // "Copy page" copies the page as Markdown; the menu next to it opens the Markdown twin or a chat that reads it.
4
+ const props = defineProps<{ path: string, origin: string }>()
5
+ const { t } = useI18n()
6
+ const cfg = useAppConfig().docs?.markdownActions ?? {}
7
+ const assistants = {
8
+ chatgpt: { icon: 'docs:openai', label: 'docs.openChatGpt' },
9
+ claude: { icon: 'docs:claude', label: 'docs.openClaude' },
10
+ } as const satisfies Record<DocsAssistant, { icon: string, label: string }>
11
+ // Unknown names in `openIn` are skipped, so a typo in app.config.ts hides one entry instead of breaking the menu.
12
+ const openIn = (cfg.openIn ?? ['chatgpt', 'claude']).filter((id) => Object.hasOwn(assistants, id))
13
+ const { open, toggle } = useDocsPopover()
14
+ const mdPath = computed(() => docsMarkdownPath(props.path))
15
+ const links = computed(() => docsAskLinks(t('docs.askPrompt', { url: new URL(mdPath.value, props.origin).href })))
16
+
17
+ const { state, copy: copyText } = useDocsCopy()
18
+ const copy = () => copyText(() => $fetch<string>(mdPath.value, { responseType: 'text' }))
19
+ const menu = openIn.length > 0
20
+ </script>
21
+
22
+ <template>
23
+ <div v-if="cfg.copy !== false || menu" ref="popover" class="docs-actions" role="group" :aria-label="t('docs.pageActions')">
24
+ <button v-if="cfg.copy !== false" type="button" class="docs-button" @click="copy">
25
+ <Icon :name="state === 'copied' ? 'lucide:check' : 'lucide:copy'" class="docs-icon" />
26
+ <span aria-live="polite">{{ state === 'copied' ? t('docs.copied') : state === 'failed' ? t('docs.copyFailed') : t('docs.copyPage') }}</span>
27
+ </button>
28
+ <button type="button" class="docs-button" :aria-expanded="open" aria-controls="docs-page-menu" :aria-label="t('docs.moreActions')" @click="toggle">
29
+ <Icon name="lucide:chevron-down" class="docs-icon" />
30
+ </button>
31
+ <ul v-if="open" id="docs-page-menu" class="docs-popover">
32
+ <li>
33
+ <a :href="mdPath" target="_blank">
34
+ <Icon name="lucide:file-text" class="docs-icon" />
35
+ <span>{{ t('docs.viewMarkdown') }}<small>{{ t('docs.viewMarkdownHint') }}</small></span>
36
+ </a>
37
+ </li>
38
+ <li v-for="id in openIn" :key="id">
39
+ <a :href="links[id]" target="_blank" rel="noopener">
40
+ <Icon :name="assistants[id].icon" class="docs-icon" />
41
+ <span>{{ t(assistants[id].label) }}<small>{{ t('docs.askHint') }}</small></span>
42
+ <Icon name="lucide:arrow-up-right" class="docs-icon" />
43
+ </a>
44
+ </li>
45
+ </ul>
46
+ </div>
47
+ </template>
@@ -0,0 +1,58 @@
1
+ <script setup lang="ts">
2
+ import { markdownComponents } from '../lib/markdownComponents'
3
+ // One blog post: title, date, authors, text. Article JSON-LD names the authors and carries both dates.
4
+ import { articleLd } from '@lupinum/ginko/internal'
5
+ import type { Post } from '../lib/blogTypes'
6
+
7
+ const props = defineProps<{ post: Post }>()
8
+ const { t, language } = useI18n()
9
+ const blog = useDocsBlog()
10
+ const site = useGinkoSite()
11
+ const route = useRoute()
12
+ const authors = computed(() => blog.authorsOf(props.post))
13
+ const absolute = (path: string) => new URL(path, site.url).href
14
+
15
+ useGinkoSeo(() => {
16
+ const { title, description, date, image } = props.post.data
17
+ const ld = articleLd({
18
+ title, description, lang: language.value, url: absolute(route.path), image: image ? absolute(image.url) : undefined,
19
+ published: date, modified: (props.post.updatedAt ?? date).slice(0, 10),
20
+ })
21
+ return {
22
+ title, description, type: 'article', image: image?.url,
23
+ // articleLd takes one author; a post may have several (and a link), so the list replaces it.
24
+ jsonLd: [authors.value.length ? { ...ld, author: authors.value.map((a) => ({ '@type': 'Person', name: a.data.name, ...(a.data.url ? { url: a.data.url } : {}) })) } : ld],
25
+ }
26
+ })
27
+ useHead(() => ({ link: [{ rel: 'alternate', type: 'application/rss+xml', title: t('docs.blog'), href: blog.feedPath.value }] }))
28
+ </script>
29
+
30
+ <template>
31
+ <div class="docs-blog">
32
+ <div class="docs-blog-panel">
33
+ <article class="docs-post">
34
+ <nav class="docs-crumbs" :aria-label="t('docs.breadcrumb')">
35
+ <ol>
36
+ <li><NuxtLink :to="blog.blogPath.value">{{ t('docs.blog') }}</NuxtLink></li>
37
+ <li><Icon name="lucide:chevron-right" class="docs-icon" /><span aria-current="page">{{ post.data.title }}</span></li>
38
+ </ol>
39
+ </nav>
40
+ <h1>{{ post.data.title }}</h1>
41
+ <p v-if="post.data.description" class="docs-lead">{{ post.data.description }}</p>
42
+ <p class="docs-meta">
43
+ <time :datetime="post.data.date">{{ blog.formatDate(post.data.date) }}</time>
44
+ <template v-if="authors.length">
45
+ · {{ t('docs.by') }}
46
+ <template v-for="(a, i) in authors" :key="a.id">
47
+ <template v-if="i">, </template>
48
+ <a v-if="a.data.url" :href="a.data.url" rel="author">{{ a.data.name }}</a>
49
+ <template v-else>{{ a.data.name }}</template>
50
+ </template>
51
+ </template>
52
+ </p>
53
+ <GinkoImage v-if="post.data.image" :image="post.data.image" class="docs-post-image" eager />
54
+ <GinkoMarkdown :value="post.data.body" :components="markdownComponents" class="docs-prose" />
55
+ </article>
56
+ </div>
57
+ </div>
58
+ </template>
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ // Search over /__ginko/search/<locale>.json (built at build time), loaded when the dialog first opens.
3
+ // Hits are grouped by page: the page itself first, then its sections. Arrow keys move, Enter opens.
4
+ import type { SearchHit, SearchIndex, SearchIndexJson } from '@lupinum/ginko/search'
5
+
6
+ const { t, locale } = useI18n()
7
+ const dialog = useTemplateRef<HTMLDialogElement>('dialog')
8
+ const input = useTemplateRef<HTMLInputElement>('input')
9
+ const query = ref('')
10
+ const active = ref(0)
11
+ // The engine is imported on first open, so MiniSearch stays out of the page's entry chunk.
12
+ const engine = shallowRef<{ index: SearchIndex; search: typeof import('@lupinum/ginko/search').search }>()
13
+ const mac = ref(true)
14
+
15
+ async function open() {
16
+ dialog.value?.showModal()
17
+ input.value?.select()
18
+ if (engine.value) return
19
+ try {
20
+ const { loadIndex, search } = await import('@lupinum/ginko/search')
21
+ const url: string = `/__ginko/search/${locale.value}.json` // a plain string: the typed route union is too deep to compare
22
+ engine.value = { index: await loadIndex(await $fetch<SearchIndexJson>(url)), search }
23
+ } catch { /* no index: the dialog shows "no results" */ }
24
+ }
25
+ watch(locale, () => { engine.value = undefined })
26
+
27
+ const hits = computed<SearchHit[]>(() => {
28
+ const q = query.value.trim()
29
+ return q && engine.value ? engine.value.search(engine.value.index, q, { limit: 12 }) : []
30
+ })
31
+ // One group per page, in the order of its best hit; the page hit (no heading) leads its group and its sections
32
+ // are indented under it. Pages then sit under their area (Docs, Blog, Changelog), again in the order of the best
33
+ // hit; the area labels show only when hits come from more than one area.
34
+ const AREAS = { docs: 'docs.docs', blog: 'docs.blog', changelog: 'docs.changelog' } as const
35
+ function areaOf(url: string): keyof typeof AREAS | undefined {
36
+ const local = url.startsWith(`/${locale.value}/`) ? url.slice(locale.value.length + 1) : url
37
+ const first = local.split(/[/#?]/)[1] ?? ''
38
+ return Object.hasOwn(AREAS, first) ? (first as keyof typeof AREAS) : undefined
39
+ }
40
+ const groups = computed(() => {
41
+ const byPage = new Map<string, SearchHit[]>()
42
+ for (const hit of hits.value) {
43
+ const page = hit.url.split('#')[0]!
44
+ byPage.set(page, [...(byPage.get(page) ?? []), hit])
45
+ }
46
+ const byArea = new Map<string, { label?: string; rows: { hit: SearchHit; indent: boolean }[] }>()
47
+ for (const list of byPage.values()) {
48
+ const area = areaOf(list[0]!.url)
49
+ const group = byArea.get(area ?? '') ?? { label: area ? t(AREAS[area]) : undefined, rows: [] }
50
+ byArea.set(area ?? '', group)
51
+ const hasPage = list.some((h) => !h.heading)
52
+ group.rows.push(...list.sort((a, b) => Number(!!a.heading) - Number(!!b.heading)).map((hit) => ({ hit, indent: hasPage && !!hit.heading })))
53
+ }
54
+ const list = [...byArea.values()]
55
+ return list.length > 1 ? list : list.map((g) => ({ ...g, label: undefined }))
56
+ })
57
+ const flat = computed(() => groups.value.flatMap((g) => g.rows))
58
+ const indexOf = (hit: SearchHit) => flat.value.findIndex((r) => r.hit.id === hit.id)
59
+ watch(query, () => { active.value = 0 })
60
+
61
+ function move(step: number) {
62
+ if (!flat.value.length) return
63
+ active.value = (active.value + step + flat.value.length) % flat.value.length
64
+ nextTick(() => document.getElementById(`docs-hit-${active.value}`)?.scrollIntoView({ block: 'nearest' }))
65
+ }
66
+ function go() {
67
+ const row = flat.value[active.value]
68
+ if (row) navigateTo(row.hit.url)
69
+ }
70
+
71
+ const route = useRoute()
72
+ watch(() => route.fullPath, () => dialog.value?.close())
73
+ function onKey(e: KeyboardEvent) {
74
+ const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || (e.target as HTMLElement)?.isContentEditable
75
+ if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey))) {
76
+ e.preventDefault()
77
+ open()
78
+ }
79
+ }
80
+ onMounted(() => {
81
+ mac.value = /Mac|iPhone|iPad/.test(navigator.platform)
82
+ window.addEventListener('keydown', onKey)
83
+ })
84
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
85
+ </script>
86
+
87
+ <template>
88
+ <button type="button" class="docs-search-trigger" :aria-label="t('docs.search')" @click="open">
89
+ <Icon name="lucide:search" class="docs-icon" />
90
+ <span class="docs-search-label">{{ t('docs.searchPlaceholder') }}</span>
91
+ <kbd class="docs-kbd">{{ mac ? '⌘' : 'Ctrl' }} K</kbd>
92
+ </button>
93
+ <Teleport to="body">
94
+ <dialog ref="dialog" class="docs-search-dialog" :aria-label="t('docs.search')" @click.self="dialog?.close()">
95
+ <div class="docs-search-field">
96
+ <Icon name="lucide:search" class="docs-icon" />
97
+ <input
98
+ ref="input" v-model="query" type="search" role="combobox" aria-autocomplete="list" aria-controls="docs-search-list"
99
+ :aria-expanded="flat.length > 0" :aria-activedescendant="flat.length ? `docs-hit-${active}` : undefined"
100
+ :placeholder="t('docs.searchPlaceholder')" :aria-label="t('docs.searchPlaceholder')" autocomplete="off" spellcheck="false"
101
+ @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="go" @keydown.esc.prevent="dialog?.close()"
102
+ >
103
+ <button type="button" class="docs-kbd" @click="dialog?.close()">Esc</button>
104
+ </div>
105
+ <div class="docs-search-body" tabindex="0" role="region" :aria-label="t('docs.searchResults')">
106
+ <ul v-if="flat.length" id="docs-search-list" class="docs-search-results" role="listbox" :aria-label="t('docs.search')">
107
+ <li v-for="(g, n) in groups" :key="n" role="none">
108
+ <p v-if="g.label" :id="`docs-hit-group-${n}`" class="docs-search-group">{{ g.label }}</p>
109
+ <ul role="group" :aria-labelledby="g.label ? `docs-hit-group-${n}` : undefined">
110
+ <li
111
+ v-for="{ hit, indent } in g.rows" :id="`docs-hit-${indexOf(hit)}`" :key="hit.id" role="option" :aria-selected="indexOf(hit) === active"
112
+ class="docs-search-hit" :class="{ 'is-section': indent }" @mousemove="active = indexOf(hit)" @click="active = indexOf(hit); go()"
113
+ >
114
+ <span class="docs-search-tile"><Icon :name="hit.heading ? 'lucide:hash' : 'lucide:file-text'" class="docs-icon" /></span>
115
+ <span class="docs-search-text">
116
+ <strong>{{ hit.heading ?? hit.title }}</strong>
117
+ <span>{{ indent ? hit.excerpt : hit.heading ? hit.title : hit.excerpt }}</span>
118
+ </span>
119
+ <Icon name="lucide:corner-down-left" class="docs-icon docs-search-enter" />
120
+ </li>
121
+ </ul>
122
+ </li>
123
+ </ul>
124
+ <p v-else-if="query.trim()" class="docs-search-empty">{{ t('docs.noResults', { query: query.trim() }) }}</p>
125
+ <p v-else class="docs-search-empty">{{ t('docs.searchHint') }}</p>
126
+ </div>
127
+ <div class="docs-search-foot" aria-hidden="true">
128
+ <span><kbd class="docs-kbd">↑</kbd><kbd class="docs-kbd">↓</kbd> {{ t('docs.searchMove') }}</span>
129
+ <span><kbd class="docs-kbd">↵</kbd> {{ t('docs.searchOpen') }}</span>
130
+ <span><kbd class="docs-kbd">Esc</kbd> {{ t('docs.close') }}</span>
131
+ </div>
132
+ </dialog>
133
+ </Teleport>
134
+ </template>