@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.
- package/README.md +24 -9
- package/app/app.vue +17 -11
- package/app/assets/css/duxt.css +47 -6
- package/app/components/DuxtAnnouncements.vue +117 -0
- package/app/components/DuxtBrunoEntries.vue +62 -0
- package/app/components/DuxtCodeBlock.vue +18 -34
- package/app/components/DuxtCodeToolbar.vue +14 -0
- package/app/components/DuxtCopyButton.vue +33 -0
- package/app/components/DuxtCopyPage.vue +32 -30
- package/app/components/DuxtFooter.vue +39 -8
- package/app/components/DuxtHeader.vue +107 -48
- package/app/components/DuxtLandingProse.vue +1 -1
- package/app/components/DuxtLinkLabel.vue +36 -0
- package/app/components/DuxtLiveWindow.vue +8 -8
- package/app/components/DuxtLocale.vue +10 -1
- package/app/components/DuxtNavigation.vue +2 -2
- package/app/components/DuxtOpenApiClient.vue +285 -90
- package/app/components/DuxtOpenApiMedia.vue +30 -18
- package/app/components/DuxtOpenApiSchema.vue +10 -7
- package/app/components/DuxtPageFeedback.vue +7 -0
- package/app/components/DuxtPageInfo.vue +15 -12
- package/app/components/DuxtPageNav.vue +41 -27
- package/app/components/DuxtPreviewSkeleton.vue +4 -4
- package/app/components/DuxtProgress.vue +2 -14
- package/app/components/DuxtSearch.vue +230 -90
- package/app/components/DuxtSearchInput.vue +70 -0
- package/app/components/DuxtSections.vue +11 -1
- package/app/components/DuxtSegmented.vue +64 -0
- package/app/components/DuxtShortcuts.vue +16 -11
- package/app/components/DuxtShortcutsTrigger.vue +49 -0
- package/app/components/DuxtSkipLink.vue +1 -1
- package/app/components/DuxtToc.vue +16 -10
- package/app/components/DuxtTranslationBanner.vue +1 -1
- package/app/components/DuxtVersion.vue +53 -12
- package/app/components/DuxtVersionBanner.vue +5 -5
- package/app/components/content/AccordionItem.vue +2 -2
- package/app/components/content/BrunoOverview.vue +260 -0
- package/app/components/content/BrunoRequest.vue +141 -0
- package/app/components/content/BrunoRequests.vue +104 -0
- package/app/components/content/Callout.vue +5 -5
- package/app/components/content/ChangelogReleases.vue +161 -40
- package/app/components/content/CodeGroup.vue +41 -37
- package/app/components/content/Field.vue +1 -1
- package/app/components/content/FileTree.vue +1 -1
- package/app/components/content/OpenApiOperation.vue +4 -4
- package/app/components/content/OpenApiOperations.vue +1 -1
- package/app/components/content/OpenApiOverview.vue +1 -1
- package/app/components/content/PackageManagers.vue +42 -58
- package/app/components/content/PageCards.vue +7 -4
- package/app/components/content/Preview.vue +15 -39
- package/app/components/content/ProseImg.vue +596 -49
- package/app/components/content/Since.vue +1 -1
- package/app/components/content/Steps.vue +6 -4
- package/app/components/content/TabGroup.vue +13 -12
- package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
- package/app/components/ui/command/CommandShortcut.vue +1 -1
- package/app/components/ui/dialog/DialogContent.vue +2 -2
- package/app/components/ui/dialog/DialogHeader.vue +1 -1
- package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
- package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
- package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
- package/app/components/ui/select/SelectItem.vue +2 -2
- package/app/components/ui/sheet/SheetContent.vue +29 -3
- package/app/components/ui/sidebar/Sidebar.vue +28 -4
- package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarInset.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
- package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
- package/app/components/ui/sidebar/index.ts +10 -2
- package/app/components/ui/sidebar/utils.ts +0 -1
- package/app/components/ui/sonner/Sonner.vue +18 -0
- package/app/components/ui/tabs/TabsList.vue +13 -3
- package/app/components/ui/tabs/TabsTrigger.vue +24 -3
- package/app/components/ui/tooltip/TooltipContent.vue +2 -2
- package/app/composables/useActiveHeading.ts +3 -25
- package/app/composables/useDuxtAnalytics.ts +50 -0
- package/app/composables/useDuxtAnimatedHeight.ts +8 -38
- package/app/composables/useDuxtAnnouncements.ts +153 -0
- package/app/composables/useDuxtCopy.ts +51 -0
- package/app/composables/useDuxtDirection.ts +23 -0
- package/app/composables/useDuxtNavigation.ts +28 -8
- package/app/composables/useDuxtPath.ts +3 -5
- package/app/composables/useDuxtSearch.ts +87 -35
- package/app/composables/useDuxtSection.ts +15 -1
- package/app/composables/useDuxtSectionRow.ts +6 -5
- package/app/composables/useDuxtShortcuts.ts +319 -20
- package/app/composables/useDuxtVersion.ts +57 -6
- package/app/composables/useDuxtViewportMeasure.ts +37 -0
- package/app/composables/useFuzzySearch.ts +9 -4
- package/app/composables/useRecentPages.ts +14 -17
- package/app/error.vue +112 -74
- package/app/layouts/changelog.vue +9 -1
- package/app/layouts/default.vue +9 -0
- package/app/layouts/docs.vue +9 -1
- package/app/layouts/reference.vue +9 -1
- package/app/middleware/duxt-section-layout.global.ts +1 -3
- package/app/pages/[...slug].vue +146 -35
- package/app/pages/index.vue +15 -23
- package/app/types/duxt.d.ts +312 -4
- package/app/utils/analytics.ts +91 -0
- package/app/utils/announcements.ts +205 -0
- package/app/utils/changelog.ts +0 -28
- package/app/utils/code-highlight.ts +3 -1
- package/app/utils/contributor-avatar.ts +27 -0
- package/app/utils/direction.ts +43 -0
- package/app/utils/duxt-config.ts +43 -0
- package/app/utils/duxt-text.ts +10 -0
- package/app/utils/format-date.ts +28 -0
- package/app/utils/generated-sections.ts +68 -12
- package/app/utils/generated-toc.ts +14 -7
- package/app/utils/locale-path.ts +43 -0
- package/app/utils/navigation-tree.ts +51 -1
- package/app/utils/nearest-page.ts +8 -0
- package/app/utils/openapi.ts +16 -11
- package/app/utils/page-controls.ts +215 -0
- package/app/utils/pill.ts +61 -0
- package/app/utils/prose-image.ts +128 -0
- package/app/utils/search-context.ts +110 -0
- package/app/utils/search-display.ts +248 -0
- package/app/utils/section-scope.ts +65 -4
- package/app/utils/stored-json.ts +31 -0
- package/app/utils/version-choices.ts +6 -13
- package/app/utils/version-paths.ts +21 -0
- package/bin/duxt-cache-key.mjs +61 -0
- package/bin/duxt-og-cache.mjs +58 -0
- package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
- package/bruno-model.ts +169 -0
- package/bruno-parse.ts +656 -0
- package/bruno-zip.ts +195 -0
- package/cli.ts +240 -0
- package/duxt-app-config.ts +18 -1
- package/frontmatter.ts +49 -0
- package/git-contributors.ts +322 -0
- package/i18n/locales/de/duxt/announcement.json +7 -0
- package/i18n/locales/de/duxt/bruno.json +25 -0
- package/i18n/locales/de/duxt/changelog.json +1 -1
- package/i18n/locales/de/duxt/code.json +2 -1
- package/i18n/locales/de/duxt/page.json +9 -0
- package/i18n/locales/de/duxt/shortcuts.json +1 -0
- package/i18n/locales/de/duxt/theme.json +2 -1
- package/i18n/locales/en/duxt/announcement.json +7 -0
- package/i18n/locales/en/duxt/bruno.json +25 -0
- package/i18n/locales/en/duxt/changelog.json +1 -1
- package/i18n/locales/en/duxt/code.json +2 -1
- package/i18n/locales/en/duxt/page.json +9 -0
- package/i18n/locales/en/duxt/shortcuts.json +1 -0
- package/i18n/locales/en/duxt/theme.json +2 -1
- package/i18n/locales/es/duxt/announcement.json +7 -0
- package/i18n/locales/es/duxt/bruno.json +25 -0
- package/i18n/locales/es/duxt/changelog.json +1 -1
- package/i18n/locales/es/duxt/code.json +2 -1
- package/i18n/locales/es/duxt/page.json +9 -0
- package/i18n/locales/es/duxt/shortcuts.json +1 -0
- package/i18n/locales/es/duxt/theme.json +2 -1
- package/i18n/locales/fr/duxt/announcement.json +7 -0
- package/i18n/locales/fr/duxt/bruno.json +25 -0
- package/i18n/locales/fr/duxt/changelog.json +1 -1
- package/i18n/locales/fr/duxt/code.json +2 -1
- package/i18n/locales/fr/duxt/page.json +9 -0
- package/i18n/locales/fr/duxt/shortcuts.json +1 -0
- package/i18n/locales/fr/duxt/theme.json +2 -1
- package/i18n/locales/pt/duxt/announcement.json +7 -0
- package/i18n/locales/pt/duxt/bruno.json +25 -0
- package/i18n/locales/pt/duxt/changelog.json +1 -1
- package/i18n/locales/pt/duxt/code.json +2 -1
- package/i18n/locales/pt/duxt/page.json +9 -0
- package/i18n/locales/pt/duxt/shortcuts.json +1 -0
- package/i18n/locales/pt/duxt/theme.json +2 -1
- package/modules/bruno.ts +118 -0
- package/modules/config.ts +26 -0
- package/modules/devtools.ts +2 -3
- package/modules/git-meta.ts +16 -47
- package/modules/search-records.ts +132 -0
- package/nuxt.config.ts +81 -7
- package/og-image-cache.ts +742 -0
- package/package.json +41 -9
- package/public/devtools/checks.html +2 -2
- package/public/devtools/config.html +59 -4
- package/public/devtools/pages.html +8 -1
- package/public/devtools/paths.html +1 -1
- package/public/devtools/sources.html +2 -2
- package/public/devtools/versions.html +3 -0
- package/report.ts +28 -19
- package/search-records.ts +380 -0
- package/section-input.ts +142 -0
- package/section-markdown.ts +67 -0
- package/section-reports.ts +10 -4
- package/sections-bruno.ts +571 -0
- package/sections-changelog.ts +76 -43
- package/sections-openapi.ts +12 -83
- package/sections-resolve.ts +174 -8
- package/sections.ts +33 -10
- package/server/devtools/shell.ts +1 -1
- package/server/mcp/tools/list-pages.ts +77 -36
- package/server/mcp/tools/list-versions.ts +84 -0
- package/server/mcp/tools/read-page.ts +80 -26
- package/server/mcp/tools/search-docs.ts +87 -53
- package/server/middleware/raw-markdown.ts +1 -1
- package/server/routes/llms-full.txt.get.ts +6 -8
- package/server/routes/llms.txt.get.ts +1 -1
- package/server/utils/duxt-server-text.ts +0 -13
- package/server/utils/llms-pages.ts +24 -9
- package/server/utils/mcp-docs.ts +577 -0
- package/sources-cache.ts +376 -0
- package/sources-git.ts +186 -31
- package/sources-resolve.ts +59 -1
- package/sources.ts +122 -13
- 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
|
+
}
|