@kirchdev/duxt 0.5.1-dev-main.48-5d5fca9 → 0.6.1-dev-main.51-119d6cc
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/app/components/DuxtOpenApiClient.vue +42 -79
- package/app/components/DuxtSearch.vue +9 -3
- package/app/components/DuxtVersion.vue +7 -7
- package/app/components/content/Mermaid.vue +41 -27
- package/app/composables/useDuxtRequest.ts +167 -0
- package/app/composables/useDuxtVersion.ts +4 -12
- package/app/utils/mermaid-render.ts +31 -0
- package/app/utils/request-samples.ts +68 -24
- package/build/bruno/bruno-parse.ts +13 -1
- package/build/cli/report.ts +0 -0
- package/build/openapi/openapi-parse.ts +24 -4
- package/build/sections/section-input.ts +41 -3
- package/build/sections/sections.ts +1 -1
- package/build/sources/sources-cache.ts +9 -11
- package/build/sources/sources.ts +45 -2
- package/i18n/locales/de/duxt/openapi.json +5 -1
- package/i18n/locales/en/duxt/openapi.json +5 -1
- package/i18n/locales/es/duxt/openapi.json +5 -1
- package/i18n/locales/fr/duxt/openapi.json +5 -1
- package/i18n/locales/pt/duxt/openapi.json +5 -1
- package/modules/redirects.ts +27 -1
- package/package.json +30 -28
- package/server/devtools/entry-path.ts +48 -3
- package/server/devtools/handler.ts +22 -2
- package/server/mcp/tools/list-pages.ts +8 -4
- package/server/mcp/tools/read-page.ts +4 -26
- package/server/mcp/tools/search-docs.ts +11 -18
- package/server/middleware/raw-markdown.ts +7 -41
- package/server/routes/llms-full.txt.get.ts +2 -1
- package/server/routes/llms.txt.get.ts +2 -1
- package/server/routes/rss.xml.get.ts +2 -1
- package/server/utils/doc-page.ts +43 -0
- package/server/utils/mcp-docs.ts +94 -35
- package/server/utils/public-origin.ts +25 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://www.schemastore.org/package.json",
|
|
3
3
|
"name": "@kirchdev/duxt",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.1-dev-main.51-119d6cc",
|
|
5
5
|
"description": "duxt is a Nuxt layer: extend it and your docs/ folder becomes a site, with theme, search, API reference and llms.txt included. Point it at other repositories, or at tags of the same one, and each becomes a version.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"content-v3",
|
|
@@ -68,59 +68,61 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@codemirror/autocomplete": "^6.20.3",
|
|
71
|
-
"@codemirror/commands": "^6.11.
|
|
71
|
+
"@codemirror/commands": "^6.11.1",
|
|
72
72
|
"@codemirror/lang-json": "^6.0.2",
|
|
73
|
-
"@codemirror/language": "^6.
|
|
73
|
+
"@codemirror/language": "^6.13.1",
|
|
74
74
|
"@codemirror/lint": "^6.9.7",
|
|
75
|
-
"@codemirror/state": "^6.7.
|
|
76
|
-
"@codemirror/view": "^6.43.
|
|
75
|
+
"@codemirror/state": "^6.7.6",
|
|
76
|
+
"@codemirror/view": "^6.43.14",
|
|
77
77
|
"@iconify-json/flag": "^1.2.12",
|
|
78
|
-
"@iconify-json/lucide": "^1.2.
|
|
79
|
-
"@iconify-json/simple-icons": "^1.2.
|
|
80
|
-
"@lezer/highlight": "^1.2.
|
|
81
|
-
"@lucide/vue": "^1.
|
|
82
|
-
"@nuxt/content": "^3.16.
|
|
78
|
+
"@iconify-json/lucide": "^1.2.141",
|
|
79
|
+
"@iconify-json/simple-icons": "^1.2.99",
|
|
80
|
+
"@lezer/highlight": "^1.2.5",
|
|
81
|
+
"@lucide/vue": "^1.53.0",
|
|
82
|
+
"@nuxt/content": "^3.16.1",
|
|
83
83
|
"@nuxt/icon": "^2.5.1",
|
|
84
84
|
"@nuxt/image": "^2.1.0",
|
|
85
85
|
"@nuxtjs/color-mode": "^4.0.1",
|
|
86
86
|
"@nuxtjs/i18n": "^10.6.0",
|
|
87
|
-
"@nuxtjs/mcp-toolkit": "^0.
|
|
88
|
-
"@nuxtjs/seo": "^5.3.
|
|
89
|
-
"@nuxtjs/sitemap": "^8.
|
|
87
|
+
"@nuxtjs/mcp-toolkit": "^0.24.1",
|
|
88
|
+
"@nuxtjs/seo": "^5.3.16",
|
|
89
|
+
"@nuxtjs/sitemap": "^8.6.1",
|
|
90
90
|
"@resvg/resvg-js": "^2.6.2",
|
|
91
|
-
"@shikijs/transformers": "^4.
|
|
91
|
+
"@shikijs/transformers": "^4.5.0",
|
|
92
92
|
"@tailwindcss/vite": "^4.3.3",
|
|
93
|
-
"@vueuse/core": "^
|
|
93
|
+
"@vueuse/core": "^15.0.0",
|
|
94
94
|
"citty": "^0.2.2",
|
|
95
95
|
"class-variance-authority": "^0.7.1",
|
|
96
96
|
"clsx": "^2.1.1",
|
|
97
97
|
"fuse.js": "^7.5.0",
|
|
98
98
|
"jiti": "^2.7.0",
|
|
99
|
-
"mermaid": "^
|
|
100
|
-
"reka-ui": "^2.
|
|
101
|
-
"satori": "^0.
|
|
99
|
+
"mermaid": "^12.1.0",
|
|
100
|
+
"reka-ui": "^2.11.0",
|
|
101
|
+
"satori": "^0.44.0",
|
|
102
102
|
"shadcn-nuxt": "^2.8.2",
|
|
103
|
-
"shiki": "^4.
|
|
104
|
-
"tailwind-merge": "^3.
|
|
103
|
+
"shiki": "^4.5.0",
|
|
104
|
+
"tailwind-merge": "^3.7.0",
|
|
105
105
|
"tailwindcss": "^4.3.3",
|
|
106
106
|
"tw-animate-css": "^1.4.0",
|
|
107
107
|
"vue-sonner": "^2.0.9",
|
|
108
|
-
"yaml": "^2.9.
|
|
109
|
-
"zod": "^4.5
|
|
108
|
+
"yaml": "^2.9.1",
|
|
109
|
+
"zod": "^4.6.5"
|
|
110
110
|
},
|
|
111
111
|
"devDependencies": {
|
|
112
112
|
"@types/jsdom": "^30.0.0",
|
|
113
|
-
"@types/node": "^26.4
|
|
114
|
-
"jsdom": "^30.
|
|
115
|
-
"nuxt": "^4.
|
|
113
|
+
"@types/node": "^26.6.4",
|
|
114
|
+
"jsdom": "^30.1.2",
|
|
115
|
+
"nuxt": "^4.6.0",
|
|
116
116
|
"typescript": "^6.0.3",
|
|
117
|
-
"vitest": "^
|
|
117
|
+
"vitest": "^5.0.3",
|
|
118
|
+
"vue": "^3.5.43"
|
|
118
119
|
},
|
|
119
120
|
"peerDependencies": {
|
|
120
|
-
"nuxt": "^4.0.0"
|
|
121
|
+
"nuxt": "^4.0.0",
|
|
122
|
+
"vue": "^3.5.0"
|
|
121
123
|
},
|
|
122
124
|
"engines": {
|
|
123
|
-
"node": ">=24"
|
|
125
|
+
"node": ">=24.15.0"
|
|
124
126
|
},
|
|
125
127
|
"scripts": {
|
|
126
128
|
"check:previews": "node scripts/check-previews.ts",
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstatSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Resolve a cache entry the tab may delete, or refuse.
|
|
5
6
|
*
|
|
6
|
-
* Its own file, importing nothing but `node:path`, for the same reason
|
|
7
|
+
* Its own file, importing nothing but `node:path` and `node:fs`, for the same reason
|
|
7
8
|
* `validate-report.ts` is its own file: everything else under `devtools/`
|
|
8
9
|
* reaches the virtual module the build generates, and a guard that cannot be
|
|
9
10
|
* tested without booting Nitro is a guard nobody re-checks. This is the one
|
|
@@ -19,7 +20,13 @@ export function cacheEntryToDrop(
|
|
|
19
20
|
dataDir: string,
|
|
20
21
|
name: unknown
|
|
21
22
|
): string | undefined {
|
|
22
|
-
if (
|
|
23
|
+
if (
|
|
24
|
+
typeof name !== 'string' ||
|
|
25
|
+
!name ||
|
|
26
|
+
name === '.' ||
|
|
27
|
+
name === '..' ||
|
|
28
|
+
isAbsolute(name)
|
|
29
|
+
) {
|
|
23
30
|
return undefined;
|
|
24
31
|
}
|
|
25
32
|
|
|
@@ -28,3 +35,41 @@ export function cacheEntryToDrop(
|
|
|
28
35
|
|
|
29
36
|
return target;
|
|
30
37
|
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether `target` is one of the entries the panel lists: a real directory.
|
|
41
|
+
*
|
|
42
|
+
* The panel offers downloaded repositories, which are directories. Content's
|
|
43
|
+
* parse cache sits beside them as a file and is not offered, and a symlink is
|
|
44
|
+
* neither — so neither may be dropped by naming it.
|
|
45
|
+
*/
|
|
46
|
+
export function isListedCacheEntry(target: string): boolean {
|
|
47
|
+
try {
|
|
48
|
+
return lstatSync(target).isDirectory();
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether a mutation came from the tab itself rather than from another site.
|
|
56
|
+
*
|
|
57
|
+
* The dev server listens on localhost, and any page the developer has open can
|
|
58
|
+
* POST a form at it — no cookie is needed when the authority is simply being
|
|
59
|
+
* able to reach the listener. So the request has to prove it came from this
|
|
60
|
+
* origin: `Sec-Fetch-Site`, which a browser sets and a page cannot, must say
|
|
61
|
+
* `same-origin`; a browser too old to send it still sends `Origin` on a
|
|
62
|
+
* cross-origin POST, which must then match the server's own. A request with
|
|
63
|
+
* neither did not come from a browser, and a cross-site request is one only a
|
|
64
|
+
* browser can be tricked into sending.
|
|
65
|
+
*/
|
|
66
|
+
export function sameOriginMutation(headers: {
|
|
67
|
+
secFetchSite?: string;
|
|
68
|
+
origin?: string;
|
|
69
|
+
selfOrigin: string;
|
|
70
|
+
}): boolean {
|
|
71
|
+
if (headers.secFetchSite) return headers.secFetchSite === 'same-origin';
|
|
72
|
+
if (headers.origin) return headers.origin === headers.selfOrigin;
|
|
73
|
+
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
@@ -4,7 +4,11 @@ import { context } from './context';
|
|
|
4
4
|
import { page, PANELS } from './shell';
|
|
5
5
|
import { pathsPanel, sourcesPanel, versionsPanel } from './sources';
|
|
6
6
|
import { checksPanel, pagesPanel, searchPanel } from './content';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
cacheEntryToDrop,
|
|
9
|
+
isListedCacheEntry,
|
|
10
|
+
sameOriginMutation
|
|
11
|
+
} from './entry-path';
|
|
8
12
|
import { cachePanel, configPanel, i18nPanel, redirectsPanel } from './system';
|
|
9
13
|
|
|
10
14
|
/**
|
|
@@ -39,10 +43,26 @@ export default defineEventHandler(async (event) => {
|
|
|
39
43
|
// The one mutation in the tab: dropping a cache entry. A POST because it
|
|
40
44
|
// deletes, so a reload or a prefetch cannot trigger it.
|
|
41
45
|
if (event.method === 'POST' && slug === 'cache') {
|
|
46
|
+
const allowed = sameOriginMutation({
|
|
47
|
+
secFetchSite: getRequestHeader(event, 'sec-fetch-site'),
|
|
48
|
+
origin: getRequestHeader(event, 'origin'),
|
|
49
|
+
selfOrigin: getRequestURL(event).origin
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!allowed) {
|
|
53
|
+
setResponseStatus(event, 403);
|
|
54
|
+
return html(
|
|
55
|
+
page(
|
|
56
|
+
slug,
|
|
57
|
+
cachePanel('Refused: the request did not come from this tab.')
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
42
62
|
const body = (await readBody(event)) as { drop?: string } | undefined;
|
|
43
63
|
const target = cacheEntryToDrop(context.dataDir, body?.drop);
|
|
44
64
|
|
|
45
|
-
if (!target) {
|
|
65
|
+
if (!target || !isListedCacheEntry(target)) {
|
|
46
66
|
return html(
|
|
47
67
|
page(
|
|
48
68
|
slug,
|
|
@@ -3,12 +3,11 @@ import {
|
|
|
3
3
|
DUXT_PAGE_MAX,
|
|
4
4
|
DUXT_PAGE_SIZE,
|
|
5
5
|
duxtBadCursor,
|
|
6
|
-
duxtCursorPage,
|
|
7
6
|
duxtLocaleSetup,
|
|
8
7
|
duxtMcpEvent,
|
|
9
8
|
duxtPageLine,
|
|
10
9
|
duxtScope,
|
|
11
|
-
|
|
10
|
+
duxtScopedPage,
|
|
12
11
|
duxtSources,
|
|
13
12
|
duxtUnknownScope,
|
|
14
13
|
duxtValidCursor
|
|
@@ -65,8 +64,13 @@ export default defineMcpTool({
|
|
|
65
64
|
const scope = duxtScope(prefix, duxtSources(), setup);
|
|
66
65
|
if (!scope) return duxtUnknownScope(prefix!);
|
|
67
66
|
|
|
68
|
-
const
|
|
69
|
-
|
|
67
|
+
const { items, nextCursor } = await duxtScopedPage(
|
|
68
|
+
event,
|
|
69
|
+
scope,
|
|
70
|
+
setup,
|
|
71
|
+
limit,
|
|
72
|
+
cursor
|
|
73
|
+
);
|
|
70
74
|
|
|
71
75
|
if (!items.length)
|
|
72
76
|
return {
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import { queryCollection } from '@nuxt/content/nitro';
|
|
2
1
|
import { z } from 'zod';
|
|
3
|
-
import {
|
|
4
|
-
import { sourcesForRoute } from '../../../build/sources/sources-resolve';
|
|
2
|
+
import { duxtDocPage } from '../../utils/doc-page';
|
|
5
3
|
import {
|
|
6
4
|
duxtAuthoredMarkdown,
|
|
7
5
|
duxtFrontmatter,
|
|
@@ -49,29 +47,9 @@ export default defineMcpTool({
|
|
|
49
47
|
const event = duxtMcpEvent();
|
|
50
48
|
const setup = duxtLocaleSetup(event);
|
|
51
49
|
const sources = duxtSources();
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// siblings, the configured fallback, then the untranslated original.
|
|
56
|
-
const chain = sourcesForRoute(
|
|
57
|
-
wanted,
|
|
58
|
-
locale ?? setup.defaultLocale,
|
|
59
|
-
sources,
|
|
60
|
-
setup.fallbackLocale
|
|
61
|
-
);
|
|
62
|
-
|
|
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;
|
|
50
|
+
const found = await duxtDocPage(event, path, sources, setup);
|
|
51
|
+
if (found) {
|
|
52
|
+
const { page, source, locale, path: wanted } = found;
|
|
75
53
|
|
|
76
54
|
const frontmatter = duxtFrontmatter({
|
|
77
55
|
path: duxtPublicPath(wanted, locale, setup),
|
|
@@ -3,18 +3,16 @@ import {
|
|
|
3
3
|
DUXT_PAGE_MAX,
|
|
4
4
|
DUXT_PAGE_SIZE,
|
|
5
5
|
duxtBadCursor,
|
|
6
|
-
duxtCursorPage,
|
|
7
6
|
duxtExcerpt,
|
|
8
7
|
duxtLocaleSetup,
|
|
9
8
|
duxtMcpEvent,
|
|
10
9
|
duxtPageLine,
|
|
11
10
|
duxtScope,
|
|
12
|
-
|
|
11
|
+
duxtScopedPage,
|
|
13
12
|
duxtSources,
|
|
14
13
|
duxtUnknownScope,
|
|
15
14
|
duxtValidCursor
|
|
16
15
|
} from '../../utils/mcp-docs';
|
|
17
|
-
import { duxtPageSearchable } from '../../../app/utils/page-controls';
|
|
18
16
|
|
|
19
17
|
/**
|
|
20
18
|
* Search, so a model does not have to read every page to find one.
|
|
@@ -60,7 +58,7 @@ export default defineMcpTool({
|
|
|
60
58
|
.describe('`nextCursor` from a previous call, to continue the results')
|
|
61
59
|
},
|
|
62
60
|
|
|
63
|
-
async handler({ query, prefix, limit, cursor }) {
|
|
61
|
+
async handler({ query, prefix, limit, cursor }, extra) {
|
|
64
62
|
if (cursor && !duxtValidCursor(cursor)) return duxtBadCursor(cursor);
|
|
65
63
|
|
|
66
64
|
const event = duxtMcpEvent();
|
|
@@ -68,21 +66,17 @@ export default defineMcpTool({
|
|
|
68
66
|
const scope = duxtScope(prefix, duxtSources(), setup);
|
|
69
67
|
if (!scope) return duxtUnknownScope(prefix!);
|
|
70
68
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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))
|
|
69
|
+
const { items, nextCursor } = await duxtScopedPage(
|
|
70
|
+
event,
|
|
71
|
+
scope,
|
|
72
|
+
setup,
|
|
73
|
+
limit,
|
|
74
|
+
cursor,
|
|
75
|
+
query.toLowerCase(),
|
|
76
|
+
extra.signal
|
|
83
77
|
);
|
|
84
78
|
|
|
85
|
-
if (!
|
|
79
|
+
if (!items.length)
|
|
86
80
|
return {
|
|
87
81
|
content: [
|
|
88
82
|
{
|
|
@@ -92,7 +86,6 @@ export default defineMcpTool({
|
|
|
92
86
|
]
|
|
93
87
|
};
|
|
94
88
|
|
|
95
|
-
const { items, nextCursor } = duxtCursorPage(matches, limit, cursor);
|
|
96
89
|
const listing = items.flatMap((page) => {
|
|
97
90
|
const excerpt = duxtExcerpt(page.rawbody, query);
|
|
98
91
|
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { queryCollection } from '@nuxt/content/nitro';
|
|
2
1
|
import { defineEventHandler, setHeader } from 'h3';
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { duxtDefaults, mergeDuxtConfig } from '../../app/utils/duxt-config';
|
|
2
|
+
import { duxtDocPage } from '../utils/doc-page';
|
|
3
|
+
import { duxtLocaleSetup, duxtSources } from '../utils/mcp-docs';
|
|
6
4
|
import { stripFrontmatter } from '../../build/content/frontmatter';
|
|
7
5
|
|
|
8
6
|
/**
|
|
@@ -21,45 +19,13 @@ export default defineEventHandler(async (event) => {
|
|
|
21
19
|
const path = event.path?.split('?')[0];
|
|
22
20
|
if (!path?.endsWith('.md')) return;
|
|
23
21
|
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const i18n = (
|
|
28
|
-
useRuntimeConfig(event).public as {
|
|
29
|
-
i18n?: {
|
|
30
|
-
locales?: ({ code: string } | string)[];
|
|
31
|
-
defaultLocale?: string;
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
).i18n;
|
|
35
|
-
const { path: wanted, locale } = splitLocalePath(
|
|
22
|
+
const found = await duxtDocPage(
|
|
23
|
+
event,
|
|
36
24
|
path.slice(0, -'.md'.length),
|
|
37
|
-
(
|
|
38
|
-
|
|
39
|
-
)
|
|
40
|
-
);
|
|
41
|
-
// Nuxt i18n loads the consumer's merged Vue I18n config in its request hook.
|
|
42
|
-
// Browser-language detection's fallback is a different policy.
|
|
43
|
-
const fallbackLocale = event.context.nuxtI18n?.vueI18nOptions
|
|
44
|
-
?.fallbackLocale as string | string[] | undefined;
|
|
45
|
-
const sources = sourcesForRoute(
|
|
46
|
-
wanted,
|
|
47
|
-
locale ?? i18n?.defaultLocale,
|
|
48
|
-
duxt.resolvedSources ?? [],
|
|
49
|
-
fallbackLocale
|
|
25
|
+
duxtSources(),
|
|
26
|
+
duxtLocaleSetup(event)
|
|
50
27
|
);
|
|
51
|
-
|
|
52
|
-
let page;
|
|
53
|
-
for (const source of sources) {
|
|
54
|
-
page = await queryCollection(
|
|
55
|
-
event,
|
|
56
|
-
source.collection as Parameters<typeof queryCollection>[1]
|
|
57
|
-
)
|
|
58
|
-
.path(wanted)
|
|
59
|
-
.select('title', 'rawbody')
|
|
60
|
-
.first();
|
|
61
|
-
if (page) break;
|
|
62
|
-
}
|
|
28
|
+
const page = found?.page;
|
|
63
29
|
|
|
64
30
|
// No page is not this middleware's error to raise: falling through lets the
|
|
65
31
|
// app answer with its own 404, which knows how to suggest a near miss.
|
|
@@ -4,6 +4,7 @@ import { llmsPages } from '../utils/llms-pages';
|
|
|
4
4
|
// pages use, or the two descriptions drift apart.
|
|
5
5
|
import { duxtDefaults, mergeDuxtConfig } from '../../app/utils/duxt-config';
|
|
6
6
|
import { resolveServerTexts } from '../utils/duxt-server-text';
|
|
7
|
+
import { duxtPublicOrigin } from '../utils/public-origin';
|
|
7
8
|
import { stripFrontmatter } from '../../build/content/frontmatter';
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -27,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
|
|
27
28
|
|
|
28
29
|
const pages = await llmsPages(event, duxt.resolvedSources, true);
|
|
29
30
|
|
|
30
|
-
const origin =
|
|
31
|
+
const origin = duxtPublicOrigin(event);
|
|
31
32
|
|
|
32
33
|
const lines = [
|
|
33
34
|
`# ${duxt.title}`,
|
|
@@ -4,6 +4,7 @@ import { llmsPages } from '../utils/llms-pages';
|
|
|
4
4
|
// pages use, or the two descriptions drift apart.
|
|
5
5
|
import { duxtDefaults, mergeDuxtConfig } from '../../app/utils/duxt-config';
|
|
6
6
|
import { resolveServerTexts } from '../utils/duxt-server-text';
|
|
7
|
+
import { duxtPublicOrigin } from '../utils/public-origin';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* llms.txt — the site's own map, for a model reading it.
|
|
@@ -24,7 +25,7 @@ export default defineEventHandler(async (event) => {
|
|
|
24
25
|
// has none by that name, and llms.txt is the whole site's index.
|
|
25
26
|
const pages = await llmsPages(event, duxt.resolvedSources);
|
|
26
27
|
|
|
27
|
-
const origin =
|
|
28
|
+
const origin = duxtPublicOrigin(event);
|
|
28
29
|
|
|
29
30
|
const lines = [
|
|
30
31
|
`# ${duxt.title}`,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { queryCollection } from '@nuxt/content/nitro';
|
|
2
2
|
import { duxtDefaults, mergeDuxtConfig } from '../../app/utils/duxt-config';
|
|
3
3
|
import { resolveServerTexts } from '../utils/duxt-server-text';
|
|
4
|
+
import { duxtPublicOrigin } from '../utils/public-origin';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* An RSS feed over one section — a changelog, a release log, a blog.
|
|
@@ -36,7 +37,7 @@ export default defineEventHandler(async (event) => {
|
|
|
36
37
|
mergeDuxtConfig(appConfig.duxt, duxtDefaults)
|
|
37
38
|
);
|
|
38
39
|
|
|
39
|
-
const origin =
|
|
40
|
+
const origin = duxtPublicOrigin(event);
|
|
40
41
|
const feed = duxt.feed;
|
|
41
42
|
const title = typeof feed?.title === 'string' ? feed.title : undefined;
|
|
42
43
|
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { queryCollection } from '@nuxt/content/nitro';
|
|
2
|
+
import type { H3Event } from 'h3';
|
|
3
|
+
import { splitLocalePath } from '../../app/utils/locale-path';
|
|
4
|
+
import {
|
|
5
|
+
sourcesForRoute,
|
|
6
|
+
type DuxtResolvedSource
|
|
7
|
+
} from '../../build/sources/sources-resolve';
|
|
8
|
+
import type { DuxtLocaleSetup } from './mcp-docs';
|
|
9
|
+
|
|
10
|
+
/** Request-bound lookup shared by HTTP Markdown and MCP, including provenance. */
|
|
11
|
+
export async function duxtDocPage(
|
|
12
|
+
event: H3Event,
|
|
13
|
+
path: string,
|
|
14
|
+
sources: DuxtResolvedSource[],
|
|
15
|
+
setup: DuxtLocaleSetup
|
|
16
|
+
) {
|
|
17
|
+
const { locale, path: wanted } = splitLocalePath(path, setup.codes);
|
|
18
|
+
for (const source of sourcesForRoute(
|
|
19
|
+
wanted,
|
|
20
|
+
locale ?? setup.defaultLocale,
|
|
21
|
+
sources,
|
|
22
|
+
setup.fallbackLocale
|
|
23
|
+
)) {
|
|
24
|
+
const page = await queryCollection(
|
|
25
|
+
event,
|
|
26
|
+
source.collection as Parameters<typeof queryCollection>[1]
|
|
27
|
+
)
|
|
28
|
+
.path(wanted)
|
|
29
|
+
.select('title', 'description', 'rawbody')
|
|
30
|
+
.first();
|
|
31
|
+
if (page)
|
|
32
|
+
return {
|
|
33
|
+
page: page as {
|
|
34
|
+
title?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
rawbody?: string;
|
|
37
|
+
},
|
|
38
|
+
source,
|
|
39
|
+
locale,
|
|
40
|
+
path: wanted
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
package/server/utils/mcp-docs.ts
CHANGED
|
@@ -379,56 +379,115 @@ export interface DuxtScopedPage {
|
|
|
379
379
|
source: DuxtResolvedSource;
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
382
|
+
// Production collections are immutable for the lifetime of a deployment.
|
|
383
|
+
// Cache paths alone: retaining raw bodies here would turn every Worker into a
|
|
384
|
+
// second database. Dev bypasses the cache so authored changes appear at once.
|
|
385
|
+
const collectionPaths = new Map<string, Promise<string[]>>();
|
|
386
|
+
async function paths(event: H3Event, collection: string): Promise<string[]> {
|
|
387
|
+
const load = async () => {
|
|
388
|
+
const rows = await queryCollection(
|
|
389
|
+
event,
|
|
390
|
+
collection as Parameters<typeof queryCollection>[1]
|
|
391
|
+
)
|
|
392
|
+
.select('path')
|
|
393
|
+
.all();
|
|
394
|
+
return rows.flatMap((row) =>
|
|
395
|
+
typeof row.path === 'string' ? [row.path] : []
|
|
396
|
+
);
|
|
397
|
+
};
|
|
398
|
+
if (import.meta.dev) return load();
|
|
399
|
+
let cached = collectionPaths.get(collection);
|
|
400
|
+
if (!cached) {
|
|
401
|
+
cached = load();
|
|
402
|
+
collectionPaths.set(collection, cached);
|
|
403
|
+
cached.catch(() => collectionPaths.delete(collection));
|
|
404
|
+
}
|
|
405
|
+
return cached;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Bounded payload reads, retaining locale precedence and Unicode matching. */
|
|
409
|
+
export async function duxtScopedPage(
|
|
392
410
|
event: H3Event,
|
|
393
411
|
scope: DuxtScope,
|
|
394
412
|
setup: DuxtLocaleSetup,
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
const found = new Map<string,
|
|
401
|
-
|
|
413
|
+
limit: number = DUXT_PAGE_SIZE,
|
|
414
|
+
cursor?: string,
|
|
415
|
+
term?: string,
|
|
416
|
+
signal?: AbortSignal
|
|
417
|
+
): Promise<DuxtCursorPage<DuxtScopedPage>> {
|
|
418
|
+
const found = new Map<string, DuxtResolvedSource>();
|
|
402
419
|
for (const group of scope.groups) {
|
|
403
420
|
for (const source of group.sources) {
|
|
404
|
-
const
|
|
421
|
+
for (const path of await paths(event, source.collection)) {
|
|
422
|
+
if (!found.has(path)) found.set(path, source);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
const candidates = [...found]
|
|
427
|
+
.map(([documentPath, source]) => ({
|
|
428
|
+
documentPath,
|
|
429
|
+
source,
|
|
430
|
+
path: duxtPublicPath(documentPath, scope.locale, setup)
|
|
431
|
+
}))
|
|
432
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
433
|
+
.filter((page) => !cursor || page.path.localeCompare(cursor) > 0);
|
|
434
|
+
const size = Math.max(1, Math.min(DUXT_PAGE_MAX, limit));
|
|
435
|
+
const matches: DuxtScopedPage[] = [];
|
|
436
|
+
const batchSize = term === undefined ? size + 1 : 64;
|
|
437
|
+
const fields =
|
|
438
|
+
term === undefined
|
|
439
|
+
? (['path', 'title', 'description', 'search'] as const)
|
|
440
|
+
: (['path', 'title', 'description', 'rawbody', 'search'] as const);
|
|
441
|
+
for (
|
|
442
|
+
let offset = 0;
|
|
443
|
+
offset < candidates.length && matches.length <= size;
|
|
444
|
+
offset += batchSize
|
|
445
|
+
) {
|
|
446
|
+
// A miss reads the whole scope, which is the price of exact matching; a
|
|
447
|
+
// client that has given up on the answer should not keep paying it.
|
|
448
|
+
signal?.throwIfAborted();
|
|
449
|
+
const batch = candidates.slice(offset, offset + batchSize);
|
|
450
|
+
const rows = new Map<string, DuxtScopedPage>();
|
|
451
|
+
for (const source of new Set(batch.map((page) => page.source))) {
|
|
452
|
+
const wanted = batch.filter((page) => page.source === source);
|
|
453
|
+
const pages = await queryCollection(
|
|
405
454
|
event,
|
|
406
455
|
source.collection as Parameters<typeof queryCollection>[1]
|
|
407
456
|
)
|
|
457
|
+
.where(
|
|
458
|
+
'path',
|
|
459
|
+
'IN',
|
|
460
|
+
wanted.map((page) => page.documentPath)
|
|
461
|
+
)
|
|
408
462
|
.select(...fields)
|
|
409
|
-
.
|
|
410
|
-
|
|
411
|
-
title?: string;
|
|
412
|
-
description?: string;
|
|
413
|
-
rawbody?: string;
|
|
414
|
-
search?: boolean;
|
|
415
|
-
}[];
|
|
416
|
-
|
|
463
|
+
.limit(wanted.length)
|
|
464
|
+
.all();
|
|
417
465
|
for (const page of pages) {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
found.set(documentPath, {
|
|
466
|
+
if (typeof page.path !== 'string') continue;
|
|
467
|
+
rows.set(page.path, {
|
|
422
468
|
...page,
|
|
423
|
-
documentPath,
|
|
424
|
-
path: duxtPublicPath(
|
|
469
|
+
documentPath: page.path,
|
|
470
|
+
path: duxtPublicPath(page.path, scope.locale, setup),
|
|
425
471
|
source
|
|
426
|
-
});
|
|
472
|
+
} as DuxtScopedPage);
|
|
427
473
|
}
|
|
428
474
|
}
|
|
475
|
+
for (const candidate of batch) {
|
|
476
|
+
const page = rows.get(candidate.documentPath);
|
|
477
|
+
if (!page) continue;
|
|
478
|
+
if (
|
|
479
|
+
term !== undefined &&
|
|
480
|
+
(page.search === false ||
|
|
481
|
+
![page.title, page.description, page.rawbody].some((value) =>
|
|
482
|
+
value?.toLowerCase().includes(term)
|
|
483
|
+
))
|
|
484
|
+
)
|
|
485
|
+
continue;
|
|
486
|
+
matches.push(page);
|
|
487
|
+
if (matches.length > size) break;
|
|
488
|
+
}
|
|
429
489
|
}
|
|
430
|
-
|
|
431
|
-
return [...found.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
490
|
+
return duxtCursorPage(matches, size);
|
|
432
491
|
}
|
|
433
492
|
|
|
434
493
|
/** How many results a listing returns when the caller does not say. */
|