@kirchdev/duxt 0.3.4-dev-main.41-23ded41 → 0.3.5-dev-main.43-3310f4b

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 +41 -9
  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
@@ -9,8 +9,8 @@ import type { DuxtSearchHit } from '@duxt/composables/useDuxtSearch';
9
9
  // Full-text search over EVERY source, not just the one being read. Content
10
10
  // builds the index at build time and queries it with SQLite's FTS, so the
11
11
  // ranking is the database's rather than a substring match of ours — and there
12
- // is no search service to run. The merge across sources, and why the results
13
- // are ranked together rather than grouped per repository, is in
12
+ // is no search service to run. The merge across sources, and why their locally
13
+ // ranked lists are interleaved rather than compared by score, is in
14
14
  // `useDuxtSearch`.
15
15
  //
16
16
  // The index is fetched on first open, not shipped with every page: a docs site
@@ -21,9 +21,19 @@ const router = useRouter();
21
21
  const localeLink = useDuxtLink();
22
22
 
23
23
  const { search, init, labelled } = useDuxtSearch();
24
+ const analytics = useDuxtAnalytics();
24
25
 
25
26
  const results = ref<DuxtSearchHit[]>([]);
26
27
 
28
+ /**
29
+ * The term the current results were produced for.
30
+ *
31
+ * Not `query`, which moves with every keystroke: the excerpts window themselves
32
+ * around a literal match, and windowing them against a term newer than the
33
+ * results already on screen makes the previews jump while the reader types.
34
+ */
35
+ const matched = ref('');
36
+
27
37
  /** True while the list shows near-misses rather than actual matches. */
28
38
  const approximate = ref(false);
29
39
 
@@ -42,54 +52,142 @@ watch(query, (term) => {
42
52
  const current = ++run;
43
53
 
44
54
  const settle = (hits: DuxtSearchHit[], fuzzy: boolean) => {
45
- if (current !== run) return;
55
+ if (current !== run) return false;
46
56
  results.value = hits;
47
57
  approximate.value = fuzzy;
58
+ matched.value = term;
59
+ return true;
48
60
  };
49
61
 
50
- if (!term.trim()) return settle([], false);
62
+ if (!term.trim()) return void settle([], false);
51
63
 
52
64
  const found = await search(term, 20);
53
- settle(found.hits, found.approximate);
65
+
66
+ // ONE EVENT PER SETTLED SEARCH, which is why it hangs off `settle` rather
67
+ // than off the call. The debounce already dropped the keystrokes nobody
68
+ // finished, and a run whose answer landed after a newer one shows the
69
+ // reader nothing — reporting it would count a search that never happened.
70
+ // A search that found nothing is reported like any other: "no result" is
71
+ // the finding a documentation site most needs out of this.
72
+ if (settle(found.hits, found.approximate)) {
73
+ analytics.track({
74
+ name: 'search',
75
+ query: term,
76
+ results: found.hits.length,
77
+ approximate: found.approximate
78
+ });
79
+ }
54
80
  }, 120);
55
81
  });
56
82
 
57
83
  const duxt = useDuxtConfig();
58
- const { recent, load } = useRecentPages();
84
+ const { recent, load } = useRecentPages(duxt.search?.recentPages);
85
+
86
+ const sections = computed(() => duxt.sections ?? []);
87
+ const sources = computed(() => duxt.resolvedSources ?? []);
59
88
 
60
89
  /** Which section a path belongs to, for grouping the hits. */
61
90
  function sectionOf(path: string) {
62
91
  return (
63
- duxt.sections?.find((section) => section.to && path.startsWith(section.to))
64
- ?.label ?? 'Documentation'
92
+ sectionLabelForPath(path, sections.value, sources.value) ?? 'Documentation'
65
93
  );
66
94
  }
67
95
 
