@kingsleyweb/bun-ui 0.0.0-stage → 3.0.0-rc.2

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 (240) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/app/docs/App.tsx +72 -0
  4. package/app/docs/api/client.ts +303 -0
  5. package/app/docs/api/contract.ts +24 -0
  6. package/app/docs/api/docs.ts +38 -0
  7. package/app/docs/api/errors.ts +144 -0
  8. package/app/docs/boot.tsx +58 -0
  9. package/app/docs/components/Badge.tsx +65 -0
  10. package/app/docs/components/Button.tsx +37 -0
  11. package/app/docs/components/Card.tsx +41 -0
  12. package/app/docs/components/ConfirmDialog.tsx +173 -0
  13. package/app/docs/components/CopyButton.tsx +68 -0
  14. package/app/docs/components/Dialog.tsx +230 -0
  15. package/app/docs/components/EmptyState.tsx +34 -0
  16. package/app/docs/components/ErrorView.tsx +70 -0
  17. package/app/docs/components/Field.tsx +93 -0
  18. package/app/docs/components/JsonEditor.tsx +151 -0
  19. package/app/docs/components/JsonView.tsx +338 -0
  20. package/app/docs/components/KeyValue.tsx +63 -0
  21. package/app/docs/components/ProblemBanner.tsx +116 -0
  22. package/app/docs/components/ScreenErrorBoundary.tsx +141 -0
  23. package/app/docs/components/Spinner.tsx +33 -0
  24. package/app/docs/components/Table.tsx +33 -0
  25. package/app/docs/components/Tabs.tsx +226 -0
  26. package/app/docs/components/classNames.ts +4 -0
  27. package/app/docs/components/components.css +691 -0
  28. package/app/docs/components/copy.ts +89 -0
  29. package/app/docs/components/fieldContext.ts +29 -0
  30. package/app/docs/components/inputValues.ts +142 -0
  31. package/app/docs/components/inputs.tsx +387 -0
  32. package/app/docs/components/jsonParse.ts +338 -0
  33. package/app/docs/components/listKeys.ts +30 -0
  34. package/app/docs/config.ts +86 -0
  35. package/app/docs/context.ts +33 -0
  36. package/app/docs/embed.ts +74 -0
  37. package/app/docs/env.d.ts +2 -0
  38. package/app/docs/format.ts +9 -0
  39. package/app/docs/hooks/useUrlTab.ts +16 -0
  40. package/app/docs/main.tsx +11 -0
  41. package/app/docs/queryClient.ts +54 -0
  42. package/app/docs/router.tsx +194 -0
  43. package/app/docs/routing.ts +194 -0
  44. package/app/docs/screens/docs/document.ts +27 -0
  45. package/app/docs/screens/docs/http/OperationView.tsx +407 -0
  46. package/app/docs/screens/docs/http/Sidebar.tsx +190 -0
  47. package/app/docs/screens/docs/http/TryItPanel.tsx +603 -0
  48. package/app/docs/screens/docs/http/http.css +405 -0
  49. package/app/docs/screens/docs/http/index.tsx +395 -0
  50. package/app/docs/screens/docs/http/model.ts +604 -0
  51. package/app/docs/screens/docs/http/tryIt.ts +455 -0
  52. package/app/docs/screens/docs/permissions.ts +6 -0
  53. package/app/docs/screens/docs/schema/Prose.tsx +43 -0
  54. package/app/docs/screens/docs/schema/SchemaTree.tsx +319 -0
  55. package/app/docs/screens/docs/schema/index.ts +30 -0
  56. package/app/docs/screens/docs/schema/resolve.ts +466 -0
  57. package/app/docs/screens/docs/schema/schema.css +137 -0
  58. package/app/docs/screens/docs/schema/skeleton.ts +92 -0
  59. package/app/docs/screens/docs/viewer.tsx +154 -0
  60. package/app/docs/screens/docs/ws/InlineText.tsx +25 -0
  61. package/app/docs/screens/docs/ws/PayloadSchema.tsx +39 -0
  62. package/app/docs/screens/docs/ws/format.ts +82 -0
  63. package/app/docs/screens/docs/ws/index.tsx +286 -0
  64. package/app/docs/screens/docs/ws/items.tsx +786 -0
  65. package/app/docs/screens/docs/ws/model.ts +1178 -0
  66. package/app/docs/screens/docs/ws/panels.tsx +440 -0
  67. package/app/docs/screens/docs/ws/ws.css +231 -0
  68. package/app/docs/shortcuts.ts +94 -0
  69. package/app/docs/styles/base.css +847 -0
  70. package/app/docs/styles/tokens.css +143 -0
  71. package/app/docs/theme.ts +56 -0
  72. package/app/kit/unauthorized.ts +222 -0
  73. package/dist/docs/assets/main-0rchxtv5.js +18 -0
  74. package/dist/docs/assets/main-0rchxtv5.js.map +99 -0
  75. package/dist/docs/assets/main-cqgyvs86.css +1 -0
  76. package/dist/docs/manifest.json +26 -0
  77. package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js +6 -0
  78. package/dist/jobs/assets/AddFlowDialog-h6xkgx9b.js.map +11 -0
  79. package/dist/jobs/assets/AddFlowDialog-n7gtc84v.css +1 -0
  80. package/dist/jobs/assets/added-cbwsxv3e.js +4 -0
  81. package/dist/jobs/assets/added-cbwsxv3e.js.map +11 -0
  82. package/dist/jobs/assets/bodies-yyw5hmrp.js +6 -0
  83. package/dist/jobs/assets/bodies-yyw5hmrp.js.map +10 -0
  84. package/dist/jobs/assets/channel-tgkvqm8y.js +4 -0
  85. package/dist/jobs/assets/channel-tgkvqm8y.js.map +10 -0
  86. package/dist/jobs/assets/channelChecks-s4yz4yhw.js +4 -0
  87. package/dist/jobs/assets/channelChecks-s4yz4yhw.js.map +10 -0
  88. package/dist/jobs/assets/index-02427smp.css +1 -0
  89. package/dist/jobs/assets/index-09z2cjft.js +4 -0
  90. package/dist/jobs/assets/index-09z2cjft.js.map +16 -0
  91. package/dist/jobs/assets/index-18bn0gvy.css +1 -0
  92. package/dist/jobs/assets/index-47pxmyxn.js +4 -0
  93. package/dist/jobs/assets/index-47pxmyxn.js.map +10 -0
  94. package/dist/jobs/assets/index-4e55z986.js +4 -0
  95. package/dist/jobs/assets/index-4e55z986.js.map +11 -0
  96. package/dist/jobs/assets/index-5c933424.js +4 -0
  97. package/dist/jobs/assets/index-5c933424.js.map +18 -0
  98. package/dist/jobs/assets/index-5kbyqspm.css +1 -0
  99. package/dist/jobs/assets/index-5ng37hk6.css +1 -0
  100. package/dist/jobs/assets/index-5w8w7fvc.css +1 -0
  101. package/dist/jobs/assets/index-71qs5qyx.css +1 -0
  102. package/dist/jobs/assets/index-btpg4r8b.css +1 -0
  103. package/dist/jobs/assets/index-d5r5n0mm.js +4 -0
  104. package/dist/jobs/assets/index-d5r5n0mm.js.map +23 -0
  105. package/dist/jobs/assets/index-e0yy2skq.css +1 -0
  106. package/dist/jobs/assets/index-hs60b25v.js +10 -0
  107. package/dist/jobs/assets/index-hs60b25v.js.map +62 -0
  108. package/dist/jobs/assets/index-k2smrr70.js +4 -0
  109. package/dist/jobs/assets/index-k2smrr70.js.map +9 -0
  110. package/dist/jobs/assets/index-q24jw3w9.js +4 -0
  111. package/dist/jobs/assets/index-q24jw3w9.js.map +32 -0
  112. package/dist/jobs/assets/index-qfb80xen.css +1 -0
  113. package/dist/jobs/assets/index-rtynx6wj.js +4 -0
  114. package/dist/jobs/assets/index-rtynx6wj.js.map +10 -0
  115. package/dist/jobs/assets/live-6k7s6358.js +4 -0
  116. package/dist/jobs/assets/live-6k7s6358.js.map +10 -0
  117. package/dist/jobs/assets/main-0jmeh6mq.js +6 -0
  118. package/dist/jobs/assets/main-0jmeh6mq.js.map +12 -0
  119. package/dist/jobs/assets/main-3yj6bv90.js +5 -0
  120. package/dist/jobs/assets/main-3yj6bv90.js.map +10 -0
  121. package/dist/jobs/assets/main-4g69fn3x.js +5 -0
  122. package/dist/jobs/assets/main-4g69fn3x.js.map +10 -0
  123. package/dist/jobs/assets/main-4gte3eqq.js +5 -0
  124. package/dist/jobs/assets/main-4gte3eqq.js.map +10 -0
  125. package/dist/jobs/assets/main-4j1fcevh.js +5 -0
  126. package/dist/jobs/assets/main-4j1fcevh.js.map +10 -0
  127. package/dist/jobs/assets/main-54qwkz28.css +1 -0
  128. package/dist/jobs/assets/main-5grjvsy7.js +5 -0
  129. package/dist/jobs/assets/main-5grjvsy7.js.map +11 -0
  130. package/dist/jobs/assets/main-5zbx60gq.js +5 -0
  131. package/dist/jobs/assets/main-5zbx60gq.js.map +11 -0
  132. package/dist/jobs/assets/main-9prgjhg4.js +5 -0
  133. package/dist/jobs/assets/main-9prgjhg4.js.map +10 -0
  134. package/dist/jobs/assets/main-a250ffk7.js +17 -0
  135. package/dist/jobs/assets/main-a250ffk7.js.map +115 -0
  136. package/dist/jobs/assets/main-d6ge7w08.js +5 -0
  137. package/dist/jobs/assets/main-d6ge7w08.js.map +10 -0
  138. package/dist/jobs/assets/main-gngzht43.js +5 -0
  139. package/dist/jobs/assets/main-gngzht43.js.map +10 -0
  140. package/dist/jobs/assets/main-hg0hrg8j.js +5 -0
  141. package/dist/jobs/assets/main-hg0hrg8j.js.map +10 -0
  142. package/dist/jobs/assets/main-hm1ng3yg.js +5 -0
  143. package/dist/jobs/assets/main-hm1ng3yg.js.map +12 -0
  144. package/dist/jobs/assets/main-jkvd92z9.js +5 -0
  145. package/dist/jobs/assets/main-jkvd92z9.js.map +10 -0
  146. package/dist/jobs/assets/main-jv5xqd13.js +5 -0
  147. package/dist/jobs/assets/main-jv5xqd13.js.map +21 -0
  148. package/dist/jobs/assets/main-jxre7w93.js +5 -0
  149. package/dist/jobs/assets/main-jxre7w93.js.map +23 -0
  150. package/dist/jobs/assets/main-kwmqkjfx.js +5 -0
  151. package/dist/jobs/assets/main-kwmqkjfx.js.map +11 -0
  152. package/dist/jobs/assets/main-n9cf6bxm.js +5 -0
  153. package/dist/jobs/assets/main-n9cf6bxm.js.map +11 -0
  154. package/dist/jobs/assets/main-pegp0ygb.js +5 -0
  155. package/dist/jobs/assets/main-pegp0ygb.js.map +10 -0
  156. package/dist/jobs/assets/main-qr573853.js +5 -0
  157. package/dist/jobs/assets/main-qr573853.js.map +12 -0
  158. package/dist/jobs/assets/main-rq97rdr4.js +4 -0
  159. package/dist/jobs/assets/main-rq97rdr4.js.map +22 -0
  160. package/dist/jobs/assets/main-szjdmydc.js +5 -0
  161. package/dist/jobs/assets/main-szjdmydc.js.map +13 -0
  162. package/dist/jobs/assets/main-tnbhrm9n.js +7 -0
  163. package/dist/jobs/assets/main-tnbhrm9n.js.map +11 -0
  164. package/dist/jobs/assets/main-ts19j4n0.js +5 -0
  165. package/dist/jobs/assets/main-ts19j4n0.js.map +25 -0
  166. package/dist/jobs/assets/main-w93x1dzs.js +5 -0
  167. package/dist/jobs/assets/main-w93x1dzs.js.map +10 -0
  168. package/dist/jobs/assets/sections-0ksdxs8m.css +1 -0
  169. package/dist/jobs/assets/sections-0y7w4fb4.js +4 -0
  170. package/dist/jobs/assets/sections-0y7w4fb4.js.map +10 -0
  171. package/dist/jobs/assets/sse-mn0t0fh7.js +5 -0
  172. package/dist/jobs/assets/sse-mn0t0fh7.js.map +11 -0
  173. package/dist/jobs/assets/sseChecks-t7tcm7ra.js +4 -0
  174. package/dist/jobs/assets/sseChecks-t7tcm7ra.js.map +10 -0
  175. package/dist/jobs/assets/validation-ysjfy072.js +4 -0
  176. package/dist/jobs/assets/validation-ysjfy072.js.map +9 -0
  177. package/dist/jobs/manifest.json +511 -0
  178. package/dts/docs/assets.d.ts +144 -0
  179. package/dts/docs/assets.d.ts.map +1 -0
  180. package/dts/docs/browser/config.d.ts +61 -0
  181. package/dts/docs/browser/config.d.ts.map +1 -0
  182. package/dts/docs/browser/index.d.ts +12 -0
  183. package/dts/docs/browser/index.d.ts.map +1 -0
  184. package/dts/docs/docsUi.d.ts +42 -0
  185. package/dts/docs/docsUi.d.ts.map +1 -0
  186. package/dts/docs/hosted.d.ts +22 -0
  187. package/dts/docs/hosted.d.ts.map +1 -0
  188. package/dts/docs/index.d.ts +18 -0
  189. package/dts/docs/index.d.ts.map +1 -0
  190. package/dts/docs/shell.d.ts +57 -0
  191. package/dts/docs/shell.d.ts.map +1 -0
  192. package/dts/docs/types.d.ts +137 -0
  193. package/dts/docs/types.d.ts.map +1 -0
  194. package/dts/index.d.ts +21 -0
  195. package/dts/index.d.ts.map +1 -0
  196. package/dts/jobs/assets.d.ts +157 -0
  197. package/dts/jobs/assets.d.ts.map +1 -0
  198. package/dts/jobs/browser/config.d.ts +67 -0
  199. package/dts/jobs/browser/config.d.ts.map +1 -0
  200. package/dts/jobs/browser/index.d.ts +10 -0
  201. package/dts/jobs/browser/index.d.ts.map +1 -0
  202. package/dts/jobs/browser/unauthorized.d.ts +9 -0
  203. package/dts/jobs/browser/unauthorized.d.ts.map +1 -0
  204. package/dts/jobs/index.d.ts +24 -0
  205. package/dts/jobs/index.d.ts.map +1 -0
  206. package/dts/jobs/jobsUi.d.ts +63 -0
  207. package/dts/jobs/jobsUi.d.ts.map +1 -0
  208. package/dts/jobs/shell.d.ts +77 -0
  209. package/dts/jobs/shell.d.ts.map +1 -0
  210. package/dts/jobs/types.d.ts +146 -0
  211. package/dts/jobs/types.d.ts.map +1 -0
  212. package/dts/kit/browser/shared.d.ts +10 -0
  213. package/dts/kit/browser/shared.d.ts.map +1 -0
  214. package/dts/kit/browser/unauthorized.d.ts +74 -0
  215. package/dts/kit/browser/unauthorized.d.ts.map +1 -0
  216. package/dts/kit/unauthorized.d.ts +47 -0
  217. package/dts/kit/unauthorized.d.ts.map +1 -0
  218. package/guides/docs.md +170 -0
  219. package/guides/jobs.md +1120 -0
  220. package/lib/docs/assets.ts +427 -0
  221. package/lib/docs/browser/config.ts +66 -0
  222. package/lib/docs/browser/index.ts +24 -0
  223. package/lib/docs/docsUi.ts +623 -0
  224. package/lib/docs/hosted.ts +31 -0
  225. package/lib/docs/index.ts +37 -0
  226. package/lib/docs/shell.ts +156 -0
  227. package/lib/docs/types.ts +149 -0
  228. package/lib/index.ts +27 -0
  229. package/lib/jobs/assets.ts +448 -0
  230. package/lib/jobs/browser/config.ts +65 -0
  231. package/lib/jobs/browser/index.ts +9 -0
  232. package/lib/jobs/browser/unauthorized.ts +11 -0
  233. package/lib/jobs/index.ts +41 -0
  234. package/lib/jobs/jobsUi.ts +709 -0
  235. package/lib/jobs/shell.ts +205 -0
  236. package/lib/jobs/types.ts +162 -0
  237. package/lib/kit/browser/shared.ts +11 -0
  238. package/lib/kit/browser/unauthorized.ts +114 -0
  239. package/lib/kit/unauthorized.ts +222 -0
  240. package/package.json +198 -4
