@specbook/ui 0.2.0 → 0.4.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 (71) hide show
  1. package/dist-viewer/assets/Host-DsO9W5X8.js +41 -0
  2. package/dist-viewer/assets/index-D59fkVA9.js +1 -0
  3. package/dist-viewer/assets/main-i06Egg5h.js +14 -0
  4. package/dist-viewer/assets/{mount-CGL2tOpN.js → mount-CLHBtIzR.js} +2 -2
  5. package/dist-viewer/assets/panel-l1xvY4kI.css +1 -0
  6. package/dist-viewer/assets/{panel-C8Ve3LeS.js → panel-nV09I7Wr.js} +1 -1
  7. package/dist-viewer/assets/registry-b_jzfqy0.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 +67 -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/OperationDocs.tsx +1 -1
  40. package/src/panel/Panel.tsx +43 -10
  41. package/src/panel/ParamGrid.tsx +15 -8
  42. package/src/panel/RequestPanel.tsx +102 -39
  43. package/src/panel/RequestView.tsx +2 -1
  44. package/src/panel/ResponseView.tsx +25 -14
  45. package/src/panel/SecretsBar.tsx +10 -9
  46. package/src/panel/SequenceDocs.tsx +7 -6
  47. package/src/panel/SequencePanel.tsx +124 -32
  48. package/src/panel/announce.ts +17 -0
  49. package/src/panel/icons.tsx +9 -0
  50. package/src/panel/short.ts +24 -0
  51. package/src/routing.ts +0 -9
  52. package/src/runtime.ts +19 -1
  53. package/src/secrets.ts +4 -2
  54. package/src/storage.ts +58 -0
  55. package/src/store.ts +8 -0
  56. package/src/styles/app.css +82 -6
  57. package/src/styles/embed.css +116 -0
  58. package/src/styles/panel.css +235 -4
  59. package/src/viewer/embed.ts +9 -2
  60. package/src/viewer/manifests.ts +2 -0
  61. package/src/viewer/mount.ts +2 -0
  62. package/src/viewer/protocol.ts +6 -0
  63. package/src/viewer/registry.ts +48 -10
  64. package/src/viewer/worker.ts +9 -1
  65. package/src/virtual.d.ts +8 -0
  66. package/dist-viewer/assets/Host-CSrM9wY1.js +0 -37
  67. package/dist-viewer/assets/index-ez9c20v3.js +0 -1
  68. package/dist-viewer/assets/main-38QGGpNT.js +0 -14
  69. package/dist-viewer/assets/panel-C-vBZrep.css +0 -1
  70. package/dist-viewer/assets/registry-ChKCFBt1.js +0 -2
  71. package/dist-viewer/assets/worker-ClGb0tE1.js +0 -181
@@ -1,10 +1,9 @@
1
- import type { AuthProfile, OAuthSettings } from '@specbook/core'
1
+ import { absoluteBase, type OAuthSettings } from '@specbook/core'
2
2
  import { useEffect, useState } from 'preact/hooks'
3
3
  import { expiryOf, noteExpiry } from '../oauth'
4
- import { absoluteBase } from '../routing'
5
4
  import { engine } from '../runtime'
6
5
  import { originOf, SECRET_PLACEHOLDER, secretStore } from '../secrets'
7
- import { env, version } from '../store'
6
+ import { env, envVarsVersion, version } from '../store'
8
7
  import { OAuthSignIn } from './OAuthSignIn'
9
8
 
