@specbook/ui 0.2.0 → 0.3.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.
Files changed (68) hide show
  1. package/dist-viewer/assets/Host-Cq-4gDuM.js +41 -0
  2. package/dist-viewer/assets/index-BBlFcQUE.js +1 -0
  3. package/dist-viewer/assets/main-CSkB-O_8.js +14 -0
  4. package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-DfwluJy4.js} +2 -2
  5. package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-9eYU7Gz6.js} +1 -1
  6. package/dist-viewer/assets/panel-hNOojwiM.css +1 -0
  7. package/dist-viewer/assets/registry-DIHX11uY.js +2 -0
  8. package/dist-viewer/assets/worker-BmvGpsJS.js +181 -0
  9. package/dist-viewer/index.html +4 -4
  10. package/dist-viewer/specbook.js +1 -1
  11. package/package.json +6 -5
  12. package/src/app.tsx +65 -12
  13. package/src/branding.ts +12 -4
  14. package/src/components/Catalog.tsx +128 -51
  15. package/src/components/EnvBadge.tsx +21 -3
  16. package/src/components/EnvironmentMenu.tsx +237 -127
  17. package/src/components/Fields.tsx +18 -1
  18. package/src/components/NewEnvironmentDialog.tsx +118 -0
  19. package/src/components/Palette.tsx +7 -2
  20. package/src/custom-environments.ts +63 -0
  21. package/src/embed/panel.tsx +286 -44
  22. package/src/embed/styles.ts +2 -0
  23. package/src/embed/target.ts +82 -0
  24. package/src/embed/theme.ts +75 -0
  25. package/src/embed.ts +44 -15
  26. package/src/engine.ts +274 -32
  27. package/src/environment-list.ts +16 -0
  28. package/src/main.tsx +7 -2
  29. package/src/nav.ts +12 -1
  30. package/src/oauth.ts +9 -8
  31. package/src/panel/Body.tsx +27 -4
  32. package/src/panel/Checks.tsx +79 -0
  33. package/src/panel/CodeSample.tsx +16 -9
  34. package/src/panel/Combo.tsx +52 -13
  35. package/src/panel/ControlsGrid.tsx +54 -9
  36. package/src/panel/ErrorBoundary.tsx +36 -0
  37. package/src/panel/Host.tsx +14 -2
  38. package/src/panel/OAuthSignIn.tsx +6 -2
  39. package/src/panel/Panel.tsx +43 -10
  40. package/src/panel/ParamGrid.tsx +15 -8
  41. package/src/panel/RequestPanel.tsx +91 -36
  42. package/src/panel/RequestView.tsx +2 -1
  43. package/src/panel/ResponseView.tsx +25 -14
  44. package/src/panel/SecretsBar.tsx +10 -9
  45. package/src/panel/SequenceDocs.tsx +7 -6
  46. package/src/panel/SequencePanel.tsx +124 -32
  47. package/src/panel/announce.ts +17 -0
  48. package/src/panel/icons.tsx +9 -0
  49. package/src/panel/short.ts +24 -0
  50. package/src/routing.ts +0 -9
  51. package/src/runtime.ts +19 -1
  52. package/src/secrets.ts +4 -2
  53. package/src/storage.ts +58 -0
  54. package/src/store.ts +8 -0
  55. package/src/styles/app.css +69 -6
  56. package/src/styles/embed.css +116 -0
  57. package/src/styles/panel.css +233 -0
  58. package/src/viewer/manifests.ts +2 -0
  59. package/src/viewer/protocol.ts +6 -0
  60. package/src/viewer/registry.ts +46 -10
  61. package/src/viewer/worker.ts +9 -1
  62. package/src/virtual.d.ts +8 -0
  63. package/dist-viewer/assets/Host-CSrM9wY1.js +0 -37
  64. package/dist-viewer/assets/index-ez9c20v3.js +0 -1
  65. package/dist-viewer/assets/main-38QGGpNT.js +0 -14
  66. package/dist-viewer/assets/panel-C-vBZrep.css +0 -1
  67. package/dist-viewer/assets/registry-ChKCFBt1.js +0 -2
  68. package/dist-viewer/assets/worker-ClGb0tE1.js +0 -181
