@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
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The requests in one folder, as the list a reader scans.
4
+ *
5
+ * MDC: `::bruno-requests` on a folder's index page. The method is a coloured
6
+ * chip and the URL is the line beside it, because "find this request" is the
7
+ * search a reader actually performs — a list of names alone makes them open
8
+ * every one to find the `DELETE`.
9
+ *
10
+ * FOLDERS FIRST, THEN REQUESTS, which is Bruno's own nesting: a folder holds
11
+ * both, and OpenAPI's flat tags have no counterpart for it. Rendering the
12
+ * sub-folders as a second list rather than flattening them is what keeps the
13
+ * page the same shape as the collection on disk.
14
+ */
15
+ defineProps<{
16
+ requests?: { name: string; method: string; url: string; to: string }[];
17
+ groups?: {
18
+ name: string;
19
+ description?: string;
20
+ requests?: number;
21
+ to: string;
22
+ }[];
23
+ }>();
24
+
25
+ const localeLink = useDuxtLink();
26
+ </script>
27
+
28
+ <template>
29
+ <div>
30
+ <!-- Outside `not-typeset`: the folder's own `docs` block is Markdown and
31
+ wants the typeset preset; the lists below emphatically do not. -->
32
+ <slot />
33
+
34
+ <section v-if="groups?.length" class="not-typeset mt-6">
35
+ <h2 class="duxt-label">{{ $t('duxt.bruno.folders') }}</h2>
36
+
37
+ <ul class="mt-3 -mx-3">
38
+ <li
39
+ v-for="group in groups"
40
+ :key="group.to"
41
+ class="border-t border-border/60 first:border-t-0"
42
+ >
43
+ <NuxtLink
44
+ :to="localeLink(group.to) ?? group.to"
45
+ class="group flex items-start gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-muted/40"
46
+ >
47
+ <span class="min-w-0 flex-1">
48
+ <span class="flex flex-wrap items-baseline gap-x-3">
49
+ <span class="font-medium">{{ group.name }}</span>
50
+ <span class="text-xs text-muted-foreground tabular-nums">
51
+ {{ group.requests }}
52
+ </span>
53
+ </span>
54
+
55
+ <span
56
+ v-if="group.description"
57
+ class="mt-1 line-clamp-2 block text-sm text-muted-foreground"
58
+ >
59
+ {{ group.description }}
60
+ </span>
61
+ </span>
62
+
63
+ <Icon
64
+ name="lucide:arrow-right"
65
+ aria-hidden="true"
66
+ class="mt-1 size-3.5 shrink-0 text-muted-foreground rtl:-scale-x-100 transition-transform ltr:group-hover:translate-x-0.5 rtl:group-hover:-translate-x-0.5"
67
+ />
68
+ </NuxtLink>
69
+ </li>
70
+ </ul>
71
+ </section>
72
+
73
+ <section v-if="requests?.length" class="not-typeset mt-6">
74
+ <h2 class="duxt-label">{{ $t('duxt.bruno.requests') }}</h2>
75
+
76
+ <ul class="mt-3 -mx-3">
77
+ <li
78
+ v-for="entry in requests"
79
+ :key="entry.to"
80
+ class="border-t border-border/60 first:border-t-0"
81
+ >
82
+ <NuxtLink
83
+ :to="localeLink(entry.to) ?? entry.to"
84
+ class="flex flex-wrap items-center gap-3 rounded-lg px-3 py-3 transition-colors hover:bg-muted/40"
85
+ >
86
+ <DuxtOpenApiMethod :method="entry.method" class="w-16" />
87
+
88
+ <span class="font-medium sm:me-auto">{{ entry.name }}</span>
89
+
90
+ <!-- The auto margin is on the NAME above, not here: this is a
91
+ `<code>`, which the stylesheet pins to left-to-right, and a
92
+ `ms-auto` resolved against that would push the URL back to the
93
+ start of a right-to-left row. -->
94
+ <code
95
+ class="w-full font-mono text-xs break-all text-muted-foreground sm:w-auto"
96
+ >
97
+ {{ entry.url }}
98
+ </code>
99
+ </NuxtLink>
100
+ </li>
101
+ </ul>
102
+ </section>
103
+ </div>
104
+ </template>
@@ -12,20 +12,20 @@ const props = withDefaults(
12
12
  );
13
13
 
