@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
@@ -62,10 +62,50 @@ function inlineList(items: unknown[], rendered: string[]): string | undefined {
62
62
  /** A JavaScript key that needs no quotes. */
63
63
  const bareKey = /^[A-Za-z_$][\w$]*$/;
64
64
 
65
+ /*
66
+ * EVERY STRING IN A SAMPLE IS DATA, and is written with an encoder that is
67
+ * complete for its language. The URL, the method, the headers and the body all
68
+ * come from a document this layer did not write — a server URL holding
69
+ * `'+(…)+'` must stay one string literal, not become an expression in a snippet
70
+ * a reader copies and runs. Escaping the quote alone is not enough: a value
71
+ * ending in a backslash escapes the closing quote instead.
72
+ */
73
+
74
+ /** A JavaScript string literal, single-quoted as a person writes one. */
75
+ function jsString(value: string): string {
76
+ const inner = JSON.stringify(value)
77
+ .slice(1, -1)
78
+ .replaceAll('\\"', '"')
79
+ .replaceAll("'", "\\'");
80
+
81
+ return `'${inner}'`;
82
+ }
83
+
84
+ /**
85
+ * A Python or Go string literal. JSON's escapes are a subset of both
86
+ * languages' double-quoted strings, so `JSON.stringify` is complete for them.
87
+ */
88
+ const quoted = (value: string) => JSON.stringify(value);
89
+
90
+ /** A PHP single-quoted literal, whose only escapes are `\\` and `\'`. */
91
+ function phpString(value: string): string {
92
+ return `'${value.replaceAll('\\', '\\\\').replaceAll("'", "\\'")}'`;
93
+ }
94
+
95
+ /**
96
+ * The response type as a type argument, or nothing.
97
+ *
98
+ * The name is a component key out of the document, and a key is any string:
99
+ * only one that is an identifier — optionally an array of one — is code.
100
+ */
101
+ function typeArgument(name?: string): string {
102
+ return name && /^[A-Za-z_$][\w$]*(?:\[\])?$/.test(name) ? `<${name}>` : '';
103
+ }
104
+
65
105
  /** A value as a JavaScript literal, indented for the depth it sits at. */
66
106
  function js(value: unknown, depth = 1): string {
67
107
  if (value === null) return 'null';
68
- if (typeof value === 'string') return `'${value.replaceAll("'", "\\'")}'`;
108
+ if (typeof value === 'string') return jsString(value);
69
109
  if (typeof value === 'number' || typeof value === 'boolean') {
70
110
  return String(value);
71
111
  }
@@ -89,7 +129,7 @@ function js(value: unknown, depth = 1): string {
89
129
 
90
130
  const written = entries
91
131
  .map(([key, item]) => {
92
- const name = bareKey.test(key) ? key : `'${key}'`;
132
+ const name = bareKey.test(key) ? key : jsString(key);
93
133
  return `${pad(depth + 1)}${name}: ${js(item, depth + 1)}`;
94
134
  })
95
135
  .join(',\n');
@@ -104,7 +144,7 @@ function py(value: unknown, depth = 1): string {
104
144
  if (value === null) return 'None';
105
145
  if (typeof value === 'boolean') return value ? 'True' : 'False';
106
146
  if (typeof value === 'number') return String(value);
107
- if (typeof value === 'string') return `"${value.replaceAll('"', '\\"')}"`;
147
+ if (typeof value === 'string') return quoted(value);
108
148
 
109
149
  if (Array.isArray(value)) {
110
150
  if (!value.length) return '[]';
@@ -126,7 +166,7 @@ function py(value: unknown, depth = 1): string {
126
166
  const written = entries
127
167
  .map(
128
168
  ([key, item]) =>
129
- `${step(depth + 1)}"${key.replaceAll('"', '\\"')}": ${py(item, depth + 1)}`
169
+ `${step(depth + 1)}${quoted(key)}: ${py(item, depth + 1)}`
130
170
  )
131
171
  .join(',\n');
132
172
 
@@ -140,7 +180,7 @@ function php(value: unknown, depth = 1): string {
140
180
  if (value === null) return 'null';
141
181
  if (typeof value === 'boolean') return value ? 'true' : 'false';
142
182
  if (typeof value === 'number') return String(value);
143
- if (typeof value === 'string') return `'${value.replaceAll("'", "\\'")}'`;
183
+ if (typeof value === 'string') return phpString(value);
144
184
 
145
185
  if (Array.isArray(value)) {
146
186
  if (!value.length) return '[]';
@@ -162,7 +202,7 @@ function php(value: unknown, depth = 1): string {
162
202
  const written = entries
163
203
  .map(
164
204
  ([key, item]) =>
165
- `${step(depth + 1)}'${key.replaceAll("'", "\\'")}' => ${php(item, depth + 1)}`
205
+ `${step(depth + 1)}${phpString(key)} => ${php(item, depth + 1)}`
166
206
  )
167
207
  .join(',\n');
168
208
 
@@ -218,7 +258,7 @@ function jsOptions(
218
258
  const options: string[] = [];
219
259
 
220
260
  if (request.method !== 'GET') {
221
- options.push(` method: '${request.method}'`);
261
+ options.push(` method: ${jsString(request.method)}`);
222
262
  }
223
263
 
224
264
  if (hasHeaders(request)) {
@@ -256,13 +296,13 @@ function jsCall(
256
296
  body: 'stringify' | 'object',
257
297
  typed = false
258
298
  ): string {
259
- const generic =
260
- typed && request.responseType ? `<${request.responseType}>` : '';
299
+ const generic = typed ? typeArgument(request.responseType) : '';
300
+ const url = jsString(request.url);
261
301
 
262
302
  const options = jsOptions(request, body);
263
- if (!options.length) return `${call}${generic}('${request.url}');`;
303
+ if (!options.length) return `${call}${generic}(${url});`;
264
304
 
265
- return `${call}${generic}('${request.url}', {\n${options.join(',\n')}\n});`;
305
+ return `${call}${generic}(${url}, {\n${options.join(',\n')}\n});`;
266
306
  }
267
307
 
268
308
  /** The request as a `fetch` call. A JSON body is written as the data it is. */
@@ -290,8 +330,8 @@ export function openApiUseFetch(request: DuxtOpenApiRequest): string {
290
330
  /** axios through its uniform form, so the shape does not change per method. */
291
331
  export function openApiAxios(request: DuxtOpenApiRequest): string {
292
332
  const options = [
293
- ` method: '${request.method.toLowerCase()}'`,
294
- ` url: '${request.url}'`
333
+ ` method: ${jsString(request.method.toLowerCase())}`,
334
+ ` url: ${jsString(request.url)}`
295
335
  ];
296
336
 
297
337
  if (hasHeaders(request)) options.push(` headers: ${js(request.headers)}`);
@@ -301,7 +341,7 @@ export function openApiAxios(request: DuxtOpenApiRequest): string {
301
341
  options.push(` data: ${data === undefined ? js(request.body) : js(data)}`);
302
342
  }
303
343
 
304
- const generic = request.responseType ? `<${request.responseType}>` : '';
344
+ const generic = typeArgument(request.responseType);
305
345
 
306
346
  return `await axios${generic}({\n${options.join(',\n')}\n});`;
307
347
  }
@@ -316,8 +356,8 @@ function pyCall(module: string, request: DuxtOpenApiRequest): string {
316
356
  : `${module}.request(`;
317
357
 
318
358
  const args = known
319
- ? [` "${request.url}"`]
320
- : [` "${request.method}"`, ` "${request.url}"`];
359
+ ? [` ${quoted(request.url)}`]
360
+ : [` ${quoted(request.method)}`, ` ${quoted(request.url)}`];
321
361
 
322
362
  if (hasHeaders(request)) args.push(` headers=${py(request.headers)}`);
323
363
 
@@ -349,7 +389,10 @@ export function openApiUrllib(request: DuxtOpenApiRequest): string {
349
389
  ? 'import json\nimport urllib.request'
350
390
  : 'import urllib.request';
351
391
 
352
- const args = [` "${request.url}"`, ` method="${request.method}"`];
392
+ const args = [
393
+ ` ${quoted(request.url)}`,
394
+ ` method=${quoted(request.method)}`
395
+ ];
353
396
 
354
397
  if (hasHeaders(request)) args.push(` headers=${py(request.headers)}`);
355
398
 
@@ -392,7 +435,7 @@ export function openApiGo(request: DuxtOpenApiRequest): string {
392
435
  }
393
436
 
394
437
  lines.push(
395
- `req, err := http.NewRequest("${request.method}", "${request.url}", ${request.body ? 'body' : 'nil'})`,
438
+ `req, err := http.NewRequest(${quoted(request.method)}, ${quoted(request.url)}, ${request.body ? 'body' : 'nil'})`,
396
439
  'if err != nil {',
397
440
  '\treturn err',
398
441
  '}'
@@ -401,7 +444,7 @@ export function openApiGo(request: DuxtOpenApiRequest): string {
401
444
  if (hasHeaders(request)) {
402
445
  lines.push('');
403
446
  for (const [name, value] of Object.entries(request.headers)) {
404
- lines.push(`req.Header.Set("${name}", "${value}")`);
447
+ lines.push(`req.Header.Set(${quoted(name)}, ${quoted(value)})`);
405
448
  }
406
449
  }
407
450
 
@@ -428,9 +471,10 @@ export function openApiGuzzle(request: DuxtOpenApiRequest): string {
428
471
  }
429
472
 
430
473
  const client = '$client = new \\GuzzleHttp\\Client();';
474
+ const head = `$client->request(${phpString(request.method)}, ${phpString(request.url)}`;
431
475
  const call = options.length
432
- ? `$client->request('${request.method}', '${request.url}', [\n${options.join(',\n')},\n]);`
433
- : `$client->request('${request.method}', '${request.url}');`;
476
+ ? `${head}, [\n${options.join(',\n')},\n]);`
477
+ : `${head});`;
434
478
 
435
479
  return `${client}\n\n${call}`;
436
480
  }
@@ -448,12 +492,12 @@ export function openApiLaravelHttp(request: DuxtOpenApiRequest): string {
448
492
  ? `, ${data === undefined ? php(request.body) : php(data, 1)}`
449
493
  : '';
450
494
 
451
- return `${head}${verb}('${request.url}'${payload});`;
495
+ return `${head}${verb}(${phpString(request.url)}${payload});`;
452
496
  }
453
497
 
454
498
  /** The curl extension, for a project with no HTTP client at all. */
455
499
  export function openApiPhpCurl(request: DuxtOpenApiRequest): string {
456
- const options = [` CURLOPT_CUSTOMREQUEST => '${request.method}'`];
500
+ const options = [` CURLOPT_CUSTOMREQUEST => ${phpString(request.method)}`];
457
501
 
458
502
  if (hasHeaders(request)) {
459
503
  const headers = Object.entries(request.headers).map(
@@ -469,7 +513,7 @@ export function openApiPhpCurl(request: DuxtOpenApiRequest): string {
469
513
  options.push(' CURLOPT_RETURNTRANSFER => true');
470
514
 
471
515
  return [
472
- `$ch = curl_init('${request.url}');`,
516
+ `$ch = curl_init(${phpString(request.url)});`,
473
517
  '',
474
518
  'curl_setopt_array($ch, [',
475
519
  options.join(',\n') + ',',
@@ -524,8 +524,11 @@ export function redactUrl(url: string): { url: string; redacted: boolean } {
524
524
  const equals = pair.indexOf('=');
525
525
  if (equals < 1) return pair;
526
526
 
527
+ // Classified DECODED: `%61pi_key` is `api_key` to every server that
528
+ // reads the query, so it has to be `api_key` here too. The name is
529
+ // published as written.
527
530
  const name = pair.slice(0, equals);
528
- if (!withheld(name, pair.slice(equals + 1))) return pair;
531
+ if (!withheld(queryName(name), pair.slice(equals + 1))) return pair;
529
532
 
530
533
  redacted = true;
531
534
  return `${name}=`;
@@ -538,6 +541,15 @@ export function redactUrl(url: string): { url: string; redacted: boolean } {
538
541
  };
539
542
  }
540
543
 
544
+ /** A query name as a server reads it; the raw name if it does not decode. */
545
+ function queryName(name: string): string {
546
+ try {
547
+ return decodeURIComponent(name.replaceAll('+', ' '));
548
+ } catch {
549
+ return name;
550
+ }
551
+ }
552
+
541
553
  /** `collection.bru`'s `auth { mode: … }`, which `auth: inherit` resolves to. */
542
554
  function sharedAuth(blocks: Block[]): DuxtBrunoAuth | undefined {
543
555
  const mode = dictionary(blocks, 'auth').mode;
File without changes
@@ -80,6 +80,26 @@ const isObject = (value: unknown): value is Node =>
80
80
  const str = (value: unknown): string | undefined =>
81
81
  typeof value === 'string' ? value : undefined;
82
82
 
83
+ /**
84
+ * A URL the document hands a reader to click, or nothing.
85
+ *
86
+ * `http`, `https`, `mailto` or relative, and NOTHING ELSE: these land in an
87
+ * `href` on the site's own origin, and `javascript:` in a contact or licence URL
88
+ * is a script this layer would be running for whoever wrote the document. MDC
89
+ * checks a component's `href` prop, but these arrive nested inside objects it
90
+ * never looks into. The scheme is read the way a browser reads it — with tabs,
91
+ * newlines and control characters removed — or `java\tscript:` walks past.
92
+ */
93
+ const link = (value: unknown): string | undefined => {
94
+ const url = str(value);
95
+ if (!url) return undefined;
96
+
97
+ const compact = [...url].filter((char) => char > ' ').join('');
98
+ const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(compact);
99
+
100
+ return !scheme || /^(?:https?|mailto)$/i.test(scheme[1]!) ? url : undefined;
101
+ };
102
+
83
103
  const num = (value: unknown): number | undefined =>
84
104
  typeof value === 'number' && Number.isFinite(value) ? value : undefined;
85
105
 
@@ -205,7 +225,7 @@ function read(document: Node, dialect: '3.0' | '3.1'): DuxtOpenApiSpec {
205
225
  /** The externalDocs object, wherever one may appear. */
206
226
  const externalDocs = (node: unknown): DuxtOpenApiExternalDocs | undefined => {
207
227
  if (!isObject(node)) return undefined;
208
- const url = str(node.url);
228
+ const url = link(node.url);
209
229
  return url ? { url, description: str(node.description) } : undefined;
210
230
  };
211
231
 
@@ -731,11 +751,11 @@ function read(document: Node, dialect: '3.0' | '3.1'): DuxtOpenApiSpec {
731
751
  version: str(info.version) ?? '',
732
752
  summary: str(info.summary),
733
753
  description: str(info.description),
734
- termsOfService: str(info.termsOfService),
754
+ termsOfService: link(info.termsOfService),
735
755
  contact: isObject(info.contact)
736
756
  ? {
737
757
  name: str(info.contact.name),
738
- url: str(info.contact.url),
758
+ url: link(info.contact.url),
739
759
  email: str(info.contact.email)
740
760
  }
741
761
  : undefined,
@@ -743,7 +763,7 @@ function read(document: Node, dialect: '3.0' | '3.1'): DuxtOpenApiSpec {
743
763
  ? {
744
764
  name: str(info.license.name) ?? '',
745
765
  identifier: str(info.license.identifier),
746
- url: str(info.license.url)
766
+ url: link(info.license.url)
747
767
  }
748
768
  : undefined
749
769
  },
@@ -12,8 +12,14 @@
12
12
  * documents: a resolver that understood a Bruno collection would have to
13
13
  * understand the next type's tree as well.
14
14
  */
15
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
- import { join, relative, sep } from 'node:path';
15
+ import {
16
+ existsSync,
17
+ readFileSync,
18
+ readdirSync,
19
+ realpathSync,
20
+ statSync
21
+ } from 'node:fs';
22
+ import { isAbsolute, join, relative, sep } from 'node:path';
17
23
  import type { DuxtSectionInput, DuxtSectionType } from './sections-resolve';
18
24
 
19
25
  /**
@@ -36,18 +42,50 @@ export function sectionInputKind(type: DuxtSectionType): 'file' | 'directory' {
36
42
  * A directory where a file was declared is as missing as nothing at all — the
37
43
  * type would read it and find nothing — and saying so here is what lets
38
44
  * `missingSectionArtefact` keep one message for both.
45
+ *
46
+ * `checkout`, for a REMOTE repository, is the directory the artefact must stay
47
+ * inside once every symlink is resolved. The walk below already refuses a
48
+ * symlinked CHILD, but the declared path itself is joined as a string: a
49
+ * repository that commits `CHANGELOG.md` as a link to `/proc/self/environ`
50
+ * would otherwise have the build read that file and publish it as its release
51
+ * history. A repository controls the bytes of its own checkout, not the
52
+ * machine that downloads it, so an artefact that resolves outside it fails the
53
+ * build rather than reading as missing — a missing file and a link out are
54
+ * different things to fix. Omitted for a local section, whose path the
55
+ * operator wrote and may legitimately point past the repository.
39
56
  */
40
57
  export function sectionArtefactExists(
41
58
  absolute: string,
42
- kind: 'file' | 'directory'
59
+ kind: 'file' | 'directory',
60
+ checkout?: string
43
61
  ): boolean {
44
62
  if (!existsSync(absolute)) return false;
45
63
 
64
+ if (checkout) assertInsideCheckout(absolute, checkout);
65
+
46
66
  const stats = statSync(absolute);
47
67
 
48
68
  return kind === 'directory' ? stats.isDirectory() : stats.isFile();
49
69
  }
50
70
 
71
+ /** Whether `absolute`, symlinks resolved, lies inside `checkout`. */
72
+ export function resolvesInside(absolute: string, checkout: string): boolean {
73
+ const rel = relative(realpathSync(checkout), realpathSync(absolute));
74
+
75
+ return !(rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel));
76
+ }
77
+
78
+ /** Throws when `absolute`, symlinks resolved, lies outside `checkout`. */
79
+ function assertInsideCheckout(absolute: string, checkout: string): void {
80
+ if (!resolvesInside(absolute, checkout)) {
81
+ throw new Error(
82
+ `duxt: "${absolute}" resolves outside its checkout (${checkout}). ` +
83
+ 'A generated section reads only files inside the repository it is ' +
84
+ 'declared on; replace the symlink with the file itself.'
85
+ );
86
+ }
87
+ }
88
+
51
89
  /**
52
90
  * The artefact at `absolute`, as the type reads it.
53
91
  *
@@ -152,7 +152,7 @@ function remoteCollection(entry: DuxtResolvedSource, type: DuxtSectionType) {
152
152
 
153
153
  // The artefact is read from the download; the HISTORY, where the download
154
154
  // is this repository, from the checkout that has all of it.
155
- pages = sectionArtefactExists(file, kind)
155
+ pages = sectionArtefactExists(file, kind, source.cwd)
156
156
  ? sectionPages(
157
157
  entry,
158
158
  type,
@@ -251,17 +251,15 @@ export function duxtSourcesCacheKey(
251
251
  };
252
252
  }
253
253
 
254
- for (const source of sources) {
255
- for (const repo of [
256
- source.repo,
257
- ...(source.locales ?? []).map((locale) =>
258
- typeof locale === 'string' ? undefined : locale.repo
259
- )
260
- ]) {
261
- const reason = repo ? privateRemoteReason(repo) : undefined;
262
- if (reason)
263
- return { cacheable: false, reason, key: '', entries, ...where };
264
- }
254
+ // Over the SAME expansion the entries came from, so every repository that is
255
+ // downloaded is one that was checked. Walking `source.repo` and
256
+ // `source.locales` by hand missed a private repository named by a locale
257
+ // override inside `refs[]` — downloaded, cached, and never looked at.
258
+ for (const { effective } of expandSources(sources, options)) {
259
+ const reason = effective.repo
260
+ ? privateRemoteReason(effective.repo)
261
+ : undefined;
262
+ if (reason) return { cacheable: false, reason, key: '', entries, ...where };
265
263
  }
266
264
 
267
265
  // A SPACE AND A NEWLINE are enough to separate the fields unambiguously: git
@@ -13,6 +13,7 @@ import { resolveLatestRefs } from './sources-git';
13
13
  // Re-exported under the name it has always had here: it moved out so a Nuxt
14
14
  // module could reach it without this file's imports coming with it.
15
15
  import { repositoryRoot } from './repository-root';
16
+ import { resolvesInside } from '../sections/section-input';
16
17
  export { repositoryRoot };
17
18
  export type {
18
19
  DuxtResolvedSource,
@@ -285,7 +286,7 @@ export function duxtSources(
285
286
  (other) => `${other.effective.path.slice(effective.path.length + 1)}/**`
286
287
  );
287
288
 
288
- collections[entry.collection] = defineCollection({
289
+ const collection = defineCollection({
289
290
  type: 'page',
290
291
  schema: pageSchema,
291
292
  source: effective.repo
@@ -311,6 +312,10 @@ export function duxtSources(
311
312
  prefix: entry.prefix
312
313
  }
313
314
  });
315
+
316
+ containedKeys(collection);
317
+
318
+ collections[entry.collection] = collection;
314
319
  });
315
320
 
316
321
  // One partials collection per LANGUAGE, named the way the page collections
@@ -322,6 +327,40 @@ export function duxtSources(
322
327
  return collections;
323
328
  }
324
329
 
330
+ /**
331
+ * Keep a downloaded repository's keys to files that are inside its checkout.
332
+ *
333
+ * Content globs a repository source with symlinks followed and reads every key
334
+ * it finds, so a repository that commits `docs/env.md` as a link to
335
+ * `/proc/self/environ` would have the build read that file and publish it as a
336
+ * page. A repository controls the bytes of its own checkout, not the machine
337
+ * that downloads it: a key that resolves outside the checkout is dropped. A
338
+ * link to another file inside the checkout is still a page. Local sources are
339
+ * left alone — their paths are the operator's own.
340
+ *
341
+ * A key is resolved the way Content's `getItem` resolves it: against the part
342
+ * of the include before its first `*`.
343
+ */
344
+ function containedKeys(collection: ReturnType<typeof defineCollection>) {
345
+ for (const source of collection.source ?? []) {
346
+ if (!source.repository) continue;
347
+
348
+ const keys = source.getKeys!;
349
+ const base = source.include.includes('*')
350
+ ? source.include.split('*')[0]!
351
+ : '';
352
+
353
+ source.getKeys = async () =>
354
+ (await keys()).filter((key) => {
355
+ try {
356
+ return resolvesInside(join(source.cwd, base, key), source.cwd);
357
+ } catch {
358
+ return false;
359
+ }
360
+ });
361
+ }
362
+ }
363
+
325
364
  /** One folder list per language: where that language's `_partials/` live. */
326
365
  function partialFolders(
327
366
  resolved: DuxtResolvedSource[],
@@ -392,9 +431,13 @@ function definePartials(
392
431
  // `select('rawbody')` in the layer stops compiling. The cost is that partials
393
432
  // carry a `sitemap` field too, which is why `/_partials/**` is excluded in
394
433
  // `nuxt.config.ts`.
395
- return defineCollection({
434
+ const collection = defineCollection({
396
435
  type: 'page',
397
436
  source: entries,
398
437
  schema: pageSchema
399
438
  });
439
+
440
+ containedKeys(collection);
441
+
442
+ return collection;
400
443
  }
@@ -76,7 +76,11 @@
76
76
  "json": "JSON",
77
77
  "format": "Formatieren",
78
78
  "invalidJson": "Noch kein gültiges JSON — die Anfrage wird so gesendet, wie sie dasteht.",
79
- "noForm": "Dieser Body lässt sich nicht als Formular darstellen: bitte als JSON bearbeiten."
79
+ "noForm": "Dieser Body lässt sich nicht als Formular darstellen: bitte als JSON bearbeiten.",
80
+ "cancel": "Abbrechen",
81
+ "cancelled": "Anfrage abgebrochen.",
82
+ "timeout": "Zeitüberschreitung nach 30 Sekunden.",
83
+ "truncated": "Antwortvorschau auf 1 MiB begrenzt."
80
84
  },
81
85
  "version": "API-Version",
82
86
  "operations": "Operationen",
@@ -76,7 +76,11 @@
76
76
  "json": "JSON",
77
77
  "format": "Format",
78
78
  "invalidJson": "Not valid JSON yet — the request will be sent as written.",
79
- "noForm": "This body cannot be drawn as a form: edit it as JSON."
79
+ "noForm": "This body cannot be drawn as a form: edit it as JSON.",
80
+ "cancel": "Cancel",
81
+ "cancelled": "Request cancelled.",
82
+ "timeout": "The request timed out after 30 seconds.",
83
+ "truncated": "Response preview limited to 1 MiB."
80
84
  },
81
85
  "version": "API version",
82
86
  "operations": "Operations",
@@ -76,7 +76,11 @@
76
76
  "json": "JSON",
77
77
  "format": "Formatear",
78
78
  "invalidJson": "Todavía no es JSON válido: la petición se enviará tal cual.",
79
- "noForm": "Este cuerpo no puede mostrarse como formulario: edítalo como JSON."
79
+ "noForm": "Este cuerpo no puede mostrarse como formulario: edítalo como JSON.",
80
+ "cancel": "Cancelar",
81
+ "cancelled": "Solicitud cancelada.",
82
+ "timeout": "La solicitud superó los 30 segundos.",
83
+ "truncated": "Vista previa limitada a 1 MiB."
80
84
  },
81
85
  "version": "Versión de la API",
82
86
  "operations": "Operaciones",
@@ -76,7 +76,11 @@
76
76
  "json": "JSON",
77
77
  "format": "Formater",
78
78
  "invalidJson": "Ce n'est pas encore du JSON valide — la requête sera envoyée telle quelle.",
79
- "noForm": "Ce corps ne peut pas être présenté sous forme de formulaire : modifiez-le en JSON."
79
+ "noForm": "Ce corps ne peut pas être présenté sous forme de formulaire : modifiez-le en JSON.",
80
+ "cancel": "Annuler",
81
+ "cancelled": "Requête annulée.",
82
+ "timeout": "La requête a dépassé 30 secondes.",
83
+ "truncated": "Aperçu de la réponse limité à 1 Mio."
80
84
  },
81
85
  "version": "Version de l’API",
82
86
  "operations": "Opérations",
@@ -76,7 +76,11 @@
76
76
  "json": "JSON",
77
77
  "format": "Formatar",
78
78
  "invalidJson": "Ainda não é JSON válido — o pedido será enviado tal como está.",
79
- "noForm": "Este corpo não pode ser apresentado como formulário: edite-o como JSON."
79
+ "noForm": "Este corpo não pode ser apresentado como formulário: edite-o como JSON.",
80
+ "cancel": "Cancelar",
81
+ "cancelled": "Pedido cancelado.",
82
+ "timeout": "O pedido excedeu 30 segundos.",
83
+ "truncated": "Pré-visualização limitada a 1 MiB."
80
84
  },
81
85
  "version": "Versão da API",
82
86
  "operations": "Operações",
@@ -130,7 +130,7 @@ export function redirectRules(
130
130
 
131
131
  for (const page of pages) {
132
132
  for (const from of page.redirectFrom) {
133
- if (!from.startsWith('/')) continue;
133
+ if (!isLiteralPath(from)) continue;
134
134
 
135
135
  const source = isInsidePrefix(from, page.prefix)
136
136
  ? from
@@ -151,5 +151,31 @@ export function redirectRules(
151
151
  return rules;
152
152
  }
153
153
 
154
+ /**
155
+ * A former URL written out in full: one path, not a pattern.
156
+ *
157
+ * Route rules are matched by pattern, so `/**` or `/:slug` in a page's
158
+ * frontmatter would be a rule over every URL it matches — the whole site, the
159
+ * MCP endpoint and `llms.txt` included — written by whoever may edit a page.
160
+ * A page owns the URLs it used to live at, so only a literal path is one: no
161
+ * wildcard or parameter segment, no `.`/`..` segment, no empty segment, no
162
+ * backslash.
163
+ */
164
+ export function isLiteralPath(path: string): boolean {
165
+ if (!path.startsWith('/') || path.includes('\\')) return false;
166
+
167
+ return path
168
+ .slice(1)
169
+ .split('/')
170
+ .every(
171
+ (segment, index, all) =>
172
+ (segment !== '' || index === all.length - 1) &&
173
+ segment !== '.' &&
174
+ segment !== '..' &&
175
+ !segment.includes('*') &&
176
+ !segment.startsWith(':')
177
+ );
178
+ }
179
+
154
180
  const isInsidePrefix = (path: string, prefix: string) =>
155
181
  Boolean(prefix) && (path === prefix || path.startsWith(`${prefix}/`));