package/src/embed.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { Target } from './embed/panel'
1
+ import { parseTarget, type Target } from './embed/target'
2
+ import { followTheme } from './embed/theme'
2
3
  import tokens from './styles/tokens.css?inline'
3
4
 
4
5
  const css = `button {
@@ -12,14 +13,27 @@ const css = `button {
12
13
  color: var(--sb-accent);
13
14
  }
14
15
  button:hover { background: var(--sb-surface-raised); }
15
- button:focus-visible { outline: 2px solid var(--sb-focus-ring); }`
16
+ button:focus-visible { outline: 2px solid var(--sb-focus-ring); }
17
+ @media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }`
18
+
19
+ let opened = false
16
20
 
17
21
  /** The panel (editor, grammars, runner) is a separate chunk fetched on first click. */
18
- async function open(target: Target) {
22
+ export async function open(target: Target, trigger?: HTMLElement): Promise<void> {
19
23
  const { show } = await import('./embed/panel')
20
- show(target)
24
+ opened = true
25
+ show(target, trigger)
26
+ }
27
+
28
+ /** Closes the panel, if it is open. */
29
+ export async function close(): Promise<void> {
30
+ if (!opened) return
31
+ const { close: closePanel } = await import('./embed/panel')
32
+ closePanel()
21
33
  }
22
34
 
35
+ const nameOf = (t: Target) => t.sequence ?? t.request ?? t.operation ?? ''
36
+
23
37
  class SpecbookPlay extends HTMLElement {
24
38
  target: Target = {}
25
39
 
@@ -33,29 +47,44 @@ class SpecbookPlay extends HTMLElement {
33
47
  b.type = 'button'
34
48
  b.part.add('play')
35
49
  b.textContent = '▶ Play'
36
- b.addEventListener('click', () => open(this.target))
50
+ b.setAttribute('aria-haspopup', 'dialog')
51
+ b.setAttribute('aria-expanded', 'false')
52
+ b.setAttribute('aria-label', `Play ${nameOf(this.target)}`.trim())
53
+ b.addEventListener('click', () => open(this.target, this))
37
54
  root.append(b)
55
+ followTheme(this, this.target.theme)
38
56
  }
39
57
  }
40
58
 
41
59
  customElements.define('specbook-play', SpecbookPlay)
42
60
 
61
+ /** A button or a link that carries the attribute is the trigger itself; anything else gets a Play button beside it. */
62
+ const isTrigger = (el: HTMLElement) => el.matches('button, a[href], [role="button"]')
63
+
43
64
  function attach(el: HTMLElement) {
44
- if (el.dataset.specbookReady) return
65
+ if (el.dataset.specbookReady !== undefined) return
45
66
  el.dataset.specbookReady = ''
46
- const play = document.createElement('specbook-play') as SpecbookPlay
47
- play.target = {
48
- service: el.dataset.specbook || undefined,
49
- operation: el.dataset.operation,
50
- sequence: el.dataset.sequence,
51
- example: el.dataset.example,
52
- tab: el.dataset.tab === 'docs' ? 'docs' : undefined,
67
+ const target = parseTarget(el)
68
+ if (!target) return
69
+ if (isTrigger(el)) {
70
+ el.setAttribute('aria-haspopup', 'dialog')
71
+ if (!el.hasAttribute('aria-expanded')) el.setAttribute('aria-expanded', 'false')
72
+ el.addEventListener('click', (e) => {
73
+ e.preventDefault()
74
+ open(target, el)
75
+ })
76
+ return
53
77
  }
78
+ const play = document.createElement('specbook-play') as SpecbookPlay
79
+ play.target = target
54
80
  el.append(play)
55
81
  }
56
82
 
57
- const scan = (root: ParentNode) =>
58
- root.querySelectorAll<HTMLElement>('[data-specbook]:not([data-specbook-ready])').forEach(attach)
83
+ const SELECTOR = ['try', 'flow']
84
+ .map((a) => `[data-specbook-${a}]:not([data-specbook-ready])`)
85
+ .join(',')
86
+
87
+ const scan = (root: ParentNode) => root.querySelectorAll<HTMLElement>(SELECTOR).forEach(attach)
59
88
 
60
89
  scan(document)
61
90
  // SPA docs add content after load
package/src/engine.ts CHANGED
@@ -1,14 +1,22 @@
1
1
  // Run orchestration for the UI. Pure TS (no DOM) so it can be unit-tested with a fake registry.
2
2
  import {
3
+ AddressError,
3
4
  type AuthProfile,
5
+ absoluteBase,
6
+ addressUses,
7
+ baseUrlFor,
4
8
  createFetchTransport,
5
9
  createMockTransport,
6
10
  type DiscoveredEndpoints,
7
11
  deepMerge,
8
12
  type Example,
13
+ environmentNames,
14
+ environmentScope,
9
15
  installFaker,
10
16
  isDiscovery,
17
+ type OAuthSettings,
11
18
  type OpMeta,
19
+ oauthFor,
12
20
  parseDiscovery,
13
21
  prepareFaker,
14
22
  type RequestDef,
@@ -20,9 +28,11 @@ import {
20
28
  type SpecbookConfig,
21
29
  type Transport,
22
30
  usesFaker,
31
+ withMock,
23
32
  } from '@specbook/core'
33
+ import type { EnvironmentStore } from './custom-environments'
24
34
  import { type ExtensionBridge, extensionTransport } from './extension'
25
- import { absoluteBase, routingTransport, withPageSecrets } from './routing'
35
+ import { routingTransport, withPageSecrets } from './routing'
26
36
  import { originOf, type SecretStore, secretStore } from './secrets'
27
37
 
28
38
  /**
@@ -75,7 +85,7 @@ export interface RunInput {
75
85
  version: number
76
86
  /** Forced mock status per run (mock env only). */
77
87
  mockStatus?: number
78
- params?: Record<string, unknown>
88
+ inputs?: Record<string, unknown>
79
89
  vars?: Record<string, unknown>
80
90
  onEvent?: (e: RunEvent) => void
81
91
  onInput?: Parameters<typeof runSequence>[1]['onInput']
@@ -87,13 +97,46 @@ export interface RunInput {
87
97
  fileDir?: string
88
98
  }
89
99
 
100
+ /** One variable of an environment as the settings show it. */
101
+ export interface VariableRow {
102
+ name: string
103
+ value: string
104
+ /** where the value comes from: the config's defaults or the environment's section, a file, this browser */
105
+ source: 'default' | 'config' | 'file' | 'browser'
106
+ /** what the variable falls back to when its own value is removed; undefined when it has no other */
107
+ fallback?: string
108
+ /** whether the value can be edited here */
109
+ editable: boolean
110
+ }
111
+
112
+ export interface EnvironmentView {
113
+ rows: VariableRow[]
114
+ /** where each service's calls go in this environment, or why they cannot */
115
+ addresses: { service: string; url?: string; error?: string }[]
116
+ /** which services' addresses and profiles' sign-in fields read each variable */
117
+ uses: ReturnType<typeof addressUses>
118
+ /** a browser-made environment: it can be deleted */
119
+ removable: boolean
120
+ }
121
+
90
122
  export interface EngineOptions {
91
123
  /** The bridge to the browser extension when it is installed and enabled for this page. */
92
124
  extension?: () => Promise<ExtensionBridge | undefined>
93
125
  /** Where the person's typed secrets are kept (the page's store by default). */
94
126
  secrets?: SecretStore
127
+ /** Environments made in this browser (a static page has no files to write them to). */
128
+ environmentStore?: EnvironmentStore
95
129
  }
96
130
 
131
+ const ENV_NAME = /^[A-Za-z0-9_-]+$/
132
+ const VAR_NAME = /^[A-Za-z_][\w.-]*$/
133
+ const hasTemplate = (v: unknown): boolean =>
134
+ typeof v === 'string'
135
+ ? /\{\{/.test(v)
136
+ : v !== null && typeof v === 'object' && Object.values(v).some(hasTemplate)
137
+ const text = (v: unknown) =>
138
+ v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '')
139
+
97
140
  export function createEngine(reg: Registry, opts: EngineOptions = {}) {
98
141
  const mockLoader = {
99
142
  statuses: (service: string, opId: string) =>
@@ -109,6 +152,11 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
109
152
  return (await reg.loadConfig(version)).default as SpecbookConfig
110
153
  }
111
154
 
155
+ const isStaticPage = () => reg.mode !== 'dev'
156
+ const browserEnvs = () => (isStaticPage() ? (opts.environmentStore?.all() ?? {}) : {})
157
+ // dev: environments created this session, until the registry reloads with their files
158
+ const createdHere = new Set<string>()
159
+
112
160
  /** Environments the config marks `production` or `sandbox`; the rest are judged by their name. */
113
161
  async function environmentRisks(
114
162
  version: number,
@@ -119,6 +167,9 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
119
167
  const risk = (e as { risk?: string } | undefined)?.risk
120
168
  if (risk === 'production' || risk === 'sandbox') out[name] = risk
121
169
  }
170
+ // a copy is as careful as what it was copied from
171
+ for (const [name, b] of Object.entries(browserEnvs()))
172
+ if (b.from && out[b.from] && !out[name]) out[name] = out[b.from] as 'production' | 'sandbox'
122
173
  return out
123
174
  }
124
175
 
@@ -130,20 +181,24 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
130
181
  const note = (e as { description?: string } | undefined)?.description
131
182
  if (note) out[name] = note
132
183
  }
184
+ for (const [name, b] of Object.entries(browserEnvs()))
185
+ if (b.from) out[name] = `copy of ${b.from}, kept in this browser`
133
186
  return out
134
187
  }
135
188
 
136
189
  async function environments(version: number): Promise<string[]> {
137
190
  const c = await config(version)
138
- const envs = new Set<string>(['mock'])
139
- for (const s of Object.values(c.services ?? {}))
140
- for (const e of Object.keys(s.baseUrl ?? {})) envs.add(e)
141
- for (const e of Object.keys(reg.environmentVars ?? {})) envs.add(e)
142
- for (const e of Object.keys(c.environments ?? {})) envs.add(e)
143
- return [...envs]
191
+ return environmentNames(
192
+ { services: c.services ?? {}, environments: c.environments },
193
+ [...Object.keys(reg.environmentVars ?? {}), ...createdHere],
194
+ Object.keys(browserEnvs()),
195
+ )
144
196
  }
145
197
 
146
- /** The environment's own variables: read live from the dev server, baked in for static builds. */
198
+ /**
199
+ * The environment's own variables: read live from the dev server; baked in for a static build, with
200
+ * the ones this browser holds over them.
201
+ */
147
202
  async function environmentVars(env: string): Promise<Record<string, string>> {
148
203
  if (reg.mode === 'dev') {
149
204
  try {
@@ -153,7 +208,166 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
153
208
  /* fall back to what was baked in */
154
209
  }
155
210
  }
156
- return reg.environmentVars?.[env] ?? {}
211
+ return { ...reg.environmentVars?.[env], ...browserEnvs()[env]?.vars }
212
+ }
213
+
214
+ /** What an address is rendered with: defaults, the environment's own settings, its file or browser copy. */
215
+ async function scopeOf(env: string, version: number): Promise<Record<string, unknown>> {
216
+ return environmentScope(await config(version), env, await environmentVars(env))
217
+ }
218
+
219
+ /** Where `service`'s calls go in `env`; undefined when it has no address there. Throws an AddressError. */
220
+ async function baseUrlOf(service: string, env: string, version: number) {
221
+ return baseUrlFor(await config(version), service, env, await scopeOf(env, version))
222
+ }
223
+
224
+ /** A profile's sign-in settings with its address fields rendered for `env`. */
225
+ async function oauthOf(
226
+ profile: string,
227
+ env: string,
228
+ version: number,
229
+ ): Promise<OAuthSettings | undefined> {
230
+ const oauth = ((await config(version)).auth?.[profile] as AuthProfile | undefined)?.oauth
231
+ // nothing to render, so nothing to ask the dev server for
232
+ if (!oauth || !hasTemplate(oauth)) return oauth
233
+ return oauthFor(oauth, await scopeOf(env, version), profile)
234
+ }
235
+
236
+ /**
237
+ * Addresses for a config the caller already holds (a page reading `engine.config` once for several
238
+ * services): the same rendering as `baseUrlOf` / `oauthOf`, with an address that cannot be worked out
239
+ * left undefined. The environment's variables are fetched only when something is written as a template.
240
+ */
241
+ async function addressesIn(cfg: SpecbookConfig, env: string) {
242
+ const templated =
243
+ Object.values(cfg.services ?? {}).some((s) => hasTemplate(s.baseUrl)) ||
244
+ Object.values(cfg.auth ?? {}).some((p) => hasTemplate((p as AuthProfile).oauth))
245
+ const scope = environmentScope(cfg, env, templated ? await environmentVars(env) : {})
246
+ return {
247
+ baseUrl(service: string): string | undefined {
248
+ try {
249
+ return baseUrlFor(cfg, service, env, scope)
250
+ } catch {
251
+ return undefined
252
+ }
253
+ },
254
+ oauth(profile: string): OAuthSettings | undefined {
255
+ const oauth = (cfg.auth?.[profile] as AuthProfile | undefined)?.oauth
256
+ try {
257
+ return oauth ? oauthFor(oauth, scope, profile) : undefined
258
+ } catch {
259
+ return undefined
260
+ }
261
+ },
262
+ }
263
+ }
264
+
265
+ /** The environment's variables as the settings show them, and where each service's calls go. */
266
+ async function environmentView(env: string, version: number): Promise<EnvironmentView> {
267
+ const c = await config(version)
268
+ const defaults = c.vars ?? {}
269
+ const section =
270
+ (c.environments?.[env] as { vars?: Record<string, unknown> } | undefined)?.vars ?? {}
271
+ const stored = browserEnvs()[env]
272
+ const baked = isStaticPage() ? (reg.environmentVars?.[env] ?? {}) : await environmentVars(env)
273
+ const browser = stored?.vars ?? {}
274
+ const names = [
275
+ ...new Set([
276
+ ...Object.keys(defaults),
277
+ ...Object.keys(section),
278
+ ...Object.keys(baked),
279
+ ...Object.keys(browser),
280
+ ]),
281
+ ]
282
+ const editable = reg.mode === 'dev' || !!opts.environmentStore
283
+ const rows: VariableRow[] = names.map((name) => {
284
+ const inherited = name in section ? section[name] : defaults[name]
285
+ const own = name in browser ? 'browser' : name in baked ? 'file' : undefined
286
+ const value = name in browser ? browser[name] : name in baked ? baked[name] : inherited
287
+ const fallbackSource = name in browser && name in baked ? baked[name] : inherited
288
+ return {
289
+ name,
290
+ value: text(value),
291
+ source: own ?? (name in section ? 'config' : 'default'),
292
+ fallback: own && fallbackSource !== undefined ? text(fallbackSource) : undefined,
293
+ editable,
294
+ }
295
+ })
296
+ const scope = environmentScope(c, env, { ...baked, ...browser })
297
+ const addresses: EnvironmentView['addresses'] = []
298
+ if (env !== 'mock')
299
+ for (const service of Object.keys(c.services ?? {})) {
300
+ try {
301
+ const url = baseUrlFor(c, service, env, scope)
302
+ addresses.push({ service, ...(url ? { url } : {}) })
303
+ } catch (e) {
304
+ if (!(e instanceof AddressError)) throw e
305
+ addresses.push({ service, error: e.message })
306
+ }
307
+ }
308
+ return { rows, addresses, uses: addressUses(c), removable: !!stored?.from }
309
+ }
310
+
311
+ /** Whether a person can make environments here: the dev server writes a file, a static page keeps it. */
312
+ const canCreateEnvironment = () => reg.mode === 'dev' || !!opts.environmentStore
313
+
314
+ /** A new environment holding a copy of `from`'s own variables (never its secrets). */
315
+ async function createEnvironment(name: string, from: string, version: number): Promise<void> {
316
+ if (!ENV_NAME.test(name))
317
+ throw new Error('Use letters, digits, "-" and "_" in an environment name')
318
+ if (name === 'mock' || (await environments(version)).includes(name))
319
+ throw new Error(`"${name}" already exists`)
320
+ if (reg.mode === 'dev') {
321
+ const r = await fetch('/__specbook/environment', {
322
+ method: 'POST',
323
+ headers: { 'content-type': 'application/json' },
324
+ body: JSON.stringify({ name, from }),
325
+ })
326
+ if (!r.ok)
327
+ throw new Error(((await r.json()) as { error?: string }).error ?? 'could not create')
328
+ createdHere.add(name)
329
+ return
330
+ }
331
+ if (!opts.environmentStore) throw new Error('This page has nowhere to keep a new environment')
332
+ const c = await config(version)
333
+ const section =
334
+ (c.environments?.[from] as { vars?: Record<string, unknown> } | undefined)?.vars ?? {}
335
+ const own = { ...section, ...(await environmentVars(from)) }
336
+ opts.environmentStore.save(name, {
337
+ from,
338
+ vars: Object.fromEntries(
339
+ Object.entries(own)
340
+ .filter(([, v]) => v !== null && typeof v !== 'object')
341
+ .map(([k, v]) => [k, String(v)]),
342
+ ),
343
+ })
344
+ }
345
+
346
+ /** Sets (or, with `null`, removes) one of an environment's own variables. */
347
+ async function setVariable(env: string, name: string, value: string | null): Promise<void> {
348
+ if (!VAR_NAME.test(name)) throw new Error(`"${name}" is not a variable name`)
349
+ if (reg.mode === 'dev') {
350
+ const r = await fetch('/__specbook/var', {
351
+ method: 'POST',
352
+ headers: { 'content-type': 'application/json' },
353
+ body: JSON.stringify({ env, name, value }),
354
+ })
355
+ if (!r.ok) throw new Error(((await r.json()) as { error?: string }).error ?? 'save failed')
356
+ return
357
+ }
358
+ const store = opts.environmentStore
359
+ if (!store) throw new Error('This page has nowhere to keep a variable')
360
+ const current = store.all()[env] ?? { vars: {} }
361
+ const vars = { ...current.vars }
362
+ if (value === null) delete vars[name]
363
+ else vars[name] = value
364
+ if (!current.from && Object.keys(vars).length === 0) store.remove(env)
365
+ else store.save(env, { ...current, vars })
366
+ }
367
+
368
+ /** Forgets an environment made in this browser. */
369
+ function deleteEnvironment(env: string): void {
370
+ opts.environmentStore?.remove(env)
157
371
  }
158
372
 
159
373
  /** Names of the variables the project already supplies in `env`: config, per-environment and env files. */
@@ -181,12 +395,21 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
181
395
  ): Promise<Transport> {
182
396
  if (env === 'mock') return createMockTransport({ loader: mockLoader })
183
397
  const cfg = await config(version)
398
+ // the environment's variables are only fetched when an address is written as a template
399
+ const templated = Object.values(cfg.services ?? {}).some((s) => hasTemplate(s.baseUrl))
400
+ const scope = templated ? await scopeOf(env, version) : {}
184
401
  const pageHref = globalThis.location?.href
185
402
  const pageOrigin = globalThis.location?.origin
186
403
  const baseOf = (service: string) => {
187
404
  if (extra[service]) return extra[service]
188
- const url = cfg.services?.[service]?.baseUrl?.[env]
189
- if (!url) throw new Error(`No base URL for ${service} in the "${env}" environment`)
405
+ let url: string | undefined
406
+ try {
407
+ url = baseUrlFor(cfg, service, env, scope)
408
+ } catch (e) {
409
+ if (e instanceof AddressError) throw new Error(e.message)
410
+ throw e
411
+ }
412
+ if (!url) throw new Error(`No base URL for ${service} in ${env}`)
190
413
  return absoluteBase(url, pageHref)
191
414
  }
192
415
  const hasProxy = reg.mode !== 'static'
@@ -275,7 +498,13 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
275
498
  signal?: AbortSignal
276
499
  }): Promise<SignInEndpoints> {
277
500
  if (i.env === 'mock') throw new Error('Sign in needs a live environment, not mock')
278
- const oauth = ((await config(i.version)).auth?.[i.profile] as AuthProfile | undefined)?.oauth
501
+ let oauth: OAuthSettings | undefined
502
+ try {
503
+ oauth = await oauthOf(i.profile, i.env, i.version)
504
+ } catch (e) {
505
+ if (e instanceof AddressError) throw new Error(e.message)
506
+ throw e
507
+ }
279
508
  if (!oauth) throw new Error(`"${i.profile}" has no sign-in configured`)
280
509
  if (!isDiscovery(oauth))
281
510
  return {
@@ -394,13 +623,9 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
394
623
  )
395
624
  return {
396
625
  env: i.env,
397
- vars: {
398
- ...(c.vars ?? {}),
399
- ...(c.environments?.[i.env]?.vars ?? {}),
400
- ...(await environmentVars(i.env)),
401
- },
626
+ vars: await scopeOf(i.env, i.version),
402
627
  overrides: i.vars,
403
- params: i.params,
628
+ inputs: i.inputs,
404
629
  transport: await transportFor(i.env, i.proxyBase ?? '/__specbook/proxy', i.version),
405
630
  // placeholders stay in the request: the dev server's proxy or a static page's transport fills them
406
631
  deferSecrets: true,
@@ -409,6 +634,8 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
409
634
  onEvent: i.onEvent,
410
635
  onInput: i.onInput,
411
636
  signal: i.signal,
637
+ // the project's own, else the runner's default
638
+ timeoutMs: c.timeoutMs,
412
639
  example: (op: { service: string; id: string }, name: string) =>
413
640
  loadExample(`${op.service}/${op.id}`, name, i.version),
414
641
  readFile: (path: string, dir?: string) => readFile(path, dir ?? i.fileDir),
@@ -433,27 +660,31 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
433
660
  return usesFaker([base, data, target.dataPatches, path, query, headers, i.vars])
434
661
  }
435
662
 
663
+ /**
664
+ * Makes faker ready for `locale` (a bundled module offers each locale as its own chunk, so only the ones it falls back
665
+ * through are fetched). Generated values need it before a run: a form that offers one asks for it when it opens.
666
+ */
667
+ async function fakerReady(version: number, locale?: string, seed?: number): Promise<boolean> {
668
+ if (!reg.loadFaker) return false
669
+ const [mod, cfg] = await Promise.all([reg.loadFaker(version), config(version)])
670
+ const ready = await prepareFaker(mod, cfg.faker, locale)
671
+ installFaker(ready as never, cfg.faker, { locale, seed })
672
+ return true
673
+ }
674
+
436
675
  async function run(
437
676
  target: RequestDef<any, any, any> | Sequence<any, any>,
438
677
  i: RunInput,
439
678
  ): Promise<RunResult> {
440
679
  // `init` generators call faker, so it must be ready before any run unless we can see it isn't used.
441
- if (reg.loadFaker && (await mayUseFaker(target, i))) {
442
- const [mod, cfg] = await Promise.all([reg.loadFaker(i.version), config(i.version)])
443
- // a bundled module offers each locale as its own chunk; fetch only the ones this run falls back through
444
- const ready = await prepareFaker(mod, cfg.faker, i.locale)
445
- installFaker(ready as never, cfg.faker, { locale: i.locale, seed: i.seed })
446
- }
680
+ if (reg.loadFaker && (await mayUseFaker(target, i)))
681
+ await fakerReady(i.version, i.locale, i.seed)
447
682
  const o = await baseOptions(i)
448
683
  if (target.kind === 'sequence') return runSequence(target, o)
449
- const def =
450
- i.mockStatus !== undefined && i.env === 'mock'
451
- ? target.with({ mock: { status: i.mockStatus } })
452
- : target
453
- return runRequest(def, o)
684
+ return runRequest(target, i.env === 'mock' ? { ...o, mockStatus: i.mockStatus } : o)
454
685
  }
455
686
 
456
- /** `base` keeps its init/capture/assert/mock; `overrides` replace path/query/headers/data. */
687
+ /** `base` keeps its init/capture/assert and mock hook; `overrides` replace path/query/headers/data. */
457
688
  async function operationRequest(
458
689
  key: string,
459
690
  version: number,
@@ -466,7 +697,8 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
466
697
  const op = mod[key.split('/')[1] as string]
467
698
  if (!op) throw new Error(`Operation module for ${key} has no export`)
468
699
  const defined = Object.fromEntries(Object.entries(overrides).filter(([, v]) => v !== undefined))
469
- return op.request({ ...(base?.input ?? {}), ...defined })
700
+ const made = op.request({ ...(base?.input ?? {}), ...defined })
701
+ return base?.mockHook ? withMock(made, base.mockHook) : made
470
702
  }
471
703
 
472
704
  async function operationMeta(key: string, version: number): Promise<OpMeta | undefined> {
@@ -540,10 +772,20 @@ export function createEngine(reg: Registry, opts: EngineOptions = {}) {
540
772
  environments,
541
773
  environmentRisks,
542
774
  environmentNotes,
775
+ environmentVars,
776
+ environmentView,
777
+ canCreateEnvironment,
778
+ createEnvironment,
779
+ deleteEnvironment,
780
+ setVariable,
781
+ baseUrlOf,
782
+ oauthOf,
783
+ addressesIn,
543
784
  managedSecrets,
544
785
  oauthEndpoints,
545
786
  oauthExchange,
546
787
  run,
788
+ fakerReady,
547
789
  operationRequest,
548
790
  operationMeta,
549
791
  examplesOf,
@@ -0,0 +1,16 @@
1
+ import { engine } from './runtime'
2
+ import { environmentNote, environmentRisk, environments, envVarsVersion, version } from './store'
3
+
4
+ /** Reads the environment list, risks and notes again (after one is made or removed) and tells dependents. */
5
+ export async function refreshEnvironments(): Promise<void> {
6
+ const v = version.value
7
+ const [names, risks, notes] = await Promise.all([
8
+ engine.environments(v),
9
+ engine.environmentRisks(v),
10
+ engine.environmentNotes(v),
11
+ ])
12
+ environments.value = names
13
+ environmentRisk.value = risks
14
+ environmentNote.value = notes
15
+ envVarsVersion.value += 1
16
+ }
package/src/main.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { loadManifests } from 'virtual:specbook/registry'
1
2
  import { render } from 'preact'
2
3
  import { App } from './app'
3
4
  import { handleOAuthCallback } from './oauth-callback'
@@ -15,8 +16,12 @@ applyTheme()
15
16
  // would strip a host page's own `?code=&state=` that the embed deliberately left alone.
16
17
  const underShell =
17
18
  (globalThis as { __SPECBOOK_VIEWER__?: unknown }).__SPECBOOK_VIEWER__ !== undefined
18
- void (underShell ? Promise.resolve(false) : handleOAuthCallback()).then((handled) => {
19
- if (!handled) render(<App />, document.getElementById('app') as HTMLElement)
19
+ void (underShell ? Promise.resolve(false) : handleOAuthCallback()).then(async (handled) => {
20
+ if (handled) return
21
+ // a built project starts with its services' lists not loaded: fetch them, then draw the app. The app's own code
22
+ // is part of this page's static imports, so it loads alongside, not after (an embed fetches only the lists it needs)
23
+ await loadManifests()
24
+ render(<App />, document.getElementById('app') as HTMLElement)
20
25
  })
21
26
 
22
27
  if (import.meta.hot) {
package/src/nav.ts CHANGED
@@ -70,7 +70,15 @@ export const paletteOpen = signal(false)
70
70
  export const catalogService = signal('')
71
71
  export const catalogFilter = signal('')
72
72
 
73
+ let navigator: ((r: Route) => void) | undefined
74
+
75
+ /** A host that is not the app (an embed) takes links itself instead of writing the page's address. */
76
+ export const setNavigator = (fn: ((r: Route) => void) | undefined) => {
77
+ navigator = fn
78
+ }
79
+
73
80
  export function go(r: Route) {
81
+ if (navigator) return navigator(r)
74
82
  location.hash = hashFor(r)
75
83
  route.value = r
76
84
  }
@@ -88,13 +96,15 @@ export interface Entry {
88
96
  /** the spec requires credentials */
89
97
  secured?: boolean
90
98
  /** sequences */
99
+ /** flows and requests */
91
100
  tags?: string[]
92
101
  /** the flow group heading */
93
102
  flowGroup?: string
94
103
  description?: string
95
104
  /** extra text matched by search, never shown */
96
105
  hay: string
97
- kind: 'seq' | 'op'
106
+ /** `req`: a saved request, which opens its operation on that request */
107
+ kind: 'seq' | 'op' | 'req'
98
108
  }
99
109
 
100
110
  export function search(entries: Entry[], query: string, limit = 40): Entry[] {
@@ -109,6 +119,7 @@ export function search(entries: Entry[], query: string, limit = 40): Entry[] {
109
119
  let score = 0
110
120
  for (const t of terms) score += title.startsWith(t) ? 3 : title.includes(t) ? 2 : 1
111
121
  if (e.kind === 'seq') score += 0.5
122
+ if (e.kind === 'req') score += 0.4
112
123
  scored.push([score, e])
113
124
  }
114
125
  return scored