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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +24 -9
  2. package/app/app.vue +17 -11
  3. package/app/assets/css/duxt.css +47 -6
  4. package/app/components/DuxtAnnouncements.vue +117 -0
  5. package/app/components/DuxtBrunoEntries.vue +62 -0
  6. package/app/components/DuxtCodeBlock.vue +18 -34
  7. package/app/components/DuxtCodeToolbar.vue +14 -0
  8. package/app/components/DuxtCopyButton.vue +33 -0
  9. package/app/components/DuxtCopyPage.vue +32 -30
  10. package/app/components/DuxtFooter.vue +39 -8
  11. package/app/components/DuxtHeader.vue +107 -48
  12. package/app/components/DuxtLandingProse.vue +1 -1
  13. package/app/components/DuxtLinkLabel.vue +36 -0
  14. package/app/components/DuxtLiveWindow.vue +8 -8
  15. package/app/components/DuxtLocale.vue +10 -1
  16. package/app/components/DuxtNavigation.vue +2 -2
  17. package/app/components/DuxtOpenApiClient.vue +285 -90
  18. package/app/components/DuxtOpenApiMedia.vue +30 -18
  19. package/app/components/DuxtOpenApiSchema.vue +10 -7
  20. package/app/components/DuxtPageFeedback.vue +7 -0
  21. package/app/components/DuxtPageInfo.vue +15 -12
  22. package/app/components/DuxtPageNav.vue +41 -27
  23. package/app/components/DuxtPreviewSkeleton.vue +4 -4
  24. package/app/components/DuxtProgress.vue +2 -14
  25. package/app/components/DuxtSearch.vue +230 -90
  26. package/app/components/DuxtSearchInput.vue +70 -0
  27. package/app/components/DuxtSections.vue +11 -1
  28. package/app/components/DuxtSegmented.vue +64 -0
  29. package/app/components/DuxtShortcuts.vue +16 -11
  30. package/app/components/DuxtShortcutsTrigger.vue +49 -0
  31. package/app/components/DuxtSkipLink.vue +1 -1
  32. package/app/components/DuxtToc.vue +16 -10
  33. package/app/components/DuxtTranslationBanner.vue +1 -1
  34. package/app/components/DuxtVersion.vue +53 -12
  35. package/app/components/DuxtVersionBanner.vue +5 -5
  36. package/app/components/content/AccordionItem.vue +2 -2
  37. package/app/components/content/BrunoOverview.vue +260 -0
  38. package/app/components/content/BrunoRequest.vue +141 -0
  39. package/app/components/content/BrunoRequests.vue +104 -0
  40. package/app/components/content/Callout.vue +5 -5
  41. package/app/components/content/ChangelogReleases.vue +161 -40
  42. package/app/components/content/CodeGroup.vue +41 -37
  43. package/app/components/content/Field.vue +1 -1
  44. package/app/components/content/FileTree.vue +1 -1
  45. package/app/components/content/OpenApiOperation.vue +4 -4
  46. package/app/components/content/OpenApiOperations.vue +1 -1
  47. package/app/components/content/OpenApiOverview.vue +1 -1
  48. package/app/components/content/PackageManagers.vue +42 -58
  49. package/app/components/content/PageCards.vue +7 -4
  50. package/app/components/content/Preview.vue +15 -39
  51. package/app/components/content/ProseImg.vue +596 -49
  52. package/app/components/content/Since.vue +1 -1
  53. package/app/components/content/Steps.vue +6 -4
  54. package/app/components/content/TabGroup.vue +13 -12
  55. package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
  56. package/app/components/ui/command/CommandShortcut.vue +1 -1
  57. package/app/components/ui/dialog/DialogContent.vue +2 -2
  58. package/app/components/ui/dialog/DialogHeader.vue +1 -1
  59. package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
  60. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
  61. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  62. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
  63. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
  64. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
  65. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
  66. package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
  67. package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
  68. package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
  69. package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
  70. package/app/components/ui/select/SelectItem.vue +2 -2
  71. package/app/components/ui/sheet/SheetContent.vue +29 -3
  72. package/app/components/ui/sidebar/Sidebar.vue +28 -4
  73. package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  74. package/app/components/ui/sidebar/SidebarInset.vue +1 -1
  75. package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  76. package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  77. package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
  78. package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  79. package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
  80. package/app/components/ui/sidebar/index.ts +10 -2
  81. package/app/components/ui/sidebar/utils.ts +0 -1
  82. package/app/components/ui/sonner/Sonner.vue +18 -0
  83. package/app/components/ui/tabs/TabsList.vue +13 -3
  84. package/app/components/ui/tabs/TabsTrigger.vue +24 -3
  85. package/app/components/ui/tooltip/TooltipContent.vue +2 -2
  86. package/app/composables/useActiveHeading.ts +3 -25
  87. package/app/composables/useDuxtAnalytics.ts +50 -0
  88. package/app/composables/useDuxtAnimatedHeight.ts +8 -38
  89. package/app/composables/useDuxtAnnouncements.ts +153 -0
  90. package/app/composables/useDuxtCopy.ts +51 -0
  91. package/app/composables/useDuxtDirection.ts +23 -0
  92. package/app/composables/useDuxtNavigation.ts +28 -8
  93. package/app/composables/useDuxtPath.ts +3 -5
  94. package/app/composables/useDuxtSearch.ts +87 -35
  95. package/app/composables/useDuxtSection.ts +15 -1
  96. package/app/composables/useDuxtSectionRow.ts +6 -5
  97. package/app/composables/useDuxtShortcuts.ts +319 -20
  98. package/app/composables/useDuxtVersion.ts +57 -6
  99. package/app/composables/useDuxtViewportMeasure.ts +37 -0
  100. package/app/composables/useFuzzySearch.ts +9 -4
  101. package/app/composables/useRecentPages.ts +14 -17
  102. package/app/error.vue +112 -74
  103. package/app/layouts/changelog.vue +9 -1
  104. package/app/layouts/default.vue +9 -0
  105. package/app/layouts/docs.vue +9 -1
  106. package/app/layouts/reference.vue +9 -1
  107. package/app/middleware/duxt-section-layout.global.ts +1 -3
  108. package/app/pages/[...slug].vue +146 -35
  109. package/app/pages/index.vue +15 -23
  110. package/app/types/duxt.d.ts +312 -4
  111. package/app/utils/analytics.ts +91 -0
  112. package/app/utils/announcements.ts +205 -0
  113. package/app/utils/changelog.ts +0 -28
  114. package/app/utils/code-highlight.ts +3 -1
  115. package/app/utils/contributor-avatar.ts +27 -0
  116. package/app/utils/direction.ts +43 -0
  117. package/app/utils/duxt-config.ts +43 -0
  118. package/app/utils/duxt-text.ts +10 -0
  119. package/app/utils/format-date.ts +28 -0
  120. package/app/utils/generated-sections.ts +68 -12
  121. package/app/utils/generated-toc.ts +14 -7
  122. package/app/utils/locale-path.ts +43 -0
  123. package/app/utils/navigation-tree.ts +51 -1
  124. package/app/utils/nearest-page.ts +8 -0
  125. package/app/utils/openapi.ts +16 -11
  126. package/app/utils/page-controls.ts +215 -0
  127. package/app/utils/pill.ts +61 -0
  128. package/app/utils/prose-image.ts +128 -0
  129. package/app/utils/search-context.ts +110 -0
  130. package/app/utils/search-display.ts +248 -0
  131. package/app/utils/section-scope.ts +65 -4
  132. package/app/utils/stored-json.ts +31 -0
  133. package/app/utils/version-choices.ts +6 -13
  134. package/app/utils/version-paths.ts +21 -0
  135. package/bin/duxt-cache-key.mjs +61 -0
  136. package/bin/duxt-og-cache.mjs +58 -0
  137. package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
  138. package/bruno-model.ts +169 -0
  139. package/bruno-parse.ts +656 -0
  140. package/bruno-zip.ts +195 -0
  141. package/cli.ts +240 -0
  142. package/duxt-app-config.ts +18 -1
  143. package/frontmatter.ts +49 -0
  144. package/git-contributors.ts +322 -0
  145. package/i18n/locales/de/duxt/announcement.json +7 -0
  146. package/i18n/locales/de/duxt/bruno.json +25 -0
  147. package/i18n/locales/de/duxt/changelog.json +1 -1
  148. package/i18n/locales/de/duxt/code.json +2 -1
  149. package/i18n/locales/de/duxt/page.json +9 -0
  150. package/i18n/locales/de/duxt/shortcuts.json +1 -0
  151. package/i18n/locales/de/duxt/theme.json +2 -1
  152. package/i18n/locales/en/duxt/announcement.json +7 -0
  153. package/i18n/locales/en/duxt/bruno.json +25 -0
  154. package/i18n/locales/en/duxt/changelog.json +1 -1
  155. package/i18n/locales/en/duxt/code.json +2 -1
  156. package/i18n/locales/en/duxt/page.json +9 -0
  157. package/i18n/locales/en/duxt/shortcuts.json +1 -0
  158. package/i18n/locales/en/duxt/theme.json +2 -1
  159. package/i18n/locales/es/duxt/announcement.json +7 -0
  160. package/i18n/locales/es/duxt/bruno.json +25 -0
  161. package/i18n/locales/es/duxt/changelog.json +1 -1
  162. package/i18n/locales/es/duxt/code.json +2 -1
  163. package/i18n/locales/es/duxt/page.json +9 -0
  164. package/i18n/locales/es/duxt/shortcuts.json +1 -0
  165. package/i18n/locales/es/duxt/theme.json +2 -1
  166. package/i18n/locales/fr/duxt/announcement.json +7 -0
  167. package/i18n/locales/fr/duxt/bruno.json +25 -0
  168. package/i18n/locales/fr/duxt/changelog.json +1 -1
  169. package/i18n/locales/fr/duxt/code.json +2 -1
  170. package/i18n/locales/fr/duxt/page.json +9 -0
  171. package/i18n/locales/fr/duxt/shortcuts.json +1 -0
  172. package/i18n/locales/fr/duxt/theme.json +2 -1
  173. package/i18n/locales/pt/duxt/announcement.json +7 -0
  174. package/i18n/locales/pt/duxt/bruno.json +25 -0
  175. package/i18n/locales/pt/duxt/changelog.json +1 -1
  176. package/i18n/locales/pt/duxt/code.json +2 -1
  177. package/i18n/locales/pt/duxt/page.json +9 -0
  178. package/i18n/locales/pt/duxt/shortcuts.json +1 -0
  179. package/i18n/locales/pt/duxt/theme.json +2 -1
  180. package/modules/bruno.ts +118 -0
  181. package/modules/config.ts +26 -0
  182. package/modules/devtools.ts +2 -3
  183. package/modules/git-meta.ts +16 -47
  184. package/modules/search-records.ts +132 -0
  185. package/nuxt.config.ts +81 -7
  186. package/og-image-cache.ts +742 -0
  187. package/package.json +41 -9
  188. package/public/devtools/checks.html +2 -2
  189. package/public/devtools/config.html +59 -4
  190. package/public/devtools/pages.html +8 -1
  191. package/public/devtools/paths.html +1 -1
  192. package/public/devtools/sources.html +2 -2
  193. package/public/devtools/versions.html +3 -0
  194. package/report.ts +28 -19
  195. package/search-records.ts +380 -0
  196. package/section-input.ts +142 -0
  197. package/section-markdown.ts +67 -0
  198. package/section-reports.ts +10 -4
  199. package/sections-bruno.ts +571 -0
  200. package/sections-changelog.ts +76 -43
  201. package/sections-openapi.ts +12 -83
  202. package/sections-resolve.ts +174 -8
  203. package/sections.ts +33 -10
  204. package/server/devtools/shell.ts +1 -1
  205. package/server/mcp/tools/list-pages.ts +77 -36
  206. package/server/mcp/tools/list-versions.ts +84 -0
  207. package/server/mcp/tools/read-page.ts +80 -26
  208. package/server/mcp/tools/search-docs.ts +87 -53
  209. package/server/middleware/raw-markdown.ts +1 -1
  210. package/server/routes/llms-full.txt.get.ts +6 -8
  211. package/server/routes/llms.txt.get.ts +1 -1
  212. package/server/utils/duxt-server-text.ts +0 -13
  213. package/server/utils/llms-pages.ts +24 -9
  214. package/server/utils/mcp-docs.ts +577 -0
  215. package/sources-cache.ts +376 -0
  216. package/sources-git.ts +186 -31
  217. package/sources-resolve.ts +59 -1
  218. package/sources.ts +122 -13
  219. package/tfplugindocs.ts +158 -0
