@kirchdev/duxt 0.3.4 → 0.3.5-dev-main.44-5337cc1

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 (219) hide show
  1. package/README.md +24 -9
  2. package/app/app.vue +17 -11
  3. package/app/assets/css/duxt.css +47 -6
  4. package/app/components/DuxtAnnouncements.vue +117 -0
  5. package/app/components/DuxtBrunoEntries.vue +62 -0
  6. package/app/components/DuxtCodeBlock.vue +18 -34
  7. package/app/components/DuxtCodeToolbar.vue +14 -0
  8. package/app/components/DuxtCopyButton.vue +33 -0
  9. package/app/components/DuxtCopyPage.vue +32 -30
  10. package/app/components/DuxtFooter.vue +39 -8
  11. package/app/components/DuxtHeader.vue +107 -48
  12. package/app/components/DuxtLandingProse.vue +1 -1
  13. package/app/components/DuxtLinkLabel.vue +36 -0
  14. package/app/components/DuxtLiveWindow.vue +8 -8
  15. package/app/components/DuxtLocale.vue +10 -1
  16. package/app/components/DuxtNavigation.vue +2 -2
  17. package/app/components/DuxtOpenApiClient.vue +285 -90
  18. package/app/components/DuxtOpenApiMedia.vue +30 -18
  19. package/app/components/DuxtOpenApiSchema.vue +10 -7
  20. package/app/components/DuxtPageFeedback.vue +7 -0
  21. package/app/components/DuxtPageInfo.vue +15 -12
  22. package/app/components/DuxtPageNav.vue +41 -27
  23. package/app/components/DuxtPreviewSkeleton.vue +4 -4
  24. package/app/components/DuxtProgress.vue +2 -14
  25. package/app/components/DuxtSearch.vue +230 -90
  26. package/app/components/DuxtSearchInput.vue +70 -0
  27. package/app/components/DuxtSections.vue +11 -1
  28. package/app/components/DuxtSegmented.vue +64 -0
  29. package/app/components/DuxtShortcuts.vue +16 -11
  30. package/app/components/DuxtShortcutsTrigger.vue +49 -0
  31. package/app/components/DuxtSkipLink.vue +1 -1
  32. package/app/components/DuxtToc.vue +16 -10
  33. package/app/components/DuxtTranslationBanner.vue +1 -1
  34. package/app/components/DuxtVersion.vue +53 -12
  35. package/app/components/DuxtVersionBanner.vue +5 -5
  36. package/app/components/content/AccordionItem.vue +2 -2
  37. package/app/components/content/BrunoOverview.vue +260 -0
  38. package/app/components/content/BrunoRequest.vue +141 -0
  39. package/app/components/content/BrunoRequests.vue +104 -0
  40. package/app/components/content/Callout.vue +5 -5
  41. package/app/components/content/ChangelogReleases.vue +161 -40
  42. package/app/components/content/CodeGroup.vue +41 -37
  43. package/app/components/content/Field.vue +1 -1
  44. package/app/components/content/FileTree.vue +1 -1
  45. package/app/components/content/OpenApiOperation.vue +4 -4
  46. package/app/components/content/OpenApiOperations.vue +1 -1
  47. package/app/components/content/OpenApiOverview.vue +1 -1
  48. package/app/components/content/PackageManagers.vue +42 -58
  49. package/app/components/content/PageCards.vue +7 -4
  50. package/app/components/content/Preview.vue +15 -39
  51. package/app/components/content/ProseImg.vue +596 -49
  52. package/app/components/content/Since.vue +1 -1
  53. package/app/components/content/Steps.vue +6 -4
  54. package/app/components/content/TabGroup.vue +13 -12
  55. package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
  56. package/app/components/ui/command/CommandShortcut.vue +1 -1
  57. package/app/components/ui/dialog/DialogContent.vue +2 -2
  58. package/app/components/ui/dialog/DialogHeader.vue +1 -1
  59. package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
  60. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
  61. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  62. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
  63. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
  64. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
  65. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
  66. package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
  67. package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
  68. package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
  69. package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
  70. package/app/components/ui/select/SelectItem.vue +2 -2
  71. package/app/components/ui/sheet/SheetContent.vue +29 -3
  72. package/app/components/ui/sidebar/Sidebar.vue +28 -4
  73. package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  74. package/app/components/ui/sidebar/SidebarInset.vue +1 -1
  75. package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  76. package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  77. package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
  78. package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  79. package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
  80. package/app/components/ui/sidebar/index.ts +10 -2
  81. package/app/components/ui/sidebar/utils.ts +0 -1
  82. package/app/components/ui/sonner/Sonner.vue +18 -0
  83. package/app/components/ui/tabs/TabsList.vue +13 -3
  84. package/app/components/ui/tabs/TabsTrigger.vue +24 -3
  85. package/app/components/ui/tooltip/TooltipContent.vue +2 -2
  86. package/app/composables/useActiveHeading.ts +3 -25
  87. package/app/composables/useDuxtAnalytics.ts +50 -0
  88. package/app/composables/useDuxtAnimatedHeight.ts +8 -38
  89. package/app/composables/useDuxtAnnouncements.ts +153 -0
  90. package/app/composables/useDuxtCopy.ts +51 -0
  91. package/app/composables/useDuxtDirection.ts +23 -0
  92. package/app/composables/useDuxtNavigation.ts +28 -8
  93. package/app/composables/useDuxtPath.ts +3 -5
  94. package/app/composables/useDuxtSearch.ts +87 -35
  95. package/app/composables/useDuxtSection.ts +15 -1
  96. package/app/composables/useDuxtSectionRow.ts +6 -5
  97. package/app/composables/useDuxtShortcuts.ts +319 -20
  98. package/app/composables/useDuxtVersion.ts +57 -6
  99. package/app/composables/useDuxtViewportMeasure.ts +37 -0
  100. package/app/composables/useFuzzySearch.ts +9 -4
  101. package/app/composables/useRecentPages.ts +14 -17
  102. package/app/error.vue +112 -74
  103. package/app/layouts/changelog.vue +9 -1
  104. package/app/layouts/default.vue +9 -0
  105. package/app/layouts/docs.vue +9 -1
  106. package/app/layouts/reference.vue +9 -1
  107. package/app/middleware/duxt-section-layout.global.ts +1 -3
  108. package/app/pages/[...slug].vue +146 -35
  109. package/app/pages/index.vue +15 -23
  110. package/app/types/duxt.d.ts +312 -4
  111. package/app/utils/analytics.ts +91 -0
  112. package/app/utils/announcements.ts +205 -0
  113. package/app/utils/changelog.ts +0 -28
  114. package/app/utils/code-highlight.ts +3 -1
  115. package/app/utils/contributor-avatar.ts +27 -0
  116. package/app/utils/direction.ts +43 -0
  117. package/app/utils/duxt-config.ts +43 -0
  118. package/app/utils/duxt-text.ts +10 -0
  119. package/app/utils/format-date.ts +28 -0
  120. package/app/utils/generated-sections.ts +68 -12
  121. package/app/utils/generated-toc.ts +14 -7
  122. package/app/utils/locale-path.ts +43 -0
  123. package/app/utils/navigation-tree.ts +51 -1
  124. package/app/utils/nearest-page.ts +8 -0
  125. package/app/utils/openapi.ts +16 -11
  126. package/app/utils/page-controls.ts +215 -0
  127. package/app/utils/pill.ts +61 -0
  128. package/app/utils/prose-image.ts +128 -0
  129. package/app/utils/search-context.ts +110 -0
  130. package/app/utils/search-display.ts +248 -0
  131. package/app/utils/section-scope.ts +65 -4
  132. package/app/utils/stored-json.ts +31 -0
  133. package/app/utils/version-choices.ts +6 -13
  134. package/app/utils/version-paths.ts +21 -0
  135. package/bin/duxt-cache-key.mjs +61 -0
  136. package/bin/duxt-og-cache.mjs +58 -0
  137. package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
  138. package/bruno-model.ts +169 -0
  139. package/bruno-parse.ts +656 -0
  140. package/bruno-zip.ts +195 -0
  141. package/cli.ts +240 -0
  142. package/duxt-app-config.ts +18 -1
  143. package/frontmatter.ts +49 -0
  144. package/git-contributors.ts +322 -0
  145. package/i18n/locales/de/duxt/announcement.json +7 -0
  146. package/i18n/locales/de/duxt/bruno.json +25 -0
  147. package/i18n/locales/de/duxt/changelog.json +1 -1
  148. package/i18n/locales/de/duxt/code.json +2 -1
  149. package/i18n/locales/de/duxt/page.json +9 -0
  150. package/i18n/locales/de/duxt/shortcuts.json +1 -0
  151. package/i18n/locales/de/duxt/theme.json +2 -1
  152. package/i18n/locales/en/duxt/announcement.json +7 -0
  153. package/i18n/locales/en/duxt/bruno.json +25 -0
  154. package/i18n/locales/en/duxt/changelog.json +1 -1
  155. package/i18n/locales/en/duxt/code.json +2 -1
  156. package/i18n/locales/en/duxt/page.json +9 -0
  157. package/i18n/locales/en/duxt/shortcuts.json +1 -0
  158. package/i18n/locales/en/duxt/theme.json +2 -1
  159. package/i18n/locales/es/duxt/announcement.json +7 -0
  160. package/i18n/locales/es/duxt/bruno.json +25 -0
  161. package/i18n/locales/es/duxt/changelog.json +1 -1
  162. package/i18n/locales/es/duxt/code.json +2 -1
  163. package/i18n/locales/es/duxt/page.json +9 -0
  164. package/i18n/locales/es/duxt/shortcuts.json +1 -0
  165. package/i18n/locales/es/duxt/theme.json +2 -1
  166. package/i18n/locales/fr/duxt/announcement.json +7 -0
  167. package/i18n/locales/fr/duxt/bruno.json +25 -0
  168. package/i18n/locales/fr/duxt/changelog.json +1 -1
  169. package/i18n/locales/fr/duxt/code.json +2 -1
  170. package/i18n/locales/fr/duxt/page.json +9 -0
  171. package/i18n/locales/fr/duxt/shortcuts.json +1 -0
  172. package/i18n/locales/fr/duxt/theme.json +2 -1
  173. package/i18n/locales/pt/duxt/announcement.json +7 -0
  174. package/i18n/locales/pt/duxt/bruno.json +25 -0
  175. package/i18n/locales/pt/duxt/changelog.json +1 -1
  176. package/i18n/locales/pt/duxt/code.json +2 -1
  177. package/i18n/locales/pt/duxt/page.json +9 -0
  178. package/i18n/locales/pt/duxt/shortcuts.json +1 -0
  179. package/i18n/locales/pt/duxt/theme.json +2 -1
  180. package/modules/bruno.ts +118 -0
  181. package/modules/config.ts +26 -0
  182. package/modules/devtools.ts +2 -3
  183. package/modules/git-meta.ts +16 -47
  184. package/modules/search-records.ts +132 -0
  185. package/nuxt.config.ts +81 -7
  186. package/og-image-cache.ts +742 -0
  187. package/package.json +42 -10
  188. package/public/devtools/checks.html +2 -2
  189. package/public/devtools/config.html +59 -4
  190. package/public/devtools/pages.html +8 -1
  191. package/public/devtools/paths.html +1 -1
  192. package/public/devtools/sources.html +2 -2
  193. package/public/devtools/versions.html +3 -0
  194. package/report.ts +28 -19
  195. package/search-records.ts +380 -0
  196. package/section-input.ts +142 -0
  197. package/section-markdown.ts +67 -0
  198. package/section-reports.ts +10 -4
  199. package/sections-bruno.ts +571 -0
  200. package/sections-changelog.ts +76 -43
  201. package/sections-openapi.ts +12 -83
  202. package/sections-resolve.ts +174 -8
  203. package/sections.ts +33 -10
  204. package/server/devtools/shell.ts +1 -1
  205. package/server/mcp/tools/list-pages.ts +77 -36
  206. package/server/mcp/tools/list-versions.ts +84 -0
  207. package/server/mcp/tools/read-page.ts +80 -26
  208. package/server/mcp/tools/search-docs.ts +87 -53
  209. package/server/middleware/raw-markdown.ts +1 -1
  210. package/server/routes/llms-full.txt.get.ts +6 -8
  211. package/server/routes/llms.txt.get.ts +1 -1
  212. package/server/utils/duxt-server-text.ts +0 -13
  213. package/server/utils/llms-pages.ts +24 -9
  214. package/server/utils/mcp-docs.ts +577 -0
  215. package/sources-cache.ts +376 -0
  216. package/sources-git.ts +186 -31
  217. package/sources-resolve.ts +59 -1
  218. package/sources.ts +122 -13
  219. package/tfplugindocs.ts +158 -0