10
9
  interface Need {
@@ -60,15 +59,17 @@ export function SecretsBar({
60
59
  }
61
60
  let on = true
62
61
  if (!supplied) engine.managedSecrets(env.value).then((m) => on && setManaged(m))
63
- engine.config(version.value).then((cfg) => {
64
- if (!on) return
62
+ engine.config(version.value).then(async (cfg) => {
65
63
  const out: Need[] = []
64
+ const where = await engine.addressesIn(cfg, env.value)
66
65
  for (const [service, s] of Object.entries(cfg.services ?? {})) {
67
66
  if (services && !services.includes(service)) continue
68
67
  const profile = s.auth ? cfg.auth?.[s.auth] : undefined
69
- const base = s.baseUrl?.[env.value]
68
+ // the address this environment's variables give; one that cannot be worked out has no origin
69
+ const base = where.baseUrl(service)
70
70
  const origin = originOf(base ? absoluteBase(base, globalThis.location?.href) : undefined)
71
71
  if (!profile || !origin) continue
72
+ const oauth = s.auth ? where.oauth(s.auth) : undefined
72
73
  const names = [
73
74
  ...new Set(
74
75
  [...JSON.stringify(profile.apply).matchAll(SECRET_PLACEHOLDER)].map(
@@ -83,15 +84,15 @@ export function SecretsBar({
83
84
  title: profile.title ?? s.auth ?? '',
84
85
  names,
85
86
  profile: s.auth as string,
86
- oauth: (profile as AuthProfile).oauth,
87
+ oauth,
87
88
  })
88
89
  }
89
- setNeeds(out)
90
+ if (on) setNeeds(out)
90
91
  })
91
92
  return () => {
92
93
  on = false
93
94
  }
94
- }, [live, env.value, wanted, version.value])
95
+ }, [live, env.value, wanted, version.value, envVarsVersion.value])
95
96
 
96
97
  if (!live || needs.length === 0) return null
97
98
  const have = (n: Need, name: string) =>
@@ -2,6 +2,7 @@ import { useMemo } from 'preact/hooks'
2
2
  import { defaultFor, type FieldSpec, specFromControl } from '../components/Fields'
3
3
  import type { SequenceInfo } from '../engine'
4
4
  import { go } from '../nav'
5
+ import { show } from './short'
5
6
  import { useSequenceNotes } from './stepnotes'
6
7
 
7
8
  const KIND: Record<string, string> = {
@@ -11,7 +12,7 @@ const KIND: Record<string, string> = {
11
12
  check: 'check',
12
13
  }
13
14
 
14
- const show = (v: unknown) => (typeof v === 'string' ? v : JSON.stringify(v))
15
+ const showValue = (v: unknown) => (typeof v === 'string' ? v : show(v))
15
16
 
16
17
  /**
17
18
  * What a sequence does, as documentation: its description (markdown), an optional markdown page from
@@ -21,9 +22,9 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
21
22
  const { seq } = info
22
23
  const { intro, notes } = useSequenceNotes(info)
23
24
 
24
- const params = useMemo(
25
+ const inputs = useMemo(
25
26
  () =>
26
- Object.entries((seq.meta.params ?? {}) as Record<string, unknown>).map(([name, c]) => {
27
+ Object.entries((seq.meta.inputs ?? {}) as Record<string, unknown>).map(([name, c]) => {
27
28
  const spec: FieldSpec = specFromControl(c)
28
29
  return { name, spec, initial: defaultFor(spec) }
29
30
  }),
@@ -42,7 +43,7 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
42
43
 
43
44
  {intro && <div class="doc-custom" dangerouslySetInnerHTML={{ __html: intro }} />}
44
45
 
45
- {params.length > 0 && (
46
+ {inputs.length > 0 && (
46
47
  <section>
47
48
  <h2>Controls</h2>
48
49
  <table class="fields">
@@ -55,7 +56,7 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
55
56
  </tr>
56
57
  </thead>
57
58
  <tbody>
58
- {params.map((p) => (
59
+ {inputs.map((p) => (
59
60
  <tr>
60
61
  <td>
61
62
  <code>{p.name}</code>
@@ -64,7 +65,7 @@ export function SequenceDocs({ info }: { info: SequenceInfo }) {
64
65
  <span class="chip">{p.spec.kind}</span>
65
66
  </td>
66
67
  <td>
67
- <code>{show(p.initial)}</code>
68
+ <code>{showValue(p.initial)}</code>
68
69
  </td>
69
70
  <td>{p.spec.description}</td>
70
71
  </tr>
@@ -1,13 +1,16 @@
1
1
  import type { RunResult, StepResult } from '@specbook/core'
2
- import { useEffect, useMemo, useState } from 'preact/hooks'
2
+ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
3
3
  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
7
  import { env, locale, seed, version } from '../store'
8
+ import { announcement, describeRun } from './announce'
9
+ import { Failure } from './Checks'
8
10
  import { ControlsGrid } from './ControlsGrid'
9
11
  import { SecretsBar } from './SecretsBar'
10
12
  import { StepExchange } from './StepExchange'
13
+ import { short, show } from './short'
11
14
  import { useSequenceNotes } from './stepnotes'
12
15
 
13
16
  /** `Check items #2` belongs to the step `Check items`. */
@@ -24,18 +27,24 @@ const ICON = {
24
27
  paused: '⏸',
25
28
  pending: '',
26
29
  }
27
- const short = (v: unknown) => {
28
- const s = typeof v === 'string' ? v : JSON.stringify(v)
29
- return s.length > 28 ? `${s.slice(0, 27)}…` : s
30
- }
31
30
 
32
31
  /** |timeline|request|: the journey on the left, the selected step's exchange on the right. */
33
- export function SequencePanel({ info }: { info: SequenceInfo }) {
32
+ export function SequencePanel({
33
+ info,
34
+ preset,
35
+ autoRun,
36
+ }: {
37
+ info: SequenceInfo
38
+ /** input values to start from instead of the defaults (an embed's data-specbook-inputs) */
39
+ preset?: Record<string, unknown>
40
+ /** run on the defaults as soon as the flow is ready */
41
+ autoRun?: boolean
42
+ }) {
34
43
  const { seq } = info
35
44
  const specs = useMemo(
36
45
  () =>
37
46
  Object.fromEntries(
38
- Object.entries((seq.meta.params ?? {}) as Record<string, unknown>).map(([k, c]) => [
47
+ Object.entries((seq.meta.inputs ?? {}) as Record<string, unknown>).map(([k, c]) => [
39
48
  k,
40
49
  specFromControl(c),
41
50
  ]),
@@ -52,7 +61,7 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
52
61
  }
53
62
  return [...names]
54
63
  }, [seq])
55
- const [params, setParams] = useState<Record<string, unknown>>({})
64
+ const [inputs, setInputs] = useState<Record<string, unknown>>({})
56
65
  const [result, setResult] = useState<RunResult>()
57
66
  const [live, setLive] = useState<Live>({})
58
67
  const [running, setRunning] = useState(false)
@@ -67,11 +76,41 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
67
76
  const [sheet, setSheet] = useState(false)
68
77
  const [error, setError] = useState<string>()
69
78
 
79
+ const hasGenerated = Object.values(specs).some((s) => s.generate)
80
+ // a flow with inputs can run on their defaults alone, as `specbook test` does
81
+ const hasInputs = Object.keys(specs).length > 0 || seq.nodes.some((n) => n.kind === 'input')
82
+
83
+ /** Every input at its default; a generated one needs faker, which is made ready first. */
84
+ const freshInputs = async () => {
85
+ if (hasGenerated)
86
+ await engine.fakerReady(version.value, locale.value, seed.value).catch(() => {})
87
+ return Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)]))
88
+ }
89
+
70
90
  useEffect(() => {
71
- setParams(Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)])))
91
+ let current = true
92
+ const start = (v: Record<string, unknown>) => ({ ...v, ...(preset ?? {}) })
93
+ setInputs(start(Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)]))))
94
+ // generated values need faker: fill them in as soon as it is ready
95
+ if (hasGenerated)
96
+ void freshInputs().then((v) => {
97
+ if (!current) return
98
+ setInputs(start(v))
99
+ if (autoRun) void run({ inputs: start(v), headless: true })
100
+ })
101
+ else if (autoRun)
102
+ void run({
103
+ inputs: start(
104
+ Object.fromEntries(Object.entries(specs).map(([k, v]) => [k, defaultFor(v)])),
105
+ ),
106
+ headless: true,
107
+ })
72
108
  setResult(undefined)