14
14
  const variants = {
15
- info: { icon: 'lucide:info', rule: 'border-l-sky-500', text: 'text-sky-500' },
15
+ info: { icon: 'lucide:info', rule: 'border-s-sky-500', text: 'text-sky-500' },
16
16
  tip: {
17
17
  icon: 'lucide:lightbulb',
18
- rule: 'border-l-emerald-500',
18
+ rule: 'border-s-emerald-500',
19
19
  text: 'text-emerald-500'
20
20
  },
21
21
  warning: {
22
22
  icon: 'lucide:triangle-alert',
23
- rule: 'border-l-amber-500',
23
+ rule: 'border-s-amber-500',
24
24
  text: 'text-amber-500'
25
25
  },
26
26
  danger: {
27
27
  icon: 'lucide:octagon-alert',
28
- rule: 'border-l-red-500',
28
+ rule: 'border-s-red-500',
29
29
  text: 'text-red-500'
30
30
  }
31
31
  };
@@ -35,7 +35,7 @@ const variant = computed(() => variants[props.type]);
35
35
 
36
36
  <template>
37
37
  <UiAlert
38
- class="my-5 flex items-start gap-2.5 rounded-md border border-l-2 bg-muted/30 px-3 py-2.5"
38
+ class="my-5 flex items-start gap-2.5 rounded-md border border-s-2 bg-muted/30 px-3 py-2.5"
39
39
  :class="variant.rule"
40
40
  >
41
41
  <Icon
@@ -29,6 +29,15 @@ const props = withDefaults(
29
29
  date?: string;
30
30
  to: string;
31
31
  groups?: { name: string; count: number }[];
32
+ /**
33
+ * Who wrote the commits this release carries, most commits first.
34
+ *
35
+ * Written by `sections-changelog.ts` out of the repository's own tags,
36
+ * and absent wherever the build could not read them — a downloaded
37
+ * source, a release with no tag yet, a changelog kept by hand. A name
38
+ * and, where git carried one, a handle; never an address.
39
+ */
40
+ contributors?: { name: string; username?: string }[];
32
41
  }[];
33
42
  }>(),
34
43
  { releases: () => [] }
