@lupinum/ginko-docs 0.4.0-rc.9 → 0.5.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 (304) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/LICENSE +1 -1
  3. package/README.md +300 -53
  4. package/app/app.config.ts +44 -55
  5. package/app/assets/docs.css +619 -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 +113 -0
  10. package/app/components/DocsIcon.vue +12 -0
  11. package/app/components/DocsLanding.vue +58 -0
  12. package/app/components/DocsLanguage.vue +40 -0
  13. package/app/components/DocsNavSheet.vue +37 -0
  14. package/app/components/DocsNavTree.vue +38 -0
  15. package/app/components/DocsPage.vue +88 -0
  16. package/app/components/DocsPageActions.vue +47 -0
  17. package/app/components/DocsPageEnd.ts +12 -0
  18. package/app/components/DocsPost.vue +58 -0
  19. package/app/components/DocsSearch.vue +136 -0
  20. package/app/components/DocsShell.vue +36 -0
  21. package/app/components/DocsSidebar.vue +19 -0
  22. package/app/components/DocsSidebarEnd.ts +4 -0
  23. package/app/components/DocsTable.vue +11 -0
  24. package/app/components/DocsThemeToggle.vue +45 -0
  25. package/app/components/DocsToc.vue +73 -0
  26. package/app/composables/useDocs.ts +53 -0
  27. package/app/composables/useDocsBlog.ts +33 -0
  28. package/app/composables/useDocsCopy.ts +17 -0
  29. package/app/composables/useDocsFrame.ts +11 -0
  30. package/app/composables/useDocsPopover.ts +36 -0
  31. package/app/composables/useDocsSite.ts +31 -0
  32. package/app/docs-pages/blog.vue +55 -0
  33. package/app/docs-pages/changelog.vue +35 -0
  34. package/app/docs-pages/doc.vue +16 -0
  35. package/app/docs-pages/docs.vue +9 -0
  36. package/app/docs-pages/index.vue +12 -0
  37. package/app/docs-pages/post.vue +8 -0
  38. package/app/error.vue +38 -115
  39. package/app/layouts/default.vue +29 -7
  40. package/app/lib/blogTypes.ts +8 -0
  41. package/app/lib/docsFirst.ts +11 -0
  42. package/app/lib/docsInit.ts +18 -0
  43. package/app/lib/docsNav.ts +36 -0
  44. package/app/lib/docsPage.ts +84 -0
  45. package/app/lib/markdownComponents.ts +15 -0
  46. package/app/lib/previewToc.ts +18 -0
  47. package/dist/config.d.mts +252 -0
  48. package/dist/config.d.ts +252 -0
  49. package/dist/config.mjs +83 -0
  50. package/dist/icons.d.mts +38 -0
  51. package/dist/icons.d.ts +38 -0
  52. package/dist/icons.mjs +175 -0
  53. package/dist/pages.d.mts +5 -0
  54. package/dist/pages.d.ts +5 -0
  55. package/dist/pages.mjs +68 -0
  56. package/i18n/de.json +1 -0
  57. package/i18n/en.json +1 -0
  58. package/i18n/fr.json +1 -0
  59. package/icons/README.md +2 -0
  60. package/icons/bluesky.svg +1 -0
  61. package/icons/claude.svg +1 -0
  62. package/icons/discord.svg +1 -0
  63. package/icons/github.svg +1 -0
  64. package/icons/linkedin.svg +1 -0
  65. package/icons/mastodon.svg +1 -0
  66. package/icons/openai.svg +1 -0
  67. package/icons/x.svg +1 -0
  68. package/icons/youtube.svg +1 -0
  69. package/nuxt.config.ts +22 -212
  70. package/package.json +44 -76
  71. package/public/_docs/fonts/LICENSE +188 -0
  72. package/public/_docs/fonts/inter-latin-wght-normal.woff2 +0 -0
  73. package/public/_docs/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  74. package/public/_docs/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  75. package/server/feeds/blog-locale.get.ts +9 -0
  76. package/server/feeds/blog.get.ts +4 -0
  77. package/server/lib/blogFeed.ts +35 -0
  78. package/server/mcp/handler.ts +108 -0
  79. package/server/routes/__ginko/docs-mcp.ts +35 -0
  80. package/shared/layerRoutes.ts +66 -0
  81. package/shared/paths.ts +4 -0
  82. package/app/app.vue +0 -98
  83. package/app/assets/css/prose.css +0 -3240
  84. package/app/assets/css/tailwind.css +0 -394
  85. package/app/assets/css/theme-palettes.css +0 -309
  86. package/app/assets/css/theme-presets.css +0 -141
  87. package/app/components/OgImage/GinkoDocs.satori.vue +0 -238
  88. package/app/components/content/DocumentPageShell.vue +0 -55
  89. package/app/components/content/Feedback.vue +0 -102
  90. package/app/components/content/ImageZoomDialog.vue +0 -84
  91. package/app/components/content/PageMarkdownCopy.vue +0 -104
  92. package/app/components/content/imageZoom.ts +0 -25
  93. package/app/components/mdc/MdcAccordion.vue +0 -67
  94. package/app/components/mdc/MdcAccordionItem.vue +0 -42
  95. package/app/components/mdc/MdcApi.vue +0 -159
  96. package/app/components/mdc/MdcAside.vue +0 -23
  97. package/app/components/mdc/MdcCallout.vue +0 -61
  98. package/app/components/mdc/MdcCard.vue +0 -175
  99. package/app/components/mdc/MdcCards.vue +0 -30
  100. package/app/components/mdc/MdcCenter.vue +0 -71
  101. package/app/components/mdc/MdcCodeGroup.vue +0 -197
  102. package/app/components/mdc/MdcCodeTree.vue +0 -234
  103. package/app/components/mdc/MdcCollapse.vue +0 -61
  104. package/app/components/mdc/MdcColumn.vue +0 -70
  105. package/app/components/mdc/MdcDropcap.vue +0 -84
  106. package/app/components/mdc/MdcError.vue +0 -23
  107. package/app/components/mdc/MdcExcerpt.vue +0 -26
  108. package/app/components/mdc/MdcFigure.vue +0 -85
  109. package/app/components/mdc/MdcFiles.vue +0 -120
  110. package/app/components/mdc/MdcIdea.vue +0 -17
  111. package/app/components/mdc/MdcInfo.vue +0 -17
  112. package/app/components/mdc/MdcInlineToc.vue +0 -51
  113. package/app/components/mdc/MdcKbd.vue +0 -10
  114. package/app/components/mdc/MdcLayout.vue +0 -57
  115. package/app/components/mdc/MdcNote.vue +0 -17
  116. package/app/components/mdc/MdcQuiz.vue +0 -241
  117. package/app/components/mdc/MdcQuizQuestion.vue +0 -221
  118. package/app/components/mdc/MdcReadMore.vue +0 -70
  119. package/app/components/mdc/MdcSteps.vue +0 -59
  120. package/app/components/mdc/MdcSuccess.vue +0 -23
  121. package/app/components/mdc/MdcTab.vue +0 -17
  122. package/app/components/mdc/MdcTabs.vue +0 -150
  123. package/app/components/mdc/MdcTimeline.vue +0 -17
  124. package/app/components/mdc/MdcTimelineItem.vue +0 -40
  125. package/app/components/mdc/MdcWarning.vue +0 -23
  126. package/app/components/mdc/api.utils.ts +0 -132
  127. package/app/components/mdc/code-tree.utils.ts +0 -22
  128. package/app/components/mdc/icons.ts +0 -10
  129. package/app/components/mdc/quiz.utils.ts +0 -38
  130. package/app/components/prose/ProseA.vue +0 -27
  131. package/app/components/prose/ProseBlockquote.vue +0 -30
  132. package/app/components/prose/ProseCode.vue +0 -14
  133. package/app/components/prose/ProseEm.vue +0 -14
  134. package/app/components/prose/ProseH1.vue +0 -18
  135. package/app/components/prose/ProseH2.vue +0 -29
  136. package/app/components/prose/ProseH3.vue +0 -29
  137. package/app/components/prose/ProseH4.vue +0 -29
  138. package/app/components/prose/ProseH5.vue +0 -15
  139. package/app/components/prose/ProseH6.vue +0 -15
  140. package/app/components/prose/ProseHr.vue +0 -12
  141. package/app/components/prose/ProseImg.vue +0 -55
  142. package/app/components/prose/ProseLi.vue +0 -14
  143. package/app/components/prose/ProseOl.vue +0 -14
  144. package/app/components/prose/ProseP.vue +0 -14
  145. package/app/components/prose/ProsePre.vue +0 -180
  146. package/app/components/prose/ProseStrong.vue +0 -14
  147. package/app/components/prose/ProseTable.vue +0 -18
  148. package/app/components/prose/ProseTbody.vue +0 -14
  149. package/app/components/prose/ProseTd.vue +0 -14
  150. package/app/components/prose/ProseTh.vue +0 -14
  151. package/app/components/prose/ProseThead.vue +0 -14
  152. package/app/components/prose/ProseTr.vue +0 -14
  153. package/app/components/prose/ProseUl.vue +0 -14
  154. package/app/components/site/ModeToggle.vue +0 -81
  155. package/app/components/site/SiteBanner.vue +0 -97
  156. package/app/components/site/SiteFooter.vue +0 -90
  157. package/app/components/site/SiteHeader.vue +0 -371
  158. package/app/components/site/SiteHeroCode.vue +0 -173
  159. package/app/components/site/SiteInteractionLayer.vue +0 -42
  160. package/app/components/site/SiteLocaleSwitcher.vue +0 -190
  161. package/app/components/site/SiteLogoMark.vue +0 -12
  162. package/app/components/site/SiteSkipLink.vue +0 -14
  163. package/app/components/site/SiteSocialLinks.vue +0 -33
  164. package/app/components/site/header-utils.ts +0 -3
  165. package/app/components/site/mode-toggle.utils.ts +0 -18
  166. package/app/components/ui/badge/Badge.vue +0 -28
  167. package/app/components/ui/badge/index.ts +0 -24
  168. package/app/components/ui/button/Button.vue +0 -31
  169. package/app/components/ui/button/index.ts +0 -35
  170. package/app/components/ui/collapsible/Collapsible.vue +0 -15
  171. package/app/components/ui/collapsible/CollapsibleContent.vue +0 -25
  172. package/app/components/ui/collapsible/CollapsibleTrigger.vue +0 -12
  173. package/app/components/ui/collapsible/index.ts +0 -3
  174. package/app/components/ui/dropdown-menu/DropdownMenu.vue +0 -15
  175. package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +0 -50
  176. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +0 -41
  177. package/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +0 -15
  178. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +0 -37
  179. package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +0 -23
  180. package/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue +0 -14
  181. package/app/components/ui/dropdown-menu/index.ts +0 -7
  182. package/app/components/ui/kbd/Kbd.vue +0 -23
  183. package/app/components/ui/kbd/index.ts +0 -21
  184. package/app/components/ui/scroll-area/ScrollArea.vue +0 -39
  185. package/app/components/ui/scroll-area/ScrollBar.vue +0 -33
  186. package/app/components/ui/scroll-area/index.ts +0 -2
  187. package/app/components/ui/separator/Separator.vue +0 -27
  188. package/app/components/ui/separator/index.ts +0 -1
  189. package/app/components/ui/sheet/Sheet.vue +0 -15
  190. package/app/components/ui/sheet/SheetContent.vue +0 -59
  191. package/app/components/ui/sheet/SheetDescription.vue +0 -21
  192. package/app/components/ui/sheet/SheetOverlay.vue +0 -26
  193. package/app/components/ui/sheet/SheetTitle.vue +0 -21
  194. package/app/components/ui/sheet/SheetTrigger.vue +0 -12
  195. package/app/components/ui/sheet/index.ts +0 -5
  196. package/app/components/ui/tabs/Tabs.vue +0 -24
  197. package/app/components/ui/tabs/TabsList.vue +0 -26
  198. package/app/components/ui/tabs/TabsTrigger.vue +0 -28
  199. package/app/components/ui/tabs/index.ts +0 -3
  200. package/app/composables/site-navigation.utils.ts +0 -133
  201. package/app/composables/useCanonicalUrl.ts +0 -9
  202. package/app/composables/useContentRouteAlternates.ts +0 -35
  203. package/app/composables/useGinkoAnalytics.ts +0 -52
  204. package/app/composables/useGinkoDocsConfig.ts +0 -6
  205. package/app/composables/useGinkoOgImage.ts +0 -20
  206. package/app/composables/useLocalizedPath.ts +0 -12
  207. package/app/composables/useLocalizedRouteSwitch.ts +0 -31
  208. package/app/composables/useMetaKey.ts +0 -18
  209. package/app/composables/usePageMarkdownActions.ts +0 -79
  210. package/app/composables/useProseAppearance.ts +0 -24
  211. package/app/composables/useSchemaJsonLd.ts +0 -25
  212. package/app/composables/useSiteNavigation.ts +0 -64
  213. package/app/config/site.utils.ts +0 -36
  214. package/app/features/docs/components/DocsBreadcrumb.vue +0 -37
  215. package/app/features/docs/components/DocsContributeLinks.vue +0 -75
  216. package/app/features/docs/components/DocsMobileToc.vue +0 -108
  217. package/app/features/docs/components/DocsPageContent.vue +0 -234
  218. package/app/features/docs/components/DocsPageNav.vue +0 -57
  219. package/app/features/docs/components/DocsSidebar.vue +0 -145
  220. package/app/features/docs/components/DocsSidebarDropdown.vue +0 -70
  221. package/app/features/docs/components/DocsSidebarItem.vue +0 -122
  222. package/app/features/docs/components/DocsSidebarList.vue +0 -35
  223. package/app/features/docs/components/DocsSidebarRow.vue +0 -27
  224. package/app/features/docs/components/DocsSidebarTabs.vue +0 -59
  225. package/app/features/docs/components/DocsToc.vue +0 -106
  226. package/app/features/docs/composables/useDocsNavigation.ts +0 -29
  227. package/app/features/docs/composables/useDocsNavigationData.ts +0 -18
  228. package/app/features/docs/composables/useRevealActive.ts +0 -58
  229. package/app/features/docs/composables/useScrollspy.ts +0 -135
  230. package/app/features/docs/docs-navigation.ts +0 -157
  231. package/app/features/docs/toc-context.ts +0 -6
  232. package/app/features/search/command-center.ts +0 -110
  233. package/app/features/search/components/SiteCommandCenter.vue +0 -359
  234. package/app/features/search/components/SiteSearchPageHighlight.vue +0 -126
  235. package/app/features/search/useCommandCenter.ts +0 -292
  236. package/app/layouts/blog.vue +0 -15
  237. package/app/layouts/docs.vue +0 -20
  238. package/app/lib/errors.ts +0 -37
  239. package/app/lib/schema-org.ts +0 -38
  240. package/app/pages/blog/[slug].vue +0 -228
  241. package/app/pages/blog/index.vue +0 -130
  242. package/app/pages/docs/[...slug].vue +0 -10
  243. package/app/pages/docs/index.vue +0 -19
  244. package/app/pages/index.vue +0 -278
  245. package/app/plugins/ginko-docs-theme.ts +0 -14
  246. package/app/plugins/ssr-width.ts +0 -6
  247. package/app/utils/content.ts +0 -67
  248. package/app/utils/file-icons.ts +0 -90
  249. package/app/utils/index.ts +0 -7
  250. package/app/utils/repository.ts +0 -20
  251. package/components.ts +0 -9
  252. package/content-collections.ts +0 -89
  253. package/content.js +0 -200
  254. package/content.ts +0 -104
  255. package/i18n/i18n.config.ts +0 -10
  256. package/i18n/locales.ts +0 -81
  257. package/i18n/messages/global/banner.ts +0 -8
  258. package/i18n/messages/global/blog.ts +0 -18
  259. package/i18n/messages/global/command.ts +0 -49
  260. package/i18n/messages/global/docs.ts +0 -36
  261. package/i18n/messages/global/errors.ts +0 -36
  262. package/i18n/messages/global/feedback.ts +0 -11
  263. package/i18n/messages/global/index.ts +0 -21
  264. package/i18n/messages/global/nav.ts +0 -26
  265. package/i18n/messages/global/site.ts +0 -4
  266. package/i18n/messages/global/theme.ts +0 -9
  267. package/i18n/messages/index.ts +0 -20
  268. package/i18n/messages/localize.ts +0 -35
  269. package/i18n/routes.ts +0 -33
  270. package/icon-bundle.ts +0 -116
  271. package/index.d.ts +0 -37
  272. package/modules/analytics-boundary.ts +0 -23
  273. package/modules/feature-routing.ts +0 -68
  274. package/modules/syntax-highlighting.ts +0 -120
  275. package/public/fonts/public-sans/LICENSE +0 -93
  276. package/public/fonts/public-sans/README.md +0 -12
  277. package/public/fonts/public-sans/public-sans-400-normal-latin.woff +0 -0
  278. package/public/fonts/public-sans/public-sans-500-normal-latin.woff +0 -0
  279. package/public/fonts/public-sans/public-sans-600-normal-latin.woff +0 -0
  280. package/public/fonts/public-sans/public-sans-700-normal-latin.woff +0 -0
  281. package/runtime/generate-favicons.mjs +0 -124
  282. package/runtime/server/agent-markdown.ts +0 -19
  283. package/server/api/_ginko-docs/redirects.get.ts +0 -28
  284. package/server/mcp/tools/get-page.ts +0 -14
  285. package/server/mcp/tools/list-pages.ts +0 -10
  286. package/server/middleware/redirects.ts +0 -22
  287. package/server/plugins/agent-markdown.ts +0 -6
  288. package/server/plugins/sitemap-policy.ts +0 -12
  289. package/server/routes/.well-known/ai-catalog.json.get.ts +0 -10
  290. package/server/routes/[locale]/blog/rss.xml.get.ts +0 -13
  291. package/server/routes/blog/rss.xml.get.ts +0 -5
  292. package/server/routes/mcp/server-card.get.ts +0 -14
  293. package/server/utils/blog-feed.ts +0 -51
  294. package/server/utils/feed.ts +0 -70
  295. package/server/utils/mcp-server-card.ts +0 -45
  296. package/server/utils/redirects.ts +0 -38
  297. package/server/utils/redirects.utils.ts +0 -58
  298. package/server/utils/sitemap.ts +0 -45
  299. package/shared/route-slugs.ts +0 -5
  300. package/shared/theme-palettes.ts +0 -37
  301. package/shared/theme-presets.ts +0 -3
  302. package/shared/types/app-config.ts +0 -172
  303. package/shared/types/nuxt-config.ts +0 -14
  304. package/tags.ts +0 -192
