@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.
- package/README.md +24 -9
- package/app/app.vue +17 -11
- package/app/assets/css/duxt.css +47 -6
- package/app/components/DuxtAnnouncements.vue +117 -0
- package/app/components/DuxtBrunoEntries.vue +62 -0
- package/app/components/DuxtCodeBlock.vue +18 -34
- package/app/components/DuxtCodeToolbar.vue +14 -0
- package/app/components/DuxtCopyButton.vue +33 -0
- package/app/components/DuxtCopyPage.vue +32 -30
- package/app/components/DuxtFooter.vue +39 -8
- package/app/components/DuxtHeader.vue +107 -48
- package/app/components/DuxtLandingProse.vue +1 -1
- package/app/components/DuxtLinkLabel.vue +36 -0
- package/app/components/DuxtLiveWindow.vue +8 -8
- package/app/components/DuxtLocale.vue +10 -1
- package/app/components/DuxtNavigation.vue +2 -2
- package/app/components/DuxtOpenApiClient.vue +285 -90
- package/app/components/DuxtOpenApiMedia.vue +30 -18
- package/app/components/DuxtOpenApiSchema.vue +10 -7
- package/app/components/DuxtPageFeedback.vue +7 -0
- package/app/components/DuxtPageInfo.vue +15 -12
- package/app/components/DuxtPageNav.vue +41 -27
- package/app/components/DuxtPreviewSkeleton.vue +4 -4
- package/app/components/DuxtProgress.vue +2 -14
- package/app/components/DuxtSearch.vue +230 -90
- package/app/components/DuxtSearchInput.vue +70 -0
- package/app/components/DuxtSections.vue +11 -1
- package/app/components/DuxtSegmented.vue +64 -0
- package/app/components/DuxtShortcuts.vue +16 -11
- package/app/components/DuxtShortcutsTrigger.vue +49 -0
- package/app/components/DuxtSkipLink.vue +1 -1
- package/app/components/DuxtToc.vue +16 -10
- package/app/components/DuxtTranslationBanner.vue +1 -1
- package/app/components/DuxtVersion.vue +53 -12
- package/app/components/DuxtVersionBanner.vue +5 -5
- package/app/components/content/AccordionItem.vue +2 -2
- package/app/components/content/BrunoOverview.vue +260 -0
- package/app/components/content/BrunoRequest.vue +141 -0
- package/app/components/content/BrunoRequests.vue +104 -0
- package/app/components/content/Callout.vue +5 -5
- package/app/components/content/ChangelogReleases.vue +161 -40
- package/app/components/content/CodeGroup.vue +41 -37
- package/app/components/content/Field.vue +1 -1
- package/app/components/content/FileTree.vue +1 -1
- package/app/components/content/OpenApiOperation.vue +4 -4
- package/app/components/content/OpenApiOperations.vue +1 -1
- package/app/components/content/OpenApiOverview.vue +1 -1
- package/app/components/content/PackageManagers.vue +42 -58
- package/app/components/content/PageCards.vue +7 -4
- package/app/components/content/Preview.vue +15 -39
- package/app/components/content/ProseImg.vue +596 -49
- package/app/components/content/Since.vue +1 -1
- package/app/components/content/Steps.vue +6 -4
- package/app/components/content/TabGroup.vue +13 -12
- package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
- package/app/components/ui/command/CommandShortcut.vue +1 -1
- package/app/components/ui/dialog/DialogContent.vue +2 -2
- package/app/components/ui/dialog/DialogHeader.vue +1 -1
- package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
- package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
- package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
- package/app/components/ui/select/SelectItem.vue +2 -2
- package/app/components/ui/sheet/SheetContent.vue +29 -3
- package/app/components/ui/sidebar/Sidebar.vue +28 -4
- package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarInset.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
- package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
- package/app/components/ui/sidebar/index.ts +10 -2
- package/app/components/ui/sidebar/utils.ts +0 -1
- package/app/components/ui/sonner/Sonner.vue +18 -0
- package/app/components/ui/tabs/TabsList.vue +13 -3
- package/app/components/ui/tabs/TabsTrigger.vue +24 -3
- package/app/components/ui/tooltip/TooltipContent.vue +2 -2
- package/app/composables/useActiveHeading.ts +3 -25
- package/app/composables/useDuxtAnalytics.ts +50 -0
- package/app/composables/useDuxtAnimatedHeight.ts +8 -38
- package/app/composables/useDuxtAnnouncements.ts +153 -0
- package/app/composables/useDuxtCopy.ts +51 -0
- package/app/composables/useDuxtDirection.ts +23 -0
- package/app/composables/useDuxtNavigation.ts +28 -8
- package/app/composables/useDuxtPath.ts +3 -5
- package/app/composables/useDuxtSearch.ts +87 -35
- package/app/composables/useDuxtSection.ts +15 -1
- package/app/composables/useDuxtSectionRow.ts +6 -5
- package/app/composables/useDuxtShortcuts.ts +319 -20
- package/app/composables/useDuxtVersion.ts +57 -6
- package/app/composables/useDuxtViewportMeasure.ts +37 -0
- package/app/composables/useFuzzySearch.ts +9 -4
- package/app/composables/useRecentPages.ts +14 -17
- package/app/error.vue +112 -74
- package/app/layouts/changelog.vue +9 -1
- package/app/layouts/default.vue +9 -0
- package/app/layouts/docs.vue +9 -1
- package/app/layouts/reference.vue +9 -1
- package/app/middleware/duxt-section-layout.global.ts +1 -3
- package/app/pages/[...slug].vue +146 -35
- package/app/pages/index.vue +15 -23
- package/app/types/duxt.d.ts +312 -4
- package/app/utils/analytics.ts +91 -0
- package/app/utils/announcements.ts +205 -0
- package/app/utils/changelog.ts +0 -28
- package/app/utils/code-highlight.ts +3 -1
- package/app/utils/contributor-avatar.ts +27 -0
- package/app/utils/direction.ts +43 -0
- package/app/utils/duxt-config.ts +43 -0
- package/app/utils/duxt-text.ts +10 -0
- package/app/utils/format-date.ts +28 -0
- package/app/utils/generated-sections.ts +68 -12
- package/app/utils/generated-toc.ts +14 -7
- package/app/utils/locale-path.ts +43 -0
- package/app/utils/navigation-tree.ts +51 -1
- package/app/utils/nearest-page.ts +8 -0
- package/app/utils/openapi.ts +16 -11
- package/app/utils/page-controls.ts +215 -0
- package/app/utils/pill.ts +61 -0
- package/app/utils/prose-image.ts +128 -0
- package/app/utils/search-context.ts +110 -0
- package/app/utils/search-display.ts +248 -0
- package/app/utils/section-scope.ts +65 -4
- package/app/utils/stored-json.ts +31 -0
- package/app/utils/version-choices.ts +6 -13
- package/app/utils/version-paths.ts +21 -0
- package/bin/duxt-cache-key.mjs +61 -0
- package/bin/duxt-og-cache.mjs +58 -0
- package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
- package/bruno-model.ts +169 -0
- package/bruno-parse.ts +656 -0
- package/bruno-zip.ts +195 -0
- package/cli.ts +240 -0
- package/duxt-app-config.ts +18 -1
- package/frontmatter.ts +49 -0
- package/git-contributors.ts +322 -0
- package/i18n/locales/de/duxt/announcement.json +7 -0
- package/i18n/locales/de/duxt/bruno.json +25 -0
- package/i18n/locales/de/duxt/changelog.json +1 -1
- package/i18n/locales/de/duxt/code.json +2 -1
- package/i18n/locales/de/duxt/page.json +9 -0
- package/i18n/locales/de/duxt/shortcuts.json +1 -0
- package/i18n/locales/de/duxt/theme.json +2 -1
- package/i18n/locales/en/duxt/announcement.json +7 -0
- package/i18n/locales/en/duxt/bruno.json +25 -0
- package/i18n/locales/en/duxt/changelog.json +1 -1
- package/i18n/locales/en/duxt/code.json +2 -1
- package/i18n/locales/en/duxt/page.json +9 -0
- package/i18n/locales/en/duxt/shortcuts.json +1 -0
- package/i18n/locales/en/duxt/theme.json +2 -1
- package/i18n/locales/es/duxt/announcement.json +7 -0
- package/i18n/locales/es/duxt/bruno.json +25 -0
- package/i18n/locales/es/duxt/changelog.json +1 -1
- package/i18n/locales/es/duxt/code.json +2 -1
- package/i18n/locales/es/duxt/page.json +9 -0
- package/i18n/locales/es/duxt/shortcuts.json +1 -0
- package/i18n/locales/es/duxt/theme.json +2 -1
- package/i18n/locales/fr/duxt/announcement.json +7 -0
- package/i18n/locales/fr/duxt/bruno.json +25 -0
- package/i18n/locales/fr/duxt/changelog.json +1 -1
- package/i18n/locales/fr/duxt/code.json +2 -1
- package/i18n/locales/fr/duxt/page.json +9 -0
- package/i18n/locales/fr/duxt/shortcuts.json +1 -0
- package/i18n/locales/fr/duxt/theme.json +2 -1
- package/i18n/locales/pt/duxt/announcement.json +7 -0
- package/i18n/locales/pt/duxt/bruno.json +25 -0
- package/i18n/locales/pt/duxt/changelog.json +1 -1
- package/i18n/locales/pt/duxt/code.json +2 -1
- package/i18n/locales/pt/duxt/page.json +9 -0
- package/i18n/locales/pt/duxt/shortcuts.json +1 -0
- package/i18n/locales/pt/duxt/theme.json +2 -1
- package/modules/bruno.ts +118 -0
- package/modules/config.ts +26 -0
- package/modules/devtools.ts +2 -3
- package/modules/git-meta.ts +16 -47
- package/modules/search-records.ts +132 -0
- package/nuxt.config.ts +81 -7
- package/og-image-cache.ts +742 -0
- package/package.json +41 -9
- package/public/devtools/checks.html +2 -2
- package/public/devtools/config.html +59 -4
- package/public/devtools/pages.html +8 -1
- package/public/devtools/paths.html +1 -1
- package/public/devtools/sources.html +2 -2
- package/public/devtools/versions.html +3 -0
- package/report.ts +28 -19
- package/search-records.ts +380 -0
- package/section-input.ts +142 -0
- package/section-markdown.ts +67 -0
- package/section-reports.ts +10 -4
- package/sections-bruno.ts +571 -0
- package/sections-changelog.ts +76 -43
- package/sections-openapi.ts +12 -83
- package/sections-resolve.ts +174 -8
- package/sections.ts +33 -10
- package/server/devtools/shell.ts +1 -1
- package/server/mcp/tools/list-pages.ts +77 -36
- package/server/mcp/tools/list-versions.ts +84 -0
- package/server/mcp/tools/read-page.ts +80 -26
- package/server/mcp/tools/search-docs.ts +87 -53
- package/server/middleware/raw-markdown.ts +1 -1
- package/server/routes/llms-full.txt.get.ts +6 -8
- package/server/routes/llms.txt.get.ts +1 -1
- package/server/utils/duxt-server-text.ts +0 -13
- package/server/utils/llms-pages.ts +24 -9
- package/server/utils/mcp-docs.ts +577 -0
- package/sources-cache.ts +376 -0
- package/sources-git.ts +186 -31
- package/sources-resolve.ts +59 -1
- package/sources.ts +122 -13
- 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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
};
|
package/server/devtools/shell.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
'
|
|
24
|
-
'
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.
|
|
44
|
-
.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
6
|
-
*
|
|
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:
|
|
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
|
|
43
|
+
path: z
|
|
44
|
+
.string()
|
|
45
|
+
.describe('Page path from `list_pages`, for example /app/deploying')
|
|
25
46
|
},
|
|
26
47
|
|
|
27
|
-
async handler({ path }
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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:
|
|
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
|
|
31
|
-
|
|
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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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 (!
|
|
85
|
+
if (!matches.length)
|
|
55
86
|
return {
|
|
56
|
-
content: [
|
|
87
|
+
content: [
|
|
88
|
+
{
|
|
89
|
+
type: 'text',
|
|
90
|
+
text: `Nothing matches "${query}" under ${scope.prefix}.`
|
|
91
|
+
}
|
|
92
|
+
]
|
|
57
93
|
};
|
|
58
|
-
}
|
|
59
94
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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 '
|
|
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
|
-
|
|
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
|
|
15
|
-
* index still has to fetch sixty pages, and a
|
|
16
|
-
*
|
|
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
|
-
}
|