@kirchdev/duxt 0.3.4 → 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
@@ -4,14 +4,33 @@ import type { HTMLAttributes } from 'vue';
4
4
  import { reactiveOmit } from '@vueuse/core';
5
5
  import { TabsTrigger, useForwardProps } from 'reka-ui';
6
6
  import { cn } from '@duxt/lib/utils';
7
+ import { duxtPill, type DuxtPillSize } from '../../../utils/pill';
7
8
 
9
+ /**
10
+ * Three looks, one behaviour.
11
+ *
12
+ * `pill` is the site's own — the code group, the preview and the request
13
+ * samples, drawn by `duxtPill` like every other "pick one of these" strip.
14
+ * `underline` is the prose tab group, which sits in running text rather than in
15
+ * a card. `default` is shadcn's, untouched, for a consumer who wants it.
16
+ */
8
17
  const props = defineProps<
9
- TabsTriggerProps & { class?: HTMLAttributes['class'] }
18
+ TabsTriggerProps & {
19
+ class?: HTMLAttributes['class'];
20
+ variant?: 'default' | 'pill' | 'underline';
21
+ size?: DuxtPillSize;
22
+ }
10
23
  >();
11
24
 
12
- const delegatedProps = reactiveOmit(props, 'class');
25
+ const delegatedProps = reactiveOmit(props, 'class', 'variant', 'size');
13
26
 
14
27
  const forwardedProps = useForwardProps(delegatedProps);
28
+
29
+ const looks = {
30
+ default: `data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-3 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
31
+ underline:
32
+ '-mb-px cursor-pointer border-b-2 border-transparent px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring data-[state=active]:border-primary data-[state=active]:text-foreground'
33
+ };
15
34
  </script>
16
35
 
17
36
  <template>
@@ -19,7 +38,9 @@ const forwardedProps = useForwardProps(delegatedProps);
19
38
  data-slot="tabs-trigger"
20
39
  :class="
21
40
  cn(
22
- `data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-3 focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
41
+ props.variant === 'pill'
42
+ ? duxtPill('tab', { size: props.size })
43
+ : looks[props.variant ?? 'default'],
23
44
  props.class
24
45
  )
25
46
  "
@@ -34,7 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
34
34
  v-bind="{ ...forwarded, ...$attrs }"
35
35
  :class="
36
36
  cn(
37
- 'bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
37
+ 'bg-neutral-900 text-neutral-50 animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance',
38
38
  props.class
39
39
  )
40
40
  "
@@ -42,7 +42,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
42
42
  <slot />
43
43
 
44
44
  <TooltipArrow
45
- class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xs"
45
+ class="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-xs bg-neutral-900 fill-neutral-900"
46
46
  />
47
47
  </TooltipContent>
48
48
  </TooltipPortal>
@@ -24,17 +24,10 @@
24
24
  * `getBoundingClientRect` per heading, coalesced into an animation frame, so at
25
25
  * most one pass per painted frame however fast the wheel turns.
26
26
  */