@@ -206,6 +206,10 @@ declare global {
206
206
  * then share the default URL prefix of a versioned documentation source.
207
207
  */
208
208
  content?: boolean;
209
+ /** Per-source conventions for files that are not documentation pages. */
210
+ exclude?: { drafts?: string; partials?: string };
211
+ /** A layer-owned Markdown dialect, such as tfplugindocs. */
212
+ flavor?: 'tfplugindocs';
209
213
  /** `owner/name` or a full git URL. Omitted means this repository. */
210
214
  repo?: string;
211
215
  /** Refs to publish as versions. Omitted means the current checkout. */
@@ -238,6 +242,23 @@ declare global {
238
242
  version?: string;
239
243
  /** Shown in the version switcher and used in the URL; defaults to the ref. */
240
244
  label?: DuxtText;
245
+ /**
246
+ * What to CALL this source where the site names it to a reader — today the
247
+ * search dialog's result captions.
248
+ *
249
+ * DISPLAY ONLY. It reaches no collection name, no URL prefix, no version
250
+ * and no ranking, which is what separates it from `label` and `slug`: those
251
+ * two are addresses that happen to be readable, and a translated address is
252
+ * not a stable URL. This is a name and nothing else, so it may be prose and
253
+ * it may be translated.
254
+ *
255
+ * Unset, the name falls back to the source's own segment and then to
256
+ * `duxt.title` — itself the localized "Documentation" label on a site that
257
+ * sets no title. Worth setting where the segment is an abbreviation (`tf`,
258
+ * `sdk`, `api`): a result captioned with one tells a reader which URL they
259
+ * are in rather than which project they are about to open.
260
+ */
261
+ name?: DuxtText;
241
262
  /**
242
263
  * Segment used in the URL for this source; defaults to the repository name.
243
264
  *
@@ -404,7 +425,7 @@ declare global {
404
425
  * The layout its pages render in. A name bound here is public surface.
405
426
  *
406
427
  * A function where the declaration's own options decide it — a changelog
407
- * split into a page per release draws chrome of its own, the same file
428
+ * split into a page per release draws a layout of its own, the same file
408
429
  * rendered whole is an ordinary docs page.
409
430
  */
410
431
  layout?:
@@ -434,6 +455,8 @@ declare global {
434
455
  prefix: string;
435
456
  repo?: string;
436
457
  version?: string;
458
+ /** The source's display name, as written — see `DuxtSourceInput.name`. */
459
+ name?: DuxtText;
437
460
  isDefault: boolean;
438
461
  /** Where the pages came from — what "Edit this page" links back to. */
439
462
  repository?: string;
@@ -447,6 +470,8 @@ declare global {
447
470
  isDefaultLocale: boolean;
448
471
  status: DuxtSourceStatusInput;
449
472
  history: boolean;
473
+ /** The layer-owned Markdown dialect this collection is normalised from. */
474
+ flavor?: 'tfplugindocs';
450
475
  /**
451
476
  * Present when this collection is a generated section rather than a docs
452
477
  * tree. `path` is then the artefact itself rather than a folder.
@@ -501,6 +526,77 @@ declare global {
501
526
  pageIcon?: string;
502
527
  }
503
528
 
529
+ /**
530
+ * Where an announcement is drawn.
531
+ *
532
+ * Three, because they answer three different questions about how loud a
533
+ * notice is. `above-header` sits at the browser edge over everything, which
534
+ * is what a release or an outage wants; `below-header` keeps the navbar at
535
+ * the top of the window and puts the notice over the sidebars and the page;
536
+ * `above-content` is quietest — it sits in the document column with the
537
+ * version and translation banners, and scrolls with the page.
538
+ */
539
+ type DuxtAnnouncementPlacement =
540
+ | 'above-header'
541
+ | 'below-header'
542
+ | 'above-content';
543
+
544
+ /**
545
+ * How many announcements a placement may draw at once.
546
+ *
547
+ * Three shapes for three answers: a number caps EVERY placement at that many
548
+ * — one release notice above the header and one outage below it, not one
549
+ * between them — an object caps each placement on its own, and `null` (or no
550
+ * value at all) means unlimited. A placement the object says nothing about is
551
+ * unlimited too.
552
+ *
553
+ * The cap is on what is DRAWN, not on what is due: dismissing the notice at
554
+ * the top of a capped placement lets the next one take its slot, so a queue
555
+ * of announcements is read rather than silently dropped.
556
+ */
557
+ type DuxtAnnouncementMaxVisible =
558
+ | number
559
+ | null
560
+ | Partial<Record<DuxtAnnouncementPlacement, number | null>>;
561
+
562
+ /**
563
+ * One site-wide notice, drawn around the page rather than in it.
564
+ *
565
+ * Deliberately NOT the version and translation banners beside it: those
566
+ * describe the page a reader is on and are the layer's to decide. This is the
567
+ * site's own sentence — a release, a migration window, a maintenance
568
+ * notice — and duxt knows nothing about it until a consumer writes one.
569
+ */
570
+ interface DuxtAnnouncement {
571
+ /**
572
+ * The dismissal identity, and the only reason to set one.
573
+ *
574
+ * Locale-independent, so a reader who dismissed the notice in English does
575
+ * not meet it again in German. Omit it and duxt derives the identity from
576
+ * the announcement's own content — see `duxtAnnouncementKey` — which is the
577
+ * right default: rewriting the sentence IS a new announcement. Set it where
578
+ * a typo may be corrected without the notice counting as new, and change it
579
+ * to make an otherwise unchanged announcement visible again.
580
+ */
581
+ id?: string;
582
+ text: DuxtText;
583
+ /** One call to action. More than one belongs on the page it links to. */
584
+ link?: DuxtLink;
585
+ /** Where the notice is drawn. Defaults to `above-header`. */
586
+ placement?: DuxtAnnouncementPlacement;
587
+ /**
588
+ * When the notice starts and stops showing, as anything `Date` parses —
589
+ * `2026-03-01`, or a full ISO timestamp with an offset.
590
+ *
591
+ * Read in the BROWSER, against the reader's own clock, so an announcement
592
+ * opens and closes on time without a rebuild and without the page being
593
+ * reloaded. A date with no time zone is local to the reader, which is
594
+ * usually what a maintenance window means; write an offset where it is not.
595
+ */
596
+ startsAt?: string;
597
+ endsAt?: string;
598
+ }
599
+
504
600
  interface DuxtAction extends DuxtLink {
505
601
  variant?:
506
602
  | 'default'
@@ -589,8 +685,9 @@ declare global {
589
685
  * A CONFIG FIELD rather than one of the layer's own strings, and the line
590
686
  * between the two is whether a reader sees it. `featuresTitle` is shipped
591
687
  * because it is `sr-only` — a name the document outline needs and nobody
592
- * reads. "And the rest" is prose on the page, and prose on the page belongs
593
- * to the site that wrote the list underneath it.
688
+ * reads. The heading over the supporting capabilities is prose on the page,
689
+ * and prose on the page belongs to the site that wrote the list underneath
690
+ * it.
594
691
  *
595
692
  * Unset, the list draws no heading and its entries are paragraphs rather
596
693
  * than headings, so the outline gains nothing to explain.
@@ -763,7 +860,11 @@ declare global {
763
860
  interface DuxtDemoFile {
764
861
  /** The tab's label and the block's header — a file name, or a command. */
765
862
  name?: string;
766
- /** A grammar the RUNTIME highlighter carries: bash, json, typescript. */
863
+ /**
864
+ * A Shiki language id. Highlighted at runtime: the build adds every
865
+ * language a showcase names to the grammars it ships. A name Shiki has no
866
+ * grammar for (`text`) renders as plain text.
867
+ */
767
868
  language?: string;
768
869
  code: string;
769
870
  }
@@ -809,6 +910,132 @@ declare global {
809
910
  alt?: DuxtText;
810
911
  }
811
912
 
913
+ /**
914
+ * What every analytics event says about WHERE it happened, and nothing else.
915
+ *
916
+ * A closed shape rather than a free record, because this is the privacy
917
+ * surface: a reader of `duxt.analytics.track` has to be able to see, in one
918
+ * place, the whole of what duxt is willing to say about them. Nothing here
919
+ * identifies a browser or a person, and nothing is added per event — the
920
+ * event's own fields carry what it is, this carries where it was.
921
+ *
922
+ * Nested rather than spread into each event on purpose: `api-request` has a
923
+ * `path` of its own — the operation's declared template — and flattening the
924
+ * two would put the page and the endpoint under one name.
925
+ */
926
+ interface DuxtAnalyticsContext {
927
+ /** The documentation path, without the locale prefix, query or fragment. */
928
+ path: string;
929
+ /** The locale the page was read in. */
930
+ locale: string;
931
+ /** Which source the page came out of, where the site has more than one. */
932
+ source?: string;
933
+ /** Which version, where the source has more than one. */
934
+ version?: string;
935
+ }
936
+
937
+ /** The class of an HTTP status, which is all an event reports of one. */
938
+ type DuxtAnalyticsStatusClass = '1xx' | '2xx' | '3xx' | '4xx' | '5xx';
939
+
940
+ /** Which layer-owned code example was copied. */
941
+ type DuxtAnalyticsCopyKind =
942
+ | 'code'
943
+ | 'code-group'
944
+ | 'package-manager'
945
+ | 'request-sample';
946
+
947
+ /**
948
+ * One settled search.
949
+ *
950
+ * Emitted once per debounced, non-empty query that actually settled — a run
951
+ * the reader typed past never arrives, because its results were never shown.
952
+ * A search that found nothing is emitted like any other: "no result" is the
953
+ * finding a documentation site most needs.
954
+ */
955
+ interface DuxtAnalyticsSearchEvent {
956
+ name: 'search';
957
+ /** The term as the reader typed it. */
958
+ query: string;
959
+ /** How many hits were shown. */
960
+ results: number;
961
+ /** True where full-text found nothing and these are near-misses. */
962
+ approximate: boolean;
963
+ }
964
+
965
+ /** A hit the reader opened, by position rather than by content. */
966
+ interface DuxtAnalyticsSearchResultEvent {
967
+ name: 'search-result';
968
+ /** The term the hit was found with. */
969
+ query: string;
970
+ /** Where it sat in the ranked list, counting from one. */
971
+ rank: number;
972
+ /** The documentation path it leads to — never its rendered text. */
973
+ to: string;
974
+ /** Which collection it came out of, where a site has more than one. */
975
+ collection?: string;
976
+ }
977
+
978
+ /**
979
+ * A copied code example.
980
+ *
981
+ * The copied TEXT is never carried. "Copy page as Markdown" is not in this
982
+ * family at all and emits nothing: it is the page, not an example in it.
983
+ */
984
+ interface DuxtAnalyticsCopyEvent {
985
+ name: 'copy';
986
+ kind: DuxtAnalyticsCopyKind;
987
+ /** The example's language, where the block declares one. */
988
+ language?: string;
989
+ /** Which manager a command block was copied for. */
990
+ manager?: DuxtPackageManager;
991
+ /** Which request sample — its configured id, never its code. */
992
+ sample?: string;
993
+ }
994
+
995
+ /**
996
+ * A try-it request, after it settled.
997
+ *
998
+ * Everything here is DECLARED — the operation as the document names it —
999
+ * never what the reader typed: not the server they chose, not the concrete
1000
+ * URL, not a header, not a credential, not a body. The client's whole promise
1001
+ * is that a token stays in the component, and an event carrying one would
1002
+ * break it from the other side.
1003
+ */
1004
+ interface DuxtAnalyticsApiRequestEvent {
1005
+ name: 'api-request';
1006
+ /** The document's own `operationId`, where it declares one. */
1007
+ operation?: string;
1008
+ /** The declared method. */
1009
+ method: string;
1010
+ /** The declared path template — `/pets/{id}`, not the URL that was called. */
1011
+ path: string;
1012
+ /** Whether the request came back at all. */
1013
+ outcome: 'response' | 'failed';
1014
+ /** The class of the status, absent where nothing came back. */
1015
+ statusClass?: DuxtAnalyticsStatusClass;
1016
+ /** Milliseconds the request itself took, without the button's own floor. */
1017
+ duration: number;
1018
+ }
1019
+
1020
+ /** The answer to "Was this page helpful?". */
1021
+ interface DuxtAnalyticsFeedbackEvent {
1022
+ name: 'feedback';
1023
+ helpful: boolean;
1024
+ }
1025
+
1026
+ /** An event before `useDuxtAnalytics` stamps the context onto it. */
1027
+ type DuxtAnalyticsEventInput =
1028
+ | DuxtAnalyticsSearchEvent
1029
+ | DuxtAnalyticsSearchResultEvent
1030
+ | DuxtAnalyticsCopyEvent
1031
+ | DuxtAnalyticsApiRequestEvent
1032
+ | DuxtAnalyticsFeedbackEvent;
1033
+
1034
+ /** What `duxt.analytics.track` is handed. Discriminated by `name`. */
1035
+ type DuxtAnalyticsEvent = DuxtAnalyticsEventInput & {
1036
+ context: DuxtAnalyticsContext;
1037
+ };
1038
+
812
1039
  interface DuxtConfig {
813
1040
  title: DuxtText;
814
1041
  /**
@@ -863,6 +1090,87 @@ declare global {
863
1090
  pageIcon?: string;
864
1091
  /** Shown as a badge beside the title. */
865
1092
  version?: string;
1093
+ /** Model hand-off entries, in display order. */
1094
+ copy?: { models?: { label: DuxtText; icon: string; url: string }[] };
1095
+ /** URL template for contributor avatars; `{username}` is replaced. */
1096
+ contributors?: { avatarUrl?: string };
1097
+ /** Generated-outline and active-heading controls. */
1098
+ toc?: { depth?: number; scrollOffset?: number };
1099
+ /** Search behaviour that depends on a site's corpus. */
1100
+ search?: {
1101
+ fuzzy?: {
1102
+ threshold?: number;
1103
+ minMatchCharLength?: number;
1104
+ limit?: number;
1105
+ };
1106
+ /** `0` disables the visible recent-pages list. */
1107
+ recentPages?: number;
1108
+ };
1109
+ /** Separate limits for derived examples and schema-tree rendering. */
1110
+ openapi?: { exampleDepth?: number; schemaDepth?: number };
1111
+ /**
1112
+ * What the site allows duxt to bind globally.
1113
+ *
1114
+ * Not the bindings themselves: those are the layer's, so the sheet, the
1115
+ * handlers and the guards cannot come to disagree about them.
1116
+ */
1117
+ shortcuts?: {
1118
+ /**
1119
+ * Whether a bare keystroke — `?`, `[`, `]` — is bound at all.
1120
+ *
1121
+ * `false` leaves `⌘/Ctrl+K`. Worth turning off where a site's own pages
1122
+ * put something under an unmodified character, or where readers arrive
1123
+ * through switch access, voice control or dictation — all of which emit
1124
+ * bare characters the reader never meant as a command.
1125
+ */
1126
+ singleCharacter?: boolean;
1127
+ };
1128
+ /**
1129
+ * Site-wide notices, in the order they are drawn.
1130
+ *
1131
+ * Empty by default: an announcement is the site's own sentence, and a layer
1132
+ * that shipped one would put somebody else's words at the top of every page
1133
+ * — the same rule `sections` and `links` follow.
1134
+ *
1135
+ * A list, not a single notice, because a release and a maintenance window
1136
+ * are two announcements and one of them ending should not take the other
1137
+ * with it. `announcementOptions.maxVisible` is how a site keeps that list
1138
+ * from stacking up on screen.
1139
+ */
1140
+ announcements?: DuxtAnnouncement[];
1141
+ /** How the list above is drawn, as opposed to what is in it. */
1142
+ announcementOptions?: {
1143
+ /** How many a placement may show at once. Unlimited unless set. */
1144
+ maxVisible?: DuxtAnnouncementMaxVisible;
1145
+ };
1146
+ /**
1147
+ * Where reader interactions go, if a site wants them anywhere.
1148
+ *
1149
+ * OFF UNTIL A SITE WRITES `track`, and off is the whole default. duxt ships
1150
+ * no provider, no SDK, no script and no adapter; it transmits nothing,
1151
+ * buffers nothing, persists nothing, sets no cookie and asks for no
1152
+ * consent — because a documentation layer cannot know what a consumer's
1153
+ * privacy policy promises, and a theme that phoned home by default is not
1154
+ * one to publish (ADR 0005, and the same reason `DuxtPageFeedback` has an
1155
+ * event and no backend).
1156
+ *
1157
+ * So this is one function and nothing else. The site's `track` is the only
1158
+ * thing that ever leaves the page, which puts the consent gate, the
1159
+ * provider and the decision to send at all on the side that owns them.
1160
+ */
1161
+ analytics?: {
1162
+ /**
1163
+ * Called once per reader interaction duxt reports.
1164
+ *
1165
+ * Runs in the BROWSER only — a build and a prerender emit nothing, so a
1166
+ * static render never calls a site's analytics with a page nobody read.
1167
+ *
1168
+ * Never awaited and never retried: whatever it throws or rejects with is
1169
+ * swallowed, because a failed analytics call must not take the search,
1170
+ * the copy, the feedback or the request down with it.
1171
+ */
1172
+ track?: (event: DuxtAnalyticsEvent) => unknown;
1173
+ };
866
1174
  navigation?: DuxtLink[];
867
1175
  /** The second navbar row: top-level parts of the documentation. */
868
1176
  sections?: DuxtSection[];
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Where the reader was, in the four fields an event is allowed to say it with.
3
+ *
4
+ * The NORMALISATION is the point, not the assembly. A path reaches this from a
5
+ * router, and a router's paths carry query strings and fragments — which is
6
+ * where a search term, a campaign id, a session token and the heading someone
7
+ * scrolled to all live. None of that is "which page", so none of it survives
8
+ * here, once, rather than at each of the six call sites that build an event.
9
+ *
10
+ * `source` and `version` are OMITTED rather than set undefined: a site with one
11
+ * source has no answer, and a key present-but-undefined is a key a provider
12
+ * serialises as null and then charges a column for.
13
+ */
14
+ export function duxtAnalyticsContext(
15
+ path: string,
16
+ locale: string,
17
+ source?: { repo?: string; version?: string }
18
+ ): DuxtAnalyticsContext {
19
+ return {
20
+ path: path.split(/[?#]/)[0] || '/',
21
+ locale,
22
+ ...(source?.repo ? { source: source.repo } : {}),
23
+ ...(source?.version ? { version: source.version } : {})
24
+ };
25
+ }
26
+
27
+ /**
28
+ * A status as its class, which is as far as an event is allowed to go.
29
+ *
30
+ * The exact code belongs to the reader's own request — their token, their id,
31
+ * their typo — and a documentation site learns nothing from 403 against 404
32
+ * that `4xx` does not already tell it. Undefined outside the five classes,
33
+ * because a request that produced no status produced no class either.
34
+ */
35
+ export function duxtStatusClass(
36
+ status: number
37
+ ): DuxtAnalyticsStatusClass | undefined {
38
+ const leading = Math.floor(status / 100);
39
+
40
+ return leading >= 1 && leading <= 5
41
+ ? (`${leading}xx` as DuxtAnalyticsStatusClass)
42
+ : undefined;
43
+ }
44
+
45
+ /** Does nothing, on purpose — the rejection handler that drops the reason. */
46
+ const ignore = () => undefined;
47
+
48
+ /**
49
+ * A value with a `then`, which is all that has to be true to reject.
50
+ *
51
+ * Duck-typed rather than `instanceof Promise`: a site may hand back its
52
+ * provider's own thenable, or a promise from another realm, and both reject
53
+ * exactly as unhandled as a native one.
54
+ */
55
+ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
56
+ typeof (value as PromiseLike<unknown> | undefined)?.then === 'function';
57
+
58
+ /**
59
+ * Hand one event to the site's callback, and never let it back out.
60
+ *
61
+ * The whole of duxt's analytics transport. There is no queue, no batch, no
62
+ * retry and no network call — a site's `track` is the only thing that ever
63
+ * leaves the page, which is what keeps the provider, the consent gate and the
64
+ * decision to transmit at all on the side that owns them.
65
+ *
66
+ * Nothing it does reaches the caller: the reader pressed copy, searched, or
67
+ * sent a request, and a provider that is down, missing a key or throwing on a
68
+ * malformed property must not take that interaction with it.
69
+ */
70
+ export function emitDuxtAnalytics(
71
+ track: ((event: DuxtAnalyticsEvent) => unknown) | undefined,
72
+ event: DuxtAnalyticsEvent
73
+ ): void {
74
+ if (typeof track !== 'function') return;
75
+
76
+ try {
77
+ const returned: unknown = track(event);
78
+
79
+ // A provider SDK almost always hands back a promise, and one that rejects
80
+ // with nothing attached is an unhandled rejection — logged by every
81
+ // browser, and fatal to a Node process configured to treat it so. Caught
82
+ // here rather than left to the site: `track` is called for its effect and
83
+ // its result is never read, so there is nowhere else a handler could go.
84
+ if (isThenable(returned)) {
85
+ returned.then(undefined, ignore);
86
+ }
87
+ } catch {
88
+ // Deliberately silent, and silent in both halves: the reader pressed copy,
89
+ // not report-that-I-copied.
90
+ }
91
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Which announcement a reader dismissed, and which are due to be shown.
3
+ *
4
+ * Pure on purpose: the browser is what evaluates an announcement's time window
5
+ * and what remembers a dismissal, so every one of these answers depends on a
6
+ * clock and a set that only exist client-side. Keeping the arithmetic here
7
+ * rather than in the component is what lets it be tested at all — see
8
+ * `tests/announcements.test.ts`.
9
+ */
10
+
11
+ /**
12
+ * Where an announcement that names no placement goes.
13
+ *
14
+ * The loudest of the three, because the announcements a site actually writes
15
+ * are releases and outages — a notice that has to be configured before it says
16
+ * anything may as well default to being seen.
17
+ */
18
+ export const DUXT_ANNOUNCEMENT_PLACEMENT: DuxtAnnouncementPlacement =
19
+ 'above-header';
20
+
21
+ /**
22
+ * A value written the same way however its object literal was typed.
23
+ *
24
+ * Keys sorted, because a locale record is an object and object key order is
25
+ * whatever the config file happened to use: a consumer alphabetising
26
+ * `{ de, en }` into `{ en, de }` must not resurrect a notice every reader has
27
+ * already dismissed.
28
+ */
29
+ function canonical(value: unknown): string {
30
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
31
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
32
+
33
+ const entries = Object.entries(value as Record<string, unknown>)
34
+ .filter(([, entry]) => entry !== undefined)
35
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
36
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`);
37
+
38
+ return `{${entries.join(',')}}`;
39
+ }
40
+
41
+ /**
42
+ * FNV-1a, as a short base-36 string.
43
+ *
44
+ * Not a cryptographic hash and not trying to be: the worst a collision can do
45
+ * is leave one announcement hidden for a reader who dismissed another, and the
46
+ * alternative — `crypto.subtle.digest` — is asynchronous, which a computed
47
+ * property cannot be.
48
+ */
49
+ function fingerprint(value: string): string {
50
+ let hash = 0x81_1c_9d_c5;
51
+
52
+ for (let index = 0; index < value.length; index += 1) {
53
+ hash ^= value.charCodeAt(index);
54
+ // The FNV prime, by shifts: `hash * 16777619` overflows a double's exact
55
+ // integer range and starts losing the low bits it exists to mix.
56
+ hash = Math.imul(hash, 0x01_00_01_93);
57
+ }
58
+
59
+ return (hash >>> 0).toString(36);
60
+ }
61
+
62
+ /**
63
+ * A configured bound as a timestamp, or nothing.
64
+ *
65
+ * An unparseable date is treated as ABSENT rather than as "never": a typo in
66
+ * `endsAt` then leaves the notice showing, which somebody notices, instead of
67
+ * silently swallowing the announcement it was supposed to close.
68
+ */
69
+ function boundary(value: string | undefined): number | undefined {
70
+ if (!value) return undefined;
71
+
72
+ const parsed = Date.parse(value);
73
+
74
+ return Number.isNaN(parsed) ? undefined : parsed;
75
+ }
76
+
77
+ /**
78
+ * Is this announcement inside its window at `now`?
79
+ *
80
+ * Half-open: it shows FROM `startsAt` and UNTIL `endsAt`, so a window ending
81
+ * where the next one begins never draws both for the same millisecond. No
82
+ * window at all means always.
83
+ */
84
+ export function duxtAnnouncementActive(
85
+ announcement: DuxtAnnouncement,
86
+ now: number
87
+ ): boolean {
88
+ const starts = boundary(announcement.startsAt);
89
+ const ends = boundary(announcement.endsAt);
90
+
91
+ return (
92
+ (starts === undefined || now >= starts) &&
93
+ (ends === undefined || now < ends)
94
+ );
95
+ }
96
+
97
+ /**
98
+ * The identity a dismissal is remembered under.
99
+ *
100
+ * A configured `id` wins and is the whole answer. Without one the identity is
101
+ * the announcement's CONTENT — every locale of it at once, so switching
102
+ * language keeps the dismissal, and any edit to the wording makes it a new
103
+ * announcement every reader is shown again.
104
+ */
105
+ export function duxtAnnouncementKey(announcement: DuxtAnnouncement): string {
106
+ if (announcement.id) return announcement.id;
107
+
108
+ // The CONTENT, and nothing else: `placement` moves the same sentence and a
109
+ // window only says how long it runs, so neither makes a notice new.
110
+ return fingerprint(
111
+ canonical({ text: announcement.text, link: announcement.link })
112
+ );
113
+ }
114
+
115
+ /** What a placement is asked for: the reader's clock, and what they dismissed. */
116
+ export interface DuxtVisibleAnnouncementsOptions {
117
+ placement: DuxtAnnouncementPlacement;
118
+ now: number;
119
+ dismissed: readonly string[];
120
+ maxVisible?: DuxtAnnouncementMaxVisible;
121
+ }
122
+
123
+ /**
124
+ * This placement's cap, or `undefined` for no cap.
125
+ *
126
+ * `null` and an absent key mean the same thing on purpose: a consumer lifting
127
+ * one placement's limit should not have to remember which of the two spellings
128
+ * the config reader happens to honour.
129
+ */
130
+ export function duxtAnnouncementLimit(
131
+ maxVisible: DuxtAnnouncementMaxVisible | undefined,
132
+ placement: DuxtAnnouncementPlacement
133
+ ): number | undefined {
134
+ if (maxVisible === undefined || maxVisible === null) return undefined;
135
+ if (typeof maxVisible === 'number') return maxVisible;
136
+
137
+ return maxVisible[placement] ?? undefined;
138
+ }
139
+
140
+ /**
141
+ * The announcements one placement draws, in the order they were configured.
142
+ *
143
+ * Order is the consumer's, never a ranking of ours: a list is read top to
144
+ * bottom, and a notice that moved because another one expired is a notice the
145
+ * reader has to find again.
146
+ */
147
+ export function duxtVisibleAnnouncements(
148
+ announcements: readonly DuxtAnnouncement[] | undefined,
149
+ options: DuxtVisibleAnnouncementsOptions
150
+ ): DuxtAnnouncement[] {
151
+ const dismissed = new Set(options.dismissed);
152
+
153
+ const due = (announcements ?? []).filter(
154
+ (announcement) =>
155
+ (announcement.placement ?? DUXT_ANNOUNCEMENT_PLACEMENT) ===
156
+ options.placement &&
157
+ duxtAnnouncementActive(announcement, options.now) &&
158
+ !dismissed.has(duxtAnnouncementKey(announcement))
159
+ );
160
+
161
+ const limit = duxtAnnouncementLimit(options.maxVisible, options.placement);
162
+
163
+ // AFTER the dismissals, not before: the cap is how many a reader looks at,
164
+ // so closing the top one promotes the next rather than retiring it unseen.
165
+ return limit === undefined ? due : due.slice(0, Math.max(0, limit));
166
+ }
167
+
168
+ /**
169
+ * When the configured list next changes what it would draw.
170
+ *
171
+ * A window has to open and close on the reader's clock, and the two ways to do
172
+ * that are to poll and to wait for the one moment that matters. This is the
173
+ * second: the component sleeps until this timestamp and re-reads then, so a
174
+ * page left open overnight is right in the morning without a timer ticking
175
+ * through the night.
176
+ *
177
+ * `undefined` where nothing is scheduled — which is the ordinary case, and
178
+ * costs no timer at all.
179
+ */
180
+ export function duxtNextAnnouncementChange(
181
+ announcements: readonly DuxtAnnouncement[] | undefined,
182
+ now: number
183
+ ): number | undefined {
184
+ let next: number | undefined;
185
+
186
+ for (const announcement of announcements ?? []) {
187
+ for (const bound of [
188
+ boundary(announcement.startsAt),
189
+ boundary(announcement.endsAt)
190
+ ]) {
191
+ // Strictly ahead: a boundary the reader has already passed changes
192
+ // nothing, and one exactly at `now` has already been accounted for by
193
+ // `duxtAnnouncementActive`.
194
+ if (
195
+ bound !== undefined &&
196
+ bound > now &&
197
+ (next === undefined || bound < next)
198
+ ) {
199
+ next = bound;
200
+ }
201
+ }
202
+ }
203
+
204
+ return next;
205
+ }