@@ -36,6 +45,18 @@ const props = withDefaults(
36
45
 
37
46
  const { locale } = useI18n();
38
47
  const localeLink = useDuxtLink();
48
+ const duxt = useDuxtConfig();
49
+
50
+ // `DuxtText` also permits a record of strings, so the resolved-config mapped
51
+ // type narrows this all-optional object too far. It remains an object at
52
+ // runtime; only its template is configurable. The same cast `DuxtPageInfo`
53
+ // makes, over the same key, for the same reason.
54
+ const contributorConfig = duxt.contributors as unknown as
55
+ | { avatarUrl?: string }
56
+ | undefined;
57
+
58
+ const avatar = (username?: string) =>
59
+ contributorAvatar(contributorConfig?.avatarUrl, username);
39
60
 
40
61
  /** Every group name the file used, in the order it first used it. */
41
62
  const names = computed(() => {
@@ -93,10 +114,34 @@ function toggle(name: string) {
93
114
  * The release day, in the reader's language — shared with the meta row a
94
115
  * release page draws, so one release cannot be dated two different ways.
95
116
  */
96
- const formatted = (value?: string) => changelogDate(value, locale.value);
117
+ const formatted = (value?: string) =>
118
+ formatDate(value, locale.value, { calendarDay: true });
97
119
  /** How many groups a row names before it starts counting the rest. */
98
120
  const SHOWN_GROUPS = 3;
99
121
 
122
+ /**
123
+ * How many faces a row shows before it starts counting the rest.
124
+ *
125
+ * Fewer than the groups, because these are pictures rather than words: four
126
+ * overlapping circles read as "a few people" at a glance, where eight read as a
127
+ * second column and push the date off the line.
128
+ */
129
+ const SHOWN_CONTRIBUTORS = 4;
130
+
131
+ /**
132
+ * One person's identity ACROSS releases.
133
+ *
134
+ * The build dedupes by email, which is the only thing that actually identifies
135
+ * a committer — and then deliberately does not ship it (see
136
+ * `git-contributors.ts`). What is left to count across releases is the handle,
137
+ * or the name where git carried no handle. Two people with the same display
138
+ * name and neither of them on GitHub therefore count as one; they are also
139
+ * indistinguishable to the reader, so the figure says exactly what the page
140
+ * shows.
141
+ */
142
+ const identity = (person: { name: string; username?: string }) =>
143
+ person.username ?? person.name;
144
+
100
145
  /**
101
146
  * The whole history in two numbers, over the list rather than in it.
102
147
  *
@@ -118,14 +163,16 @@ const stats = computed(() => ({
118
163
  all + (release.groups ?? []).reduce((sum, group) => sum + group.count, 0),
119
164
  0
120
165
  ),
121
- // What the project actually does, as opposed to how much of it: a history of
122
- // forty releases that only ever fixes bugs says something a change count
123
- // cannot. Counted over the names the file used, so it is the file's own
124
- // vocabulary being counted and not a taxonomy of ours.
125
- kinds: new Set(
126
- shown.value.flatMap((release) =>
127
- (release.groups ?? []).map((group) => group.name)
128
- )
166
+ // WHO, not what kind of. This figure used to count the group names the file
167
+ // used, which described the changelog rather than the project — and a
168
+ // documentation site's first question about a project it is weighing up is
169
+ // how many people are behind it. The kinds are still on the page: they are
170
+ // what the filter offers and what every row names.
171
+ //
172
+ // Counted over what is SHOWN, like every figure here, so filtering to
173
+ // breaking changes answers "who has ever shipped one".
174
+ contributors: new Set(
175
+ shown.value.flatMap((release) => (release.contributors ?? []).map(identity))
129
176
  ).size,
130
177
  // The last day something shipped — the one figure here that is not a count,
131
178
  // and the one a reader checks first on a project they are considering.
@@ -137,8 +184,8 @@ const latest = (release: { to: string }) =>
137
184
  release.to === props.releases[0]?.to;
138
185
 
139
186
  /**
140
- * The rows as they are drawn: a release, the groups it NAMES, and how many it
141
- * leaves.
187
+ * The rows as they are drawn: a release, the groups it NAMES, the people it
188
+ * SHOWS, and how many of each it leaves.
142
189
  *
143
190
  * The whole list was the row before this, and on a release that touched
144
191
  * everything that was twelve names over two lines — twelve facts on a page
@@ -154,6 +201,10 @@ const latest = (release: { to: string }) =>
154
201
  * A FILTERED row names what it was filtered by first. Otherwise selecting
155
202
  * "Tests" leaves a release matching on a group its row does not mention, which
156
203
  * reads as a filter that let the wrong page through.
204
+ *
205
+ * THE PEOPLE ARE NOT RE-SORTED. The build already ordered them by how much of
206
+ * the release each one wrote, and nothing on this page filters by person — so
207
+ * the cap takes the front of a list that already means something.
157
208
  */
158
209
  const rows = computed(() =>
159
210
  shown.value.map((release) => {
@@ -165,10 +216,14 @@ const rows = computed(() =>
165
216
  return chosen || b.count - a.count;
166
217
  });
167
218
 
219
+ const people = release.contributors ?? [];
220
+
168
221
  return {
169
222
  release,
170
223
  named: groups.slice(0, SHOWN_GROUPS),
171
- rest: Math.max(0, groups.length - SHOWN_GROUPS)
224
+ rest: Math.max(0, groups.length - SHOWN_GROUPS),
225
+ people: people.slice(0, SHOWN_CONTRIBUTORS),
226
+ others: Math.max(0, people.length - SHOWN_CONTRIBUTORS)
172
227
  };
173
228
  })
174
229
  );
@@ -249,7 +304,7 @@ const rows = computed(() =>
249
304
  />
250
305
  <span class="truncate">{{ changelogLabel(name) }}</span>
251
306
  <span
252
- class="ml-auto pl-2 text-xs text-muted-foreground tabular-nums"
307
+ class="ms-auto ps-2 text-xs text-muted-foreground tabular-nums"
253
308
  >
254
309
  {{ totals[name] }}
255
310
  </span>
@@ -369,15 +424,20 @@ const rows = computed(() =>
369
424
  </dt>
370
425
  </div>
371
426
 
372
- <div v-if="stats.kinds" class="flex flex-col gap-1">
427
+ <!-- Dropped rather than shown as a nought wherever the build could not
428
+ read a history — a downloaded source, a changelog whose versions
429
+ were never tagged. "0 contributors" is a claim about the project;
430
+ an absent figure is a claim about this build, which is the true
431
+ one. Same rule as the date beside it. -->
432
+ <div v-if="stats.contributors" class="flex flex-col gap-1">
373
433
  <dd
374
434
  class="text-2xl font-semibold tracking-tight tabular-nums sm:text-3xl"
375
435
  >
376
- {{ stats.kinds }}
436
+ {{ stats.contributors }}
377
437
  </dd>
378
438
  <dt class="flex items-center gap-1.5 text-xs font-medium text-primary">
379
- <Icon name="lucide:list-filter" class="size-3.5" />
380
- {{ $t('duxt.changelog.kinds') }}
439
+ <Icon name="lucide:users" class="size-3.5" />
440
+ {{ $t('duxt.changelog.contributors') }}
381
441
  </dt>
382
442
  </div>
383
443
 
@@ -434,7 +494,7 @@ const rows = computed(() =>
434
494
  move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
435
495
  >
436
496
  <li
437
- v-for="{ release, named, rest } in rows"
497
+ v-for="{ release, named, rest, people, others } in rows"
438
498
  :key="release.to"
439
499
  class="border-t border-border/60 first:border-t-0"
440
500
  >
@@ -478,40 +538,101 @@ const rows = computed(() =>
478
538
  </time>
479
539
  </span>
480
540
 
481
- <p
482
- class="col-span-2 row-start-2 text-xs text-muted-foreground sm:col-span-1 sm:col-start-2 sm:row-start-1"
541
+ <!-- WHAT the release carried and WHO carried it, in one cell. They
542
+ share the summary column rather than taking a track each: a
543
+ fifth grid column would hold an empty gutter on every release
544
+ whose contributors this build could not read, and the faces
545
+ belong beside the summary they are the other half of. `flex-wrap`
546
+ lets the people drop under the groups on a narrow row instead of
547
+ squeezing the line. -->
548
+ <div
549
+ class="col-span-2 row-start-2 flex flex-wrap items-center gap-x-2 gap-y-1 sm:col-span-1 sm:col-start-2 sm:row-start-1"
483
550
  >
484
- <span v-for="(group, index) in named" :key="group.name">
485
- <!-- The separator is punctuation, not a word: set fainter than
486
- either side and given room, so the eye breaks the line into
487
- three facts instead of reading one run of text. -->
551
+ <p class="text-xs text-muted-foreground">
552
+ <span v-for="(group, index) in named" :key="group.name">
553
+ <!-- The separator is punctuation, not a word: set fainter than
554
+ either side and given room, so the eye breaks the line into
555
+ three facts instead of reading one run of text. -->
556
+ <span
557
+ v-if="index"
558
+ aria-hidden="true"
559
+ class="px-1 text-muted-foreground/50"
560
+ >
561
+ ·
562
+ </span>
563
+ {{ changelogLabel(group.name) }}
564
+ <span class="ms-0.5 font-medium text-foreground tabular-nums">
565
+ {{ group.count }}
566
+ </span>
567
+ </span>
568
+
569
+ <template v-if="rest">
570
+ <span aria-hidden="true" class="px-1 text-muted-foreground/50">
571
+ ·
572
+ </span>
573
+ {{ $t('duxt.changelog.more', { count: rest }) }}
574
+ </template>
575
+ </p>
576
+
577
+ <!-- FACES, NOT NAMES, and the row is why: four names are a second
578
+ line of prose competing with the summary, where four
579
+ overlapping circles are read as "a few people" without being
580
+ read at all. The name is still THERE for anyone who needs it —
581
+ as the image's own alternative text, so a screen reader hears
582
+ the people rather than "image, image, image", and so the row's
583
+ link says who wrote the release it leads to.
584
+
585
+ They OVERLAP, each ringed in the page's own background, which
586
+ is the one arrangement that says "these belong together" at
587
+ 18px without a label. `ring` rather than `border`, so the ring
588
+ sits outside the circle and the picture is not cropped by it. -->
589
+ <span v-if="people.length" class="flex shrink-0 items-center">
488
590
  <span
489
- v-if="index"
490
- aria-hidden="true"
491
- class="px-1 text-muted-foreground/50"
591
+ v-for="person in people"
592
+ :key="identity(person)"
593
+ class="-ms-1.5 first:ms-0"
492
594
  >
493
- ·
494
- </span>
495
- {{ changelogLabel(group.name) }}
496
- <span class="ml-0.5 font-medium text-foreground tabular-nums">
497
- {{ group.count }}
595
+ <img
596
+ v-if="avatar(person.username)"
597
+ :src="avatar(person.username)"
598
+ :alt="person.name"
599
+ width="18"
600
+ height="18"
601
+ loading="lazy"
602
+ class="size-[18px] rounded-full bg-muted ring-2 ring-background"
603
+ />
604
+ <!-- No handle, so no picture: git only carries one in a
605
+ noreply address, and a guessed avatar is somebody else's
606
+ face. The initial is decoration over the name beside it. -->
607
+ <span
608
+ v-else
609
+ class="flex size-[18px] items-center justify-center rounded-full bg-muted text-[9px] font-medium ring-2 ring-background"
610
+ >
611
+ <span aria-hidden="true">
612
+ {{ person.name.slice(0, 1).toUpperCase() }}
613
+ </span>
614
+ <span class="sr-only">{{ person.name }}</span>
615
+ </span>
498
616
  </span>
499
- </span>
500
617
 
501
- <template v-if="rest">
502
- <span aria-hidden="true" class="px-1 text-muted-foreground/50">
503
- ·
618
+ <span
619
+ v-if="others"
620
+ class="-ms-1.5 flex h-[18px] items-center rounded-full bg-muted px-1.5 text-[9px] font-medium text-muted-foreground ring-2 ring-background tabular-nums"
621
+ >
622
+ <span aria-hidden="true">+{{ others }}</span>
623
+ <span class="sr-only">
624
+ {{ $t('duxt.changelog.more', { count: others }) }}
625
+ </span>
504
626
  </span>
505
- {{ $t('duxt.changelog.more', { count: rest }) }}
506
- </template>
507
- </p>
627
+ </span>
628
+ </div>
508
629
 
509
630
  <!-- The affordance every card and row of this site uses: the arrow
510
631
  that steps forward under the pointer. -->
511
632
  <Icon
512
633
  name="lucide:arrow-right"
513
634
  aria-hidden="true"
514
- class="col-start-2 row-start-1 size-3.5 shrink-0 self-center text-muted-foreground transition-transform group-hover:translate-x-0.5 sm:col-start-4"
635
+ class="col-start-2 row-start-1 size-3.5 shrink-0 self-center text-muted-foreground rtl:-scale-x-100 transition-transform ltr:group-hover:translate-x-0.5 rtl:group-hover:-translate-x-0.5 sm:col-start-4"
515
636
  />
516
637
  </NuxtLink>
517
638
  </li>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui';
2
+ import { TabsContent, TabsRoot } from 'reka-ui';
3
3
 
4
4
  /**
5
5
  * Several fences, one block, one tab each.
@@ -29,6 +29,8 @@ interface Entry {
29
29
  label: string;
30
30
  icon: string;
31
31
  code: string;
32
+ /** The fence's own language, where it declared one — for the copy event. */
33
+ language?: string;
32
34
  node: VNode;
33
35
  }
34
36
 
@@ -56,6 +58,7 @@ const entries = computed<Entry[]>(() => {
56
58
  props.language ? 'lucide:terminal' : 'lucide:file'
57
59
  ),
58
60
  code: props.code ?? '',
61
+ language: props.language,
59
62
  node
60
63
  };
61
64
  });
@@ -87,9 +90,8 @@ const shell = useTemplateRef<HTMLElement>('shell');
87
90
  const body = useTemplateRef<HTMLElement>('body');
88
91
 
89
92
  useDuxtAnimatedHeight(shell, body);
90
- const copied = ref(false);
91
- const notify = useDuxtToast();
92
- const { t } = useI18n();
93
+ const { copied, copy: copyText } = useDuxtCopy();
94
+ const analytics = useDuxtAnalytics();
93
95
 
94
96
  async function copy() {
95
97
  const text =
@@ -97,14 +99,15 @@ async function copy() {
97
99
  root.value?.querySelector('[data-state="active"] code')?.textContent ||
98
100
  '';
99
101
 
100
- try {
101
- await navigator.clipboard.writeText(text);
102
- copied.value = true;
103
- notify.success(t('duxt.code.copiedToast'));
104
- setTimeout(() => (copied.value = false), 2000);
105
- } catch {
106
- notify.error(t('duxt.page.copyFailed'));
107
- }
102
+ if (!(await copyText(text))) return;
103
+
104
+ // Which tab was showing is the interesting part — a group exists because
105
+ // the same thing is spelled several ways, and this says which spelling won.
106
+ analytics.track({
107
+ name: 'copy',
108
+ kind: 'code-group',
109
+ language: current.value?.language
110
+ });
108
111
  }
109
112
  </script>
110
113
 
@@ -119,37 +122,26 @@ async function copy() {
119
122
  inside it is a control a screen reader is told to treat as a tab and
120
123
  cannot. So the row is a plain box and the tablist is the part of it
121
124
  that actually holds tabs. -->
122
- <div
123
- class="flex min-h-11 items-center gap-1 border-b bg-muted/40 px-2 py-1.5"
124
- >
125
- <TabsList
126
- class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
125
+ <DuxtCodeToolbar>
126
+ <UiTabsList
127
+ variant="bare"
128
+ class="min-w-0 flex-1 overflow-x-auto"
127
129
  :aria-label="$t('duxt.page.tabs') as string"
128
130
  >
129
- <TabsTrigger
131
+ <UiTabsTrigger
130
132
  v-for="entry in entries"
131
133
  :key="entry.value"
132
134
  :value="entry.value"
133
- class="flex shrink-0 cursor-pointer items-center gap-1.5 rounded-md px-2.5 py-1 font-mono text-xs transition-colors data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=inactive]:text-muted-foreground hover:bg-accent hover:text-foreground"
135
+ variant="pill"
136
+ class="font-mono"
134
137
  >
135
138
  <Icon :name="entry.icon" class="size-3.5" />
136
139
  {{ entry.label }}
137
- </TabsTrigger>
138
- </TabsList>
139
-
140
- <UiButton
141
- variant="ghost"
142
- size="icon"
143
- class="size-7 shrink-0"
144
- :aria-label="copied ? $t('duxt.code.copied') : $t('duxt.code.copy')"
145
- @click="copy"
146
- >
147
- <Icon
148
- :name="copied ? 'lucide:check' : 'lucide:copy'"
149
- class="size-3.5"
150
- />
151
- </UiButton>
152
- </div>
140
+ </UiTabsTrigger>
141
+ </UiTabsList>
142
+
143
+ <DuxtCopyButton :copied="copied" @click="copy" />
144
+ </DuxtCodeToolbar>
153
145
 
154
146
  <!-- THE BLOCK FOLLOWS THE FILE THAT IS SHOWING, over 300ms.
155
147
 
@@ -166,12 +158,24 @@ async function copy() {
166
158
  ref="shell"
167
159
  class="overflow-hidden motion-safe:transition-[height] motion-safe:duration-300 motion-safe:ease-out"
168
160
  >
169
- <div ref="body">
161
+ <div ref="body" class="relative">
162
+ <!-- EVERY PANEL STAYS MOUNTED, and the switch is a CROSSFADE. Mounted on
163
+ demand, a new panel left the body empty for about 150ms and the box
164
+ folded shut and opened again; hidden outright, the new file was
165
+ simply there, cut off by the box it had not yet grown into.
166
+
167
+ So the panel leaving is lifted out of the flow — absolute, over the
168
+ top of the one arriving — and fades out while the new one fades in.
169
+ Out of the flow it takes no height, so the box still follows the
170
+ file that shows. `invisible` lands when the fade ends, which keeps
171
+ the hidden panels out of the tab order and away from a screen
172
+ reader. -->
170
173
  <TabsContent
171
174
  v-for="entry in entries"
172
175
  :key="entry.value"
173
176
  :value="entry.value"
174
- class="focus-visible:outline-none [&_.duxt-code]:my-0 [&_.duxt-code]:rounded-none [&_.duxt-code]:border-0"
177
+ force-mount
178
+ class="focus-visible:outline-none motion-safe:transition-[opacity,visibility] motion-safe:duration-200 motion-safe:ease-out data-[state=inactive]:pointer-events-none data-[state=inactive]:invisible data-[state=inactive]:absolute data-[state=inactive]:inset-x-0 data-[state=inactive]:top-0 data-[state=inactive]:opacity-0 [&_.duxt-code]:my-0 [&_.duxt-code]:rounded-none [&_.duxt-code]:border-0"
175
179
  >
176
180
  <!-- `:header="false"`: the tab above already says what the bar inside
177
181
  would, and the copy button now sits beside the tabs. -->
@@ -37,7 +37,7 @@ defineProps<{
37
37
 
38
38
  <code
39
39
  v-if="$props.default !== undefined"
40
- class="ml-auto font-mono text-xs text-muted-foreground"
40
+ class="ms-auto font-mono text-xs text-muted-foreground"
41
41
  >
42
42
  = {{ $props.default }}
43
43
  </code>
@@ -65,7 +65,7 @@ const expanded = computed(() => {
65
65
  v-slot="{ isExpanded }"
66
66
  :key="item._id"
67
67
  v-bind="item.bind"
68
- :style="{ paddingLeft: `${item.level - 1}rem` }"
68
+ :style="{ paddingInlineStart: `${item.level - 1}rem` }"
69
69
  class="flex items-center gap-1.5 rounded px-1 py-[3px] outline-none focus:bg-accent data-[selected]:bg-accent/60"
70
70
  >
71
71
  <template v-if="isDirectory(item.value)">
@@ -107,7 +107,7 @@ const clientAnchor = computed(() =>
107
107
  {{ $t('duxt.openapi.requestBody') }}
108
108
  <span
109
109
  v-if="operation.requestBody.required"
110
- class="ml-1 text-xs font-medium text-destructive"
110
+ class="ms-1 text-xs font-medium text-destructive"
111
111
  >
112
112
  {{ $t('duxt.openapi.required') }}
113
113
  </span>
@@ -151,7 +151,7 @@ const clientAnchor = computed(() =>
151
151
  client sits after the description, which is the order a phone reads.
152
152
 
153
153
  AND IT SCROLLS ITSELF, the way the sidebar and the table of contents
154
- already do — same expression, `100vh` less the chrome above it. Sticky
154
+ already do — same expression, `100vh` less the header above it. Sticky
155
155
  alone pinned a panel taller than the viewport, so the request samples
156
156
  under the send button were cut off and only came into view once the
157
157
  reader had scrolled the whole page to its end: the panel is beside the
@@ -161,7 +161,7 @@ const clientAnchor = computed(() =>
161
161
  normal flow and a second scrollbar inside the page would be one nobody
162
162
  asked for.
163
163
 
164
- AND IT KEEPS A REM OFF THE CHROME. Pinned at exactly `--duxt-header-offset`
164
+ AND IT KEEPS A REM OFF THE HEADER. Pinned at exactly `--duxt-header-offset`
165
165
  the card's top border sat against the navbar's bottom border, so the
166
166
  two read as one thick line and the panel looked attached to the header
167
167
  rather than floating under it. The `max-h` gives the same rem back at
@@ -171,7 +171,7 @@ const clientAnchor = computed(() =>
171
171
  class="not-typeset min-w-0 scroll-mt-[var(--duxt-header-offset)]"
172
172
  >
173
173
  <div
174
- class="xl:sticky xl:top-[calc(var(--duxt-header-offset)+1rem)] xl:max-h-[calc(100vh-var(--duxt-header-offset)-2.5rem)] xl:overflow-y-auto xl:pr-1"
174
+ class="xl:sticky xl:top-[calc(var(--duxt-header-offset)+1rem)] xl:max-h-[calc(100vh-var(--duxt-header-offset)-2.5rem)] xl:overflow-y-auto xl:pe-1"
175
175
  >
176
176
  <DuxtOpenApiClient
177
177
  :operation="operation"
@@ -46,7 +46,7 @@ const localeLink = useDuxtLink();
46
46
 
47
47
  <span
48
48
  v-if="entry.summary"
49
- class="w-full text-sm text-muted-foreground sm:ml-auto sm:w-auto sm:text-right"
49
+ class="w-full text-sm text-muted-foreground sm:ms-auto sm:w-auto sm:text-end"
50
50
  >
51
51
  {{ entry.summary }}
52
52
  </span>
@@ -151,7 +151,7 @@ const operations = computed(() =>
151
151
  <Icon
152
152
  name="lucide:arrow-right"
153
153
  aria-hidden="true"
154
- class="mt-1 size-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5"
154
+ class="mt-1 size-3.5 shrink-0 text-muted-foreground rtl:-scale-x-100 transition-transform ltr:group-hover:translate-x-0.5 rtl:group-hover:-translate-x-0.5"
155
155
  />
156
156
  </NuxtLink>
157
157
  </li>