@kirchdev/duxt 0.3.4-dev-main.41-23ded41 → 0.3.5-dev-main.43-3310f4b

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 +41 -9
  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
package/bruno-parse.ts ADDED
@@ -0,0 +1,656 @@
1
+ /**
2
+ * A Bruno collection, read out of a directory of `.bru` files.
3
+ *
4
+ * THE LANGUAGE. `.bru` is a block format: a name, a `{`, lines until a `}` in
5
+ * the first column. A block is either a DICTIONARY of `key: value` lines — a
6
+ * `~` in front of the key means the line is kept but switched off — or a TEXT
7
+ * block whose contents are a body, a script or a page of Markdown. Which of the
8
+ * two a block is is decided by its name and not by looking at it, because a JSON
9
+ * body's first line is indistinguishable from a dictionary's and guessing would
10
+ * pick the wrong one on `{"a": 1}`.
11
+ *
12
+ * The closing brace is matched in the FIRST COLUMN rather than by counting
13
+ * braces, and that is the format's own rule rather than a shortcut: a JSON body
14
+ * is full of braces, and a counter would end the block in the middle of one.
15
+ *
16
+ * WHAT THIS PARSER REFUSES TO READ is as much of its job as what it reads. A
17
+ * Bruno collection is a working client's state, and three things in it are not
18
+ * documentation:
19
+ *
20
+ * - `environments/` holds hosts, tokens and keys. The files are COUNTED and
21
+ * never opened, so no code path exists down which a key could reach a page.
22
+ * - `script:*`, `tests` and `assert` are executable code. A request records
23
+ * THAT it has them; the code is never carried, so it cannot be rendered by a
24
+ * component written later, and it is never executed.
25
+ * - An `auth:<mode>` block's contents are a credential as often as a
26
+ * placeholder. The mode is kept, the value is dropped.
27
+ *
28
+ * The same rule is applied by NAME to header and parameter values, and to the
29
+ * URL itself — see `redactEntry` and `redactUrl`. The URL matters as much as
30
+ * the tables do, because Bruno writes a query parameter into BOTH and userinfo
31
+ * (`https://user:pass@host`) into the URL alone.
32
+ */
33
+ import type {
34
+ DuxtBrunoAuth,
35
+ DuxtBrunoBody,
36
+ DuxtBrunoCollection,
37
+ DuxtBrunoEntry,
38
+ DuxtBrunoFolder,
39
+ DuxtBrunoParam,
40
+ DuxtBrunoRequest
41
+ } from './bruno-model';
42
+ import type { DuxtSectionInput } from './sections-resolve';
43
+
44
+ /** The manifest at the root of every Bruno collection. */
45
+ const MANIFEST = 'bruno.json';
46
+ /** The folder-level file, which names and orders a directory. */
47
+ const FOLDER = 'folder.bru';
48
+ /** The collection-level file: shared headers, auth and docs. */
49
+ const COLLECTION = 'collection.bru';
50
+
51
+ /**
52
+ * The directory this layer counts and never opens.
53
+ *
54
+ * Stated as a constant so the rule is greppable from the model, which cites it.
55
+ */
56
+ export const BRUNO_UNPUBLISHED = 'environments';
57
+
58
+ /** The methods a `.bru` file can name a block after. */
59
+ const METHODS = new Set([
60
+ 'get',
61
+ 'post',
62
+ 'put',
63
+ 'patch',
64
+ 'delete',
65
+ 'head',
66
+ 'options',
67
+ 'trace'
68
+ ]);
69
+
70
+ /**
71
+ * Blocks whose contents are TEXT rather than a dictionary.
72
+ *
73
+ * By name, never by inspection: a JSON body's `{` opens a line that looks
74
+ * exactly like a nested dictionary, and a parser that guessed would read
75
+ * `{"a": 1}` as a key called `{"a"`.
76
+ */
77
+ const TEXT_BLOCKS = new Set([
78
+ 'docs',
79
+ 'tests',
80
+ 'body',
81
+ 'body:json',
82
+ 'body:text',
83
+ 'body:xml',
84
+ 'body:sparql',
85
+ 'body:graphql',
86
+ 'body:graphql:vars',
87
+ 'script:pre-request',
88
+ 'script:post-response'
89
+ ]);
90
+
91
+ /** A text body's block name, to the language its fence gets. */
92
+ const LANGUAGES: Record<string, string> = {
93
+ json: 'json',
94
+ xml: 'xml',
95
+ graphql: 'graphql',
96
+ sparql: 'sparql',
97
+ text: 'text'
98
+ };
99
+
100
+ /**
101
+ * Names whose value is withheld, wherever the value is written.
102
+ *
103
+ * By NAME rather than by looking for something token-shaped, because a token is
104
+ * not shaped like anything: `sk-live-…`, a bare JWT and a base64 basic pair
105
+ * have no form in common, and a heuristic that caught two of them would publish
106
+ * the third. An entry under one of these names is withheld unless its value is
107
+ * nothing but `{{variable}}` placeholders — which name a secret rather than
108
+ * carrying one.
109
+ *
110
+ * NOT A HEADER LIST. The same rule runs over query and path parameters and over
111
+ * the query string of the URL itself, and a credential written as a PARAMETER
112
+ * takes spellings a header never does: `api_key` and `access_token` are the two
113
+ * commonest of all, and neither is an `Authorization`.
114
+ *
115
+ * NORMALISED, so one entry covers every spelling a name is written in —
116
+ * `api_key`, `api-key`, `API-Key` and `apikey` are one name here. Adding a
117
+ * separator to a name is the cheapest way past a list, and it is not a way past
118
+ * this one.
119
+ */
120
+ const CREDENTIAL_NAMES = new Set([
121
+ 'authorization',
122
+ 'proxyauthorization',
123
+ 'cookie',
124
+ 'setcookie',
125
+ 'xapikey',
126
+ 'apikey',
127
+ 'apisecret',
128
+ 'xauthtoken',
129
+ 'accesstoken',
130
+ 'refreshtoken',
131
+ 'idtoken',
132
+ 'token',
133
+ 'accesskey',
134
+ 'secretkey',
135
+ 'privatekey',
136
+ 'clientsecret',
137
+ 'key',
138
+ 'secret',
139
+ 'signature',
140
+ 'sig',
141
+ 'password',
142
+ 'passwd',
143
+ 'pwd'
144
+ ]);
145
+
146
+ /**
147
+ * The one literal a withheld value may carry beside its placeholders.
148
+ *
149
+ * An HTTP auth scheme is not a credential: `Bearer {{token}}` names the scheme
150
+ * and the variable and carries neither, and hiding it would take away the one
151
+ * thing the reader needs. Everything else beside a placeholder is where HALF a
152
+ * credential gets written — `Bearer sk-live-abc123 {{sig}}` is a live token
153
+ * with a signature variable next to it — so the allowance is a closed list of
154
+ * scheme words rather than a shape.
155
+ */
156
+ const AUTH_SCHEMES = new Set([
157
+ 'bearer',
158
+ 'basic',
159
+ 'digest',
160
+ 'token',
161
+ 'apikey',
162
+ 'jwt',
163
+ 'hmac'
164
+ ]);
165
+
166
+ /** A `{{variable}}`, which names a secret rather than carrying one. */
167
+ const PLACEHOLDER = /\{\{[^{}]+\}\}/;
168
+
169
+ /** Every one of them, for taking them out of a value before reading it. */
170
+ const PLACEHOLDERS = /\{\{[^{}]+\}\}/g;
171
+
172
+ interface Block {
173
+ name: string;
174
+ lines: string[];
175
+ }
176
+
177
+ /**
178
+ * The whole collection.
179
+ *
180
+ * Walks the input's own listing rather than the filesystem, so the parser is
181
+ * the same code over a real directory and over a map in a test — and so a name
182
+ * this function composes can never reach `readFileSync`.
183
+ */
184
+ export function parseBrunoCollection(
185
+ input: DuxtSectionInput
186
+ ): DuxtBrunoCollection {
187
+ const warnings: string[] = [];
188
+ const files = input.files();
189
+
190
+ const manifest = files.includes(MANIFEST)
191
+ ? readManifest(input.read(MANIFEST), warnings)
192
+ : (warnings.push(
193
+ 'the collection has no bruno.json, so it may not open in Bruno.'
194
+ ),
195
+ {});
196
+
197
+ // COUNTED, NEVER READ. The count is what the page needs — a reader has to be
198
+ // told the collection expects an environment — and opening one is the only
199
+ // way a key could reach a page, so there is no code that does.
200
+ const environments = files.filter((file) =>
201
+ file.startsWith(`${BRUNO_UNPUBLISHED}/`)
202
+ ).length;
203
+
204
+ const shared = files.includes(COLLECTION)
205
+ ? blocksOf(input.read(COLLECTION))
206
+ : [];
207
+
208
+ const requests = new Map<string, DuxtBrunoRequest[]>();
209
+ const folders = new Map<
210
+ string,
211
+ { name: string; seq?: number; docs?: string }
212
+ >();
213
+
214
+ for (const file of files) {
215
+ if (file.startsWith(`${BRUNO_UNPUBLISHED}/`)) continue;
216
+ if (!file.endsWith('.bru')) continue;
217
+ if (file === COLLECTION) continue;
218
+
219
+ const dir = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '';
220
+
221
+ if (file.endsWith(`/${FOLDER}`) || file === FOLDER) {
222
+ folders.set(dir, folderMeta(input.read(file)));
223
+ continue;
224
+ }
225
+
226
+ const request = parseBruFile(file, input.read(file), warnings);
227
+ if (!request) continue;
228
+
229
+ const group = requests.get(dir);
230
+ if (group) group.push(request);
231
+ else requests.set(dir, [request]);
232
+ }
233
+
234
+ // Every directory that holds anything is a folder, whether or not it declared
235
+ // a `folder.bru` — a collection that never opened Bruno's folder settings
236
+ // still has folders on disk, and a reference that only showed the declared
237
+ // ones would drop most of it.
238
+ for (const dir of requests.keys()) ancestors(dir, folders);
239
+ for (const dir of Array.from(folders.keys())) ancestors(dir, folders);
240
+
241
+ return {
242
+ name: manifest.name ?? input.path,
243
+ version: manifest.version,
244
+ docs: textBlock(shared, 'docs'),
245
+ headers: entries(shared, 'headers').map(redactEntry),
246
+ auth: sharedAuth(shared),
247
+ folders: tree('', folders, requests),
248
+ requests: order(requests.get('') ?? []),
249
+ environments,
250
+ warnings
251
+ };
252
+ }
253
+
254
+ /**
255
+ * One `.bru` request file.
256
+ *
257
+ * Returns nothing rather than an empty request where the file names no method:
258
+ * a `.bru` with no method block is a Bruno artefact of some other kind, and a
259
+ * page for it would be a page about nothing.
260
+ */
261
+ export function parseBruFile(
262
+ file: string,
263
+ source: string,
264
+ warnings: string[]
265
+ ): DuxtBrunoRequest | undefined {
266
+ const blocks = blocksOf(source);
267
+ const meta = dictionary(blocks, 'meta');
268
+
269
+ const method = blocks.find((block) => METHODS.has(block.name));
270
+
271
+ if (!method) {
272
+ warnings.push(
273
+ `the request "${file}" names no HTTP method, so it was skipped.`
274
+ );
275
+ return undefined;
276
+ }
277
+
278
+ const call = toDictionary(method.lines);
279
+ const seq = Number(meta.seq);
280
+
281
+ const body = readBody(blocks, call.body);
282
+ const url = redactUrl(call.url ?? '');
283
+
284
+ return {
285
+ name: meta.name ?? fallbackName(file),
286
+ file,
287
+ ...(Number.isFinite(seq) ? { seq } : {}),
288
+ method: method.name.toUpperCase(),
289
+ url: url.url,
290
+ ...(url.redacted ? { urlRedacted: true } : {}),
291
+ kind: meta.type ?? 'http',
292
+ params: [
293
+ ...entries(blocks, 'params:query').map((entry): DuxtBrunoParam => ({
294
+ ...entry,
295
+ in: 'query'
296
+ })),
297
+ ...entries(blocks, 'params:path').map((entry): DuxtBrunoParam => ({
298
+ ...entry,
299
+ in: 'path'
300
+ }))
301
+ ].map(redactEntry),
302
+ headers: entries(blocks, 'headers').map(redactEntry),
303
+ ...(call.auth && call.auth !== 'none' ? { auth: { mode: call.auth } } : {}),
304
+ ...(body ? { body } : {}),
305
+ ...(textBlock(blocks, 'docs') ? { docs: textBlock(blocks, 'docs') } : {}),
306
+ // SAID, NEVER CARRIED. The code is not put on the model at all, so no
307
+ // component written later can render it and nothing can execute it.
308
+ ...(blocks.some((block) => block.name.startsWith('script:'))
309
+ ? { script: true }
310
+ : {}),
311
+ ...(blocks.some(
312
+ (block) => block.name === 'tests' || block.name === 'assert'
313
+ )
314
+ ? { tests: true }
315
+ : {})
316
+ };
317
+ }
318
+
319
+ /**
320
+ * The file's blocks, in order.
321
+ *
322
+ * A block opens on a line that is a name and a `{` and closes on the first
323
+ * later line that is a lone `}` in the FIRST COLUMN — the format's own rule.
324
+ * Counting braces instead would end a JSON body at its first nested object.
325
+ */
326
+ function blocksOf(source: string): Block[] {
327
+ const lines = source.split(/\r?\n/);
328
+ const blocks: Block[] = [];
329
+
330
+ for (let index = 0; index < lines.length; index++) {
331
+ const open = /^([\w:-]+)\s*\{\s*$/.exec(lines[index]!);
332
+ if (!open) continue;
333
+
334
+ const body: string[] = [];
335
+
336
+ while (++index < lines.length && !/^\}\s*$/.test(lines[index]!)) {
337
+ body.push(lines[index]!);
338
+ }
339
+
340
+ blocks.push({ name: open[1]!, lines: body });
341
+ }
342
+
343
+ return blocks;
344
+ }
345
+
346
+ /** A dictionary block's lines, as entries. */
347
+ function entries(blocks: Block[], name: string): DuxtBrunoEntry[] {
348
+ const block = blocks.find((candidate) => candidate.name === name);
349
+ if (!block) return [];
350
+
351
+ const out: DuxtBrunoEntry[] = [];
352
+
353
+ for (const line of block.lines) {
354
+ const trimmed = line.trim();
355
+ if (!trimmed) continue;
356
+
357
+ const colon = trimmed.indexOf(':');
358
+ if (colon < 1) continue;
359
+
360
+ const disabled = trimmed.startsWith('~');
361
+ const key = trimmed.slice(disabled ? 1 : 0, colon).trim();
362
+ const value = trimmed.slice(colon + 1).trim();
363
+
364
+ out.push({ name: key, value, ...(disabled ? { disabled: true } : {}) });
365
+ }
366
+
367
+ return out;
368
+ }
369
+
370
+ /** A dictionary block as a plain map, for the blocks that are one. */
371
+ function dictionary(blocks: Block[], name: string): Record<string, string> {
372
+ return Object.fromEntries(
373
+ entries(blocks, name).map((entry) => [entry.name, entry.value])
374
+ );
375
+ }
376
+
377
+ function toDictionary(lines: string[]): Record<string, string> {
378
+ return dictionary([{ name: 'x', lines }], 'x');
379
+ }
380
+
381
+ /** A text block's contents, dedented by the two spaces the format indents by. */
382
+ function textBlock(blocks: Block[], name: string): string | undefined {
383
+ const block = blocks.find(
384
+ (candidate) => candidate.name === name && TEXT_BLOCKS.has(candidate.name)
385
+ );
386
+
387
+ if (!block) return undefined;
388
+
389
+ // Dedented by the SHALLOWEST non-empty line rather than by a fixed two, so a
390
+ // body whose author indented it four keeps its own internal shape.
391
+ const indents = block.lines
392
+ .filter((line) => line.trim())
393
+ .map((line) => /^\s*/.exec(line)![0].length);
394
+
395
+ const strip = indents.length ? Math.min(...indents) : 0;
396
+
397
+ const text = block.lines
398
+ .map((line) => line.slice(strip))
399
+ .join('\n')
400
+ .replace(/^\n+|\s+$/g, '');
401
+
402
+ return text || undefined;
403
+ }
404
+
405
+ /** The body, in whichever spelling the file used. */
406
+ function readBody(
407
+ blocks: Block[],
408
+ declared?: string
409
+ ): DuxtBrunoBody | undefined {
410
+ const block = blocks.find(
411
+ (candidate) =>
412
+ candidate.name.startsWith('body') &&
413
+ candidate.name !== 'body:graphql:vars'
414
+ );
415
+
416
+ if (!block) return undefined;
417
+
418
+ const type =
419
+ block.name === 'body' ? (declared ?? 'text') : block.name.slice(5);
420
+
421
+ if (
422
+ type === 'form-urlencoded' ||
423
+ type === 'multipart-form' ||
424
+ type === 'file'
425
+ ) {
426
+ return { type, entries: entries(blocks, block.name) };
427
+ }
428
+
429
+ const text = textBlock(blocks, block.name);
430
+ const variables = textBlock(blocks, 'body:graphql:vars');
431
+
432
+ return {
433
+ type,
434
+ ...(LANGUAGES[type] ? { language: LANGUAGES[type] } : {}),
435
+ ...(text ? { text } : {}),
436
+ ...(variables ? { variables } : {})
437
+ };
438
+ }
439
+
440
+ /**
441
+ * A value withheld where the name says it carries a credential.
442
+ *
443
+ * The ENTRY SURVIVES and the value does not: an endpoint wanting an
444
+ * `Authorization` header is exactly what a reference has to say, and dropping
445
+ * the line would describe a request the collection does not make.
446
+ *
447
+ * A value made only of `{{placeholders}}` is kept, because it names a secret
448
+ * rather than carrying one — and hiding it would take away the one thing the
449
+ * reader needs, which is the name of the variable to set.
450
+ */
451
+ function redactEntry<T extends DuxtBrunoEntry>(entry: T): T {
452
+ if (!withheld(entry.name, entry.value)) return entry;
453
+
454
+ return { ...entry, value: '', redacted: true };
455
+ }
456
+
457
+ /** Whether this name, carrying this value, is a credential being published. */
458
+ function withheld(name: string, value: string): boolean {
459
+ if (!CREDENTIAL_NAMES.has(credentialName(name))) return false;
460
+
461
+ return Boolean(value) && !placeholderOnly(value);
462
+ }
463
+
464
+ /** A name reduced to the one spelling `CREDENTIAL_NAMES` holds. */
465
+ function credentialName(name: string): string {
466
+ return name.toLowerCase().replaceAll(/[^a-z0-9]/g, '');
467
+ }
468
+
469
+ /**
470
+ * Nothing but `{{placeholders}}`, punctuation and an auth scheme word.
471
+ *
472
+ * A function rather than the regex this replaced, which admitted a `[\s\w-]*`
473
+ * run around every placeholder — and `[\w-]` is precisely token shape, so
474
+ * `Bearer sk-live-abc123 {{sig}}` passed a test whose own name says it must
475
+ * not. What survives the placeholders is read word by word instead, and a word
476
+ * that is not a scheme is treated as the secret it may well be.
477
+ */
478
+ function placeholderOnly(value: string): boolean {
479
+ if (!PLACEHOLDER.test(value)) return false;
480
+
481
+ return value
482
+ .replaceAll(PLACEHOLDERS, ' ')
483
+ .split(/[^A-Za-z0-9_-]+/)
484
+ .every((word) => !word || AUTH_SCHEMES.has(word.toLowerCase()));
485
+ }
486
+
487
+ /**
488
+ * The URL as it may be published, and whether anything was taken out.
489
+ *
490
+ * THE URL IS THE OTHER DOOR, and it has to be shut on the same rule the tables
491
+ * are: Bruno mirrors a query parameter into the `url` line AND `params:query`,
492
+ * so a value withheld in the parameter table is written out again one line
493
+ * above it unless this runs. Userinfo — `https://svc:hunter2@host` — is worse
494
+ * still: it has no parameter counterpart at all, so the URL is the only place
495
+ * it is ever written and the only place it can be taken out.
496
+ *
497
+ * The NAME survives and the value does not, exactly as `redactEntry` does it: a
498
+ * reader has to know the endpoint wants an `api_key`, and must not be handed
499
+ * the collection author's.
500
+ */
501
+ export function redactUrl(url: string): { url: string; redacted: boolean } {
502
+ let redacted = false;
503
+
504
+ // Userinfo is whatever sits before an `@` in the AUTHORITY, so the match
505
+ // stops at the first `/`, `?` or `#` — an `@` in a path or a query value is
506
+ // an ordinary character and stays.
507
+ const stripped = url.replace(
508
+ /^((?:[A-Za-z][\w+.-]*:)?\/\/)[^/?#]*@/,
509
+ (_match, scheme: string) => {
510
+ redacted = true;
511
+ return scheme;
512
+ }
513
+ );
514
+
515
+ const mark = stripped.indexOf('?');
516
+ if (mark < 0) return { url: stripped, redacted };
517
+
518
+ const end = stripped.indexOf('#', mark);
519
+ const query = stripped.slice(mark + 1, end < 0 ? undefined : end);
520
+
521
+ const cleaned = query
522
+ .split('&')
523
+ .map((pair) => {
524
+ const equals = pair.indexOf('=');
525
+ if (equals < 1) return pair;
526
+
527
+ const name = pair.slice(0, equals);
528
+ if (!withheld(name, pair.slice(equals + 1))) return pair;
529
+
530
+ redacted = true;
531
+ return `${name}=`;
532
+ })
533
+ .join('&');
534
+
535
+ return {
536
+ url: `${stripped.slice(0, mark)}?${cleaned}${end < 0 ? '' : stripped.slice(end)}`,
537
+ redacted
538
+ };
539
+ }
540
+
541
+ /** `collection.bru`'s `auth { mode: … }`, which `auth: inherit` resolves to. */
542
+ function sharedAuth(blocks: Block[]): DuxtBrunoAuth | undefined {
543
+ const mode = dictionary(blocks, 'auth').mode;
544
+
545
+ return mode && mode !== 'none' ? { mode } : undefined;
546
+ }
547
+
548
+ /** What a `folder.bru` said about its directory. */
549
+ function folderMeta(source: string): {
550
+ name: string;
551
+ seq?: number;
552
+ docs?: string;
553
+ } {
554
+ const blocks = blocksOf(source);
555
+ const meta = dictionary(blocks, 'meta');
556
+ const seq = Number(meta.seq);
557
+
558
+ return {
559
+ name: meta.name ?? '',
560
+ ...(Number.isFinite(seq) ? { seq } : {}),
561
+ ...(textBlock(blocks, 'docs') ? { docs: textBlock(blocks, 'docs') } : {})
562
+ };
563
+ }
564
+
565
+ /** Every directory on the way to `dir`, so a folder's parents exist too. */
566
+ function ancestors(
567
+ dir: string,
568
+ folders: Map<string, { name: string; seq?: number; docs?: string }>
569
+ ): void {
570
+ if (!dir) return;
571
+
572
+ const parts = dir.split('/');
573
+
574
+ for (let depth = 1; depth <= parts.length; depth++) {
575
+ const path = parts.slice(0, depth).join('/');
576
+ if (!folders.has(path)) folders.set(path, { name: '' });
577
+ }
578
+ }
579
+
580
+ /** The folders directly under `parent`, recursively, in Bruno's own order. */
581
+ function tree(
582
+ parent: string,
583
+ folders: Map<string, { name: string; seq?: number; docs?: string }>,
584
+ requests: Map<string, DuxtBrunoRequest[]>
585
+ ): DuxtBrunoFolder[] {
586
+ const depth = parent ? parent.split('/').length : 0;
587
+
588
+ const children = [...folders.entries()].filter(
589
+ ([dir]) =>
590
+ (parent ? dir.startsWith(`${parent}/`) : dir) &&
591
+ dir.split('/').length === depth + 1
592
+ );
593
+
594
+ return sequenced(
595
+ children.map(([dir, meta]) => ({
596
+ name: meta.name || dir.slice(dir.lastIndexOf('/') + 1),
597
+ dir,
598
+ ...(meta.seq === undefined ? {} : { seq: meta.seq }),
599
+ ...(meta.docs ? { docs: meta.docs } : {}),
600
+ folders: tree(dir, folders, requests),
601
+ requests: order(requests.get(dir) ?? [])
602
+ }))
603
+ );
604
+ }
605
+
606
+ /** Requests in `seq` order, falling back to the name. */
607
+ function order(requests: DuxtBrunoRequest[]): DuxtBrunoRequest[] {
608
+ return sequenced(requests);
609
+ }
610
+
611
+ /**
612
+ * Bruno's own ordering: `seq` first, then the name.
613
+ *
614
+ * A folder that never had its order set has no `seq` at all, and sorting those
615
+ * by name is what keeps two builds of the same collection identical — the
616
+ * listing they came from is sorted, but a missing `seq` would otherwise leave
617
+ * them in whatever order the walk produced.
618
+ */
619
+ function sequenced<T extends { name: string; seq?: number }>(items: T[]): T[] {
620
+ return [...items].sort((left, right) => {
621
+ const a = left.seq ?? Number.MAX_SAFE_INTEGER;
622
+ const b = right.seq ?? Number.MAX_SAFE_INTEGER;
623
+
624
+ return a === b ? left.name.localeCompare(right.name) : a - b;
625
+ });
626
+ }
627
+
628
+ /** `bruno.json`, as far as this layer reads it. */
629
+ function readManifest(
630
+ source: string,
631
+ warnings: string[]
632
+ ): { name?: string; version?: string } {
633
+ try {
634
+ const json: unknown = JSON.parse(source);
635
+
636
+ if (!json || typeof json !== 'object') return {};
637
+
638
+ const record = json as Record<string, unknown>;
639
+
640
+ return {
641
+ ...(typeof record.name === 'string' ? { name: record.name } : {}),
642
+ ...(typeof record.version === 'string' ? { version: record.version } : {})
643
+ };
644
+ } catch (error) {
645
+ warnings.push(`bruno.json could not be read — ${(error as Error).message}`);
646
+ return {};
647
+ }
648
+ }
649
+
650
+ /** A request's name, where `meta` gave it none: the file, unprefixed. */
651
+ function fallbackName(file: string): string {
652
+ return file
653
+ .slice(file.lastIndexOf('/') + 1)
654
+ .replace(/\.bru$/, '')
655
+ .replace(/^\d+[.\-_]/, '');
656
+ }