@specbook/ui 0.3.0 → 0.5.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.
@@ -7,10 +7,10 @@
7
7
  <title>Specbook</title>
8
8
  <link rel="icon" href="./favicon.svg" type="image/svg+xml" />
9
9
  <link rel="apple-touch-icon" href="./apple-touch-icon.png" />
10
- <script type="module" crossorigin src="./assets/index-BBlFcQUE.js"></script>
11
- <link rel="modulepreload" crossorigin href="./assets/panel-9eYU7Gz6.js">
12
- <link rel="modulepreload" crossorigin href="./assets/mount-DfwluJy4.js">
13
- <link rel="stylesheet" crossorigin href="./assets/panel-hNOojwiM.css">
10
+ <script type="module" crossorigin src="./assets/index-BcHcUWbc.js"></script>
11
+ <link rel="modulepreload" crossorigin href="./assets/panel-nV09I7Wr.js">
12
+ <link rel="modulepreload" crossorigin href="./assets/mount-D3holu10.js">
13
+ <link rel="stylesheet" crossorigin href="./assets/panel-l1xvY4kI.css">
14
14
  <link rel="stylesheet" crossorigin href="./assets/index-Ds6DZpGP.css">
15
15
  </head>
16
16
  <body>
@@ -1 +1 @@
1
- import{a as e,r as t,t as n}from"./assets/mount-DfwluJy4.js";function r(){let e=document.getElementById(`specbook-config`);if(e?.textContent)try{return JSON.parse(e.textContent)}catch{}let t=document.querySelector(`script[src$="specbook.js"][data-spec]`);return{spec:t?.dataset.spec,baseUrl:t?.dataset.baseUrl}}function i(e){let t=e?document.querySelector(e):document.getElementById(`app`);if(t)return t;let n=document.createElement(`div`);return n.id=`app`,document.body.append(n),n}var a=(e,t,n=!1)=>{e.textContent=t,e.style.cssText=`font:16px system-ui,sans-serif;padding:24px;color:${n?`#b42318`:`#5b6270`}`};async function o(){if(await e({stripWhenUnclaimed:!1}))return;let o=r(),s=i(o.target);if(!o.spec)return a(s,`Specbook: no spec was given (set "spec" in #specbook-config).`,!0);let c;try{c=new URL(o.spec,location.href).toString()}catch{return a(s,`Specbook: "${o.spec}" is not a valid address.`,!0)}a(s,`Loading the API…`);let l=new t;try{let e=await l.open({kind:`url`,url:c}).promise;s.textContent=``,s.removeAttribute(`style`),await n({result:e,worker:l,source:{kind:`url`,url:c},baseUrl:o.baseUrl,shareable:!1,setTitle:!1})}catch(e){a(s,`Specbook: ${e instanceof Error?e.message:String(e)}`,!0)}}o();
1
+ import{a as e,r as t,t as n}from"./assets/mount-D3holu10.js";function r(){let e=document.getElementById(`specbook-config`);if(e?.textContent)try{return JSON.parse(e.textContent)}catch{}let t=document.querySelector(`script[src$="specbook.js"][data-spec]`);return{spec:t?.dataset.spec,baseUrl:t?.dataset.baseUrl,defaultEnv:t?.dataset.defaultEnv}}function i(e){let t=e?document.querySelector(e):document.getElementById(`app`);if(t)return t;let n=document.createElement(`div`);return n.id=`app`,document.body.append(n),n}var a=(e,t,n=!1)=>{e.textContent=t,e.style.cssText=`font:16px system-ui,sans-serif;padding:24px;color:${n?`#b42318`:`#5b6270`}`};async function o(){if(await e({stripWhenUnclaimed:!1}))return;let o=r(),s=i(o.target);if(!o.spec)return a(s,`Specbook: no spec was given (set "spec" in #specbook-config).`,!0);let c;try{c=new URL(o.spec,location.href).toString()}catch{return a(s,`Specbook: "${o.spec}" is not a valid address.`,!0)}a(s,`Loading the API…`);let l=new t;try{let e=await l.open({kind:`url`,url:c}).promise;s.textContent=``,s.removeAttribute(`style`),await n({result:e,worker:l,source:{kind:`url`,url:c},baseUrl:o.baseUrl,defaultEnv:typeof o.defaultEnv==`string`?o.defaultEnv:void 0,shareable:!1,setTitle:!1})}catch(e){a(s,`Specbook: ${e instanceof Error?e.message:String(e)}`,!0)}}o();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specbook/ui",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "The Specbook docs app, spec viewer and embeddable specbook.js.",
5
5
  "keywords": [
6
6
  "specbook",
@@ -29,14 +29,14 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@preact/signals": "2.11.3",
32
- "@specbook/codegen": "0.3.0",
33
- "@specbook/core": "0.3.0",
32
+ "@specbook/codegen": "0.5.0",
33
+ "@specbook/core": "0.5.0",
34
34
  "preact": "11.0.1",
35
35
  "prism-code-editor": "^5.4.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@faker-js/faker": "9.3.0",
39
- "@specbook/vite": "0.3.0",
39
+ "@specbook/vite": "0.5.0",
40
40
  "@vitest/browser-playwright": "5.0.3",
41
41
  "playwright": "1.64.0",
42
42
  "typescript": "7.0.2",
package/src/app.tsx CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  environmentNote,
20
20
  environmentRisk,
21
21
  environments,
22
+ fakerInUse,
22
23
  locale,
23
24
  newEnvironmentOpen,
24
25
  seed,
@@ -108,6 +109,8 @@ export function App() {
108
109
  .finally(loadRest)
109
110
  engine.environments(version.value).then((e) => {
110
111
  environments.value = e
112
+ const first = globalThis.__SPECBOOK_VIEWER__?.defaultEnv
113
+ if (first && e.includes(first)) env.value = first
111
114
  })
112
115
  engine.environmentRisks(version.value).then((r) => {
113
116
  environmentRisk.value = r
@@ -289,12 +292,17 @@ export function App() {
289
292
  {registry.loadFaker && (
290
293
  <span
291
294
  class="row muted"
292
- title="Generated values such as {{faker.person.fullName}}: new on every run, or fixed so the same number always gives the same data"
295
+ title={
296
+ fakerInUse.value
297
+ ? 'Generated values such as {{faker.person.fullName}}: new on every run, or fixed so the same number always gives the same data. `specbook test --seed` reproduces a run.'
298
+ : 'Applies when the open request or flow uses {{faker.*}}'
299
+ }
293
300
  >
294
301
  <label class="row">
295
- Synthetic data
302
+ Generated values
296
303
  <select
297
- aria-label="Synthetic data"
304
+ aria-label="Generated values"
305
+ disabled={!fakerInUse.value}
298
306
  value={seed.value === undefined ? 'random' : 'fixed'}
299
307
  onChange={(e) => {
300
308
  const fixed = (e.target as HTMLSelectElement).value === 'fixed'
@@ -311,6 +319,7 @@ export function App() {
311
319
  class="seed-input"
312
320
  aria-label="Seed number"
313
321
  title="The same number gives the same data every run"
322
+ disabled={!fakerInUse.value}
314
323
  value={seed.value}
315
324
  onInput={(e) => {
316
325
  const n = (e.target as HTMLInputElement).valueAsNumber
package/src/branding.ts CHANGED
@@ -87,7 +87,8 @@ export function applyBranding(
87
87
  }
88
88
  style.textContent = cfg ? brandCss(cfg) : ''
89
89
  const name = resolved.name ?? fallbackName
90
- document.title = resolved.title ? `${name} · ${resolved.title}` : name
90
+ if (globalThis.__SPECBOOK_VIEWER__?.setTitle !== false)
91
+ document.title = resolved.title ? `${name} · ${resolved.title}` : name
91
92
  if (resolved.favicon || resolved.logo) {
92
93
  let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
93
94
  if (!link) {
package/src/engine.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  runSequence,
27
27
  type Sequence,
28
28
  type SpecbookConfig,
29
+ setFaker,
29
30
  type Transport,
30
31
  usesFaker,
31
32
  withMock,
@@ -75,6 +76,8 @@ export interface Registry {
75
76
  loadConfig: (v: number) => Promise<{ default: any }>
76
77
  /** Project's @faker-js/faker (ESM), when installed. */
77
78
  loadFaker?: ((v: number) => Promise<Record<string, any>>) | null
79
+ /** Said when a run uses `{{faker.*}}` and there is no faker (the hosted viewer ships none). */
80
+ fakerUnavailable?: string
78
81
  mode?: 'dev' | 'static'
79
82
  /** variables from `.specbook/environments/<env>.env`, baked in at build time */
80
83
  environmentVars?: Record<string, Record<string, string>>
@@ -138,6 +141,20 @@ const text = (v: unknown) =>
138
141
  v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '')
139
142
 
140
143
  export function createEngine(reg: Registry, opts: EngineOptions = {}) {
144
+ // no faker to load: a `{{faker.*}}` ends the run with the registry's message, not "install @faker-js/faker"
145
+ if (!reg.loadFaker && reg.fakerUnavailable) {
146
+ const message = reg.fakerUnavailable
147
+ setFaker(
148
+ new Proxy(
149
+ {},
150
+ {
151
+ get() {
152
+ throw new Error(message)
153
+ },
154
+ },
155
+ ),
156
+ )
157
+ }
141
158
  const mockLoader = {
142
159
  statuses: (service: string, opId: string) =>
143
160
  reg.manifests[service]?.operations.find((o) => o.id === opId)?.statuses ?? [],
@@ -60,7 +60,7 @@ function SavedRequestNote({ variant, html }: { variant: Variant; html?: string }
60
60
  return (
61
61
  <section class="saved-note-doc">
62
62
  <h2>
63
- Saved request <code>{variant.name}</code>
63
+ Request <code>{variant.name}</code>
64
64
  </h2>
65
65
  <div class="doc-custom" dangerouslySetInnerHTML={{ __html: html }} />
66
66
  </section>
@@ -1,4 +1,10 @@
1
- import { collectPlaceholders, type Encoding, type Example, type RunResult } from '@specbook/core'
1
+ import {
2
+ collectPlaceholders,
3
+ type Encoding,
4
+ type Example,
5
+ type RunResult,
6
+ rootName,
7
+ } from '@specbook/core'
2
8
  import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
9
  import { EnvBadge } from '../components/EnvBadge'
4
10
  import type { Variant } from '../engine'
@@ -7,7 +13,16 @@ import { engine, isStatic, registry, saveFile } from '../runtime'
7
13
  import { type Defs, type JsonSchema, propsOf, unwrap } from '../schema'
8
14
  import { statusClass, statusName } from '../status'
9
15
  import { kv } from '../storage'
10
- import { env, envSettingsOpen, envVarsVersion, features, locale, seed, version } from '../store'
16
+ import {
17
+ env,
18
+ envSettingsOpen,
19
+ envVarsVersion,
20
+ fakerInUse,
21
+ features,
22
+ locale,
23
+ seed,
24
+ version,
25
+ } from '../store'
11
26
  import { announcement, describeRun } from './announce'
12
27
  import { Body } from './Body'
13
28
  import { DraftCodeSample } from './CodeSample'
@@ -235,13 +250,36 @@ export function RequestPanel({ service, op, variants, initial, autoRun }: Props)
235
250
  .filter(
236
251
  (n) => !n.startsWith('faker.') && !n.startsWith('secrets.') && !n.startsWith('session.'),
237
252
  )
238
- .map((n) => n.split('.')[0] as string)
253
+ .map(rootName)
239
254
  .filter((n, i, a) => a.indexOf(n) === i)
240
255
  } catch {
241
256
  return []
242
257
  }
243
258
  }, [draft, encoding])
244
259
 
260
+ // the header's Generated values control applies while this request can use faker: a `{{faker.*}}` in what is
261
+ // typed, or an `init`/`data` function in a saved request
262
+ const usesFakerNow = useMemo(() => {
263
+ const input = saved?.def.input
264
+ if (Object.values(input?.init ?? {}).some((v) => typeof v === 'function')) return true
265
+ if (typeof input?.data === 'function') return true
266
+ if (!draft) return false
267
+ try {
268
+ const parts = requestParts(draft, encoding)
269
+ return [
270
+ ...collectPlaceholders([parts.path, parts.query, parts.headers, parts.data, vars]),
271
+ ].some((n) => n.startsWith('faker.'))
272
+ } catch {
273
+ return false
274
+ }
275
+ }, [saved, draft, encoding, vars])
276
+ useEffect(() => {
277
+ fakerInUse.value = usesFakerNow
278
+ return () => {
279
+ fakerInUse.value = false
280
+ }
281
+ }, [usesFakerNow])
282
+
245
283
  // placeholders nothing supplies: not in config or the environment's files. They stay listed while
246
284
  // you type, so the field you are filling does not disappear under you.
247
285
  // a saved request brings the values of its own `init`: they are what a run uses, so nothing is missing
@@ -435,7 +473,7 @@ export function RequestPanel({ service, op, variants, initial, autoRun }: Props)
435
473
  </option>
436
474
  ))}
437
475
  {variants.map((v) => (
438
- <option value={`saved:${v.file}`}>{v.name} · saved request</option>
476
+ <option value={`saved:${v.file}`}>{v.name} · request</option>
439
477
  ))}
440
478
  </select>
441
479
  </label>
@@ -520,7 +558,9 @@ export function RequestPanel({ service, op, variants, initial, autoRun }: Props)
520
558
 
521
559
  {saved && (
522
560
  <p class="muted lock">
523
- A saved request: its own values, checks and mock hook apply, and it is read-only here.
561
+ A request from your project: it runs with its own values and checks. Edit its file to
562
+ change it.
563
+ {env.value === 'mock' && saved.def.mockHook ? ' Its mock hook shapes the answer.' : ''}
524
564
  </p>
525
565
  )}
526
566
 
@@ -4,7 +4,7 @@ import { EnvBadge } from '../components/EnvBadge'
4
4
  import { defaultFor, type FieldSpec, specFromControl } from '../components/Fields'
5
5
  import type { SequenceInfo } from '../engine'
6
6
  import { engine } from '../runtime'
7
- import { env, locale, seed, version } from '../store'
7
+ import { env, fakerInUse, locale, seed, version } from '../store'
8
8
  import { announcement, describeRun } from './announce'
9
9
  import { Failure } from './Checks'
10
10
  import { ControlsGrid } from './ControlsGrid'
@@ -80,6 +80,13 @@ export function SequencePanel({
80
80
  // a flow with inputs can run on their defaults alone, as `specbook test` does
81
81
  const hasInputs = Object.keys(specs).length > 0 || seq.nodes.some((n) => n.kind === 'input')
82
82
 
83
+ useEffect(() => {
84
+ fakerInUse.value = hasGenerated
85
+ return () => {
86
+ fakerInUse.value = false
87
+ }
88
+ }, [hasGenerated])
89
+
83
90
  /** Every input at its default; a generated one needs faker, which is made ready first. */
84
91
  const freshInputs = async () => {
85
92
  if (hasGenerated)
package/src/store.ts CHANGED
@@ -10,6 +10,8 @@ export const environmentRisk = signal<Record<string, 'production' | 'sandbox'>>(
10
10
  export const environmentNote = signal<Record<string, string>>({})
11
11
  export const locale = signal<string | undefined>(undefined)
12
12
  export const seed = signal<number | undefined>(undefined)
13
+ /** Whether the request or flow that is open can use generated values; the header's control is enabled while it is. */
14
+ export const fakerInUse = signal(false)
13
15
  /** Bumped when an environment's variables change, so what is derived from them (addresses, origins) is redone. */
14
16
  export const envVarsVersion = signal(0)
15
17
  /** Whether Environment settings is open; set from elsewhere to point a person at it. */
@@ -684,6 +684,19 @@
684
684
  text-overflow: ellipsis;
685
685
  white-space: nowrap;
686
686
  }
687
+ /* room for it: a name of ordinary length stays whole and the search field gives way instead, while a
688
+ longer one still ends in an ellipsis. Narrower than this only the logo is left. */
689
+ @media (min-width: 360px) {
690
+ .brand {
691
+ flex: 0 0 auto;
692
+ }
693
+ .brand-name {
694
+ max-width: 9ch;
695
+ }
696
+ .header .palette-btn {
697
+ min-width: 64px;
698
+ }
699
+ }
687
700
  .palette-btn {
688
701
  flex: 1;
689
702
  min-width: 0;
@@ -736,7 +749,7 @@
736
749
  width: 56%;
737
750
  min-width: 0;
738
751
  }
739
- /* Synthetic data is a row inside a row (its label and, once fixed, a seed field): the inner one fills it */
752
+ /* Generated values is a row inside a row (its label and, once fixed, a seed field): the inner one fills it */
740
753
  .header-controls .row > .row {
741
754
  flex: 1 1 100%;
742
755
  }
@@ -1971,12 +1971,10 @@
1971
1971
  .panel-head .spacer {
1972
1972
  display: none;
1973
1973
  }
1974
- .panel-head label.row > .muted {
1974
+ /* the Environment badge in the panel is the one switch on a phone: a bare, unlabeled copy here only confuses */
1975
+ .panel-head label.row {
1975
1976
  display: none;
1976
1977
  }
1977
- .panel-head select {
1978
- max-width: 34%;
1979
- }
1980
1978
  .stepper {
1981
1979
  gap: 0;
1982
1980
  }
@@ -7,7 +7,8 @@
7
7
  * <script type="module" src="https://ui.specbook.dev/v1/specbook.js"></script>
8
8
  *
9
9
  * The script takes the page over (it owns the document, as `/docs` does). `spec` may be relative.
10
- * `baseUrl` gives the API address when the document has none, `target` names another container.
10
+ * `baseUrl` gives the API address when the document has none, `target` names another container, and
11
+ * `defaultEnv` (`"live"` or the name of a server) picks the environment shown first instead of `mock`.
11
12
  */
12
13
  import { handleOAuthCallback } from '../oauth-callback'
13
14
  import { SpecWorker } from './client'
@@ -17,6 +18,7 @@ interface EmbedConfig {
17
18
  spec?: string
18
19
  baseUrl?: string
19
20
  target?: string
21
+ defaultEnv?: string
20
22
  }
21
23
 
22
24
  function readConfig(): EmbedConfig {
@@ -29,7 +31,11 @@ function readConfig(): EmbedConfig {
29
31
  }
30
32
  }
31
33
  const script = document.querySelector<HTMLScriptElement>('script[src$="specbook.js"][data-spec]')
32
- return { spec: script?.dataset.spec, baseUrl: script?.dataset.baseUrl }
34
+ return {
35
+ spec: script?.dataset.spec,
36
+ baseUrl: script?.dataset.baseUrl,
37
+ defaultEnv: script?.dataset.defaultEnv,
38
+ }
33
39
  }
34
40
 
35
41
  function container(sel?: string): HTMLElement {
@@ -72,6 +78,7 @@ async function start() {
72
78
  worker,
73
79
  source: { kind: 'url', url },
74
80
  baseUrl: cfg.baseUrl,
81
+ defaultEnv: typeof cfg.defaultEnv === 'string' ? cfg.defaultEnv : undefined,
75
82
  shareable: false,
76
83
  setTitle: false,
77
84
  })
@@ -12,6 +12,7 @@ export async function mountOpened(o: {
12
12
  worker: SpecWorker
13
13
  source: Source
14
14
  baseUrl?: string
15
+ defaultEnv?: string
15
16
  shareable?: boolean
16
17
  setTitle?: boolean
17
18
  }): Promise<void> {
@@ -19,6 +20,8 @@ export async function mountOpened(o: {
19
20
  const g: ViewerGlobal = {
20
21
  open: o.result,
21
22
  baseUrl: o.baseUrl,
23
+ defaultEnv: o.defaultEnv,
24
+ setTitle: o.setTitle,
22
25
  source:
23
26
  o.shareable === false
24
27
  ? undefined
@@ -31,22 +34,4 @@ export async function mountOpened(o: {
31
34
  globalThis.__SPECBOOK_VIEWER__ = g
32
35
  if (o.setTitle !== false) document.title = `${o.result.title} · Specbook`
33
36
  await import('../main')
34
- warmFaker()
35
- }
36
-
37
- /**
38
- * Mock responses are generated with the spec, so the first mock request needs no faker. Faker (a
39
- * sizeable chunk) is only used by "Dynamic" examples and `{{faker.*}}` values, so it is fetched when the
40
- * page has been idle for a moment instead of up front: by the time someone picks Dynamic it is cached.
41
- */
42
- function warmFaker() {
43
- if ((navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData)
44
- return
45
- const idle: (f: () => void) => void =
46
- (window as unknown as { requestIdleCallback?: (f: () => void) => void }).requestIdleCallback ??
47
- ((f) => setTimeout(f, 1500))
48
- idle(() => {
49
- // the registry's loader is the same import the run would make
50
- void import('./registry').then((r) => r.loadFaker()).catch(() => {})
51
- })
52
37
  }
@@ -12,6 +12,10 @@ export interface ViewerGlobal {
12
12
  definitions(): Promise<Record<string, any>>
13
13
  /** the base URL the person gave, when the document has none of its own */
14
14
  baseUrl?: string
15
+ /** the environment to show first, when it is one the document offers (otherwise `mock`) */
16
+ defaultEnv?: string
17
+ /** false on a page that is not the viewer's own (an embed), where the host's title stays */
18
+ setTitle?: boolean
15
19
  /** where the document came from, which decides whether a link to this view can be shared */
16
20
  source?: import('./share').ViewerSource
17
21
  }
@@ -124,18 +128,12 @@ export const operations = Object.fromEntries(
124
128
  ) as Record<string, () => Promise<Record<string, any>>>
125
129
 
126
130
  /**
127
- * Faker, English only: a locale is its own chunk in a project build, but a viewer opening a stranger's
128
- * spec has no reason to ship the others. Loaded only when a run uses `{{faker.*}}`.
131
+ * The hosted viewer and the embed ship no faker: a spec's mock and example values come from the generator, and a
132
+ * `{{faker.*}}` someone types is refused with this message instead of being sent as it is.
129
133
  */
130
- export const loadFaker = () =>
131
- import('./faker-en').then((m) => ({
132
- Faker: m.Faker,
133
- // a run asks for its locale and `base`, the fallback every locale chain ends in
134
- locales: {
135
- en: () => Promise.resolve(m.en),
136
- base: () => Promise.resolve(m.base),
137
- },
138
- }))
134
+ export const loadFaker = null
135
+ export const fakerUnavailable =
136
+ 'Generated values ({{faker.…}}) work in a Specbook project, not in the hosted viewer. Type a value instead.'
139
137
 
140
138
  // `live` is the document's first server, or the address the person typed for it; the rest keep their own
141
139
  // names. Each address is the variable `baseUrl` in its environment, so a copy of one can point elsewhere.
@@ -185,8 +183,6 @@ export const loadConfig = () =>
185
183
  auth: open.auth,
186
184
  ...(Object.keys(vars).length ? { vars } : {}),
187
185
  ...(Object.keys(environmentSettings).length ? { environments: environmentSettings } : {}),
188
- // one locale, so the header offers no locale choice
189
- faker: { default: 'en', locales: { en: ['en', 'base'] } },
190
186
  services: {
191
187
  [service]: {
192
188
  spec: '',