73
109
  setLive({})
74
110
  setPause(undefined)
111
+ return () => {
112
+ current = false
113
+ }
75
114
  }, [info.file])
76
115
 
77
116
  const planned = seq.nodes.map((n) => n.name)
@@ -80,7 +119,20 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
80
119
  : planned.map((n) => ({ name: n }))
81
120
  const current = rows[picked]?.step
82
121
 
83
- const run = async () => {
122
+ /** ends the run that is going: what is out is stopped, and nothing after it starts */
123
+ const stopper = useRef<AbortController | undefined>(undefined)
124
+ const [stopped, setStopped] = useState(false)
125
+ const stop = () => {
126
+ setStopped(true)
127
+ stopper.current?.abort()
128
+ // a run waiting for a choice is released, and then finds it is stopped
129
+ pause?.resolve(choice)
130
+ }
131
+
132
+ const run = async (options: { inputs?: Record<string, unknown>; headless?: boolean } = {}) => {
133
+ const ctl = new AbortController()
134
+ stopper.current = ctl
135
+ setStopped(false)
84
136
  setRunning(true)
85
137
  setResult(undefined)
86
138
  setError(undefined)
@@ -91,32 +143,42 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
91
143
  const r = await engine.run(seq, {
92
144
  env: env.value,
93
145
  version: version.value,
94
- params,
146
+ inputs: options.inputs ?? inputs,
95
147
  locale: locale.value,
96
148
  seed: seed.value,
149
+ signal: ctl.signal,
97
150
  onEvent: (e) => {
98
151
  if (e.type === 'step:start') setLive((l) => ({ ...l, [e.name]: 'running' }))
99
152
  if (e.type === 'step:end') setLive((l) => ({ ...l, [e.name]: e.status }))
100
153
  },
101
- onInput: ({ name, controls }) =>
102
- new Promise((resolve) => {
103
- const c = controls as Record<string, FieldSpec>
104
- setLive((l) => ({ ...l, [name]: 'paused' }))
105
- setChoice(Object.fromEntries(Object.entries(c).map(([k, v]) => [k, defaultFor(v)])))
106
- setPause({
107
- name,
108
- controls: c,
109
- resolve: (values) => {
110
- setPause(undefined)
111
- resolve(values)
112
- },
113
- })
114
- }),
154
+ onInput: options.headless
155
+ ? undefined
156
+ : ({ name, controls }) =>
157
+ new Promise((resolve) => {
158
+ const c = controls as Record<string, FieldSpec>
159
+ setLive((l) => ({ ...l, [name]: 'paused' }))
160
+ setChoice(Object.fromEntries(Object.entries(c).map(([k, v]) => [k, defaultFor(v)])))
161
+ setPause({
162
+ name,
163
+ controls: c,
164
+ resolve: (values) => {
165
+ setPause(undefined)
166
+ resolve(values)
167
+ },
168
+ })
169
+ }),
115
170
  })
116
171
  setResult(r)
172
+ announcement.value = describeRun(r)
173
+ // a run that did not pass shows its first failing step, not its first step
174
+ const bad = r.steps
175
+ .filter((s) => s.kind !== 'auth')
176
+ .findIndex((s) => s.status === 'fail' || s.status === 'error')
177
+ if (bad >= 0) setPicked(bad)
117
178
  } catch (e) {
118
179
  setError(e instanceof Error ? e.message : String(e))
119
180
  } finally {
181
+ stopper.current = undefined
120
182
  setRunning(false)
121
183
  setPause(undefined)
122
184
  }
@@ -128,17 +190,43 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
128
190
  <div class={`sequence-panel ${sheet && current ? 'sheet-open' : ''}`} data-seq={seq.meta.id}>
129
191
  <div class="seq-left">
130
192
  <SecretsBar services={serviceNames} />
131
- <ControlsGrid title="Controls" specs={specs} values={params} onChange={setParams} />
193
+ <ControlsGrid title="Inputs" specs={specs} values={inputs} onChange={setInputs} />
132
194
  <div class="seq-run">
133
- <button type="button" class="btn primary" disabled={running} onClick={run}>
195
+ <button type="button" class="btn primary" disabled={running} onClick={() => run()}>
134
196
  {running ? 'Running…' : '▶ Run'}
135
197
  </button>
198
+ {running && (
199
+ <button type="button" class="btn" onClick={stop}>
200
+ Stop
201
+ </button>
202
+ )}
203
+ {hasInputs && (
204
+ <button
205
+ type="button"
206
+ class="btn"
207
+ disabled={running}
208
+ title="Skip every question and use each input's default, as specbook test does"
209
+ onClick={async () => {
210
+ const fresh = await freshInputs()
211
+ setInputs(fresh)
212
+ await run({ inputs: fresh, headless: true })
213
+ }}
214
+ >
215
+ Run with defaults
216
+ </button>
217
+ )}
136
218
  <EnvBadge />
137
219
  {result && (
138
220
  <span class={`badge ${overall}`}>
139
221
  {ICON[overall as 'pass']}{' '}
140
- {overall === 'pass' ? 'Passed' : overall === 'fail' ? 'Failed' : 'Error'} ·{' '}
141
- {result.totals.passed}/{result.totals.steps} steps · {result.durationMs}ms
222
+ {overall === 'pass'
223
+ ? 'Passed'
224
+ : stopped
225
+ ? 'Stopped'
226
+ : overall === 'fail'
227
+ ? 'Failed'
228
+ : 'Error'}{' '}
229
+ · {result.totals.passed}/{result.totals.steps} steps · {result.durationMs}ms
142
230
  </span>
143
231
  )}
144
232
  </div>
@@ -187,16 +275,20 @@ export function SequencePanel({ info }: { info: SequenceInfo }) {
187
275
  ))}