@@ -0,0 +1,136 @@
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
+ // `shortcut` (default true): `/` and Ctrl/⌘ K open this search. A second trigger on the same page sets it to false.
7
+ const { shortcut = true } = defineProps<{ shortcut?: boolean }>()
8
+ const { t, locale } = useI18n()
9
+ const dialog = useTemplateRef<HTMLDialogElement>('dialog')
10
+ const input = useTemplateRef<HTMLInputElement>('input')
11
+ const query = ref('')
12
+ const active = ref(0)
13
+ // The engine is imported on first open, so MiniSearch stays out of the page's entry chunk.
14
+ const engine = shallowRef<{ index: SearchIndex; search: typeof import('@lupinum/ginko/search').search }>()
15
+ const mac = ref(true)
16
+
17
+ async function open() {
18
+ dialog.value?.showModal()
19
+ input.value?.select()
20
+ if (engine.value) return
21
+ try {
22
+ const { loadIndex, search } = await import('@lupinum/ginko/search')
23
+ const url: string = `/__ginko/search/${locale.value}.json` // a plain string: the typed route union is too deep to compare
24
+ engine.value = { index: await loadIndex(await $fetch<SearchIndexJson>(url)), search }
25
+ } catch { /* no index: the dialog shows "no results" */ }
26
+ }
27
+ watch(locale, () => { engine.value = undefined })
28
+
29
+ const hits = computed<SearchHit[]>(() => {
30
+ const q = query.value.trim()
31
+ return q && engine.value ? engine.value.search(engine.value.index, q, { limit: 12 }) : []
32
+ })
33
+ // One group per page, in the order of its best hit; the page hit (no heading) leads its group and its sections
34
+ // are indented under it. Pages then sit under their area (Docs, Blog, Changelog), again in the order of the best
35
+ // hit; the area labels show only when hits come from more than one area.
36
+ const AREAS = { docs: 'docs.docs', blog: 'docs.blog', changelog: 'docs.changelog' } as const
37
+ function areaOf(url: string): keyof typeof AREAS | undefined {
38
+ const local = url.startsWith(`/${locale.value}/`) ? url.slice(locale.value.length + 1) : url
39
+ const first = local.split(/[/#?]/)[1] ?? ''
40
+ return Object.hasOwn(AREAS, first) ? (first as keyof typeof AREAS) : undefined
41
+ }
42
+ const groups = computed(() => {
43
+ const byPage = new Map<string, SearchHit[]>()
44
+ for (const hit of hits.value) {
45
+ const page = hit.url.split('#')[0]!
46
+ byPage.set(page, [...(byPage.get(page) ?? []), hit])
47
+ }
48
+ const byArea = new Map<string, { label?: string; rows: { hit: SearchHit; indent: boolean }[] }>()
49
+ for (const list of byPage.values()) {
50
+ const area = areaOf(list[0]!.url)
51
+ const group = byArea.get(area ?? '') ?? { label: area ? t(AREAS[area]) : undefined, rows: [] }
52
+ byArea.set(area ?? '', group)
53
+ const hasPage = list.some((h) => !h.heading)
54
+ group.rows.push(...list.sort((a, b) => Number(!!a.heading) - Number(!!b.heading)).map((hit) => ({ hit, indent: hasPage && !!hit.heading })))
55
+ }
56
+ const list = [...byArea.values()]
57
+ return list.length > 1 ? list : list.map((g) => ({ ...g, label: undefined }))
58
+ })
59
+ const flat = computed(() => groups.value.flatMap((g) => g.rows))
60
+ const indexOf = (hit: SearchHit) => flat.value.findIndex((r) => r.hit.id === hit.id)
61
+ watch(query, () => { active.value = 0 })
62
+
63
+ function move(step: number) {
64
+ if (!flat.value.length) return
65
+ active.value = (active.value + step + flat.value.length) % flat.value.length
66
+ nextTick(() => document.getElementById(`docs-hit-${active.value}`)?.scrollIntoView({ block: 'nearest' }))
67
+ }
68
+ function go() {
69
+ const row = flat.value[active.value]
70
+ if (row) navigateTo(row.hit.url)
71
+ }
72
+
73
+ const route = useRoute()
74
+ watch(() => route.fullPath, () => dialog.value?.close())
75
+ function onKey(e: KeyboardEvent) {
76
+ const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || (e.target as HTMLElement)?.isContentEditable
77
+ if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey))) {
78
+ e.preventDefault()
79
+ open()
80
+ }
81
+ }
82
+ onMounted(() => {
83
+ mac.value = /Mac|iPhone|iPad/.test(navigator.platform)
84
+ if (shortcut) window.addEventListener('keydown', onKey)
85
+ })
86
+ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
87
+ </script>
88
+
89
+ <template>
90
+ <button type="button" class="docs-search-trigger" :aria-label="t('docs.search')" @click="open">
91
+ <DocsIcon name="lucide:search" class="docs-icon" />
92
+ <span class="docs-search-label">{{ t('docs.searchPlaceholder') }}</span>
93
+ <kbd class="docs-kbd">{{ mac ? '⌘' : 'Ctrl' }} K</kbd>
94
+ </button>
95
+ <Teleport to="body">
96
+ <dialog ref="dialog" class="docs-search-dialog" :aria-label="t('docs.search')" @click.self="dialog?.close()">
97
+ <div class="docs-search-field">
98
+ <DocsIcon name="lucide:search" class="docs-icon" />
99
+ <input
100
+ ref="input" v-model="query" type="search" role="combobox" aria-autocomplete="list" aria-controls="docs-search-list"
101
+ :aria-expanded="flat.length > 0" :aria-activedescendant="flat.length ? `docs-hit-${active}` : undefined"
102
+ :placeholder="t('docs.searchPlaceholder')" :aria-label="t('docs.searchPlaceholder')" autocomplete="off" spellcheck="false"
103
+ @keydown.down.prevent="move(1)" @keydown.up.prevent="move(-1)" @keydown.enter.prevent="go" @keydown.esc.prevent="dialog?.close()"
104
+ >
105
+ <button type="button" class="docs-kbd" @click="dialog?.close()">Esc</button>
106
+ </div>
107
+ <div class="docs-search-body" tabindex="0" role="region" :aria-label="t('docs.searchResults')">
108
+ <ul v-if="flat.length" id="docs-search-list" class="docs-search-results" role="listbox" :aria-label="t('docs.search')">
109
+ <li v-for="(g, n) in groups" :key="n" role="none">
110
+ <p v-if="g.label" :id="`docs-hit-group-${n}`" class="docs-search-group">{{ g.label }}</p>
111
+ <ul role="group" :aria-labelledby="g.label ? `docs-hit-group-${n}` : undefined">
112
+ <li
113
+ v-for="{ hit, indent } in g.rows" :id="`docs-hit-${indexOf(hit)}`" :key="hit.id" role="option" :aria-selected="indexOf(hit) === active"
114
+ class="docs-search-hit" :class="{ 'is-section': indent }" @mousemove="active = indexOf(hit)" @click="active = indexOf(hit); go()"
115
+ >
116
+ <span class="docs-search-tile"><DocsIcon :name="hit.heading ? 'lucide:hash' : 'lucide:file-text'" class="docs-icon" /></span>
117
+ <span class="docs-search-text">
118
+ <strong>{{ hit.heading ?? hit.title }}</strong>
119
+ <span>{{ indent ? hit.excerpt : hit.heading ? hit.title : hit.excerpt }}</span>
120
+ </span>
121
+ <DocsIcon name="lucide:corner-down-left" class="docs-icon docs-search-enter" />
122
+ </li>
123
+ </ul>
124
+ </li>
125
+ </ul>
126
+ <p v-else-if="query.trim()" class="docs-search-empty">{{ t('docs.noResults', { query: query.trim() }) }}</p>
127
+ <p v-else class="docs-search-empty">{{ t('docs.searchHint') }}</p>
128
+ </div>
129
+ <div class="docs-search-foot" aria-hidden="true">
130
+ <span><kbd class="docs-kbd">↑</kbd><kbd class="docs-kbd">↓</kbd> {{ t('docs.searchMove') }}</span>
131
+ <span><kbd class="docs-kbd">↵</kbd> {{ t('docs.searchOpen') }}</span>
132
+ <span><kbd class="docs-kbd">Esc</kbd> {{ t('docs.close') }}</span>
133
+ </div>
134
+ </dialog>
135
+ </Teleport>
136
+ </template>
@@ -0,0 +1,36 @@
1
+ <script setup lang="ts">
2
+ // The docs body: the navigation on the side and the page next to it. Public: a site builds its own pages inside the
3
+ // docs frame with it (slots: default, `bar`). In the layer's own frame the header holds the search and, on phones, the
4
+ // navigation. Inside a site's layout the shell brings both: the search above the navigation, and on phones a bar with
5
+ // a button that opens the navigation as a sheet, the search, and the page's own controls (slot `bar`, the table of
6
+ // contents). `docs.search: false` in app.config leaves the search out (a site with <DocsSearch /> in its own header).
7
+ const { t } = useI18n()
8
+ const standalone = useDocsStandalone()
9
+ const search = !standalone && useAppConfig().docs?.search !== false
10
+ const nav = ref(false)
11
+ </script>
12
+
13
+ <template>
14
+ <div class="docs-shell">
15
+ <nav class="docs-sidebar" :aria-label="t('docs.nav')">
16
+ <div v-if="search" class="docs-sidebar-search">
17
+ <DocsSearch />
18
+ </div>
19
+ <DocsSidebar />
20
+ </nav>
21
+ <div id="docs-content" class="docs-content" tabindex="-1">
22
+ <div class="docs-bar">
23
+ <div v-if="!standalone" class="docs-bar-start">
24
+ <button type="button" class="docs-bar-nav" aria-haspopup="dialog" :aria-expanded="nav" @click="nav = true">
25
+ <DocsIcon name="lucide:panel-left-open" class="docs-icon" />{{ t('docs.contents') }}
26
+ </button>
27
+ <!-- A second trigger for phones; the one above the navigation keeps the keyboard shortcut. -->
28
+ <DocsSearch v-if="search" :shortcut="false" />
29
+ </div>
30
+ <slot name="bar" />
31
+ </div>
32
+ <slot />
33
+ </div>
34
+ <DocsNavSheet v-if="!standalone" v-model:open="nav" />
35
+ </div>
36
+ </template>
@@ -0,0 +1,19 @@
1
+ <script setup lang="ts">
2
+ // The side navigation: the section tabs (when the docs have several), then the pages of the active tab in their
3
+ // sections, then the site's DocsSidebarEnd. Used in the sidebar, the phone sheet and the header menu.
4
+ const { t } = useI18n()
5
+ const { tabs, activeTab, sections } = useDocs()
6
+ </script>
7
+
8
+ <template>
9
+ <div>
10
+ <nav v-if="tabs.length" class="docs-tabs" :aria-label="t('docs.sections')">
11
+ <NuxtLink v-for="tab in tabs" :key="tab.title" :to="tab.path" :aria-current="tab.title === activeTab ? 'true' : undefined">{{ tab.title }}</NuxtLink>
12
+ </nav>
13
+ <section v-for="(s, i) in sections" :key="i" class="docs-nav-section">
14
+ <h2 v-if="s.title" class="docs-nav-heading">{{ s.title }}</h2>
15
+ <DocsNavTree :nodes="s.nodes" />
16
+ </section>
17
+ <DocsSidebarEnd />
18
+ </div>
19
+ </template>
@@ -0,0 +1,4 @@
1
+ // Override point: below the docs navigation (sidebar, phone sheet and the header menu). Renders nothing here; a site
2
+ // puts its own `components/DocsSidebarEnd.vue` in its app to add links or a contact line. A render function, not an
3
+ // empty <template>: server and browser then agree on the empty placeholder.
4
+ export default defineComponent({ name: 'DocsSidebarEnd', render: () => null })
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ // Markdown tables scroll sideways on a phone. The wrapper is the scroll box: keyboard users can focus it and scroll with the arrow keys.
3
+ // role=group, not region: a page can hold several tables, and landmarks with the same name are an error.
4
+ const { t } = useI18n()
5
+ </script>
6
+
7
+ <template>
8
+ <div class="docs-table" role="group" tabindex="0" :aria-label="t('docs.table')">
9
+ <table><slot /></table>
10
+ </div>
11
+ </template>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ // Auto, light or dark. Auto follows the system and is the default; a choice lives in localStorage and
3
+ // <html data-theme>, and the head script applies it before paint. Both icons are in the HTML and CSS shows the
4
+ // right one, so the server render never guesses the reader's scheme.
5
+ import { COLOR_KEY, colorInitScript } from '../lib/docsInit'
6
+
7
+ const { t } = useI18n()
8
+ useHead({ script: [{ key: 'docs-color-init', innerHTML: colorInitScript(), tagPriority: 'critical' }] })
9
+
10
+ // `segmented` is the phone menu's Auto/Light/Dark switch: the same states and the same storage.
11
+ const props = defineProps<{ segmented?: boolean }>()
12
+ type Choice = 'auto' | 'light' | 'dark'
13
+ const system = (): 'light' | 'dark' => (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
14
+ const current = (): Choice => (document.documentElement.getAttribute('data-theme') as Choice | null) ?? 'auto'
15
+ // Only read when the switch is on screen (the phone menu renders on the client), so SSR never guesses.
16
+ const choice = ref<Choice>(props.segmented && import.meta.client ? current() : 'auto')
17
+
18
+ function choose(next: Choice) {
19
+ const html = document.documentElement
20
+ if (next === 'auto') html.removeAttribute('data-theme')
21
+ else html.setAttribute('data-theme', next)
22
+ choice.value = next
23
+ try {
24
+ if (next === 'auto') localStorage.removeItem(COLOR_KEY)
25
+ else localStorage.setItem(COLOR_KEY, next)
26
+ } catch { /* storage blocked: the choice lasts until reload */ }
27
+ }
28
+ // The header button flips what the reader sees; flipping back to the system's scheme returns to Auto.
29
+ function toggle() {
30
+ const shown = current() === 'auto' ? system() : current()
31
+ const next = shown === 'dark' ? 'light' : 'dark'
32
+ choose(next === system() ? 'auto' : next)
33
+ }
34
+ const OPTIONS = ['auto', 'light', 'dark'] as const
35
+ </script>
36
+
37
+ <template>
38
+ <div v-if="props.segmented" class="docs-segmented" role="group" :aria-label="t('docs.theme.label')">
39
+ <button v-for="o in OPTIONS" :key="o" type="button" :aria-pressed="choice === o" @click="choose(o)">{{ t(`docs.theme.${o}`) }}</button>
40
+ </div>
41
+ <button v-else type="button" class="docs-icon-button docs-theme-toggle" :aria-label="t('docs.theme.toggle')" :title="t('docs.theme.toggle')" @click="toggle">
42
+ <DocsIcon name="lucide:moon" class="docs-icon docs-when-light" />
43
+ <DocsIcon name="lucide:sun" class="docs-icon docs-when-dark" />
44
+ </button>
45
+ </template>
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ // The table of contents of a page: a rail beside the text on wide screens (`aside`), a menu in the docs bar
3
+ // on narrower ones (`menu`). The current section is the last heading that has passed the top of the screen.
4
+ interface TocLink { id: string, text: string, children?: TocLink[] }
5
+ const props = defineProps<{ links: TocLink[], variant: 'aside' | 'menu' }>()
6
+ const { t } = useI18n()
7
+ const { open, toggle } = useDocsPopover()
8
+
9
+ const flat = computed(() => props.links.flatMap((l) => [{ ...l, sub: false }, ...(l.children ?? []).map((c) => ({ ...c, sub: true }))]))
10
+ const current = ref<string>()
11
+
12
+ // A scroll listener instead of an IntersectionObserver: the text may render after this component on client
13
+ // navigation, and looking the headings up on each (throttled) scroll needs no bookkeeping for that.
14
+ let frame = 0
15
+ function spy() {
16
+ cancelAnimationFrame(frame)
17
+ frame = requestAnimationFrame(() => {
18
+ const top = Number.parseFloat(getComputedStyle(document.documentElement).scrollPaddingTop) || 96
19
+ let id: string | undefined
20
+ for (const l of flat.value) {
21
+ const el = document.getElementById(l.id)
22
+ if (el && el.getBoundingClientRect().top <= top + 8) id = l.id
23
+ }
24
+ current.value = id ?? flat.value[0]?.id
25
+ })
26
+ }
27
+ onMounted(() => {
28
+ spy()
29
+ window.addEventListener('scroll', spy, { passive: true })
30
+ })
31
+ onBeforeUnmount(() => {
32
+ cancelAnimationFrame(frame)
33
+ window.removeEventListener('scroll', spy)
34
+ })
35
+ watch(() => props.links, () => nextTick(spy))
36
+ // A long table of contents scrolls in its own box: keep the current entry in view (the box only, not the page).
37
+ const aside = ref<HTMLElement | null>(null)
38
+ watch(current, (id) => {
39
+ const box = aside.value
40
+ const link = id ? box?.querySelector<HTMLElement>(`a[href="#${CSS.escape(id)}"]`) : undefined
41
+ if (!box || !link || box.scrollHeight <= box.clientHeight) return
42
+ const top = link.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop
43
+ if (top < box.scrollTop + 32 || top > box.scrollTop + box.clientHeight - 48) box.scrollTop = top - box.clientHeight / 3
44
+ })
45
+ const title = computed(() => flat.value.find((l) => l.id === current.value)?.text)
46
+ </script>
47
+
48
+ <template>
49
+ <nav v-if="props.variant === 'aside'" ref="aside" class="docs-toc" :aria-label="t('docs.onThisPage')">
50
+ <p class="docs-toc-title">
51
+ <DocsIcon name="lucide:text-align-start" class="docs-icon" />{{ t('docs.onThisPage') }}
52
+ </p>
53
+ <ul class="docs-toc-list">
54
+ <li v-for="l in flat" :key="l.id">
55
+ <a :href="`#${l.id}`" :class="{ 'is-sub': l.sub }" :aria-current="current === l.id ? 'location' : undefined">{{ l.text }}</a>
56
+ </li>
57
+ </ul>
58
+ <slot />
59
+ </nav>
60
+ <div v-else ref="popover" class="docs-toc-menu">
61
+ <button type="button" :aria-expanded="open" aria-controls="docs-toc-menu" @click="toggle">
62
+ <span>{{ title && flat.length > 1 ? title : t('docs.onThisPage') }}</span>
63
+ <DocsIcon name="lucide:chevron-down" class="docs-icon" />
64
+ </button>
65
+ <nav v-if="open" id="docs-toc-menu" class="docs-popover" :aria-label="t('docs.onThisPage')">
66
+ <ul class="docs-toc-list">
67
+ <li v-for="l in flat" :key="l.id">
68
+ <a :href="`#${l.id}`" :class="{ 'is-sub': l.sub }" :aria-current="current === l.id ? 'location' : undefined" @click="toggle">{{ l.text }}</a>
69
+ </li>
70
+ </ul>
71
+ </nav>
72
+ </div>
73
+ </template>
@@ -0,0 +1,53 @@
1
+ // What is docs-specific on top of Ginko's navigation: the tree comes from `useGinkoNav`, the
2
+ // breadcrumb chain from `useGinkoBreadcrumbs`; this adds tabs and sections from the entries' fields.
3
+ import schema from '#ginko/schema'
4
+ import { enrichNav, type DocNode, type DocTab } from '../lib/docsNav'
5
+
6
+ export function useDocs() {
7
+ const items = useGinko('docs')
8
+ const tree = useGinkoNav(['docs'])
9
+ const chain = useGinkoBreadcrumbs(['docs'])
10
+
11
+ // Ginko's nodes carry title and path; badge, icon, section and tab live on the entry (rules: enrichNav).
12
+ const nodes = computed<DocNode[]>(() => {
13
+ const byId = new Map(items.value.items.map((d) => [d.id, d.data]))
14
+ return enrichNav(tree.value, (id) => byId.get(id))
15
+ })
16
+
17
+ const tabs = computed<DocTab[]>(() => {
18
+ const out = new Map<string, DocTab>()
19
+ for (const n of nodes.value) if (n.tab && !out.has(n.tab)) out.set(n.tab, { title: n.tab, path: n.path })
20
+ return out.size > 1 ? [...out.values()] : []
21
+ })
22
+ const activeTab = computed(() => (tabs.value.length ? nodes.value.find((n) => n.path === chain.value[0]?.path)?.tab ?? tabs.value[0]!.title : undefined))
23
+
24
+ // Roots of the active tab, grouped by `section` (a section is a run of neighbours with the same one).
25
+ const sections = computed(() => {
26
+ const out: { title?: string; nodes: DocNode[] }[] = []
27
+ for (const node of nodes.value) {
28
+ if (tabs.value.length && (node.tab ?? tabs.value[0]!.title) !== activeTab.value) continue
29
+ const last = out.at(-1)
30
+ if (last && last.title === node.section) last.nodes.push(node)
31
+ else out.push({ title: node.section, nodes: [node] })
32
+ }
33
+ return out
34
+ })
35
+
36
+ // Tab (when tabs are shown), then the parent chain down to this page.
37
+ const crumbs = computed<{ title: string; path: string }[]>(() => {
38
+ const tab = tabs.value.find((t) => t.title === activeTab.value)
39
+ return [...(tab ? [tab] : []), ...chain.value.map(({ title, path }) => ({ title, path }))]
40
+ })
41
+
42
+ /** The first page of the docs in this language: where `/docs` (and `/` without a start page) lead. */
43
+ const first = computed(() => nodes.value[0]?.path)
44
+
45
+ /** Where the docs live in this language (docsCollection's `base`, `/docs` by default), e.g. `/de/docs`. */
46
+ const { localePath } = useI18n()
47
+ const { mainLocale } = useGinkoSite()
48
+ const route = (schema.bricks.docs as { route?: Record<string, string> } | undefined)?.route?.[mainLocale]
49
+ const mainBase = route?.replace(/\/\[slug\]$/, '') ?? '/docs'
50
+ const base = computed(() => localePath(mainBase))
51
+
52
+ return { nodes, tabs, activeTab, sections, crumbs, first, base, mainBase }
53
+ }
@@ -0,0 +1,33 @@
1
+ // The optional blog and changelog: whether the site has them, where they live in this language, and typed
2
+ // reads. The layer cannot know the site's bricks at compile time, so the reads go through one cast here.
3
+ import schema from '#ginko/schema'
4
+ import type { Author, ChangelogEntry, Post } from '../lib/blogTypes'
5
+
6
+
7
+ const has = (brick: string) => Object.hasOwn(schema.bricks, brick)
8
+ const read = <T>(brick: string) => (useGinko as unknown as (b: string) => ComputedRef<{ items: T[] }>)(brick)
9
+
10
+ export function useDocsBlog() {
11
+ const { localePath } = useI18n()
12
+ // The addresses of the bricks' lists in the main language, written by modules/pages.ts.
13
+ const address = useRuntimeConfig().public.ginkoDocs
14
+ const hasPosts = has('posts') && !!address.blog
15
+ const hasChangelog = has('changelog') && !!address.changelog
16
+
17
+ const authors = hasPosts && has('authors') ? read<Author>('authors') : undefined
18
+ const authorsOf = (post: Post) => (post.data.authors ?? []).flatMap((id) => authors?.value.items.find((a) => a.id === id) ?? [])
19
+ const { format } = useI18n()
20
+ const formatDate = (iso: string) => format.date(iso, 'long')
21
+
22
+ // `draft` is a reserved content key, no schema field; it reaches the bundle only in dev, where drafts show.
23
+ const isDraft = (post: Post) => 'draft' in post.data && post.data.draft === true
24
+ return {
25
+ hasPosts, hasChangelog, authorsOf, isDraft, formatDate,
26
+ blogPath: computed(() => localePath(address.blog)),
27
+ feedPath: computed(() => localePath(`${address.blog}/rss.xml`)),
28
+ changelogPath: computed(() => localePath(address.changelog)),
29
+ posts: () => read<Post>('posts'),
30
+ changelog: () => read<ChangelogEntry>('changelog'),
31
+ post: (slug: string) => (useGinkoEntry as unknown as (b: string, s: string) => Readonly<Ref<Post>>)('posts', slug),
32
+ }
33
+ }
@@ -0,0 +1,17 @@
1
+ // Copies text and shows the result for two seconds: `copied`, or `failed` when the clipboard is blocked.
2
+ export function useDocsCopy() {
3
+ const state = ref<'idle' | 'copied' | 'failed'>('idle')
4
+ let timer: ReturnType<typeof setTimeout> | undefined
5
+ async function copy(text: string | (() => Promise<string>)) {
6
+ try {
7
+ await navigator.clipboard.writeText(typeof text === 'string' ? text : await text())
8
+ state.value = 'copied'
9
+ } catch {
10
+ state.value = 'failed'
11
+ }
12
+ clearTimeout(timer)
13
+ timer = setTimeout(() => { state.value = 'idle' }, 2000)
14
+ }
15
+ onBeforeUnmount(() => clearTimeout(timer))
16
+ return { state, copy }
17
+ }
@@ -0,0 +1,11 @@
1
+ import type { InjectionKey } from 'vue'
2
+
3
+ // Whether the docs render in the layer's own frame (layouts/default.vue) or inside a site's layout. Standalone, the
4
+ // header holds the search and, on phones, the navigation; embedded, DocsShell brings both itself.
5
+ const STANDALONE: InjectionKey<boolean> = Symbol('docs-standalone')
6
+
7
+ /** Called by the layer's own layout. */
8
+ export const provideDocsStandalone = () => provide(STANDALONE, true)
9
+
10
+ /** True inside the layer's own frame, false when a site's layout holds the docs. */
11
+ export const useDocsStandalone = () => inject(STANDALONE, false)
@@ -0,0 +1,36 @@
1
+ // A small disclosure popover (language menu, page actions, phone table of contents): a button toggles a panel;
2
+ // a click outside, Escape or a page change closes it, and Escape returns focus to the button.
3
+ // The component puts `ref="popover"` on the element that holds both the button and the panel, and binds `toggle`
4
+ // to the button's click (Escape returns focus to that button).
5
+ export function useDocsPopover() {
6
+ const open = ref(false)
7
+ const root = useTemplateRef<HTMLElement>('popover')
8
+ const route = useRoute()
9
+ let trigger: HTMLElement | undefined
10
+
11
+ function close(refocus = false) {
12
+ if (!open.value) return
13
+ open.value = false
14
+ if (refocus) (trigger ?? root.value?.querySelector<HTMLElement>('[aria-expanded]'))?.focus()
15
+ }
16
+ const onClick = (e: MouseEvent) => { if (!root.value?.contains(e.target as Node)) close() }
17
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close(true) }
18
+ function listen(on: boolean) {
19
+ if (on) {
20
+ document.addEventListener('click', onClick, true)
21
+ document.addEventListener('keydown', onKey)
22
+ } else {
23
+ document.removeEventListener('click', onClick, true)
24
+ document.removeEventListener('keydown', onKey)
25
+ }
26
+ }
27
+
28
+ watch(open, (value) => listen(value))
29
+ watch(() => route.fullPath, () => close())
30
+ onBeforeUnmount(() => listen(false))
31
+ function toggle(event?: Event) {
32
+ if (event?.currentTarget instanceof HTMLElement) trigger = event.currentTarget
33
+ open.value = !open.value
34
+ }
35
+ return { open, toggle, close }
36
+ }
@@ -0,0 +1,31 @@
1
+ // The site's own links outside Markdown (app.config nav, footer and banner, the start page's links and cards): their
2
+ // texts and their addresses.
3
+ import schema from '#ginko/schema'
4
+ import { docsMainPath, docsRouteLink } from '../lib/docsPage'
5
+
6
+ export function useDocsSite() {
7
+ const { t, localePath } = useI18n()
8
+ const docs = useGinko('docs')
9
+ const locale = useGinkoLocale()
10
+ const { mainLocale } = useGinkoSite()
11
+ const pages = schema.pages as Record<string, Partial<Record<string, string>> | undefined>
12
+ const docsRoute = (schema.bricks as Record<string, { route?: Partial<Record<string, string>> } | undefined>).docs?.route
13
+ const find = {
14
+ // A page the current language lacks leads to its main-language page.
15
+ doc: (ref: string) => docs.value.items.find((d) => d.id === `docs/${ref}` || d.data.slug === ref)?.url ?? docsMainPath(docsRoute, mainLocale, ref),
16
+ page: (key: string) => pages[key]?.[locale.value] ?? pages[key]?.[mainLocale],
17
+ }
18
+ return {
19
+ /** A message key the site defines (`docs.nav.<id>`, `docs.footer.<id>`, `docs.banner.<id>`); the layer's types do not know them. */
20
+ text: t as (key: string) => string,
21
+ /**
22
+ * An address in the reader's language: a route link (`$docs.<page>`, `$page.<key>`) becomes the page's path, an
23
+ * internal path gets the language prefix; an absolute URL, a mail link or an anchor stays as written.
24
+ */
25
+ href: (raw: string) => {
26
+ // A route link that names nothing never becomes an address: it would be a relative link to a 404.
27
+ if (raw.startsWith('$')) return docsRouteLink(raw, find) ?? localePath('/')
28
+ return raw.startsWith('/') && !raw.startsWith('//') ? localePath(raw) : raw
29
+ },
30
+ }
31
+ }
@@ -0,0 +1,55 @@
1
+ <script setup lang="ts">
2
+ // The blog: all posts, newest first (the collection's order).
3
+ const { t, language } = useI18n()
4
+ const blog = useDocsBlog()
5
+ const langOf = useGinkoLang()
6
+ const posts = blog.posts()
7
+ // A list page: CollectionPage JSON-LD, so search engines read it as the index of its entries.
8
+ const site = useGinkoSite()
9
+ const route = useRoute()
10
+ useGinkoSeo(() => ({
11
+ title: t('docs.blog'),
12
+ description: t('docs.blogLead'),
13
+ jsonLd: [{ '@context': 'https://schema.org', '@type': 'CollectionPage', name: t('docs.blog'), url: new URL(route.path, site.url).href, inLanguage: language.value }],
14
+ }))
15
+ useHead(() => ({ link: [{ rel: 'alternate', type: 'application/rss+xml', title: t('docs.blog'), href: blog.feedPath.value }] }))
16
+ // Nitro's crawler only follows links to pages (no `.xml`), but it also reads this header, so `nuxt generate` writes the feed.
17
+ if (import.meta.server) useResponseHeader('x-nitro-prerender').value = blog.feedPath.value
18
+ </script>
19
+
20
+ <template>
21
+ <div class="docs-blog">
22
+ <div class="docs-blog-panel">
23
+ <header class="docs-blog-head">
24
+ <div>
25
+ <h1>{{ t('docs.blog') }}</h1>
26
+ <p class="docs-lead">{{ t('docs.blogLead') }}</p>
27
+ </div>
28
+ <a :href="blog.feedPath.value" type="application/rss+xml"><DocsIcon name="lucide:rss" class="docs-icon" />{{ t('docs.rss') }}</a>
29
+ </header>
30
+ <ul class="docs-posts">
31
+ <li v-for="post in posts.items" :key="post.id">
32
+ <div class="docs-post-side">
33
+ <p class="docs-meta"><time :datetime="post.data.date">{{ blog.formatDate(post.data.date) }}</time></p>
34
+ <p v-if="blog.authorsOf(post).length" class="docs-authors">
35
+ <template v-for="a in blog.authorsOf(post)" :key="a.id">
36
+ <GinkoImage v-if="a.data.avatar" :image="a.data.avatar" class="docs-avatar" />
37
+ </template>
38
+ {{ blog.authorsOf(post).map((a) => a.data.name).join(', ') }}
39
+ </p>
40
+ </div>
41
+ <div class="docs-post-main">
42
+ <h2>
43
+ <NuxtLink :to="post.url">{{ post.data.title }}</NuxtLink>
44
+ <span v-if="blog.isDraft(post)" class="docs-tag docs-tag-draft">{{ t('docs.draft') }}</span>
45
+ </h2>
46
+ <p v-if="post.data.description" class="docs-post-teaser" :lang="langOf(post, 'description')">{{ post.data.description }}</p>
47
+ <p v-if="post.data.tags?.length" class="docs-tags">
48
+ <span v-for="tag in post.data.tags" :key="tag.tag" class="docs-tag">{{ tag.tag }}</span>
49
+ </p>
50
+ </div>
51
+ </li>
52
+ </ul>
53
+ </div>
54
+ </div>
55
+ </template>
@@ -0,0 +1,35 @@
1
+ <script setup lang="ts">
2
+ import { releaseAnchor as anchor } from '@lupinum/ginko/basics'
3
+ import { markdownComponents } from '../lib/markdownComponents'
4
+ // The changelog: one page, one section per release, newest first (the collection's order). Each version has an anchor.
5
+ const { t, language } = useI18n()
6
+ const blog = useDocsBlog()
7
+ const langOf = useGinkoLang()
8
+ const entries = blog.changelog()
9
+ // A list page: CollectionPage JSON-LD, so search engines read it as the index of its entries.
10
+ const site = useGinkoSite()
11
+ const route = useRoute()
12
+ useGinkoSeo(() => ({
13
+ title: t('docs.changelog'),
14
+ description: t('docs.changelogLead'),
15
+ jsonLd: [{ '@context': 'https://schema.org', '@type': 'CollectionPage', name: t('docs.changelog'), url: new URL(route.path, site.url).href, inLanguage: language.value }],
16
+ }))
17
+ </script>
18
+
19
+ <template>
20
+ <div class="docs-blog">
21
+ <div class="docs-blog-panel">
22
+ <header class="docs-blog-head">
23
+ <h1>{{ t('docs.changelog') }}</h1>
24
+ </header>
25
+ <p v-if="!entries.items.length" class="docs-meta">{{ t('docs.changelogEmpty') }}</p>
26
+ <section v-for="entry in entries.items" :id="anchor(entry.data.version)" :key="entry.id" class="docs-release">
27
+ <div class="docs-release-meta">
28
+ <h2><a :href="`#${anchor(entry.data.version)}`">{{ entry.data.version }}</a></h2>
29
+ <p class="docs-meta"><time :datetime="entry.data.date">{{ blog.formatDate(entry.data.date) }}</time></p>
30
+ </div>
31
+ <GinkoMarkdown :value="entry.data.body" :components="markdownComponents" class="docs-prose" :lang="langOf(entry, 'body')" />
32
+ </section>
33
+ </div>
34
+ </div>
35
+ </template>
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ const route = useRoute()
3
+ const doc = useGinkoEntry('docs', () => route.params.slug)
4
+ // The toc comes with the text (page state holds only its ref); the same request as the page's <GinkoMarkdown>.
5
+ const tree = await useGinkoMarkdown(() => doc.value.data.body)
6
+ const toc = computed(() => tree.value?.toc?.links ?? [])
7
+ </script>
8
+
9
+ <template>
10
+ <DocsShell>
11
+ <template v-if="toc.length" #bar>
12
+ <DocsToc :links="toc" variant="menu" />
13
+ </template>
14
+ <DocsPage :doc="doc" />
15
+ </DocsShell>
16
+ </template>
@@ -0,0 +1,9 @@
1
+ <script setup lang="ts">
2
+ import { toFirstDoc } from '../lib/docsFirst'
3
+ // The docs address itself (`/docs`) has no page of its own: it leads to the first docs page of the language.
4
+ await toFirstDoc()
5
+ </script>
6
+
7
+ <template>
8
+ <div />
9
+ </template>