@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
@@ -608,20 +608,27 @@ async function copySample() {
608
608
 
609
609
  /* ------------------------------------------------------------------- send */
610
610
 
611
- interface Result {
612
- status: number;
613
- statusText: string;
614
- duration: number;
615
- headers: [string, string][];
616
- body: string;
617
- }
618
-
619
- /** How long the send button shows its spinner, however fast the answer is. */
620
- const MINIMUM_WAIT = 300;
621
-
622
- const sending = ref(false);
623
- const result = ref<Result>();
624
- const failure = ref<string>();
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
- sending.value = true;
658
- // THE PREVIOUS ANSWER STAYS UNTIL THIS ONE ARRIVES. Clearing it here emptied
659
- // the panel the moment the button was pressed, so the page collapsed by the
660
- // height of a response and grew back a moment later — under the cursor that
661
- // had just clicked. It is dimmed and marked busy instead, and replaced when
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
- * Only inside a per-version CHANGELOG, and only below its overview: a release
70
- * page carries a release slug, and an older version's changelog has no page
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?.generated?.type !== 'changelog' || path.value === own.prefix) {
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 || owner.generated?.type !== 'changelog') continue;
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 container = useTemplateRef<HTMLElement>('container');
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
- let mermaid: typeof import('mermaid').default | undefined;
23
- let counter = 0;
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 || !container.value) return;
27
-
27
+ if (!import.meta.client || !mounted) return;
28
+ const current = ++revision;
28
29
  const text = source.value.trim();
29
- if (!text) return;
30
+ if (!text) {
31
+ svg.value = '';
32
+ return;
33
+ }
30
34
 
31
35
  try {
32
- mermaid ??= (await import('mermaid')).default;
33
-
34
- mermaid.initialize({
35
- startOnLoad: false,
36
- securityLevel: 'strict',
37
- theme: colorMode.value === 'dark' ? 'dark' : 'default'
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
- source.value =
52
- props.code ??
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 ref="container" :class="{ 'text-sm whitespace-pre': failed }">
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
- * Only a changelog release page can be: every version publishes its own
112
- * changelog, and a branch's may carry a release the default version has not
113
- * shipped. Swapping the prefix alone then pointed the banner's link and the
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
+ }