@@ -0,0 +1,571 @@
1
+ /**
2
+ * The `bruno` type: a Bruno collection, published as reference pages.
3
+ *
4
+ * The third type in the registry and the second API one, and the question it
5
+ * was filed to answer is whether a second API type is a second RENDERING PATH.
6
+ * It is not: a Bruno request page renders in the same `reference` layout an
7
+ * OpenAPI operation does, and the try-it column is the same component fed a
8
+ * different parser's output. What this file adds is a parser, two policies and
9
+ * an adapter — which is the registry doing the job it was built for.
10
+ *
11
+ * WHAT A BRUNO COLLECTION IS NOT is why the pages are shaped the way they are.
12
+ * It is a client artefact: requests, folders, headers, bodies, scripts and
13
+ * environments, with no response schemas, no reusable components and no
14
+ * statement of what a field means. So these pages say what the collection
15
+ * actually says — method, URL, headers, parameters, body — and invent nothing.
16
+ * The issue that decided this type rejected converting Bruno to OpenAPI for
17
+ * exactly that reason: the conversion invents schemas it does not have, which
18
+ * makes the thinness invisible rather than honest.
19
+ *
20
+ * ONE PAGE PER REQUEST, a page per folder, and the collection's own overview —
21
+ * the same shape `openapi` uses, for the same reason: everything the layer can
22
+ * do hangs off pages, and a deep link to a request has to survive being pasted
23
+ * into a ticket.
24
+ *
25
+ * THE ARCHIVE IS THE POINT OF THE OVERVIEW. A reference tells you what the
26
+ * requests are; a collection is a thing you OPEN. The download is built from
27
+ * the exact version and locale the reader is looking at — see `bruno-zip.ts` —
28
+ * which is what neither a link to the repository's default branch nor a "Fetch
29
+ * in Bruno" clone of today's HEAD can offer.
30
+ */
31
+ import { stringify as stringifyYaml } from 'yaml';
32
+ import { frontmatterBlock } from './frontmatter';
33
+ import { firstLine, pageOrder, urlSegment } from './section-markdown';
34
+ import type {
35
+ DuxtBrunoCollection,
36
+ DuxtBrunoEntry,
37
+ DuxtBrunoFolder,
38
+ DuxtBrunoRequest
39
+ } from './bruno-model';
40
+ import { parseBrunoCollection } from './bruno-parse';
41
+ import { brunoZipPath } from './bruno-zip';
42
+ import type {
43
+ DuxtSectionContext,
44
+ DuxtSectionInput,
45
+ DuxtSectionOptions,
46
+ DuxtSectionPage,
47
+ DuxtSectionType
48
+ } from './sections-resolve';
49
+
50
+ /**
51
+ * The layout this type binds — the SAME one `openapi` binds.
52
+ *
53
+ * Deliberately not a name of its own. The two types produce the same kind of
54
+ * page and want the same layout: the full column width, the request list on the
55
+ * left, no reading measure. A second layout would be a copy that drifts, and a
56
+ * consumer who overrode `reference` to brand their API pages would find half of
57
+ * them unbranded.
58
+ */
59
+ export const DUXT_BRUNO_LAYOUT = 'reference';
60
+
61
+ /** Bruno's own deep link, which needs no third-party script on the page. */
62
+ const FETCH_IN_BRUNO = 'https://fetch.usebruno.com/?url=';
63
+
64
+ /** The variable a Bruno collection conventionally puts its host in. */
65
+ const DEFAULT_BASE_VARIABLE = 'baseUrl';
66
+
67
+ export const brunoSectionType: DuxtSectionType = {
68
+ parse: parseBrunoSection,
69
+ /**
70
+ * A DIRECTORY, which is the contract change this type is the reason for.
71
+ *
72
+ * `bruno.json`, a tree of `.bru` files, `folder.bru` ordering and an
73
+ * `environments/` directory — there is no one file to open, and a type that
74
+ * had to be handed one would have to be handed the wrong thing.
75
+ */
76
+ input: 'directory',
77
+ /**
78
+ * PER VERSION, for the reason `openapi` is: an API belongs to the release it
79
+ * describes, and a reader on `v1` must not be shown `v2`'s endpoints.
80
+ */
81
+ versioning: 'per-version',
82
+ /**
83
+ * PER LOCALE, also matching `openapi`. A `.bru` file's `docs` blocks are
84
+ * written prose — the same prose an OpenAPI `description` is — so a
85
+ * translated collection is a real artefact rather than the same files
86
+ * relabelled. A locale that declares none builds nothing and falls through to
87
+ * the original with the translation banner saying so.
88
+ */
89
+ localisation: 'per-locale',
90
+ layout: DUXT_BRUNO_LAYOUT,
91
+ /**
92
+ * `lucide`, not `simple-icons`, and the repository's icon rule says why: this
93
+ * entry names a SECTION OF THE DOCUMENTATION, not the Bruno platform. The one
94
+ * place `simple-icons:bruno` belongs is the "Fetch in Bruno" action, which
95
+ * hands the collection to Bruno itself.
96
+ */
97
+ icon: 'lucide:send'
98
+ };
99
+
100
+ /** What a site turned on, as this type reads it. */
101
+ interface BrunoOptions {
102
+ tryIt?: {
103
+ /** The public base URL requests are sent to. */
104
+ baseUrl?: string;
105
+ /** The Bruno variable that base URL stands in for. */
106
+ baseVariable?: string;
107
+ /** Variables with a public value, and nothing else, may be substituted. */
108
+ variables?: Record<string, string>;
109
+ };
110
+ /** A public git URL, which turns on the "Fetch in Bruno" action. */
111
+ fetch?: string;
112
+ }
113
+
114
+ function parseBrunoSection(
115
+ input: DuxtSectionInput,
116
+ context: DuxtSectionContext
117
+ ): DuxtSectionPage[] {
118
+ const collection = parseBrunoCollection(input);
119
+
120
+ // Into the report rather than onto the console, for the reason every other
121
+ // type does it: a collection is parsed while the config is loading, so a
122
+ // printed warning has scrolled away before the dev server has started.
123
+ for (const warning of collection.warnings) context.warn?.(warning);
124
+
125
+ const options = readOptions(context.options);
126
+
127
+ // ONE NAMESPACE FOR BOTH. A folder and a request at the same level are
128
+ // siblings in the URL — `/api/shipments` and `/api/ping` — so the slugs have
129
+ // to be unique across the two lists together, not within each.
130
+ const level = slugsFor(collection.folders, collection.requests);
131
+
132
+ return [
133
+ overview(collection, context, options, level),
134
+ ...collection.folders.flatMap((folder, index) =>
135
+ folderPages(
136
+ folder,
137
+ options,
138
+ level.slugs[index]!,
139
+ `${pageOrder(index, level.width)}.`,
140
+ '',
141
+ context.prefix
142
+ )
143
+ ),
144
+ ...collection.requests.map((request, index) =>
145
+ requestPage(
146
+ request,
147
+ options,
148
+ level.slugs[collection.folders.length + index]!,
149
+ `${pageOrder(collection.folders.length + index, level.width)}.`,
150
+ ''
151
+ )
152
+ )
153
+ ];
154
+ }
155
+
156
+ /**
157
+ * Unique URL segments for one level of the tree.
158
+ *
159
+ * Two requests called `Get user` and `Get User` slugify to the same segment,
160
+ * and without this the second one's page would overwrite the first's — a
161
+ * request silently missing from the reference, which is the failure mode the
162
+ * OpenAPI type already answers for tags and operations. Numbered in the order
163
+ * they are read, so the first keeps the clean name.
164
+ */
165
+ function slugsFor(
166
+ folders: DuxtBrunoFolder[],
167
+ requests: DuxtBrunoRequest[]
168
+ ): { slugs: string[]; width: number } {
169
+ const taken = new Set<string>();
170
+
171
+ const slugs = [
172
+ ...folders.map((folder) => folder.name || folder.dir),
173
+ ...requests.map((request) => request.name)
174
+ ].map((name) => {
175
+ const base = urlSegment(name, 'request');
176
+
177
+ let candidate = base;
178
+ let attempt = 2;
179
+ while (taken.has(candidate)) candidate = `${base}-${attempt++}`;
180
+ taken.add(candidate);
181
+
182
+ return candidate;
183
+ });
184
+
185
+ return { slugs, width: folders.length + requests.length };
186
+ }
187
+
188
+ /* ------------------------------------------------------------------ pages */
189
+
190
+ /** The collection's own page: what it is, what is in it, how to get it. */
191
+ function overview(
192
+ collection: DuxtBrunoCollection,
193
+ context: DuxtSectionContext,
194
+ options: BrunoOptions,
195
+ level: { slugs: string[] }
196
+ ): DuxtSectionPage {
197
+ const props = {
198
+ name: collection.name,
199
+ version: collection.version,
200
+ auth: collection.auth,
201
+ headers: collection.headers,
202
+ environments: collection.environments,
203
+ // A REMOTE collection gets no archive, and the limit is real rather than
204
+ // chosen: Content's checkout lands in a hash-cached directory known only
205
+ // inside the collection that declared it, so the module that writes these
206
+ // files cannot read the artefact a second time. A link to a file nothing
207
+ // wrote would be worse than the link being absent.
208
+ download: context.remote ? undefined : brunoZipPath(context.collection),
209
+ fetch: options.fetch
210
+ ? `${FETCH_IN_BRUNO}${encodeURIComponent(options.fetch)}`
211
+ : undefined,
212
+ groups: collection.folders.map((folder, index) => ({
213
+ name: folder.name,
214
+ description: firstLine(folder.docs),
215
+ requests: count(folder),
216
+ to: `${context.prefix}/${level.slugs[index]}`
217
+ })),
218
+ requests: collection.requests.map((request, index) => ({
219
+ name: request.name,
220
+ method: request.method,
221
+ url: request.url,
222
+ to: `${context.prefix}/${level.slugs[collection.folders.length + index]}`
223
+ }))
224
+ };
225
+
226
+ return {
227
+ file: 'index.md',
228
+ body: page(
229
+ {
230
+ title: collection.name,
231
+ description: firstLine(collection.docs) ?? collection.name
232
+ },
233
+ [component('bruno-overview', props, collection.docs)]
234
+ )
235
+ };
236
+ }
237
+
238
+ /** A folder's index page, and a page for everything under it. */
239
+ function folderPages(
240
+ folder: DuxtBrunoFolder,
241
+ options: BrunoOptions,
242
+ slug: string,
243
+ prefix: string,
244
+ parent: string,
245
+ parentUrl: string
246
+ ): DuxtSectionPage[] {
247
+ // The FILE path carries the `NN.` ordering prefixes; the URL does not,
248
+ // because Content strips them. Two values rather than one derived from the
249
+ // other by a regex — which is what this was, and a folder legitimately named
250
+ // `1.x` walked straight into it.
251
+ const dir = `${parent}${prefix}${slug}`;
252
+ const to = `${parentUrl}/${slug}`;
253
+
254
+ const level = slugsFor(folder.folders, folder.requests);
255
+
256
+ const children = folder.folders.flatMap((child, index) =>
257
+ folderPages(
258
+ child,
259
+ options,
260
+ level.slugs[index]!,
261
+ `${pageOrder(index, level.width)}.`,
262
+ `${dir}/`,
263
+ to
264
+ )
265
+ );
266
+
267
+ const requests = folder.requests.map((request, index) =>
268
+ requestPage(
269
+ request,
270
+ options,
271
+ level.slugs[folder.folders.length + index]!,
272
+ `${pageOrder(folder.folders.length + index, level.width)}.`,
273
+ `${dir}/`
274
+ )
275
+ );
276
+
277
+ const index: DuxtSectionPage = {
278
+ file: `${dir}/index.md`,
279
+ body: page({ title: folder.name, description: firstLine(folder.docs) }, [
280
+ component(
281
+ 'bruno-requests',
282
+ {
283
+ requests: folder.requests.map((request, position) => ({
284
+ name: request.name,
285
+ method: request.method,
286
+ url: request.url,
287
+ to: `${to}/${level.slugs[folder.folders.length + position]}`
288
+ })),
289
+ groups: folder.folders.map((child, position) => ({
290
+ name: child.name,
291
+ description: firstLine(child.docs),
292
+ requests: count(child),
293
+ to: `${to}/${level.slugs[position]}`
294
+ }))
295
+ },
296
+ folder.docs
297
+ )
298
+ ])
299
+ };
300
+
301
+ return [index, ...children, ...requests];
302
+ }
303
+
304
+ /** One request, as one page. */
305
+ function requestPage(
306
+ request: DuxtBrunoRequest,
307
+ options: BrunoOptions,
308
+ slug: string,
309
+ prefix: string,
310
+ parent: string
311
+ ): DuxtSectionPage {
312
+ // The docs travel in the SLOT rather than in the props, so what the file
313
+ // wrote as Markdown is rendered as Markdown — the same rule `openapi`
314
+ // follows for a description.
315
+ const { docs, ...rest } = request;
316
+
317
+ const client = tryIt(request, options);
318
+
319
+ return {
320
+ file: `${parent}${prefix}${slug}.md`,
321
+ body: page(
322
+ {
323
+ title: request.name,
324
+ description: firstLine(docs) ?? `${request.method} ${request.url}`
325
+ },
326
+ [component('bruno-request', { request: rest, ...client }, docs)]
327
+ )
328
+ };
329
+ }
330
+
331
+ /* ----------------------------------------------------------------- try-it */
332
+
333
+ /**
334
+ * The request as the existing try-it client takes it — or nothing at all.
335
+ *
336
+ * OFF UNTIL A SITE TURNS IT ON, which is the decision this whole function
337
+ * implements. A Bruno collection is written against whatever the author could
338
+ * reach, which is routinely a staging host behind a VPN or a `localhost` port;
339
+ * a documentation site that pointed a send button at it would be offering
340
+ * readers a request that cannot work, and a site that guessed a public host
341
+ * from the collection would be inventing one. So the site states the public
342
+ * base URL and the variables that have a public value, and anything it did not
343
+ * state leaves the page static.
344
+ *
345
+ * ADAPTED TO `DuxtOpenApiOperation` rather than given a client of its own: the
346
+ * existing one already answers "where do the credentials go" in a way this
347
+ * layer has committed to — in the component's own state, never stored, never
348
+ * proxied — and a second client would be a second place to get that wrong.
349
+ */
350
+ function tryIt(
351
+ request: DuxtBrunoRequest,
352
+ options: BrunoOptions
353
+ ): { operation: Record<string, unknown>; servers: { url: string }[] } | object {
354
+ const base = options.tryIt?.baseUrl;
355
+ if (!base) return {};
356
+
357
+ const variable = options.tryIt?.baseVariable ?? DEFAULT_BASE_VARIABLE;
358
+
359
+ const resolved = resolve(request.url, {
360
+ [variable]: '',
361
+ ...options.tryIt?.variables
362
+ });
363
+
364
+ // A URL still holding `{{something}}` cannot be sent, and a send button that
365
+ // builds a wrong URL is worse than no button: the reader gets a failure the
366
+ // page cannot explain.
367
+ if (resolved === undefined) return {};
368
+
369
+ const [withoutQuery] = resolved.split('?');
370
+
371
+ // Bruno writes a path parameter as `:name`; OpenAPI writes it as `{name}`,
372
+ // which is the spelling the client draws a box for.
373
+ const path = (withoutQuery || '/').replace(/:([A-Za-z_][\w-]*)/g, '{$1}');
374
+
375
+ const parameters = [
376
+ ...request.params
377
+ .filter((entry) => !entry.disabled)
378
+ .map((entry) => parameter(entry, entry.in)),
379
+ ...request.headers
380
+ .filter((entry) => !entry.disabled)
381
+ .map((entry) => parameter(entry, 'header'))
382
+ ];
383
+
384
+ return {
385
+ servers: [{ url: base }],
386
+ operation: {
387
+ kind: 'operation',
388
+ method: request.method,
389
+ path: path.startsWith('/') ? path : `/${path}`,
390
+ deprecated: false,
391
+ ...(parameters.length ? { parameters } : {}),
392
+ ...(request.body?.text
393
+ ? {
394
+ requestBody: {
395
+ required: false,
396
+ content: [
397
+ {
398
+ type: mediaType(request.body.type),
399
+ examples: [
400
+ { name: 'default', value: example(request.body.text) }
401
+ ]
402
+ }
403
+ ]
404
+ }
405
+ }
406
+ : {})
407
+ }
408
+ };
409
+ }
410
+
411
+ /**
412
+ * One box in the client.
413
+ *
414
+ * A REDACTED value carries no example at all, so the box opens empty and the
415
+ * reader supplies their own credential. Prefilling it would put the collection
416
+ * author's live token into every reader's form — which is the leak the
417
+ * redaction exists to stop, arriving by the other door.
418
+ */
419
+ function parameter(entry: DuxtBrunoEntry, where: string) {
420
+ return {
421
+ name: entry.name,
422
+ in: where,
423
+ required: where === 'path',
424
+ ...(entry.redacted || !entry.value
425
+ ? {}
426
+ : { examples: [{ name: 'default', value: entry.value }] })
427
+ };
428
+ }
429
+
430
+ /**
431
+ * A URL with its `{{variables}}` filled in, or nothing where one is unknown.
432
+ *
433
+ * Only what the site DECLARED is substituted. A variable the site said nothing
434
+ * about is not guessed at and not left in: the whole URL is abandoned, because
435
+ * half a URL is a request to the wrong place.
436
+ */
437
+ function resolve(
438
+ url: string,
439
+ values: Record<string, string>
440
+ ): string | undefined {
441
+ let unknown = false;
442
+
443
+ const out = url.replaceAll(
444
+ /\{\{\s*([^{}]+?)\s*\}\}/g,
445
+ (_match, name: string) => {
446
+ const value = values[name];
447
+
448
+ if (value === undefined) {
449
+ unknown = true;
450
+ return '';
451
+ }
452
+
453
+ return value;
454
+ }
455
+ );
456
+
457
+ return unknown ? undefined : out;
458
+ }
459
+
460
+ /** The body's media type, as a header value. */
461
+ function mediaType(type: string): string {
462
+ if (type === 'json') return 'application/json';
463
+ if (type === 'xml') return 'application/xml';
464
+ if (type === 'graphql') return 'application/json';
465
+ if (type === 'sparql') return 'application/sparql-query';
466
+
467
+ return 'text/plain';
468
+ }
469
+
470
+ /** A JSON body as a value the client's editor can open; anything else as text. */
471
+ function example(text: string): unknown {
472
+ try {
473
+ return JSON.parse(text);
474
+ } catch {
475
+ return text;
476
+ }
477
+ }
478
+
479
+ /* ---------------------------------------------------------------- helpers */
480
+
481
+ function readOptions(options: DuxtSectionOptions): BrunoOptions {
482
+ const tryItOption = options.tryIt;
483
+ const fetchOption = options.fetch;
484
+
485
+ const tryIt =
486
+ tryItOption && typeof tryItOption === 'object'
487
+ ? (tryItOption as BrunoOptions['tryIt'])
488
+ : undefined;
489
+
490
+ return {
491
+ ...(tryIt?.baseUrl ? { tryIt } : {}),
492
+ ...(typeof fetchOption === 'string' && fetchOption
493
+ ? { fetch: fetchOption }
494
+ : {})
495
+ };
496
+ }
497
+
498
+ /** Every request under a folder, its own and its children's. */
499
+ function count(folder: DuxtBrunoFolder): number {
500
+ return (
501
+ folder.requests.length +
502
+ folder.folders.reduce((total, child) => total + count(child), 0)
503
+ );
504
+ }
505
+
506
+ /** An MDC block component with YAML props and Markdown inside it. */
507
+ function component(
508
+ name: string,
509
+ props: Record<string, unknown>,
510
+ slot?: string
511
+ ): string {
512
+ const body = slot?.trim() ?? '';
513
+
514
+ const longest = Math.max(
515
+ 2,
516
+ ...body.split('\n').map((line) => /^\s*(:+)/.exec(line)?.[1]?.length ?? 0)
517
+ );
518
+
519
+ const fence = ':'.repeat(Math.max(3, longest + 1));
520
+
521
+ return [
522
+ `${fence}${name}`,
523
+ '---',
524
+ stringifyYaml(compact(props)).trimEnd(),
525
+ '---',
526
+ ...(body ? [body] : []),
527
+ fence
528
+ ].join('\n');
529
+ }
530
+
531
+ /**
532
+ * The props, with every absent thing actually absent.
533
+ *
534
+ * `undefined` is not YAML, and a key written as `null` is a key a component has
535
+ * to test for a second way. Empty lists and maps go too — the model's own rule,
536
+ * stated in `openapi-model.ts` and followed here so the two types' props read
537
+ * the same.
538
+ */
539
+ function compact(value: unknown): unknown {
540
+ if (Array.isArray(value)) return value.map(compact);
541
+
542
+ if (value && typeof value === 'object') {
543
+ const out: Record<string, unknown> = {};
544
+
545
+ for (const [key, item] of Object.entries(value)) {
546
+ if (item === undefined || item === null) continue;
547
+ if (Array.isArray(item) && !item.length) continue;
548
+ if (
549
+ typeof item === 'object' &&
550
+ !Array.isArray(item) &&
551
+ !Object.keys(item).length
552
+ ) {
553
+ continue;
554
+ }
555
+
556
+ out[key] = compact(item);
557
+ }
558
+
559
+ return out;
560
+ }
561
+
562
+ return value;
563
+ }
564
+
565
+ /** A page: frontmatter, then the lines. */
566
+ function page(
567
+ fields: Record<string, string | undefined>,
568
+ lines: string[]
569
+ ): string {
570
+ return [frontmatterBlock(fields), '', ...lines, ''].join('\n');
571
+ }