27
- export function useActiveHeading(ids: Ref<string[]>) {
27
+ export function useActiveHeading(ids: Ref<string[]>, scrollOffset = 96) {
28
28
  const active = ref<string>();
29
29
 
30
- /** Where a heading counts as reached: reading position, not the top edge. */
31
- const TRIGGER = 96;
32
-
33
- let frame = 0;
34
-
35
30
  function measure() {
36
- frame = 0;
37
-
38
31
  const headings = ids.value
39
32
  .map((id) => document.getElementById(id))
40
33
  .filter((element): element is HTMLElement => Boolean(element));
@@ -58,33 +51,18 @@ export function useActiveHeading(ids: Ref<string[]>) {
58
51
 
59
52
  let current = headings[0]!;
60
53
  for (const heading of headings) {
61
- if (heading.getBoundingClientRect().top > TRIGGER) break;
54
+ if (heading.getBoundingClientRect().top > scrollOffset) break;
62
55
  current = heading;
63
56
  }
64
57
 
65
58
  active.value = current.id;
66
59
  }
67
60
 
68
- function schedule() {
69
- if (frame) return;
70
- frame = requestAnimationFrame(measure);
71
- }
72
-
73
- onMounted(() => {
74
- measure();
75
- window.addEventListener('scroll', schedule, { passive: true });
76
- window.addEventListener('resize', schedule, { passive: true });
77
- });
61
+ useDuxtViewportMeasure(measure);
78
62
 
79
63
  // A new page's headings exist only after it renders, and the ids change
80
64
  // before the DOM does.
81
65
  watch(ids, () => nextTick(measure));
82
66
 
83
- onBeforeUnmount(() => {
84
- if (frame) cancelAnimationFrame(frame);
85
- window.removeEventListener('scroll', schedule);
86
- window.removeEventListener('resize', schedule);
87
- });
88
-
89
67
  return active;
90
68
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Report one reader interaction to whoever the site said, if it said anyone.
3
+ *
4
+ * OPT-IN IS THE WHOLE FEATURE, and it is worth saying what that buys. duxt
5
+ * ships no provider, no SDK, no adapter and no script: a site that has not
6
+ * written `duxt.analytics.track` downloads not one byte more than before this
7
+ * existed, and every call below reaches a `typeof track !== 'function'` and
8
+ * stops. There is no queue, no buffer, no storage, no cookie and no consent
9
+ * banner, because duxt never holds the data long enough to need any of them —
10
+ * the site's own function is the only thing that ever leaves the page.
11
+ *
12
+ * That puts three decisions on the side that owns them: WHICH provider, WHETHER
13
+ * to transmit at all, and under what consent. A layer cannot answer any of the
14
+ * three for somebody else's readers — the same reason `DuxtPageFeedback` has an
15
+ * event and no backend, and the footer's legal row ships empty (ADR 0005).
16
+ *
17
+ * WHAT IS NEVER SENT, stated once so it can be checked: no API credential, no
18
+ * request or response body, no concrete request URL, no header, no copied text,
19
+ * no rendered search result, no browser or reader identifier, and no query
20
+ * string or fragment off the page's own path. The event types say the rest —
21
+ * they are a closed union, so what duxt can report is readable in one place.
22
+ *
23
+ * CLIENT ONLY. A build and a prerender call nobody: a static render is not a
24
+ * reader, and a site's analytics must not fill up with pages the crawler
25
+ * opened. `duxt.app` prerenders every page it has, which is exactly the case
26
+ * that would otherwise send hundreds of events from a CI box.
27
+ */
28
+ export function useDuxtAnalytics() {
29
+ const duxt = useDuxtConfig();
30
+ const path = useDuxtPath();
31
+ const { locale } = useI18n();
32
+ // The source the reader is standing in, not the one a hit came out of — a
33
+ // search result carries its own provenance on the event itself.
34
+ const { source } = useDuxtCollection();
35
+
36
+ /**
37
+ * Stamp the context on and hand it over. Returns nothing, on purpose:
38
+ * nothing a call site does should depend on whether reporting worked.
39
+ */
40
+ function track<Event extends DuxtAnalyticsEventInput>(event: Event): void {
41
+ if (import.meta.server) return;
42
+
43
+ emitDuxtAnalytics(duxt.analytics?.track, {
44
+ ...event,
45
+ context: duxtAnalyticsContext(path.value, locale.value, source.value)
46
+ });
47
+ }
48
+
49
+ return { track };
50
+ }
@@ -45,15 +45,6 @@ const HEIGHT_STEP = 24;
45
45
  */
46
46
  const SETTLE_MS = 60;
47
47
 
48
- /**
49
- * How long the content takes to come back once the box is on its way.
50
- *
51
- * Shorter than the box's own 300ms on purpose: the point is to be readable
52
- * again well before the box stops moving, not to make the reader wait out two
53
- * animations in a row.
54
- */
55
- const FADE_MS = 200;
56
-
57
48
  export function useDuxtAnimatedHeight(
58
49
  shell: Readonly<import('vue').ShallowRef<HTMLElement | null>>,
59
50
  body: Readonly<import('vue').ShallowRef<HTMLElement | null>>,
@@ -69,10 +60,10 @@ export function useDuxtAnimatedHeight(
69
60
 
70
61
  // The box carries the border and `box-sizing: border-box`, so its height
71
62
  // has to include what that border takes; the content's height does not.
72
- const chrome = box.offsetHeight - box.clientHeight;
63
+ const borderHeight = box.offsetHeight - box.clientHeight;
73
64
 
74
65
  const step = (height: number) =>
75
- Math.ceil((height + chrome) / HEIGHT_STEP) * HEIGHT_STEP;
66
+ Math.ceil((height + borderHeight) / HEIGHT_STEP) * HEIGHT_STEP;
76
67
 
77
68
  let last = step(content.offsetHeight);
78
69
 
@@ -90,28 +81,16 @@ export function useDuxtAnimatedHeight(
90
81
 
91
82
  let settle: ReturnType<typeof setTimeout> | undefined;
92
83
 
93
- const reduced = window.matchMedia('(prefers-reduced-motion: reduce)');
94
-
95
84
  const observer = new ResizeObserver(() => {
96
85
  const next = step(content.offsetHeight);
97
86
  if (next === last) return;
98
87
 
99
- // THE CONTENT FADES WHILE THE BOX TRAVELS.
100
- //
101
- // The new panel is there the instant the tab is clicked, and until the
102
- // box has grown to fit it, `overflow-hidden` cuts it off — a taller
103
- // sample appeared with its last lines sliced away for the length of the
104
- // animation. Hidden for that moment and faded back in as the box arrives,
105
- // there is nothing cut off to see: what a reader watches is one sample
106
- // replacing another, not a box catching up with its contents.
107
- //
108
- // Set with the transition off, so it is instant going out and animated
109
- // coming back — a fade in both directions would be twice as long a wait
110
- // for the same effect.
111
- if (!reduced.matches) {
112
- content.style.transition = 'none';
113
- content.style.opacity = '0';
114
- }
88
+ // NOTHING FADES. The content used to be hidden while the box travelled and
89
+ // faded back in as it arrived, so a taller file was never seen cut off.
90
+ // What a reader saw instead was the block emptying first and filling
91
+ // again — the code gone for the length of the fade on every tab change.
92
+ // Clipped by `overflow-hidden` and revealed as the box opens, the new file
93
+ // is there the whole time.
115
94
 
116
95
  // ONLY THE VALUE IT SETTLES ON, never the ones on the way.
117
96
  //
@@ -131,13 +110,6 @@ export function useDuxtAnimatedHeight(
131
110
  last = settled;
132
111
  box.style.height = `${settled}px`;
133
112
  }
134
-
135
- if (!reduced.matches) {
136
- requestAnimationFrame(() => {
137
- content.style.transition = `opacity ${FADE_MS}ms ease-out`;
138
- content.style.opacity = '1';
139
- });
140
- }
141
113
  }, SETTLE_MS);
142
114
  });
143
115
 
@@ -148,8 +120,6 @@ export function useDuxtAnimatedHeight(
148
120
  clearTimeout(settle);
149
121
  box.style.height = '';
150
122
  box.style.transition = '';
151
- content.style.opacity = '';
152
- content.style.transition = '';
153
123
  });
154
124
  });
155
125
  }
