@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,11 +1,24 @@
1
1
  import type { DuxtResolvedSource } from '../../sources-resolve';
2
2
  import { searchSources } from '../utils/search-scope';
3
+ // Imported rather than auto-imported, the same as `searchSources` above: this
4
+ // composable is covered by a plain node test, which has no Nuxt globals.
5
+ import { asText } from '../utils/duxt-text';
6
+ import { duxtPageSearchable } from '../utils/page-controls';
7
+ import { sourceDisplayNames } from '../utils/search-display';
8
+ import type { DuxtSearchProvenance } from '../utils/search-display';
3
9
  import type { DuxtSearchSection } from '@duxt/composables/useFuzzySearch';
4
10
 
5
11
  /** A hit, with the source it came out of. */
6
12
  export interface DuxtSearchHit extends DuxtSearchSection {
7
- /** Undefined on a site with one source: there is nothing to distinguish. */
8
- source?: { label: string; collection: string };
13
+ /**
14
+ * Undefined on a site with one source: there is nothing to distinguish.
15
+ *
16
+ * A NAME and the two facts that qualify it, rather than the one string this
17
+ * used to carry. That string was `[repo, version].join(' ')` with the URL
18
+ * prefix behind it as a fallback, so the site's own documentation announced
19
+ * itself as `/` — see `sourceDisplayNames` for the ladder that replaced it.
20
+ */
21
+ source?: DuxtSearchProvenance & { collection: string };
9
22
  }
10
23
 
