@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
package/sections.ts CHANGED
@@ -17,7 +17,6 @@
17
17
  * artefact, a type that read nothing out of one — is the severity policy, and
18
18
  * it lives in the pure half where a test can reach it.
19
19
  */
20
- import { existsSync, readFileSync } from 'node:fs';
21
20
  import { join } from 'node:path';
22
21
  import { defineCollection, defineCollectionSource } from '@nuxt/content';
23
22
  import type {
@@ -37,7 +36,13 @@ import {
37
36
  resolveGeneratedSections,
38
37
  sectionPages
39
38
  } from './sections-resolve';
39
+ import {
40
+ diskSectionInput,
41
+ sectionArtefactExists,
42
+ sectionInputKind
43
+ } from './section-input';
40
44
  import { resolveLatestRefs } from './sources-git';
45
+ import { localHistoryFor } from './git-contributors';
41
46
  import { pageSchema, repositoryRoot } from './sources';
42
47
 
43
48
  /**
@@ -79,11 +84,17 @@ export function duxtGeneratedCollections(
79
84
  * neither.
80
85
  */
81
86
  function localCollection(entry: DuxtResolvedSource, type: DuxtSectionType) {
82
- const file = join(repositoryRoot(), entry.path);
83
-
84
- const pages = existsSync(file)
85
- ? sectionPages(entry, type, readFileSync(file, 'utf8'))
86
- : missingSectionArtefact(entry, file);
87
+ // The checkout goes with the handle, which is what lets the release history
88
+ // read the commits between two tags. `remoteCollection` below has a
89
+ // `--depth 1` clone and passes one only where that clone is of this very
90
+ // repository — see `localHistoryFor`.
91
+ const root = repositoryRoot();
92
+ const file = join(root, entry.path);
93
+ const kind = sectionInputKind(type);
94
+
95
+ const pages = sectionArtefactExists(file, kind)
96
+ ? sectionPages(entry, type, diskSectionInput(file, entry.path, kind, root))
97
+ : missingSectionArtefact(entry, file, type);
87
98
 
88
99
  const source = defineCollectionSource({
89
100
  getKeys: async () => pages.map((page) => page.file),
@@ -137,10 +148,22 @@ function remoteCollection(entry: DuxtResolvedSource, type: DuxtSectionType) {
137
148
  // `cwd` is where Content put the checkout, filled in by the `prepare` it
138
149
  // installed on this source and run before the first `getKeys`.
139
150
  const file = join(source.cwd, entry.path);
140
-
141
- pages = existsSync(file)
142
- ? sectionPages(entry, type, readFileSync(file, 'utf8'))
143
- : missingSectionArtefact(entry, file);
151
+ const kind = sectionInputKind(type);
152
+
153
+ // The artefact is read from the download; the HISTORY, where the download
154
+ // is this repository, from the checkout that has all of it.
155
+ pages = sectionArtefactExists(file, kind)
156
+ ? sectionPages(
157
+ entry,
158
+ type,
159
+ diskSectionInput(
160
+ file,
161
+ entry.path,
162
+ kind,
163
+ localHistoryFor(url, repositoryRoot())
164
+ )
165
+ )
166
+ : missingSectionArtefact(entry, file, type);
144
167
 
145
168
  return pages;
146
169
  };
@@ -586,7 +586,7 @@ export function page(active: string, body: string, preview = false): string {
586
586
  * The tab row is how the devtools panel is navigated, and it is exactly wrong
587
587
  * inside a documentation page: the frame sits under prose describing ONE
588
588
  * panel, and a reader who switched tabs in it would be reading about Sources
589
- * while looking at Paths. The row is also the panel's own chrome — the
589
+ * while looking at Paths. The row is also the panel's own header — the
590
590
  * documentation already carries a heading, a description and a sidebar entry
591
591
  * saying which panel this is.
592
592
  */
@@ -1,4 +1,18 @@
1
- import { queryCollection } from '@nuxt/content/nitro';
1
+ import { z } from 'zod';
2
+ import {
3
+ DUXT_PAGE_MAX,
4
+ DUXT_PAGE_SIZE,
5
+ duxtBadCursor,
6
+ duxtCursorPage,
7
+ duxtLocaleSetup,
8
+ duxtMcpEvent,
9
+ duxtPageLine,
10
+ duxtScope,
11
+ duxtScopedPages,
12
+ duxtSources,
13
+ duxtUnknownScope,
14
+ duxtValidCursor
15
+ } from '../../utils/mcp-docs';
2
16
 
3
17
  /**
4
18
  * The documentation's table of contents, for a model.
@@ -6,47 +20,74 @@ import { queryCollection } from '@nuxt/content/nitro';
6
20
  * Built on @nuxtjs/mcp-toolkit rather than a JSON endpoint of our own: it
7
21
  * speaks the actual protocol through the official SDK, so a client connects to
8
22
  * `/mcp` instead of someone writing a server around our shape.
23
+ *
24
+ * BOUNDED AND SCOPED, where it used to return every page of every version and
25
+ * every language in one unlabelled block. A prefix from `list_versions` picks
26
+ * the edition; `limit` and `cursor` keep one call's answer readable. Omitting
27
+ * the prefix still lists the whole site — in its default language, because the
28
+ * locale is part of the prefix rather than an axis of its own.
9
29
  */
10
- /** Every collection the manifest names — a versioned site has none called `docs`. */
11
- function duxtCollections(): 'docs'[] {
12
- const { duxt } = useAppConfig() as { duxt?: Partial<DuxtConfig> };
13
- const names = duxt?.resolvedSources?.length
14
- ? duxt.resolvedSources.map((source) => source.collection)
15
- : ['docs'];
16
- return names as 'docs'[];
17
- }
18
-
19
30
  export default defineMcpTool({
20
31
  name: 'list_pages',
21
32
  title: 'List documentation pages',
22
33
  description:
23
- 'Every page of this documentation with its path, title and description. ' +
24
- 'Start here, then read a page with `read_page`.',
34
+ 'The pages of this documentation with their path, title and description. ' +
35
+ 'Scope with a prefix from `list_versions`, or omit it for every version. ' +
36
+ 'Then read a page with `read_page`.',
25
37
  annotations: { readOnlyHint: true },
26
38
 
27
- // A tool without an `inputSchema` is called with ONE argument: the SDK hands
28
- // the handler `extra` directly rather than `(args, extra)`. Written the other
29
- // way round, `extra` was undefined and every call failed on `extra.event`.
30
- async handler(extra) {
31
- const pages = (
32
- await Promise.all(
33
- duxtCollections().map((name) =>
34
- queryCollection(extra.event, name)
35
- .select('path', 'title', 'description')
36
- .all()
37
- )
38
- )
39
- ).flat();
40
-
41
- const listing = pages
42
- .filter((page) => page.path)
43
- .sort((a, b) => a.path!.localeCompare(b.path!))
44
- .map(
45
- (page) =>
46
- `- ${page.path} — ${page.title}${page.description ? `: ${page.description}` : ''}`
47
- )
48
- .join('\n');
49
-
50
- return { content: [{ type: 'text', text: listing }] };
39
+ inputSchema: {
40
+ prefix: z
41
+ .string()
42
+ .optional()
43
+ .describe(
44
+ 'URL prefix from `list_versions`, for example /app or /de/app. ' +
45
+ 'Omit for every version in the default language.'
46
+ ),
47
+ limit: z
48
+ .number()
49
+ .int()
50
+ .min(1)
51
+ .max(DUXT_PAGE_MAX)
52
+ .default(DUXT_PAGE_SIZE)
53
+ .describe(`How many pages to return, at most ${DUXT_PAGE_MAX}`),
54
+ cursor: z
55
+ .string()
56
+ .optional()
57
+ .describe('`nextCursor` from a previous call, to continue the listing')
58
+ },
59
+
60
+ async handler({ prefix, limit, cursor }) {
61
+ if (cursor && !duxtValidCursor(cursor)) return duxtBadCursor(cursor);
62
+
63
+ const event = duxtMcpEvent();
64
+ const setup = duxtLocaleSetup(event);
65
+ const scope = duxtScope(prefix, duxtSources(), setup);
66
+ if (!scope) return duxtUnknownScope(prefix!);
67
+
68
+ const pages = await duxtScopedPages(event, scope, setup);
69
+ const { items, nextCursor } = duxtCursorPage(pages, limit, cursor);
70
+
71
+ if (!items.length)
72
+ return {
73
+ content: [
74
+ {
75
+ type: 'text',
76
+ text: cursor
77
+ ? `No further pages under ${scope.prefix}.`
78
+ : `No pages are published under ${scope.prefix}.`
79
+ }
80
+ ]
81
+ };
82
+
83
+ const listing = [
84
+ `${items.length} page(s) under ${scope.prefix}:`,
85
+ ...items.map(duxtPageLine)
86
+ ];
87
+ // The cursor is stated rather than implied: a listing that simply stops at
88
+ // the limit reads as complete, and an agent then documents half a site.
89
+ if (nextCursor) listing.push('', `nextCursor: ${nextCursor}`);
90
+
91
+ return { content: [{ type: 'text', text: listing.join('\n') }] };
51
92
  }
52
93
  });
@@ -0,0 +1,84 @@
1
+ import {
2
+ duxtDocVersions,
3
+ duxtLocaleSetup,
4
+ duxtMcpEvent,
5
+ duxtSources
6
+ } from '../../utils/mcp-docs';
7
+
8
+ /**
9
+ * Which editions of this documentation exist, and which one to read.
10
+ *
11
+ * The first call an agent should make, and the one that was missing: a site
12
+ * publishing `/app`, `/app/v1` and four languages answered `list_pages` with
13
+ * every page of every one of them, and nothing said which was current. A
14
+ * reader has a version switcher for this; a model had to infer it from paths.
15
+ *
16
+ * What it hands back is the PREFIX — the same string a browser takes — because
17
+ * that is also the whole of the scope `list_pages` and `search_docs` accept.
18
+ * No opaque identifier, no repository/version/locale tuple to reassemble.
19
+ */
20
+ export default defineMcpTool({
21
+ name: 'list_versions',
22
+ title: 'List documentation versions',
23
+ description:
24
+ 'Every version of this documentation: the URL prefix that scopes it, its ' +
25
+ 'label, lifecycle status, the languages it is published in and whether it ' +
26
+ 'is the default. Pass a prefix to `list_pages` or `search_docs`.',
27
+ annotations: { readOnlyHint: true },
28
+
29
+ // NO PARAMETERS, AND THAT IS DELIBERATE. A tool without an `inputSchema` is
30
+ // called with ONE argument — the SDK hands the handler `extra` rather than
31
+ // `(args, extra)` — so a handler that names its parameters has to know which
32
+ // arity it is on. Nothing here needs either of them: the request comes from
33
+ // `duxtMcpEvent()`, which is where it always had to come from.
34
+ async handler() {
35
+ const setup = duxtLocaleSetup(duxtMcpEvent());
36
+ const versions = duxtDocVersions(duxtSources(), setup);
37
+
38
+ // A site whose every source is a generated section publishes no version to
39
+ // choose between. Saying so beats an empty block an agent reads as a fault.
40
+ if (!versions.length)
41
+ return {
42
+ content: [
43
+ {
44
+ type: 'text',
45
+ text:
46
+ 'This site publishes no versioned documentation. ' +
47
+ 'Call `list_pages` without a prefix.'
48
+ }
49
+ ]
50
+ };
51
+
52
+ const listing = versions
53
+ .map((version) => {
54
+ // The lifecycle and the default role are different questions, and a
55
+ // list where three entries look alike says nothing about which of them
56
+ // is still safe to read.
57
+ const roles = [version.status, version.isDefault && 'default'].filter(
58
+ Boolean
59
+ );
60
+ const lines = [
61
+ `- ${version.prefix} — ${version.label} (${roles.join(', ')})`
62
+ ];
63
+
64
+ if (version.locales.length)
65
+ lines.push(
66
+ ` locales: ${version.locales
67
+ .map((locale) => `${locale.code} → ${locale.prefix}`)
68
+ .join(', ')}`
69
+ );
70
+
71
+ const origin = [
72
+ version.repository,
73
+ version.ref && `${version.refKind ?? 'ref'} ${version.ref}`,
74
+ version.path
75
+ ].filter(Boolean);
76
+ if (origin.length) lines.push(` source: ${origin.join(', ')}`);
77
+
78
+ return lines.join('\n');
79
+ })
80
+ .join('\n');
81
+
82
+ return { content: [{ type: 'text', text: listing }] };
83
+ }
84
+ });
@@ -1,47 +1,97 @@
1
1
  import { queryCollection } from '@nuxt/content/nitro';
2
2
  import { z } from 'zod';
3
+ import { splitLocalePath } from '../../../app/utils/locale-path';
4
+ import { sourcesForRoute } from '../../../sources-resolve';
5
+ import {
6
+ duxtAuthoredMarkdown,
7
+ duxtFrontmatter,
8
+ duxtLocaleSetup,
9
+ duxtMcpEvent,
10
+ duxtPublicPath,
11
+ duxtSources
12
+ } from '../../utils/mcp-docs';
3
13
 
4
14
  /**
5
- * One page's prose. Returns the stored content rather than rendered HTML — a
6
- * model reading documentation wants the text, not the theme around it.
15
+ * One page, as the author wrote it.
16
+ *
17
+ * ONE response shape, and it is Markdown: duxt's own YAML frontmatter over the
18
+ * authored body with the author's frontmatter removed. Before this the tool
19
+ * returned a title, a description and `JSON.stringify(page.body)` — Content's
20
+ * parsed AST, which is longer than the page, harder to read than the page, and
21
+ * not what any other machine-readable surface of this site hands over.
22
+ *
23
+ * The frontmatter is duxt's because the author's is gone: it says which
24
+ * version and which language this text is, which is precisely what a model
25
+ * holding one page out of four editions cannot otherwise know. `title` rides
26
+ * along for the same reason — the authored frontmatter carried it, the body
27
+ * usually has no heading of its own, and stripping one without the other hands
28
+ * over an untitled page.
29
+ *
30
+ * The path is the PUBLIC one, the same string `list_pages` printed, so a round
31
+ * trip through the two tools needs no translation. A locale segment on it
32
+ * selects the language, exactly as it does in a browser.
7
33
  */
8
- /** Every collection the manifest names — a versioned site has none called `docs`. */
9
- function duxtCollections(): 'docs'[] {
10
- const { duxt } = useAppConfig() as { duxt?: Partial<DuxtConfig> };
11
- const names = duxt?.resolvedSources?.length
12
- ? duxt.resolvedSources.map((source) => source.collection)
13
- : ['docs'];
14
- return names as 'docs'[];
15
- }
16
-
17
34
  export default defineMcpTool({
18
35
  name: 'read_page',
19
36
  title: 'Read a documentation page',
20
- description: 'The full content of one page. Paths come from `list_pages`.',
37
+ description:
38
+ 'One page as Markdown, with duxt frontmatter naming its version, language ' +
39
+ 'and lifecycle status. Paths come from `list_pages`.',
21
40
  annotations: { readOnlyHint: true },
22
41
 
23
42
  inputSchema: {
24
- path: z.string().describe('Page path, for example /guide/deploying')
43
+ path: z
44
+ .string()
45
+ .describe('Page path from `list_pages`, for example /app/deploying')
25
46
  },
26
47
 
27
- async handler({ path }, extra) {
28
- // Try each collection: a path carries its own prefix, so at most one has it.
29
- const found = await Promise.all(
30
- duxtCollections().map((name) =>
31
- queryCollection(extra.event, name).path(path).first()
32
- )
48
+ async handler({ path }) {
49
+ const event = duxtMcpEvent();
50
+ const setup = duxtLocaleSetup(event);
51
+ const sources = duxtSources();
52
+ const { locale, path: wanted } = splitLocalePath(path, setup.codes);
53
+
54
+ // The same chain the rendered page takes: the requested language, its
55
+ // siblings, the configured fallback, then the untranslated original.
56
+ const chain = sourcesForRoute(
57
+ wanted,
58
+ locale ?? setup.defaultLocale,
59
+ sources,
60
+ setup.fallbackLocale
33
61
  );
34
- const page = found.find(Boolean);
35
62
 
36
- if (!page) {
63
+ for (const source of chain) {
64
+ const page = (await queryCollection(
65
+ event,
66
+ source.collection as Parameters<typeof queryCollection>[1]
67
+ )
68
+ .path(wanted)
69
+ .select('title', 'description', 'rawbody')
70
+ .first()) as unknown as
71
+ | { title?: string; description?: string; rawbody?: string }
72
+ | undefined;
73
+
74
+ if (!page) continue;
75
+
76
+ const frontmatter = duxtFrontmatter({
77
+ path: duxtPublicPath(wanted, locale, setup),
78
+ prefix: duxtPublicPath(source.prefix, locale, setup),
79
+ title: page.title,
80
+ description: page.description,
81
+ version: source.version,
82
+ ref: source.ref,
83
+ refKind: source.refKind,
84
+ locale: source.locale,
85
+ status: source.status
86
+ });
87
+
37
88
  return {
38
89
  content: [
39
90
  {
40
91
  type: 'text',
41
- text: `No page at ${path}. Use \`list_pages\` for valid paths.`
92
+ text: `${frontmatter}\n\n${duxtAuthoredMarkdown(page.rawbody)}`
42
93
  }
43
- ],
44
- isError: true
94
+ ]
45
95
  };
46
96
  }
47
97
 
@@ -49,9 +99,13 @@ export default defineMcpTool({
49
99
  content: [
50
100
  {
51
101
  type: 'text',
52
- text: `# ${page.title}\n\n${page.description ?? ''}\n\n${JSON.stringify(page.body)}`
102
+ text:
103
+ `No page at ${path}. ` +
104
+ 'Use `list_pages` for valid paths, or `list_versions` to pick a ' +
105
+ 'version first.'
53
106
  }
54
- ]
107
+ ],
108
+ isError: true
55
109
  };
56
110
  }
57
111
  });
@@ -1,74 +1,108 @@
1
- import { queryCollection } from '@nuxt/content/nitro';
2
1
  import { z } from 'zod';
2
+ import {
3
+ DUXT_PAGE_MAX,
4
+ DUXT_PAGE_SIZE,
5
+ duxtBadCursor,
6
+ duxtCursorPage,
7
+ duxtExcerpt,
8
+ duxtLocaleSetup,
9
+ duxtMcpEvent,
10
+ duxtPageLine,
11
+ duxtScope,
12
+ duxtScopedPages,
13
+ duxtSources,
14
+ duxtUnknownScope,
15
+ duxtValidCursor
16
+ } from '../../utils/mcp-docs';
17
+ import { duxtPageSearchable } from '../../../app/utils/page-controls';
3
18
 
4
19
  /**
5
20
  * Search, so a model does not have to read every page to find one.
6
21
  *
7
- * A LIKE over title and description rather than the FTS index the browser
8
- * uses: that index is built client-side from the collection dump, and a server
9
- * tool has the database itself.
22
+ * THE BODY, not only the title and the description. A LIKE over those two
23
+ * found "Deploying" and missed every page that merely explains deployment,
24
+ * which is most of them — a search matching only what is already in the table
25
+ * of contents tells an agent nothing `list_pages` had not.
26
+ *
27
+ * Matched in JavaScript rather than in SQL, and that is the trade written
28
+ * down: SQLite's LIKE is case-insensitive for ASCII ONLY, so a German page
29
+ * searched for "übersicht" matched nothing at all, and the excerpt needs the
30
+ * body in hand regardless. The scope is what keeps the cost bounded — an agent
31
+ * that names a version pays for that version.
10
32
  */
11
- /** Every collection the manifest names — a versioned site has none called `docs`. */
12
- function duxtCollections(): 'docs'[] {
13
- const { duxt } = useAppConfig() as { duxt?: Partial<DuxtConfig> };
14
- const names = duxt?.resolvedSources?.length
15
- ? duxt.resolvedSources.map((source) => source.collection)
16
- : ['docs'];
17
- return names as 'docs'[];
18
- }
19
-
20
33
  export default defineMcpTool({
21
34
  name: 'search_docs',
22
35
  title: 'Search the documentation',
23
- description: 'Find pages whose title or description matches a term.',
36
+ description:
37
+ 'Find pages whose title, description or Markdown contains a term, with an ' +
38
+ 'excerpt around the first match. Scope with a prefix from `list_versions`.',
24
39
  annotations: { readOnlyHint: true },
25
40
 
26
41
  inputSchema: {
27
- query: z.string().describe('What to look for')
42
+ query: z.string().min(1).describe('What to look for'),
43
+ prefix: z
44
+ .string()
45
+ .optional()
46
+ .describe(
47
+ 'URL prefix from `list_versions`, for example /app or /de/app. ' +
48
+ 'Omit to search every version in the default language.'
49
+ ),
50
+ limit: z
51
+ .number()
52
+ .int()
53
+ .min(1)
54
+ .max(DUXT_PAGE_MAX)
55
+ .default(DUXT_PAGE_SIZE)
56
+ .describe(`How many results to return, at most ${DUXT_PAGE_MAX}`),
57
+ cursor: z
58
+ .string()
59
+ .optional()
60
+ .describe('`nextCursor` from a previous call, to continue the results')
28
61
  },
29
62
 
30
- async handler({ query }, extra) {
31
- const term = `%${query}%`;
63
+ async handler({ query, prefix, limit, cursor }) {
64
+ if (cursor && !duxtValidCursor(cursor)) return duxtBadCursor(cursor);
65
+
66
+ const event = duxtMcpEvent();
67
+ const setup = duxtLocaleSetup(event);
68
+ const scope = duxtScope(prefix, duxtSources(), setup);
69
+ if (!scope) return duxtUnknownScope(prefix!);
70
+
71
+ const term = query.toLowerCase();
72
+ const holds = (value: string | undefined) =>
73
+ Boolean(value?.toLowerCase().includes(term));
32
74
 
33
- // Both conditions in ONE group. Content joins the query's top-level
34
- // conditions with AND regardless of how each was added, so a `where()`
35
- // followed by an `orWhere()` asks for a term in the title AND in the
36
- // description — which matched nothing at all. Inside a group the operator
37
- // is the one the group was opened with, so `orWhere` gives title OR
38
- // description.
39
- const pages = (
40
- await Promise.all(
41
- duxtCollections().map((name) =>
42
- queryCollection(extra.event, name)
43
- .select('path', 'title', 'description')
44
- .orWhere((group) =>
45
- group
46
- .where('title', 'LIKE', term)
47
- .where('description', 'LIKE', term)
48
- )
49
- .all()
50
- )
51
- )
52
- ).flat();
75
+ // `search: false` is applied HERE rather than in `duxtScopedPages`, which
76
+ // also feeds `list_pages` and `list_versions`: the opt-out makes a page
77
+ // un-findable, not unpublished, so the tree and the page itself are left
78
+ // alone. Same predicate the client search and the llms indexes use.
79
+ const matches = (await duxtScopedPages(event, scope, setup, true)).filter(
80
+ (page) =>
81
+ duxtPageSearchable(page) &&
82
+ (holds(page.title) || holds(page.description) || holds(page.rawbody))
83
+ );
53
84
 
54
- if (!pages.length) {
85
+ if (!matches.length)
55
86
  return {
56
- content: [{ type: 'text', text: `Nothing matches "${query}".` }]
87
+ content: [
88
+ {
89
+ type: 'text',
90
+ text: `Nothing matches "${query}" under ${scope.prefix}.`
91
+ }
92
+ ]
57
93
  };
58
- }
59
94
 
60
- return {
61
- content: [
62
- {
63
- type: 'text',
64
- text: pages
65
- .map(
66
- (page) =>
67
- `- ${page.path} — ${page.title}${page.description ? `: ${page.description}` : ''}`
68
- )
69
- .join('\n')
70
- }
71
- ]
72
- };
95
+ const { items, nextCursor } = duxtCursorPage(matches, limit, cursor);
96
+ const listing = items.flatMap((page) => {
97
+ const excerpt = duxtExcerpt(page.rawbody, query);
98
+
99
+ return excerpt
100
+ ? [duxtPageLine(page), ` ${excerpt}`]
101
+ : [duxtPageLine(page)];
102
+ });
103
+
104
+ if (nextCursor) listing.push('', `nextCursor: ${nextCursor}`);
105
+
106
+ return { content: [{ type: 'text', text: listing.join('\n') }] };
73
107
  }
74
108
  });
@@ -3,7 +3,7 @@ import { defineEventHandler, setHeader } from 'h3';
3
3
  import { splitLocalePath } from '../../app/utils/locale-path';
4
4
  import { sourcesForRoute } from '../../sources-resolve';
5
5
  import { duxtDefaults, mergeDuxtConfig } from '../../app/utils/duxt-config';
6
- import { stripFrontmatter } from '../utils/duxt-server-text';
6
+ import { stripFrontmatter } from '../../frontmatter';
7
7
 
8
8
  /**
9
9
  * `…/guide/deploying.md` serves the page's Markdown source.
@@ -3,17 +3,15 @@ import { llmsPages } from '../utils/llms-pages';
3
3
  // does not see its utils. The defaults have to come from the same file the
4
4
  // pages use, or the two descriptions drift apart.
5
5
  import { duxtDefaults, mergeDuxtConfig } from '../../app/utils/duxt-config';
6
- import {
7
- resolveServerTexts,
8
- stripFrontmatter
9
- } from '../utils/duxt-server-text';
6
+ import { resolveServerTexts } from '../utils/duxt-server-text';
7
+ import { stripFrontmatter } from '../../frontmatter';
10
8
 
11
9
  /**
12
10
  * llms-full.txt — the whole documentation as one Markdown file.
13
11
  *
14
- * The companion to `llms.txt`, which is an index: a model that has read the
15
- * index still has to fetch sixty pages, and a model given this one has read
16
- * them. The convention (llmstxt.org) names both.
12
+ * The duxt extension beside `llms.txt`, which is the llms.txt convention's
13
+ * index: a model that has read the index still has to fetch sixty pages, and a
14
+ * model given this one has read them.
17
15
  *
18
16
  * The Markdown as written, not the rendered HTML — which is why the collection
19
17
  * schema asks Content for `rawbody`. An MDC block reaches the reader as the
@@ -27,7 +25,7 @@ export default defineEventHandler(async (event) => {
27
25
  mergeDuxtConfig(appConfig.duxt, duxtDefaults)
28
26
  );
29
27
 
30
- const pages = await llmsPages(event, duxt.resolvedSources);
28
+ const pages = await llmsPages(event, duxt.resolvedSources, true);
31
29
 
32
30
  const origin = getRequestURL(event).origin;
33
31
 
@@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
38
38
  .sort((a, b) => a.path!.localeCompare(b.path!))
39
39
  .map(
40
40
  (page) =>
41
- `- [${page.title ?? page.path}](${origin}${page.path})` +
41
+ `- [${page.title ?? page.path}](${origin}${page.path}.md)` +
42
42
  (page.description ? `: ${page.description}` : '')
43
43
  ),
44
44
  ''
@@ -31,16 +31,3 @@ function lookup(key: string): string | undefined {
31
31
  export function resolveServerTexts<T>(config: T): T {
32
32
  return resolveDuxtTexts(config, 'en-GB', lookup);
33
33
  }
34
-
35
- /**
36
- * The Markdown body without its frontmatter block.
37
- *
38
- * Content's `rawbody` is the file as it sits on disk, frontmatter included. A
39
- * reader wants the prose; a model handed `---\ntitle: …\n---` reads a page
40
- * whose first heading is a YAML fence.
41
- */
42
- export function stripFrontmatter(body: string): string {
43
- const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(body);
44
-
45
- return match ? body.slice(match[0].length) : body;
46
- }