@specbook/ui 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +6 -2
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
- package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
- package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
- package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
- package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
- package/dist-viewer/assets/worker-Dw3Ps3hk.js +181 -0
- package/dist-viewer/favicon.svg +1 -0
- package/dist-viewer/index.html +19 -0
- package/dist-viewer/specbook.js +1 -0
- package/index.html +12 -0
- package/package.json +60 -3
- package/src/app.tsx +363 -0
- package/src/assets/mark.svg +1 -0
- package/src/badge.ts +14 -0
- package/src/branding.ts +92 -0
- package/src/components/Catalog.tsx +324 -0
- package/src/components/EnvBadge.tsx +46 -0
- package/src/components/EnvironmentMenu.tsx +262 -0
- package/src/components/Fields.tsx +220 -0
- package/src/components/GitHubStar.tsx +22 -0
- package/src/components/ListResizer.tsx +70 -0
- package/src/components/ModeHint.tsx +17 -0
- package/src/components/Palette.tsx +85 -0
- package/src/components/PoweredBy.tsx +15 -0
- package/src/components/ShareMenu.tsx +94 -0
- package/src/components/ThemeToggle.tsx +52 -0
- package/src/embed/panel.tsx +114 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +432 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +16 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +237 -0
- package/src/panel/Combo.tsx +124 -0
- package/src/panel/ControlsGrid.tsx +189 -0
- package/src/panel/FieldDoc.tsx +42 -0
- package/src/panel/FieldTable.tsx +113 -0
- package/src/panel/Host.tsx +100 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +193 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +625 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +156 -0
- package/src/panel/SequenceDocs.tsx +132 -0
- package/src/panel/SequencePanel.tsx +247 -0
- package/src/panel/StepExchange.tsx +118 -0
- package/src/panel/completions.ts +114 -0
- package/src/panel/draft.ts +106 -0
- package/src/panel/fieldnotes-split.ts +55 -0
- package/src/panel/fieldnotes.ts +40 -0
- package/src/panel/graphql.ts +31 -0
- package/src/panel/icons.tsx +85 -0
- package/src/panel/jsonCursor.ts +115 -0
- package/src/panel/stepnotes-split.ts +38 -0
- package/src/panel/stepnotes.ts +58 -0
- package/src/routing.ts +120 -0
- package/src/runtime.ts +32 -0
- package/src/schema.ts +359 -0
- package/src/secrets.ts +61 -0
- package/src/status.ts +27 -0
- package/src/store.ts +10 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1897 -0
- package/src/styles/tokens.css +122 -0
- package/src/theme.ts +37 -0
- package/src/viewer/Opening.tsx +43 -0
- package/src/viewer/bundles.ts +74 -0
- package/src/viewer/client.ts +54 -0
- package/src/viewer/embed.ts +78 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +285 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +51 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +154 -0
- package/src/viewer/share.ts +134 -0
- package/src/viewer/site.ts +3 -0
- package/src/viewer/uploads.ts +53 -0
- package/src/viewer/viewer.css +184 -0
- package/src/viewer/worker-factory.ts +14 -0
- package/src/viewer/worker.ts +171 -0
- package/src/virtual.d.ts +104 -0
package/src/schema.ts
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// JSON Schema helpers for the annotated request/response views: resolve refs, unwrap the
|
|
2
|
+
// "{{variable}}" placeholder alternative, describe nodes, and validate a value against a schema.
|
|
3
|
+
|
|
4
|
+
export type JsonSchema = Record<string, any>
|
|
5
|
+
export type Defs = Record<string, any> | undefined
|
|
6
|
+
|
|
7
|
+
export type Kind =
|
|
8
|
+
| 'object'
|
|
9
|
+
| 'array'
|
|
10
|
+
| 'string'
|
|
11
|
+
| 'integer'
|
|
12
|
+
| 'number'
|
|
13
|
+
| 'boolean'
|
|
14
|
+
| 'null'
|
|
15
|
+
| 'unknown'
|
|
16
|
+
|
|
17
|
+
export const TEMPLATE = /^\s*\{\{[^}]+\}\}\s*$/
|
|
18
|
+
|
|
19
|
+
export const isTemplate = (v: unknown): v is string => typeof v === 'string' && TEMPLATE.test(v)
|
|
20
|
+
|
|
21
|
+
function isPlaceholderSchema(s: JsonSchema): boolean {
|
|
22
|
+
return s?.type === 'string' && typeof s.pattern === 'string' && s.pattern.includes('\\{\\{')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const META = ['title', 'description', 'default', 'examples', 'deprecated']
|
|
26
|
+
|
|
27
|
+
function withMeta(target: JsonSchema, outer: JsonSchema): JsonSchema {
|
|
28
|
+
const out = { ...target }
|
|
29
|
+
for (const k of META) if (outer[k] !== undefined && out[k] === undefined) out[k] = outer[k]
|
|
30
|
+
return out
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Follows `$ref` (into the service definitions) until a concrete schema is reached. */
|
|
34
|
+
export function resolveRef(schema: JsonSchema | undefined, defs: Defs): JsonSchema {
|
|
35
|
+
let s: JsonSchema = schema ?? {}
|
|
36
|
+
for (let i = 0; i < 12 && s.$ref; i++) {
|
|
37
|
+
const name = String(s.$ref).split('/').pop() as string
|
|
38
|
+
const target = defs?.$defs?.[name]
|
|
39
|
+
if (!target) return { ...s, type: undefined }
|
|
40
|
+
s = withMeta(target, s)
|
|
41
|
+
}
|
|
42
|
+
return s
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolves refs, drops the `"{{variable}}"` alternative, and flattens `allOf`.
|
|
47
|
+
* For `oneOf`/`anyOf` it picks the first branch (callers that have a value use `pickBranch`).
|
|
48
|
+
*/
|
|
49
|
+
export function unwrap(schema: JsonSchema | undefined, defs: Defs, depth = 0): JsonSchema {
|
|
50
|
+
let s = resolveRef(schema, defs)
|
|
51
|
+
if (depth > 10) return s
|
|
52
|
+
if (Array.isArray(s.anyOf)) {
|
|
53
|
+
const branches = s.anyOf.filter((b: JsonSchema) => !isPlaceholderSchema(b))
|
|
54
|
+
if (branches.length === 1) return unwrap(withMeta(branches[0], s), defs, depth + 1)
|
|
55
|
+
if (branches.length > 1) return unwrap(withMeta(branches[0], s), defs, depth + 1)
|
|
56
|
+
}
|
|
57
|
+
if (Array.isArray(s.oneOf) && s.oneOf.length)
|
|
58
|
+
return unwrap(withMeta(s.oneOf[0], s), defs, depth + 1)
|
|
59
|
+
if (Array.isArray(s.allOf)) {
|
|
60
|
+
const merged: JsonSchema = { ...s }
|
|
61
|
+
Reflect.deleteProperty(merged, 'allOf')
|
|
62
|
+
const props: Record<string, any> = { ...(merged.properties ?? {}) }
|
|
63
|
+
const required = new Set<string>(merged.required ?? [])
|
|
64
|
+
for (const part of s.allOf as JsonSchema[]) {
|
|
65
|
+
const u = unwrap(part, defs, depth + 1)
|
|
66
|
+
Object.assign(props, u.properties ?? {})
|
|
67
|
+
for (const r of u.required ?? []) required.add(r)
|
|
68
|
+
for (const k of META) if (u[k] !== undefined && merged[k] === undefined) merged[k] = u[k]
|
|
69
|
+
if (u.type && !merged.type) merged.type = u.type
|
|
70
|
+
if (u.items && !merged.items) merged.items = u.items
|
|
71
|
+
if (u.enum && !merged.enum) merged.enum = u.enum
|
|
72
|
+
}
|
|
73
|
+
merged.properties = props
|
|
74
|
+
merged.required = [...required]
|
|
75
|
+
if (!merged.type && Object.keys(props).length) merged.type = 'object'
|
|
76
|
+
s = merged
|
|
77
|
+
}
|
|
78
|
+
return s
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function kindOf(schema: JsonSchema): Kind {
|
|
82
|
+
const t = Array.isArray(schema.type) ? schema.type.find((x: string) => x !== 'null') : schema.type
|
|
83
|
+
if (
|
|
84
|
+
t === 'object' ||
|
|
85
|
+
t === 'array' ||
|
|
86
|
+
t === 'string' ||
|
|
87
|
+
t === 'integer' ||
|
|
88
|
+
t === 'number' ||
|
|
89
|
+
t === 'boolean'
|
|
90
|
+
) {
|
|
91
|
+
return t
|
|
92
|
+
}
|
|
93
|
+
if (schema.properties) return 'object'
|
|
94
|
+
if (schema.items) return 'array'
|
|
95
|
+
if (schema.enum) return typeof schema.enum[0] === 'number' ? 'number' : 'string'
|
|
96
|
+
return t === 'null' ? 'null' : 'unknown'
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function describe(schema: JsonSchema): string | undefined {
|
|
100
|
+
const d = schema.description ?? schema.title
|
|
101
|
+
return typeof d === 'string' && d.trim() ? d.trim() : undefined
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** First sentence/line of a description, for compact inline comments. */
|
|
105
|
+
export function summary(desc: string | undefined, max = 120): string | undefined {
|
|
106
|
+
if (!desc) return undefined
|
|
107
|
+
const line = desc.split(/\n+/)[0] as string
|
|
108
|
+
const sentence = line.match(/^.*?[.!?](\s|$)/)?.[0]?.trim() ?? line
|
|
109
|
+
const text = sentence.replace(/\s+/g, ' ')
|
|
110
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface Prop {
|
|
114
|
+
name: string
|
|
115
|
+
schema: JsonSchema
|
|
116
|
+
required: boolean
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function propsOf(schema: JsonSchema, defs: Defs): Prop[] {
|
|
120
|
+
const s = unwrap(schema, defs)
|
|
121
|
+
const required = new Set<string>(s.required ?? [])
|
|
122
|
+
return Object.entries<JsonSchema>(s.properties ?? {}).map(([name, sch]) => ({
|
|
123
|
+
name,
|
|
124
|
+
schema: sch,
|
|
125
|
+
required: required.has(name),
|
|
126
|
+
}))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function childSchema(
|
|
130
|
+
schema: JsonSchema,
|
|
131
|
+
key: string | number,
|
|
132
|
+
defs: Defs,
|
|
133
|
+
): JsonSchema | undefined {
|
|
134
|
+
const s = unwrap(schema, defs)
|
|
135
|
+
if (typeof key === 'number') return s.items
|
|
136
|
+
if (s.properties?.[key]) return s.properties[key]
|
|
137
|
+
if (s.additionalProperties && typeof s.additionalProperties === 'object')
|
|
138
|
+
return s.additionalProperties
|
|
139
|
+
return undefined
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface Issue {
|
|
143
|
+
path: string
|
|
144
|
+
kind: 'type' | 'enum' | 'required' | 'extra' | 'range'
|
|
145
|
+
message: string
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const typeName = (v: unknown) => (v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v)
|
|
149
|
+
|
|
150
|
+
/** Pick the oneOf/anyOf branch whose primary type matches the value (falls back to the first). */
|
|
151
|
+
function pickBranch(schema: JsonSchema, value: unknown, defs: Defs): JsonSchema {
|
|
152
|
+
const s = resolveRef(schema, defs)
|
|
153
|
+
const branches = ((s.oneOf ?? s.anyOf) as JsonSchema[] | undefined)?.filter(
|
|
154
|
+
(b) => !isPlaceholderSchema(b),
|
|
155
|
+
)
|
|
156
|
+
if (!branches?.length) return unwrap(s, defs)
|
|
157
|
+
const want = typeName(value)
|
|
158
|
+
const match = branches.find((b) => {
|
|
159
|
+
const k = kindOf(unwrap(b, defs))
|
|
160
|
+
return (
|
|
161
|
+
k === want || (k === 'integer' && want === 'number') || (k === 'number' && want === 'number')
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
return unwrap(withMeta(match ?? (branches[0] as JsonSchema), s), defs)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface ValidateOptions {
|
|
168
|
+
/** Report object keys that the schema does not declare (response conformance). */
|
|
169
|
+
reportExtra?: boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function validate(
|
|
173
|
+
value: unknown,
|
|
174
|
+
schema: JsonSchema | undefined,
|
|
175
|
+
defs: Defs,
|
|
176
|
+
opts: ValidateOptions = {},
|
|
177
|
+
path = '',
|
|
178
|
+
depth = 0,
|
|
179
|
+
): Issue[] {
|
|
180
|
+
if (!schema || depth > 12) return []
|
|
181
|
+
if (isTemplate(value)) return [] // a {{variable}} stands for any value
|
|
182
|
+
const s = pickBranch(schema, value, defs)
|
|
183
|
+
const issues: Issue[] = []
|
|
184
|
+
const at = path || '(root)'
|
|
185
|
+
const nullable = Array.isArray(s.type) && s.type.includes('null')
|
|
186
|
+
if (value === null) {
|
|
187
|
+
if (!nullable && kindOf(s) !== 'null' && kindOf(s) !== 'unknown' && s.nullable !== true) {
|
|
188
|
+
issues.push({ path: at, kind: 'type', message: `expected ${kindOf(s)}, got null` })
|
|
189
|
+
}
|
|
190
|
+
return issues
|
|
191
|
+
}
|
|
192
|
+
const kind = kindOf(s)
|
|
193
|
+
const got = typeName(value)
|
|
194
|
+
switch (kind) {
|
|
195
|
+
case 'object': {
|
|
196
|
+
if (got !== 'object')
|
|
197
|
+
return [{ path: at, kind: 'type', message: `expected object, got ${got}` }]
|
|
198
|
+
const obj = value as Record<string, unknown>
|
|
199
|
+
const props = (s.properties ?? {}) as Record<string, JsonSchema>
|
|
200
|
+
for (const r of (s.required ?? []) as string[]) {
|
|
201
|
+
if (!(r in obj))
|
|
202
|
+
issues.push({
|
|
203
|
+
path: path ? `${path}.${r}` : r,
|
|
204
|
+
kind: 'required',
|
|
205
|
+
message: 'required field is missing',
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
209
|
+
const p = path ? `${path}.${k}` : k
|
|
210
|
+
if (props[k]) issues.push(...validate(v, props[k], defs, opts, p, depth + 1))
|
|
211
|
+
else if (s.additionalProperties && typeof s.additionalProperties === 'object')
|
|
212
|
+
issues.push(...validate(v, s.additionalProperties, defs, opts, p, depth + 1))
|
|
213
|
+
else if (
|
|
214
|
+
opts.reportExtra &&
|
|
215
|
+
s.additionalProperties !== true &&
|
|
216
|
+
Object.keys(props).length > 0
|
|
217
|
+
)
|
|
218
|
+
issues.push({ path: p, kind: 'extra', message: 'not in the spec' })
|
|
219
|
+
}
|
|
220
|
+
return issues
|
|
221
|
+
}
|
|
222
|
+
case 'array': {
|
|
223
|
+
if (got !== 'array')
|
|
224
|
+
return [{ path: at, kind: 'type', message: `expected array, got ${got}` }]
|
|
225
|
+
;(value as unknown[]).forEach((item, i) => {
|
|
226
|
+
issues.push(...validate(item, s.items, defs, opts, `${path}[${i}]`, depth + 1))
|
|
227
|
+
})
|
|
228
|
+
return issues
|
|
229
|
+
}
|
|
230
|
+
case 'string': {
|
|
231
|
+
if (got !== 'string')
|
|
232
|
+
return [{ path: at, kind: 'type', message: `expected string, got ${got}` }]
|
|
233
|
+
if (s.enum && !s.enum.includes(value)) {
|
|
234
|
+
issues.push({ path: at, kind: 'enum', message: `must be one of: ${s.enum.join(' · ')}` })
|
|
235
|
+
}
|
|
236
|
+
return issues
|
|
237
|
+
}
|
|
238
|
+
case 'integer':
|
|
239
|
+
case 'number': {
|
|
240
|
+
if (got !== 'number')
|
|
241
|
+
return [{ path: at, kind: 'type', message: `expected ${kind}, got ${got}` }]
|
|
242
|
+
const n = value as number
|
|
243
|
+
if (kind === 'integer' && !Number.isInteger(n))
|
|
244
|
+
issues.push({ path: at, kind: 'type', message: 'expected an integer' })
|
|
245
|
+
if (s.minimum !== undefined && n < s.minimum)
|
|
246
|
+
issues.push({ path: at, kind: 'range', message: `must be ≥ ${s.minimum}` })
|
|
247
|
+
if (s.maximum !== undefined && n > s.maximum)
|
|
248
|
+
issues.push({ path: at, kind: 'range', message: `must be ≤ ${s.maximum}` })
|
|
249
|
+
if (s.enum && !s.enum.includes(n))
|
|
250
|
+
issues.push({ path: at, kind: 'enum', message: `must be one of: ${s.enum.join(' · ')}` })
|
|
251
|
+
return issues
|
|
252
|
+
}
|
|
253
|
+
case 'boolean':
|
|
254
|
+
return got === 'boolean'
|
|
255
|
+
? []
|
|
256
|
+
: [{ path: at, kind: 'type', message: `expected boolean, got ${got}` }]
|
|
257
|
+
default:
|
|
258
|
+
return []
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export type KeyPath = (string | number)[]
|
|
263
|
+
|
|
264
|
+
/** A path such as "data.tags[0].id", the same format `validate` uses for issue paths. */
|
|
265
|
+
export const pathString = (p: KeyPath): string =>
|
|
266
|
+
p.reduce<string>(
|
|
267
|
+
(acc, k) => (typeof k === 'number' ? `${acc}[${k}]` : acc ? `${acc}.${k}` : String(k)),
|
|
268
|
+
'',
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* For every line of a JSON document, the path of the key/element that starts on it (or undefined).
|
|
273
|
+
* Tolerates incomplete or invalid JSON, so annotations keep working while the user types.
|
|
274
|
+
*/
|
|
275
|
+
export function linePaths(text: string): (KeyPath | undefined)[] {
|
|
276
|
+
interface Box {
|
|
277
|
+
type: 'obj' | 'arr'
|
|
278
|
+
path: KeyPath
|
|
279
|
+
key?: string
|
|
280
|
+
idx: number
|
|
281
|
+
}
|
|
282
|
+
const out: (KeyPath | undefined)[] = [undefined]
|
|
283
|
+
const stack: Box[] = []
|
|
284
|
+
let line = 0
|
|
285
|
+
const set = (p: KeyPath) => {
|
|
286
|
+
if (out[line] === undefined) out[line] = p
|
|
287
|
+
}
|
|
288
|
+
const top = () => stack[stack.length - 1]
|
|
289
|
+
const childPath = (b: Box | undefined): KeyPath | undefined =>
|
|
290
|
+
!b
|
|
291
|
+
? []
|
|
292
|
+
: b.type === 'arr'
|
|
293
|
+
? [...b.path, b.idx]
|
|
294
|
+
: b.key !== undefined
|
|
295
|
+
? [...b.path, b.key]
|
|
296
|
+
: undefined
|
|
297
|
+
|
|
298
|
+
for (let i = 0; i < text.length; i++) {
|
|
299
|
+
const c = text[i] as string
|
|
300
|
+
if (c === '\n') {
|
|
301
|
+
line++
|
|
302
|
+
out[line] = undefined
|
|
303
|
+
} else if (c === '"') {
|
|
304
|
+
let j = i + 1
|
|
305
|
+
let str = ''
|
|
306
|
+
while (j < text.length && text[j] !== '"') {
|
|
307
|
+
if (text[j] === '\\') {
|
|
308
|
+
str += text[j + 1] ?? ''
|
|
309
|
+
j += 2
|
|
310
|
+
} else str += text[j++]
|
|
311
|
+
}
|
|
312
|
+
let k = j + 1
|
|
313
|
+
while (k < text.length && /\s/.test(text[k] as string) && text[k] !== '\n') k++
|
|
314
|
+
const t = top()
|
|
315
|
+
if (t?.type === 'obj' && text[k] === ':') {
|
|
316
|
+
t.key = str
|
|
317
|
+
set([...t.path, str])
|
|
318
|
+
} else if (t?.type === 'arr') set([...t.path, t.idx])
|
|
319
|
+
i = j
|
|
320
|
+
} else if (c === '{' || c === '[') {
|
|
321
|
+
const t = top()
|
|
322
|
+
const p = childPath(t)
|
|
323
|
+
if (p) set(p)
|
|
324
|
+
stack.push({ type: c === '{' ? 'obj' : 'arr', path: p ?? [], idx: 0 })
|
|
325
|
+
} else if (c === '}' || c === ']') {
|
|
326
|
+
stack.pop()
|
|
327
|
+
const t = top()
|
|
328
|
+
if (t?.type === 'arr') t.idx++
|
|
329
|
+
} else if (c === ',') {
|
|
330
|
+
const t = top()
|
|
331
|
+
// object commas need no bookkeeping; scalar array elements advance the index where they end
|
|
332
|
+
void t
|
|
333
|
+
} else if (/[-\d]|[tfn]/.test(c)) {
|
|
334
|
+
const t = top()
|
|
335
|
+
if (t?.type === 'arr') {
|
|
336
|
+
set([...t.path, t.idx])
|
|
337
|
+
// scalar element: advance the index at the next comma
|
|
338
|
+
let j = i
|
|
339
|
+
while (j < text.length && !/[,\]\n]/.test(text[j] as string)) j++
|
|
340
|
+
if (text[j] === ',') t.idx++
|
|
341
|
+
i = j - 1
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return out
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function schemaAt(
|
|
349
|
+
root: JsonSchema | undefined,
|
|
350
|
+
path: KeyPath,
|
|
351
|
+
defs: Defs,
|
|
352
|
+
): JsonSchema | undefined {
|
|
353
|
+
let s = root
|
|
354
|
+
for (const k of path) {
|
|
355
|
+
if (!s) return undefined
|
|
356
|
+
s = childSchema(s, k, defs)
|
|
357
|
+
}
|
|
358
|
+
return s
|
|
359
|
+
}
|
package/src/secrets.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a static page keeps the credentials a person types in.
|
|
3
|
+
*
|
|
4
|
+
* Values are keyed by (API origin, secret name): a key typed for one API is never sent to another
|
|
5
|
+
* host, whatever a spec's `servers` says. The backend is `sessionStorage` (this tab only, gone when it
|
|
6
|
+
* closes) with an in-memory fallback; another backend (an encrypted store, say) can sit behind the same
|
|
7
|
+
* interface. Requests carry `{{secrets.NAME}}` placeholders until the transport sends them.
|
|
8
|
+
*/
|
|
9
|
+
const KEY = 'specbook.secrets.v1'
|
|
10
|
+
|
|
11
|
+
type Table = Record<string, Record<string, string>>
|
|
12
|
+
|
|
13
|
+
export interface SecretStore {
|
|
14
|
+
get(origin: string, name: string): string | undefined
|
|
15
|
+
set(origin: string, name: string, value: string): void
|
|
16
|
+
remove(origin: string, name: string): void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let memory: Table = {}
|
|
20
|
+
|
|
21
|
+
const read = (): Table => {
|
|
22
|
+
try {
|
|
23
|
+
const raw = sessionStorage.getItem(KEY)
|
|
24
|
+
return raw ? (JSON.parse(raw) as Table) : memory
|
|
25
|
+
} catch {
|
|
26
|
+
return memory
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const write = (t: Table) => {
|
|
31
|
+
memory = t
|
|
32
|
+
try {
|
|
33
|
+
sessionStorage.setItem(KEY, JSON.stringify(t))
|
|
34
|
+
} catch {
|
|
35
|
+
/* private mode or blocked storage: the in-memory copy still serves this page */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const secretStore: SecretStore = {
|
|
40
|
+
get: (origin, name) => read()[origin]?.[name],
|
|
41
|
+
set(origin, name, value) {
|
|
42
|
+
const t = read()
|
|
43
|
+
write({ ...t, [origin]: { ...t[origin], [name]: value } })
|
|
44
|
+
},
|
|
45
|
+
remove(origin, name) {
|
|
46
|
+
const t = read()
|
|
47
|
+
const { [name]: _gone, ...rest } = t[origin] ?? {}
|
|
48
|
+
write({ ...t, [origin]: rest })
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const originOf = (url: string | undefined): string | undefined => {
|
|
53
|
+
try {
|
|
54
|
+
return url ? new URL(url).origin : undefined
|
|
55
|
+
} catch {
|
|
56
|
+
return undefined
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `{{secrets.NAME}}`, the same placeholder the dev server's proxy substitutes. */
|
|
61
|
+
export const SECRET_PLACEHOLDER = /\{\{\s*secrets\.([\w$-]+)\s*\}\}/g
|
package/src/status.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const NAMES: Record<number, string> = {
|
|
2
|
+
200: 'OK',
|
|
3
|
+
201: 'Created',
|
|
4
|
+
202: 'Accepted',
|
|
5
|
+
204: 'No content',
|
|
6
|
+
301: 'Moved',
|
|
7
|
+
304: 'Not modified',
|
|
8
|
+
400: 'Bad request',
|
|
9
|
+
401: 'Unauthorized',
|
|
10
|
+
402: 'Payment required',
|
|
11
|
+
403: 'Forbidden',
|
|
12
|
+
404: 'Not found',
|
|
13
|
+
405: 'Invalid input',
|
|
14
|
+
409: 'Conflict',
|
|
15
|
+
410: 'Gone',
|
|
16
|
+
412: 'Precondition failed',
|
|
17
|
+
422: 'Invalid input',
|
|
18
|
+
429: 'Too many requests',
|
|
19
|
+
500: 'Server error',
|
|
20
|
+
502: 'Bad gateway',
|
|
21
|
+
503: 'Unavailable',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const statusName = (s: number) =>
|
|
25
|
+
NAMES[s] ?? (s >= 500 ? 'Server error' : s >= 400 ? 'Client error' : 'Response')
|
|
26
|
+
export const statusClass = (s: number) =>
|
|
27
|
+
s >= 500 ? 's5' : s >= 400 ? 's4' : s >= 300 ? 's3' : 's2'
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { signal } from '@preact/signals'
|
|
2
|
+
|
|
3
|
+
/** Bumped whenever sources may have changed; used to bust dynamic-import caches. */
|
|
4
|
+
export const version = signal(Date.now())
|
|
5
|
+
export const env = signal('mock')
|
|
6
|
+
export const environments = signal<string[]>(['mock'])
|
|
7
|
+
/** Environments the config marks `production` or `sandbox`; others are judged by their name. */
|
|
8
|
+
export const environmentRisk = signal<Record<string, 'production' | 'sandbox'>>({})
|
|
9
|
+
export const locale = signal<string | undefined>(undefined)
|
|
10
|
+
export const seed = signal<number | undefined>(undefined)
|