@@ -0,0 +1,153 @@
1
+ /** Where the dismissals are kept. Namespaced so a consumer's keys cannot collide. */
2
+ const STORAGE_KEY = 'duxt:announcements';
3
+
4
+ /**
5
+ * The one timer the whole page needs, at module scope.
6
+ *
7
+ * Three placements mean up to three components asking the same question — when
8
+ * does the next window open or close — and three timers firing at the same
9
+ * millisecond to set the same value. Module scope is safe here because every
10
+ * write below sits behind `import.meta.client`: in the browser the module is
11
+ * one app for one page load, and on the server it is never touched at all.
12
+ */
13
+ let timer: ReturnType<typeof setTimeout> | undefined;
14
+ let scheduledFor: number | undefined;
15
+
16
+ /**
17
+ * The announcements a placement should draw, and how a reader closes one.
18
+ *
19
+ * TWO configs are read, deliberately. The RESOLVED one is what the banner
20
+ * prints — `useDuxtConfig()` has collapsed each `text` to the reader's own
21
+ * language. The WRITTEN one is what a dismissal is remembered against, because
22
+ * a derived identity has to survive a language change, and a sentence resolved
23
+ * for `de-DE` is a different string from the same sentence resolved for
24
+ * `en-GB`. The two lists are the same list in the same order, so an index
25
+ * addresses the same announcement in both.
26
+ */
27
+ export function useDuxtAnnouncements(placement: DuxtAnnouncementPlacement) {
28
+ const appConfig = useAppConfig();
29
+ const duxt = useDuxtConfig();
30
+
31
+ /** As the consumer wrote it: every locale of every sentence, still together. */
32
+ const written = computed<DuxtAnnouncement[]>(
33
+ () => mergeDuxtConfig(appConfig.duxt, duxtDefaults).announcements ?? []
34
+ );
35
+
36
+ /**
37
+ * The reader's clock, shared so the three placements never disagree about
38
+ * what time it is mid-render.
39
+ *
40
+ * Seeded at `0` rather than at `Date.now()`: `0` is before every window a
41
+ * consumer would write, so an announcement with a `startsAt` cannot flash
42
+ * during hydration before the real time arrives.
43
+ */
44
+ const now = useState<number>('duxt-announcements-now', () => 0);
45
+
46
+ /** The keys this reader has closed. Empty until the browser says otherwise. */
47
+ const dismissed = useState<string[]>(
48
+ 'duxt-announcements-dismissed',
49
+ () => []
50
+ );
51
+
52
+ function read(): string[] {
53
+ // Blocked storage, or a value someone else wrote — either way, nothing has
54
+ // been dismissed and every due announcement shows.
55
+ const parsed = readStoredJson(STORAGE_KEY);
56
+
57
+ return Array.isArray(parsed)
58
+ ? parsed.filter((entry): entry is string => typeof entry === 'string')
59
+ : [];
60
+ }
61
+
62
+ /**
63
+ * Keep only the keys the config still names.
64
+ *
65
+ * Without it the list grows for the life of the browser profile: every
66
+ * announcement a site ever ran leaves a key behind, and none of them can ever
67
+ * match anything again — a derived key changes with its sentence, and a
68
+ * retired `id` is gone from the config.
69
+ */
70
+ function persist(keys: string[]) {
71
+ const live = new Set(written.value.map(duxtAnnouncementKey));
72
+ const next = keys.filter((key) => live.has(key));
73
+
74
+ dismissed.value = next;
75
+
76
+ // A dismissal that cannot be remembered still hides the banner for this
77
+ // page. Not worth an error the reader would have to read.
78
+ writeStoredJson(STORAGE_KEY, next);
79
+ }
80
+
81
+ /**
82
+ * Wake up exactly once, at the next moment the list changes.
83
+ *
84
+ * Not a poll: an announcement scheduled for next Tuesday costs one sleeping
85
+ * timer rather than a tick every second for six days. `setTimeout` overflows
86
+ * past 2^31 ms and fires immediately, so a longer wait is taken in day-long
87
+ * steps — each one simply re-asks the same question.
88
+ */
89
+ function schedule() {
90
+ if (!import.meta.client) return;
91
+
92
+ const next = duxtNextAnnouncementChange(written.value, now.value);
93
+
94
+ if (next === undefined) {
95
+ scheduledFor = undefined;
96
+ return;
97
+ }
98
+
99
+ if (timer !== undefined && scheduledFor === next) return;
100
+
101
+ const DAY = 86_400_000;
102
+ clearTimeout(timer);
103
+ scheduledFor = next;
104
+ timer = setTimeout(
105
+ () => {
106
+ timer = undefined;
107
+ scheduledFor = undefined;
108
+ now.value = Date.now();
109
+ schedule();
110
+ },
111
+ Math.min(Math.max(next - Date.now(), 0), DAY)
112
+ );
113
+ }
114
+
115
+ onMounted(() => {
116
+ now.value = Date.now();
117
+ persist(read());
118
+ schedule();
119
+ });
120
+
121
+ const visible = computed(() => {
122
+ const due = new Set(
123
+ duxtVisibleAnnouncements(written.value, {
124
+ placement,
125
+ now: now.value,
126
+ dismissed: dismissed.value,
127
+ maxVisible: duxt.announcementOptions?.maxVisible
128
+ })
129
+ );
130
+
131
+ // Paired back to the resolved list by POSITION: `resolveDuxtTexts` maps an
132
+ // array one to one, so index `n` is the same announcement in both — which
133
+ // is what lets the identity come from the written text and the words on
134
+ // screen from the resolved one.
135
+ return written.value.flatMap((announcement, index) =>
136
+ due.has(announcement)
137
+ ? [
138
+ {
139
+ key: duxtAnnouncementKey(announcement),
140
+ text: duxt.announcements?.[index]?.text ?? '',
141
+ link: duxt.announcements?.[index]?.link
142
+ }
143
+ ]
144
+ : []
145
+ );
146
+ });
147
+
148
+ function dismiss(key: string) {
149
+ persist([...dismissed.value, key]);
150
+ }
151
+
152
+ return { visible, dismiss };
153
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Copy a string to the clipboard, and say whether it worked.
3
+ *
4
+ * Every copy control on the site does the same four things — write, confirm
5
+ * with a toast, show a check for two seconds, report a refusal — and seven of
6
+ * them used to spell it out. The spellings had drifted: two reported a failure
7
+ * in English whatever the reader's language, one never confirmed a success at
8
+ * all, and none cleared its timer, so two copies inside two seconds reset the
9
+ * icon early.
10
+ *
11
+ * Returns whether the text reached the clipboard, which is what a caller
12
+ * reporting the copy needs: an analytics event for a copy the clipboard
13
+ * refused would be a copy that never happened.
14
+ *
15
+ * `toast: false` for a control whose own label already changes to "Copied" —
16
+ * a second confirmation in the corner says the same thing twice.
17
+ */
18
+ export function useDuxtCopy({
19
+ toast = true,
20
+ failureKey = 'duxt.code.copyFailed'
21
+ }: { toast?: boolean; failureKey?: string } = {}) {
22
+ const copied = ref(false);
23
+ const notify = useDuxtToast();
24
+ const { t } = useI18n();
25
+
26
+ let reset: ReturnType<typeof setTimeout> | undefined;
27
+
28
+ async function copy(text: string | undefined): Promise<boolean> {
29
+ if (!text) return false;
30
+
31
+ try {
32
+ await navigator.clipboard.writeText(text);
33
+ } catch {
34
+ // Unavailable over plain HTTP, or refused by the browser.
35
+ notify.error(t(failureKey));
36
+ return false;
37
+ }
38
+
39
+ copied.value = true;
40
+ if (toast) notify.success(t('duxt.code.copiedToast'));
41
+
42
+ clearTimeout(reset);
43
+ reset = setTimeout(() => (copied.value = false), 2000);
44
+
45
+ return true;
46
+ }
47
+
48
+ onScopeDispose(() => clearTimeout(reset));
49
+
50
+ return { copied, copy };
51
+ }
@@ -0,0 +1,23 @@
1
+ import { localeDirection } from '@duxt/utils/direction';
2
+ import type { DuxtDirection } from '@duxt/utils/direction';
3
+
4
+ /**
5
+ * Which way round this page is drawn.
6
+ *
7
+ * Two shells need the answer and neither can borrow it from the other:
8
+ * `error.vue` REPLACES `app.vue` rather than nesting inside it, which is how
9
+ * the error page came to set `lang` and never `dir`. One composable, both
10
+ * shells, and the layer's one statement about direction.
11
+ *
12
+ * WHAT THE ANSWER IS FOR is wider than the `<html>` attribute. reka-ui reads
13
+ * its direction from a `ConfigProvider` and from nothing else — `useDirection`
14
+ * injects that context and defaults to `ltr`, never looking at the document —
15
+ * so a menu, a select and a tooltip stay left-to-right under a right-to-left
16
+ * page unless the same value is handed to the provider. That is the difference
17
+ * between a theme that CAN be mirrored and one that merely says it is.
18
+ */
19
+ export function useDuxtDirection(): ComputedRef<DuxtDirection> {
20
+ const { locale, locales } = useI18n();
21
+
22
+ return computed(() => localeDirection(locales.value, locale.value));
23
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ContentNavigationItem } from '@nuxt/content';
2
+ import { tfplugindocsNavigation } from '../../tfplugindocs';
2
3
 
3
4
  /**
4
5
  * Which collections the navigation is being fetched from.
@@ -22,7 +23,11 @@ import type { ContentNavigationItem } from '@nuxt/content';
22
23
  const useNavigationSource = () =>
23
24
  useState('duxt-navigation-source', () => ({
24
25
  base: 'docs' as DuxtCollectionName,
25
- translation: 'docs' as DuxtCollectionName
26
+ translation: 'docs' as DuxtCollectionName,
27
+ // The base source's tfplugindocs prefix, or `undefined` for any other
28
+ // flavour. Resolved in `useDuxtNavigation`, never in the handler — see
29
+ // the note there.
30
+ tfplugindocsPrefix: undefined as string | undefined
26
31
  }));
27
32
 
28
33
  /**
@@ -35,19 +40,28 @@ const useNavigationSource = () =>
35
40
  * see `overlayTranslations`.
36
41
  */
37
42
  const handler = async (): Promise<ContentNavigationItem[]> => {
38
- // Read BEFORE the first await: after it, the route may have moved on.
39
- const { base, translation } = useNavigationSource().value;
43
+ // Read BEFORE the first await: after it, the route may have moved on — and
44
+ // the Nuxt instance is gone. NOTHING below the first await may call a
45
+ // composable: `useDuxtConfig()` sat under it once, threw E1001 on every
46
+ // request, and the handler's error left every page without a sidebar.
47
+ // `tests/async-data-context.test.ts` holds that line.
48
+ const { base, translation, tfplugindocsPrefix } = useNavigationSource().value;
40
49
 
41
50
  const tree = await queryCollectionNavigation(base as DuxtCollectionArg, [
42
51
  'icon',
43
- 'description'
52
+ 'description',
53
+ 'subcategory'
44
54
  ]);
45
55
 
46
56
  // Before the overlay: a folder's title comes from its own index page, and a
47
57
  // translated index has to be able to carry that up with it.
48
58
  const named = titleFoldersFromIndex(tree);
59
+ const navigable =
60
+ tfplugindocsPrefix === undefined
61
+ ? named
62
+ : tfplugindocsNavigation(named, tfplugindocsPrefix);
49
63
 
50
- if (translation === base) return named;
64
+ if (translation === base) return navigable;
51
65
 
52
66
  const translated = await queryCollection(translation as DuxtCollectionArg)
53
67
  .select('path', 'title', 'description')
@@ -55,7 +69,7 @@ const handler = async (): Promise<ContentNavigationItem[]> => {
55
69
 
56
70
  return titleFoldersFromIndex(
57
71
  overlayTranslations(
58
- named,
72
+ navigable,
59
73
  new Map(translated.map((page) => [page.path, page]))
60
74
  )
61
75
  );
@@ -68,14 +82,20 @@ const handler = async (): Promise<ContentNavigationItem[]> => {
68
82
  * one cache entry, and neither may two languages of one.
69
83
  */
70
84
  export function useDuxtNavigation() {
71
- const { collection, baseCollection } = useDuxtCollection();
85
+ const { collection, baseCollection, sources } = useDuxtCollection();
72
86
  const source = useNavigationSource();
73
87
 
74
88
  // Kept in step before every fetch, including the ones `watch` triggers.
75
89
  watchEffect(() => {
90
+ const base = sources.value.find(
91
+ (entry) => entry.collection === baseCollection.value
92
+ );
93
+
76
94
  source.value = {
77
95
  base: baseCollection.value,
78
- translation: collection.value
96
+ translation: collection.value,
97
+ tfplugindocsPrefix:
98
+ base?.flavor === 'tfplugindocs' ? base.prefix : undefined
79
99
  };
80
100
  });
81
101
 
@@ -8,11 +8,7 @@ export function useDuxtPath() {
8
8
  const route = useRoute();
9
9
  const { locales } = useI18n();
10
10
 
11
- const codes = computed(() =>
12
- locales.value.map((entry) =>
13
- typeof entry === 'string' ? entry : entry.code
14
- )
15
- );
11
+ const codes = computed(() => localeCodesOf(locales.value));
16
12
 
17
13
  return computed(() => stripLocalePrefix(route.path, codes.value));
18
14
  }
@@ -30,6 +26,8 @@ export function useDuxtLink() {
30
26
 
31
27
  return (to?: string) => {
32
28
  if (!to?.startsWith('/')) return to;
29
+ // A root server route has no locale variant — see `isMachineRoute`.
30
+ if (isMachineRoute(to)) return to;
33
31
 
34
32
  // localePath resolves a route, and a trailing anchor is not part of one.
35
33
  // Split it off and put it back, so a sidebar entry pointing at a heading