@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.
Files changed (34) hide show
  1. package/app/components/DuxtOpenApiClient.vue +42 -79
  2. package/app/components/DuxtSearch.vue +9 -3
  3. package/app/components/DuxtVersion.vue +7 -7
  4. package/app/components/content/Mermaid.vue +41 -27
  5. package/app/composables/useDuxtRequest.ts +167 -0
  6. package/app/composables/useDuxtVersion.ts +4 -12
  7. package/app/utils/mermaid-render.ts +31 -0
  8. package/app/utils/request-samples.ts +68 -24
  9. package/build/bruno/bruno-parse.ts +13 -1
  10. package/build/cli/report.ts +0 -0
  11. package/build/openapi/openapi-parse.ts +24 -4
  12. package/build/sections/section-input.ts +41 -3
  13. package/build/sections/sections.ts +1 -1
  14. package/build/sources/sources-cache.ts +9 -11
  15. package/build/sources/sources.ts +45 -2
  16. package/i18n/locales/de/duxt/openapi.json +5 -1
  17. package/i18n/locales/en/duxt/openapi.json +5 -1
  18. package/i18n/locales/es/duxt/openapi.json +5 -1
  19. package/i18n/locales/fr/duxt/openapi.json +5 -1
  20. package/i18n/locales/pt/duxt/openapi.json +5 -1
  21. package/modules/redirects.ts +27 -1
  22. package/package.json +30 -28
  23. package/server/devtools/entry-path.ts +48 -3
  24. package/server/devtools/handler.ts +22 -2
  25. package/server/mcp/tools/list-pages.ts +8 -4
  26. package/server/mcp/tools/read-page.ts +4 -26
  27. package/server/mcp/tools/search-docs.ts +11 -18
  28. package/server/middleware/raw-markdown.ts +7 -41
  29. package/server/routes/llms-full.txt.get.ts +2 -1
  30. package/server/routes/llms.txt.get.ts +2 -1
  31. package/server/routes/rss.xml.get.ts +2 -1
  32. package/server/utils/doc-page.ts +43 -0
  33. package/server/utils/mcp-docs.ts +94 -35
  34. 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.5.1-dev-main.48-5d5fca9",
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.0",
71
+ "@codemirror/commands": "^6.11.1",
72
72
  "@codemirror/lang-json": "^6.0.2",
73
- "@codemirror/language": "^6.12.4",
73
+ "@codemirror/language": "^6.13.1",
74
74
  "@codemirror/lint": "^6.9.7",
75
- "@codemirror/state": "^6.7.4",
76
- "@codemirror/view": "^6.43.11",
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.127",
79
- "@iconify-json/simple-icons": "^1.2.94",
80
- "@lezer/highlight": "^1.2.3",
81
- "@lucide/vue": "^1.37.0",
82
- "@nuxt/content": "^3.16.0",
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.19.0",
88
- "@nuxtjs/seo": "^5.3.14",
89
- "@nuxtjs/sitemap": "^8.5.0",
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.4.3",
91
+ "@shikijs/transformers": "^4.5.0",
92
92
  "@tailwindcss/vite": "^4.3.3",
93
- "@vueuse/core": "^14.4.0",
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": "^11.17.2",
100
- "reka-ui": "^2.10.4",
101
- "satori": "^0.33.4",
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.4.3",
104
- "tailwind-merge": "^3.6.0",
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.0",
109
- "zod": "^4.5.4"
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.0",
114
- "jsdom": "^30.0.1",
115
- "nuxt": "^4.5.2",
113
+ "@types/node": "^26.6.4",
114
+ "jsdom": "^30.1.2",
115
+ "nuxt": "^4.6.0",
116
116
  "typescript": "^6.0.3",
117
- "vitest": "^4.1.11"
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 { join, resolve } from 'node:path';
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 (typeof name !== 'string' || !name || name === '.' || name === '..') {
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 { cacheEntryToDrop } from './entry-path';
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
- duxtScopedPages,
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 pages = await duxtScopedPages(event, scope, setup);
69
- const { items, nextCursor } = duxtCursorPage(pages, limit, cursor);
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 { splitLocalePath } from '../../../app/utils/locale-path';
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 { 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
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
- duxtScopedPages,
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 term = query.toLowerCase();
72
- const holds = (value: string | undefined) =>
73
- Boolean(value?.toLowerCase().includes(term));
74
-
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))
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 (!matches.length)
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 { splitLocalePath } from '../../app/utils/locale-path';
4
- import { sourcesForRoute } from '../../build/sources/sources-resolve';
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 appConfig = useAppConfig() as { duxt?: Partial<DuxtConfig> };
25
- const duxt = mergeDuxtConfig(appConfig.duxt, duxtDefaults);
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
- (i18n?.locales ?? []).map((entry) =>
38
- typeof entry === 'string' ? entry : entry.code
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 = getRequestURL(event).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 = getRequestURL(event).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 = getRequestURL(event).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
+ }
@@ -379,56 +379,115 @@ export interface DuxtScopedPage {
379
379
  source: DuxtResolvedSource;
380
380
  }
381
381
 
382
- /**
383
- * Every page in a scope, translations resolved.
384
- *
385
- * One pass per collection rather than one query per page: the chain is short
386
- * and the first collection holding a path wins, which is the same fallback the
387
- * rendered page takes. `rawbody` is only asked for when the caller needs it —
388
- * a table of contents that loads every body is the cost `llms.txt` already had
389
- * to have taken out of it.
390
- */
391
- export async function duxtScopedPages(
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
- withBody = false
396
- ): Promise<DuxtScopedPage[]> {
397
- const fields = withBody
398
- ? (['path', 'title', 'description', 'rawbody', 'search'] as const)
399
- : (['path', 'title', 'description', 'search'] as const);
400
- const found = new Map<string, DuxtScopedPage>();
401
-
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 pages = (await queryCollection(
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
- .all()) as unknown as {
410
- path?: string;
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
- const documentPath = page.path;
419
- if (!documentPath || found.has(documentPath)) continue;
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(documentPath, scope.locale, setup),
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. */