@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,455 @@
1
+ import type { HttpMethod, QueryScalar, QueryValue } from "../../../api/client";
2
+ import type { SchemaRoot } from "../schema/resolve";
3
+ import type { DocOperation, DocParameter } from "./model";
4
+ import {
5
+ CSRF_HEADER_VALUE,
6
+ segment,
7
+ serializeQuery,
8
+ } from "../../../api/client";
9
+ import { JSON_METHODS, MUTATING_METHODS } from "../../../api/contract";
10
+ import { declaredTypes, deref, isSchemaObject } from "../schema/resolve";
11
+ import { schemaSkeleton } from "../schema/skeleton";
12
+ import { successStatuses } from "./model";
13
+
14
+ /**
15
+ * The try-it panel's logic: which input each parameter gets, the request a
16
+ * filled form makes, whether the panel may send it at all, and the curl and
17
+ * fetch() snippets of that exact request. The request goes through the
18
+ * viewer's own client, so the headers here restate its rules (for the
19
+ * snippets) rather than decide them.
20
+ */
21
+
22
+ /** Methods the viewer's client can send. */
23
+ const CLIENT_METHODS: ReadonlySet<string> = new Set([
24
+ "GET",
25
+ "POST",
26
+ "PUT",
27
+ "PATCH",
28
+ "DELETE",
29
+ ]);
30
+
31
+ /** Action verbs whose try-it needs a typed confirmation: they delete, discard or irreversibly end work. */
32
+ const DESTRUCTIVE_VERBS: ReadonlySet<string> = new Set([
33
+ "remove",
34
+ "drain",
35
+ "clean",
36
+ "kill",
37
+ "fail",
38
+ ]);
39
+
40
+ /** The input a parameter is edited with. */
41
+ export type InputKind =
42
+ | { kind: "text" }
43
+ | { kind: "number"; integer: boolean; min?: number; max?: number }
44
+ | { kind: "boolean" }
45
+ | { kind: "enum"; options: string[] }
46
+ | { kind: "list"; item: "text" | "integer" | "number" }
47
+ | { kind: "enum-list"; options: string[] };
48
+
49
+ /** A form value: text for scalars and comma lists, the ticked options for an enum list. */
50
+ export type FieldValue = string | readonly string[];
51
+
52
+ /** What the form holds, keyed by {@link paramKey}. */
53
+ export type TryItValues = Readonly<Record<string, FieldValue>>;
54
+
55
+ /** A parameter's form key: `path:queue`, `query:state`. */
56
+ export function paramKey(parameter: Pick<DocParameter, "in" | "name">): string {
57
+ return `${parameter.in}:${parameter.name}`;
58
+ }
59
+
60
+ /** The scalar item type of a schema (`$ref`s followed). */
61
+ function scalarType(schema: unknown, root: SchemaRoot): string | undefined {
62
+ const node = deref(schema, root).schema;
63
+ if (!isSchemaObject(node)) {
64
+ return undefined;
65
+ }
66
+ return declaredTypes(node).find((type) => type !== "null");
67
+ }
68
+
69
+ /** The string values of an enum schema, when it is one. */
70
+ function enumOptions(schema: unknown, root: SchemaRoot): string[] | undefined {
71
+ const node = deref(schema, root).schema;
72
+ if (!isSchemaObject(node) || !Array.isArray(node.enum)) {
73
+ return undefined;
74
+ }
75
+ return node.enum
76
+ .filter((value) => value !== null)
77
+ .map((value) => String(value));
78
+ }
79
+
80
+ /** How a parameter is edited, from its schema. */
81
+ export function inputKind(
82
+ parameter: DocParameter,
83
+ root: SchemaRoot,
84
+ ): InputKind {
85
+ const node = deref(parameter.schema, root).schema;
86
+ const schema = isSchemaObject(node) ? node : {};
87
+ const type = scalarType(schema, root);
88
+ if (type === "array") {
89
+ const options = enumOptions(schema.items, root);
90
+ if (options) {
91
+ return { kind: "enum-list", options };
92
+ }
93
+ const item = scalarType(schema.items, root);
94
+ return {
95
+ kind: "list",
96
+ item: item === "integer" || item === "number" ? item : "text",
97
+ };
98
+ }
99
+ const options = enumOptions(schema, root);
100
+ if (options) {
101
+ return { kind: "enum", options };
102
+ }
103
+ if (type === "boolean") {
104
+ return { kind: "boolean" };
105
+ }
106
+ if (type === "integer" || type === "number") {
107
+ return {
108
+ kind: "number",
109
+ integer: type === "integer",
110
+ min: typeof schema.minimum === "number" ? schema.minimum : undefined,
111
+ max: typeof schema.maximum === "number" ? schema.maximum : undefined,
112
+ };
113
+ }
114
+ return { kind: "text" };
115
+ }
116
+
117
+ /** The parameters the form edits: path and query. Header parameters are the client's to send. */
118
+ export function formParameters(operation: DocOperation): DocParameter[] {
119
+ return operation.parameters.filter(
120
+ (parameter) => parameter.in === "path" || parameter.in === "query",
121
+ );
122
+ }
123
+
124
+ /** The schema of the JSON body, when the operation takes one worth editing. */
125
+ export function bodySchema(operation: DocOperation): unknown {
126
+ const json = operation.requestBody?.content.find(
127
+ (media) => media.contentType === "application/json",
128
+ );
129
+ const schema = json?.schema;
130
+ // The API's bodiless POSTs declare a body only to require the media type;
131
+ // its schema is a bare description ("Ignored. Send no body, or `{}`.").
132
+ if (
133
+ !isSchemaObject(schema) ||
134
+ Object.keys(schema).every(
135
+ (key) => key === "description" || key.startsWith("x-"),
136
+ )
137
+ ) {
138
+ return undefined;
139
+ }
140
+ return schema;
141
+ }
142
+
143
+ /** The editor's starting text: the body schema's required skeleton, or `""`. */
144
+ export function initialBodyText(
145
+ operation: DocOperation,
146
+ root: SchemaRoot,
147
+ ): string {
148
+ const schema = bodySchema(operation);
149
+ return schema === undefined
150
+ ? ""
151
+ : JSON.stringify(schemaSkeleton(schema, root), null, 2);
152
+ }
153
+
154
+ /** A parameter's form value as the query takes it, or an error message. */
155
+ function coerce(
156
+ parameter: DocParameter,
157
+ kind: InputKind,
158
+ value: FieldValue | undefined,
159
+ ): { value: QueryValue } | { error: string } {
160
+ if (kind.kind === "enum-list") {
161
+ const picked = Array.isArray(value) ? value : [];
162
+ return { value: picked.length > 0 ? [...picked] : undefined };
163
+ }
164
+ const raw = typeof value === "string" ? value.trim() : "";
165
+ if (raw === "") {
166
+ return parameter.required
167
+ ? { error: `${parameter.name} is required` }
168
+ : { value: undefined };
169
+ }
170
+ switch (kind.kind) {
171
+ case "number": {
172
+ const number = Number(raw);
173
+ if (
174
+ !Number.isFinite(number) ||
175
+ (kind.integer && !Number.isInteger(number))
176
+ ) {
177
+ return {
178
+ error: `${parameter.name} must be ${kind.integer ? "an integer" : "a number"}`,
179
+ };
180
+ }
181
+ return { value: number };
182
+ }
183
+ case "boolean":
184
+ return raw === "true" || raw === "false"
185
+ ? { value: raw === "true" }
186
+ : { error: `${parameter.name} must be true or false` };
187
+ case "list": {
188
+ const items = raw
189
+ .split(",")
190
+ .map((item) => item.trim())
191
+ .filter(Boolean);
192
+ const values: QueryScalar[] = [];
193
+ for (const item of items) {
194
+ if (kind.item === "text") {
195
+ values.push(item);
196
+ continue;
197
+ }
198
+ const number = Number(item);
199
+ if (
200
+ !Number.isFinite(number) ||
201
+ (kind.item === "integer" && !Number.isInteger(number))
202
+ ) {
203
+ return {
204
+ error: `${parameter.name}: "${item}" is not ${kind.item === "integer" ? "an integer" : "a number"}`,
205
+ };
206
+ }
207
+ values.push(number);
208
+ }
209
+ return { value: values.length > 0 ? values : undefined };
210
+ }
211
+ default:
212
+ return { value: raw };
213
+ }
214
+ }
215
+
216
+ /** A request the panel sends, in the terms of `ApiClient.request`. */
217
+ export interface TryItRequest {
218
+ /** The method. */
219
+ method: HttpMethod;
220
+ /** The path under the API base, path parameters percent-encoded. */
221
+ path: string;
222
+ /** The query; arrays go as repeated keys. */
223
+ query: Record<string, QueryValue>;
224
+ /** The JSON body, or `undefined` for none. */
225
+ body: unknown;
226
+ }
227
+
228
+ /** The outcome of {@link buildRequest}. */
229
+ export type BuildResult =
230
+ | { ok: true; request: TryItRequest }
231
+ | { ok: false; errors: Record<string, string> };
232
+
233
+ /**
234
+ * The request a filled form makes: path parameters substituted (each
235
+ * percent-encoded as one segment, so `a/b` is `a%2Fb`), query values typed
236
+ * from their schemas, and the body as parsed. `body` is the editor's parsed
237
+ * value, `undefined` for an empty editor.
238
+ */
239
+ export function buildRequest(
240
+ operation: DocOperation,
241
+ root: SchemaRoot,
242
+ values: TryItValues,
243
+ body: unknown,
244
+ ): BuildResult {
245
+ const errors: Record<string, string> = {};
246
+ if (!CLIENT_METHODS.has(operation.method)) {
247
+ return {
248
+ ok: false,
249
+ errors: { method: `${operation.method} cannot be sent from here` },
250
+ };
251
+ }
252
+ let path = operation.path;
253
+ const query: Record<string, QueryValue> = {};
254
+ for (const parameter of formParameters(operation)) {
255
+ const key = paramKey(parameter);
256
+ const result = coerce(parameter, inputKind(parameter, root), values[key]);
257
+ if ("error" in result) {
258
+ errors[key] = result.error;
259
+ continue;
260
+ }
261
+ if (parameter.in === "path") {
262
+ path = path
263
+ .split(`{${parameter.name}}`)
264
+ .join(segment(String(result.value)));
265
+ } else if (result.value !== undefined) {
266
+ query[parameter.name] = result.value;
267
+ }
268
+ }
269
+ if (
270
+ body === undefined &&
271
+ operation.requestBody?.required &&
272
+ bodySchema(operation) !== undefined
273
+ ) {
274
+ errors.body = "A body is required";
275
+ }
276
+ if (Object.keys(errors).length > 0) {
277
+ return { ok: false, errors };
278
+ }
279
+ return {
280
+ ok: true,
281
+ request: { method: operation.method as HttpMethod, path, query, body },
282
+ };
283
+ }
284
+
285
+ /**
286
+ * The headers the viewer's client sends for a method — restated from
287
+ * `createDocsClient` for the snippets: `Accept` always, `Content-Type` on
288
+ * POST/PUT/PATCH (body or not), and the CSRF header on every mutation when
289
+ * the viewer is configured with one.
290
+ */
291
+ export function clientHeaders(
292
+ method: HttpMethod,
293
+ csrfHeader: string | null,
294
+ ): Record<string, string> {
295
+ const headers: Record<string, string> = { Accept: "application/json" };
296
+ if (JSON_METHODS.has(method)) {
297
+ headers["Content-Type"] = "application/json";
298
+ }
299
+ if (csrfHeader && MUTATING_METHODS.has(method)) {
300
+ headers[csrfHeader] = CSRF_HEADER_VALUE;
301
+ }
302
+ return headers;
303
+ }
304
+
305
+ /** Where the snippets point. */
306
+ export interface SnippetContext {
307
+ /** The app's API base (`/jobs-api`, or an absolute URL). */
308
+ apiBase: string;
309
+ /** The page's origin, for an absolute curl URL. */
310
+ origin: string;
311
+ /** The UI's CSRF header, or `null`. */
312
+ csrfHeader: string | null;
313
+ }
314
+
315
+ /** The URL the client requests: base + path + query. */
316
+ export function requestUrl(request: TryItRequest, apiBase: string): string {
317
+ return `${apiBase.replace(/\/+$/, "")}${request.path}${serializeQuery(request.query)}`;
318
+ }
319
+
320
+ /** Quotes a word for a POSIX shell. */
321
+ function shellQuote(value: string): string {
322
+ return `'${value.replace(/'/g, `'\\''`)}'`;
323
+ }
324
+
325
+ /** A `curl` command sending exactly what the panel sends. */
326
+ export function curlSnippet(
327
+ request: TryItRequest,
328
+ context: SnippetContext,
329
+ ): string {
330
+ const relative = requestUrl(request, context.apiBase);
331
+ const url = /^https?:\/\//.test(relative)
332
+ ? relative
333
+ : `${context.origin}${relative}`;
334
+ const lines = [`curl -X ${request.method} ${shellQuote(url)}`];
335
+ for (const [name, value] of Object.entries(
336
+ clientHeaders(request.method, context.csrfHeader),
337
+ )) {
338
+ lines.push(` -H ${shellQuote(`${name}: ${value}`)}`);
339
+ }
340
+ if (request.body !== undefined) {
341
+ lines.push(` --data ${shellQuote(JSON.stringify(request.body))}`);
342
+ }
343
+ return [
344
+ "# The browser also sends this site's cookies (credentials: same-origin).",
345
+ "# If the API's authorize reads a session, add it: -b 'name=value'",
346
+ lines.join(" \\\n"),
347
+ ].join("\n");
348
+ }
349
+
350
+ /** A `fetch()` call sending exactly what the panel sends, from a page on this origin. */
351
+ export function fetchSnippet(
352
+ request: TryItRequest,
353
+ context: SnippetContext,
354
+ ): string {
355
+ const headers = clientHeaders(request.method, context.csrfHeader);
356
+ const headerLines = Object.entries(headers)
357
+ .map(
358
+ ([name, value]) =>
359
+ ` ${JSON.stringify(name)}: ${JSON.stringify(value)},`,
360
+ )
361
+ .join("\n");
362
+ const lines = [
363
+ `const response = await fetch(${JSON.stringify(requestUrl(request, context.apiBase))}, {`,
364
+ ` method: ${JSON.stringify(request.method)},`,
365
+ " headers: {",
366
+ headerLines,
367
+ " },",
368
+ ` credentials: "same-origin",`,
369
+ ];
370
+ if (request.body !== undefined) {
371
+ lines.push(
372
+ ` body: JSON.stringify(${JSON.stringify(request.body, null, 2).replace(/\n/g, "\n ")}),`,
373
+ );
374
+ }
375
+ lines.push("});", "const data = await response.json();");
376
+ return lines.join("\n");
377
+ }
378
+
379
+ /**
380
+ * Whether sending an operation needs a TYPED confirmation: a DELETE, or an
381
+ * action whose verb removes, discards or irreversibly ends work
382
+ * (`jobs.remove`, `queues.drain`, `queues.clean`, `runners.kill`,
383
+ * `repeatables.remove`, `jobs.fail`: a failed job is dead for good).
384
+ */
385
+ export function isDestructive(operation: DocOperation): boolean {
386
+ if (operation.method === "DELETE") {
387
+ return true;
388
+ }
389
+ const verb = operation.action?.split(".").pop() ?? "";
390
+ return DESTRUCTIVE_VERBS.has(verb);
391
+ }
392
+
393
+ /** Whether the panel may send, and why not. */
394
+ export type TryItGate = { enabled: true } | { enabled: false; reason: string };
395
+
396
+ /** What {@link tryItGate} decides from. */
397
+ export interface GateInputs {
398
+ /** `meta.readOnly`. */
399
+ readOnly: boolean;
400
+ /** Whether the caller holds the operation's action (untargeted permissions). */
401
+ permitted: boolean;
402
+ }
403
+
404
+ /**
405
+ * The panel is off for a mutation on a read-only API, and for an action the
406
+ * caller does not hold. Advisory, like every permission marker here:
407
+ * `authorize` may decide per target, and the API's answer stays final.
408
+ */
409
+ export function tryItGate(
410
+ operation: DocOperation,
411
+ inputs: GateInputs,
412
+ ): TryItGate {
413
+ if (!CLIENT_METHODS.has(operation.method)) {
414
+ return {
415
+ enabled: false,
416
+ reason: `The viewer's client does not send ${operation.method}.`,
417
+ };
418
+ }
419
+ // Kept although a read-only `createJobsApi` neither routes nor documents
420
+ // its mutations: this reason shows only for a document that lists one
421
+ // anyway, a stale or foreign OpenAPI document (a cached copy from before
422
+ // the API went read-only, or another API's). The API would refuse the
423
+ // send, so the panel says why instead of offering it.
424
+ if (operation.mutation && inputs.readOnly) {
425
+ return {
426
+ enabled: false,
427
+ reason: "This API is read-only: it refuses every change.",
428
+ };
429
+ }
430
+ if (operation.action !== undefined && !inputs.permitted) {
431
+ return {
432
+ enabled: false,
433
+ reason: `You do not have the ${operation.action} permission.`,
434
+ };
435
+ }
436
+ return { enabled: true };
437
+ }
438
+
439
+ /**
440
+ * The note try-it shows when the status received is not one the operation
441
+ * documents, naming the documented success statuses (`2xx` when it documents
442
+ * none); `undefined` when the status is documented.
443
+ */
444
+ export function documentedNote(
445
+ operation: DocOperation,
446
+ status: number,
447
+ ): string | undefined {
448
+ if (
449
+ operation.responses.some((response) => response.status === String(status))
450
+ ) {
451
+ return undefined;
452
+ }
453
+ const success = successStatuses(operation);
454
+ return `Not a documented status. Documented success: ${success.length > 0 ? success.join(" / ") : "2xx"}.`;
455
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * How an embedding app answers "does the caller hold this permission": `true`
3
+ * held, `false` lacked, `undefined` not a permission it knows. A standalone
4
+ * viewer passes no probe and every permission renders plainly.
5
+ */
6
+ export type PermissionProbe = (action: string) => boolean | undefined;
@@ -0,0 +1,43 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** Props of {@link Prose}. */
4
+ export interface ProseProps {
5
+ /** The text: a spec's `description`, which may carry `` `code` `` spans and blank-line paragraphs. */
6
+ text: string;
7
+ /** Extra class names on each paragraph. */
8
+ className?: string;
9
+ }
10
+
11
+ /** Splits one paragraph into text and `<code>` spans at backticks. */
12
+ function inlineCode(text: string): ReactNode[] {
13
+ return text.split(/(`[^`]+`)/).map((part, index) =>
14
+ part.length > 2 && part.startsWith("`") && part.endsWith("`") ? (
15
+ // eslint-disable-next-line react/no-array-index-key -- the split is fixed for a given text
16
+ <code key={index}>{part.slice(1, -1)}</code>
17
+ ) : (
18
+ part
19
+ ),
20
+ );
21
+ }
22
+
23
+ /**
24
+ * A spec description as paragraphs, with backtick spans as code. Nothing
25
+ * else of Markdown is interpreted, and no HTML is ever injected: a spec is
26
+ * data from the API, rendered as text.
27
+ */
28
+ export function Prose({ text, className }: ProseProps) {
29
+ const paragraphs = text.split(/\n\s*\n/).filter((part) => part.trim() !== "");
30
+ return (
31
+ <>
32
+ {paragraphs.map((paragraph, index) => (
33
+ <p
34
+ // eslint-disable-next-line react/no-array-index-key -- paragraphs of a fixed text
35
+ key={index}
36
+ className={className}
37
+ >
38
+ {inlineCode(paragraph)}
39
+ </p>
40
+ ))}
41
+ </>
42
+ );
43
+ }