@@ -0,0 +1,604 @@
1
+ import type { SpecDocument } from "../../../api/docs";
2
+ import { DEFAULT_EXTENSION_PREFIXES } from "../../../../../lib/docs/browser/config.ts";
3
+ import { deref, isSchemaObject, refOf, resolveRef } from "../schema/resolve";
4
+
5
+ /**
6
+ * The parts of an OpenAPI 3.1 document the HTTP reference renders, read out
7
+ * of the JSON into plain records. Everything is defensive: the document is
8
+ * data from the API, and a field of the wrong shape is skipped rather than
9
+ * trusted.
10
+ *
11
+ * The extensions are vendor-neutral: each is named on its node as
12
+ * `<prefix>-<name>` (`x-bun-jobs-action`, `x-bun-common-csrf`, …) and read
13
+ * from the first prefix of {@link HttpModelOptions.extensions} that carries
14
+ * it; one under any other prefix is ignored.
15
+ */
16
+
17
+ /** Options of {@link listOperations}. */
18
+ export interface HttpModelOptions {
19
+ /**
20
+ * The extension prefixes, first hit winning. Defaults to
21
+ * `["x-bun-common", "x-bun-jobs"]`.
22
+ */
23
+ extensions?: readonly string[];
24
+ }
25
+
26
+ /** Reads the `<prefix>-<name>` extension of a node from the first prefix carrying it. */
27
+ function extensionOf(
28
+ node: Record<string, unknown>,
29
+ name: string,
30
+ prefixes: readonly string[],
31
+ ): unknown {
32
+ for (const prefix of prefixes) {
33
+ const key = `${prefix}-${name}`;
34
+ if (Object.hasOwn(node, key)) {
35
+ return node[key];
36
+ }
37
+ }
38
+ return undefined;
39
+ }
40
+
41
+ /** Path item keys that are operations, in the order OpenAPI lists them. */
42
+ const OPERATION_METHODS = [
43
+ "get",
44
+ "put",
45
+ "post",
46
+ "delete",
47
+ "options",
48
+ "head",
49
+ "patch",
50
+ "trace",
51
+ ] as const;
52
+
53
+ /** Where a parameter goes. */
54
+ export type ParameterLocation = "path" | "query" | "header" | "cookie";
55
+
56
+ /** One parameter of an operation. */
57
+ export interface DocParameter {
58
+ /** Its name. */
59
+ name: string;
60
+ /** Where it goes. */
61
+ in: ParameterLocation;
62
+ /** Whether it must be sent (path parameters always are). */
63
+ required: boolean;
64
+ /** What it means. */
65
+ description?: string;
66
+ /** Its schema (possibly a `$ref`). */
67
+ schema: unknown;
68
+ /** OpenAPI `style` (`form` for the API's array query parameters). */
69
+ style?: string;
70
+ /** OpenAPI `explode`: `true` sends an array as repeated keys. */
71
+ explode?: boolean;
72
+ /** Whether it is deprecated. */
73
+ deprecated: boolean;
74
+ }
75
+
76
+ /** One media type of a body. */
77
+ export interface DocMediaType {
78
+ /** E.g. `application/json`. */
79
+ contentType: string;
80
+ /** Its schema, when it declares one. */
81
+ schema: unknown;
82
+ }
83
+
84
+ /** An operation's request body. */
85
+ export interface DocRequestBody {
86
+ /** Whether a body must be sent. */
87
+ required: boolean;
88
+ /** A note, e.g. that `Content-Type: application/json` is required even when bodiless. */
89
+ description?: string;
90
+ /** The media types accepted. */
91
+ content: DocMediaType[];
92
+ }
93
+
94
+ /** One documented response. */
95
+ export interface DocResponse {
96
+ /** `"200"`, `"404"`, `"default"`, `"4XX"`. */
97
+ status: string;
98
+ /** What it means (for a problem: the titles of its codes). */
99
+ description: string;
100
+ /** Its bodies. */
101
+ content: DocMediaType[];
102
+ /** `<prefix>-codes`: the problem codes answered with this status. */
103
+ codes: string[];
104
+ /** The `$ref` it came from (`#/components/responses/Problem`), if any. */
105
+ ref?: string;
106
+ }
107
+
108
+ /** `<prefix>-csrf`: what a mutation must carry. */
109
+ export interface DocCsrf {
110
+ /** The header every mutation must carry (any non-empty value), or `null` when none is required. */
111
+ header: string | null;
112
+ /** Whether `Content-Type: application/json` is required, even without a body. */
113
+ requireJson: boolean;
114
+ }
115
+
116
+ /** One operation, flattened. */
117
+ export interface DocOperation {
118
+ /** `operationId` (or `METHOD path` when the document gives none). */
119
+ id: string;
120
+ /** Upper-case method. */
121
+ method: string;
122
+ /** The path template, relative to the server (`/queues/{queue}`). */
123
+ path: string;
124
+ /** One line. */
125
+ summary?: string;
126
+ /** More. */
127
+ description?: string;
128
+ /** Its tags. */
129
+ tags: string[];
130
+ /** Whether it is deprecated. */
131
+ deprecated: boolean;
132
+ /** Path-item and operation parameters, operation ones winning. */
133
+ parameters: DocParameter[];
134
+ /** The body it takes. */
135
+ requestBody?: DocRequestBody;
136
+ /** Its responses, in document order. */
137
+ responses: DocResponse[];
138
+ /** `<prefix>-action`: the permission the operation needs. */
139
+ action?: string;
140
+ /** `<prefix>-mutation`: whether it changes state (CSRF checks, refused when read-only). */
141
+ mutation: boolean;
142
+ /** `<prefix>-requires`: the driver methods it needs. */
143
+ requires: string[];
144
+ /** `<prefix>-csrf`, on mutations when CSRF checks are on. */
145
+ csrf?: DocCsrf;
146
+ }
147
+
148
+ /** One tag, with the operations under it. */
149
+ export interface DocTagGroup {
150
+ /** The tag's name. */
151
+ name: string;
152
+ /** What it covers. */
153
+ description?: string;
154
+ /** Its operations, in document order. */
155
+ operations: DocOperation[];
156
+ }
157
+
158
+ /** One server entry. */
159
+ export interface DocServer {
160
+ /** Its URL, often relative (`/jobs-api`). */
161
+ url: string;
162
+ /** A note. */
163
+ description?: string;
164
+ }
165
+
166
+ /** The document's header. */
167
+ export interface DocInfo {
168
+ /** `openapi`, e.g. `3.1.0`. */
169
+ openapi: string;
170
+ /** The API's title. */
171
+ title: string;
172
+ /** Its version. */
173
+ version: string;
174
+ /** What it is. */
175
+ description?: string;
176
+ /** Where it is served. */
177
+ servers: DocServer[];
178
+ }
179
+
180
+ /** A string field, or `undefined`. */
181
+ function text(value: unknown): string | undefined {
182
+ return typeof value === "string" ? value : undefined;
183
+ }
184
+
185
+ /** A string array field, or `[]`. */
186
+ function strings(value: unknown): string[] {
187
+ return Array.isArray(value)
188
+ ? value.filter((item): item is string => typeof item === "string")
189
+ : [];
190
+ }
191
+
192
+ /** Follows a `$ref` to a component (a parameter, a response, a body), one level of indirection at a time. */
193
+ function component(
194
+ doc: SpecDocument,
195
+ value: unknown,
196
+ ): { node: Record<string, unknown> | undefined; ref?: string } {
197
+ const ref = refOf(value);
198
+ if (ref === undefined) {
199
+ return { node: isSchemaObject(value) ? value : undefined };
200
+ }
201
+ const target = deref(value, doc).schema;
202
+ return { node: isSchemaObject(target) ? target : undefined, ref };
203
+ }
204
+
205
+ /** The media types of a `content` map. */
206
+ function mediaTypes(value: unknown): DocMediaType[] {
207
+ if (!isSchemaObject(value)) {
208
+ return [];
209
+ }
210
+ return Object.entries(value).map(([contentType, media]) => ({
211
+ contentType,
212
+ schema: isSchemaObject(media) ? media.schema : undefined,
213
+ }));
214
+ }
215
+
216
+ /** Reads one parameter. */
217
+ function readParameter(
218
+ doc: SpecDocument,
219
+ raw: unknown,
220
+ ): DocParameter | undefined {
221
+ const { node } = component(doc, raw);
222
+ const name = text(node?.name);
223
+ const location = text(node?.in);
224
+ if (
225
+ !node ||
226
+ name === undefined ||
227
+ (location !== "path" &&
228
+ location !== "query" &&
229
+ location !== "header" &&
230
+ location !== "cookie")
231
+ ) {
232
+ return undefined;
233
+ }
234
+ return {
235
+ name,
236
+ in: location,
237
+ required: location === "path" || node.required === true,
238
+ description: text(node.description),
239
+ schema: node.schema,
240
+ style: text(node.style),
241
+ explode: typeof node.explode === "boolean" ? node.explode : undefined,
242
+ deprecated: node.deprecated === true,
243
+ };
244
+ }
245
+
246
+ /** Reads the `<prefix>-csrf` extension. */
247
+ function readCsrf(value: unknown): DocCsrf | undefined {
248
+ if (!isSchemaObject(value)) {
249
+ return undefined;
250
+ }
251
+ return {
252
+ header: text(value.header) ?? null,
253
+ requireJson: value.requireJson === true,
254
+ };
255
+ }
256
+
257
+ /** Reads one operation of a path item. */
258
+ function readOperation(
259
+ doc: SpecDocument,
260
+ path: string,
261
+ method: string,
262
+ raw: Record<string, unknown>,
263
+ shared: unknown[],
264
+ prefixes: readonly string[],
265
+ ): DocOperation {
266
+ const parameters = new Map<string, DocParameter>();
267
+ for (const item of [
268
+ ...shared,
269
+ ...(Array.isArray(raw.parameters) ? raw.parameters : []),
270
+ ]) {
271
+ const parameter = readParameter(doc, item);
272
+ if (parameter) {
273
+ parameters.set(`${parameter.in}:${parameter.name}`, parameter);
274
+ }
275
+ }
276
+ const body = component(doc, raw.requestBody).node;
277
+ const responses: DocResponse[] = [];
278
+ if (isSchemaObject(raw.responses)) {
279
+ for (const [status, value] of Object.entries(raw.responses)) {
280
+ const { node, ref } = component(doc, value);
281
+ responses.push({
282
+ status,
283
+ description: text(node?.description) ?? "",
284
+ content: mediaTypes(node?.content),
285
+ codes: strings(
286
+ node === undefined ? undefined : extensionOf(node, "codes", prefixes),
287
+ ),
288
+ ...(ref ? { ref } : {}),
289
+ });
290
+ }
291
+ }
292
+ return {
293
+ id: text(raw.operationId) ?? `${method.toUpperCase()} ${path}`,
294
+ method: method.toUpperCase(),
295
+ path,
296
+ summary: text(raw.summary),
297
+ description: text(raw.description),
298
+ tags: strings(raw.tags),
299
+ deprecated: raw.deprecated === true,
300
+ parameters: [...parameters.values()],
301
+ requestBody: body
302
+ ? {
303
+ required: body.required === true,
304
+ description: text(body.description),
305
+ content: mediaTypes(body.content),
306
+ }
307
+ : undefined,
308
+ responses,
309
+ action: text(extensionOf(raw, "action", prefixes)),
310
+ mutation: extensionOf(raw, "mutation", prefixes) === true,
311
+ requires: strings(extensionOf(raw, "requires", prefixes)),
312
+ csrf: readCsrf(extensionOf(raw, "csrf", prefixes)),
313
+ };
314
+ }
315
+
316
+ /** Every operation of the document, in path order and, within a path, method order. */
317
+ export function listOperations(
318
+ doc: SpecDocument,
319
+ options: HttpModelOptions = {},
320
+ ): DocOperation[] {
321
+ const prefixes = options.extensions ?? DEFAULT_EXTENSION_PREFIXES;
322
+ const operations: DocOperation[] = [];
323
+ if (!isSchemaObject(doc.paths)) {
324
+ return operations;
325
+ }
326
+ for (const [path, item] of Object.entries(doc.paths)) {
327
+ const pathItem = component(doc, item).node;
328
+ if (!pathItem) {
329
+ continue;
330
+ }
331
+ const shared = Array.isArray(pathItem.parameters)
332
+ ? pathItem.parameters
333
+ : [];
334
+ for (const method of OPERATION_METHODS) {
335
+ const raw = pathItem[method];
336
+ if (isSchemaObject(raw)) {
337
+ operations.push(
338
+ readOperation(doc, path, method, raw, shared, prefixes),
339
+ );
340
+ }
341
+ }
342
+ }
343
+ return operations;
344
+ }
345
+
346
+ /** The document's header: title, version, description, servers. */
347
+ export function readInfo(doc: SpecDocument): DocInfo {
348
+ const info = isSchemaObject(doc.info) ? doc.info : {};
349
+ const servers = Array.isArray(doc.servers)
350
+ ? doc.servers.flatMap((server): DocServer[] => {
351
+ const url = isSchemaObject(server) ? text(server.url) : undefined;
352
+ return url === undefined
353
+ ? []
354
+ : [
355
+ {
356
+ url,
357
+ description: text(
358
+ (server as Record<string, unknown>).description,
359
+ ),
360
+ },
361
+ ];
362
+ })
363
+ : [];
364
+ return {
365
+ openapi: text(doc.openapi) ?? "",
366
+ title: text(info.title) ?? "API",
367
+ version: text(info.version) ?? "",
368
+ description: text(info.description),
369
+ servers: servers.length > 0 ? servers : [{ url: "/" }],
370
+ };
371
+ }
372
+
373
+ /**
374
+ * Operations by tag, in the document's `tags` order; tags an operation uses
375
+ * but the list omits follow in first-use order, then `Other` for untagged
376
+ * operations. An operation with two tags is listed under both.
377
+ */
378
+ export function groupByTag(
379
+ doc: SpecDocument,
380
+ operations: readonly DocOperation[],
381
+ ): DocTagGroup[] {
382
+ const groups = new Map<string, DocTagGroup>();
383
+ if (Array.isArray(doc.tags)) {
384
+ for (const tag of doc.tags) {
385
+ const name = isSchemaObject(tag) ? text(tag.name) : undefined;
386
+ if (name !== undefined && !groups.has(name)) {
387
+ groups.set(name, {
388
+ name,
389
+ description: text((tag as Record<string, unknown>).description),
390
+ operations: [],
391
+ });
392
+ }
393
+ }
394
+ }
395
+ const other: DocOperation[] = [];
396
+ for (const operation of operations) {
397
+ if (operation.tags.length === 0) {
398
+ other.push(operation);
399
+ }
400
+ for (const tag of operation.tags) {
401
+ let group = groups.get(tag);
402
+ if (!group) {
403
+ group = { name: tag, operations: [] };
404
+ groups.set(tag, group);
405
+ }
406
+ group.operations.push(operation);
407
+ }
408
+ }
409
+ const result = [...groups.values()].filter(
410
+ (group) => group.operations.length > 0,
411
+ );
412
+ if (other.length > 0) {
413
+ result.push({ name: "Other", operations: other });
414
+ }
415
+ return result;
416
+ }
417
+
418
+ /** One section of the sidebar: a document's `x-tagGroups` entry, over the tags it names. */
419
+ export interface DocSection {
420
+ /** The section's name. */
421
+ name: string;
422
+ /** Its tags with their operations, in the section's order. */
423
+ tags: DocTagGroup[];
424
+ }
425
+
426
+ /** A section with a React key unique among its siblings, and its position. */
427
+ export interface KeyedSection {
428
+ /** Its name, suffixed `#2`, `#3`… when a hand-written document repeats one. */
429
+ key: string;
430
+ /** Its position in the document's order (for heading ids). */
431
+ index: number;
432
+ /** The section. */
433
+ section: DocSection;
434
+ }
435
+
436
+ /**
437
+ * Keys `sections` for rendering: two sections may share a name in a
438
+ * hand-written document, so the name alone is not a key, and a position is
439
+ * not a stable one (`react/no-array-index-key`).
440
+ */
441
+ export function keyedSections(sections: readonly DocSection[]): KeyedSection[] {
442
+ const seen = new Map<string, number>();
443
+ return sections.map((section, index) => {
444
+ const count = (seen.get(section.name) ?? 0) + 1;
445
+ seen.set(section.name, count);
446
+ return {
447
+ key: count === 1 ? section.name : `${section.name}#${count}`,
448
+ index,
449
+ section,
450
+ };
451
+ });
452
+ }
453
+
454
+ /** The section the tags no `x-tagGroups` entry names go in. */
455
+ const OTHER_SECTION = "Other";
456
+
457
+ /**
458
+ * The document's sections (`x-tagGroups`, Redoc's and Scalar's shape: `[{
459
+ * name, tags }]`, the one bun-common's `generateOpenApi({ tagGroups })`
460
+ * emits), each over its tags' groups, in the document's order; `undefined`
461
+ * when the document declares none, so the sidebar stays one level deep. A
462
+ * malformed entry is skipped, and a list with none well formed reads as none.
463
+ *
464
+ * Unlike Redoc, nothing is hidden: a group no section names (`Other`, the
465
+ * untagged operations, among them) follows in a trailing `Other` section, or
466
+ * in the document's section of that name when it has one. A tag in two
467
+ * sections is listed in each; a section with no group (a search left none)
468
+ * is dropped.
469
+ */
470
+ export function groupBySection(
471
+ doc: SpecDocument,
472
+ groups: readonly DocTagGroup[],
473
+ ): DocSection[] | undefined {
474
+ const raw = doc["x-tagGroups"];
475
+ if (!Array.isArray(raw)) {
476
+ return undefined;
477
+ }
478
+ const declared = raw.flatMap(
479
+ (entry: unknown): { name: string; tags: string[] }[] => {
480
+ if (!isSchemaObject(entry)) {
481
+ return [];
482
+ }
483
+ const name = text(entry.name);
484
+ return name === undefined || !Array.isArray(entry.tags)
485
+ ? []
486
+ : [{ name, tags: strings(entry.tags) }];
487
+ },
488
+ );
489
+ if (declared.length === 0) {
490
+ return undefined;
491
+ }
492
+ const byName = new Map(groups.map((group) => [group.name, group]));
493
+ const named = new Set<string>();
494
+ const sections: DocSection[] = [];
495
+ for (const { name, tags } of declared) {
496
+ const section: DocSection = { name, tags: [] };
497
+ for (const tag of tags) {
498
+ const group = byName.get(tag);
499
+ if (group && !section.tags.includes(group)) {
500
+ section.tags.push(group);
501
+ named.add(tag);
502
+ }
503
+ }
504
+ sections.push(section);
505
+ }
506
+ const rest = groups.filter((group) => !named.has(group.name));
507
+ if (rest.length > 0) {
508
+ const other = sections.find((section) => section.name === OTHER_SECTION);
509
+ if (other) {
510
+ other.tags.push(...rest);
511
+ } else {
512
+ sections.push({ name: OTHER_SECTION, tags: rest });
513
+ }
514
+ }
515
+ return sections.filter((section) => section.tags.length > 0);
516
+ }
517
+
518
+ /**
519
+ * Whether an operation matches a search: every whitespace-separated term
520
+ * must appear, ignoring case, in its path, id, summary, method or action.
521
+ */
522
+ export function matchesSearch(operation: DocOperation, query: string): boolean {
523
+ const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
524
+ if (terms.length === 0) {
525
+ return true;
526
+ }
527
+ const haystack = [
528
+ operation.method,
529
+ operation.path,
530
+ operation.id,
531
+ operation.summary ?? "",
532
+ operation.action ?? "",
533
+ ]
534
+ .join(" ")
535
+ .toLowerCase();
536
+ return terms.every((term) => haystack.includes(term));
537
+ }
538
+
539
+ /** Groups filtered by a search; groups left empty are dropped. */
540
+ export function filterGroups(
541
+ groups: readonly DocTagGroup[],
542
+ query: string,
543
+ ): DocTagGroup[] {
544
+ return groups
545
+ .map((group) => ({
546
+ ...group,
547
+ operations: group.operations.filter((operation) =>
548
+ matchesSearch(operation, query),
549
+ ),
550
+ }))
551
+ .filter((group) => group.operations.length > 0);
552
+ }
553
+
554
+ /**
555
+ * A server URL as the reader can use it: a relative one (`/jobs-api`, the
556
+ * API's default) is resolved against the origin the app talks to — the API
557
+ * base's own origin when it is absolute, else the page's.
558
+ */
559
+ export function resolveServerUrl(
560
+ url: string,
561
+ apiBase: string,
562
+ pageOrigin: string,
563
+ ): string {
564
+ const origin = /^https?:\/\//.test(apiBase)
565
+ ? new URL(apiBase).origin
566
+ : pageOrigin;
567
+ try {
568
+ return new URL(url, `${origin}/`).href.replace(/\/$/, "");
569
+ } catch {
570
+ return url;
571
+ }
572
+ }
573
+
574
+ /** The named component schemas, sorted as the document lists them. */
575
+ export function componentSchemaNames(doc: SpecDocument): string[] {
576
+ const schemas = resolveRef(doc, "#/components/schemas");
577
+ return isSchemaObject(schemas) ? Object.keys(schemas) : [];
578
+ }
579
+
580
+ /** The tone a method badge takes. */
581
+ export function methodTone(
582
+ method: string,
583
+ ): "info" | "success" | "warning" | "danger" | "neutral" {
584
+ switch (method) {
585
+ case "GET":
586
+ return "info";
587
+ case "POST":
588
+ return "success";
589
+ case "PUT":
590
+ case "PATCH":
591
+ return "warning";
592
+ case "DELETE":
593
+ return "danger";
594
+ default:
595
+ return "neutral";
596
+ }
597
+ }
598
+
599
+ /** The success responses (2xx): the note try-it shows when the status it received is not documented. */
600
+ export function successStatuses(operation: DocOperation): string[] {
601
+ return operation.responses
602
+ .map((response) => response.status)
603
+ .filter((status) => /^2\d\d$/.test(status));
604
+ }