@kirchdev/duxt 0.6.0 → 0.7.0
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
|
@@ -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
|
|
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 :
|
|
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
|
|
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)}
|
|
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
|
|
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)}
|
|
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:
|
|
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
|
-
|
|
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}(
|
|
303
|
+
if (!options.length) return `${call}${generic}(${url});`;
|
|
264
304
|
|
|
265
|
-
return `${call}${generic}(
|
|
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:
|
|
294
|
-
` 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
|
|
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
|
-
? [`
|
|
320
|
-
: [`
|
|
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 = [
|
|
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(
|
|
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(
|
|
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
|
-
? `$
|
|
433
|
-
: `$
|
|
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}(
|
|
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 =>
|
|
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(
|
|
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;
|
package/build/cli/report.ts
CHANGED
|
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 =
|
|
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:
|
|
754
|
+
termsOfService: link(info.termsOfService),
|
|
735
755
|
contact: isObject(info.contact)
|
|
736
756
|
? {
|
|
737
757
|
name: str(info.contact.name),
|
|
738
|
-
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:
|
|
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 {
|
|
16
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
package/build/sources/sources.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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",
|
package/modules/redirects.ts
CHANGED
|
@@ -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
|
|
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}/`));
|