@kirchdev/duxt 0.6.0 → 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
|
@@ -608,20 +608,27 @@ async function copySample() {
|
|
|
608
608
|
|
|
609
609
|
/* ------------------------------------------------------------------- send */
|
|
610
610
|
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
const
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
611
|
+
const requestState = useDuxtRequest({
|
|
612
|
+
settled: (outcome, duration, status) =>
|
|
613
|
+
report(
|
|
614
|
+
outcome,
|
|
615
|
+
duration,
|
|
616
|
+
status === undefined ? undefined : duxtStatusClass(status)
|
|
617
|
+
)
|
|
618
|
+
});
|
|
619
|
+
const { sending, result, cancel } = requestState;
|
|
620
|
+
const failure = computed(() => {
|
|
621
|
+
switch (requestState.failure.value) {
|
|
622
|
+
case 'cancelled':
|
|
623
|
+
return t('duxt.openapi.client.cancelled');
|
|
624
|
+
case 'timeout':
|
|
625
|
+
return t('duxt.openapi.client.timeout');
|
|
626
|
+
case 'blocked':
|
|
627
|
+
return t('duxt.openapi.client.blocked');
|
|
628
|
+
default:
|
|
629
|
+
return undefined;
|
|
630
|
+
}
|
|
631
|
+
});
|
|
625
632
|
|
|
626
633
|
/**
|
|
627
634
|
* The request that just settled, described entirely by the DOCUMENT.
|
|
@@ -654,64 +661,11 @@ function report(
|
|
|
654
661
|
}
|
|
655
662
|
|
|
656
663
|
async function send() {
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
// there is something to replace it with.
|
|
663
|
-
|
|
664
|
-
const started = performance.now();
|
|
665
|
-
|
|
666
|
-
try {
|
|
667
|
-
const response = await fetch(request.value.url, {
|
|
668
|
-
method: request.value.method,
|
|
669
|
-
headers: request.value.headers,
|
|
670
|
-
body: request.value.body
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
const text = await response.text();
|
|
674
|
-
const duration = Math.round(performance.now() - started);
|
|
675
|
-
|
|
676
|
-
failure.value = undefined;
|
|
677
|
-
result.value = {
|
|
678
|
-
status: response.status,
|
|
679
|
-
statusText: response.statusText,
|
|
680
|
-
duration,
|
|
681
|
-
headers: [...response.headers.entries()],
|
|
682
|
-
body: pretty(text)
|
|
683
|
-
};
|
|
684
|
-
|
|
685
|
-
report('response', duration, duxtStatusClass(response.status));
|
|
686
|
-
} catch (error) {
|
|
687
|
-
// `fetch` rejects with a bare TypeError for a CORS refusal, a DNS failure
|
|
688
|
-
// and an offline browser alike — the browser deliberately tells the page
|
|
689
|
-
// nothing more — so the message says what the possibilities are rather than
|
|
690
|
-
// pretending to know which one happened.
|
|
691
|
-
result.value = undefined;
|
|
692
|
-
failure.value =
|
|
693
|
-
error instanceof Error && error.name !== 'TypeError'
|
|
694
|
-
? error.message
|
|
695
|
-
: t('duxt.openapi.client.blocked');
|
|
696
|
-
|
|
697
|
-
// No status and no class: nothing came back to have one. The message is not
|
|
698
|
-
// reported either — for a CORS refusal it is this component's own guess.
|
|
699
|
-
report('failed', Math.round(performance.now() - started));
|
|
700
|
-
} finally {
|
|
701
|
-
// A FLOOR UNDER THE WAIT. The demo endpoint answers in a few milliseconds,
|
|
702
|
-
// so the spinner appeared and vanished inside one frame — which reads as
|
|
703
|
-
// the button having flickered rather than as a request having been made.
|
|
704
|
-
// Held long enough to be seen, and the button stays disabled for it, so the
|
|
705
|
-
// same request cannot be fired twice while the first is in flight.
|
|
706
|
-
const elapsed = performance.now() - started;
|
|
707
|
-
if (elapsed < MINIMUM_WAIT) {
|
|
708
|
-
await new Promise((resolve) =>
|
|
709
|
-
setTimeout(resolve, MINIMUM_WAIT - elapsed)
|
|
710
|
-
);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
sending.value = false;
|
|
714
|
-
}
|
|
664
|
+
await requestState.send(request.value.url, {
|
|
665
|
+
method: request.value.method,
|
|
666
|
+
headers: request.value.headers,
|
|
667
|
+
body: request.value.body
|
|
668
|
+
});
|
|
715
669
|
}
|
|
716
670
|
|
|
717
671
|
/**
|
|
@@ -803,13 +757,6 @@ function gridColumns(element: HTMLElement): number {
|
|
|
803
757
|
}
|
|
804
758
|
|
|
805
759
|
/** A JSON body, indented; anything else exactly as it arrived. */
|
|
806
|
-
function pretty(text: string): string {
|
|
807
|
-
try {
|
|
808
|
-
return JSON.stringify(JSON.parse(text), null, 2);
|
|
809
|
-
} catch {
|
|
810
|
-
return text;
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
760
|
</script>
|
|
814
761
|
|
|
815
762
|
<template>
|
|
@@ -1235,6 +1182,15 @@ function pretty(text: string): string {
|
|
|
1235
1182
|
{{ $t('duxt.openapi.client.send') }}
|
|
1236
1183
|
</UiButton>
|
|
1237
1184
|
|
|
1185
|
+
<UiButton
|
|
1186
|
+
v-if="sending"
|
|
1187
|
+
type="button"
|
|
1188
|
+
variant="outline"
|
|
1189
|
+
class="w-full"
|
|
1190
|
+
@click="cancel"
|
|
1191
|
+
>
|
|
1192
|
+
{{ $t('duxt.openapi.client.cancel') }}
|
|
1193
|
+
</UiButton>
|
|
1238
1194
|
<p class="text-xs text-muted-foreground">
|
|
1239
1195
|
{{ $t('duxt.openapi.client.direct') }}
|
|
1240
1196
|
</p>
|
|
@@ -1288,6 +1244,13 @@ function pretty(text: string): string {
|
|
|
1288
1244
|
language="json"
|
|
1289
1245
|
/>
|
|
1290
1246
|
|
|
1247
|
+
<p
|
|
1248
|
+
v-if="result.truncated"
|
|
1249
|
+
role="status"
|
|
1250
|
+
class="text-sm text-muted-foreground"
|
|
1251
|
+
>
|
|
1252
|
+
{{ $t('duxt.openapi.client.truncated') }}
|
|
1253
|
+
</p>
|
|
1291
1254
|
<details v-if="result.headers.length" class="mt-2">
|
|
1292
1255
|
<summary class="cursor-pointer text-xs text-muted-foreground">
|
|
1293
1256
|
{{ $t('duxt.openapi.headers') }}
|
|
@@ -33,6 +33,7 @@ const results = ref<DuxtSearchHit[]>([]);
|
|
|
33
33
|
* results already on screen makes the previews jump while the reader types.
|
|
34
34
|
*/
|
|
35
35
|
const matched = ref('');
|
|
36
|
+
const searching = ref(false);
|
|
36
37
|
|
|
37
38
|
/** True while the list shows near-misses rather than actual matches. */
|
|
38
39
|
const approximate = ref(false);
|
|
@@ -48,11 +49,12 @@ let run = 0;
|
|
|
48
49
|
// dependency in a layer a consumer installs.
|
|
49
50
|
watch(query, (term) => {
|
|
50
51
|
clearTimeout(pending);
|
|
52
|
+
const current = ++run;
|
|
53
|
+
searching.value = Boolean(term.trim());
|
|
51
54
|
pending = setTimeout(async () => {
|
|
52
|
-
const current = ++run;
|
|
53
|
-
|
|
54
55
|
const settle = (hits: DuxtSearchHit[], fuzzy: boolean) => {
|
|
55
56
|
if (current !== run) return false;
|
|
57
|
+
searching.value = false;
|
|
56
58
|
results.value = hits;
|
|
57
59
|
approximate.value = fuzzy;
|
|
58
60
|
matched.value = term;
|
|
@@ -79,6 +81,10 @@ watch(query, (term) => {
|
|
|
79
81
|
}
|
|
80
82
|
}, 120);
|
|
81
83
|
});
|
|
84
|
+
onBeforeUnmount(() => {
|
|
85
|
+
clearTimeout(pending);
|
|
86
|
+
run++;
|
|
87
|
+
});
|
|
82
88
|
|
|
83
89
|
const duxt = useDuxtConfig();
|
|
84
90
|
const { recent, load } = useRecentPages(duxt.search?.recentPages);
|
|
@@ -271,7 +277,7 @@ const searchHint = computed(() => hint('search'));
|
|
|
271
277
|
rather than an `<input>`, is in `DuxtSearchInput`. -->
|
|
272
278
|
<DuxtSearchInput ref="filter" v-model="query" />
|
|
273
279
|
|
|
274
|
-
<UiCommandList class="max-h-[60vh]">
|
|
280
|
+
<UiCommandList :aria-busy="searching" class="max-h-[60vh]">
|
|
275
281
|
<!-- An empty box is a dead end. Without a term the dialog offers the
|
|
276
282
|
sections as entry points and, once there is any history, the pages
|
|
277
283
|
this reader came from — which is what they are most likely after. -->
|
|
@@ -66,11 +66,8 @@ function caption(version: DuxtLink) {
|
|
|
66
66
|
* The pages this one would become in each version, checked where they may not
|
|
67
67
|
* exist.
|
|
68
68
|
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* for a release cut after it. Those are looked up in the target's own
|
|
72
|
-
* collection; any other page keeps its place exactly as `versionPath` puts it,
|
|
73
|
-
* because every other per-version tree is the same tree at another ref.
|
|
69
|
+
* Editions may remove ordinary pages and generated endpoints too. A missing
|
|
70
|
+
* target lands at that edition's root instead of a URL nobody serves.
|
|
74
71
|
*
|
|
75
72
|
* Nothing is read after the first `await` but plain values captured before it —
|
|
76
73
|
* the rule `tests/async-data-context.test.ts` holds.
|
|
@@ -80,7 +77,7 @@ const { data: missing } = useAsyncData(
|
|
|
80
77
|
async () => {
|
|
81
78
|
const sources = duxt.resolvedSources ?? [];
|
|
82
79
|
const own = source.value;
|
|
83
|
-
if (own
|
|
80
|
+
if (!own || path.value === own.prefix) {
|
|
84
81
|
return [];
|
|
85
82
|
}
|
|
86
83
|
|
|
@@ -93,7 +90,10 @@ const { data: missing } = useAsyncData(
|
|
|
93
90
|
version.to ?? '/'
|
|
94
91
|
);
|
|
95
92
|
const owner = sourceForPath(target, sources);
|
|
96
|
-
if (!owner
|
|
93
|
+
if (!owner) {
|
|
94
|
+
gone.push(target);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
97
|
|
|
98
98
|
const hit = await queryCollection(owner.collection as DuxtCollectionArg)
|
|
99
99
|
.path(target)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
import { renderDuxtMermaid } from '../../utils/mermaid-render';
|
|
2
3
|
/**
|
|
3
4
|
* A mermaid diagram, from ```mermaid or from `::mermaid`.
|
|
4
5
|
*
|
|
@@ -13,58 +14,71 @@
|
|
|
13
14
|
*/
|
|
14
15
|
const props = defineProps<{ code?: string }>();
|
|
15
16
|
|
|
16
|
-
const
|
|
17
|
+
const authored = useTemplateRef<HTMLElement>('authored');
|
|
17
18
|
const colorMode = useColorMode();
|
|
18
|
-
const slots = useSlots();
|
|
19
19
|
const source = ref('');
|
|
20
20
|
const failed = ref(false);
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
let
|
|
21
|
+
const svg = ref('');
|
|
22
|
+
const id = `duxt-mermaid-${useId().replace(/[^A-Za-z0-9_-]/g, '_')}`;
|
|
23
|
+
let revision = 0;
|
|
24
|
+
let mounted = false;
|
|
24
25
|
|
|
25
26
|
async function render() {
|
|
26
|
-
if (!import.meta.client || !
|
|
27
|
-
|
|
27
|
+
if (!import.meta.client || !mounted) return;
|
|
28
|
+
const current = ++revision;
|
|
28
29
|
const text = source.value.trim();
|
|
29
|
-
if (!text)
|
|
30
|
+
if (!text) {
|
|
31
|
+
svg.value = '';
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
30
34
|
|
|
31
35
|
try {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const { svg } = await mermaid.render(`duxt-mermaid-${counter++}`, text);
|
|
41
|
-
container.value.innerHTML = svg;
|
|
36
|
+
const rendered = await renderDuxtMermaid(
|
|
37
|
+
`${id}-${current}`,
|
|
38
|
+
text,
|
|
39
|
+
colorMode.value === 'dark',
|
|
40
|
+
() => mounted && current === revision
|
|
41
|
+
);
|
|
42
|
+
if (current !== revision || !mounted) return;
|
|
43
|
+
svg.value = rendered;
|
|
42
44
|
failed.value = false;
|
|
43
45
|
} catch {
|
|
46
|
+
if (current !== revision || !mounted) return;
|
|
44
47
|
// A diagram that does not parse must not take the page with it. The source
|
|
45
48
|
// stays on screen, which is more useful than an empty box.
|
|
49
|
+
svg.value = '';
|
|
46
50
|
failed.value = true;
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
onMounted(() => {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
container.value?.textContent?.trim() ??
|
|
54
|
-
(typeof slots.default?.()[0]?.children === 'string'
|
|
55
|
-
? (slots.default()[0]!.children as string)
|
|
56
|
-
: '');
|
|
57
|
-
|
|
55
|
+
mounted = true;
|
|
56
|
+
source.value = props.code ?? authored.value?.textContent?.trim() ?? '';
|
|
58
57
|
void render();
|
|
59
58
|
});
|
|
60
|
-
|
|
59
|
+
onUpdated(() => {
|
|
60
|
+
const next = props.code ?? authored.value?.textContent?.trim() ?? '';
|
|
61
|
+
if (next !== source.value) {
|
|
62
|
+
source.value = next;
|
|
63
|
+
void render();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
61
66
|
watch(() => colorMode.value, render);
|
|
67
|
+
onBeforeUnmount(() => {
|
|
68
|
+
mounted = false;
|
|
69
|
+
revision++;
|
|
70
|
+
});
|
|
62
71
|
</script>
|
|
63
72
|
|
|
64
73
|
<template>
|
|
65
74
|
<div class="my-6 flex justify-center overflow-x-auto rounded-lg border p-4">
|
|
66
|
-
<div
|
|
75
|
+
<div
|
|
76
|
+
ref="authored"
|
|
77
|
+
:hidden="Boolean(svg)"
|
|
78
|
+
:class="{ 'text-sm whitespace-pre': failed }"
|
|
79
|
+
>
|
|
67
80
|
<slot>{{ code }}</slot>
|
|
68
81
|
</div>
|
|
82
|
+
<div v-if="svg" v-html="svg" />
|
|
69
83
|
</div>
|
|
70
84
|
</template>
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { onScopeDispose, ref } from 'vue';
|
|
2
|
+
|
|
3
|
+
export interface DuxtRequestResult {
|
|
4
|
+
status: number;
|
|
5
|
+
statusText: string;
|
|
6
|
+
duration: number;
|
|
7
|
+
headers: [string, string][];
|
|
8
|
+
body: string;
|
|
9
|
+
truncated: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type DuxtRequestFailure = 'cancelled' | 'timeout' | 'blocked';
|
|
13
|
+
|
|
14
|
+
/** A direct, ephemeral browser request with a bounded response preview. */
|
|
15
|
+
export function useDuxtRequest({
|
|
16
|
+
timeout = 30_000,
|
|
17
|
+
maxBytes = 1024 * 1024,
|
|
18
|
+
minimumWait = 300,
|
|
19
|
+
settled
|
|
20
|
+
}: {
|
|
21
|
+
timeout?: number;
|
|
22
|
+
maxBytes?: number;
|
|
23
|
+
minimumWait?: number;
|
|
24
|
+
settled?: (
|
|
25
|
+
outcome: 'response' | 'failed',
|
|
26
|
+
duration: number,
|
|
27
|
+
status?: number
|
|
28
|
+
) => void;
|
|
29
|
+
} = {}) {
|
|
30
|
+
const sending = ref(false);
|
|
31
|
+
const result = ref<DuxtRequestResult>();
|
|
32
|
+
const failure = ref<DuxtRequestFailure>();
|
|
33
|
+
let active: AbortController | undefined;
|
|
34
|
+
let disposed = false;
|
|
35
|
+
|
|
36
|
+
const cancel = () =>
|
|
37
|
+
active?.abort(new DOMException('Cancelled', 'AbortError'));
|
|
38
|
+
onScopeDispose(() => {
|
|
39
|
+
disposed = true;
|
|
40
|
+
cancel();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
async function send(url: string, init: RequestInit) {
|
|
44
|
+
if (sending.value || disposed) return;
|
|
45
|
+
const controller = new AbortController();
|
|
46
|
+
active = controller;
|
|
47
|
+
sending.value = true;
|
|
48
|
+
const started = performance.now();
|
|
49
|
+
const timer = setTimeout(
|
|
50
|
+
() => controller.abort(new DOMException('Timed out', 'TimeoutError')),
|
|
51
|
+
timeout
|
|
52
|
+
);
|
|
53
|
+
try {
|
|
54
|
+
const response = await fetch(url, { ...init, signal: controller.signal });
|
|
55
|
+
const preview = await readPreview(response, maxBytes, controller.signal);
|
|
56
|
+
const duration = Math.round(performance.now() - started);
|
|
57
|
+
if (disposed) return;
|
|
58
|
+
result.value = {
|
|
59
|
+
status: response.status,
|
|
60
|
+
statusText: response.statusText,
|
|
61
|
+
duration,
|
|
62
|
+
headers: [...response.headers.entries()],
|
|
63
|
+
...preview,
|
|
64
|
+
body: preview.truncated
|
|
65
|
+
? preview.body
|
|
66
|
+
: formatPreview(preview.body, maxBytes)
|
|
67
|
+
};
|
|
68
|
+
failure.value = undefined;
|
|
69
|
+
settled?.('response', duration, response.status);
|
|
70
|
+
} catch {
|
|
71
|
+
if (disposed) return;
|
|
72
|
+
result.value = undefined;
|
|
73
|
+
failure.value = controller.signal.aborted
|
|
74
|
+
? controller.signal.reason?.name === 'TimeoutError'
|
|
75
|
+
? 'timeout'
|
|
76
|
+
: 'cancelled'
|
|
77
|
+
: 'blocked';
|
|
78
|
+
settled?.('failed', Math.round(performance.now() - started));
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
const remaining = minimumWait - (performance.now() - started);
|
|
82
|
+
if (!disposed && remaining > 0)
|
|
83
|
+
await new Promise((resolve) => setTimeout(resolve, remaining));
|
|
84
|
+
if (active === controller) {
|
|
85
|
+
active = undefined;
|
|
86
|
+
sending.value = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return { sending, result, failure, send, cancel };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatPreview(text: string, maxBytes: number): string {
|
|
94
|
+
// Indenting nested JSON can expand a few KiB into tens of MiB. Bound both
|
|
95
|
+
// formatting work and its output, not just the incoming response stream.
|
|
96
|
+
if (text.length > 64 * 1024) return text;
|
|
97
|
+
let depth = 0;
|
|
98
|
+
let quoted = false;
|
|
99
|
+
let escaped = false;
|
|
100
|
+
for (const character of text) {
|
|
101
|
+
if (escaped) {
|
|
102
|
+
escaped = false;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (quoted && character === '\\') {
|
|
106
|
+
escaped = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (character === '"') {
|
|
110
|
+
quoted = !quoted;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (quoted) continue;
|
|
114
|
+
if (character === '{' || character === '[') {
|
|
115
|
+
if (++depth > 32) return text;
|
|
116
|
+
} else if (character === '}' || character === ']') depth--;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const formatted = JSON.stringify(JSON.parse(text), null, 2);
|
|
120
|
+
return new TextEncoder().encode(formatted).byteLength <= maxBytes
|
|
121
|
+
? formatted
|
|
122
|
+
: text;
|
|
123
|
+
} catch {
|
|
124
|
+
return text;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function readPreview(
|
|
129
|
+
response: Response,
|
|
130
|
+
maxBytes: number,
|
|
131
|
+
signal: AbortSignal
|
|
132
|
+
) {
|
|
133
|
+
if (!response.body) return { body: '', truncated: false };
|
|
134
|
+
const reader = response.body.getReader();
|
|
135
|
+
const decoder = new TextDecoder();
|
|
136
|
+
const parts: string[] = [];
|
|
137
|
+
let bytes = 0;
|
|
138
|
+
// Also cancels a stalled body after the headers have arrived.
|
|
139
|
+
const abort = () => {
|
|
140
|
+
void reader.cancel().catch(() => {});
|
|
141
|
+
};
|
|
142
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
143
|
+
if (signal.aborted) abort();
|
|
144
|
+
try {
|
|
145
|
+
while (true) {
|
|
146
|
+
signal.throwIfAborted();
|
|
147
|
+
const chunk = await reader.read();
|
|
148
|
+
signal.throwIfAborted();
|
|
149
|
+
if (chunk.done) break;
|
|
150
|
+
const remaining = maxBytes - bytes;
|
|
151
|
+
parts.push(
|
|
152
|
+
decoder.decode(chunk.value.subarray(0, remaining), { stream: true })
|
|
153
|
+
);
|
|
154
|
+
bytes += chunk.value.byteLength;
|
|
155
|
+
if (bytes > maxBytes) {
|
|
156
|
+
await reader.cancel();
|
|
157
|
+
parts.push(decoder.decode());
|
|
158
|
+
return { body: parts.join(''), truncated: true };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
parts.push(decoder.decode());
|
|
162
|
+
return { body: parts.join(''), truncated: false };
|
|
163
|
+
} finally {
|
|
164
|
+
signal.removeEventListener('abort', abort);
|
|
165
|
+
reader.releaseLock();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -108,11 +108,9 @@ export function useDuxtVersion() {
|
|
|
108
108
|
/**
|
|
109
109
|
* Whether the same page is MISSING from the preferred version.
|
|
110
110
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* canonical at a page that does not exist. Every other per-version tree is the
|
|
115
|
-
* same tree at another ref, so nothing else is looked up.
|
|
111
|
+
* Any edition may rename or remove a page, including an API endpoint. Resolve
|
|
112
|
+
* existence before rendering the banner and canonical, rather than creating
|
|
113
|
+
* a link to a guessed target that the preferred version never published.
|
|
116
114
|
*
|
|
117
115
|
* `useAsyncData`, so the server waits for it and the canonical in the
|
|
118
116
|
* prerendered HTML is already right. Keyed per path, so the banner and the
|
|
@@ -123,13 +121,7 @@ export function useDuxtVersion() {
|
|
|
123
121
|
async () => {
|
|
124
122
|
const own = current.value;
|
|
125
123
|
const target = preferred.value;
|
|
126
|
-
if (
|
|
127
|
-
!own ||
|
|
128
|
-
!target ||
|
|
129
|
-
own === target ||
|
|
130
|
-
own.generated?.type !== 'changelog' ||
|
|
131
|
-
path.value === own.prefix
|
|
132
|
-
) {
|
|
124
|
+
if (!own || !target || own === target || path.value === own.prefix) {
|
|
133
125
|
return false;
|
|
134
126
|
}
|
|
135
127
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
let queue: Promise<unknown> = Promise.resolve();
|
|
2
|
+
|
|
3
|
+
/** Mermaid owns global configuration: initialise and render as one operation. */
|
|
4
|
+
export function renderDuxtMermaid(
|
|
5
|
+
id: string,
|
|
6
|
+
source: string,
|
|
7
|
+
dark: boolean,
|
|
8
|
+
current: () => boolean = () => true
|
|
9
|
+
): Promise<string> {
|
|
10
|
+
const render = queue
|
|
11
|
+
.catch(() => {})
|
|
12
|
+
.then(async () => {
|
|
13
|
+
if (!current()) return '';
|
|
14
|
+
const mermaid = (await import('mermaid')).default;
|
|
15
|
+
if (!current()) return '';
|
|
16
|
+
mermaid.initialize({
|
|
17
|
+
startOnLoad: false,
|
|
18
|
+
securityLevel: 'strict',
|
|
19
|
+
suppressErrorRendering: true,
|
|
20
|
+
theme: dark ? 'dark' : 'default'
|
|
21
|
+
});
|
|
22
|
+
try {
|
|
23
|
+
return (await mermaid.render(id, source)).svg;
|
|
24
|
+
} finally {
|
|
25
|
+
// A parser failure can leave Mermaid's temporary render container behind.
|
|
26
|
+
document.getElementById(`d${id}`)?.remove();
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
queue = render;
|
|
30
|
+
return render;
|
|
31
|
+
}
|