188
276
  {step &&
189
277
  Object.entries(step.captured).map(([k, v]) => (
190
- <span class="cap make" title={JSON.stringify(v)}>
278
+ <span class="cap make" title={show(v)}>
191
279
  → {k} <b>{short(v)}</b>
192
280
  </span>
193
281
  ))}
194
282
  {step?.error && <div class="error-text">{step.error}</div>}
195
283
  {failed.map((a) => (
196
- <div class="error-text">✗ {a.label}</div>
284
+ <Failure a={a} />
197
285
  ))}
198
286
  {status === 'skipped' && (
199
- <div class="muted">Skipped: an earlier step did not pass.</div>
287
+ <div class="muted">
288
+ {stopped
289
+ ? 'Skipped: you stopped the run.'
290
+ : 'Skipped: an earlier step did not pass.'}
291
+ </div>
200
292
  )}
201
293
  {pause && live[row.name] === 'paused' && (
202
294
  <div class="pause">
@@ -0,0 +1,17 @@
1
+ import { signal } from '@preact/signals'
2
+ import type { RunResult } from '@specbook/core'
3
+
4
+ /** What a screen reader is told when a run finishes: the panel renders it in a polite live region. */
5
+ export const announcement = signal('')
6
+
7
+ /** One sentence for a finished run: a request by its response, a flow by how many steps passed. */
8
+ export function describeRun(r: RunResult): string {
9
+ if (r.kind === 'request') {
10
+ const s = r.steps.find((x) => x.response) ?? r.steps[0]
11
+ if (!s?.response) return r.status === 'error' ? 'The request could not be sent.' : 'Done.'
12
+ const checks = s.assertions.filter((a) => !a.pass).length
13
+ return `${s.response.status}, ${r.durationMs} milliseconds${checks ? `, ${checks} check${checks === 1 ? '' : 's'} failed` : ''}.`
14
+ }
15
+ const word = r.status === 'pass' ? 'passed' : r.status === 'fail' ? 'failed' : 'could not run'
16
+ return `Flow ${word}, ${r.totals.passed} of ${r.totals.steps} steps passed.`
17
+ }
@@ -83,3 +83,12 @@ export function StarIcon({ size = 14 }: { size?: number }) {
83
83
  </svg>
84
84
  )
85
85
  }
86
+
87
+ /** A filled square, the usual "stop" mark. */
88
+ export function StopSquare({ size = 16 }: { size?: number }) {
89
+ return (
90
+ <svg viewBox="0 0 24 24" width={size} height={size} fill="currentColor" aria-hidden="true">
91
+ <rect x="6" y="6" width="12" height="12" rx="2" />
92
+ </svg>
93
+ )
94
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A value as text, whatever it is. A run hands the page whatever the project's code and the API produced, and JSON cannot
3
+ * write all of it: `undefined` and functions come back as nothing, a circular object or a BigInt throws. A line that cannot
4
+ * be drawn must not stop the panel, so this never throws and never returns nothing.
5
+ */
6
+ export function show(v: unknown, space?: number): string {
7
+ try {
8
+ const s = JSON.stringify(v, null, space)
9
+ if (s !== undefined) return s
10
+ } catch {
11
+ /* circular, or something JSON has no form for: name it instead */
12
+ }
13
+ try {
14
+ return String(v)
15
+ } catch {
16
+ return Object.prototype.toString.call(v)
17
+ }
18
+ }
19
+
20
+ /** A value for a line of text: its text, cut at 28 characters. */
21
+ export function short(v: unknown): string {
22
+ const s = typeof v === 'string' ? v : show(v)
23
+ return s.length > 28 ? `${s.slice(0, 27)}…` : s
24
+ }
package/src/routing.ts CHANGED
@@ -29,15 +29,6 @@ export function routeFor(o: {
29
29
  return 'direct'
30
30
  }
31
31
 
32
- /** A config `baseUrl` may be relative (`/api/v1`): it is resolved against the page. */
33
- export function absoluteBase(baseUrl: string, pageHref?: string): string {
34
- try {
35
- return new URL(baseUrl, pageHref).toString()
36
- } catch {
37
- return baseUrl
38
- }
39
- }
40
-
41
32
  const names = (req: PreparedRequest): string[] => {
42
33
  const out = new Set<string>()
43
34
  const scan = (v: unknown) => {
package/src/runtime.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as reg from 'virtual:specbook/registry'
2
+ import { createEnvironmentStore } from './custom-environments'
2
3
  import { createEngine, pickRunnable } from './engine'
3
4
  import { detectExtension, type ExtensionBridge } from './extension'
4
5
  import { version } from './store'
@@ -14,10 +15,27 @@ const extension = async () => {
14
15
  return bridge
15
16
  }
16
17
 
17
- export const engine = createEngine(reg as never, { extension })
18
+ /**
19
+ * A page with no project files keeps the environments a person makes in the browser: per spec in the
20
+ * hosted viewer (its registry says where), per page for a built site. An embed has none.
21
+ */
22
+ const storeAt = (reg as { environmentStore?: { scope: string; session: boolean } | null })
23
+ .environmentStore
24
+ const environmentStore =
25
+ reg.mode === 'static' && storeAt !== null
26
+ ? createEnvironmentStore(
27
+ storeAt?.scope ?? `${globalThis.location?.origin}${globalThis.location?.pathname}`,
28
+ storeAt?.session ? 'session' : 'local',
29
+ )
30
+ : undefined
31
+
32
+ export const engine = createEngine(reg as never, { extension, environmentStore })
18
33
 
19
34
  export const isStatic = reg.mode === 'static'
20
35
 
36
+ /** A build that publishes only the requests and flows the project uses (`specbook build --minimal`). */
37
+ export const isMinimal = !!(reg as { minimal?: boolean }).minimal
38
+
21
39
  /** Dev only: writes a project file through the plugin, then busts import caches. */
22
40
  export async function saveFile(path: string, content: string): Promise<void> {
23
41
  const r = await fetch('/__specbook/save', {
package/src/secrets.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  * closes) with an in-memory fallback; another backend (an encrypted store, say) can sit behind the same
7
7
  * interface. Requests carry `{{secrets.NAME}}` placeholders until the transport sends them.
8
8
  */
9
+ import { kv } from './storage'
10
+
9
11
  const KEY = 'specbook.secrets.v1'
10
12
 
11
13
  type Table = Record<string, Record<string, string>>
@@ -20,7 +22,7 @@ let memory: Table = {}
20
22
 
21
23
  const read = (): Table => {
22
24
  try {
23
- const raw = sessionStorage.getItem(KEY)
25
+ const raw = kv.session.getItem(KEY)
24
26
  return raw ? (JSON.parse(raw) as Table) : memory
25
27
  } catch {
26
28
  return memory
@@ -30,7 +32,7 @@ const read = (): Table => {
30
32
  const write = (t: Table) => {
31
33
  memory = t
32
34
  try {
33
- sessionStorage.setItem(KEY, JSON.stringify(t))
35
+ kv.session.setItem(KEY, JSON.stringify(t))
34
36
  } catch {
35
37
  /* private mode or blocked storage: the in-memory copy still serves this page */
36
38
  }
package/src/storage.ts ADDED
@@ -0,0 +1,58 @@
1
+ // Where the UI keeps what a person typed. In the app and the viewer it is the browser's storage, kept as before. In an embed
2
+ // it is memory: a docs page shares its origin's `localStorage` with its own code, and a visitor's drafts and credentials
3
+ // must not land there, or be read from there.
4
+
5
+ export type Store = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
6
+
7
+ function memory(): Store {
8
+ const m = new Map<string, string>()
9
+ return {
10
+ getItem: (k) => m.get(k) ?? null,
11
+ setItem: (k, v) => void m.set(k, String(v)),
12
+ removeItem: (k) => void m.delete(k),
13
+ }
14
+ }
15
+
16
+ /** Reading the global can itself throw (blocked site data), so it is done when asked and a failure falls back to memory. */
17
+ function browser(which: 'localStorage' | 'sessionStorage'): Store {
18
+ try {
19
+ const s = globalThis[which]
20
+ if (s) return s
21
+ } catch {
22
+ /* blocked */
23
+ }
24
+ return memory()
25
+ }
26
+
27
+ let local: Store | undefined
28
+ let session: Store | undefined
29
+ let isolated = false
30
+
31
+ export const kv = {
32
+ get local(): Store {
33
+ if (!isolated) return browser('localStorage')
34
+ if (!local) local = memory()
35
+ return local
36
+ },
37
+ get session(): Store {
38
+ if (!isolated) return browser('sessionStorage')
39
+ if (!session) session = memory()
40
+ return session
41
+ },
42
+ }
43
+
44
+ /** From here on nothing is read from or written to the page's own storage. */
45
+ export function useMemoryStorage(): void {
46
+ isolated = true
47
+ local = undefined
48
+ session = undefined
49
+ }
50
+
51
+ /** Back to the browser's storage (for tests). */
52
+ export function useBrowserStorage(): void {
53
+ isolated = false
54
+ local = undefined
55
+ session = undefined
56
+ }
57
+
58
+ export const storageIsolated = () => isolated
package/src/store.ts CHANGED
@@ -10,3 +10,11 @@ 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
+ /** Bumped when an environment's variables change, so what is derived from them (addresses, origins) is redone. */
14
+ export const envVarsVersion = signal(0)
15
+ /** Whether Environment settings is open; set from elsewhere to point a person at it. */
16
+ export const envSettingsOpen = signal(false)
17
+ /** The New environment dialog. */
18
+ export const newEnvironmentOpen = signal(false)
19
+ /** What the shell around the panels offers. An embed has no dialog for creating an environment or for settings. */
20
+ export const features = signal({ createEnvironment: true, environmentSettings: true })