@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.
- package/LICENSE +21 -0
- package/README.md +151 -0
- package/app/app.config.ts +13 -0
- package/app/app.vue +122 -0
- package/app/assets/css/duxt.css +555 -0
- package/app/assets/css/typeset.css +498 -0
- package/app/components/DuxtBrand.vue +53 -0
- package/app/components/DuxtBreadcrumb.vue +37 -0
- package/app/components/DuxtCodeBlock.vue +143 -0
- package/app/components/DuxtCopyPage.vue +117 -0
- package/app/components/DuxtFooter.vue +86 -0
- package/app/components/DuxtHeader.vue +496 -0
- package/app/components/DuxtJsonEditor.vue +359 -0
- package/app/components/DuxtLandingProse.vue +90 -0
- package/app/components/DuxtLandingShowcase.vue +295 -0
- package/app/components/DuxtLiveWindow.vue +343 -0
- package/app/components/DuxtLocale.vue +86 -0
- package/app/components/DuxtMetaList.vue +76 -0
- package/app/components/DuxtNavigation.vue +94 -0
- package/app/components/DuxtNavigationLink.vue +41 -0
- package/app/components/DuxtOpenApiCallbacks.vue +92 -0
- package/app/components/DuxtOpenApiClient.vue +1339 -0
- package/app/components/DuxtOpenApiMedia.vue +108 -0
- package/app/components/DuxtOpenApiMethod.vue +59 -0
- package/app/components/DuxtOpenApiParameters.vue +114 -0
- package/app/components/DuxtOpenApiResponses.vue +109 -0
- package/app/components/DuxtOpenApiSchema.vue +365 -0
- package/app/components/DuxtOpenApiSecurity.vue +115 -0
- package/app/components/DuxtPageFeedback.vue +56 -0
- package/app/components/DuxtPageInfo.vue +194 -0
- package/app/components/DuxtPageNav.vue +93 -0
- package/app/components/DuxtPreviewSkeleton.vue +262 -0
- package/app/components/DuxtProgress.vue +45 -0
- package/app/components/DuxtSearch.vue +271 -0
- package/app/components/DuxtSections.vue +65 -0
- package/app/components/DuxtShortcuts.vue +46 -0
- package/app/components/DuxtSkipLink.vue +34 -0
- package/app/components/DuxtToc.vue +86 -0
- package/app/components/DuxtTranslationBanner.vue +64 -0
- package/app/components/DuxtVersion.vue +168 -0
- package/app/components/DuxtVersionBanner.vue +109 -0
- package/app/components/OgImage/Duxt.satori.vue +82 -0
- package/app/components/content/Accordion.vue +37 -0
- package/app/components/content/AccordionItem.vue +46 -0
- package/app/components/content/Callout.vue +58 -0
- package/app/components/content/ChangelogGroup.vue +91 -0
- package/app/components/content/ChangelogReleases.vue +520 -0
- package/app/components/content/CodeGroup.vue +183 -0
- package/app/components/content/DevtoolsPanel.vue +107 -0
- package/app/components/content/Field.vue +50 -0
- package/app/components/content/FieldGroup.vue +8 -0
- package/app/components/content/FileTree.vue +92 -0
- package/app/components/content/Mermaid.vue +70 -0
- package/app/components/content/OpenApiOperation.vue +185 -0
- package/app/components/content/OpenApiOperations.vue +72 -0
- package/app/components/content/OpenApiOverview.vue +302 -0
- package/app/components/content/PackageManagers.vue +187 -0
- package/app/components/content/PageCards.vue +48 -0
- package/app/components/content/Partial.vue +65 -0
- package/app/components/content/Preview.vue +173 -0
- package/app/components/content/ProseA.vue +55 -0
- package/app/components/content/ProseH2.vue +53 -0
- package/app/components/content/ProseH3.vue +53 -0
- package/app/components/content/ProseH4.vue +53 -0
- package/app/components/content/ProseImg.vue +87 -0
- package/app/components/content/ProsePre.vue +58 -0
- package/app/components/content/ProseTable.vue +11 -0
- package/app/components/content/Since.vue +21 -0
- package/app/components/content/Steps.vue +20 -0
- package/app/components/content/Tab.vue +23 -0
- package/app/components/content/TabGroup.vue +58 -0
- package/app/components/ui/alert/Alert.vue +21 -0
- package/app/components/ui/alert/AlertDescription.vue +22 -0
- package/app/components/ui/alert/AlertTitle.vue +22 -0
- package/app/components/ui/alert/index.ts +24 -0
- package/app/components/ui/badge/Badge.vue +28 -0
- package/app/components/ui/badge/index.ts +52 -0
- package/app/components/ui/breadcrumb/Breadcrumb.vue +13 -0
- package/app/components/ui/breadcrumb/BreadcrumbEllipsis.vue +22 -0
- package/app/components/ui/breadcrumb/BreadcrumbItem.vue +17 -0
- package/app/components/ui/breadcrumb/BreadcrumbLink.vue +24 -0
- package/app/components/ui/breadcrumb/BreadcrumbList.vue +22 -0
- package/app/components/ui/breadcrumb/BreadcrumbPage.vue +20 -0
- package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +21 -0
- package/app/components/ui/breadcrumb/index.ts +7 -0
- package/app/components/ui/button/Button.vue +31 -0
- package/app/components/ui/button/index.ts +39 -0
- package/app/components/ui/card/Card.vue +22 -0
- package/app/components/ui/card/CardAction.vue +22 -0
- package/app/components/ui/card/CardContent.vue +14 -0
- package/app/components/ui/card/CardDescription.vue +17 -0
- package/app/components/ui/card/CardFooter.vue +17 -0
- package/app/components/ui/card/CardHeader.vue +22 -0
- package/app/components/ui/card/CardTitle.vue +17 -0
- package/app/components/ui/card/index.ts +7 -0
- package/app/components/ui/collapsible/Collapsible.vue +19 -0
- package/app/components/ui/collapsible/CollapsibleContent.vue +12 -0
- package/app/components/ui/collapsible/CollapsibleTrigger.vue +12 -0
- package/app/components/ui/collapsible/index.ts +3 -0
- package/app/components/ui/command/Command.vue +98 -0
- package/app/components/ui/command/CommandDialog.vue +42 -0
- package/app/components/ui/command/CommandEmpty.vue +31 -0
- package/app/components/ui/command/CommandGroup.vue +52 -0
- package/app/components/ui/command/CommandInput.vue +45 -0
- package/app/components/ui/command/CommandItem.vue +85 -0
- package/app/components/ui/command/CommandList.vue +32 -0
- package/app/components/ui/command/CommandSeparator.vue +23 -0
- package/app/components/ui/command/CommandShortcut.vue +19 -0
- package/app/components/ui/command/index.ts +29 -0
- package/app/components/ui/dialog/Dialog.vue +15 -0
- package/app/components/ui/dialog/DialogClose.vue +12 -0
- package/app/components/ui/dialog/DialogContent.vue +61 -0
- package/app/components/ui/dialog/DialogDescription.vue +25 -0
- package/app/components/ui/dialog/DialogFooter.vue +30 -0
- package/app/components/ui/dialog/DialogHeader.vue +17 -0
- package/app/components/ui/dialog/DialogOverlay.vue +28 -0
- package/app/components/ui/dialog/DialogScrollContent.vue +65 -0
- package/app/components/ui/dialog/DialogTitle.vue +25 -0
- package/app/components/ui/dialog/DialogTrigger.vue +12 -0
- package/app/components/ui/dialog/index.ts +10 -0
- package/app/components/ui/dropdown-menu/DropdownMenu.vue +19 -0
- package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +47 -0
- package/app/components/ui/dropdown-menu/DropdownMenuContent.vue +47 -0
- package/app/components/ui/dropdown-menu/DropdownMenuGroup.vue +12 -0
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +41 -0
- package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +27 -0
- package/app/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +21 -0
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +48 -0
- package/app/components/ui/dropdown-menu/DropdownMenuSeparator.vue +23 -0
- package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +19 -0
- package/app/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
- package/app/components/ui/dropdown-menu/DropdownMenuSubContent.vue +34 -0
- package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +34 -0
- package/app/components/ui/dropdown-menu/DropdownMenuTrigger.vue +17 -0
- package/app/components/ui/dropdown-menu/index.ts +16 -0
- package/app/components/ui/input/Input.vue +35 -0
- package/app/components/ui/input/index.ts +1 -0
- package/app/components/ui/navigation-menu/NavigationMenu.vue +42 -0
- package/app/components/ui/navigation-menu/NavigationMenuContent.vue +35 -0
- package/app/components/ui/navigation-menu/NavigationMenuIndicator.vue +32 -0
- package/app/components/ui/navigation-menu/NavigationMenuItem.vue +23 -0
- package/app/components/ui/navigation-menu/NavigationMenuLink.vue +30 -0
- package/app/components/ui/navigation-menu/NavigationMenuList.vue +30 -0
- package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +31 -0
- package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +30 -0
- package/app/components/ui/navigation-menu/index.ts +14 -0
- package/app/components/ui/scroll-area/ScrollArea.vue +31 -0
- package/app/components/ui/scroll-area/ScrollBar.vue +38 -0
- package/app/components/ui/scroll-area/index.ts +2 -0
- package/app/components/ui/select/Select.vue +15 -0
- package/app/components/ui/select/SelectContent.vue +59 -0
- package/app/components/ui/select/SelectGroup.vue +19 -0
- package/app/components/ui/select/SelectItem.vue +42 -0
- package/app/components/ui/select/SelectItemText.vue +12 -0
- package/app/components/ui/select/SelectLabel.vue +16 -0
- package/app/components/ui/select/SelectScrollDownButton.vue +28 -0
- package/app/components/ui/select/SelectScrollUpButton.vue +28 -0
- package/app/components/ui/select/SelectSeparator.vue +20 -0
- package/app/components/ui/select/SelectTrigger.vue +32 -0
- package/app/components/ui/select/SelectValue.vue +12 -0
- package/app/components/ui/select/index.ts +11 -0
- package/app/components/ui/separator/Separator.vue +30 -0
- package/app/components/ui/separator/index.ts +1 -0
- package/app/components/ui/sheet/Sheet.vue +15 -0
- package/app/components/ui/sheet/SheetClose.vue +12 -0
- package/app/components/ui/sheet/SheetContent.vue +68 -0
- package/app/components/ui/sheet/SheetDescription.vue +23 -0
- package/app/components/ui/sheet/SheetFooter.vue +15 -0
- package/app/components/ui/sheet/SheetHeader.vue +15 -0
- package/app/components/ui/sheet/SheetOverlay.vue +28 -0
- package/app/components/ui/sheet/SheetTitle.vue +23 -0
- package/app/components/ui/sheet/SheetTrigger.vue +12 -0
- package/app/components/ui/sheet/index.ts +8 -0
- package/app/components/ui/sidebar/Sidebar.vue +110 -0
- package/app/components/ui/sidebar/SidebarContent.vue +23 -0
- package/app/components/ui/sidebar/SidebarFooter.vue +18 -0
- package/app/components/ui/sidebar/SidebarGroup.vue +18 -0
- package/app/components/ui/sidebar/SidebarGroupAction.vue +31 -0
- package/app/components/ui/sidebar/SidebarGroupContent.vue +18 -0
- package/app/components/ui/sidebar/SidebarGroupLabel.vue +30 -0
- package/app/components/ui/sidebar/SidebarHeader.vue +18 -0
- package/app/components/ui/sidebar/SidebarInput.vue +19 -0
- package/app/components/ui/sidebar/SidebarInset.vue +23 -0
- package/app/components/ui/sidebar/SidebarMenu.vue +18 -0
- package/app/components/ui/sidebar/SidebarMenuAction.vue +42 -0
- package/app/components/ui/sidebar/SidebarMenuBadge.vue +28 -0
- package/app/components/ui/sidebar/SidebarMenuButton.vue +60 -0
- package/app/components/ui/sidebar/SidebarMenuButtonChild.vue +36 -0
- package/app/components/ui/sidebar/SidebarMenuItem.vue +18 -0
- package/app/components/ui/sidebar/SidebarMenuSkeleton.vue +35 -0
- package/app/components/ui/sidebar/SidebarMenuSub.vue +24 -0
- package/app/components/ui/sidebar/SidebarMenuSubButton.vue +43 -0
- package/app/components/ui/sidebar/SidebarMenuSubItem.vue +18 -0
- package/app/components/ui/sidebar/SidebarProvider.vue +109 -0
- package/app/components/ui/sidebar/SidebarRail.vue +35 -0
- package/app/components/ui/sidebar/SidebarSeparator.vue +19 -0
- package/app/components/ui/sidebar/SidebarTrigger.vue +26 -0
- package/app/components/ui/sidebar/index.ts +62 -0
- package/app/components/ui/sidebar/utils.ts +19 -0
- package/app/components/ui/skeleton/Skeleton.vue +17 -0
- package/app/components/ui/skeleton/index.ts +1 -0
- package/app/components/ui/sonner/Sonner.vue +67 -0
- package/app/components/ui/sonner/index.ts +1 -0
- package/app/components/ui/tabs/Tabs.vue +26 -0
- package/app/components/ui/tabs/TabsContent.vue +23 -0
- package/app/components/ui/tabs/TabsList.vue +28 -0
- package/app/components/ui/tabs/TabsTrigger.vue +30 -0
- package/app/components/ui/tabs/index.ts +4 -0
- package/app/components/ui/textarea/Textarea.vue +32 -0
- package/app/components/ui/textarea/index.ts +1 -0
- package/app/components/ui/tooltip/Tooltip.vue +15 -0
- package/app/components/ui/tooltip/TooltipContent.vue +49 -0
- package/app/components/ui/tooltip/TooltipProvider.vue +14 -0
- package/app/components/ui/tooltip/TooltipTrigger.vue +12 -0
- package/app/components/ui/tooltip/index.ts +4 -0
- package/app/composables/useActiveHeading.ts +90 -0
- package/app/composables/useDuxtAnimatedHeight.ts +155 -0
- package/app/composables/useDuxtBreadcrumb.ts +60 -0
- package/app/composables/useDuxtChoice.ts +28 -0
- package/app/composables/useDuxtCollection.ts +89 -0
- package/app/composables/useDuxtConfig.ts +47 -0
- package/app/composables/useDuxtNavigation.ts +87 -0
- package/app/composables/useDuxtPageFocus.ts +30 -0
- package/app/composables/useDuxtPath.ts +42 -0
- package/app/composables/useDuxtSearch.ts +208 -0
- package/app/composables/useDuxtSection.ts +44 -0
- package/app/composables/useDuxtSectionRow.ts +49 -0
- package/app/composables/useDuxtShortcuts.ts +58 -0
- package/app/composables/useDuxtSiteUrl.ts +27 -0
- package/app/composables/useDuxtToast.ts +25 -0
- package/app/composables/useDuxtVersion.ts +138 -0
- package/app/composables/useFuzzySearch.ts +83 -0
- package/app/composables/usePackageManager.ts +4 -0
- package/app/composables/useRecentPages.ts +52 -0
- package/app/error.vue +133 -0
- package/app/layouts/changelog.vue +85 -0
- package/app/layouts/default.vue +23 -0
- package/app/layouts/docs.vue +62 -0
- package/app/layouts/reference.vue +85 -0
- package/app/lib/utils.ts +7 -0
- package/app/middleware/duxt-section-layout.global.ts +57 -0
- package/app/pages/[...slug].vue +441 -0
- package/app/pages/index.vue +479 -0
- package/app/types/duxt.d.ts +955 -0
- package/app/utils/aside.ts +16 -0
- package/app/utils/changelog.ts +182 -0
- package/app/utils/code-highlight.ts +116 -0
- package/app/utils/duxt-config.ts +185 -0
- package/app/utils/duxt-text.ts +135 -0
- package/app/utils/file-icons.ts +62 -0
- package/app/utils/generated-sections.ts +190 -0
- package/app/utils/generated-toc.ts +216 -0
- package/app/utils/heading-link.ts +32 -0
- package/app/utils/locale-path.ts +45 -0
- package/app/utils/navigation-tree.ts +206 -0
- package/app/utils/nearest-page.ts +72 -0
- package/app/utils/openapi.ts +1028 -0
- package/app/utils/package-command.ts +162 -0
- package/app/utils/request-samples.ts +635 -0
- package/app/utils/section-scope.ts +97 -0
- package/app/utils/sheet-navigation.ts +100 -0
- package/app/utils/source-file.ts +84 -0
- package/app/utils/version-choices.ts +68 -0
- package/app/utils/version-paths.ts +65 -0
- package/bin/duxt-report.mjs +45 -0
- package/components.json +19 -0
- package/content-cache.ts +193 -0
- package/content.config.ts +38 -0
- package/duxt-app-config.ts +124 -0
- package/highlight-langs.ts +68 -0
- package/i18n/i18n.config.ts +16 -0
- package/i18n/locales/de/duxt/changelog.json +17 -0
- package/i18n/locales/de/duxt/code.json +13 -0
- package/i18n/locales/de/duxt/defaults.json +22 -0
- package/i18n/locales/de/duxt/devtools.json +7 -0
- package/i18n/locales/de/duxt/error.json +10 -0
- package/i18n/locales/de/duxt/footer.json +7 -0
- package/i18n/locales/de/duxt/locale.json +7 -0
- package/i18n/locales/de/duxt/nav.json +17 -0
- package/i18n/locales/de/duxt/openapi.json +89 -0
- package/i18n/locales/de/duxt/page.json +34 -0
- package/i18n/locales/de/duxt/search.json +12 -0
- package/i18n/locales/de/duxt/shortcuts.json +11 -0
- package/i18n/locales/de/duxt/theme.json +7 -0
- package/i18n/locales/de/duxt/toc.json +7 -0
- package/i18n/locales/de/duxt/version.json +25 -0
- package/i18n/locales/en/duxt/changelog.json +17 -0
- package/i18n/locales/en/duxt/code.json +13 -0
- package/i18n/locales/en/duxt/defaults.json +22 -0
- package/i18n/locales/en/duxt/devtools.json +7 -0
- package/i18n/locales/en/duxt/error.json +10 -0
- package/i18n/locales/en/duxt/footer.json +7 -0
- package/i18n/locales/en/duxt/locale.json +7 -0
- package/i18n/locales/en/duxt/nav.json +17 -0
- package/i18n/locales/en/duxt/openapi.json +89 -0
- package/i18n/locales/en/duxt/page.json +34 -0
- package/i18n/locales/en/duxt/search.json +12 -0
- package/i18n/locales/en/duxt/shortcuts.json +11 -0
- package/i18n/locales/en/duxt/theme.json +7 -0
- package/i18n/locales/en/duxt/toc.json +7 -0
- package/i18n/locales/en/duxt/version.json +25 -0
- package/i18n/locales/es/duxt/changelog.json +17 -0
- package/i18n/locales/es/duxt/code.json +13 -0
- package/i18n/locales/es/duxt/defaults.json +22 -0
- package/i18n/locales/es/duxt/devtools.json +7 -0
- package/i18n/locales/es/duxt/error.json +10 -0
- package/i18n/locales/es/duxt/footer.json +7 -0
- package/i18n/locales/es/duxt/locale.json +7 -0
- package/i18n/locales/es/duxt/nav.json +17 -0
- package/i18n/locales/es/duxt/openapi.json +89 -0
- package/i18n/locales/es/duxt/page.json +34 -0
- package/i18n/locales/es/duxt/search.json +12 -0
- package/i18n/locales/es/duxt/shortcuts.json +11 -0
- package/i18n/locales/es/duxt/theme.json +7 -0
- package/i18n/locales/es/duxt/toc.json +7 -0
- package/i18n/locales/es/duxt/version.json +25 -0
- package/i18n/locales/fr/duxt/changelog.json +17 -0
- package/i18n/locales/fr/duxt/code.json +13 -0
- package/i18n/locales/fr/duxt/defaults.json +22 -0
- package/i18n/locales/fr/duxt/devtools.json +7 -0
- package/i18n/locales/fr/duxt/error.json +10 -0
- package/i18n/locales/fr/duxt/footer.json +7 -0
- package/i18n/locales/fr/duxt/locale.json +7 -0
- package/i18n/locales/fr/duxt/nav.json +17 -0
- package/i18n/locales/fr/duxt/openapi.json +89 -0
- package/i18n/locales/fr/duxt/page.json +34 -0
- package/i18n/locales/fr/duxt/search.json +12 -0
- package/i18n/locales/fr/duxt/shortcuts.json +11 -0
- package/i18n/locales/fr/duxt/theme.json +7 -0
- package/i18n/locales/fr/duxt/toc.json +7 -0
- package/i18n/locales/fr/duxt/version.json +25 -0
- package/i18n/locales/pt/duxt/changelog.json +17 -0
- package/i18n/locales/pt/duxt/code.json +13 -0
- package/i18n/locales/pt/duxt/defaults.json +22 -0
- package/i18n/locales/pt/duxt/devtools.json +7 -0
- package/i18n/locales/pt/duxt/error.json +10 -0
- package/i18n/locales/pt/duxt/footer.json +7 -0
- package/i18n/locales/pt/duxt/locale.json +7 -0
- package/i18n/locales/pt/duxt/nav.json +17 -0
- package/i18n/locales/pt/duxt/openapi.json +89 -0
- package/i18n/locales/pt/duxt/page.json +34 -0
- package/i18n/locales/pt/duxt/search.json +12 -0
- package/i18n/locales/pt/duxt/shortcuts.json +11 -0
- package/i18n/locales/pt/duxt/theme.json +7 -0
- package/i18n/locales/pt/duxt/toc.json +7 -0
- package/i18n/locales/pt/duxt/version.json +25 -0
- package/i18n/locales/pt-BR/duxt/error.json +8 -0
- package/i18n/locales/pt-BR/duxt/nav.json +10 -0
- package/i18n/locales/pt-BR/duxt/page.json +10 -0
- package/i18n/locales/pt-BR/duxt/search.json +11 -0
- package/i18n/locales/pt-BR/duxt/version.json +13 -0
- package/mdc.config.ts +28 -0
- package/modules/config.ts +440 -0
- package/modules/devtools.ts +126 -0
- package/modules/git-meta.ts +349 -0
- package/modules/redirects.ts +152 -0
- package/modules/validate.ts +137 -0
- package/nuxt.config.ts +530 -0
- package/openapi-model.ts +358 -0
- package/openapi-parse.ts +1052 -0
- package/package.json +161 -0
- package/public/devtools/cache.html +318 -0
- package/public/devtools/checks.html +309 -0
- package/public/devtools/config.html +461 -0
- package/public/devtools/i18n.html +309 -0
- package/public/devtools/pages.html +348 -0
- package/public/devtools/paths.html +315 -0
- package/public/devtools/redirects.html +309 -0
- package/public/devtools/search.html +324 -0
- package/public/devtools/sources.html +309 -0
- package/public/devtools/versions.html +318 -0
- package/report.ts +303 -0
- package/repository-root.ts +26 -0
- package/section-reports.ts +55 -0
- package/sections-changelog.ts +644 -0
- package/sections-openapi.ts +450 -0
- package/sections-resolve.ts +711 -0
- package/sections.ts +157 -0
- package/server/devtools/content.ts +120 -0
- package/server/devtools/context.ts +38 -0
- package/server/devtools/entry-path.ts +30 -0
- package/server/devtools/handler.ts +104 -0
- package/server/devtools/preview.ts +461 -0
- package/server/devtools/render/content.ts +233 -0
- package/server/devtools/render/sources.ts +414 -0
- package/server/devtools/render/system.ts +352 -0
- package/server/devtools/shell.ts +611 -0
- package/server/devtools/sources.ts +122 -0
- package/server/devtools/system.ts +178 -0
- package/server/mcp/tools/list-pages.ts +52 -0
- package/server/mcp/tools/read-page.ts +57 -0
- package/server/mcp/tools/search-docs.ts +74 -0
- package/server/middleware/raw-markdown.ts +86 -0
- package/server/routes/llms-full.txt.get.ts +75 -0
- package/server/routes/llms.txt.get.ts +63 -0
- package/server/routes/rss.xml.get.ts +125 -0
- package/server/utils/duxt-server-text.ts +46 -0
- package/sources-git.ts +113 -0
- package/sources-resolve.ts +908 -0
- package/sources.ts +287 -0
- 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 }} 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>
|