@kirchdev/duxt 0.3.4 → 0.3.5-dev-main.44-5337cc1

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 (219) hide show
  1. package/README.md +24 -9
  2. package/app/app.vue +17 -11
  3. package/app/assets/css/duxt.css +47 -6
  4. package/app/components/DuxtAnnouncements.vue +117 -0
  5. package/app/components/DuxtBrunoEntries.vue +62 -0
  6. package/app/components/DuxtCodeBlock.vue +18 -34
  7. package/app/components/DuxtCodeToolbar.vue +14 -0
  8. package/app/components/DuxtCopyButton.vue +33 -0
  9. package/app/components/DuxtCopyPage.vue +32 -30
  10. package/app/components/DuxtFooter.vue +39 -8
  11. package/app/components/DuxtHeader.vue +107 -48
  12. package/app/components/DuxtLandingProse.vue +1 -1
  13. package/app/components/DuxtLinkLabel.vue +36 -0
  14. package/app/components/DuxtLiveWindow.vue +8 -8
  15. package/app/components/DuxtLocale.vue +10 -1
  16. package/app/components/DuxtNavigation.vue +2 -2
  17. package/app/components/DuxtOpenApiClient.vue +285 -90
  18. package/app/components/DuxtOpenApiMedia.vue +30 -18
  19. package/app/components/DuxtOpenApiSchema.vue +10 -7
  20. package/app/components/DuxtPageFeedback.vue +7 -0
  21. package/app/components/DuxtPageInfo.vue +15 -12
  22. package/app/components/DuxtPageNav.vue +41 -27
  23. package/app/components/DuxtPreviewSkeleton.vue +4 -4
  24. package/app/components/DuxtProgress.vue +2 -14
  25. package/app/components/DuxtSearch.vue +230 -90
  26. package/app/components/DuxtSearchInput.vue +70 -0
  27. package/app/components/DuxtSections.vue +11 -1
  28. package/app/components/DuxtSegmented.vue +64 -0
  29. package/app/components/DuxtShortcuts.vue +16 -11
  30. package/app/components/DuxtShortcutsTrigger.vue +49 -0
  31. package/app/components/DuxtSkipLink.vue +1 -1
  32. package/app/components/DuxtToc.vue +16 -10
  33. package/app/components/DuxtTranslationBanner.vue +1 -1
  34. package/app/components/DuxtVersion.vue +53 -12
  35. package/app/components/DuxtVersionBanner.vue +5 -5
  36. package/app/components/content/AccordionItem.vue +2 -2
  37. package/app/components/content/BrunoOverview.vue +260 -0
  38. package/app/components/content/BrunoRequest.vue +141 -0
  39. package/app/components/content/BrunoRequests.vue +104 -0
  40. package/app/components/content/Callout.vue +5 -5
  41. package/app/components/content/ChangelogReleases.vue +161 -40
  42. package/app/components/content/CodeGroup.vue +41 -37
  43. package/app/components/content/Field.vue +1 -1
  44. package/app/components/content/FileTree.vue +1 -1
  45. package/app/components/content/OpenApiOperation.vue +4 -4
  46. package/app/components/content/OpenApiOperations.vue +1 -1
  47. package/app/components/content/OpenApiOverview.vue +1 -1
  48. package/app/components/content/PackageManagers.vue +42 -58
  49. package/app/components/content/PageCards.vue +7 -4
  50. package/app/components/content/Preview.vue +15 -39
  51. package/app/components/content/ProseImg.vue +596 -49
  52. package/app/components/content/Since.vue +1 -1
  53. package/app/components/content/Steps.vue +6 -4
  54. package/app/components/content/TabGroup.vue +13 -12
  55. package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
  56. package/app/components/ui/command/CommandShortcut.vue +1 -1
  57. package/app/components/ui/dialog/DialogContent.vue +2 -2
  58. package/app/components/ui/dialog/DialogHeader.vue +1 -1
  59. package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
  60. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
  61. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  62. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
  63. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
  64. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
  65. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
  66. package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
  67. package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
  68. package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
  69. package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
  70. package/app/components/ui/select/SelectItem.vue +2 -2
  71. package/app/components/ui/sheet/SheetContent.vue +29 -3
  72. package/app/components/ui/sidebar/Sidebar.vue +28 -4
  73. package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  74. package/app/components/ui/sidebar/SidebarInset.vue +1 -1
  75. package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  76. package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  77. package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
  78. package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  79. package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
  80. package/app/components/ui/sidebar/index.ts +10 -2
  81. package/app/components/ui/sidebar/utils.ts +0 -1
  82. package/app/components/ui/sonner/Sonner.vue +18 -0
  83. package/app/components/ui/tabs/TabsList.vue +13 -3
  84. package/app/components/ui/tabs/TabsTrigger.vue +24 -3
  85. package/app/components/ui/tooltip/TooltipContent.vue +2 -2
  86. package/app/composables/useActiveHeading.ts +3 -25
  87. package/app/composables/useDuxtAnalytics.ts +50 -0
  88. package/app/composables/useDuxtAnimatedHeight.ts +8 -38
  89. package/app/composables/useDuxtAnnouncements.ts +153 -0
  90. package/app/composables/useDuxtCopy.ts +51 -0
  91. package/app/composables/useDuxtDirection.ts +23 -0
  92. package/app/composables/useDuxtNavigation.ts +28 -8
  93. package/app/composables/useDuxtPath.ts +3 -5
  94. package/app/composables/useDuxtSearch.ts +87 -35
  95. package/app/composables/useDuxtSection.ts +15 -1
  96. package/app/composables/useDuxtSectionRow.ts +6 -5
  97. package/app/composables/useDuxtShortcuts.ts +319 -20
  98. package/app/composables/useDuxtVersion.ts +57 -6
  99. package/app/composables/useDuxtViewportMeasure.ts +37 -0
  100. package/app/composables/useFuzzySearch.ts +9 -4
  101. package/app/composables/useRecentPages.ts +14 -17
  102. package/app/error.vue +112 -74
  103. package/app/layouts/changelog.vue +9 -1
  104. package/app/layouts/default.vue +9 -0
  105. package/app/layouts/docs.vue +9 -1
  106. package/app/layouts/reference.vue +9 -1
  107. package/app/middleware/duxt-section-layout.global.ts +1 -3
  108. package/app/pages/[...slug].vue +146 -35
  109. package/app/pages/index.vue +15 -23
  110. package/app/types/duxt.d.ts +312 -4
  111. package/app/utils/analytics.ts +91 -0
  112. package/app/utils/announcements.ts +205 -0
  113. package/app/utils/changelog.ts +0 -28
  114. package/app/utils/code-highlight.ts +3 -1
  115. package/app/utils/contributor-avatar.ts +27 -0
  116. package/app/utils/direction.ts +43 -0
  117. package/app/utils/duxt-config.ts +43 -0
  118. package/app/utils/duxt-text.ts +10 -0
  119. package/app/utils/format-date.ts +28 -0
  120. package/app/utils/generated-sections.ts +68 -12
  121. package/app/utils/generated-toc.ts +14 -7
  122. package/app/utils/locale-path.ts +43 -0
  123. package/app/utils/navigation-tree.ts +51 -1
  124. package/app/utils/nearest-page.ts +8 -0
  125. package/app/utils/openapi.ts +16 -11
  126. package/app/utils/page-controls.ts +215 -0
  127. package/app/utils/pill.ts +61 -0
  128. package/app/utils/prose-image.ts +128 -0
  129. package/app/utils/search-context.ts +110 -0
  130. package/app/utils/search-display.ts +248 -0
  131. package/app/utils/section-scope.ts +65 -4
  132. package/app/utils/stored-json.ts +31 -0
  133. package/app/utils/version-choices.ts +6 -13
  134. package/app/utils/version-paths.ts +21 -0
  135. package/bin/duxt-cache-key.mjs +61 -0
  136. package/bin/duxt-og-cache.mjs +58 -0
  137. package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
  138. package/bruno-model.ts +169 -0
  139. package/bruno-parse.ts +656 -0
  140. package/bruno-zip.ts +195 -0
  141. package/cli.ts +240 -0
  142. package/duxt-app-config.ts +18 -1
  143. package/frontmatter.ts +49 -0
  144. package/git-contributors.ts +322 -0
  145. package/i18n/locales/de/duxt/announcement.json +7 -0
  146. package/i18n/locales/de/duxt/bruno.json +25 -0
  147. package/i18n/locales/de/duxt/changelog.json +1 -1
  148. package/i18n/locales/de/duxt/code.json +2 -1
  149. package/i18n/locales/de/duxt/page.json +9 -0
  150. package/i18n/locales/de/duxt/shortcuts.json +1 -0
  151. package/i18n/locales/de/duxt/theme.json +2 -1
  152. package/i18n/locales/en/duxt/announcement.json +7 -0
  153. package/i18n/locales/en/duxt/bruno.json +25 -0
  154. package/i18n/locales/en/duxt/changelog.json +1 -1
  155. package/i18n/locales/en/duxt/code.json +2 -1
  156. package/i18n/locales/en/duxt/page.json +9 -0
  157. package/i18n/locales/en/duxt/shortcuts.json +1 -0
  158. package/i18n/locales/en/duxt/theme.json +2 -1
  159. package/i18n/locales/es/duxt/announcement.json +7 -0
  160. package/i18n/locales/es/duxt/bruno.json +25 -0
  161. package/i18n/locales/es/duxt/changelog.json +1 -1
  162. package/i18n/locales/es/duxt/code.json +2 -1
  163. package/i18n/locales/es/duxt/page.json +9 -0
  164. package/i18n/locales/es/duxt/shortcuts.json +1 -0
  165. package/i18n/locales/es/duxt/theme.json +2 -1
  166. package/i18n/locales/fr/duxt/announcement.json +7 -0
  167. package/i18n/locales/fr/duxt/bruno.json +25 -0
  168. package/i18n/locales/fr/duxt/changelog.json +1 -1
  169. package/i18n/locales/fr/duxt/code.json +2 -1
  170. package/i18n/locales/fr/duxt/page.json +9 -0
  171. package/i18n/locales/fr/duxt/shortcuts.json +1 -0
  172. package/i18n/locales/fr/duxt/theme.json +2 -1
  173. package/i18n/locales/pt/duxt/announcement.json +7 -0
  174. package/i18n/locales/pt/duxt/bruno.json +25 -0
  175. package/i18n/locales/pt/duxt/changelog.json +1 -1
  176. package/i18n/locales/pt/duxt/code.json +2 -1
  177. package/i18n/locales/pt/duxt/page.json +9 -0
  178. package/i18n/locales/pt/duxt/shortcuts.json +1 -0
  179. package/i18n/locales/pt/duxt/theme.json +2 -1
  180. package/modules/bruno.ts +118 -0
  181. package/modules/config.ts +26 -0
  182. package/modules/devtools.ts +2 -3
  183. package/modules/git-meta.ts +16 -47
  184. package/modules/search-records.ts +132 -0
  185. package/nuxt.config.ts +81 -7
  186. package/og-image-cache.ts +742 -0
  187. package/package.json +42 -10
  188. package/public/devtools/checks.html +2 -2
  189. package/public/devtools/config.html +59 -4
  190. package/public/devtools/pages.html +8 -1
  191. package/public/devtools/paths.html +1 -1
  192. package/public/devtools/sources.html +2 -2
  193. package/public/devtools/versions.html +3 -0
  194. package/report.ts +28 -19
  195. package/search-records.ts +380 -0
  196. package/section-input.ts +142 -0
  197. package/section-markdown.ts +67 -0
  198. package/section-reports.ts +10 -4
  199. package/sections-bruno.ts +571 -0
  200. package/sections-changelog.ts +76 -43
  201. package/sections-openapi.ts +12 -83
  202. package/sections-resolve.ts +174 -8
  203. package/sections.ts +33 -10
  204. package/server/devtools/shell.ts +1 -1
  205. package/server/mcp/tools/list-pages.ts +77 -36
  206. package/server/mcp/tools/list-versions.ts +84 -0
  207. package/server/mcp/tools/read-page.ts +80 -26
  208. package/server/mcp/tools/search-docs.ts +87 -53
  209. package/server/middleware/raw-markdown.ts +1 -1
  210. package/server/routes/llms-full.txt.get.ts +6 -8
  211. package/server/routes/llms.txt.get.ts +1 -1
  212. package/server/utils/duxt-server-text.ts +0 -13
  213. package/server/utils/llms-pages.ts +24 -9
  214. package/server/utils/mcp-docs.ts +577 -0
  215. package/sources-cache.ts +376 -0
  216. package/sources-git.ts +186 -31
  217. package/sources-resolve.ts +59 -1
  218. package/sources.ts +122 -13
  219. package/tfplugindocs.ts +158 -0
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
- import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui';
2
+ import { useResizeObserver } from '@vueuse/core';
3
+ import { TabsContent, TabsRoot } from 'reka-ui';
3
4
  import type {
4
5
  DuxtOpenApiOperation,
5
6
  DuxtOpenApiSecurity,
@@ -67,7 +68,7 @@ const split = computed(() => props.layout === 'split');
67
68
 
68
69
  const id = useId();
69
70
  const { t } = useI18n();
70
- const notify = useDuxtToast();
71
+ const analytics = useDuxtAnalytics();
71
72
 
72
73
  const parameters = computed(() => props.operation.parameters ?? []);
73
74
  const bodies = computed(() => props.operation.requestBody?.content ?? []);
@@ -98,6 +99,12 @@ const variables = ref<Record<string, string>>({});
98
99
 
99
100
  const base = computed(() => openApiServerUrl(server.value, variables.value));
100
101
 
102
+ // Read before `values` below, which calls it while the component is set up.
103
+ // Declared further down it was in its temporal dead zone at that point, and
104
+ // every operation page that renders the client answered a 500 with
105
+ // "Cannot access 'duxt' before initialization".
106
+ const duxt = useDuxtConfig();
107
+
101
108
  /* ------------------------------------------------------------- parameters */
102
109
 
103
110
  /**
@@ -114,7 +121,12 @@ const values = ref<Record<string, string>>(
114
121
  // what is sent say the same thing about every parameter.
115
122
  .filter((parameter) => parameter.in !== 'cookie')
116
123
  .map((parameter) => {
117
- const derived = openApiExampleValue(parameter.schema, 'request');
124
+ const derived = openApiExampleValue(
125
+ parameter.schema,
126
+ 'request',
127
+ 0,
128
+ duxt.openapi?.exampleDepth
129
+ );
118
130
  const example = parameter.examples?.[0]?.value ?? derived;
119
131
 
120
132
  return [
@@ -168,8 +180,6 @@ const bodyKeys = computed(() => openApiBodyKeys(media.value?.schema));
168
180
  * property of one endpoint: somebody who edits the body as JSON means it on
169
181
  * the next one too. Falls back to the form the moment it cannot serve.
170
182
  */
171
- const duxt = useDuxtConfig();
172
-
173
183
  const storedMode = useDuxtChoice('request-body-view');
174
184
 
175
185
  const bodyMode = computed({
@@ -244,7 +254,12 @@ const body = ref(
244
254
  bodies.value.length
245
255
  ? openApiJson(
246
256
  bodies.value[0]!.examples?.[0]?.value ??
247
- openApiExampleValue(bodies.value[0]!.schema, 'request')
257
+ openApiExampleValue(
258
+ bodies.value[0]!.schema,
259
+ 'request',
260
+ 0,
261
+ duxt.openapi?.exampleDepth
262
+ )
248
263
  )
249
264
  : ''
250
265
  );
@@ -530,17 +545,65 @@ const { data: sampleHtml } = await useAsyncData(
530
545
  { watch: [shown] }
531
546
  );
532
547
 
533
- const copiedSample = ref(false);
548
+ /**
549
+ * A CROSSFADE between two samples, not a cut.
550
+ *
551
+ * The sample is a single block whose markup is replaced, so without help the
552
+ * new one simply appeared — cut off by a card that had not yet grown into it.
553
+ * The markup being replaced is kept for the length of the fade, laid over the
554
+ * top of the new one and faded out while the new one fades in; nothing is ever
555
+ * empty.
556
+ *
557
+ * Only for a change of language or client. The sample is also rewritten on
558
+ * every keystroke in the form above, and a fade per character typed is a
559
+ * flicker, not a transition.
560
+ */
561
+ const leavingSample = ref<string>();
562
+ const enteringSample = ref(false);
563
+ let switchingSample = false;
564
+ let leavingTimer: ReturnType<typeof setTimeout> | undefined;
565
+
566
+ watch([group, sample], () => {
567
+ switchingSample = true;
568
+ });
569
+
570
+ watch(sampleHtml, (next, previous) => {
571
+ if (!switchingSample || !previous || next === previous) return;
572
+ switchingSample = false;
573
+
574
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
575
+
576
+ clearTimeout(leavingTimer);
577
+ leavingSample.value = previous;
578
+ enteringSample.value = true;
579
+
580
+ requestAnimationFrame(() =>
581
+ requestAnimationFrame(() => {
582
+ enteringSample.value = false;
583
+ })
584
+ );
585
+
586
+ leavingTimer = setTimeout(() => {
587
+ leavingSample.value = undefined;
588
+ }, 250);
589
+ });
590
+
591
+ onBeforeUnmount(() => clearTimeout(leavingTimer));
592
+
593
+ const { copied: copiedSample, copy } = useDuxtCopy();
534
594
 
535
595
  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
- }
596
+ if (!(await copy(shown.value.code))) return;
597
+
598
+ // WHICH sample, never the sample. The code is the reader's own request
599
+ // written out — server, parameters, token and all — so only the two pieces
600
+ // of declared configuration travel.
601
+ analytics.track({
602
+ name: 'copy',
603
+ kind: 'request-sample',
604
+ sample: shown.value.id,
605
+ language: shown.value.language
606
+ });
544
607
  }
545
608
 
546
609
  /* ------------------------------------------------------------------- send */
@@ -560,6 +623,36 @@ const sending = ref(false);
560
623
  const result = ref<Result>();
561
624
  const failure = ref<string>();
562
625
 
626
+ /**
627
+ * The request that just settled, described entirely by the DOCUMENT.
628
+ *
629
+ * Everything here is declared in the OpenAPI file — the operation's id, its
630
+ * method, its path template — and nothing is the request that was actually
631
+ * made. Not the server the reader picked, not the URL it produced, not a
632
+ * header, not the token in it, not the body, not the response. That is the same
633
+ * promise the docblock at the top of this file makes about where credentials
634
+ * go, kept from the other side: a component that holds a token in its own state
635
+ * and then posts it to a site's analytics has not kept it anywhere.
636
+ *
637
+ * The duration is the request's own, taken before the spinner's floor is
638
+ * applied — `MINIMUM_WAIT` is a thing about the button, not about the API.
639
+ */
640
+ function report(
641
+ outcome: 'response' | 'failed',
642
+ duration: number,
643
+ statusClass?: DuxtAnalyticsStatusClass
644
+ ) {
645
+ analytics.track({
646
+ name: 'api-request',
647
+ operation: props.operation.operationId,
648
+ method: props.operation.method,
649
+ path: props.operation.path,
650
+ outcome,
651
+ statusClass,
652
+ duration
653
+ });
654
+ }
655
+
563
656
  async function send() {
564
657
  sending.value = true;
565
658
  // THE PREVIOUS ANSWER STAYS UNTIL THIS ONE ARRIVES. Clearing it here emptied
@@ -578,15 +671,18 @@ async function send() {
578
671
  });
579
672
 
580
673
  const text = await response.text();
674
+ const duration = Math.round(performance.now() - started);
581
675
 
582
676
  failure.value = undefined;
583
677
  result.value = {
584
678
  status: response.status,
585
679
  statusText: response.statusText,
586
- duration: Math.round(performance.now() - started),
680
+ duration,
587
681
  headers: [...response.headers.entries()],
588
682
  body: pretty(text)
589
683
  };
684
+
685
+ report('response', duration, duxtStatusClass(response.status));
590
686
  } catch (error) {
591
687
  // `fetch` rejects with a bare TypeError for a CORS refusal, a DNS failure
592
688
  // and an offline browser alike — the browser deliberately tells the page
@@ -597,6 +693,10 @@ async function send() {
597
693
  error instanceof Error && error.name !== 'TypeError'
598
694
  ? error.message
599
695
  : t('duxt.openapi.client.blocked');
696
+
697
+ // No status and no class: nothing came back to have one. The message is not
698
+ // reported either — for a CORS refusal it is this component's own guess.
699
+ report('failed', Math.round(performance.now() - started));
600
700
  } finally {
601
701
  // A FLOOR UNDER THE WAIT. The demo endpoint answers in a few milliseconds,
602
702
  // so the spinner appeared and vanished inside one frame — which reads as
@@ -625,6 +725,83 @@ const sampleBody = useTemplateRef<HTMLElement>('sampleBody');
625
725
 
626
726
  useDuxtAnimatedHeight(sampleShell, sampleBody, () => split.value);
627
727
 
728
+ /**
729
+ * THE PROSE BESIDE THE FORM STAYS WHERE IT IS when an answer arrives.
730
+ *
731
+ * The row centres its two halves, so a card that grew by a response pulled the
732
+ * text beside it down by half that height — the paragraph the reader had just
733
+ * read slid away while they looked at the answer. Centring against the FORM
734
+ * alone keeps the first paint exactly as it was and leaves the prose put: the
735
+ * offset is the card's height minus the response row's, which is the same
736
+ * number at every frame of that row opening.
737
+ *
738
+ * Only while the halves stand side by side; stacked, there is nothing to
739
+ * centre against. Until mounted the row's own `items-center` places it, and
740
+ * that is the same position, so hydration moves nothing.
741
+ */
742
+ const formProse = useTemplateRef<HTMLElement>('formProse');
743
+ const formCard = useTemplateRef<HTMLElement>('formCard');
744
+ const responseRow = useTemplateRef<HTMLElement>('responseRow');
745
+ const proseOffset = ref<number>();
746
+
747
+ useResizeObserver([formCard, formProse], () => {
748
+ const prose = formProse.value;
749
+ const card = formCard.value;
750
+ const row = responseRow.value;
751
+ if (!split.value || !prose || !card || !row) return;
752
+
753
+ proseOffset.value =
754
+ gridColumns(card) > 1
755
+ ? Math.max(
756
+ 0,
757
+ (card.offsetHeight - row.offsetHeight - prose.offsetHeight) / 2
758
+ )
759
+ : undefined;
760
+ });
761
+
762
+ /**
763
+ * THE SAME FOR THE PROSE BESIDE THE SAMPLES, which moves for a different
764
+ * reason: the card changes height with every language, and a row centring its
765
+ * halves nudged the text up and down on each tab click.
766
+ *
767
+ * Here there is no part of the card to subtract, so the text is centred once
768
+ * against the column as it first stands — and again only when the WIDTH
769
+ * changes, which is when its own lines rewrap. A tab click changes neither.
770
+ */
771
+ const sampleProse = useTemplateRef<HTMLElement>('sampleProse');
772
+ const sampleColumn = useTemplateRef<HTMLElement>('sampleColumn');
773
+ const sampleProseOffset = ref<number>();
774
+
775
+ /** The column's height when the text last rewrapped, and the width it did at. */
776
+ let sampleBaseline = 0;
777
+ let sampleWidth: number | undefined;
778
+
779
+ useResizeObserver(sampleProse, () => {
780
+ const prose = sampleProse.value;
781
+ const column = sampleColumn.value;
782
+ if (!split.value || !prose || !column) return;
783
+
784
+ if (prose.offsetWidth !== sampleWidth) {
785
+ sampleWidth = prose.offsetWidth;
786
+ sampleBaseline = column.offsetHeight;
787
+ }
788
+
789
+ sampleProseOffset.value =
790
+ gridColumns(column) > 1
791
+ ? Math.max(0, (sampleBaseline - prose.offsetHeight) / 2)
792
+ : undefined;
793
+ });
794
+
795
+ /**
796
+ * How many columns the grid holding `element` lays out — one when the halves
797
+ * stack, which is when neither piece of prose has anything to centre against.
798
+ */
799
+ function gridColumns(element: HTMLElement): number {
800
+ return getComputedStyle(element.parentElement ?? element)
801
+ .gridTemplateColumns.split(' ')
802
+ .filter(Boolean).length;
803
+ }
804
+
628
805
  /** A JSON body, indented; anything else exactly as it arrived. */
629
806
  function pretty(text: string): string {
630
807
  try {
@@ -661,7 +838,19 @@ function pretty(text: string): string {
661
838
  split ? 'grid items-center gap-8 lg:grid-cols-2 lg:gap-16' : 'contents'
662
839
  "
663
840
  >
664
- <div v-if="split" :class="reverse ? 'lg:order-2' : 'lg:order-1'">
841
+ <div
842
+ v-if="split"
843
+ ref="formProse"
844
+ :class="[
845
+ reverse ? 'lg:order-2' : 'lg:order-1',
846
+ proseOffset === undefined ? '' : 'self-start'
847
+ ]"
848
+ :style="
849
+ proseOffset === undefined
850
+ ? undefined
851
+ : { marginTop: `${proseOffset}px` }
852
+ "
853
+ >
665
854
  <slot name="beside-form" />
666
855
  </div>
667
856
 
@@ -669,6 +858,7 @@ function pretty(text: string): string {
669
858
  centred in its row grows in both directions when an answer arrives,
670
859
  and the form the reader is looking at moves up under their cursor. -->
671
860
  <div
861
+ ref="formCard"
672
862
  :class="
673
863
  split
674
864
  ? [
@@ -906,11 +1096,11 @@ function pretty(text: string): string {
906
1096
  </label>
907
1097
 
908
1098
  <!-- 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. -->
1099
+ view, so it comes and goes with it — and at the START of the row
1100
+ that costs nothing: the toggle is pinned to the far END by
1101
+ `ms-auto`, so the control the reader just clicked cannot move out
1102
+ from under the pointer, which is what happened when the two
1103
+ shared the end of this row. -->
914
1104
  <UiButton
915
1105
  v-if="mode === 'json'"
916
1106
  type="button"
@@ -926,26 +1116,18 @@ function pretty(text: string): string {
926
1116
  the schema cannot describe as a form has one view, and a strip
927
1117
  offering a tab that falls straight back is a control that lies
928
1118
  about what it does. -->
929
- <div
1119
+ <DuxtSegmented
930
1120
  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>
1121
+ :model-value="mode"
1122
+ :options="[
1123
+ { value: 'form', label: $t('duxt.openapi.client.form') },
1124
+ { value: 'json', label: $t('duxt.openapi.client.json') }
1125
+ ]"
1126
+ size="sm"
1127
+ tone="flat"
1128
+ class="ms-auto gap-0.5 rounded-md border p-0.5"
1129
+ @update:model-value="bodyMode = $event"
1130
+ />
949
1131
  </div>
950
1132
 
951
1133
  <UiSelect v-if="bodies.length > 1" v-model="bodyValue">
@@ -1072,6 +1254,7 @@ function pretty(text: string): string {
1072
1254
  in advance; the inner `overflow-hidden` is what lets the row be
1073
1255
  shorter than its content on the way. -->
1074
1256
  <div
1257
+ ref="responseRow"
1075
1258
  class="grid motion-safe:transition-all motion-safe:duration-300 motion-safe:ease-out"
1076
1259
  :class="
1077
1260
  failure || result
@@ -1094,7 +1277,7 @@ function pretty(text: string): string {
1094
1277
  <span class="text-muted-foreground">
1095
1278
  {{ result.statusText }}
1096
1279
  </span>
1097
- <span class="ml-auto font-mono text-xs text-muted-foreground">
1280
+ <span class="ms-auto font-mono text-xs text-muted-foreground">
1098
1281
  {{ result.duration }}&nbsp;ms
1099
1282
  </span>
1100
1283
  </div>
@@ -1146,7 +1329,19 @@ function pretty(text: string): string {
1146
1329
  split ? 'grid items-center gap-8 lg:grid-cols-2 lg:gap-16' : 'contents'
1147
1330
  "
1148
1331
  >
1149
- <div v-if="split" :class="reverse ? 'lg:order-1' : 'lg:order-2'">
1332
+ <div
1333
+ v-if="split"
1334
+ ref="sampleProse"
1335
+ :class="[
1336
+ reverse ? 'lg:order-1' : 'lg:order-2',
1337
+ sampleProseOffset === undefined ? '' : 'self-start'
1338
+ ]"
1339
+ :style="
1340
+ sampleProseOffset === undefined
1341
+ ? undefined
1342
+ : { marginTop: `${sampleProseOffset}px` }
1343
+ "
1344
+ >
1150
1345
  <slot name="beside-samples" />
1151
1346
  </div>
1152
1347
 
@@ -1159,6 +1354,7 @@ function pretty(text: string): string {
1159
1354
  in both directions at once, and its tab strip — the control the
1160
1355
  reader just clicked — moved up under their cursor. -->
1161
1356
  <div
1357
+ ref="sampleColumn"
1162
1358
  :class="
1163
1359
  split
1164
1360
  ? ['min-w-0 self-start', reverse ? 'lg:order-2' : 'lg:order-1']
@@ -1195,39 +1391,30 @@ function pretty(text: string): string {
1195
1391
  button inside one as `aria-required-children`. The header row is
1196
1392
  the flex container instead, so it still sits where every other
1197
1393
  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 min-w-0 flex-1 items-center gap-1 overflow-x-auto"
1394
+ <DuxtCodeToolbar>
1395
+ <UiTabsList
1396
+ variant="bare"
1397
+ class="min-w-0 flex-1 overflow-x-auto"
1203
1398
  :aria-label="$t('duxt.openapi.client.samples') as string"
1204
1399
  >
1205
- <TabsTrigger
1400
+ <UiTabsTrigger
1206
1401
  v-for="entry in groups"
1207
1402
  :key="entry"
1208
1403
  :value="entry"
1209
- class="flex shrink-0 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"
1404
+ variant="pill"
1405
+ class="font-mono"
1210
1406
  >
1211
1407
  <Icon :name="groupIcon(entry)" class="size-3.5" />
1212
1408
  {{ 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
- "
1409
+ </UiTabsTrigger>
1410
+ </UiTabsList>
1411
+
1412
+ <DuxtCopyButton
1413
+ :copied="copiedSample"
1414
+ class="ms-auto"
1223
1415
  @click="copySample"
1224
- >
1225
- <Icon
1226
- :name="copiedSample ? 'lucide:check' : 'lucide:copy'"
1227
- class="size-3.5"
1228
- />
1229
- </UiButton>
1230
- </div>
1416
+ />
1417
+ </DuxtCodeToolbar>
1231
1418
 
1232
1419
  <!-- THE CLIENTS OF THE ACTIVE LANGUAGE, in a row of their own.
1233
1420
  Beside the tabs they were a control the reader had to open to learn
@@ -1240,28 +1427,20 @@ function pretty(text: string): string {
1240
1427
  `ui/radio-group` here to implement it. A group of pressed buttons
1241
1428
  promises only what it does — every one reachable by Tab, every one
1242
1429
  saying whether it is on. -->
1243
- <div
1430
+ <DuxtSegmented
1244
1431
  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>
1432
+ v-model="sample"
1433
+ :options="
1434
+ clients.map((entry) => ({
1435
+ value: entry.id,
1436
+ label: entry.label
1437
+ }))
1438
+ "
1439
+ :label="$t('duxt.openapi.client.sampleClient') as string"
1440
+ size="sm"
1441
+ mono
1442
+ class="overflow-x-auto border-b bg-muted/20 px-2 py-1.5"
1443
+ />
1265
1444
 
1266
1445
  <!-- Six is where a row stops being a row. A language with that many
1267
1446
  clients is a site that configured them, and a select carries any
@@ -1314,14 +1493,30 @@ function pretty(text: string): string {
1314
1493
  The floor stays for the panel layout, where there is no row to
1315
1494
  reserve anything: on an operation page the card is in a column
1316
1495
  that scrolls as a whole. -->
1317
- <TabsContent :value="group">
1496
+ <TabsContent :value="group" class="relative">
1318
1497
  <!-- eslint-disable-next-line vue/no-v-html -- Shiki's own output over
1319
1498
  a string this component built; nothing a reader typed reaches it
1320
1499
  unescaped. -->
1500
+ <!-- eslint-disable-next-line vue/no-v-html -- the sample that was
1501
+ showing, kept only for the length of the fade. -->
1502
+ <div
1503
+ v-if="leavingSample"
1504
+ aria-hidden="true"
1505
+ class="duxt-code-body duxt-code-body-sm pointer-events-none absolute inset-x-0 top-0 transition-opacity duration-200 ease-out"
1506
+ :class="enteringSample ? 'opacity-100' : 'opacity-0'"
1507
+ v-html="leavingSample"
1508
+ />
1321
1509
  <div
1322
1510
  v-if="sampleHtml"
1323
1511
  class="duxt-code-body duxt-code-body-sm"
1324
- :class="split ? '' : 'min-h-56'"
1512
+ :class="[
1513
+ split ? '' : 'min-h-56',
1514
+ leavingSample
1515
+ ? enteringSample
1516
+ ? 'opacity-0'
1517
+ : 'opacity-100 transition-opacity duration-200 ease-out'
1518
+ : ''
1519
+ ]"
1325
1520
  v-html="sampleHtml"
1326
1521
  />
1327
1522
  <pre
@@ -22,15 +22,29 @@ const props = defineProps<{
22
22
 
23
23
  const types = computed(() => props.content ?? []);
24
24
  const current = ref(0);
25
+ const duxt = useDuxtConfig();
25
26
 
26
27
  const media = computed(() => types.value[current.value] ?? types.value[0]);
27
28
 
29
+ /** The index as the string a pill group keys its options by. */
30
+ const currentValue = computed({
31
+ get: () => String(current.value),
32
+ set: (value: string) => {
33
+ current.value = Number(value);
34
+ }
35
+ });
36
+
28
37
  /** The examples the document wrote, and — where it wrote none — a derived one. */
29
38
  const examples = computed(() => {
30
39
  const written = media.value?.examples ?? [];
31
40
  if (written.length) return written;
32
41
 
33
- const derived = openApiExampleValue(media.value?.schema, props.direction);
42
+ const derived = openApiExampleValue(
43
+ media.value?.schema,
44
+ props.direction,
45
+ 0,
46
+ duxt.openapi?.exampleDepth
47
+ );
34
48
 
35
49
  return derived === null ? [] : [{ name: 'example', value: derived }];
36
50
  });
@@ -38,23 +52,21 @@ const examples = computed(() => {
38
52
 
39
53
  <template>
40
54
  <div v-if="types.length" class="min-w-0">
41
- <div v-if="types.length > 1" class="mb-3 flex flex-wrap gap-1.5">
42
- <button
43
- v-for="(entry, index) in types"
44
- :key="entry.type"
45
- type="button"
46
- class="rounded-md px-2 py-1 font-mono text-xs transition-colors"
47
- :class="
48
- index === current
49
- ? 'bg-muted text-foreground'
50
- : 'text-muted-foreground hover:text-foreground'
51
- "
52
- :aria-pressed="index === current"
53
- @click="current = index"
54
- >
55
- {{ entry.type }}
56
- </button>
57
- </div>
55
+ <!-- `flat`: this strip sits on the page itself, where a raised pill's
56
+ shadow has nothing to lift it off. -->
57
+ <DuxtSegmented
58
+ v-if="types.length > 1"
59
+ v-model="currentValue"
60
+ :options="
61
+ types.map((entry, index) => ({
62
+ value: String(index),
63
+ label: entry.type
64
+ }))
65
+ "
66
+ tone="flat"
67
+ mono
68
+ class="mb-3 flex-wrap gap-1.5"
69
+ />
58
70
 
59
71
  <p v-else class="mb-2 font-mono text-xs text-muted-foreground">
60
72
  {{ media?.type }}