@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
@@ -52,13 +52,32 @@ const { data: found } = await useAsyncData(
52
52
  if (hit) return { page: hit, from: entry!.locale, asked: locale.value };
53
53
  }
54
54
 
55
- return undefined;
55
+ // `null`, not `undefined`: Nuxt reads a handler that resolves to nothing as
56
+ // one that failed to return (E3006) and repeats the query on the client.
57
+ // No page in any language is a real answer — the 404 below.
58
+ return null;
56
59
  },
57
60
  { watch: [collection, path] }
58
61
  );
59
62
 
60
63
  const page = computed(() => found.value?.page);
61
64
 
65
+ /**
66
+ * What THIS page says about the docs shell around it.
67
+ *
68
+ * Eight frontmatter fields, resolved over the site's own switches in one place
69
+ * — see `app/utils/page-controls.ts`. The alternative was eight `v-if`s each
70
+ * reading a different field off `page.value` with its own idea of what an
71
+ * absent value means, which is how `duxt.breadcrumb` ended up being the only
72
+ * one of the eight that existed at all.
73
+ */
74
+ const controls = computed(() =>
75
+ duxtPageControls(page.value, {
76
+ breadcrumb: duxt?.breadcrumb,
77
+ tocMaxDepth: duxt.toc?.depth
78
+ })
79
+ );
80
+
62
81
  /** True when the reader is being shown a language they did not ask for. */
