@specbook/ui 0.0.0-stage → 0.2.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 (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -2
  3. package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
  4. package/dist-viewer/apple-touch-icon.png +0 -0
  5. package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
  6. package/dist-viewer/assets/faker-B3c03STC.js +11 -0
  7. package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
  8. package/dist-viewer/assets/index-ez9c20v3.js +1 -0
  9. package/dist-viewer/assets/main-38QGGpNT.js +14 -0
  10. package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
  11. package/dist-viewer/assets/panel-C-vBZrep.css +1 -0
  12. package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
  13. package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
  14. package/dist-viewer/assets/worker-ClGb0tE1.js +181 -0
  15. package/dist-viewer/favicon.svg +1 -0
  16. package/dist-viewer/index.html +19 -0
  17. package/dist-viewer/specbook.js +1 -0
  18. package/index.html +12 -0
  19. package/package.json +61 -3
  20. package/src/app.tsx +368 -0
  21. package/src/assets/mark.svg +1 -0
  22. package/src/badge.ts +14 -0
  23. package/src/branding.ts +92 -0
  24. package/src/components/Catalog.tsx +324 -0
  25. package/src/components/EnvBadge.tsx +48 -0
  26. package/src/components/EnvironmentMenu.tsx +262 -0
  27. package/src/components/Fields.tsx +220 -0
  28. package/src/components/GitHubStar.tsx +22 -0
  29. package/src/components/ListResizer.tsx +70 -0
  30. package/src/components/ModeHint.tsx +17 -0
  31. package/src/components/Palette.tsx +85 -0
  32. package/src/components/PoweredBy.tsx +15 -0
  33. package/src/components/ShareMenu.tsx +94 -0
  34. package/src/components/ThemeToggle.tsx +52 -0
  35. package/src/embed/panel.tsx +115 -0
  36. package/src/embed/styles.ts +27 -0
  37. package/src/embed.ts +65 -0
  38. package/src/engine.ts +574 -0
  39. package/src/extension.ts +101 -0
  40. package/src/fielddoc.ts +83 -0
  41. package/src/main.tsx +24 -0
  42. package/src/markdown.ts +133 -0
  43. package/src/nav.ts +118 -0
  44. package/src/oauth-callback.ts +87 -0
  45. package/src/oauth.ts +194 -0
  46. package/src/panel/Body.tsx +182 -0
  47. package/src/panel/BodyEditor.tsx +92 -0
  48. package/src/panel/CodeSample.tsx +238 -0
  49. package/src/panel/Combo.tsx +124 -0
  50. package/src/panel/ControlsGrid.tsx +189 -0
  51. package/src/panel/FieldDoc.tsx +42 -0
  52. package/src/panel/FieldTable.tsx +113 -0
  53. package/src/panel/Host.tsx +100 -0
  54. package/src/panel/OAuthSignIn.tsx +163 -0
  55. package/src/panel/OperationDocs.tsx +181 -0
  56. package/src/panel/Panel.tsx +197 -0
  57. package/src/panel/ParamGrid.tsx +396 -0
  58. package/src/panel/RequestPanel.tsx +627 -0
  59. package/src/panel/RequestView.tsx +108 -0
  60. package/src/panel/ResponseView.tsx +182 -0
  61. package/src/panel/SecretsBar.tsx +189 -0
  62. package/src/panel/SequenceDocs.tsx +132 -0
  63. package/src/panel/SequencePanel.tsx +247 -0
  64. package/src/panel/StepExchange.tsx +118 -0
  65. package/src/panel/completions.ts +114 -0
  66. package/src/panel/draft.ts +106 -0
  67. package/src/panel/fieldnotes-split.ts +55 -0
  68. package/src/panel/fieldnotes.ts +40 -0
  69. package/src/panel/graphql.ts +31 -0
  70. package/src/panel/icons.tsx +85 -0
  71. package/src/panel/jsonCursor.ts +115 -0
  72. package/src/panel/stepnotes-split.ts +38 -0
  73. package/src/panel/stepnotes.ts +58 -0
  74. package/src/routing.ts +120 -0
  75. package/src/runtime.ts +32 -0
  76. package/src/schema.ts +359 -0
  77. package/src/secrets.ts +61 -0
  78. package/src/status.ts +27 -0
  79. package/src/store.ts +12 -0
  80. package/src/styles/app.css +821 -0
  81. package/src/styles/panel.css +1939 -0
  82. package/src/styles/tokens.css +122 -0
  83. package/src/theme.ts +37 -0
  84. package/src/viewer/Opening.tsx +43 -0
  85. package/src/viewer/bundles.ts +74 -0
  86. package/src/viewer/client.ts +54 -0
  87. package/src/viewer/embed.ts +83 -0
  88. package/src/viewer/faker-en.ts +3 -0
  89. package/src/viewer/main.tsx +289 -0
  90. package/src/viewer/mount.ts +52 -0
  91. package/src/viewer/protocol.ts +54 -0
  92. package/src/viewer/recent.ts +22 -0
  93. package/src/viewer/registry-data.ts +8 -0
  94. package/src/viewer/registry.ts +163 -0
  95. package/src/viewer/share.ts +134 -0
  96. package/src/viewer/site.ts +3 -0
  97. package/src/viewer/uploads.ts +53 -0
  98. package/src/viewer/viewer.css +184 -0
  99. package/src/viewer/worker-factory.ts +14 -0
  100. package/src/viewer/worker.ts +176 -0
  101. package/src/virtual.d.ts +104 -0
@@ -0,0 +1,31 @@
1
+ export interface Graphql {
2
+ query: string
3
+ variables: string
4
+ rest: Record<string, unknown>
5
+ }
6
+
7
+ /** A GraphQL body is `{ query, variables?, operationName? }`; split it so each part gets its own editor. */
8
+ export function splitGraphql(body: string): Graphql | undefined {
9
+ try {
10
+ const { query, variables, ...rest } = JSON.parse(body)
11
+ if (typeof query !== 'string') return undefined
12
+ return {
13
+ query,
14
+ variables: variables === undefined ? '' : JSON.stringify(variables, null, 2),
15
+ rest,
16
+ }
17
+ } catch {
18
+ return undefined
19
+ }
20
+ }
21
+
22
+ export function joinGraphql(g: Graphql): string {
23
+ let variables: unknown
24
+ try {
25
+ variables = g.variables.trim() ? JSON.parse(g.variables) : undefined
26
+ } catch {
27
+ // keep what was typed so the body stays editable; the panel reports invalid JSON
28
+ variables = g.variables
29
+ }
30
+ return JSON.stringify({ query: g.query, variables, ...g.rest }, null, 2)
31
+ }
@@ -0,0 +1,85 @@
1
+ /** A paper plane pointing right, the usual "send" mark. SVG because Unicode has no paper-plane emoji. */
2
+ export function PaperPlane({ size = 16 }: { size?: number }) {
3
+ return (
4
+ <svg
5
+ viewBox="0 0 24 24"
6
+ width={size}
7
+ height={size}
8
+ fill="none"
9
+ stroke="currentColor"
10
+ stroke-width="1.8"
11
+ stroke-linecap="round"
12
+ stroke-linejoin="round"
13
+ aria-hidden="true"
14
+ >
15
+ <path d="M3 3l19 9-19 9 3.5-9z" />
16
+ <path d="M6.5 12H14" />
17
+ </svg>
18
+ )
19
+ }
20
+
21
+ /** A padlock: the operation needs credentials. */
22
+ export function Lock({ size = 12 }: { size?: number }) {
23
+ return (
24
+ <svg
25
+ viewBox="0 0 24 24"
26
+ width={size}
27
+ height={size}
28
+ fill="none"
29
+ stroke="currentColor"
30
+ stroke-width="2"
31
+ stroke-linecap="round"
32
+ stroke-linejoin="round"
33
+ aria-hidden="true"
34
+ >
35
+ <rect x="4" y="11" width="16" height="10" rx="2" />
36
+ <path d="M8 11V7a4 4 0 0 1 8 0v4" />
37
+ </svg>
38
+ )
39
+ }
40
+
41
+ /** The share mark of Apple's platforms and most browsers: an arrow rising out of a tray. */
42
+ export function ShareIcon({ size = 16 }: { size?: number }) {
43
+ return (
44
+ <svg
45
+ viewBox="0 0 24 24"
46
+ width={size}
47
+ height={size}
48
+ fill="none"
49
+ stroke="currentColor"
50
+ stroke-width="1.8"
51
+ stroke-linecap="round"
52
+ stroke-linejoin="round"
53
+ aria-hidden="true"
54
+ >
55
+ <path d="M12 15V3" />
56
+ <path d="M7.5 7.5 12 3l4.5 4.5" />
57
+ <path d="M5 11H4.5A1.5 1.5 0 0 0 3 12.5v7A1.5 1.5 0 0 0 4.5 21h15a1.5 1.5 0 0 0 1.5-1.5v-7a1.5 1.5 0 0 0-1.5-1.5H19" />
58
+ </svg>
59
+ )
60
+ }
61
+
62
+ export function GitHubMark({ size = 16 }: { size?: number }) {
63
+ return (
64
+ <svg viewBox="0 0 16 16" width={size} height={size} fill="currentColor" aria-hidden="true">
65
+ <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 4 0c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
66
+ </svg>
67
+ )
68
+ }
69
+
70
+ export function StarIcon({ size = 14 }: { size?: number }) {
71
+ return (
72
+ <svg
73
+ viewBox="0 0 24 24"
74
+ width={size}
75
+ height={size}
76
+ fill="none"
77
+ stroke="currentColor"
78
+ stroke-width="1.8"
79
+ stroke-linejoin="round"
80
+ aria-hidden="true"
81
+ >
82
+ <path d="m12 3 2.7 5.6 6.1.9-4.4 4.3 1 6.1L12 17l-5.4 2.9 1-6.1-4.4-4.3 6.1-.9z" />
83
+ </svg>
84
+ )
85
+ }
@@ -0,0 +1,115 @@
1
+ import type { KeyPath } from '../schema'
2
+
3
+ export interface JsonCursor {
4
+ /** `key`: completing a property name of the object at `path`. `value`: completing the value at `path`. */
5
+ mode: 'key' | 'value'
6
+ path: KeyPath
7
+ /** the range a completion replaces: a whole string (quotes included) or the word being typed */
8
+ from: number
9
+ to: number
10
+ inString: boolean
11
+ /** what was typed so far inside the string or word */
12
+ prefix: string
13
+ /** key mode: properties the object already has */
14
+ existing: Set<string>
15
+ /** key mode in a string: a `:` already follows, so only the key is replaced */
16
+ hasColon: boolean
17
+ }
18
+
19
+ type Frame =
20
+ | { t: 'o'; key?: string; expectKey: boolean; keys: Set<string> }
21
+ | { t: 'a'; index: number }
22
+
23
+ const WORD = /[\w$.+-]/
24
+
25
+ /**
26
+ * Where the cursor is in a (possibly unfinished) JSON document: which object or value, whether a key
27
+ * or a value is being written, and what is already there. Tolerates broken JSON, since the user is
28
+ * mid-edit. Returns undefined at the document root or where nothing can be completed.
29
+ */
30
+ export function jsonCursor(text: string, pos: number): JsonCursor | undefined {
31
+ const stack: Frame[] = []
32
+ const top = () => stack[stack.length - 1]
33
+ let i = 0
34
+ while (i < pos) {
35
+ const c = text[i]
36
+ if (c === '"') {
37
+ let j = i + 1
38
+ let value = ''
39
+ while (j < text.length && text[j] !== '"' && text[j] !== '\n') {
40
+ if (text[j] === '\\') {
41
+ value += text[j + 1] ?? ''
42
+ j += 2
43
+ } else value += text[j++]
44
+ }
45
+ const closed = text[j] === '"'
46
+ const t = top()
47
+ if (pos <= j || (!closed && pos <= text.length && j >= pos)) {
48
+ // the cursor is inside this string
49
+ const isKey = t?.t === 'o' && t.expectKey
50
+ const to = closed ? j + 1 : pos
51
+ let after = to
52
+ while (/\s/.test(text[after] ?? '')) after++
53
+ return finish({
54
+ mode: isKey ? 'key' : 'value',
55
+ from: i,
56
+ to,
57
+ inString: true,
58
+ prefix: text.slice(i + 1, pos),
59
+ hasColon: text[after] === ':',
60
+ })
61
+ }
62
+ if (t?.t === 'o' && t.expectKey) {
63
+ t.key = value
64
+ t.keys.add(value)
65
+ t.expectKey = false
66
+ }
67
+ i = j + 1
68
+ continue
69
+ }
70
+ if (c === '{') stack.push({ t: 'o', expectKey: true, keys: new Set() })
71
+ else if (c === '[') stack.push({ t: 'a', index: 0 })
72
+ else if (c === '}' || c === ']') stack.pop()
73
+ else if (c === ',') {
74
+ const t = top()
75
+ if (t?.t === 'o') {
76
+ t.expectKey = true
77
+ t.key = undefined
78
+ } else if (t?.t === 'a') t.index++
79
+ }
80
+ i++
81
+ }
82
+
83
+ // not inside a string: the word being typed, if any
84
+ let from = pos
85
+ while (from > 0 && WORD.test(text[from - 1] ?? '')) from--
86
+ let to = pos
87
+ while (to < text.length && WORD.test(text[to] ?? '')) to++
88
+ const t = top()
89
+ return finish({
90
+ mode: t?.t === 'o' && t.expectKey ? 'key' : 'value',
91
+ from,
92
+ to,
93
+ inString: false,
94
+ prefix: text.slice(from, pos),
95
+ hasColon: false,
96
+ })
97
+
98
+ function finish(
99
+ c: Pick<JsonCursor, 'mode' | 'from' | 'to' | 'inString' | 'prefix' | 'hasColon'>,
100
+ ) {
101
+ if (stack.length === 0) return undefined
102
+ const path: KeyPath = []
103
+ for (let k = 0; k < stack.length; k++) {
104
+ const f = stack[k] as Frame
105
+ const last = k === stack.length - 1
106
+ if (f.t === 'o') {
107
+ if (last && c.mode === 'key') break
108
+ if (f.key === undefined) return undefined
109
+ path.push(f.key)
110
+ } else path.push(f.index)
111
+ }
112
+ const f = top()
113
+ return { ...c, path, existing: f?.t === 'o' ? f.keys : new Set<string>() }
114
+ }
115
+ }
@@ -0,0 +1,38 @@
1
+ export interface SequenceNotes {
2
+ /** the page without the sections that belong to steps */
3
+ intro: string
4
+ /** step name -> HTML of the section headed by that name */
5
+ notes: Map<string, string>
6
+ }
7
+
8
+ /** The text of a heading, without tags or entities. */
9
+ const plain = (h: string) =>
10
+ h
11
+ .replace(/<[^>]+>/g, '')
12
+ .replace(/&amp;/g, '&')
13
+ .replace(/&lt;/g, '<')
14
+ .replace(/&gt;/g, '>')
15
+ .replace(/&quot;/g, '"')
16
+ .replace(/&#39;/g, "'")
17
+
18
+ const norm = (s: string) => s.replace(/\s+/g, ' ').trim().toLowerCase()
19
+
20
+ /**
21
+ * A `## Heading` that names a step becomes that step's note (everything up to the next `##`).
22
+ * Anything else stays in the intro, so ordinary headings keep working.
23
+ */
24
+ export function splitStepNotes(html: string, stepNames: string[]): SequenceNotes {
25
+ const notes = new Map<string, string>()
26
+ if (!html || stepNames.length === 0) return { intro: html, notes }
27
+ const wanted = new Map(stepNames.map((n) => [norm(n), n]))
28
+ const intro: string[] = []
29
+ // the page is rendered markdown, so every `##` is an <h2> at the top level
30
+ for (const chunk of html.split(/(?=<h2[ >])/)) {
31
+ const m = /^<h2[^>]*>([\s\S]*?)<\/h2>/.exec(chunk)
32
+ const name = m ? wanted.get(norm(plain(m[1] as string))) : undefined
33
+ if (m && name !== undefined) {
34
+ if (!notes.has(name)) notes.set(name, chunk.slice(m[0].length))
35
+ } else intro.push(chunk)
36
+ }
37
+ return { intro: intro.join(''), notes }
38
+ }
@@ -0,0 +1,58 @@
1
+ import { useEffect, useMemo, useState } from 'preact/hooks'
2
+ import type { SequenceInfo } from '../engine'
3
+ import { markdownToHtml } from '../markdown'
4
+ import { registry } from '../runtime'
5
+
6
+ export { type SequenceNotes, splitStepNotes } from './stepnotes-split'
7
+
8
+ import { type SequenceNotes, splitStepNotes } from './stepnotes-split'
9
+
10
+ /** The sequence's markdown page (or its description) split into an intro and per-step notes. */
11
+ export function useSequenceNotes(info: SequenceInfo): SequenceNotes & { loaded: boolean } {
12
+ const { seq } = info
13
+ const [html, setHtml] = useState<string>()
14
+ const [loaded, setLoaded] = useState(false)
15
+ useEffect(() => {
16
+ let live = true
17
+ setHtml(undefined)
18
+ setLoaded(false)
19
+ const page = registry.docs.find(
20
+ (d) => d.sequence === seq.meta.id || d.sequenceFile === info.file,
21
+ )
22
+ if (!page) {
23
+ setLoaded(true)
24
+ return
25
+ }
26
+ page
27
+ .load()
28
+ .then((h: string) => live && setHtml(h))
29
+ .finally(() => live && setLoaded(true))
30
+ return () => {
31
+ live = false
32
+ }
33
+ }, [seq.meta.id, info.file])
34
+
35
+ const names = useMemo(() => seq.nodes.map((n) => n.name), [seq])
36
+ return useMemo(() => {
37
+ const source = html ?? markdownToHtml(seq.meta.description)
38
+ return { ...splitStepNotes(source, names), loaded }
39
+ }, [html, names, seq, loaded])
40
+ }
41
+
42
+ /** The markdown beside a saved request (`<name>.req.md`), rendered. */
43
+ export function useRequestNote(file: string | undefined): string | undefined {
44
+ const [html, setHtml] = useState<string>()
45
+ useEffect(() => {
46
+ let live = true
47
+ setHtml(undefined)
48
+ if (file)
49
+ registry.docs
50
+ .find((d) => d.requestFile === file)
51
+ ?.load()
52
+ .then((h: string) => live && setHtml(h))
53
+ return () => {
54
+ live = false
55
+ }
56
+ }, [file])
57
+ return html
58
+ }
package/src/routing.ts ADDED
@@ -0,0 +1,120 @@
1
+ import {
2
+ MissingSecretsError,
3
+ type PreparedRequest,
4
+ secretNames,
5
+ substituteText,
6
+ type Transport,
7
+ } from '@specbook/core'
8
+ import { originOf, SECRET_PLACEHOLDER, type SecretStore } from './secrets'
9
+
10
+ /** Who makes the outbound call for a service. */
11
+ export type Route = 'direct' | 'proxy' | 'extension'
12
+
13
+ /**
14
+ * Per service and per request:
15
+ * - a configured proxy (the dev server, or a hosted portal's same-origin route) takes every call;
16
+ * - otherwise a same-origin API is called straight from the page (no extension, cookies just work);
17
+ * - otherwise the extension, when it is installed and enabled, bypasses CORS;
18
+ * - otherwise a direct call, which may be blocked by CORS and says so.
19
+ */
20
+ export function routeFor(o: {
21
+ proxy: boolean
22
+ extension: boolean
23
+ baseUrl: string
24
+ pageOrigin?: string
25
+ }): Route {
26
+ if (o.proxy) return 'proxy'
27
+ if (o.pageOrigin && originOf(o.baseUrl) === o.pageOrigin) return 'direct'
28
+ if (o.extension) return 'extension'
29
+ return 'direct'
30
+ }
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
+ const names = (req: PreparedRequest): string[] => {
42
+ const out = new Set<string>()
43
+ const scan = (v: unknown) => {
44
+ if (typeof v === 'string') for (const n of secretNames(new TextEncoder().encode(v))) out.add(n)
45
+ else if (Array.isArray(v)) v.forEach(scan)
46
+ else if (v && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype)
47
+ Object.values(v).forEach(scan)
48
+ }
49
+ scan(req.headers)
50
+ scan(req.query)
51
+ scan(req.body)
52
+ return [...out]
53
+ }
54
+
55
+ /**
56
+ * Puts the values the person typed into a request, bound to the API's origin. A name with no value is
57
+ * left as a placeholder for whoever executes the call (the dev server falls back to its environment
58
+ * files, the extension to its own store); a direct call has no one to fall back on, so it stops there.
59
+ */
60
+ export function withPageSecrets(
61
+ inner: Transport,
62
+ o: {
63
+ store: SecretStore
64
+ originFor(service: string): string | undefined
65
+ unresolved: 'keep' | 'fail'
66
+ },
67
+ ): Transport {
68
+ return (req, signal) => {
69
+ const asked = names(req)
70
+ if (asked.length === 0) return inner(req, signal)
71
+ const origin = o.originFor(req.service)
72
+ const values = new Map<string, string>()
73
+ const missing: string[] = []
74
+ for (const n of asked) {
75
+ const v = origin ? o.store.get(origin, n) : undefined
76
+ if (v === undefined) missing.push(n)
77
+ else values.set(n, v)
78
+ }
79
+ if (missing.length && o.unresolved === 'fail') throw new MissingSecretsError(missing)
80
+ const walk = (v: unknown): unknown =>
81
+ typeof v === 'string'
82
+ ? substituteText(v, values)
83
+ : Array.isArray(v)
84
+ ? v.map(walk)
85
+ : v && typeof v === 'object' && Object.getPrototypeOf(v) === Object.prototype
86
+ ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]))
87
+ : v
88
+ return inner(
89
+ {
90
+ ...req,
91
+ headers: walk(req.headers) as typeof req.headers,
92
+ query: walk(req.query) as typeof req.query,
93
+ body: walk(req.body),
94
+ },
95
+ signal,
96
+ )
97
+ }
98
+ }
99
+
100
+ export function routingTransport(o: {
101
+ direct: Transport
102
+ proxy?: Transport
103
+ extension?: Transport
104
+ baseOf(service: string): string
105
+ pageOrigin?: string
106
+ }): Transport {
107
+ return (req, signal) => {
108
+ const route = routeFor({
109
+ proxy: !!o.proxy,
110
+ extension: !!o.extension,
111
+ baseUrl: o.baseOf(req.service),
112
+ pageOrigin: o.pageOrigin,
113
+ })
114
+ const send = route === 'proxy' ? o.proxy : route === 'extension' ? o.extension : o.direct
115
+ return (send ?? o.direct)(req, signal)
116
+ }
117
+ }
118
+
119
+ /** Re-exported for the credentials prompt. */
120
+ export { SECRET_PLACEHOLDER }
package/src/runtime.ts ADDED
@@ -0,0 +1,32 @@
1
+ import * as reg from 'virtual:specbook/registry'
2
+ import { createEngine, pickRunnable } from './engine'
3
+ import { detectExtension, type ExtensionBridge } from './extension'
4
+ import { version } from './store'
5
+
6
+ export const registry = reg
7
+
8
+ // looking for the extension costs a short wait when it is absent, so a run does not repeat it
9
+ let found: { at: number; bridge: ExtensionBridge | undefined } | undefined
10
+ const extension = async () => {
11
+ if (found && Date.now() - found.at < 30_000) return found.bridge
12
+ const bridge = await detectExtension()
13
+ found = { at: Date.now(), bridge }
14
+ return bridge
15
+ }
16
+
17
+ export const engine = createEngine(reg as never, { extension })
18
+
19
+ export const isStatic = reg.mode === 'static'
20
+
21
+ /** Dev only: writes a project file through the plugin, then busts import caches. */
22
+ export async function saveFile(path: string, content: string): Promise<void> {
23
+ const r = await fetch('/__specbook/save', {
24
+ method: 'POST',
25
+ headers: { 'content-type': 'application/json' },
26
+ body: JSON.stringify({ path, content }),
27
+ })
28
+ if (!r.ok) throw new Error(((await r.json()) as { error?: string }).error ?? 'save failed')
29
+ version.value = Date.now()
30
+ }
31
+
32
+ export { pickRunnable }