@kirchdev/duxt 0.1.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 (401) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +151 -0
  3. package/app/app.config.ts +13 -0
  4. package/app/app.vue +122 -0
  5. package/app/assets/css/duxt.css +555 -0
  6. package/app/assets/css/typeset.css +498 -0
  7. package/app/components/DuxtBrand.vue +53 -0
  8. package/app/components/DuxtBreadcrumb.vue +37 -0
  9. package/app/components/DuxtCodeBlock.vue +143 -0
  10. package/app/components/DuxtCopyPage.vue +117 -0
  11. package/app/components/DuxtFooter.vue +86 -0
  12. package/app/components/DuxtHeader.vue +496 -0
  13. package/app/components/DuxtJsonEditor.vue +359 -0
  14. package/app/components/DuxtLandingProse.vue +90 -0
  15. package/app/components/DuxtLandingShowcase.vue +295 -0
  16. package/app/components/DuxtLiveWindow.vue +343 -0
  17. package/app/components/DuxtLocale.vue +86 -0
  18. package/app/components/DuxtMetaList.vue +76 -0
  19. package/app/components/DuxtNavigation.vue +94 -0
  20. package/app/components/DuxtNavigationLink.vue +41 -0
  21. package/app/components/DuxtOpenApiCallbacks.vue +92 -0
  22. package/app/components/DuxtOpenApiClient.vue +1339 -0
  23. package/app/components/DuxtOpenApiMedia.vue +108 -0
  24. package/app/components/DuxtOpenApiMethod.vue +59 -0
  25. package/app/components/DuxtOpenApiParameters.vue +114 -0
  26. package/app/components/DuxtOpenApiResponses.vue +109 -0
  27. package/app/components/DuxtOpenApiSchema.vue +365 -0
  28. package/app/components/DuxtOpenApiSecurity.vue +115 -0
  29. package/app/components/DuxtPageFeedback.vue +56 -0
  30. package/app/components/DuxtPageInfo.vue +194 -0
  31. package/app/components/DuxtPageNav.vue +93 -0
  32. package/app/components/DuxtPreviewSkeleton.vue +262 -0
  33. package/app/components/DuxtProgress.vue +45 -0
  34. package/app/components/DuxtSearch.vue +271 -0
  35. package/app/components/DuxtSections.vue +65 -0
  36. package/app/components/DuxtShortcuts.vue +46 -0
  37. package/app/components/DuxtSkipLink.vue +34 -0
  38. package/app/components/DuxtToc.vue +86 -0
  39. package/app/components/DuxtTranslationBanner.vue +64 -0
  40. package/app/components/DuxtVersion.vue +168 -0
  41. package/app/components/DuxtVersionBanner.vue +109 -0
  42. package/app/components/OgImage/Duxt.satori.vue +82 -0
  43. package/app/components/content/Accordion.vue +37 -0
  44. package/app/components/content/AccordionItem.vue +46 -0
  45. package/app/components/content/Callout.vue +58 -0
  46. package/app/components/content/ChangelogGroup.vue +91 -0
  47. package/app/components/content/ChangelogReleases.vue +520 -0
  48. package/app/components/content/CodeGroup.vue +183 -0
  49. package/app/components/content/DevtoolsPanel.vue +107 -0
  50. package/app/components/content/Field.vue +50 -0
  51. package/app/components/content/FieldGroup.vue +8 -0
  52. package/app/components/content/FileTree.vue +92 -0
  53. package/app/components/content/Mermaid.vue +70 -0
  54. package/app/components/content/OpenApiOperation.vue +185 -0
  55. package/app/components/content/OpenApiOperations.vue +72 -0
  56. package/app/components/content/OpenApiOverview.vue +302 -0
  57. package/app/components/content/PackageManagers.vue +187 -0
  58. package/app/components/content/PageCards.vue +48 -0
  59. package/app/components/content/Partial.vue +65 -0
  60. package/app/components/content/Preview.vue +173 -0
  61. package/app/components/content/ProseA.vue +55 -0
  62. package/app/components/content/ProseH2.vue +53 -0
  63. package/app/components/content/ProseH3.vue +53 -0
  64. package/app/components/content/ProseH4.vue +53 -0
  65. package/app/components/content/ProseImg.vue +87 -0
  66. package/app/components/content/ProsePre.vue +58 -0
  67. package/app/components/content/ProseTable.vue +11 -0
  68. package/app/components/content/Since.vue +21 -0
  69. package/app/components/content/Steps.vue +20 -0
  70. package/app/components/content/Tab.vue +23 -0
  71. package/app/components/content/TabGroup.vue +58 -0
  72. package/app/components/ui/alert/Alert.vue +21 -0
  73. package/app/components/ui/alert/AlertDescription.vue +22 -0
  74. package/app/components/ui/alert/AlertTitle.vue +22 -0
  75. package/app/components/ui/alert/index.ts +24 -0
  76. package/app/components/ui/badge/Badge.vue +28 -0
  77. package/app/components/ui/badge/index.ts +52 -0
  78. package/app/components/ui/breadcrumb/Breadcrumb.vue +13 -0
  79. package/app/components/ui/breadcrumb/BreadcrumbEllipsis.vue +22 -0
  80. package/app/components/ui/breadcrumb/BreadcrumbItem.vue +17 -0
  81. package/app/components/ui/breadcrumb/BreadcrumbLink.vue +24 -0
  82. package/app/components/ui/breadcrumb/BreadcrumbList.vue +22 -0
  83. package/app/components/ui/breadcrumb/BreadcrumbPage.vue +20 -0
  84. package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +21 -0
  85. package/app/components/ui/breadcrumb/index.ts +7 -0
  86. package/app/components/ui/button/Button.vue +31 -0
  87. package/app/components/ui/button/index.ts +39 -0
  88. package/app/components/ui/card/Card.vue +22 -0
  89. package/app/components/ui/card/CardAction.vue +22 -0
  90. package/app/components/ui/card/CardContent.vue +14 -0
  91. package/app/components/ui/card/CardDescription.vue +17 -0
  92. package/app/components/ui/card/CardFooter.vue +17 -0
  93. package/app/components/ui/card/CardHeader.vue +22 -0
  94. package/app/components/ui/card/CardTitle.vue +17 -0
  95. package/app/components/ui/card/index.ts +7 -0
  96. package/app/components/ui/collapsible/Collapsible.vue +19 -0
  97. package/app/components/ui/collapsible/CollapsibleContent.vue +12 -0
  98. package/app/components/ui/collapsible/CollapsibleTrigger.vue +12 -0
  99. package/app/components/ui/collapsible/index.ts +3 -0
  100. package/app/components/ui/command/Command.vue +98 -0
  101. package/app/components/ui/command/CommandDialog.vue +42 -0
  102. package/app/components/ui/command/CommandEmpty.vue +31 -0
  103. package/app/components/ui/command/CommandGroup.vue +52 -0
  104. package/app/components/ui/command/CommandInput.vue +45 -0
  105. package/app/components/ui/command/CommandItem.vue +85 -0
  106. package/app/components/ui/command/CommandList.vue +32 -0
  107. package/app/components/ui/command/CommandSeparator.vue +23 -0
  108. package/app/components/ui/command/CommandShortcut.vue +19 -0
  109. package/app/components/ui/command/index.ts +29 -0
  110. package/app/components/ui/dialog/Dialog.vue +15 -0
  111. package/app/components/ui/dialog/DialogClose.vue +12 -0
  112. package/app/components/ui/dialog/DialogContent.vue +61 -0
  113. package/app/components/ui/dialog/DialogDescription.vue +25 -0
  114. package/app/components/ui/dialog/DialogFooter.vue +30 -0
  115. package/app/components/ui/dialog/DialogHeader.vue +17 -0
  116. package/app/components/ui/dialog/DialogOverlay.vue +28 -0
  117. package/app/components/ui/dialog/DialogScrollContent.vue +65 -0
  118. package/app/components/ui/dialog/DialogTitle.vue +25 -0
  119. package/app/components/ui/dialog/DialogTrigger.vue +12 -0
  120. package/app/components/ui/dialog/index.ts +10 -0
  121. package/app/components/ui/dropdown-menu/DropdownMenu.vue +19 -0
  122. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +47 -0
  123. package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +47 -0
  124. package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +12 -0
  125. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +41 -0
  126. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +27 -0
  127. package/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +21 -0
  128. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +48 -0
  129. package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +23 -0
  130. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +19 -0
  131. package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
  132. package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +34 -0
  133. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +34 -0
  134. package/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue +17 -0
  135. package/app/components/ui/dropdown-menu/index.ts +16 -0
  136. package/app/components/ui/input/Input.vue +35 -0
  137. package/app/components/ui/input/index.ts +1 -0
  138. package/app/components/ui/navigation-menu/NavigationMenu.vue +42 -0
  139. package/app/components/ui/navigation-menu/NavigationMenuContent.vue +35 -0
  140. package/app/components/ui/navigation-menu/NavigationMenuIndicator.vue +32 -0
  141. package/app/components/ui/navigation-menu/NavigationMenuItem.vue +23 -0
  142. package/app/components/ui/navigation-menu/NavigationMenuLink.vue +30 -0
  143. package/app/components/ui/navigation-menu/NavigationMenuList.vue +30 -0
  144. package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +31 -0
  145. package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +30 -0
  146. package/app/components/ui/navigation-menu/index.ts +14 -0
  147. package/app/components/ui/scroll-area/ScrollArea.vue +31 -0
  148. package/app/components/ui/scroll-area/ScrollBar.vue +38 -0
  149. package/app/components/ui/scroll-area/index.ts +2 -0
  150. package/app/components/ui/select/Select.vue +15 -0
  151. package/app/components/ui/select/SelectContent.vue +59 -0
  152. package/app/components/ui/select/SelectGroup.vue +19 -0
  153. package/app/components/ui/select/SelectItem.vue +42 -0
  154. package/app/components/ui/select/SelectItemText.vue +12 -0
  155. package/app/components/ui/select/SelectLabel.vue +16 -0
  156. package/app/components/ui/select/SelectScrollDownButton.vue +28 -0
  157. package/app/components/ui/select/SelectScrollUpButton.vue +28 -0
  158. package/app/components/ui/select/SelectSeparator.vue +20 -0
  159. package/app/components/ui/select/SelectTrigger.vue +32 -0
  160. package/app/components/ui/select/SelectValue.vue +12 -0
  161. package/app/components/ui/select/index.ts +11 -0
  162. package/app/components/ui/separator/Separator.vue +30 -0
  163. package/app/components/ui/separator/index.ts +1 -0
  164. package/app/components/ui/sheet/Sheet.vue +15 -0
  165. package/app/components/ui/sheet/SheetClose.vue +12 -0
  166. package/app/components/ui/sheet/SheetContent.vue +68 -0
  167. package/app/components/ui/sheet/SheetDescription.vue +23 -0
  168. package/app/components/ui/sheet/SheetFooter.vue +15 -0
  169. package/app/components/ui/sheet/SheetHeader.vue +15 -0
  170. package/app/components/ui/sheet/SheetOverlay.vue +28 -0
  171. package/app/components/ui/sheet/SheetTitle.vue +23 -0
  172. package/app/components/ui/sheet/SheetTrigger.vue +12 -0
  173. package/app/components/ui/sheet/index.ts +8 -0
  174. package/app/components/ui/sidebar/Sidebar.vue +110 -0
  175. package/app/components/ui/sidebar/SidebarContent.vue +23 -0
  176. package/app/components/ui/sidebar/SidebarFooter.vue +18 -0
  177. package/app/components/ui/sidebar/SidebarGroup.vue +18 -0
  178. package/app/components/ui/sidebar/SidebarGroupAction.vue +31 -0
  179. package/app/components/ui/sidebar/SidebarGroupContent.vue +18 -0
  180. package/app/components/ui/sidebar/SidebarGroupLabel.vue +30 -0
  181. package/app/components/ui/sidebar/SidebarHeader.vue +18 -0
  182. package/app/components/ui/sidebar/SidebarInput.vue +19 -0
  183. package/app/components/ui/sidebar/SidebarInset.vue +23 -0
  184. package/app/components/ui/sidebar/SidebarMenu.vue +18 -0
  185. package/app/components/ui/sidebar/SidebarMenuAction.vue +42 -0
  186. package/app/components/ui/sidebar/SidebarMenuBadge.vue +28 -0
  187. package/app/components/ui/sidebar/SidebarMenuButton.vue +60 -0
  188. package/app/components/ui/sidebar/SidebarMenuButtonChild.vue +36 -0
  189. package/app/components/ui/sidebar/SidebarMenuItem.vue +18 -0
  190. package/app/components/ui/sidebar/SidebarMenuSkeleton.vue +35 -0
  191. package/app/components/ui/sidebar/SidebarMenuSub.vue +24 -0
  192. package/app/components/ui/sidebar/SidebarMenuSubButton.vue +43 -0
  193. package/app/components/ui/sidebar/SidebarMenuSubItem.vue +18 -0
  194. package/app/components/ui/sidebar/SidebarProvider.vue +109 -0
  195. package/app/components/ui/sidebar/SidebarRail.vue +35 -0
  196. package/app/components/ui/sidebar/SidebarSeparator.vue +19 -0
  197. package/app/components/ui/sidebar/SidebarTrigger.vue +26 -0
  198. package/app/components/ui/sidebar/index.ts +62 -0
  199. package/app/components/ui/sidebar/utils.ts +19 -0
  200. package/app/components/ui/skeleton/Skeleton.vue +17 -0
  201. package/app/components/ui/skeleton/index.ts +1 -0
  202. package/app/components/ui/sonner/Sonner.vue +67 -0
  203. package/app/components/ui/sonner/index.ts +1 -0
  204. package/app/components/ui/tabs/Tabs.vue +26 -0
  205. package/app/components/ui/tabs/TabsContent.vue +23 -0
  206. package/app/components/ui/tabs/TabsList.vue +28 -0
  207. package/app/components/ui/tabs/TabsTrigger.vue +30 -0
  208. package/app/components/ui/tabs/index.ts +4 -0
  209. package/app/components/ui/textarea/Textarea.vue +32 -0
  210. package/app/components/ui/textarea/index.ts +1 -0
  211. package/app/components/ui/tooltip/Tooltip.vue +15 -0
  212. package/app/components/ui/tooltip/TooltipContent.vue +49 -0
  213. package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
  214. package/app/components/ui/tooltip/TooltipTrigger.vue +12 -0
  215. package/app/components/ui/tooltip/index.ts +4 -0
  216. package/app/composables/useActiveHeading.ts +90 -0
  217. package/app/composables/useDuxtAnimatedHeight.ts +155 -0
  218. package/app/composables/useDuxtBreadcrumb.ts +60 -0
  219. package/app/composables/useDuxtChoice.ts +28 -0
  220. package/app/composables/useDuxtCollection.ts +89 -0
  221. package/app/composables/useDuxtConfig.ts +47 -0
  222. package/app/composables/useDuxtNavigation.ts +87 -0
  223. package/app/composables/useDuxtPageFocus.ts +30 -0
  224. package/app/composables/useDuxtPath.ts +42 -0
  225. package/app/composables/useDuxtSearch.ts +208 -0
  226. package/app/composables/useDuxtSection.ts +44 -0
  227. package/app/composables/useDuxtSectionRow.ts +49 -0
  228. package/app/composables/useDuxtShortcuts.ts +58 -0
  229. package/app/composables/useDuxtSiteUrl.ts +27 -0
  230. package/app/composables/useDuxtToast.ts +25 -0
  231. package/app/composables/useDuxtVersion.ts +138 -0
  232. package/app/composables/useFuzzySearch.ts +83 -0
  233. package/app/composables/usePackageManager.ts +4 -0
  234. package/app/composables/useRecentPages.ts +52 -0
  235. package/app/error.vue +133 -0
  236. package/app/layouts/changelog.vue +85 -0
  237. package/app/layouts/default.vue +23 -0
  238. package/app/layouts/docs.vue +62 -0
  239. package/app/layouts/reference.vue +85 -0
  240. package/app/lib/utils.ts +7 -0
  241. package/app/middleware/duxt-section-layout.global.ts +57 -0
  242. package/app/pages/[...slug].vue +441 -0
  243. package/app/pages/index.vue +479 -0
  244. package/app/types/duxt.d.ts +955 -0
  245. package/app/utils/aside.ts +16 -0
  246. package/app/utils/changelog.ts +182 -0
  247. package/app/utils/code-highlight.ts +116 -0
  248. package/app/utils/duxt-config.ts +185 -0
  249. package/app/utils/duxt-text.ts +135 -0
  250. package/app/utils/file-icons.ts +62 -0
  251. package/app/utils/generated-sections.ts +190 -0
  252. package/app/utils/generated-toc.ts +216 -0
  253. package/app/utils/heading-link.ts +32 -0
  254. package/app/utils/locale-path.ts +45 -0
  255. package/app/utils/navigation-tree.ts +206 -0
  256. package/app/utils/nearest-page.ts +72 -0
  257. package/app/utils/openapi.ts +1028 -0
  258. package/app/utils/package-command.ts +162 -0
  259. package/app/utils/request-samples.ts +635 -0
  260. package/app/utils/section-scope.ts +97 -0
  261. package/app/utils/sheet-navigation.ts +100 -0
  262. package/app/utils/source-file.ts +84 -0
  263. package/app/utils/version-choices.ts +68 -0
  264. package/app/utils/version-paths.ts +65 -0
  265. package/bin/duxt-report.mjs +45 -0
  266. package/components.json +19 -0
  267. package/content-cache.ts +193 -0
  268. package/content.config.ts +38 -0
  269. package/duxt-app-config.ts +124 -0
  270. package/highlight-langs.ts +68 -0
  271. package/i18n/i18n.config.ts +16 -0
  272. package/i18n/locales/de/duxt/changelog.json +17 -0
  273. package/i18n/locales/de/duxt/code.json +13 -0
  274. package/i18n/locales/de/duxt/defaults.json +22 -0
  275. package/i18n/locales/de/duxt/devtools.json +7 -0
  276. package/i18n/locales/de/duxt/error.json +10 -0
  277. package/i18n/locales/de/duxt/footer.json +7 -0
  278. package/i18n/locales/de/duxt/locale.json +7 -0
  279. package/i18n/locales/de/duxt/nav.json +17 -0
  280. package/i18n/locales/de/duxt/openapi.json +89 -0
  281. package/i18n/locales/de/duxt/page.json +34 -0
  282. package/i18n/locales/de/duxt/search.json +12 -0
  283. package/i18n/locales/de/duxt/shortcuts.json +11 -0
  284. package/i18n/locales/de/duxt/theme.json +7 -0
  285. package/i18n/locales/de/duxt/toc.json +7 -0
  286. package/i18n/locales/de/duxt/version.json +25 -0
  287. package/i18n/locales/en/duxt/changelog.json +17 -0
  288. package/i18n/locales/en/duxt/code.json +13 -0
  289. package/i18n/locales/en/duxt/defaults.json +22 -0
  290. package/i18n/locales/en/duxt/devtools.json +7 -0
  291. package/i18n/locales/en/duxt/error.json +10 -0
  292. package/i18n/locales/en/duxt/footer.json +7 -0
  293. package/i18n/locales/en/duxt/locale.json +7 -0
  294. package/i18n/locales/en/duxt/nav.json +17 -0
  295. package/i18n/locales/en/duxt/openapi.json +89 -0
  296. package/i18n/locales/en/duxt/page.json +34 -0
  297. package/i18n/locales/en/duxt/search.json +12 -0
  298. package/i18n/locales/en/duxt/shortcuts.json +11 -0
  299. package/i18n/locales/en/duxt/theme.json +7 -0
  300. package/i18n/locales/en/duxt/toc.json +7 -0
  301. package/i18n/locales/en/duxt/version.json +25 -0
  302. package/i18n/locales/es/duxt/changelog.json +17 -0
  303. package/i18n/locales/es/duxt/code.json +13 -0
  304. package/i18n/locales/es/duxt/defaults.json +22 -0
  305. package/i18n/locales/es/duxt/devtools.json +7 -0
  306. package/i18n/locales/es/duxt/error.json +10 -0
  307. package/i18n/locales/es/duxt/footer.json +7 -0
  308. package/i18n/locales/es/duxt/locale.json +7 -0
  309. package/i18n/locales/es/duxt/nav.json +17 -0
  310. package/i18n/locales/es/duxt/openapi.json +89 -0
  311. package/i18n/locales/es/duxt/page.json +34 -0
  312. package/i18n/locales/es/duxt/search.json +12 -0
  313. package/i18n/locales/es/duxt/shortcuts.json +11 -0
  314. package/i18n/locales/es/duxt/theme.json +7 -0
  315. package/i18n/locales/es/duxt/toc.json +7 -0
  316. package/i18n/locales/es/duxt/version.json +25 -0
  317. package/i18n/locales/fr/duxt/changelog.json +17 -0
  318. package/i18n/locales/fr/duxt/code.json +13 -0
  319. package/i18n/locales/fr/duxt/defaults.json +22 -0
  320. package/i18n/locales/fr/duxt/devtools.json +7 -0
  321. package/i18n/locales/fr/duxt/error.json +10 -0
  322. package/i18n/locales/fr/duxt/footer.json +7 -0
  323. package/i18n/locales/fr/duxt/locale.json +7 -0
  324. package/i18n/locales/fr/duxt/nav.json +17 -0
  325. package/i18n/locales/fr/duxt/openapi.json +89 -0
  326. package/i18n/locales/fr/duxt/page.json +34 -0
  327. package/i18n/locales/fr/duxt/search.json +12 -0
  328. package/i18n/locales/fr/duxt/shortcuts.json +11 -0
  329. package/i18n/locales/fr/duxt/theme.json +7 -0
  330. package/i18n/locales/fr/duxt/toc.json +7 -0
  331. package/i18n/locales/fr/duxt/version.json +25 -0
  332. package/i18n/locales/pt/duxt/changelog.json +17 -0
  333. package/i18n/locales/pt/duxt/code.json +13 -0
  334. package/i18n/locales/pt/duxt/defaults.json +22 -0
  335. package/i18n/locales/pt/duxt/devtools.json +7 -0
  336. package/i18n/locales/pt/duxt/error.json +10 -0
  337. package/i18n/locales/pt/duxt/footer.json +7 -0
  338. package/i18n/locales/pt/duxt/locale.json +7 -0
  339. package/i18n/locales/pt/duxt/nav.json +17 -0
  340. package/i18n/locales/pt/duxt/openapi.json +89 -0
  341. package/i18n/locales/pt/duxt/page.json +34 -0
  342. package/i18n/locales/pt/duxt/search.json +12 -0
  343. package/i18n/locales/pt/duxt/shortcuts.json +11 -0
  344. package/i18n/locales/pt/duxt/theme.json +7 -0
  345. package/i18n/locales/pt/duxt/toc.json +7 -0
  346. package/i18n/locales/pt/duxt/version.json +25 -0
  347. package/i18n/locales/pt-BR/duxt/error.json +8 -0
  348. package/i18n/locales/pt-BR/duxt/nav.json +10 -0
  349. package/i18n/locales/pt-BR/duxt/page.json +10 -0
  350. package/i18n/locales/pt-BR/duxt/search.json +11 -0
  351. package/i18n/locales/pt-BR/duxt/version.json +13 -0
  352. package/mdc.config.ts +28 -0
  353. package/modules/config.ts +440 -0
  354. package/modules/devtools.ts +126 -0
  355. package/modules/git-meta.ts +349 -0
  356. package/modules/redirects.ts +152 -0
  357. package/modules/validate.ts +137 -0
  358. package/nuxt.config.ts +530 -0
  359. package/openapi-model.ts +358 -0
  360. package/openapi-parse.ts +1052 -0
  361. package/package.json +161 -0
  362. package/public/devtools/cache.html +318 -0
  363. package/public/devtools/checks.html +309 -0
  364. package/public/devtools/config.html +461 -0
  365. package/public/devtools/i18n.html +309 -0
  366. package/public/devtools/pages.html +348 -0
  367. package/public/devtools/paths.html +315 -0
  368. package/public/devtools/redirects.html +309 -0
  369. package/public/devtools/search.html +324 -0
  370. package/public/devtools/sources.html +309 -0
  371. package/public/devtools/versions.html +318 -0
  372. package/report.ts +303 -0
  373. package/repository-root.ts +26 -0
  374. package/section-reports.ts +55 -0
  375. package/sections-changelog.ts +644 -0
  376. package/sections-openapi.ts +450 -0
  377. package/sections-resolve.ts +711 -0
  378. package/sections.ts +157 -0
  379. package/server/devtools/content.ts +120 -0
  380. package/server/devtools/context.ts +38 -0
  381. package/server/devtools/entry-path.ts +30 -0
  382. package/server/devtools/handler.ts +104 -0
  383. package/server/devtools/preview.ts +461 -0
  384. package/server/devtools/render/content.ts +233 -0
  385. package/server/devtools/render/sources.ts +414 -0
  386. package/server/devtools/render/system.ts +352 -0
  387. package/server/devtools/shell.ts +611 -0
  388. package/server/devtools/sources.ts +122 -0
  389. package/server/devtools/system.ts +178 -0
  390. package/server/mcp/tools/list-pages.ts +52 -0
  391. package/server/mcp/tools/read-page.ts +57 -0
  392. package/server/mcp/tools/search-docs.ts +74 -0
  393. package/server/middleware/raw-markdown.ts +86 -0
  394. package/server/routes/llms-full.txt.get.ts +75 -0
  395. package/server/routes/llms.txt.get.ts +63 -0
  396. package/server/routes/rss.xml.get.ts +125 -0
  397. package/server/utils/duxt-server-text.ts +46 -0
  398. package/sources-git.ts +113 -0
  399. package/sources-resolve.ts +908 -0
  400. package/sources.ts +287 -0
  401. package/validate-report.ts +494 -0