96
+ /**
97
+ * What each source is CALLED, rather than which URL segment it occupies.
98
+ *
99
+ * The context line below used to spell its source slot out of the manifest's
100
+ * `repo`, which is an address: documentation served at the root had none and
101
+ * showed nothing, and a source whose segment is an abbreviation showed the
102
+ * abbreviation. See `sourceDisplayNames` for the ladder, and `sources[].name`
103
+ * for the one rung a consumer writes.
104
+ */
105
+ const names = computed(() =>
106
+ sourceDisplayNames(sources.value, asText(duxt.title) ?? '')
107
+ );
108
+
109
+ /**
110
+ * The line under every row's title, and deliberately the SAME line in both
111
+ * lists.
112
+ *
113
+ * "Recently viewed" showed a title and its section, which on a site serving
114
+ * several editions of one repository is three identical rows — the reader saw
115
+ * `Harbour` three times and had to open them to find out which was which. A hit
116
+ * had the opposite problem: it named the page it sat in but not the edition
117
+ * that page came from. One `section · source/version · route` answers both, and
118
+ * answering them the same way is what makes the palette scannable.
119
+ */
120
+ const contextOf = (path: string) =>
121
+ searchContext(path, sections.value, sources.value, { names: names.value });
122
+
123
+ const history = computed(() =>
124
+ recent.value.map((page) => ({ ...page, context: contextOf(page.path) }))
125
+ );
126
+
68
127
  /**
69
128
  * Hits grouped by section rather than by page. Grouping by page produced one
70
129
  * heading per result; a section groups many, which is what a heading is for —
71
130
  * and the reader already thinks in sections, because the navbar shows them.
72
131
  */
