@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,12 +4,18 @@ import {
4
4
  sourcesForRoute,
5
5
  type DuxtResolvedSource
6
6
  } from '../../sources-resolve';
7
+ import { duxtPageSearchable } from '../../app/utils/page-controls';
7
8
 
8
9
  /** Enumerate public pages, selecting the same collection chain as HTML. */
9
10
  export async function llmsPages(
10
11
  event: H3Event,
11
- sources: DuxtResolvedSource[] = []
12
+ sources: DuxtResolvedSource[] = [],
13
+ includeRawbody = false
12
14
  ) {
15
+ // llms.txt is a map of the documentation a reader should start with. Older
16
+ // versions stay published at their own URLs, but making an agent choose
17
+ // between them repeats a decision the source manifest has already made.
18
+ const defaultSources = sources.filter((source) => source.isDefault);
13
19
  const i18n = (
14
20
  useRuntimeConfig(event).public as unknown as {
15
21
  i18n?: {
@@ -29,19 +35,28 @@ export async function llmsPages(
29
35
  const locales =
30
36
  strategy === 'no_prefix' || !codes.length ? [defaultLocale] : codes;
31
37
  const collections = sources.length
32
- ? [...new Set(sources.map((source) => source.collection))]
38
+ ? [...new Set(defaultSources.map((source) => source.collection))]
33
39
  : ['docs'];
40
+ // `search` travels with every row so the opt-out is applied once, here, and
41
+ // not in the two routes: llms.txt and llms-full.txt are one index in two
42
+ // shapes, and a page hidden from one but listed in the other is a bug nobody
43
+ // would go looking for.
44
+ const fields = includeRawbody
45
+ ? (['path', 'title', 'description', 'rawbody', 'search'] as const)
46
+ : (['path', 'title', 'description', 'search'] as const);
34
47
  const entries = await Promise.all(
35
48
  collections.map(
36
49
  async (name) =>
37
50
  [
38
51
  name,
39
- await queryCollection(
40
- event,
41
- name as Parameters<typeof queryCollection>[1]
42
- )
43
- .select('path', 'title', 'description', 'rawbody')
44
- .all()
52
+ (
53
+ await queryCollection(
54
+ event,
55
+ name as Parameters<typeof queryCollection>[1]
56
+ )
57
+ .select(...fields)
58
+ .all()
59
+ ).filter((page) => duxtPageSearchable(page))
45
60
  ] as const
46
61
  )
47
62
  );
@@ -63,7 +78,7 @@ export async function llmsPages(
63
78
  for (const locale of locales) {
64
79
  for (const path of paths) {
65
80
  const chain = sources.length
66
- ? sourcesForRoute(path, locale, sources, fallbackLocale).map(
81
+ ? sourcesForRoute(path, locale, defaultSources, fallbackLocale).map(
67
82
  (source) => source.collection
68
83
  )
69
84
  : ['docs'];
@@ -0,0 +1,577 @@
1
+ import { queryCollection } from '@nuxt/content/nitro';
2
+ import type { H3Event } from 'h3';
3
+ import { splitLocalePath } from '../../app/utils/locale-path';
4
+ import { isInside } from '../../app/utils/version-paths';
5
+ import {
6
+ localeChain,
7
+ sourcesForRoute,
8
+ type DuxtResolvedSource
9
+ } from '../../sources-resolve';
10
+ import { stripFrontmatter } from '../../frontmatter';
11
+
12
+ /**
13
+ * What the four MCP tools know about this site's documentation.
14
+ *
15
+ * Lifted out of the tools because all three of them opened with the SAME
16
+ * private `duxtCollections()` — a copy each, all three assuming one flat pile
17
+ * of pages. A versioned, translated site is not that: `/app` and `/app/v1` are
18
+ * different editions of one page, and asking every collection for a path
19
+ * answers with whichever sorted first. The tools now share one model of the
20
+ * manifest, and what an agent scopes by is a URL PREFIX — the thing it can
21
+ * also paste into a browser, rather than a collection name that exists nowhere
22
+ * else.
23
+ */
24
+
25
+ /**
26
+ * A site that declares no sources at all still has one collection.
27
+ *
28
+ * Content's own default is `docs`, and the three tools each open-coded that
29
+ * fallback. Synthesised as a real entry instead, so the scope, the locale
30
+ * chain and the page loader have one code path rather than two.
31
+ */
32
+ const BARE: DuxtResolvedSource = {
33
+ collection: 'docs',
34
+ prefix: '',
35
+ path: 'docs',
36
+ isDefault: true,
37
+ isDefaultLocale: true,
38
+ status: 'current',
39
+ history: false
40
+ };
41
+
42
+ /**
43
+ * The request behind an MCP tool call.
44
+ *
45
+ * NOT `extra.event`, which is what all four tools read for their first nine
46
+ * days and which has never existed. `@nuxtjs/mcp-toolkit` hands a tool handler
47
+ * the MCP SDK's own `RequestHandlerExtra` — `signal`, `requestId`,
48
+ * `sessionId`, `authInfo`, `requestInfo`, `sendNotification`, `sendRequest` —
49
+ * and nothing of H3's. The read was silently `undefined`, and the two things
50
+ * it was passed to both tolerate that: `queryCollection(undefined, …)` falls
51
+ * back to the global `$fetch` and `useRuntimeConfig(undefined)` returns the
52
+ * global config. So `/mcp` answered, wrongly but visibly enough, until
53
+ * `duxtLocaleSetup` began dereferencing `event.context` for the i18n fallback
54
+ * — from which point EVERY tool call answered `Cannot read properties of
55
+ * undefined (reading 'context')` as an `isError` result. Nothing caught it:
56
+ * the unit tests hand the handlers an `extra` of their own invention with an
57
+ * `event` on it, and `pnpm check` probes no MCP endpoint at all.
58
+ *
59
+ * `useEvent()` is how the toolkit itself reaches the request — `useMcpServer`,
60
+ * `useMcpSession`, its logger and its notifier all call it — and it is why the
61
+ * layer turns `nitro.experimental.asyncContext` on in `nuxt.config.ts`.
62
+ *
63
+ * Having the REAL event, rather than teaching the tools to live without one,
64
+ * is the point: the four tools then read the database through exactly the code
65
+ * path `llms.txt` and the `.md` middleware take — `event.$fetch` and the
66
+ * request's own i18n fallback — instead of a second one that only `/mcp` runs
67
+ * and that only a Worker would have disagreed with.
68
+ */
69
+ export function duxtMcpEvent(): H3Event {
70
+ return useEvent();
71
+ }
72
+
73
+ /** The resolved manifest, or the single collection a bare site publishes. */
74
+ export function duxtSources(): DuxtResolvedSource[] {
75
+ const { duxt } = useAppConfig() as { duxt?: Partial<DuxtConfig> };
76
+ const sources = duxt?.resolvedSources ?? [];
77
+
78
+ return sources.length ? sources : [BARE];
79
+ }
80
+
81
+ /** How this site turns a locale into a URL segment. */
82
+ export interface DuxtLocaleSetup {
83
+ /** The codes the site serves, in declaration order. */
84
+ codes: string[];
85
+ /** The code served without a segment under `prefix_except_default`. */
86
+ defaultLocale?: string;
87
+ /** `@nuxtjs/i18n`'s routing strategy. */
88
+ strategy: string;
89
+ /** What a missing translation falls back to before the original. */
90
+ fallbackLocale?: string | string[];
91
+ }
92
+
93
+ /**
94
+ * Read that off the request.
95
+ *
96
+ * The same three reads `llms-pages` and the `.md` middleware each do inline —
97
+ * runtime config for the locales and the strategy, the request context for the
98
+ * fallback, which Nuxt i18n only merges in its own request hook.
99
+ */
100
+ export function duxtLocaleSetup(event: H3Event): DuxtLocaleSetup {
101
+ const i18n = (
102
+ useRuntimeConfig(event).public as unknown as {
103
+ i18n?: {
104
+ locales?: (string | { code: string })[];
105
+ defaultLocale?: string;
106
+ strategy?: string;
107
+ };
108
+ }
109
+ ).i18n;
110
+ const codes = (i18n?.locales ?? []).map((locale) =>
111
+ typeof locale === 'string' ? locale : locale.code
112
+ );
113
+
114
+ return {
115
+ codes,
116
+ defaultLocale: i18n?.defaultLocale ?? codes[0],
117
+ strategy: i18n?.strategy ?? 'prefix_except_default',
118
+ fallbackLocale: event.context.nuxtI18n?.vueI18nOptions?.fallbackLocale as
119
+ | string
120
+ | string[]
121
+ | undefined
122
+ };
123
+ }
124
+
125
+ /**
126
+ * A documentation path as the site serves it, locale segment included.
127
+ *
128
+ * `prefix_except_default` is the interesting one: the default language keeps
129
+ * the bare path, every other language gets a segment in front of it. Same rule
130
+ * `llms-pages` applies when it enumerates the site for `llms.txt`.
131
+ */
132
+ export function duxtPublicPath(
133
+ path: string,
134
+ locale: string | undefined,
135
+ setup: DuxtLocaleSetup
136
+ ): string {
137
+ const prefixed =
138
+ locale &&
139
+ setup.strategy !== 'no_prefix' &&
140
+ (setup.strategy !== 'prefix_except_default' ||
141
+ locale !== setup.defaultLocale);
142
+
143
+ if (!prefixed) return path || '/';
144
+
145
+ return `/${locale}${path === '/' ? '' : path}`;
146
+ }
147
+
148
+ /** One language a version is published in, and the prefix that scopes it. */
149
+ export interface DuxtVersionLocale {
150
+ /** The locale code, as the site declares it. */
151
+ code: string;
152
+ /** The public prefix for this version in this language. */
153
+ prefix: string;
154
+ }
155
+
156
+ /** One edition of the documentation, as an agent chooses between them. */
157
+ export interface DuxtDocVersion {
158
+ /** The public prefix that scopes it — what `list_pages` takes. */
159
+ prefix: string;
160
+ /** Shown in the switcher: the version label, or the source's segment. */
161
+ label: string;
162
+ /** Where this version sits in its life. */
163
+ status: DuxtResolvedSource['status'];
164
+ /** True for the edition served without a version prefix. */
165
+ isDefault: boolean;
166
+ /** The repository the pages were read from. */
167
+ repository?: string;
168
+ /** The ref they were read at, and which namespace it lives in. */
169
+ ref?: string;
170
+ refKind?: 'branch' | 'tag';
171
+ /** The folder inside that repository. */
172
+ path: string;
173
+ /** Every language it is genuinely published in. */
174
+ locales: DuxtVersionLocale[];
175
+ }
176
+
177
+ /**
178
+ * Is this version a translation into `code`, or only reachable from it?
179
+ *
180
+ * The chain's head is the collection that answers a request in `code`. Where
181
+ * that head is the code itself or its language, the version is translated;
182
+ * where the chain fell through to a sibling region or the original, it is not
183
+ * — `en-US` reading `en-GB` through the fallback is not a US translation, and
184
+ * listing it as one tells an agent a page exists that nobody wrote.
185
+ */
186
+ function translatedInto(
187
+ code: string,
188
+ available: (string | undefined)[],
189
+ fallbackLocale: string | string[] | undefined
190
+ ): boolean {
191
+ const head = localeChain(code, available, fallbackLocale)[0];
192
+
193
+ return head === code || head === code.split('-')[0];
194
+ }
195
+
196
+ /**
197
+ * Every documentation version this site publishes.
198
+ *
199
+ * Grouped by PREFIX, because that is what identifies an edition: the languages
200
+ * of one version share it, and two versions never do. Generated sections are
201
+ * left out — a changelog or an API reference is a part of a version, not a
202
+ * version of its own, and it is reached by scoping to the version that owns it.
203
+ */
204
+ export function duxtDocVersions(
205
+ sources: DuxtResolvedSource[],
206
+ setup: DuxtLocaleSetup
207
+ ): DuxtDocVersion[] {
208
+ const editions = new Map<string, DuxtResolvedSource[]>();
209
+
210
+ for (const source of sources) {
211
+ if (source.generated) continue;
212
+
213
+ const group = editions.get(source.prefix);
214
+ if (group) group.push(source);
215
+ else editions.set(source.prefix, [source]);
216
+ }
217
+
218
+ return [...editions.entries()]
219
+ .map(([prefix, group]) => {
220
+ const lead = group.find((source) => source.isDefaultLocale) ?? group[0]!;
221
+ const available = group.map((source) => source.locale);
222
+
223
+ return {
224
+ prefix: duxtPublicPath(prefix, undefined, setup),
225
+ label: lead.version ?? lead.repo ?? 'default',
226
+ status: lead.status,
227
+ isDefault: lead.isDefault,
228
+ repository: lead.repository,
229
+ ref: lead.ref,
230
+ refKind: lead.refKind,
231
+ path: lead.path,
232
+ locales: setup.codes
233
+ .filter((code) =>
234
+ translatedInto(code, available, setup.fallbackLocale)
235
+ )
236
+ .map((code) => ({
237
+ code,
238
+ prefix: duxtPublicPath(prefix, code, setup)
239
+ }))
240
+ };
241
+ })
242
+ .sort(
243
+ (a, b) =>
244
+ Number(b.isDefault) - Number(a.isDefault) ||
245
+ a.prefix.localeCompare(b.prefix)
246
+ );
247
+ }
248
+
249
+ /** The collections one prefix inside a scope resolves to, in fallback order. */
250
+ interface DuxtScopeGroup {
251
+ prefix: string;
252
+ sources: DuxtResolvedSource[];
253
+ }
254
+
255
+ /** A version-and-language scope, resolved from a public URL prefix. */
256
+ export interface DuxtScope {
257
+ /** The prefix as the site serves it, `/` for the whole site. */
258
+ prefix: string;
259
+ /** The locale segment stripped off it, where there was one. */
260
+ locale?: string;
261
+ /** The collections it covers, one entry per source prefix. */
262
+ groups: DuxtScopeGroup[];
263
+ }
264
+
265
+ /** `/app/` and `app` both mean `/app`; `/` and nothing both mean the root. */
266
+ function normalise(prefix: string): string {
267
+ const trimmed = prefix.trim();
268
+ if (!trimmed || trimmed === '/') return '';
269
+
270
+ const rooted = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
271
+
272
+ return rooted.replace(/\/+$/, '');
273
+ }
274
+
275
+ /**
276
+ * The version prefix a source belongs to: the longest one enclosing it.
277
+ *
278
+ * This is what keeps a nested edition out of its parent's scope. The default
279
+ * version sits at `/app` and the retired one at `/app/v1`, so `/app/v1` is
280
+ * literally inside `/app` — and a scope that took everything inside itself
281
+ * would answer "the current documentation" with two versions of every page.
282
+ * Asking which version prefix is NEAREST puts each source under exactly one.
283
+ */
284
+ function editionOf(source: DuxtResolvedSource, editions: string[]): string {
285
+ const enclosing = editions.filter((prefix) =>
286
+ isInside(source.prefix, prefix)
287
+ );
288
+
289
+ // A generated section declared above every documentation prefix belongs to
290
+ // no edition; it answers for itself rather than joining someone else's.
291
+ return enclosing.length
292
+ ? enclosing.reduce((longest, prefix) =>
293
+ prefix.length > longest.length ? prefix : longest
294
+ )
295
+ : source.prefix;
296
+ }
297
+
298
+ /**
299
+ * Which collections a public prefix scopes to.
300
+ *
301
+ * `undefined` for a prefix nothing serves — an empty list would read as "this
302
+ * version has no pages", and an agent that mistyped a prefix has to be told to
303
+ * ask `list_versions` instead. Omitting the prefix is NOT the same as scoping
304
+ * to `/`: no scope means every version, while `/` is the edition served at the
305
+ * site root.
306
+ */
307
+ export function duxtScope(
308
+ prefix: string | undefined,
309
+ sources: DuxtResolvedSource[],
310
+ setup: DuxtLocaleSetup
311
+ ): DuxtScope | undefined {
312
+ const asked = prefix === undefined ? undefined : normalise(prefix);
313
+ const { locale, path } =
314
+ asked === undefined
315
+ ? { locale: undefined, path: '' }
316
+ : splitLocalePath(asked, setup.codes);
317
+ const wanted = path === '/' ? '' : path;
318
+
319
+ const editions = [
320
+ ...new Set(
321
+ sources
322
+ .filter((source) => !source.generated)
323
+ .map((source) => source.prefix)
324
+ )
325
+ ];
326
+
327
+ // A version prefix takes the sources of that edition — its own pages and the
328
+ // sections generated beside them. Anything else an agent might paste in (a
329
+ // generated section addressed directly) falls back to what sits inside it.
330
+ const edition = sources.filter(
331
+ (source) => editionOf(source, editions) === wanted
332
+ );
333
+ const selected =
334
+ asked === undefined
335
+ ? sources
336
+ : edition.length
337
+ ? edition
338
+ : sources.filter((source) => isInside(source.prefix, wanted));
339
+
340
+ if (!selected.length) return undefined;
341
+
342
+ const prefixes = [...new Set(selected.map((source) => source.prefix))].sort();
343
+
344
+ return {
345
+ prefix: duxtPublicPath(wanted, locale, setup),
346
+ locale,
347
+ groups: prefixes.map((group) => ({
348
+ prefix: group,
349
+ sources: sourcesForRoute(
350
+ group || '/',
351
+ locale ?? setup.defaultLocale,
352
+ sources,
353
+ setup.fallbackLocale
354
+ )
355
+ }))
356
+ };
357
+ }
358
+
359
+ /** One page of a scope, at the URL the scope serves it from. */
360
+ export interface DuxtScopedPage {
361
+ /** The public URL path, locale segment included. */
362
+ path: string;
363
+ /** The path the collection stores it under. */
364
+ documentPath: string;
365
+ title?: string;
366
+ description?: string;
367
+ rawbody?: string;
368
+ /**
369
+ * `false` when the page opted out of search.
370
+ *
371
+ * Carried on every scoped page and applied by `search_docs` alone, because
372
+ * the decided rule is that `search: false` makes a page un-findable rather
373
+ * than unpublished. `list_pages` still enumerates it — that listing is the
374
+ * tree, which is the thing `navigation: false` governs — and `read_page`
375
+ * still answers for it at its unchanged URL.
376
+ */
377
+ search?: boolean;
378
+ /** The source that answered — which language the text is actually in. */
379
+ source: DuxtResolvedSource;
380
+ }
381
+
382
+ /**
383
+ * Every page in a scope, translations resolved.
384
+ *
385
+ * One pass per collection rather than one query per page: the chain is short
386
+ * and the first collection holding a path wins, which is the same fallback the
387
+ * rendered page takes. `rawbody` is only asked for when the caller needs it —
388
+ * a table of contents that loads every body is the cost `llms.txt` already had
389
+ * to have taken out of it.
390
+ */
391
+ export async function duxtScopedPages(
392
+ event: H3Event,
393
+ scope: DuxtScope,
394
+ setup: DuxtLocaleSetup,
395
+ withBody = false
396
+ ): Promise<DuxtScopedPage[]> {
397
+ const fields = withBody
398
+ ? (['path', 'title', 'description', 'rawbody', 'search'] as const)
399
+ : (['path', 'title', 'description', 'search'] as const);
400
+ const found = new Map<string, DuxtScopedPage>();
401
+
402
+ for (const group of scope.groups) {
403
+ for (const source of group.sources) {
404
+ const pages = (await queryCollection(
405
+ event,
406
+ source.collection as Parameters<typeof queryCollection>[1]
407
+ )
408
+ .select(...fields)
409
+ .all()) as unknown as {
410
+ path?: string;
411
+ title?: string;
412
+ description?: string;
413
+ rawbody?: string;
414
+ search?: boolean;
415
+ }[];
416
+
417
+ for (const page of pages) {
418
+ const documentPath = page.path;
419
+ if (!documentPath || found.has(documentPath)) continue;
420
+
421
+ found.set(documentPath, {
422
+ ...page,
423
+ documentPath,
424
+ path: duxtPublicPath(documentPath, scope.locale, setup),
425
+ source
426
+ });
427
+ }
428
+ }
429
+ }
430
+
431
+ return [...found.values()].sort((a, b) => a.path.localeCompare(b.path));
432
+ }
433
+
434
+ /** How many results a listing returns when the caller does not say. */
435
+ export const DUXT_PAGE_SIZE = 20;
436
+ /** The ceiling, so one call cannot return the whole site. */
437
+ export const DUXT_PAGE_MAX = 100;
438
+
439
+ /** A bounded slice of a listing, and where to resume it. */
440
+ export interface DuxtCursorPage<T> {
441
+ items: T[];
442
+ nextCursor?: string;
443
+ }
444
+
445
+ /**
446
+ * Page a listing by its LAST PATH rather than by an offset.
447
+ *
448
+ * An offset silently skips or repeats a page whenever the documentation
449
+ * changes between two calls, and an agent has no way to notice. A path is
450
+ * stable under the same sort, needs no server-side state, and is readable in a
451
+ * transcript — so it stays the path itself rather than an encoded blob, which
452
+ * also keeps it portable to a runtime without `Buffer`.
453
+ */
454
+ export function duxtCursorPage<T extends { path: string }>(
455
+ items: T[],
456
+ limit: number,
457
+ cursor?: string
458
+ ): DuxtCursorPage<T> {
459
+ const size = Math.min(
460
+ DUXT_PAGE_MAX,
461
+ Math.max(1, Math.trunc(limit || DUXT_PAGE_SIZE))
462
+ );
463
+ const after = cursor
464
+ ? items.filter((item) => item.path.localeCompare(cursor) > 0)
465
+ : items;
466
+ const page = after.slice(0, size);
467
+
468
+ return {
469
+ items: page,
470
+ nextCursor: after.length > page.length ? page.at(-1)?.path : undefined
471
+ };
472
+ }
473
+
474
+ /** A cursor this module could have issued — anything else is a mistake. */
475
+ export const duxtValidCursor = (cursor: string) => cursor.startsWith('/');
476
+
477
+ /**
478
+ * A bounded window of the authored Markdown around the first match.
479
+ *
480
+ * Plain text, never the rendered body: a model asked to judge whether a page
481
+ * is the right one wants the sentence the term sits in, and an AST of MDC
482
+ * nodes is neither shorter nor clearer than the page itself. Whitespace is
483
+ * collapsed so a code fence or a table does not spend the whole window on
484
+ * indentation.
485
+ */
486
+ export function duxtExcerpt(
487
+ body: string | undefined,
488
+ query: string,
489
+ radius = 90
490
+ ): string | undefined {
491
+ const text = stripFrontmatter(body ?? '')
492
+ .replace(/\s+/g, ' ')
493
+ .trim();
494
+ if (!text) return undefined;
495
+
496
+ const at = text.toLowerCase().indexOf(query.toLowerCase());
497
+ // A match in the title or the description only: the opening lines are the
498
+ // best answer to "what is this page", which is the question being asked.
499
+ const start = at < 0 ? 0 : Math.max(0, at - radius);
500
+ const end =
501
+ at < 0
502
+ ? Math.min(text.length, radius * 2)
503
+ : Math.min(text.length, at + query.length + radius);
504
+
505
+ return `${start > 0 ? '…' : ''}${text.slice(start, end)}${
506
+ end < text.length ? '…' : ''
507
+ }`;
508
+ }
509
+
510
+ /**
511
+ * A YAML block duxt writes, over the Markdown the author wrote.
512
+ *
513
+ * Every value is quoted rather than quoted-when-necessary: a version label, a
514
+ * description or a repository name may carry a colon, and an unquoted value
515
+ * with one ends the mapping there — the exact silent failure
516
+ * `tests/frontmatter-yaml.test.ts` exists to catch in this repository's own
517
+ * pages. Undefined keys are dropped; a key present with no value says
518
+ * something the manifest never said.
519
+ */
520
+ export function duxtFrontmatter(
521
+ fields: Record<string, string | boolean | undefined>
522
+ ): string {
523
+ const lines = Object.entries(fields)
524
+ .filter(([, value]) => value !== undefined && value !== '')
525
+ .map(([key, value]) =>
526
+ typeof value === 'boolean'
527
+ ? `${key}: ${value}`
528
+ : `${key}: "${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`
529
+ );
530
+
531
+ return `---\n${lines.join('\n')}\n---`;
532
+ }
533
+
534
+ /** The body an agent reads: the author's text, without the author's YAML. */
535
+ export const duxtAuthoredMarkdown = (body: string | undefined) =>
536
+ stripFrontmatter(body ?? '').trim();
537
+
538
+ /** One line of a listing, the shape every tool prints a page in. */
539
+ export const duxtPageLine = (page: {
540
+ path: string;
541
+ title?: string;
542
+ description?: string;
543
+ }) =>
544
+ `- ${page.path} — ${page.title ?? page.path}${
545
+ page.description ? `: ${page.description}` : ''
546
+ }`;
547
+
548
+ /**
549
+ * What a listing says when a scope does not exist.
550
+ *
551
+ * An error rather than an empty page, and it names the tool that answers the
552
+ * question — an agent handed `[]` reads "this version is empty" and stops.
553
+ */
554
+ export const duxtUnknownScope = (prefix: string) => ({
555
+ content: [
556
+ {
557
+ type: 'text' as const,
558
+ text:
559
+ `No documentation is served at ${prefix}. ` +
560
+ 'Call `list_versions` for the prefixes this site publishes.'
561
+ }
562
+ ],
563
+ isError: true
564
+ });
565
+
566
+ /** What a listing says when handed a cursor it cannot have issued. */
567
+ export const duxtBadCursor = (cursor: string) => ({
568
+ content: [
569
+ {
570
+ type: 'text' as const,
571
+ text:
572
+ `"${cursor}" is not a cursor from this server. ` +
573
+ 'Pass the `nextCursor` of the previous call, or omit it to start over.'
574
+ }
575
+ ],
576
+ isError: true
577
+ });