11
24
  /**
@@ -16,13 +29,11 @@ export interface DuxtSearchHit extends DuxtSearchSection {
16
29
  * the thing the `sources` model makes possible and a single-repository theme
17
30
  * cannot do at all.
18
31
  *
19
- * RANKED TOGETHER, NOT GROUPED PER REPOSITORY. The choice matters and it is not
20
- * obvious. Grouping per repository asks the reader to know which project their
21
- * answer is in — but if they knew that, the search would be doing less work
22
- * than the sidebar. So the lists are interleaved by rank and each hit carries a
23
- * badge saying where it came from: the best answers first, provenance kept.
24
- * The source being read goes first in each round, because a reader searching
25
- * inside a project usually means that project.
32
+ * INTERLEAVED, NOT GLOBALLY RANKED. Content ranks each collection separately,
33
+ * and those score spaces are not comparable. The lists are therefore
34
+ * interleaved by position and each hit carries its provenance. The source being
35
+ * read goes first in each round, because a reader searching inside a project
36
+ * usually means that project.
26
37
  *
27
38
  * ONE VERSION PER ARTEFACT. Searching every version returns each page as many
28
39
  * times as there are versions, which buries the answer under its own history.
@@ -78,7 +89,8 @@ export function useDuxtSearch() {
78
89
  const approximate = searchable.map((entry) => ({
79
90
  entry,
80
91
  search: useFuzzySearch(entry.collection as DuxtCollectionName, {
81
- ignoredTags: ['table']
92
+ ignoredTags: ['table'],
93
+ ...duxt.search?.fuzzy
82
94
  }).search
83
95
  }));
84
96
 
@@ -92,14 +104,33 @@ export function useDuxtSearch() {
92
104
  )
93
105
  );
94
106
 
95
- const pagePaths = new Map<string, Promise<Set<string>>>();
107
+ /**
108
+ * What a collection holds, and what it holds back.
109
+ *
110
+ * `all` answers the translation question — which pages this edition has, so a
111
+ * worse language is not offered for one a better language already answers.
112
+ * `excluded` answers a different one: which pages wrote `search: false`, and
113
+ * so belong in no result list at all. Both are read in the one query that was
114
+ * already being made, because the index the FTS and the fuzzy pass search is
115
+ * built from Content's search sections and carries no frontmatter — a page
116
+ * can only be dropped here, by path, after the fact.
117
+ */
118
+ const pagePaths = new Map<
119
+ string,
120
+ Promise<{ all: Set<string>; excluded: Set<string> }>
121
+ >();
96
122
  function paths(collection: string) {
97
123
  let pending = pagePaths.get(collection);
98
124
  if (!pending) {
99
125
  pending = queryCollection(collection as DuxtCollectionArg)
100
- .select('path')
126
+ .select('path', 'search')
101
127
  .all()
102
- .then((pages) => new Set(pages.map((page) => page.path)))
128
+ .then((pages) => ({
129
+ all: new Set(pages.map((page) => page.path)),
130
+ excluded: new Set(
131
+ pages.filter((page) => !duxtPageSearchable(page)).map((p) => p.path)
132
+ )
133
+ }))
103
134
  .catch((error) => {
104
135
  pagePaths.delete(collection);
105
136
  throw error;
@@ -111,17 +142,25 @@ export function useDuxtSearch() {
111
142
 
112
143
  // A fallback may answer only for a PAGE absent from every better language,
113
144
  // even when a heading or the search term exists only in the original.
145
+ //
146
+ // A page that opted out of search is hidden in its OWN edition too, which is
147
+ // the difference between the two halves of this set: one suppresses a
148
+ // duplicate, the other suppresses the page itself.
114
149
  async function visiblePages(wanted: DuxtResolvedSource[]) {
115
150
  return Promise.all(
116
151
  wanted.map(async (entry, index) => {
117
152
  const preferred = wanted
118
153
  .slice(0, index)
119
154
  .filter((other) => other.prefix === entry.prefix);
120
- return new Set(
121
- (
122
- await Promise.all(preferred.map((other) => paths(other.collection)))
123
- ).flatMap((pages) => [...pages])
124
- );
155
+ const [covered, own] = await Promise.all([
156
+ Promise.all(preferred.map((other) => paths(other.collection))),
157
+ paths(entry.collection)
158
+ ]);
159
+
160
+ return new Set([
161
+ ...covered.flatMap((pages) => [...pages.all]),
162
+ ...own.excluded
163
+ ]);
125
164
  })
126
165
  );
127
166
  }
@@ -131,10 +170,32 @@ export function useDuxtSearch() {
131
170
  () => new Set(active.value.map((entry) => entry.prefix)).size > 1
132
171
  );
133
172
 
134
- const labelOf = (entry: (typeof searchable)[number]) =>
135
- [entry.repo, entry.version].filter(Boolean).join(' ') ||
136
- entry.prefix ||
137
- '/';
173
+ /**
174
+ * What each collection is CALLED, by collection name.
175
+ *
176
+ * Computed over the whole searchable set once rather than per hit: the
177
+ * collision qualifier needs to see every source at the same time to know
178
+ * whether a name is ambiguous at all.
179
+ */
180
+ const names = computed(() =>
181
+ sourceDisplayNames(searchable, asText(duxt.title) ?? '')
182
+ );
183
+
184
+ const provenanceOf = (
185
+ entry: (typeof searchable)[number]
186
+ ): DuxtSearchProvenance => ({
187
+ name: names.value.get(entry.collection) ?? '',
188
+ // The artefact keeps its own label beside the source's name: a reader
189
+ // looking at a hit from a changelog wants to be told it is the changelog,
190
+ // and which project's changelog it is.
191
+ artefact: entry.generated?.label,
192
+ version: entry.version
193
+ });
194
+
195
+ const sourceOf = (entry: (typeof searchable)[number]) =>
196
+ labelled.value
197
+ ? { ...provenanceOf(entry), collection: entry.collection }
198
+ : undefined;
138
199
 
139
200
  async function init() {
140
201
  await Promise.all(
@@ -151,7 +212,8 @@ export function useDuxtSearch() {
151
212
  * Without scores to compare across collections — each database ranks inside
152
213
  * itself — position is the only comparable thing there is. Round-robin over
153
214
  * the lists gives every source its best hit before any source gets its
154
- * second, which is the closest honest approximation of "ranked together".
215
+ * second. The result preserves each collection's local order without
216
+ * pretending its score is comparable to another collection's.
155
217
  */
156
218
  function interleave(lists: DuxtSearchHit[][], limit: number) {
157
219
  const merged: DuxtSearchHit[] = [];
@@ -210,12 +272,7 @@ export function useDuxtSearch() {
210
272
 
211
273
  return hits.map((hit) => ({
212
274
  ...hit,
213
- source: labelled.value
214
- ? {
215
- label: labelOf(entry.entry),
216
- collection: entry.entry.collection
217
- }
218
- : undefined
275
+ source: sourceOf(entry.entry)
219
276
  }));
220
277
  })
221
278
  );
@@ -237,12 +294,7 @@ export function useDuxtSearch() {
237
294
 
238
295
  return hits.map((hit) => ({
239
296
  ...hit,
240
- source: labelled.value
241
- ? {
242
- label: labelOf(entry.entry),
243
- collection: entry.entry.collection
244
- }
245
- : undefined
297
+ source: sourceOf(entry.entry)
246
298
  }));
247
299
  })
248
300
  );
@@ -29,9 +29,23 @@ export function useDuxtSection(
29
29
  * in the prose beside it — so the sidebar drew that tree instead of the
30
30
  * endpoints. Same rule, and the same bug, as `sourceForPath`.
31
31
  */
32
+ /**
33
+ * AT THE READER'S EDITION. The sections are written once, at the default
34
+ * edition — `/concepts` — and on `/v0.2.0/concepts/sources` nothing in that
35
+ * list prefixes the page: no branch was found and the sidebar drew the whole
36
+ * tree, every section at once, on every other version. `sectionsForPath`
37
+ * moves them to the edition being read, the same list the section row shows.
38
+ */
32
39
  const section = computed(() =>
33
40
  currentSection(
34
- [...(duxt.sections ?? []), ...(duxt.navigation ?? [])],
41
+ [
42
+ ...sectionsForPath(
43
+ duxt.sections ?? [],
44
+ duxt.resolvedSources ?? [],
45
+ path.value
46
+ ),
47
+ ...(duxt.navigation ?? [])
48
+ ],
35
49
  path.value
36
50
  )
37
51
  );
@@ -33,11 +33,12 @@ export function useDuxtSectionRow() {
33
33
  * where there is one — as a length the layouts hand down in
34
34
  * `--duxt-header-offset`.
35
35
  *
36
- * NOT `chrome`, which is what this was called. The word is right in the sense
37
- * an interface means it and wrong in the sense most readers reach for first,
38
- * and a name that has to be explained is a name chosen badly.
39
- * `DuxtLiveWindow`'s `chrome` prop keeps it, because there it really is a
40
- * drawn browser bar.
36
+ * NAMED FOR WHAT IT MEASURES, not for an umbrella word. What the layer draws
37
+ * around a page is named as the specific thing — the header, the navigation,
38
+ * the aside, the docs shell — and what a page may turn off is a page control,
39
+ * because a name that has to be explained is a name chosen badly.
40
+ * `DuxtLiveWindow`'s `chrome` prop is the one exception, because there it
41
+ * really is a drawn browser bar.
41
42
  *
42
43
  * A CSS variable rather than a class per case: the sticky columns live in
43
44
  * three layouts and in the page's own contents column, and four copies of a
@@ -1,32 +1,139 @@
1
1
  /**
2
- * One place that knows which keys the theme listens to.
2
+ * One place that knows which keys the theme listens to — and when it may.
3
3
  *
4
- * The overlay on `?` and the handlers themselves have to agree, and the only
5
- * way they do is by being the same list — a shortcut sheet that lists a key
6
- * nothing binds is worse than no sheet.
4
+ * Four things used to be able to disagree: the sheet on `?`, the handlers, the
5
+ * guards each handler remembered for itself, and a binding table a consumer
6
+ * could rewrite. They are one definition now. A consumer keeps the decision
7
+ * that is genuinely its own — whether unmodified keys are bound at all — and
8
+ * that is a boolean, which cannot drift from a list it is not.
7
9
  *
8
- * A shortcut never fires while the reader is typing. Without that guard, `?`
9
- * in the search box opens the overlay instead of being searched for, which is
10
- * the single most common bug in this kind of feature.
10
+ * The guards matter as much as the bindings. A global single-character
11
+ * shortcut is a key the reader never opted into: it has to stand down while
12
+ * they are typing, while a dialog or menu owns the keyboard, when something
13
+ * nearer the event has already handled it, and whenever focus is on a control
14
+ * rather than on the page itself. `⌘/Ctrl+K` is the one exception to the last
15
+ * of those, because no control wants that chord.
11
16
  */
17
+ export type DuxtShortcutAction = 'search' | 'help' | 'previous' | 'next';
18
+
12
19
  export interface DuxtShortcut {
20
+ action: DuxtShortcutAction;
13
21
  /** The key, as `KeyboardEvent.key` reports it. */
14
22
  key: string;
15
23
  /** Requires ⌘ on a Mac, Ctrl elsewhere. */
16
24
  meta?: boolean;
17
25
  /** i18n key for what it does. */
18
26
  label: string;
19
- /** How to draw it. */
20
- keys: string[];
21
27
  }
22
28
 
23
- export const duxtShortcuts: DuxtShortcut[] = [
24
- { key: 'k', meta: true, label: 'duxt.shortcuts.search', keys: ['⌘', 'K'] },
25
- { key: '?', label: 'duxt.shortcuts.help', keys: ['?'] },
26
- { key: '[', label: 'duxt.shortcuts.previous', keys: ['['] },
27
- { key: ']', label: 'duxt.shortcuts.next', keys: [']'] }
29
+ /**
30
+ * The bindings, and the only place they are written down.
31
+ *
32
+ * Not `app.config`: a table of keys a consumer may rewrite is a table the
33
+ * sheet, the handlers and the guards each have to agree with, and the whole
34
+ * defect this replaces was those four drifting apart. What a consumer owns is
35
+ * the POLICY below — whether the unmodified keys are live at all — which is
36
+ * one boolean and has nothing to drift from.
37
+ */
38
+ export const duxtShortcuts: readonly DuxtShortcut[] = [
39
+ { action: 'search', key: 'k', meta: true, label: 'duxt.shortcuts.search' },
40
+ { action: 'help', key: '?', label: 'duxt.shortcuts.help' },
41
+ { action: 'previous', key: '[', label: 'duxt.shortcuts.previous' },
42
+ { action: 'next', key: ']', label: 'duxt.shortcuts.next' }
28
43
  ];
29
44
 
45
+ /** Which modifier the reader's keyboard actually carries. */
46
+ export type DuxtPlatform = 'mac' | 'other';
47
+
48
+ /**
49
+ * Apple or not, from whatever the browser is willing to say.
50
+ *
51
+ * `userAgentData` first because `navigator.platform` is deprecated and frozen
52
+ * — Chrome reports `MacIntel` from an iPad and `Win32` from anything it has
53
+ * decided to lie to. No browser at all (the server, and the first paint of a
54
+ * prerendered page) answers `other`: `Ctrl` is the safe thing to have drawn if
55
+ * the guess has to be wrong, since it is what the larger half of readers use.
56
+ */
57
+ export function duxtPlatform(navigatorLike?: {
58
+ platform?: string;
59
+ userAgentData?: { platform?: string };
60
+ }): DuxtPlatform {
61
+ const reported =
62
+ navigatorLike?.userAgentData?.platform ?? navigatorLike?.platform ?? '';
63
+
64
+ return /mac|iphone|ipad|ipod/i.test(reported) ? 'mac' : 'other';
65
+ }
66
+
67
+ /**
68
+ * How to draw a binding — the only place a key becomes glyphs.
69
+ *
70
+ * Derived rather than stored, so a binding cannot come to advertise a key it
71
+ * does not listen to, and so `⌘` and `Ctrl` are one branch instead of two
72
+ * entries somebody has to keep in step.
73
+ */
74
+ export function shortcutKeys(
75
+ shortcut: Pick<DuxtShortcut, 'key' | 'meta'>,
76
+ platform: DuxtPlatform
77
+ ): string[] {
78
+ const key =
79
+ shortcut.key.length === 1 ? shortcut.key.toUpperCase() : shortcut.key;
80
+
81
+ return shortcut.meta ? [platform === 'mac' ? '⌘' : 'Ctrl', key] : [key];
82
+ }
83
+
84
+ /**
85
+ * The same keys on one line, for a hint drawn inside a control.
86
+ *
87
+ * `⌘K` needs no separator and reads worse with one; `Ctrl` is a word, and
88
+ * `CtrlK` is not a thing anybody has ever pressed.
89
+ */
90
+ export const shortcutHint = (keys: string[]): string =>
91
+ keys.join((keys[0]?.length ?? 0) > 1 ? '+' : '');
92
+
93
+ /** What a consumer decides about the keys duxt listens to globally. */
94
+ export interface DuxtShortcutPolicy {
95
+ /**
96
+ * Whether a bare keystroke — `?`, `[`, `]` — triggers anything at all.
97
+ *
98
+ * A global single-character shortcut is the one binding that can fire under
99
+ * a reader who never asked for it: switch access, voice control and dictation
100
+ * all emit bare characters as a matter of course, and a page that navigates
101
+ * away under them shows no cause. `false` leaves `⌘/Ctrl+K`, which no input
102
+ * method produces by accident.
103
+ */
104
+ singleCharacter?: boolean;
105
+ }
106
+
107
+ /** One unmodified keystroke — the tier a consumer can switch off. */
108
+ export const isSingleCharacterShortcut = (
109
+ shortcut: Pick<DuxtShortcut, 'meta'>
110
+ ) => !shortcut.meta;
111
+
112
+ /** The bindings that are live under a consumer's policy. */
113
+ export function activeDuxtShortcuts(
114
+ policy?: DuxtShortcutPolicy
115
+ ): readonly DuxtShortcut[] {
116
+ return policy?.singleCharacter === false
117
+ ? duxtShortcuts.filter((shortcut) => !isSingleCharacterShortcut(shortcut))
118
+ : duxtShortcuts;
119
+ }
120
+
121
+ /**
122
+ * The live binding behind one action, or nothing.
123
+ *
124
+ * Every visible hint goes through this rather than through `duxtShortcuts`
125
+ * directly, and the difference is the whole point: a hint read off the raw
126
+ * table would keep drawing `[` on a site whose policy unbound it — a label for
127
+ * a key that does nothing, which is the sheet's own defect one level further
128
+ * out.
129
+ */
130
+ export function duxtShortcutFor<T extends Pick<DuxtShortcut, 'action'>>(
131
+ shortcuts: readonly T[],
132
+ action: DuxtShortcutAction
133
+ ): T | undefined {
134
+ return shortcuts.find((shortcut) => shortcut.action === action);
135
+ }
136
+
30
137
  /** Is the reader typing? Then the key belongs to whatever they are typing in. */
31
138
  export function isTyping(target: EventTarget | null): boolean {
32
139
  const element = target as HTMLElement | null;
@@ -38,21 +145,213 @@ export function isTyping(target: EventTarget | null): boolean {
38
145
  );
39
146
  }
40
147
 
41
- /** Bind one key, with the guards every one of them needs. */
148
+ /**
149
+ * Widgets that answer keystrokes themselves, so a global binding must not.
150
+ *
151
+ * Ancestry rather than the focused element alone: a dialog's keys belong to the
152
+ * dialog wherever focus has landed inside it, and a contenteditable region is
153
+ * being typed into even when the event target is some `<span>` within it.
154
+ */
155
+ const OWNING_SELECTOR = [
156
+ 'input',
157
+ 'textarea',
158
+ 'select',
159
+ '[contenteditable="true"]',
160
+ 'dialog',
161
+ '[role="dialog"]',
162
+ '[role="alertdialog"]',
163
+ '[role="menu"]',
164
+ '[role="menubar"]',
165
+ '[role="listbox"]',
166
+ '[role="combobox"]',
167
+ '[role="grid"]',
168
+ '[role="treegrid"]',
169
+ '[role="tree"]'
170
+ ].join(',');
171
+
172
+ /**
173
+ * Anything the reader can operate — which is not the same set as above.
174
+ *
175
+ * A link or a button owns no keystroke, so `⌘/Ctrl+K` is still the reader's to
176
+ * press while one has focus. A BARE character is different: it is the keystroke
177
+ * a control is most likely to want next, so the unmodified tier stands down
178
+ * here and fires only on the page's own content.
179
+ *
180
+ * `tabindex="-1"` is excluded deliberately — that is the focus target
181
+ * `useDuxtPageFocus` moves to after a navigation, and a reader who has just
182
+ * arrived on a page is exactly who `[` and `]` are for.
183
+ */
184
+ const INTERACTIVE_SELECTOR = [
185
+ 'a[href]',
186
+ 'button',
187
+ 'summary',
188
+ 'details',
189
+ 'audio',
190
+ 'video',
191
+ '[tabindex]:not([tabindex^="-"])',
192
+ '[role="button"]',
193
+ '[role="link"]',
194
+ '[role="checkbox"]',
195
+ '[role="radio"]',
196
+ '[role="switch"]',
197
+ '[role="slider"]',
198
+ '[role="spinbutton"]',
199
+ '[role="tab"]',
200
+ '[role="option"]',
201
+ '[role="menuitem"]',
202
+ '[role="textbox"]'
203
+ ].join(',');
204
+
205
+ const within = (target: EventTarget | null, selector: string): boolean => {
206
+ const element = target as Element | null;
207
+
208
+ return typeof element?.closest === 'function'
209
+ ? element.closest(selector) !== null
210
+ : false;
211
+ };
212
+
213
+ /** Focus sits in something that answers this keystroke itself. */
214
+ export const ownsKeyboard = (target: EventTarget | null): boolean =>
215
+ isTyping(target) || within(target, OWNING_SELECTOR);
216
+
217
+ /** Focus is on a control rather than on the page's own content. */
218
+ export const isInteractiveTarget = (target: EventTarget | null): boolean =>
219
+ within(target, INTERACTIVE_SELECTOR);
220
+
221
+ /** What matching reads off a keydown — the whole event is never needed. */
222
+ export type DuxtShortcutEvent = Pick<
223
+ KeyboardEvent,
224
+ 'key' | 'metaKey' | 'ctrlKey' | 'defaultPrevented' | 'target'
225
+ >;
226
+
227
+ const matchesKey = (
228
+ shortcut: Pick<DuxtShortcut, 'key' | 'meta'>,
229
+ event: DuxtShortcutEvent
230
+ ) =>
231
+ shortcut.key.toLowerCase() === event.key.toLowerCase() &&
232
+ Boolean(shortcut.meta) === Boolean(event.metaKey || event.ctrlKey);
233
+
234
+ /**
235
+ * The shortcut this keystroke fires, or nothing — the one gate every binding
236
+ * passes through.
237
+ *
238
+ * Every guard lives here rather than at the four call sites, because a guard
239
+ * that only three of them remembered is the bug this replaces.
240
+ */
241
+ export function findDuxtShortcut<
242
+ T extends Pick<DuxtShortcut, 'action' | 'key' | 'meta'>
243
+ >(shortcuts: readonly T[], event: DuxtShortcutEvent): T | undefined {
244
+ // Something nearer the key has already answered it.
245
+ if (event.defaultPrevented) return undefined;
246
+ if (ownsKeyboard(event.target)) return undefined;
247
+
248
+ const interactive = isInteractiveTarget(event.target);
249
+
250
+ return shortcuts.find(
251
+ (shortcut) =>
252
+ matchesKey(shortcut, event) &&
253
+ !(interactive && isSingleCharacterShortcut(shortcut))
254
+ );
255
+ }
256
+
257
+ /**
258
+ * Bind a set of actions, with every guard the policy requires.
259
+ *
260
+ * Takes a getter rather than a list: the policy is reactive config, and a
261
+ * binding captured once would keep firing for a key the site has since
262
+ * switched off.
263
+ */
42
264
  export function onDuxtShortcut(
43
- match: (event: KeyboardEvent) => boolean,
44
- run: (event: KeyboardEvent) => void
265
+ shortcuts: () => readonly DuxtShortcut[],
266
+ run: (action: DuxtShortcutAction, event: KeyboardEvent) => void
45
267
  ) {
46
268
  onMounted(() => {
47
269
  const handler = (event: KeyboardEvent) => {
48
- if (isTyping(event.target)) return;
49
- if (!match(event)) return;
270
+ const shortcut = findDuxtShortcut(shortcuts(), event);
271
+ if (!shortcut) return;
50
272
 
51
273
  event.preventDefault();
52
- run(event);
274
+ run(shortcut.action, event);
53
275
  };
54
276
 
55
277
  window.addEventListener('keydown', handler);
56
278
  onBeforeUnmount(() => window.removeEventListener('keydown', handler));
57
279
  });
58
280
  }
281
+
282
+ /**
283
+ * The shortcuts this site actually has, how to draw them, and how to bind one.
284
+ *
285
+ * The single entry point for every component that cares: the sheet renders
286
+ * `shortcuts`, the handlers call `on()`, and both read the same list under the
287
+ * same policy — which is what stops a sheet advertising a key nothing listens
288
+ * to.
289
+ */
290
+ export function useDuxtShortcuts() {
291
+ const duxt = useDuxtConfig();
292
+
293
+ // After mount, never during render. The server has no keyboard to ask, and a
294
+ // prerendered page is a file — so the modifier is resolved once the reader's
295
+ // own browser is there to answer, and `Ctrl` stands until it does.
296
+ const platform = ref<DuxtPlatform>('other');
297
+ onMounted(() => {
298
+ platform.value = duxtPlatform(globalThis.navigator);
299
+ });
300
+
301
+ const shortcuts = computed(() => activeDuxtShortcuts(duxt.shortcuts));
302
+
303
+ /** How to draw one, on the keyboard the reader is actually holding. */
304
+ const keys = (shortcut: Pick<DuxtShortcut, 'key' | 'meta'>) =>
305
+ shortcutKeys(shortcut, platform.value);
306
+
307
+ /**
308
+ * What to print beside a control, or nothing where the key is not bound.
309
+ *
310
+ * `undefined` rather than an empty string, so a template writes
311
+ * `v-if="hint('next')"` and draws no `<kbd>` at all — an empty key cap beside
312
+ * a link is worse than no hint, and a site with `singleCharacter: false` has
313
+ * two of them on every page.
314
+ */
315
+ const hint = (action: DuxtShortcutAction) => {
316
+ const shortcut = duxtShortcutFor(shortcuts.value, action);
317
+
318
+ return shortcut ? shortcutHint(keys(shortcut)) : undefined;
319
+ };
320
+
321
+ /** Bind one action, or several that share a handler. */
322
+ const on = (
323
+ actions: DuxtShortcutAction | DuxtShortcutAction[],
324
+ run: (action: DuxtShortcutAction, event: KeyboardEvent) => void
325
+ ) => {
326
+ const wanted = Array.isArray(actions) ? actions : [actions];
327
+
328
+ onDuxtShortcut(
329
+ () =>
330
+ shortcuts.value.filter((shortcut) => wanted.includes(shortcut.action)),
331
+ run
332
+ );
333
+ };
334
+
335
+ return { shortcuts, keys, hint, on, platform };
336
+ }
337
+
338
+ /**
339
+ * Whether the shortcut sheet is open — shared, because the key is no longer the
340
+ * only way in.
341
+ *
342
+ * The sheet used to own a `ref` of its own, which worked while `?` was the only
343
+ * opener. A visible control has to reach the same boolean from elsewhere in the
344
+ * tree, and it has to be ONE boolean: a second instance of the sheet would be a
345
+ * second dialog, and two dialogs listening to `?` is the drift this file
346
+ * exists to prevent. `useState` rather than a module-level `ref` because a
347
+ * module-level one is shared between requests on the server.
348
+ *
349
+ * It matters most where `?` is not bound at all. With
350
+ * `shortcuts.singleCharacter: false` the visible control is the ONLY entry
351
+ * point, so nothing about the sheet may depend on the key having fired.
352
+ */
353
+ export function useDuxtShortcutSheet() {
354
+ const open = useState('duxt-shortcuts', () => false);
355
+
356
+ return { open, toggle: () => (open.value = !open.value) };
357
+ }