73
132
  const grouped = computed(() => {
74
- // With several sources the list is ONE ranked list with a badge per hit —
75
- // grouping it by section would re-sort exactly the ranking the merge just
76
- // produced. See `useDuxtSearch` for why ranking beats grouping here.
133
+ const rows = results.value.map((hit) => ({
134
+ ...hit,
135
+ // WITHOUT the source slot wherever a caption above the run already states
136
+ // it — see `searchRows`. The section and the route stay, because those are
137
+ // the row's own and are what tell two editions of one title apart.
138
+ context: searchContext(hit.id, sections.value, sources.value, {
139
+ names: names.value,
140
+ source: !labelled.value
141
+ }),
142
+ // A title and a route say where a result IS; they do not say whether it
143
+ // answers the question, which is the judgement made before opening a page.
144
+ excerpt: searchExcerpt(hit.content, matched.value)
145
+ }));
146
+
147
+ // With several sources the list is ONE ranked list whose rows say where they
148
+ // came from — grouping it by section would re-sort exactly the ranking the
149
+ // merge just produced. See `useDuxtSearch` for why ranking beats grouping.
77
150
  if (labelled.value) {
78
- return [{ label: '', hits: results.value }];
151
+ return [{ label: '', hits: rows }];
79
152
  }
80
153
 
81
- const bySection = new Map<string, DuxtSearchHit[]>();
154
+ const bySection = new Map<string, typeof rows>();
82
155
 
83
- for (const hit of results.value) {
84
- // A section label may be configured per locale; the group key has to be a
85
- // plain string, and by this point useDuxtConfig has resolved it.
86
- const label = asText(sectionOf(hit.id)) ?? 'Documentation';
156
+ for (const hit of rows) {
157
+ const label = sectionOf(hit.id);
87
158
  bySection.set(label, [...(bySection.get(label) ?? []), hit]);
88
159
  }
89
160
 
90
161
  return [...bySection.entries()].map(([label, hits]) => ({ label, hits }));
91
162
  });
92
163
 
164
+ /**
165
+ * The groups above, with each one's repeated source label lifted into captions.
166
+ *
167
+ * Per GROUP rather than over the flat list: a caption opens a run, and a run
168
+ * reaching across a section heading would be opened by a caption sitting under
169
+ * the wrong heading. `searchRows` keeps the order and the count of what it is
170
+ * given either way — see there for why provenance must never re-sort.
171
+ */
172
+ const groups = computed(() =>
173
+ grouped.value.map((group) => ({
174
+ label: group.label,
175
+ rows: searchRows(group.hits)
176
+ }))
177
+ );
178
+
179
+ const filter = useTemplateRef<{ refresh: () => void }>('filter');
180
+
181
+ /**
182
+ * Hand the highlight back to the input's list every time the rows change.
183
+ *
184
+ * `flush: 'post'`, because what the input re-seats the highlight against is the
185
+ * DOM: asked before Vue has patched it, reka would measure the rows this
186
+ * answer just replaced. Why the input owns that decision rather than this
187
+ * component, and what it does with it, is in `DuxtSearchInput.refresh`.
188
+ */
189
+ watch(results, () => filter.value?.refresh(), { flush: 'post' });
190
+
93
191
  async function show() {
94
192
  load();
95
193
  open.value = true;
@@ -104,30 +202,45 @@ function go(id: string) {
104
202
  router.push(localeLink(id)!);
105
203
  }
106
204
 
107
- /** Where a hit sits: the page, and the headings above it inside that page. */
108
- function context(result: DuxtSearchSection) {
109
- const page = result.titles[0];
110
- const between = result.titles.slice(1);
111
-
112
- return [page, ...between].filter(Boolean).join(' › ');
205
+ /**
206
+ * A hit the reader opened — not a section tile and not a recent page, which
207
+ * `go` also serves and which nobody searched for.
208
+ *
209
+ * The rank is the position in the MERGED list rather than in the group it is
210
+ * drawn in: the list is one ranking with headings and captions over it, so a
211
+ * hit's place in its section says nothing about how well the search did. Found
212
+ * by `id` rather than by identity, because a row is a COPY of its hit — it
213
+ * carries the context line and excerpt spread onto it — so the object the
214
+ * template hands back is never the one sitting in `results`. Reported before
215
+ * `go`, which clears the term this hit was found with.
216
+ */
217
+ function openResult(hit: DuxtSearchHit) {
218
+ analytics.track({
219
+ name: 'search-result',
220
+ query: query.value,
221
+ rank: results.value.findIndex((entry) => entry.id === hit.id) + 1,
222
+ // The path, never the hit's rendered text.
223
+ to: hit.id,
224
+ collection: hit.source?.collection
225
+ });
226
+
227
+ go(hit.id);
113
228
  }
114
229
 
115
- onMounted(() => {
116
- const handler = (event: KeyboardEvent) => {
117
- if (event.key.toLowerCase() === 'k' && (event.metaKey || event.ctrlKey)) {
118
- event.preventDefault();
119
-
120
- if (open.value) {
121
- open.value = false;
122
- } else {
123
- void show();
124
- }
125
- }
126
- };
230
+ const { hint, on } = useDuxtShortcuts();
127
231
 
128
- window.addEventListener('keydown', handler);
129
- onBeforeUnmount(() => window.removeEventListener('keydown', handler));
232
+ on('search', () => {
233
+ if (open.value) open.value = false;
234
+ else void show();
130
235
  });
236
+
237
+ /**
238
+ * The hint on the button, from the same definition the handler binds.
239
+ *
240
+ * Through the LIVE list rather than the raw table, so a site that has unbound
241
+ * the key draws no cap at all instead of advertising a chord nothing answers.
242
+ */
243
+ const searchHint = computed(() => hint('search'));
131
244
  </script>
132
245
 
133
246
  <template>
@@ -141,9 +254,10 @@ onMounted(() => {
141
254
  <Icon name="lucide:search" class="size-4" />
142
255
  <span class="text-sm">{{ $t('duxt.search.label') }}</span>
143
256
  <kbd
144
- class="ml-auto hidden rounded border bg-muted px-1.5 font-mono text-[10px] sm:inline-block"
257
+ v-if="searchHint"
258
+ class="ms-auto hidden rounded border bg-muted px-1.5 font-mono text-[10px] sm:inline-block"
145
259
  >
146
- ⌘K
260
+ {{ searchHint }}
147
261
  </kbd>
148
262
  </UiButton>
149
263
 
@@ -153,22 +267,9 @@ onMounted(() => {
153
267
  against rendered text — dropping entries that mounted after the term
154
268
  changed. Leaving its state empty keeps one filter in charge, the
155
269
  database's. It also means CommandEmpty never renders, so the empty
156
- state is ours too.
157
-
158
- No clear button: DialogContent already draws a close X, and two of
159
- them in the same corner is one too many. -->
160
- <div class="flex items-center gap-2 border-b px-3">
161
- <Icon
162
- name="lucide:search"
163
- class="size-4 shrink-0 text-muted-foreground"
164
- />
165
- <input
166
- v-model="query"
167
- class="flex h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
168
- :placeholder="$t('duxt.search.placeholder')"
169
- autofocus
170
- />
171
- </div>
270
+ state is ours too. What it is INSTEAD, and why that is a component
271
+ rather than an `<input>`, is in `DuxtSearchInput`. -->
272
+ <DuxtSearchInput ref="filter" v-model="query" />
172
273
 
173
274
  <UiCommandList class="max-h-[60vh]">
174
275
  <!-- An empty box is a dead end. Without a term the dialog offers the
@@ -180,19 +281,24 @@ onMounted(() => {
180
281
  :heading="$t('duxt.search.recent')"
181
282
  >
182
283
  <UiCommandItem
183
- v-for="page in recent"
284
+ v-for="page in history"
184
285
  :key="page.path"
185
286
  :value="`recent ${page.path}`"
186
- class="gap-2"
287
+ class="items-start gap-2"
187
288
  @select="go(page.path)"
188
289
  >
189
290
  <Icon
190
291
  name="lucide:history"
191
- class="size-3.5 shrink-0 text-muted-foreground"
292
+ class="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
192
293
  />
193
- <span class="truncate">{{ page.title }}</span>
194
- <span class="ml-auto truncate pl-3 text-xs text-muted-foreground">
195
- {{ sectionOf(page.path) }}
294
+ <span class="flex min-w-0 flex-col">
295
+ <span class="truncate">{{ page.title }}</span>
296
+ <span
297
+ v-if="page.context"
298
+ class="truncate text-xs text-muted-foreground"
299
+ >
300
+ {{ page.context }}
301
+ </span>
196
302
  </span>
197
303
  </UiCommandItem>
198
304
  </UiCommandGroup>
@@ -224,47 +330,81 @@ onMounted(() => {
224
330
 
225
331
  <!-- Say so when the exact search came up empty, otherwise a near-miss
226
332
  reads as a match and the reader wonders why their term is missing
227
- from the result. -->
333
+ from the result.
334
+
335
+ ONLY WITH SOMETHING TO SHOW. The approximate pass can come up empty
336
+ too, and then this line stood under "Nothing found" promising the
337
+ closest matches of a list that had none. -->
228
338
  <div
229
- v-if="approximate"
339
+ v-if="approximate && results.length"
230
340
  class="flex items-center gap-2 px-3 pt-3 pb-1 text-xs text-muted-foreground"
231
341
  >
232
342
  <Icon name="lucide:sparkles" class="size-3.5 shrink-0" />
233
343
  {{ $t('duxt.search.approximate', { query }) }}
234
344
  </div>
235
345
 
236
- <!-- One line per hit, grouped by section: the page is context on the
237
- right, not a heading of its own. -->
346
+ <!-- THREE LINES per hit: the title, the same context line the history
347
+ above carries, and an excerpt of the section's own text. The
348
+ repository badge is gone — provenance is stated once at the head of
349
+ each run of results that share it, rather than printed onto every
350
+ row, which was the repetition both halves of this dialog were asked
351
+ to remove. -->
238
352
  <UiCommandGroup
239
- v-for="group in grouped"
353
+ v-for="group in groups"
240
354
  :key="group.label"
241
355
  :heading="group.label || undefined"
242
356
  >
243
- <UiCommandItem
244
- v-for="hit in group.hits"
245
- :key="hit.id"
246
- :value="hit.id"
247
- class="gap-2"
248
- @select="go(hit.id)"
249
- >
250
- <Icon
251
- :name="hit.level > 1 ? 'lucide:hash' : 'lucide:file-text'"
252
- class="size-3.5 shrink-0 text-muted-foreground"
253
- />
254
- <span class="truncate">{{ hit.title }}</span>
255
- <span class="ml-auto truncate pl-3 text-xs text-muted-foreground">
256
- {{ context(hit) }}
257
- </span>
258
- <!-- Which repository and version this came out of. Only drawn where
259
- there is more than one, so a single-source site sees nothing. -->
260
- <UiBadge
261
- v-if="hit.source"
262
- variant="secondary"
263
- class="shrink-0 font-mono text-[10px]"
357
+ <template v-for="row in group.rows" :key="row.hit.id">
358
+ <!-- Opens a run of hits from one source. `aria-hidden`, because the
359
+ same text reaches a screen reader on every row below through
360
+ `row.provenance` — a caption is a way of not repeating something
361
+ visually, and repeating it to one reader while hiding it from
362
+ another is how a shared label becomes a lie. -->
363
+ <div
364
+ v-if="row.caption"
365
+ aria-hidden="true"
366
+ class="truncate px-2 pt-3 pb-1 text-[11px] font-medium text-muted-foreground/80 first:pt-1"
367
+ >
368
+ {{ row.caption }}
369
+ </div>
370
+
371
+ <UiCommandItem
372
+ :value="row.hit.id"
373
+ class="items-start gap-2"
374
+ @select="openResult(row.hit)"
264
375
  >
265
- {{ hit.source.label }}
266
- </UiBadge>
267
- </UiCommandItem>
376
+ <Icon
377
+ :name="row.hit.level > 1 ? 'lucide:hash' : 'lucide:file-text'"
378
+ class="mt-0.5 size-3.5 shrink-0 text-muted-foreground"
379
+ />
380
+ <!-- `min-w-0` is what actually lets the lines truncate: a flex item
381
+ defaults to `min-width: auto`, which refuses to shrink below
382
+ its content and pushes the dialog into a horizontal scroll on a
383
+ 320px screen instead. -->
384
+ <span class="flex min-w-0 flex-col">
385
+ <span class="truncate">{{ row.hit.title }}</span>
386
+ <span
387
+ v-if="row.hit.context"
388
+ class="truncate text-xs text-muted-foreground"
389
+ >
390
+ {{ row.hit.context }}
391
+ </span>
392
+ <!-- Interpolated, never `v-html`: this is a document's own text,
393
+ and a page that writes about `<script>` must read as prose
394
+ rather than run as markup. Dropped entirely when the section
395
+ has no text, so an empty preview leaves no empty row. -->
396
+ <span
397
+ v-if="row.hit.excerpt"
398
+ class="line-clamp-2 text-xs text-muted-foreground/80"
399
+ >
400
+ {{ row.hit.excerpt }}
401
+ </span>
402
+ <span v-if="row.provenance" class="sr-only">
403
+ {{ row.provenance }}
404
+ </span>
405
+ </span>
406
+ </UiCommandItem>
407
+ </template>
268
408
  </UiCommandGroup>
269
409
  </UiCommandList>
270
410
  </UiCommandDialog>
@@ -0,0 +1,70 @@
1
+ <script setup lang="ts">
2
+ // The search dialog's input — reka's `ListboxFilter`, and NOT shadcn's
3
+ // `CommandInput`.
4
+ //
5
+ // `CommandInput` is `ListboxFilter` plus one line: it writes the term into
6
+ // Command's own `filterState`, and Command then scores the rendered list a
7
+ // SECOND time against its text — dropping entries that mounted after the term
8
+ // changed, and re-ranking what the database already ranked. So the term must
9
+ // never reach `filterState`, and the conclusion long drawn from that was to
10
+ // drop `ListboxFilter` along with it and type into a plain `<input>`.
11
+ //
12
+ // That threw away the half that was never the problem. `ListboxFilter` filters
13
+ // NOTHING. What it does is the combobox wiring: forward Arrow/Home/End to the
14
+ // listbox and Enter to the highlighted item, publish `aria-activedescendant`,
15
+ // and clear `focusable` so the list leaves the tab order and the input keeps
16
+ // focus while the list keeps the highlight. Without it the results were
17
+ // reachable by mouse only — #89.
18
+ //
19
+ // It is a component of its own because `highlightFirstItem` is reka's API for a
20
+ // DESCENDANT of the listbox root, and `DuxtSearch` is its PARENT: it renders
21
+ // the root, so it cannot reach in. One component inside the dialog can, and
22
+ // `refresh()` is what it hands back.
23
+ //
24
+ // No clear button: `DialogContent` already draws a close X, and two of them in
25
+ // the same corner is one too many.
26
+ import { ListboxFilter, injectListboxRootContext } from 'reka-ui';
27
+
28
+ const query = defineModel<string>({ default: '' });
29
+
30
+ const root = injectListboxRootContext();
31
+
32
+ /**
33
+ * Re-seat the highlight on the first row once a search has settled.
34
+ *
35
+ * `ListboxFilter` highlights the first item on every KEYSTROKE, which is right
36
+ * for a list filtered synchronously and wrong for one that is fetched: the
37
+ * database answers a debounce later, and by then the row it highlighted has
38
+ * unmounted. reka keeps the reference either way, so what is left is an
39
+ * `aria-activedescendant` naming an element no longer in the document and an
40
+ * Enter that does nothing — reka guards that one with `isConnected`. Those are
41
+ * #89's two symptoms again, one step further along.
42
+ *
43
+ * A highlight still on a live row is left exactly where it is: the reader put
44
+ * it there with the arrow keys, and a search that settles without changing the
45
+ * rows must not move it.
46
+ */
47
+ function refresh() {
48
+ if (root.highlightedElement.value?.isConnected) return;
49
+
50
+ // Cleared FIRST, so a search that found nothing ends with no active
51
+ // descendant rather than a dangling one: `highlightFirstItem` only ever
52
+ // assigns, and over an empty collection it does nothing at all.
53
+ root.highlightedElement.value = null;
54
+ root.highlightFirstItem();
55
+ }
56
+
57
+ defineExpose({ refresh });
58
+ </script>
59
+
60
+ <template>
61
+ <div class="flex items-center gap-2 border-b px-3">
62
+ <Icon name="lucide:search" class="size-4 shrink-0 text-muted-foreground" />
63
+ <ListboxFilter
64
+ v-model="query"
65
+ auto-focus
66
+ class="flex h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
67
+ :placeholder="$t('duxt.search.placeholder')"
68
+ />
69
+ </div>
70
+ </template>
@@ -58,7 +58,17 @@ function current(to?: string) {
58
58
  "
59
59
  >
60
60
  <Icon v-if="section.icon" :name="section.icon" class="size-4" />
61
- {{ section.label }}
61
+ <!-- The lit chip is `font-medium`, which is wider than the regular
62
+ weight, so every chip after it shifted sideways on each navigation.
63
+ The invisible zero-height `::after` carries the label at the medium
64
+ weight and reserves that width whether the chip is lit or not. A
65
+ pseudo-element rather than a second span, so `textContent` stays the
66
+ label once and `visibility: hidden` keeps it out of the name. -->
67
+ <span
68
+ class="inline-flex flex-col after:invisible after:h-0 after:overflow-hidden after:font-medium after:content-[attr(data-label)]"
69
+ :data-label="section.label"
70
+ >{{ section.label }}</span
71
+ >
62
72
  </NuxtLink>
63
73
  </nav>
64
74
  </div>
@@ -0,0 +1,64 @@
1
+ <script setup lang="ts" generic="T extends string">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { cn } from '@duxt/lib/utils';
4
+
5
+ /**
6
+ * A row of pressed-or-not buttons: pick one of a few, see which is picked.
7
+ *
8
+ * `role="group"` with `aria-pressed`, NOT a radiogroup or a tablist: a
9
+ * radiogroup promises arrow-key navigation between its options and a tablist
10
+ * promises panels, and this implements neither. A group of pressed buttons
11
+ * promises only what it does — every one reachable by Tab, every one saying
12
+ * whether it is on. Where a strip really does switch panels, it is a
13
+ * `UiTabsList` with `variant="pill"`, which draws the same pill.
14
+ *
15
+ * The `option` slot is for a label that is more than an icon and a word — the
16
+ * package managers swap their mark with the theme.
17
+ */
18
+ export interface DuxtSegmentedOption<Value extends string = string> {
19
+ value: Value;
20
+ label: string;
21
+ icon?: string;
22
+ }
23
+
24
+ const props = withDefaults(
25
+ defineProps<{
26
+ options: readonly DuxtSegmentedOption<T>[];
27
+ /** The group's accessible name. */
28
+ label?: string;
29
+ size?: DuxtPillSize;
30
+ tone?: DuxtPillTone;
31
+ /** Monospaced labels, for things a reader would type: a media type, a client. */
32
+ mono?: boolean;
33
+ class?: HTMLAttributes['class'];
34
+ }>(),
35
+ { size: 'md', tone: 'raised', mono: false }
36
+ );
37
+
38
+ const model = defineModel<T>({ required: true });
39
+ </script>
40
+
41
+ <template>
42
+ <div
43
+ role="group"
44
+ :aria-label="label"
45
+ :class="cn('flex items-center gap-1', props.class)"
46
+ >
47
+ <button
48
+ v-for="option in options"
49
+ :key="option.value"
50
+ type="button"
51
+ :aria-pressed="model === option.value"
52
+ :class="[
53
+ duxtPill(model === option.value, { size, tone }),
54
+ mono && 'font-mono'
55
+ ]"
56
+ @click="model = option.value"
57
+ >
58
+ <slot name="option" :option="option" :active="model === option.value">
59
+ <Icon v-if="option.icon" :name="option.icon" class="size-3.5" />
60
+ {{ option.label }}
61
+ </slot>
62
+ </button>
63
+ </div>
64
+ </template>
@@ -1,16 +1,21 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * The shortcut sheet, on `?`.
3
+ * The shortcut sheet — on `?`, and on the control that opens it.
4
4
  *
5
- * Its list is `duxtShortcuts`, the same array the handlers match against, so
6
- * the sheet cannot come to advertise a key nothing binds.
5
+ * It lists what `useDuxtShortcuts()` reports as live — the same list, under the
6
+ * same policy, that the handlers bind — so it cannot advertise a key nothing
7
+ * listens to.
8
+ *
9
+ * Its open state lives in `useDuxtShortcutSheet()` rather than in a `ref` here,
10
+ * because `?` is no longer the only way in: `DuxtShortcutsTrigger` opens the
11
+ * same sheet from the header, and on a site with
12
+ * `shortcuts.singleCharacter: false` it is the only thing that can — there is
13
+ * no `?` binding left.
7
14
  */
8
- const open = ref(false);
15
+ const { open } = useDuxtShortcutSheet();
16
+ const { shortcuts, keys, on } = useDuxtShortcuts();
9
17
 
10
- onDuxtShortcut(
11
- (event) => event.key === '?' && !event.metaKey && !event.ctrlKey,
12
- () => (open.value = !open.value)
13
- );
18
+ on('help', () => (open.value = !open.value));
14
19
  </script>
15
20
 
16
21
  <template>
@@ -25,14 +30,14 @@ onDuxtShortcut(
25
30
 
26
31
  <ul class="flex flex-col gap-2 text-sm">
27
32
  <li
28
- v-for="shortcut in duxtShortcuts"
29
- :key="shortcut.label"
33
+ v-for="shortcut in shortcuts"
34
+ :key="shortcut.action"
30
35
  class="flex items-center justify-between gap-4"
31
36
  >
32
37
  <span>{{ $t(shortcut.label) }}</span>
33
38
  <span class="flex gap-1">
34
39
  <kbd
35
- v-for="key in shortcut.keys"
40
+ v-for="key in keys(shortcut)"
36
41
  :key="key"
37
42
  class="rounded border bg-muted px-1.5 py-0.5 font-mono text-xs"
38
43
  >