63
82
  const untranslated = computed(() => {
64
83
  if (!translates.value || !found.value) return false;
@@ -76,12 +95,17 @@ if (!page.value) {
76
95
  throw createError({
77
96
  statusCode: 404,
78
97
  statusMessage: 'Page not found',
79
- fatal: true
98
+ // Fatal on the CLIENT only. A client-side navigation needs it to replace
99
+ // the page with the error page; the server renders that page for any
100
+ // error thrown here. Fatal on the server, Nitro files every missing URL
101
+ // as a sensitive failure and logs it with a stack trace — in dev and in
102
+ // production alike, one line per bot probing a path that does not exist.
103
+ fatal: import.meta.client
80
104
  });
81
105
  }
82
106
 
83
107
  // Feeds the search dialog's empty state.
84
- const { remember } = useRecentPages();
108
+ const { remember } = useRecentPages(duxt.search?.recentPages);
85
109
  onMounted(() =>
86
110
  remember({ path: path.value, title: page.value?.title ?? path.value })
87
111
  );
@@ -129,7 +153,22 @@ const owned = computed(() =>
129
153
  * genuinely have no outline.
130
154
  */
131
155
  const generated = computed(() =>
132
- owned.value ? generatedToc(page.value?.body) : []
156
+ owned.value && controls.value.toc
157
+ ? generatedToc(page.value?.body, controls.value.tocMaxDepth)
158
+ : []
159
+ );
160
+
161
+ /**
162
+ * The written page's outline, cut to the depth the page asked for.
163
+ *
164
+ * Content parses every level down to `h6` — see `content.build.markdown.toc` in
165
+ * `nuxt.config.ts` — so this is where the column is decided rather than at
166
+ * parse time, and `toc: { maxDepth: 5 }` has something to show.
167
+ */
168
+ const outline = computed(() =>
169
+ controls.value.toc
170
+ ? duxtTocLinks(page.value?.body?.toc?.links, controls.value.tocMaxDepth)
171
+ : []
133
172
  );
134
173
 
135
174
  /**
@@ -175,6 +214,21 @@ const aside = computed(
175
214
  (generated.value.length > 0 || (duxt.aside?.links?.length ?? 0) > 0)
176
215
  );
177
216
 
217
+ /**
218
+ * Is there anything left to put in a written page's right-hand column?
219
+ *
220
+ * It was drawn unconditionally, which was right while nothing could empty it —
221
+ * a page that turns off both the contents and the provenance on a site with no
222
+ * aside links would otherwise reserve fourteen rems for nothing. Page info is
223
+ * on by default, so the ordinary page keeps exactly the column it had.
224
+ */
225
+ const column = computed(
226
+ () =>
227
+ outline.value.length > 0 ||
228
+ (duxt.aside?.links?.length ?? 0) > 0 ||
229
+ controls.value.pageInfo
230
+ );
231
+
178
232
  // The social card. Rendered from the layer's own template unless the consumer
179
233
  // ships a component of the same name — see `OgImage/Duxt.satori.vue`.
180
234
  //
@@ -227,6 +281,19 @@ useHead(() => ({
227
281
  {
228
282
  rel: 'canonical',
229
283
  href: absolute(localeLink(preferredPath.value) ?? preferredPath.value)
284
+ },
285
+ {
286
+ // Static HTML retains these relations when an edge serves a prerendered
287
+ // page, unlike an HTTP Link header configured only at runtime.
288
+ rel: 'alternate',
289
+ type: 'text/markdown',
290
+ href: `${localeLink(path.value) ?? path.value}.md`
291
+ },
292
+ {
293
+ // Unhead's narrow relation union does not yet include this standard
294
+ // relation, but it still renders arbitrary link relation values.
295
+ rel: 'describedby' as never,
296
+ href: '/llms.txt'
230
297
  }
231
298
  ]
232
299
  }));
@@ -272,8 +339,16 @@ useSchemaOrg([
272
339
  <!-- The type's own layout owns the page: no reading width and no header of
273
340
  ours — see `owned`. The CONTENTS COLUMN is drawn here rather than left to
274
341
  the layout, because only the page holds the body it is read from. -->
275
- <div v-if="owned" class="flex min-w-0 flex-1 gap-10">
342
+ <!-- BOTH BRANCHES HANG ON `page`. A missing page throws its 404 out of
343
+ `setup`, and Vue still renders the component once on the way to the
344
+ error page — with none of the bindings `setup` never got to return. The
345
+ first nested read then threw `Cannot read properties of undefined
346
+ (reading 'fullWidth')` on every client-side navigation to a missing page.
347
+ Without a page there is nothing of this component to draw. -->
348
+ <div v-if="page && owned" class="flex min-w-0 flex-1 gap-10">
276
349
  <div class="min-w-0 flex-1 py-8">
350
+ <DuxtAnnouncements placement="above-content" />
351
+
277
352
  <DuxtVersionBanner />
278
353
 
279
354
  <DuxtTranslationBanner v-if="untranslated" :from="found?.from" />
@@ -288,23 +363,45 @@ useSchemaOrg([
288
363
  does not — and a header narrower than the parameter tables under it
289
364
  draws a rule that stops halfway across the page. -->
290
365
  <header v-if="!titled" class="mb-8 border-b pb-8">
291
- <DuxtBreadcrumb
292
- v-if="duxt?.breadcrumb !== false"
293
- :path="path"
294
- class="mb-3"
295
- />
296
-
297
- <div class="flex items-start justify-between gap-4">
366
+ <DuxtBreadcrumb v-if="controls.breadcrumb" :path="path" class="mb-3" />
367
+
368
+ <!-- THE TITLE ROW, AND WHY IT IS ALLOWED TO WRAP. This shape repeats
369
+ three times on this page — here, in the compact row below and in
370
+ the written-page header further down — and at 320px all three used
371
+ to push the document sideways. Nothing in the row could give: the
372
+ copy control is `shrink-0` by choice, the row was `nowrap`, and an
373
+ `<h1>` keeps `min-width: auto`, so a title whose longest word is
374
+ wide at `text-4xl` pins the row at that word's min-content. Three
375
+ of duxt's own pages overflowed, *Introduction* by 56px.
376
+
377
+ Below `sm` the control drops to its own line instead, because at
378
+ that width there is no room for a 36px title and a 145px control
379
+ side by side, and a clipped title is worse than a wrapped control.
380
+ `sm:flex-nowrap` keeps the control beside the title everywhere
381
+ else: `flex-wrap` breaks lines on an item's MAX-content, so
382
+ wrapping unconditionally would demote the control below any
383
+ ordinary long title on a desktop, which is not the bug.
384
+
385
+ `min-w-0` plus `wrap-break-word` on the heading is the backstop for
386
+ the case wrapping cannot reach — a single word wider than the whole
387
+ column. `overflow-wrap: break-word` does not lower an element's
388
+ min-content width, so without `min-w-0` it can never act inside a
389
+ flex row; and because it only breaks a word that fits on no line of
390
+ its own, an ordinary title is left exactly as it was. -->
391
+ <div
392
+ class="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 sm:flex-nowrap"
393
+ >
298
394
  <h1
299
395
  ref="heading"
300
396
  tabindex="-1"
301
- class="text-4xl font-semibold tracking-tight text-balance outline-none"
397
+ class="min-w-0 text-4xl font-semibold tracking-tight text-balance wrap-break-word outline-none"
302
398
  >
303
399
  {{ page?.title }}
304
400
  </h1>
305
401
 
306
402
  <DuxtCopyPage
307
- class="mt-1"
403
+ v-if="controls.copyPage"
404
+ class="mt-1 ms-auto"
308
405
  :path="path"
309
406
  :title="page?.title"
310
407
  :rawbody="(page as { rawbody?: string })?.rawbody"
@@ -328,11 +425,15 @@ useSchemaOrg([
328
425
 
329
426
  <!-- A type that DOES draw its own title keeps the compact row: where the
330
427
  reader is, and what they can do with the page. -->
331
- <div v-else class="mb-6 flex items-start justify-between gap-4">
332
- <DuxtBreadcrumb v-if="duxt?.breadcrumb !== false" :path="path" />
428
+ <div
429
+ v-else
430
+ class="mb-6 flex flex-wrap items-start justify-between gap-x-4 gap-y-2 sm:flex-nowrap"
431
+ >
432
+ <DuxtBreadcrumb v-if="controls.breadcrumb" :path="path" />
333
433
 
334
434
  <DuxtCopyPage
335
- class="-mt-1 ml-auto"
435
+ v-if="controls.copyPage"
436
+ class="-mt-1 ms-auto"
336
437
  :path="path"
337
438
  :title="page?.title"
338
439
  :rawbody="(page as { rawbody?: string })?.rawbody"
@@ -349,7 +450,7 @@ useSchemaOrg([
349
450
  at the section's edge, so the links can only be siblings — and a
350
451
  release page that ended in whitespace now ends in the release before
351
452
  it, which is how a history is read. -->
352
- <DuxtPageNav :path="path" />
453
+ <DuxtPageNav v-if="controls.prevNext" :path="path" />
353
454
 
354
455
  <!-- Provenance survives the header, because it is the one part of it that
355
456
  is still true: every page of a generated section came out of one
@@ -359,7 +460,7 @@ useSchemaOrg([
359
460
  <!-- As a row, and as wide as the page: with no column to sit in it is a
360
461
  footer, and a rule that stopped at the reading measure under a page
361
462
  set wider than that ended halfway across. -->
362
- <DuxtPageInfo v-if="!aside" row :page="page" />
463
+ <DuxtPageInfo v-if="!aside && controls.pageInfo" row :page="page" />
363
464
  </div>
364
465
 
365
466
  <div v-if="aside" class="hidden w-56 shrink-0 xl:block">
@@ -368,37 +469,46 @@ useSchemaOrg([
368
469
  >
369
470
  <DuxtToc :links="generated" />
370
471
 
371
- <DuxtPageInfo :page="page" />
472
+ <DuxtPageInfo v-if="controls.pageInfo" :page="page" />
372
473
  </div>
373
474
  </div>
374
475
  </div>
375
476
 
376
- <div v-else class="flex min-w-0 flex-1 justify-center gap-10">
377
- <article class="min-w-0 max-w-3xl flex-1 py-8">
477
+ <div v-else-if="page" class="flex min-w-0 flex-1 justify-center gap-10">
478
+ <!-- `fullWidth` removes the reading measure and NOTHING else: the header,
479
+ the left navigation and the column beside the article all stay, and a
480
+ page that wants those gone says so with their own controls. It is the
481
+ one page control that is off until asked for, because it removes a
482
+ constraint rather than something the shell draws. -->
483
+ <article
484
+ class="min-w-0 flex-1 py-8"
485
+ :class="controls.fullWidth ? undefined : 'max-w-3xl'"
486
+ >
487
+ <DuxtAnnouncements placement="above-content" />
488
+
378
489
  <DuxtVersionBanner />
379
490
 
380
491
  <DuxtTranslationBanner v-if="untranslated" :from="found?.from" />
381
492
 
382
493
  <header class="mb-8 border-b pb-8">
383
- <DuxtBreadcrumb
384
- v-if="duxt?.breadcrumb !== false"
385
- :path="path"
386
- class="mb-3"
387
- />
494
+ <DuxtBreadcrumb v-if="controls.breadcrumb" :path="path" class="mb-3" />
388
495
  <!-- The copy action sits with the title, not under the article: it is
389
496
  what a reader does with the page BEFORE reading it, and a control
390
497
  for that at the bottom is a control nobody finds. -->
391
- <div class="flex items-start justify-between gap-4">
498
+ <div
499
+ class="flex flex-wrap items-start justify-between gap-x-4 gap-y-2 sm:flex-nowrap"
500
+ >
392
501
  <h1
393
502
  ref="heading"
394
503
  tabindex="-1"
395
- class="text-4xl font-semibold tracking-tight text-balance outline-none"
504
+ class="min-w-0 text-4xl font-semibold tracking-tight text-balance wrap-break-word outline-none"
396
505
  >
397
506
  {{ page?.title }}
398
507
  </h1>
399
508
 
400
509
  <DuxtCopyPage
401
- class="mt-1"
510
+ v-if="controls.copyPage"
511
+ class="mt-1 ms-auto"
402
512
  :path="path"
403
513
  :title="page?.title"
404
514
  :rawbody="(page as { rawbody?: string })?.rawbody"
@@ -416,25 +526,26 @@ useSchemaOrg([
416
526
  <ContentRenderer v-if="page" :value="page" />
417
527
  </div>
418
528
 
419
- <DuxtPageNav :path="path" />
529
+ <DuxtPageNav v-if="controls.prevNext" :path="path" />
420
530
 
421
- <DuxtPageFeedback />
531
+ <DuxtPageFeedback v-if="controls.feedback" />
422
532
  </article>
423
533
 
424
534
  <!-- A div for the same reason as the sidebar's: DuxtToc's two <nav>s are
425
535
  the landmarks, and both are labelled. -->
426
- <div class="hidden w-56 shrink-0 xl:block">
536
+ <!-- It drops out entirely once a page has emptied it — see `column`. -->
537
+ <div v-if="column" class="hidden w-56 shrink-0 xl:block">
427
538
  <div
428
539
  class="sticky top-[var(--duxt-header-offset)] max-h-[calc(100vh-var(--duxt-header-offset)-1.5rem)] overflow-y-auto py-8"
429
540
  >
430
- <DuxtToc :links="page?.body?.toc?.links ?? []" />
541
+ <DuxtToc :links="outline" />
431
542
 
432
543
  <!-- Provenance under the contents: where this page came from, when it
433
544
  last changed and who wrote it. On the right rather than under the
434
545
  article, where it read as an afterthought below the prev/next
435
546
  links — this column is already the one answering what a page IS
436
547
  rather than what it says. -->
437
- <DuxtPageInfo :page="page" />
548
+ <DuxtPageInfo v-if="controls.pageInfo" :page="page" />
438
549
  </div>
439
550
  </div>
440
551
  </div>
@@ -21,7 +21,6 @@
21
21
  const duxt = useDuxtConfig();
22
22
  const localeLink = useDuxtLink();
23
23
  const { t } = useI18n();
24
- const notify = useDuxtToast();
25
24
 
26
25
  /**
27
26
  * Where a hero button goes when the config did not say — the same fallback
@@ -48,22 +47,11 @@ const { data: highlightedCommand } = await useAsyncData(
48
47
  { watch: [command] }
49
48
  );
50
49
 
51
- const copied = ref(false);
52
- async function copyCommand() {
53
- if (!command.value) return;
54
-
55
- try {
56
- await navigator.clipboard.writeText(command.value);
57
- copied.value = true;
58
- // The icon alone is a 3.5 rem change under the reader's own cursor, which
59
- // is exactly where they are not looking after a click. The toast is the
60
- // confirmation; the icon is what stays for the two seconds after it.
61
- notify.success(t('duxt.code.copiedToast'));
62
- setTimeout(() => (copied.value = false), 2000);
63
- } catch {
64
- notify.error(t('duxt.page.copyFailed'));
65
- }
66
- }
50
+ // The icon alone is a 3.5 rem change under the reader's own cursor, which is
51
+ // exactly where they are not looking after a click. The toast is the
52
+ // confirmation; the icon is what stays for the two seconds after it.
53
+ const { copied, copy } = useDuxtCopy();
54
+ const copyCommand = () => copy(command.value);
67
55
 
68
56
  /**
69
57
  * The badge, in either of its two shapes. A string is the label and nothing
@@ -275,12 +263,16 @@ defineOgImage('Duxt', {
275
263
  <span aria-hidden="true" class="text-muted-foreground select-none"
276
264
  >$</span
277
265
  >
266
+ <!-- A shell command does not turn round with the interface, so
267
+ it declares its own direction and aligns to the start of THAT
268
+ — which is the left, in either locale. -->
278
269
  <span
279
270
  v-if="highlightedCommand"
280
- class="min-w-0 overflow-x-auto text-left [&_pre]:bg-transparent!"
271
+ dir="ltr"
272
+ class="min-w-0 overflow-x-auto text-start [&_pre]:bg-transparent!"
281
273
  v-html="highlightedCommand"
282
274
  />
283
- <span v-else class="min-w-0 overflow-x-auto text-left">{{
275
+ <span v-else dir="ltr" class="min-w-0 overflow-x-auto text-start">{{
284
276
  command
285
277
  }}</span>
286
278
  <Icon
@@ -412,7 +404,7 @@ defineOgImage('Duxt', {
412
404
  ? 'lucide:arrow-up-right'
413
405
  : 'lucide:arrow-right'
414
406
  "
415
- class="size-3.5 text-muted-foreground transition-transform group-hover:translate-x-0.5"
407
+ class="size-3.5 text-muted-foreground rtl:-scale-x-100 transition-transform ltr:group-hover:translate-x-0.5 rtl:group-hover:-translate-x-0.5"
416
408
  />
417
409
  </UiCardTitle>
418
410
 
@@ -425,9 +417,9 @@ defineOgImage('Duxt', {
425
417
  </div>
426
418
  </section>
427
419
 
428
- <!-- THE REST. One line each, no cards and no links: these are real and
429
- worth naming, and a card apiece would say they matter as much as the
430
- four above.
420
+ <!-- THE SUPPORTING SYSTEM. One line each, no cards and no links: these are
421
+ real parts of publishing and reading the content, while a card apiece
422
+ would say they matter as much as the primary capabilities above.
431
423
 
432
424
  A <ul>, and the entries are paragraphs rather than headings: this is a
433
425
  list of things, not a part of the document with sections under it — and