@@ -0,0 +1,1339 @@
1
+ <script setup lang="ts">
2
+ import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui';
3
+ import type {
4
+ DuxtOpenApiOperation,
5
+ DuxtOpenApiSecurity,
6
+ DuxtOpenApiSecurityScheme,
7
+ DuxtOpenApiServer
8
+ } from '../../openapi-model';
9
+
10
+ /**
11
+ * The try-it client: the one part of a reference that is not a document.
12
+ *
13
+ * WHERE THE CREDENTIALS GO, because that is the question this component exists
14
+ * to answer honestly. Everything a reader types here lives in this component's
15
+ * own reactive state and nowhere else. It is never written to `localStorage`,
16
+ * `sessionStorage`, a cookie or the URL; it is never sent to the site serving
17
+ * these pages; and it does not survive a reload or a navigation to another
18
+ * operation. The only place a token goes is the request the reader pressed send
19
+ * on, to the server the reader chose. That is a deliberate trade: a client that
20
+ * remembered the token would be pleasanter to use and would put a live
21
+ * credential in browser storage on a page that is, by design, published on the
22
+ * open web.
23
+ *
24
+ * NO PROXY, EVER. The request is made by the reader's own browser with `fetch`,
25
+ * straight at the API. So a server that does not send CORS headers cannot be
26
+ * called from here, and the failure is reported as what it is rather than
27
+ * routed around: a proxy on the documentation site would be an open relay that
28
+ * forwards a reader's credentials to a host of their choosing, and every
29
+ * documentation site running it would be one.
30
+ *
31
+ * NO BOX FOR A COOKIE. `Cookie` is a forbidden header name for `fetch`, so a
32
+ * browser drops it with no error, no rejection and nothing in the console. A
33
+ * page therefore cannot satisfy an `apiKey` scheme declared `in: cookie`, nor a
34
+ * cookie parameter — and drawing a box for one would collect a live credential
35
+ * to throw it away, while the `curl` sample beneath showed the header present.
36
+ * So the client says so, the way it already does for `mutualTLS`, and the
37
+ * request it sends and the sample it prints are the same request again.
38
+ *
39
+ * Rendered on the server as well as the client — the form is a pure function of
40
+ * the operation, so there is nothing to mismatch, and a control the a11y gate
41
+ * cannot see is a control nothing checks the labels of.
42
+ */
43
+ const props = defineProps<{
44
+ operation: DuxtOpenApiOperation;
45
+ servers?: DuxtOpenApiServer[];
46
+ security?: DuxtOpenApiSecurity;
47
+ securitySchemes?: DuxtOpenApiSecurityScheme[];
48
+ /**
49
+ * `panel` — one card, which is what the 23rem column beside an endpoint has
50
+ * room for. `split` — the form and the samples as two, for a place with the
51
+ * whole width and no column to be narrow in. See the template.
52
+ */
53
+ layout?: 'panel' | 'split';
54
+ /**
55
+ * Which side the FORM takes in the first of the two split rows; the samples
56
+ * row then takes the other one.
57
+ *
58
+ * The page decides it, not this component: the rows are part of a run of
59
+ * bands that alternate down the whole landing page, and a component counting
60
+ * only its own two would sooner or later repeat the side of the band above
61
+ * it.
62
+ */
63
+ reverse?: boolean;
64
+ }>();
65
+
66
+ const split = computed(() => props.layout === 'split');
67
+
68
+ const id = useId();
69
+ const { t } = useI18n();
70
+ const notify = useDuxtToast();
71
+
72
+ const parameters = computed(() => props.operation.parameters ?? []);
73
+ const bodies = computed(() => props.operation.requestBody?.content ?? []);
74
+
75
+ /* ---------------------------------------------------------------- servers */
76
+
77
+ const servers = computed(() => props.servers ?? []);
78
+ const serverIndex = ref(0);
79
+ const server = computed(() => servers.value[serverIndex.value]);
80
+
81
+ /**
82
+ * The chosen server, as the STRING reka's Select carries.
83
+ *
84
+ * A list's value is a string in every listbox implementation there is, and the
85
+ * index it stands for is what everything downstream reads — so the conversion
86
+ * lives here, once, rather than in a `.number` modifier the component does not
87
+ * have.
88
+ */
89
+ const serverValue = computed({
90
+ get: () => String(serverIndex.value),
91
+ set: (value: string) => {
92
+ serverIndex.value = Number(value);
93
+ }
94
+ });
95
+
96
+ /** One box per `{variable}` the chosen server's URL still carries. */
97
+ const variables = ref<Record<string, string>>({});
98
+
99
+ const base = computed(() => openApiServerUrl(server.value, variables.value));
100
+
101
+ /* ------------------------------------------------------------- parameters */
102
+
103
+ /**
104
+ * The boxes, prefilled from the document.
105
+ *
106
+ * A reader who presses send on an untouched form should get a request that at
107
+ * least reaches the endpoint, so a parameter starts at whatever the document
108
+ * called an example — its own, its default, or the first member of its enum.
109
+ */
110
+ const values = ref<Record<string, string>>(
111
+ Object.fromEntries(
112
+ (props.operation.parameters ?? [])
113
+ // No box is drawn for a cookie, so no state is kept for one: the form and
114
+ // what is sent say the same thing about every parameter.
115
+ .filter((parameter) => parameter.in !== 'cookie')
116
+ .map((parameter) => {
117
+ const derived = openApiExampleValue(parameter.schema, 'request');
118
+ const example = parameter.examples?.[0]?.value ?? derived;
119
+
120
+ return [
121
+ openApiParameterKey(parameter),
122
+ example === null || example === undefined
123
+ ? ''
124
+ : typeof example === 'string'
125
+ ? example
126
+ : openApiJson(example)
127
+ ];
128
+ })
129
+ )
130
+ );
131
+
132
+ /* ------------------------------------------------------------------- body */
133
+
134
+ const bodyIndex = ref(0);
135
+ const media = computed(() => bodies.value[bodyIndex.value]);
136
+
137
+ /**
138
+ * Is this body a shape a form can say?
139
+ *
140
+ * The answer follows the CHOSEN media type, not the operation: a document may
141
+ * describe the same endpoint as flat JSON and as multipart, and only one of
142
+ * those is a form. See `openApiBodyForm` for what disqualifies a schema.
143
+ */
144
+ const bodyForm = computed(() => openApiBodyForm(media.value?.schema));
145
+
146
+ /** JSON only: a form over `text/csv` would be a form over one string. */
147
+ const formable = computed(
148
+ () => bodyForm.value.expressible && /json/i.test(media.value?.type ?? '')
149
+ );
150
+
151
+ const bodyFields = computed(() =>
152
+ bodyForm.value.expressible ? bodyForm.value.fields : []
153
+ );
154
+
155
+ /**
156
+ * What the editor knows about this body — wider than the form's fields.
157
+ *
158
+ * A body the form gives up on still has keys worth offering and worth
159
+ * checking: see `openApiBodyKeys` for why the two questions are not the same
160
+ * one asked twice.
161
+ */
162
+ const bodyKeys = computed(() => openApiBodyKeys(media.value?.schema));
163
+
164
+ /**
165
+ * Which view the reader is in — remembered, for the reason the sample is.
166
+ *
167
+ * The choice between a form and raw JSON is a way of working rather than a
168
+ * property of one endpoint: somebody who edits the body as JSON means it on
169
+ * the next one too. Falls back to the form the moment it cannot serve.
170
+ */
171
+ const duxt = useDuxtConfig();
172
+
173
+ const storedMode = useDuxtChoice('request-body-view');
174
+
175
+ const bodyMode = computed({
176
+ get: (): 'form' | 'json' => (storedMode.value === 'json' ? 'json' : 'form'),
177
+ set: (value: 'form' | 'json') => {
178
+ storedMode.value = value;
179
+ }
180
+ });
181
+
182
+ /**
183
+ * The two views, with their labels written out rather than built from the
184
+ * mode: `tests/i18n-ownership.test.ts` reads the sources for the keys a locale
185
+ * file ships, and a key assembled at runtime is a key nothing points at.
186
+ */
187
+ const bodyViews = [
188
+ { value: 'form' as const, label: 'duxt.openapi.client.form' },
189
+ { value: 'json' as const, label: 'duxt.openapi.client.json' }
190
+ ];
191
+
192
+ const mode = computed(() => (formable.value ? bodyMode.value : 'json'));
193
+
194
+ /**
195
+ * The form's boxes, derived from the TEXT rather than kept beside it.
196
+ *
197
+ * One value, two views: whatever was last typed in the editor is what the form
198
+ * opens on, and writing a box back edits that same text. Two states would drift
199
+ * the first time a reader switched tabs mid-edit — and the text is the one that
200
+ * has to win, because it is what is sent.
201
+ */
202
+ const bodyValues = computed({
203
+ get: () => openApiBodyValues(bodyFields.value, body.value),
204
+ set: (values: Record<string, string>) => {
205
+ body.value = openApiBodyJson(bodyFields.value, values);
206
+ }
207
+ });
208
+
209
+ function setBodyValue(name: string, value: string) {
210
+ bodyValues.value = { ...bodyValues.value, [name]: value };
211
+ }
212
+
213
+ /** What the body is not, while it is not JSON. Empty is not an error. */
214
+ const bodyError = computed(() => {
215
+ const text = body.value.trim();
216
+ if (!text) return undefined;
217
+
218
+ try {
219
+ JSON.parse(text);
220
+ return undefined;
221
+ } catch (error) {
222
+ return error instanceof Error ? error.message : String(error);
223
+ }
224
+ });
225
+
226
+ /** Two spaces, the way every other JSON on these pages is written. */
227
+ function formatBody() {
228
+ try {
229
+ body.value = openApiJson(JSON.parse(body.value));
230
+ } catch {
231
+ // Nothing to format yet; the error under the box already says so.
232
+ }
233
+ }
234
+
235
+ /** The chosen media type, as a string — see `serverValue`. */
236
+ const bodyValue = computed({
237
+ get: () => String(bodyIndex.value),
238
+ set: (value: string) => {
239
+ bodyIndex.value = Number(value);
240
+ }
241
+ });
242
+
243
+ const body = ref(
244
+ bodies.value.length
245
+ ? openApiJson(
246
+ bodies.value[0]!.examples?.[0]?.value ??
247
+ openApiExampleValue(bodies.value[0]!.schema, 'request')
248
+ )
249
+ : ''
250
+ );
251
+
252
+ /* ------------------------------------------------------------------- auth */
253
+
254
+ /**
255
+ * The schemes this operation actually accepts.
256
+ *
257
+ * Every alternative is offered rather than only the first: which one a reader
258
+ * holds a credential for is not something the page can know, and a client that
259
+ * only ever asks for the first is a client half the readers cannot use.
260
+ */
261
+ const schemes = computed(() => {
262
+ const wanted = new Set(
263
+ (props.security ?? []).flatMap((alternative) =>
264
+ alternative.map((requirement) => requirement.key)
265
+ )
266
+ );
267
+
268
+ return (props.securitySchemes ?? []).filter((scheme) =>
269
+ wanted.has(scheme.key)
270
+ );
271
+ });
272
+
273
+ /** In memory, for the life of this component, and deliberately nowhere else. */
274
+ const credentials = ref<Record<string, string>>({});
275
+
276
+ /**
277
+ * A field pre-filled from the document's own `x-duxt-example`.
278
+ *
279
+ * For a demo endpoint that answers to any token, or to one known one: without
280
+ * it a reader has to invent a value before the button does anything, which is a
281
+ * step between "I see a client" and "I saw it work".
282
+ *
283
+ * ONLY WHAT THE DOCUMENT PUBLISHES. Nothing is read from storage and nothing is
284
+ * kept — see the note at the top of this file — so this is a value that was
285
+ * already public, or it is not there at all.
286
+ */
287
+ watchEffect(() => {
288
+ for (const scheme of schemes.value) {
289
+ if (scheme.example && credentials.value[scheme.key] === undefined) {
290
+ credentials.value[scheme.key] = scheme.example;
291
+ }
292
+ }
293
+ });
294
+ const usernames = ref<Record<string, string>>({});
295
+
296
+ const isBasic = (scheme: DuxtOpenApiSecurityScheme) =>
297
+ scheme.type === 'http' && scheme.scheme?.toLowerCase() === 'basic';
298
+
299
+ /** A scheme no browser can satisfy, said out loud rather than drawn as a box. */
300
+ const isClientCertificate = (scheme: DuxtOpenApiSecurityScheme) =>
301
+ scheme.type === 'mutualTLS';
302
+
303
+ /** The other one: a page cannot set a cookie, so it is said rather than drawn. */
304
+ const isCookieKey = (scheme: DuxtOpenApiSecurityScheme) =>
305
+ scheme.type === 'apiKey' && scheme.in === 'cookie';
306
+
307
+ /* ---------------------------------------------------------------- request */
308
+
309
+ /** What the credentials add to the request, per the scheme that asked for them. */
310
+ function authFor(): { headers: Record<string, string>; query: string[] } {
311
+ const headers: Record<string, string> = {};
312
+ const query: string[] = [];
313
+
314
+ for (const scheme of schemes.value) {
315
+ const value = credentials.value[scheme.key]?.trim();
316
+ if (!value) continue;
317
+
318
+ if (scheme.type === 'apiKey' && scheme.name) {
319
+ // `in: cookie` is absent on purpose: it draws no box, so it holds no
320
+ // value, and a `Cookie` header would be dropped by the browser anyway.
321
+ if (scheme.in === 'header') headers[scheme.name] = value;
322
+ else if (scheme.in === 'query') {
323
+ query.push(
324
+ `${encodeURIComponent(scheme.name)}=${encodeURIComponent(value)}`
325
+ );
326
+ }
327
+
328
+ continue;
329
+ }
330
+
331
+ if (isBasic(scheme)) {
332
+ const user = usernames.value[scheme.key] ?? '';
333
+ // `btoa` is browser-only; on the server this function is never called.
334
+ headers.Authorization = `Basic ${globalThis.btoa?.(`${user}:${value}`) ?? ''}`;
335
+ continue;
336
+ }
337
+
338
+ if (scheme.type === 'http') {
339
+ const word = scheme.scheme
340
+ ? scheme.scheme[0]!.toUpperCase() + scheme.scheme.slice(1)
341
+ : 'Bearer';
342
+ headers.Authorization = `${word} ${value}`;
343
+ continue;
344
+ }
345
+
346
+ // oauth2 and openIdConnect both end in a bearer token; this client asks for
347
+ // the token rather than running a flow, which would need a redirect URI
348
+ // registered against a documentation site.
349
+ headers.Authorization = `Bearer ${value}`;
350
+ }
351
+
352
+ return { headers, query };
353
+ }
354
+
355
+ const request = computed(() => {
356
+ const auth = authFor();
357
+
358
+ const built = openApiRequest(
359
+ props.operation,
360
+ base.value,
361
+ values.value,
362
+ {
363
+ ...(media.value ? { 'Content-Type': media.value.type } : {}),
364
+ ...auth.headers
365
+ },
366
+ bodies.value.length && body.value.trim() ? body.value : undefined
367
+ );
368
+
369
+ if (!auth.query.length) return built;
370
+
371
+ return {
372
+ ...built,
373
+ url: `${built.url}${built.url.includes('?') ? '&' : '?'}${auth.query.join('&')}`
374
+ };
375
+ });
376
+
377
+ /**
378
+ * The samples this site offers, each already written for the current request.
379
+ *
380
+ * `generate` runs here rather than at build time because the request is being
381
+ * edited: a path parameter or a body character changes and every sample has to
382
+ * say the new thing. `label` and `group` arrive already resolved — a consumer's
383
+ * own entry may carry an i18n key, and `useDuxtConfig` collapses all three text
384
+ * forms before a component ever sees them.
385
+ */
386
+ /**
387
+ * The samples this site offers, each already written for the current request.
388
+ *
389
+ * `generate` runs here rather than at build time because the request is being
390
+ * edited: a path parameter or a body character changes and every sample has to
391
+ * say the new thing. `label` and `group` arrive already resolved — a consumer's
392
+ * own entry may carry an i18n key, and `useDuxtConfig` collapses all three text
393
+ * forms before a component ever sees them.
394
+ *
395
+ * A SPEC SAMPLE REPLACES THE GENERATED ONES FOR ITS LANGUAGE. `x-codeSamples` is
396
+ * written by the person who owns the API, who knows its idioms better than a
397
+ * generator does — so where a document carries a `php` sample, PHP's tab shows
398
+ * that instead of Guzzle and Laravel. The price is that it is STATIC: it cannot
399
+ * follow the reader's edits, which is why it is labelled rather than quietly
400
+ * mixed in.
401
+ *
402
+ * It keeps the shipped group's name where the language matches one, so a `php`
403
+ * sample joins a tab reading PHP rather than opening a second one spelled
404
+ * differently.
405
+ */
406
+ const samples = computed(() => {
407
+ const shipped = resolveRequestSamples(duxt.requestSamples).map((entry) => ({
408
+ id: entry.id,
409
+ language: entry.language,
410
+ label: asText(entry.label) ?? entry.id,
411
+ group: asText(entry.group) ?? entry.id,
412
+ icon: entry.icon,
413
+ code: entry.generate(request.value),
414
+ fromSpec: false
415
+ }));
416
+
417
+ const written = props.operation.codeSamples ?? [];
418
+ if (!written.length) return shipped;
419
+
420
+ const replaced = new Set(written.map((entry) => entry.lang));
421
+
422
+ return [
423
+ ...shipped.filter((entry) => !replaced.has(entry.language)),
424
+ ...written.map((entry, index) => ({
425
+ id: `spec:${entry.lang}:${index}`,
426
+ language: entry.lang,
427
+ label: entry.label ?? entry.lang,
428
+ group:
429
+ shipped.find((candidate) => candidate.language === entry.lang)?.group ??
430
+ entry.lang,
431
+ // A spec sample carries no icon of its own; where it joins a shipped
432
+ // group that group keeps its mark, and where it opens one of its own the
433
+ // language answers for it.
434
+ icon: shipped.find((candidate) => candidate.language === entry.lang)
435
+ ?.icon,
436
+ code: entry.source,
437
+ fromSpec: true
438
+ }))
439
+ ];
440
+ });
441
+
442
+ /**
443
+ * REMEMBERED ACROSS PAGES, like the package manager: a reader who works in
444
+ * `curl` works in `curl` on the next endpoint too, and asking again on every
445
+ * page of a forty-endpoint reference is asking forty times. The cookie travels
446
+ * with the request, so the server already renders the right tab.
447
+ *
448
+ * The ID is what is stored, not an index: the list is configurable and open, so
449
+ * a position means nothing across two sites, or across one that adds an entry.
450
+ */
451
+ const stored = useDuxtChoice('request-sample');
452
+
453
+ const sample = computed({
454
+ get: () =>
455
+ samples.value.find((entry) => entry.id === stored.value)?.id ??
456
+ samples.value[0]!.id,
457
+ set: (value: string) => {
458
+ stored.value = value;
459
+ }
460
+ });
461
+
462
+ const shown = computed(() =>
463
+ samples.value.find((entry) => entry.id === sample.value)!
464
+ );
465
+
466
+ /**
467
+ * TWO LEVELS, because the registry is open and a flat strip is not.
468
+ *
469
+ * The language is the strip and the client is a select inside it, so PHP with
470
+ * Guzzle, Laravel and the curl extension costs one tab rather than three — and a
471
+ * consumer adding three Ruby generators still costs one. Choosing a language
472
+ * moves to its first client; the cookie holds the client, so the language it
473
+ * belongs to needs no second cookie.
474
+ */
475
+ const groups = computed(() => [
476
+ ...new Set(samples.value.map((entry) => entry.group))
477
+ ]);
478
+
479
+ const group = computed({
480
+ get: () => shown.value.group,
481
+ set: (value: string) => {
482
+ const first = samples.value.find((entry) => entry.group === value);
483
+ if (first) sample.value = first.id;
484
+ }
485
+ });
486
+
487
+ /**
488
+ * The mark for a language tab.
489
+ *
490
+ * A sample's own `icon` where it names one, and `fileIcon(language)` otherwise —
491
+ * which is the fallback and not the rule, because the language is the GRAMMAR: a
492
+ * `$fetch` sample asks for `typescript`, so the JavaScript tab used to wear a
493
+ * TypeScript logo, and `curl` a generic shell file.
494
+ */
495
+ function groupIcon(group: string): string {
496
+ const named = samples.value.find(
497
+ (entry) => entry.group === group && entry.icon
498
+ );
499
+ if (named?.icon) return named.icon;
500
+
501
+ return fileIcon(
502
+ samples.value.find((entry) => entry.group === group)?.language
503
+ );
504
+ }
505
+
506
+ const clients = computed(() =>
507
+ samples.value.filter((entry) => entry.group === group.value)
508
+ );
509
+
510
+ /**
511
+ * The sample, coloured the way every other block on the page is.
512
+ *
513
+ * A STABLE KEY with `shown` watched, rather than the code in the key: the
514
+ * sample is rewritten on every keystroke, and keying on it would leave one
515
+ * cache entry per character typed. This way the server highlights what it
516
+ * renders and the client replaces it as the request changes.
517
+ */
518
+ const { data: sampleHtml } = await useAsyncData(
519
+ `duxt-openapi-sample-${id}`,
520
+ () => {
521
+ const lang = duxtCodeLang(shown.value.language);
522
+
523
+ return lang
524
+ ? highlightCode(shown.value.code, lang)
525
+ : // `null`, not `undefined`: Nuxt reads a handler that resolves to
526
+ // nothing as one that failed to return, warns, and repeats the request
527
+ // on the client. Here nothing to highlight is a real answer.
528
+ Promise.resolve(null);
529
+ },
530
+ { watch: [shown] }
531
+ );
532
+
533
+ const copiedSample = ref(false);
534
+
535
+ async function copySample() {
536
+ try {
537
+ await navigator.clipboard.writeText(shown.value.code);
538
+ copiedSample.value = true;
539
+ notify.success(t('duxt.code.copiedToast'));
540
+ setTimeout(() => (copiedSample.value = false), 2000);
541
+ } catch {
542
+ notify.error(t('duxt.page.copyFailed'));
543
+ }
544
+ }
545
+
546
+ /* ------------------------------------------------------------------- send */
547
+
548
+ interface Result {
549
+ status: number;
550
+ statusText: string;
551
+ duration: number;
552
+ headers: [string, string][];
553
+ body: string;
554
+ }
555
+
556
+ /** How long the send button shows its spinner, however fast the answer is. */
557
+ const MINIMUM_WAIT = 300;
558
+
559
+ const sending = ref(false);
560
+ const result = ref<Result>();
561
+ const failure = ref<string>();
562
+
563
+ async function send() {
564
+ sending.value = true;
565
+ // THE PREVIOUS ANSWER STAYS UNTIL THIS ONE ARRIVES. Clearing it here emptied
566
+ // the panel the moment the button was pressed, so the page collapsed by the
567
+ // height of a response and grew back a moment later — under the cursor that
568
+ // had just clicked. It is dimmed and marked busy instead, and replaced when
569
+ // there is something to replace it with.
570
+
571
+ const started = performance.now();
572
+
573
+ try {
574
+ const response = await fetch(request.value.url, {
575
+ method: request.value.method,
576
+ headers: request.value.headers,
577
+ body: request.value.body
578
+ });
579
+
580
+ const text = await response.text();
581
+
582
+ failure.value = undefined;
583
+ result.value = {
584
+ status: response.status,
585
+ statusText: response.statusText,
586
+ duration: Math.round(performance.now() - started),
587
+ headers: [...response.headers.entries()],
588
+ body: pretty(text)
589
+ };
590
+ } catch (error) {
591
+ // `fetch` rejects with a bare TypeError for a CORS refusal, a DNS failure
592
+ // and an offline browser alike — the browser deliberately tells the page
593
+ // nothing more — so the message says what the possibilities are rather than
594
+ // pretending to know which one happened.
595
+ result.value = undefined;
596
+ failure.value =
597
+ error instanceof Error && error.name !== 'TypeError'
598
+ ? error.message
599
+ : t('duxt.openapi.client.blocked');
600
+ } finally {
601
+ // A FLOOR UNDER THE WAIT. The demo endpoint answers in a few milliseconds,
602
+ // so the spinner appeared and vanished inside one frame — which reads as
603
+ // the button having flickered rather than as a request having been made.
604
+ // Held long enough to be seen, and the button stays disabled for it, so the
605
+ // same request cannot be fired twice while the first is in flight.
606
+ const elapsed = performance.now() - started;
607
+ if (elapsed < MINIMUM_WAIT) {
608
+ await new Promise((resolve) =>
609
+ setTimeout(resolve, MINIMUM_WAIT - elapsed)
610
+ );
611
+ }
612
+
613
+ sending.value = false;
614
+ }
615
+ }
616
+
617
+ /**
618
+ * The sample card follows its content's height instead of switching to it — the
619
+ * same behaviour `CodeGroup` has, out of the same composable. Not in `panel`
620
+ * layout: there the card sits in a column that scrolls as a whole and should
621
+ * size to its content at once.
622
+ */
623
+ const sampleShell = useTemplateRef<HTMLElement>('sampleShell');
624
+ const sampleBody = useTemplateRef<HTMLElement>('sampleBody');
625
+
626
+ useDuxtAnimatedHeight(sampleShell, sampleBody, () => split.value);
627
+
628
+ /** A JSON body, indented; anything else exactly as it arrived. */
629
+ function pretty(text: string): string {
630
+ try {
631
+ return JSON.stringify(JSON.parse(text), null, 2);
632
+ } catch {
633
+ return text;
634
+ }
635
+ }
636
+ </script>
637
+
638
+ <template>
639
+ <!-- ONE CARD, OR TWO SECTIONS. `panel` is the shape an operation page wants:
640
+ a single box in a 23rem column beside the endpoint.
641
+
642
+ `split` is for a band on the landing page, which has the whole width and
643
+ no column to be narrow in. The form and the samples become two rows, each
644
+ with its own prose beside it and the sides alternating the way the bands
645
+ around them do — so what was one very tall column reads as two ordinary
646
+ sections.
647
+
648
+ THE SPLIT IS INSIDE THIS COMPONENT rather than two components on the
649
+ page, and that is the whole reason it is built this way: the samples are
650
+ written from the server, the token, the parameters and the body as they
651
+ stand RIGHT NOW. Two components would be two states, and the second
652
+ section would show a request that is nearly, but not exactly, the one the
653
+ button sends. The prose arrives through slots, so staying one component
654
+ costs the page nothing in what it can say. -->
655
+ <!-- THE ROWS SIT AS FAR APART AS TWO BANDS DO. A band is `py-12 sm:py-16`,
656
+ so two of them stand 24 / 32 apart — half of that between these two read
657
+ as one band with a gap in it rather than as the two sections they are. -->
658
+ <div :class="split ? 'space-y-24 sm:space-y-32' : 'rounded-lg border'">
659
+ <div
660
+ :class="
661
+ split ? 'grid items-center gap-8 lg:grid-cols-2 lg:gap-16' : 'contents'
662
+ "
663
+ >
664
+ <div v-if="split" :class="reverse ? 'lg:order-2' : 'lg:order-1'">
665
+ <slot name="beside-form" />
666
+ </div>
667
+
668
+ <!-- `self-start`, for the reason the samples box below gives: a card
669
+ centred in its row grows in both directions when an answer arrives,
670
+ and the form the reader is looking at moves up under their cursor. -->
671
+ <div
672
+ :class="
673
+ split
674
+ ? [
675
+ 'self-start rounded-lg border',
676
+ reverse ? 'lg:order-1' : 'lg:order-2'
677
+ ]
678
+ : 'contents'
679
+ "
680
+ >
681
+ <div class="border-b px-4 py-3">
682
+ <h2 class="text-sm font-semibold">
683
+ {{ $t('duxt.openapi.client.title') }}
684
+ </h2>
685
+ </div>
686
+
687
+ <!-- NOT A LOGIN, and password managers have to be told so. A `<form>`
688
+ holding a `type="password"` field is exactly the shape Bitwarden,
689
+ 1Password, LastPass and Dashlane look for, so they offered to fill
690
+ the token box with a site password and to save whatever was typed
691
+ there as a new credential.
692
+
693
+ Each vendor reads its own attribute — there is no standard one, and
694
+ `autocomplete="off"` alone has not been respected for years. They
695
+ are set on the form AND on each credential box, because the ones
696
+ that scan for a form and the ones that scan for a field are
697
+ different products. -->
698
+ <form
699
+ class="space-y-4 px-4 py-3"
700
+ autocomplete="off"
701
+ data-form-type="other"
702
+ data-bwignore
703
+ data-1p-ignore
704
+ data-lpignore="true"
705
+ @submit.prevent="send"
706
+ >
707
+ <!-- server -->
708
+ <div v-if="servers.length">
709
+ <label
710
+ :for="`${id}-server`"
711
+ class="mb-1 block text-xs font-medium text-muted-foreground"
712
+ >
713
+ {{ $t('duxt.openapi.client.server') }}
714
+ </label>
715
+
716
+ <UiSelect v-model="serverValue">
717
+ <!-- The chosen value written out, not left to `SelectValue`. That
718
+ one reads the text off the item the reader picked, and the items
719
+ exist only once reka has mounted them — so the server rendered an
720
+ empty box and the URL appeared a beat after hydration. This is
721
+ the same string, and it is in the first byte. -->
722
+ <UiSelectTrigger
723
+ :id="`${id}-server`"
724
+ class="w-full font-mono text-sm"
725
+ >
726
+ <span class="truncate">{{ server?.url }}</span>
727
+ </UiSelectTrigger>
728
+
729
+ <UiSelectContent>
730
+ <UiSelectItem
731
+ v-for="(entry, index) in servers"
732
+ :key="entry.url"
733
+ :value="String(index)"
734
+ class="font-mono text-sm"
735
+ >
736
+ {{ entry.url }}
737
+ </UiSelectItem>
738
+ </UiSelectContent>
739
+ </UiSelect>
740
+
741
+ <div
742
+ v-for="variable in server?.variables ?? []"
743
+ :key="variable.name"
744
+ class="mt-2"
745
+ >
746
+ <label
747
+ :for="`${id}-var-${variable.name}`"
748
+ class="mb-1 block font-mono text-xs text-muted-foreground"
749
+ >
750
+ {{ variable.name }}
751
+ </label>
752
+ <UiInput
753
+ :id="`${id}-var-${variable.name}`"
754
+ v-model="variables[variable.name]"
755
+ class="font-mono text-sm"
756
+ :placeholder="variable.default"
757
+ autocomplete="off"
758
+ />
759
+ </div>
760
+ </div>
761
+
762
+ <!-- authentication -->
763
+ <fieldset v-if="schemes.length" class="space-y-2">
764
+ <legend class="mb-1 text-xs font-medium text-muted-foreground">
765
+ {{ $t('duxt.openapi.client.auth') }}
766
+ </legend>
767
+
768
+ <div v-for="scheme in schemes" :key="scheme.key">
769
+ <p
770
+ v-if="isClientCertificate(scheme)"
771
+ class="text-sm text-muted-foreground"
772
+ >
773
+ {{ $t('duxt.openapi.client.certificate') }}
774
+ </p>
775
+
776
+ <p
777
+ v-else-if="isCookieKey(scheme)"
778
+ class="text-sm text-muted-foreground"
779
+ >
780
+ {{ $t('duxt.openapi.client.cookie') }}
781
+ </p>
782
+
783
+ <template v-else>
784
+ <label
785
+ v-if="isBasic(scheme)"
786
+ :for="`${id}-user-${scheme.key}`"
787
+ class="mb-1 block font-mono text-xs text-muted-foreground"
788
+ >
789
+ {{ $t('duxt.openapi.client.username') }}
790
+ </label>
791
+ <UiInput
792
+ v-if="isBasic(scheme)"
793
+ :id="`${id}-user-${scheme.key}`"
794
+ v-model="usernames[scheme.key]"
795
+ class="mb-2 text-sm"
796
+ autocomplete="off"
797
+ data-form-type="other"
798
+ data-bwignore
799
+ data-1p-ignore
800
+ data-lpignore="true"
801
+ />
802
+
803
+ <label
804
+ :for="`${id}-auth-${scheme.key}`"
805
+ class="mb-1 block font-mono text-xs text-muted-foreground"
806
+ >
807
+ {{ scheme.key }}
808
+ </label>
809
+ <UiInput
810
+ :id="`${id}-auth-${scheme.key}`"
811
+ v-model="credentials[scheme.key]"
812
+ type="password"
813
+ class="text-sm"
814
+ autocomplete="off"
815
+ spellcheck="false"
816
+ data-form-type="other"
817
+ data-bwignore
818
+ data-1p-ignore
819
+ data-lpignore="true"
820
+ />
821
+ </template>
822
+ </div>
823
+
824
+ <p class="text-xs text-muted-foreground">
825
+ {{ $t('duxt.openapi.client.credentials') }}
826
+ </p>
827
+ </fieldset>
828
+
829
+ <!-- parameters -->
830
+ <fieldset v-if="parameters.length" class="space-y-2">
831
+ <legend class="mb-1 text-xs font-medium text-muted-foreground">
832
+ {{ $t('duxt.openapi.parameters') }}
833
+ </legend>
834
+
835
+ <div
836
+ v-for="parameter in parameters"
837
+ :key="openApiParameterKey(parameter)"
838
+ >
839
+ <template v-if="parameter.in === 'cookie'">
840
+ <p class="mb-1 font-mono text-xs text-muted-foreground">
841
+ {{ parameter.name }}
842
+ </p>
843
+ <p class="text-sm text-muted-foreground">
844
+ {{ $t('duxt.openapi.client.cookie') }}
845
+ </p>
846
+ </template>
847
+
848
+ <template v-else>
849
+ <label
850
+ :for="`${id}-p-${openApiParameterKey(parameter)}`"
851
+ class="mb-1 block font-mono text-xs text-muted-foreground"
852
+ >
853
+ {{ parameter.name }}
854
+ <span v-if="parameter.required" class="text-destructive"
855
+ >*</span
856
+ >
857
+ </label>
858
+ <!-- The box the DOCUMENT asks for: a list where it names the
859
+ whole set of values, a number with the range it gave, a text
860
+ box only where it said nothing. See `openApiField`. -->
861
+ <UiSelect
862
+ v-if="openApiField(parameter.schema).control === 'select'"
863
+ v-model="values[openApiParameterKey(parameter)]"
864
+ >
865
+ <UiSelectTrigger
866
+ :id="`${id}-p-${openApiParameterKey(parameter)}`"
867
+ class="w-full font-mono text-sm"
868
+ >
869
+ <span class="truncate">
870
+ {{ values[openApiParameterKey(parameter)] }}
871
+ </span>
872
+ </UiSelectTrigger>
873
+
874
+ <UiSelectContent>
875
+ <UiSelectItem
876
+ v-for="option in openApiField(parameter.schema).options"
877
+ :key="option"
878
+ :value="option"
879
+ class="font-mono text-sm"
880
+ >
881
+ {{ option }}
882
+ </UiSelectItem>
883
+ </UiSelectContent>
884
+ </UiSelect>
885
+
886
+ <UiInput
887
+ v-else
888
+ :id="`${id}-p-${openApiParameterKey(parameter)}`"
889
+ v-model="values[openApiParameterKey(parameter)]"
890
+ v-bind="openApiField(parameter.schema).attrs"
891
+ class="font-mono text-sm"
892
+ autocomplete="off"
893
+ />
894
+ </template>
895
+ </div>
896
+ </fieldset>
897
+
898
+ <!-- body -->
899
+ <div v-if="bodies.length">
900
+ <div class="mb-1 flex items-center gap-2">
901
+ <label
902
+ :for="`${id}-body`"
903
+ class="block text-xs font-medium text-muted-foreground"
904
+ >
905
+ {{ $t('duxt.openapi.requestBody') }}
906
+ </label>
907
+
908
+ <!-- Beside the label, not beside the toggle. It belongs to the JSON
909
+ view, so it comes and goes with it — and on the LEFT that costs
910
+ nothing: the toggle is pinned to the right edge by `ml-auto`, so
911
+ the control the reader just clicked cannot move out from under
912
+ the pointer, which is what happened when the two shared the right
913
+ end of this row. -->
914
+ <UiButton
915
+ v-if="mode === 'json'"
916
+ type="button"
917
+ variant="ghost"
918
+ size="sm"
919
+ class="h-6 px-2 text-xs"
920
+ @click="formatBody"
921
+ >
922
+ {{ $t('duxt.openapi.client.format') }}
923
+ </UiButton>
924
+
925
+ <!-- The toggle only where there is something to toggle TO: a body
926
+ the schema cannot describe as a form has one view, and a strip
927
+ offering a tab that falls straight back is a control that lies
928
+ about what it does. -->
929
+ <div
930
+ v-if="formable"
931
+ class="ml-auto flex items-center gap-0.5 rounded-md border p-0.5"
932
+ >
933
+ <button
934
+ v-for="view in ['form', 'json'] as const"
935
+ :key="view"
936
+ type="button"
937
+ :aria-pressed="mode === view"
938
+ class="cursor-pointer rounded px-2 py-0.5 text-xs font-medium transition-colors"
939
+ :class="
940
+ mode === view
941
+ ? 'bg-accent text-foreground'
942
+ : 'text-muted-foreground hover:text-foreground'
943
+ "
944
+ @click="bodyMode = view"
945
+ >
946
+ {{ $t(`duxt.openapi.client.${view}`) }}
947
+ </button>
948
+ </div>
949
+ </div>
950
+
951
+ <UiSelect v-if="bodies.length > 1" v-model="bodyValue">
952
+ <UiSelectTrigger
953
+ :aria-label="$t('duxt.openapi.client.mediaType')"
954
+ class="mb-2 w-full font-mono text-sm"
955
+ >
956
+ <span class="truncate">{{ media?.type }}</span>
957
+ </UiSelectTrigger>
958
+
959
+ <UiSelectContent>
960
+ <UiSelectItem
961
+ v-for="(entry, index) in bodies"
962
+ :key="entry.type"
963
+ :value="String(index)"
964
+ class="font-mono text-sm"
965
+ >
966
+ {{ entry.type }}
967
+ </UiSelectItem>
968
+ </UiSelectContent>
969
+ </UiSelect>
970
+
971
+ <!-- One box per property, exactly as the parameters above are drawn —
972
+ same helper, same rules, so an `integer` is a number box here for
973
+ the same reason it is one there. -->
974
+ <div v-if="mode === 'form'" class="space-y-2">
975
+ <div v-for="entry in bodyFields" :key="entry.name">
976
+ <label
977
+ :for="`${id}-b-${entry.name}`"
978
+ class="mb-1 block font-mono text-xs text-muted-foreground"
979
+ >
980
+ {{ entry.name }}
981
+ <span v-if="entry.required" class="text-destructive">*</span>
982
+ </label>
983
+
984
+ <UiSelect
985
+ v-if="entry.field.control === 'select'"
986
+ :model-value="bodyValues[entry.name]"
987
+ @update:model-value="
988
+ (value) => setBodyValue(entry.name, String(value ?? ''))
989
+ "
990
+ >
991
+ <UiSelectTrigger
992
+ :id="`${id}-b-${entry.name}`"
993
+ class="w-full font-mono text-sm"
994
+ >
995
+ <span class="truncate">{{ bodyValues[entry.name] }}</span>
996
+ </UiSelectTrigger>
997
+
998
+ <UiSelectContent>
999
+ <UiSelectItem
1000
+ v-for="option in entry.field.options"
1001
+ :key="option"
1002
+ :value="option"
1003
+ class="font-mono text-sm"
1004
+ >
1005
+ {{ option }}
1006
+ </UiSelectItem>
1007
+ </UiSelectContent>
1008
+ </UiSelect>
1009
+
1010
+ <UiInput
1011
+ v-else
1012
+ :id="`${id}-b-${entry.name}`"
1013
+ :model-value="bodyValues[entry.name]"
1014
+ v-bind="entry.field.attrs"
1015
+ class="font-mono text-sm"
1016
+ autocomplete="off"
1017
+ @update:model-value="
1018
+ (value) => setBodyValue(entry.name, String(value ?? ''))
1019
+ "
1020
+ />
1021
+ </div>
1022
+ </div>
1023
+
1024
+ <template v-else>
1025
+ <DuxtJsonEditor
1026
+ :id="`${id}-body`"
1027
+ v-model="body"
1028
+ :keys="bodyKeys"
1029
+ :aria-label="$t('duxt.openapi.requestBody')"
1030
+ />
1031
+
1032
+ <!-- Said while it is still fixable, rather than after a 400. The
1033
+ request is not blocked: a document is sometimes wrong about what
1034
+ its own endpoint accepts, and this client exists to find out. -->
1035
+ <p v-if="bodyError" class="mt-1 text-xs text-destructive">
1036
+ {{ $t('duxt.openapi.client.invalidJson') }}
1037
+ </p>
1038
+ <p
1039
+ v-else-if="!formable"
1040
+ class="mt-1 text-xs text-muted-foreground"
1041
+ >
1042
+ {{ $t('duxt.openapi.client.noForm') }}
1043
+ </p>
1044
+ </template>
1045
+ </div>
1046
+
1047
+ <UiButton type="submit" :disabled="sending" class="w-full">
1048
+ <Icon
1049
+ :name="sending ? 'lucide:loader-circle' : 'lucide:send'"
1050
+ class="size-4"
1051
+ :class="{ 'animate-spin': sending }"
1052
+ />
1053
+ {{ $t('duxt.openapi.client.send') }}
1054
+ </UiButton>
1055
+
1056
+ <p class="text-xs text-muted-foreground">
1057
+ {{ $t('duxt.openapi.client.direct') }}
1058
+ </p>
1059
+ </form>
1060
+
1061
+ <!-- WHAT CAME BACK. Always in the DOM, collapsed to nothing until
1062
+ there is something to show.
1063
+
1064
+ NOT a `<Transition>` around a `v-if`, which is what this was. That
1065
+ makes a node appear and disappear, and server and client then
1066
+ disagree about what stands in its place — a fragment against a
1067
+ comment, which is the hydration mismatch it produced. Nothing is
1068
+ added or removed here: the row goes from `0fr` to `1fr` and the
1069
+ border comes with it.
1070
+
1071
+ `grid-rows` is still the way to animate to a height nobody can know
1072
+ in advance; the inner `overflow-hidden` is what lets the row be
1073
+ shorter than its content on the way. -->
1074
+ <div
1075
+ class="grid motion-safe:transition-all motion-safe:duration-300 motion-safe:ease-out"
1076
+ :class="
1077
+ failure || result
1078
+ ? 'grid-rows-[1fr] border-t opacity-100'
1079
+ : 'grid-rows-[0fr] opacity-0'
1080
+ "
1081
+ :aria-busy="sending || undefined"
1082
+ >
1083
+ <div class="overflow-hidden">
1084
+ <div class="px-4 py-3">
1085
+ <p v-if="failure" class="text-sm text-destructive">
1086
+ {{ failure }}
1087
+ </p>
1088
+
1089
+ <template v-else-if="result">
1090
+ <div class="flex flex-wrap items-center gap-2 text-sm">
1091
+ <span class="font-mono font-semibold">
1092
+ {{ result.status }}
1093
+ </span>
1094
+ <span class="text-muted-foreground">
1095
+ {{ result.statusText }}
1096
+ </span>
1097
+ <span class="ml-auto font-mono text-xs text-muted-foreground">
1098
+ {{ result.duration }}&nbsp;ms
1099
+ </span>
1100
+ </div>
1101
+
1102
+ <DuxtCodeBlock
1103
+ v-if="result.body"
1104
+ :code="result.body"
1105
+ language="json"
1106
+ />
1107
+
1108
+ <details v-if="result.headers.length" class="mt-2">
1109
+ <summary class="cursor-pointer text-xs text-muted-foreground">
1110
+ {{ $t('duxt.openapi.headers') }}
1111
+ </summary>
1112
+ <dl
1113
+ class="mt-2 space-y-1 font-mono text-xs text-muted-foreground"
1114
+ >
1115
+ <div
1116
+ v-for="[name, value] in result.headers"
1117
+ :key="name"
1118
+ class="flex gap-2"
1119
+ >
1120
+ <dt class="shrink-0">{{ name }}</dt>
1121
+ <dd class="break-all">{{ value }}</dd>
1122
+ </div>
1123
+ </dl>
1124
+ </details>
1125
+ </template>
1126
+ </div>
1127
+ </div>
1128
+ </div>
1129
+ </div>
1130
+ </div>
1131
+
1132
+ <!-- the same request, as something to paste elsewhere -->
1133
+ <!-- ONE CARD, tabs and code together, exactly as `::preview` and the
1134
+ package-manager block are built: the strip used to float above a card
1135
+ of its own, so the page carried two boxes for one thing and the copy
1136
+ button sat inside the lower one, away from the choice it belongs to. -->
1137
+ <!-- NO RESERVED HEIGHT, and both halves centred.
1138
+
1139
+ Reserving room for the longest sample left half the row empty whenever
1140
+ a reader picked `curl`, which is the sample most of them pick. The row
1141
+ sizes to its card again — and the card's height is ANIMATED rather than
1142
+ switched, so the sections below slide rather than jump. See
1143
+ `measureSample` for why that needs JavaScript. -->
1144
+ <div
1145
+ :class="
1146
+ split ? 'grid items-center gap-8 lg:grid-cols-2 lg:gap-16' : 'contents'
1147
+ "
1148
+ >
1149
+ <div v-if="split" :class="reverse ? 'lg:order-1' : 'lg:order-2'">
1150
+ <slot name="beside-samples" />
1151
+ </div>
1152
+
1153
+ <!-- A HEADING OVER THE CARD, not a card around the card. The samples are
1154
+ already a bordered box with its own tab strip; wrapping that in a
1155
+ second bordered box to carry a title drew a box inside a box for one
1156
+ thing. The title stands above it instead, the way a label does.
1157
+
1158
+ `self-start`, so the box grows DOWNWARDS. Centred in its row it grew
1159
+ in both directions at once, and its tab strip — the control the
1160
+ reader just clicked — moved up under their cursor. -->
1161
+ <div
1162
+ :class="
1163
+ split
1164
+ ? ['self-start', reverse ? 'lg:order-2' : 'lg:order-1']
1165
+ : 'border-t px-4 py-3'
1166
+ "
1167
+ >
1168
+ <h2 v-if="split" class="mb-2 text-sm font-semibold">
1169
+ {{ $t('duxt.openapi.client.samples') }}
1170
+ </h2>
1171
+
1172
+ <!-- THE BOX FOLLOWS ITS CONTENT, but takes 300ms to get there.
1173
+
1174
+ A `curl` of four lines and a Go client of twenty are two heights,
1175
+ and switching between them moved everything below the row in a
1176
+ single frame. Animated, the page slides instead.
1177
+
1178
+ `overflow-hidden` is what lets the shell be shorter than what is
1179
+ inside it while its height is still on the way. -->
1180
+ <div
1181
+ ref="sampleShell"
1182
+ :class="
1183
+ split
1184
+ ? 'overflow-hidden motion-safe:transition-[height] motion-safe:duration-300 motion-safe:ease-out'
1185
+ : ''
1186
+ "
1187
+ >
1188
+ <div ref="sampleBody">
1189
+ <TabsRoot
1190
+ v-model="group"
1191
+ class="overflow-hidden rounded-lg border bg-card"
1192
+ >
1193
+ <!-- The copy button is a SIBLING of the strip, not a child of it: a
1194
+ `tablist` may hold tabs and nothing else, and axe reports the
1195
+ button inside one as `aria-required-children`. The header row is
1196
+ the flex container instead, so it still sits where every other
1197
+ card on the site puts it. -->
1198
+ <div
1199
+ class="flex min-h-11 items-center gap-1 border-b bg-muted/40 px-2 py-1.5"
1200
+ >
1201
+ <TabsList
1202
+ class="flex items-center gap-1"
1203
+ :aria-label="$t('duxt.openapi.client.samples') as string"
1204
+ >
1205
+ <TabsTrigger
1206
+ v-for="entry in groups"
1207
+ :key="entry"
1208
+ :value="entry"
1209
+ class="flex cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1 font-mono text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm"
1210
+ >
1211
+ <Icon :name="groupIcon(entry)" class="size-3.5" />
1212
+ {{ entry }}
1213
+ </TabsTrigger>
1214
+ </TabsList>
1215
+
1216
+ <UiButton
1217
+ variant="ghost"
1218
+ size="icon"
1219
+ class="ml-auto size-7 hover:bg-accent hover:text-foreground"
1220
+ :aria-label="
1221
+ copiedSample ? $t('duxt.code.copied') : $t('duxt.code.copy')
1222
+ "
1223
+ @click="copySample"
1224
+ >
1225
+ <Icon
1226
+ :name="copiedSample ? 'lucide:check' : 'lucide:copy'"
1227
+ class="size-3.5"
1228
+ />
1229
+ </UiButton>
1230
+ </div>
1231
+
1232
+ <!-- THE CLIENTS OF THE ACTIVE LANGUAGE, in a row of their own.
1233
+ Beside the tabs they were a control the reader had to open to learn
1234
+ that PHP has three of them; on their own line the choice is the
1235
+ thing they see. Dropped entirely where the language has one client,
1236
+ because a picker with a single option cannot be used.
1237
+
1238
+ `role="group"` with `aria-pressed`, NOT a radiogroup: a radiogroup
1239
+ promises arrow-key navigation between its options, and there is no
1240
+ `ui/radio-group` here to implement it. A group of pressed buttons
1241
+ promises only what it does — every one reachable by Tab, every one
1242
+ saying whether it is on. -->
1243
+ <div
1244
+ v-if="clients.length > 1 && clients.length < 6"
1245
+ role="group"
1246
+ :aria-label="$t('duxt.openapi.client.sampleClient') as string"
1247
+ class="flex items-center gap-1 overflow-x-auto border-b bg-muted/20 px-2 py-1.5"
1248
+ >
1249
+ <button
1250
+ v-for="entry in clients"
1251
+ :key="entry.id"
1252
+ type="button"
1253
+ :aria-pressed="entry.id === sample"
1254
+ class="cursor-pointer whitespace-nowrap rounded-md px-2 py-0.5 font-mono text-xs transition-colors"
1255
+ :class="
1256
+ entry.id === sample
1257
+ ? 'bg-background text-foreground shadow-sm'
1258
+ : 'text-muted-foreground hover:bg-accent hover:text-foreground'
1259
+ "
1260
+ @click="sample = entry.id"
1261
+ >
1262
+ {{ entry.label }}
1263
+ </button>
1264
+ </div>
1265
+
1266
+ <!-- Six is where a row stops being a row. A language with that many
1267
+ clients is a site that configured them, and a select carries any
1268
+ number on any width — the trade the chips above cannot make. -->
1269
+ <div
1270
+ v-else-if="clients.length >= 6"
1271
+ class="flex items-center border-b bg-muted/20 px-2 py-1.5"
1272
+ >
1273
+ <UiSelect v-model="sample">
1274
+ <UiSelectTrigger
1275
+ class="h-7 w-auto gap-1.5 border-0 bg-transparent px-2 font-mono text-xs shadow-none hover:bg-accent"
1276
+ :aria-label="
1277
+ $t('duxt.openapi.client.sampleClient') as string
1278
+ "
1279
+ >
1280
+ {{ shown.label }}
1281
+ </UiSelectTrigger>
1282
+
1283
+ <UiSelectContent>
1284
+ <UiSelectItem
1285
+ v-for="entry in clients"
1286
+ :key="entry.id"
1287
+ :value="entry.id"
1288
+ class="font-mono text-xs"
1289
+ >
1290
+ {{ entry.label }}
1291
+ </UiSelectItem>
1292
+ </UiSelectContent>
1293
+ </UiSelect>
1294
+ </div>
1295
+
1296
+ <!-- Said plainly, because the difference is invisible otherwise: a
1297
+ reader who edits the body and switches to this tab would see a
1298
+ sample that does not contain the edit, and conclude the editor is
1299
+ broken rather than that this sample is fixed. -->
1300
+ <p
1301
+ v-if="shown.fromSpec"
1302
+ class="border-b bg-muted/20 px-4 py-2 text-xs text-muted-foreground"
1303
+ >
1304
+ {{ $t('duxt.openapi.client.fromSpec') }}
1305
+ </p>
1306
+
1307
+ <!-- THE CARD KEEPS ITS OWN HEIGHT. The row around it reserves the
1308
+ space — see the `min-h` on the grid below — so a `curl` of four
1309
+ lines and a Go client of twenty both leave the section under
1310
+ this one exactly where it was. What changes is the card, and it
1311
+ grows and shrinks about its own middle because the row centres
1312
+ it.
1313
+
1314
+ The floor stays for the panel layout, where there is no row to
1315
+ reserve anything: on an operation page the card is in a column
1316
+ that scrolls as a whole. -->
1317
+ <TabsContent :value="group">
1318
+ <!-- eslint-disable-next-line vue/no-v-html -- Shiki's own output over
1319
+ a string this component built; nothing a reader typed reaches it
1320
+ unescaped. -->
1321
+ <div
1322
+ v-if="sampleHtml"
1323
+ class="duxt-code-body duxt-code-body-sm"
1324
+ :class="split ? '' : 'min-h-56'"
1325
+ v-html="sampleHtml"
1326
+ />
1327
+ <pre
1328
+ v-else
1329
+ class="overflow-x-auto p-4 text-xs"
1330
+ :class="split ? '' : 'min-h-56'"
1331
+ ><code>{{ shown.code }}</code></pre>
1332
+ </TabsContent>
1333
+ </TabsRoot>
1334
+ </div>
1335
+ </div>
1336
+ </div>
1337
+ </div>
1338
+ </div>
1339
+ </template>