@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.
- package/LICENSE +21 -0
- package/README.md +6 -2
- package/dist-viewer/THIRD-PARTY-NOTICES.txt +581 -0
- package/dist-viewer/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-CSrM9wY1.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/index-ez9c20v3.js +1 -0
- package/dist-viewer/assets/main-38QGGpNT.js +14 -0
- package/dist-viewer/assets/mount-CGL2tOpN.js +2 -0
- package/dist-viewer/assets/panel-C-vBZrep.css +1 -0
- package/dist-viewer/assets/panel-C8Ve3LeS.js +1 -0
- package/dist-viewer/assets/registry-ChKCFBt1.js +2 -0
- package/dist-viewer/assets/worker-ClGb0tE1.js +181 -0
- package/dist-viewer/favicon.svg +1 -0
- package/dist-viewer/index.html +19 -0
- package/dist-viewer/specbook.js +1 -0
- package/index.html +12 -0
- package/package.json +61 -3
- package/src/app.tsx +368 -0
- package/src/assets/mark.svg +1 -0
- package/src/badge.ts +14 -0
- package/src/branding.ts +92 -0
- package/src/components/Catalog.tsx +324 -0
- package/src/components/EnvBadge.tsx +48 -0
- package/src/components/EnvironmentMenu.tsx +262 -0
- package/src/components/Fields.tsx +220 -0
- package/src/components/GitHubStar.tsx +22 -0
- package/src/components/ListResizer.tsx +70 -0
- package/src/components/ModeHint.tsx +17 -0
- package/src/components/Palette.tsx +85 -0
- package/src/components/PoweredBy.tsx +15 -0
- package/src/components/ShareMenu.tsx +94 -0
- package/src/components/ThemeToggle.tsx +52 -0
- package/src/embed/panel.tsx +115 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +574 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +24 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/oauth-callback.ts +87 -0
- package/src/oauth.ts +194 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +238 -0
- package/src/panel/Combo.tsx +124 -0
- package/src/panel/ControlsGrid.tsx +189 -0
- package/src/panel/FieldDoc.tsx +42 -0
- package/src/panel/FieldTable.tsx +113 -0
- package/src/panel/Host.tsx +100 -0
- package/src/panel/OAuthSignIn.tsx +163 -0
- package/src/panel/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +197 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +627 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +189 -0
- package/src/panel/SequenceDocs.tsx +132 -0
- package/src/panel/SequencePanel.tsx +247 -0
- package/src/panel/StepExchange.tsx +118 -0
- package/src/panel/completions.ts +114 -0
- package/src/panel/draft.ts +106 -0
- package/src/panel/fieldnotes-split.ts +55 -0
- package/src/panel/fieldnotes.ts +40 -0
- package/src/panel/graphql.ts +31 -0
- package/src/panel/icons.tsx +85 -0
- package/src/panel/jsonCursor.ts +115 -0
- package/src/panel/stepnotes-split.ts +38 -0
- package/src/panel/stepnotes.ts +58 -0
- package/src/routing.ts +120 -0
- package/src/runtime.ts +32 -0
- package/src/schema.ts +359 -0
- package/src/secrets.ts +61 -0
- package/src/status.ts +27 -0
- package/src/store.ts +12 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1939 -0
- package/src/styles/tokens.css +122 -0
- package/src/theme.ts +37 -0
- package/src/viewer/Opening.tsx +43 -0
- package/src/viewer/bundles.ts +74 -0
- package/src/viewer/client.ts +54 -0
- package/src/viewer/embed.ts +83 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +289 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +54 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +8 -0
- package/src/viewer/registry.ts +163 -0
- package/src/viewer/share.ts +134 -0
- package/src/viewer/site.ts +3 -0
- package/src/viewer/uploads.ts +53 -0
- package/src/viewer/viewer.css +184 -0
- package/src/viewer/worker-factory.ts +14 -0
- package/src/viewer/worker.ts +176 -0
- package/src/virtual.d.ts +104 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/* Semantic tokens (public theming API). Components use only these. */
|
|
2
|
+
@layer reset, tokens, base, components, brand;
|
|
3
|
+
|
|
4
|
+
@layer tokens {
|
|
5
|
+
:root,
|
|
6
|
+
:host {
|
|
7
|
+
color-scheme: light;
|
|
8
|
+
--sb-bg: #f8fafc;
|
|
9
|
+
--sb-surface: #ffffff;
|
|
10
|
+
--sb-surface-raised: #f1f5f9;
|
|
11
|
+
--sb-border: #e2e8f0;
|
|
12
|
+
--sb-text: #0f172a;
|
|
13
|
+
--sb-text-muted: #64748b;
|
|
14
|
+
--sb-accent: #2563eb;
|
|
15
|
+
--sb-accent-hover: #1d4ed8;
|
|
16
|
+
--sb-text-on-accent: #ffffff;
|
|
17
|
+
--sb-focus-ring: #93c5fd;
|
|
18
|
+
--sb-info: #0369a1;
|
|
19
|
+
--sb-success: #15803d;
|
|
20
|
+
--sb-success-subtle: #dcfce7;
|
|
21
|
+
--sb-danger: #b91c1c;
|
|
22
|
+
--sb-text-on-danger: #ffffff;
|
|
23
|
+
--sb-danger-subtle: #fee2e2;
|
|
24
|
+
--sb-warning: #b45309;
|
|
25
|
+
--sb-warning-subtle: #fef3c7;
|
|
26
|
+
--sb-method-get: #0369a1;
|
|
27
|
+
--sb-method-post: #15803d;
|
|
28
|
+
--sb-method-put: #b45309;
|
|
29
|
+
--sb-method-patch: #7c3aed;
|
|
30
|
+
--sb-method-delete: #b91c1c;
|
|
31
|
+
--sb-code-bg: #f8fafc;
|
|
32
|
+
--sb-code-line: #e8eefc;
|
|
33
|
+
--sb-code-key: #0369a1;
|
|
34
|
+
--sb-code-string: #15803d;
|
|
35
|
+
--sb-code-number: #b45309;
|
|
36
|
+
--sb-code-literal: #7c3aed;
|
|
37
|
+
--sb-code-keyword: #2563eb;
|
|
38
|
+
--sb-code-comment: #64748b;
|
|
39
|
+
--sb-code-punct: #475569;
|
|
40
|
+
--sb-font-sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
41
|
+
--sb-font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
42
|
+
--sb-radius-control: 6px;
|
|
43
|
+
--sb-radius-card: 10px;
|
|
44
|
+
--sb-space-1: 4px;
|
|
45
|
+
--sb-space-2: 8px;
|
|
46
|
+
--sb-space-3: 12px;
|
|
47
|
+
--sb-space-4: 16px;
|
|
48
|
+
--sb-space-6: 24px;
|
|
49
|
+
}
|
|
50
|
+
@media (prefers-color-scheme: dark) {
|
|
51
|
+
:root:not([data-theme="light"]),
|
|
52
|
+
:host(:not([data-theme="light"])) {
|
|
53
|
+
color-scheme: dark;
|
|
54
|
+
--sb-bg: #0b1020;
|
|
55
|
+
--sb-surface: #111827;
|
|
56
|
+
--sb-surface-raised: #1e293b;
|
|
57
|
+
--sb-border: #243049;
|
|
58
|
+
--sb-text: #e5e7eb;
|
|
59
|
+
--sb-text-muted: #94a3b8;
|
|
60
|
+
--sb-accent: #3b82f6;
|
|
61
|
+
--sb-accent-hover: #60a5fa;
|
|
62
|
+
--sb-text-on-accent: #0b1020;
|
|
63
|
+
--sb-focus-ring: #1d4ed8;
|
|
64
|
+
--sb-success: #4ade80;
|
|
65
|
+
--sb-success-subtle: #052e16;
|
|
66
|
+
--sb-danger: #f87171;
|
|
67
|
+
--sb-text-on-danger: #0b1020;
|
|
68
|
+
--sb-danger-subtle: #450a0a;
|
|
69
|
+
--sb-warning: #fbbf24;
|
|
70
|
+
--sb-warning-subtle: #422006;
|
|
71
|
+
--sb-method-get: #38bdf8;
|
|
72
|
+
--sb-method-post: #4ade80;
|
|
73
|
+
--sb-method-put: #fbbf24;
|
|
74
|
+
--sb-method-patch: #a78bfa;
|
|
75
|
+
--sb-method-delete: #f87171;
|
|
76
|
+
--sb-code-bg: #0f172a;
|
|
77
|
+
--sb-code-line: #1e2a4a;
|
|
78
|
+
--sb-code-key: #38bdf8;
|
|
79
|
+
--sb-code-string: #4ade80;
|
|
80
|
+
--sb-code-number: #fbbf24;
|
|
81
|
+
--sb-code-literal: #a78bfa;
|
|
82
|
+
--sb-code-keyword: #60a5fa;
|
|
83
|
+
--sb-code-comment: #94a3b8;
|
|
84
|
+
--sb-code-punct: #94a3b8;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
:root[data-theme="dark"],
|
|
88
|
+
:host([data-theme="dark"]) {
|
|
89
|
+
color-scheme: dark;
|
|
90
|
+
--sb-bg: #0b1020;
|
|
91
|
+
--sb-surface: #111827;
|
|
92
|
+
--sb-surface-raised: #1e293b;
|
|
93
|
+
--sb-border: #243049;
|
|
94
|
+
--sb-text: #e5e7eb;
|
|
95
|
+
--sb-text-muted: #94a3b8;
|
|
96
|
+
--sb-accent: #3b82f6;
|
|
97
|
+
--sb-accent-hover: #60a5fa;
|
|
98
|
+
--sb-text-on-accent: #0b1020;
|
|
99
|
+
--sb-focus-ring: #1d4ed8;
|
|
100
|
+
--sb-success: #4ade80;
|
|
101
|
+
--sb-success-subtle: #052e16;
|
|
102
|
+
--sb-danger: #f87171;
|
|
103
|
+
--sb-text-on-danger: #0b1020;
|
|
104
|
+
--sb-danger-subtle: #450a0a;
|
|
105
|
+
--sb-warning: #fbbf24;
|
|
106
|
+
--sb-warning-subtle: #422006;
|
|
107
|
+
--sb-method-get: #38bdf8;
|
|
108
|
+
--sb-method-post: #4ade80;
|
|
109
|
+
--sb-method-put: #fbbf24;
|
|
110
|
+
--sb-method-patch: #a78bfa;
|
|
111
|
+
--sb-method-delete: #f87171;
|
|
112
|
+
--sb-code-bg: #0f172a;
|
|
113
|
+
--sb-code-line: #1e2a4a;
|
|
114
|
+
--sb-code-key: #38bdf8;
|
|
115
|
+
--sb-code-string: #4ade80;
|
|
116
|
+
--sb-code-number: #fbbf24;
|
|
117
|
+
--sb-code-literal: #a78bfa;
|
|
118
|
+
--sb-code-keyword: #60a5fa;
|
|
119
|
+
--sb-code-comment: #94a3b8;
|
|
120
|
+
--sb-code-punct: #94a3b8;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { signal } from '@preact/signals'
|
|
2
|
+
|
|
3
|
+
export type Theme = 'auto' | 'light' | 'dark'
|
|
4
|
+
|
|
5
|
+
const KEY = 'specbook:theme'
|
|
6
|
+
|
|
7
|
+
function load(): Theme {
|
|
8
|
+
try {
|
|
9
|
+
const t = localStorage.getItem(KEY)
|
|
10
|
+
return t === 'light' || t === 'dark' ? t : 'auto'
|
|
11
|
+
} catch {
|
|
12
|
+
return 'auto'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** `auto` follows the operating system; the tokens react to `data-theme` on the root element. */
|
|
17
|
+
export const theme = signal<Theme>(load())
|
|
18
|
+
|
|
19
|
+
export function applyTheme(t: Theme = theme.value) {
|
|
20
|
+
if (typeof document === 'undefined') return
|
|
21
|
+
if (t === 'auto') delete document.documentElement.dataset.theme
|
|
22
|
+
else document.documentElement.dataset.theme = t
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const NEXT: Record<Theme, Theme> = { auto: 'light', light: 'dark', dark: 'auto' }
|
|
26
|
+
|
|
27
|
+
export function cycleTheme() {
|
|
28
|
+
const next = NEXT[theme.value]
|
|
29
|
+
theme.value = next
|
|
30
|
+
try {
|
|
31
|
+
if (next === 'auto') localStorage.removeItem(KEY)
|
|
32
|
+
else localStorage.setItem(KEY, next)
|
|
33
|
+
} catch {
|
|
34
|
+
/* optional */
|
|
35
|
+
}
|
|
36
|
+
applyTheme(next)
|
|
37
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import markUrl from '../assets/mark.svg'
|
|
2
|
+
|
|
3
|
+
const ROWS = [78, 92, 64, 84, 70, 88, 60, 80, 72, 90, 66, 76]
|
|
4
|
+
// the header's controls on a wide screen, by width: two labelled selects, then three icon buttons
|
|
5
|
+
const PILLS = [93, 118, 81, 72, 46, 46, 32]
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What a link to `?spec=` shows while the document downloads: the app's own header and two columns, so
|
|
9
|
+
* the real UI replaces it in place instead of a centred card giving way to a different layout. It reuses
|
|
10
|
+
* the shell's structural classes for the geometry and none of the ones tests and scripts look for.
|
|
11
|
+
*/
|
|
12
|
+
export function Opening({ what, progress }: { what: string; progress?: string }) {
|
|
13
|
+
return (
|
|
14
|
+
<div class="app sk" role="status" aria-live="polite" aria-busy="true">
|
|
15
|
+
<header class="header">
|
|
16
|
+
<span class="sk-brand">
|
|
17
|
+
<img src={markUrl} alt="" width="24" height="24" />
|
|
18
|
+
Specbook
|
|
19
|
+
</span>
|
|
20
|
+
<span class="sk-search" aria-hidden="true" />
|
|
21
|
+
<span class="spacer" />
|
|
22
|
+
{PILLS.map((w) => (
|
|
23
|
+
<span class="sk-pill" style={{ width: `${w}px` }} aria-hidden="true" />
|
|
24
|
+
))}
|
|
25
|
+
<span class="sk-menu" aria-hidden="true" />
|
|
26
|
+
</header>
|
|
27
|
+
<div class="layout" data-view="splash">
|
|
28
|
+
<nav class="list-col" aria-hidden="true">
|
|
29
|
+
<span class="sk-filter" />
|
|
30
|
+
{ROWS.map((w) => (
|
|
31
|
+
<span class="sk-row" style={{ width: `${w}%` }} />
|
|
32
|
+
))}
|
|
33
|
+
</nav>
|
|
34
|
+
<main class="stage">
|
|
35
|
+
<p class="sk-status">{progress ?? 'Opening…'}</p>
|
|
36
|
+
<p class="sk-what" title={what}>
|
|
37
|
+
{what}
|
|
38
|
+
</p>
|
|
39
|
+
</main>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { GeneratedFile } from '@specbook/codegen/browser'
|
|
2
|
+
|
|
3
|
+
/** Raw JSON text of what one operation's page needs, assembled without parsing it. */
|
|
4
|
+
export interface OpParts {
|
|
5
|
+
schema?: string
|
|
6
|
+
responses?: string
|
|
7
|
+
docs?: string
|
|
8
|
+
examples: Record<string, string>
|
|
9
|
+
mock: Record<string, string>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface Parts {
|
|
13
|
+
ops: Map<string, OpParts>
|
|
14
|
+
definitions: string
|
|
15
|
+
manifest: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const trim = (s: string) => s.trimEnd()
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sorts a data-only `generate` run into the same per-operation bundles the static build writes
|
|
22
|
+
* (`{ schema, responses, docs?, examples, mock }`), keeping each piece as the JSON text it already is.
|
|
23
|
+
*/
|
|
24
|
+
export function sortFiles(files: GeneratedFile[]): Parts {
|
|
25
|
+
const ops = new Map<string, OpParts>()
|
|
26
|
+
const at = (id: string) => {
|
|
27
|
+
const have = ops.get(id)
|
|
28
|
+
if (have) return have
|
|
29
|
+
const fresh: OpParts = { examples: {}, mock: {} }
|
|
30
|
+
ops.set(id, fresh)
|
|
31
|
+
return fresh
|
|
32
|
+
}
|
|
33
|
+
let definitions = '{}'
|
|
34
|
+
let manifest = '{}'
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
const parts = f.path.split('/')
|
|
37
|
+
if (f.path === 'schemas/definitions.schema.json') definitions = trim(f.content)
|
|
38
|
+
else if (f.path === 'manifest.json') manifest = f.content
|
|
39
|
+
else if (parts[0] === 'schemas' && parts[1]?.endsWith('.request.schema.json'))
|
|
40
|
+
at(parts[1].replace(/\.request\.schema\.json$/, '')).schema = trim(f.content)
|
|
41
|
+
else if (parts[0] === 'schemas' && parts[1]?.endsWith('.responses.schema.json'))
|
|
42
|
+
at(parts[1].replace(/\.responses\.schema\.json$/, '')).responses = trim(f.content)
|
|
43
|
+
else if (parts[0] === 'docs' && parts[1]?.endsWith('.json')) {
|
|
44
|
+
// nothing worth a file or a request: no text, and no examples beyond the generated pair
|
|
45
|
+
const d = JSON.parse(f.content) as { description?: string; examples?: unknown[] }
|
|
46
|
+
if (d.description || (d.examples?.length ?? 0) > 2)
|
|
47
|
+
at(parts[1].replace(/\.json$/, '')).docs = trim(f.content)
|
|
48
|
+
} else if (parts[0] === 'examples' && parts.length === 3) {
|
|
49
|
+
const [, id, file] = parts as [string, string, string]
|
|
50
|
+
if (file.endsWith('.example.json'))
|
|
51
|
+
at(id).examples[file.replace(/\.example\.json$/, '')] = trim(f.content)
|
|
52
|
+
else if (file.endsWith('.response.json'))
|
|
53
|
+
at(id).mock[file.replace(/\.response\.json$/, '')] = trim(f.content)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { ops, definitions, manifest }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const obj = (entries: Record<string, string>) =>
|
|
60
|
+
`{${Object.entries(entries)
|
|
61
|
+
.map(([k, v]) => `${JSON.stringify(k)}:${v}`)
|
|
62
|
+
.join(',')}}`
|
|
63
|
+
|
|
64
|
+
/** The JSON text of one operation's bundle. */
|
|
65
|
+
export function bundleJson(p: OpParts | undefined): string {
|
|
66
|
+
if (!p) return '{}'
|
|
67
|
+
const out: string[] = []
|
|
68
|
+
if (p.schema) out.push(`"schema":${p.schema}`)
|
|
69
|
+
if (p.responses) out.push(`"responses":${p.responses}`)
|
|
70
|
+
if (p.docs) out.push(`"docs":${p.docs}`)
|
|
71
|
+
if (Object.keys(p.examples).length) out.push(`"examples":${obj(p.examples)}`)
|
|
72
|
+
if (Object.keys(p.mock).length) out.push(`"mock":${obj(p.mock)}`)
|
|
73
|
+
return `{${out.join(',')}}`
|
|
74
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { OpenResult, Request, Response, Source } from './protocol'
|
|
2
|
+
import { startWorker } from './worker-factory'
|
|
3
|
+
|
|
4
|
+
type Call = Request extends infer R ? (R extends { id: number } ? Omit<R, 'id'> : never) : never
|
|
5
|
+
|
|
6
|
+
export type OpenFailure = Error & { code?: 'cors' | 'http' | 'too-large' | 'timeout' | 'parse' }
|
|
7
|
+
|
|
8
|
+
/** The main thread's handle on the spec worker: one request at a time per id, with progress. */
|
|
9
|
+
export class SpecWorker {
|
|
10
|
+
private worker: Worker
|
|
11
|
+
private next = 1
|
|
12
|
+
private pending = new Map<
|
|
13
|
+
number,
|
|
14
|
+
{ resolve(v: unknown): void; reject(e: Error): void; onProgress?: (m: string) => void }
|
|
15
|
+
>()
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
this.worker = startWorker()
|
|
19
|
+
this.worker.addEventListener('message', (e: MessageEvent<Response>) => {
|
|
20
|
+
const m = e.data
|
|
21
|
+
const p = this.pending.get(m.id)
|
|
22
|
+
if (!p) return
|
|
23
|
+
if ('progress' in m) return p.onProgress?.(m.progress)
|
|
24
|
+
this.pending.delete(m.id)
|
|
25
|
+
if (m.ok) p.resolve(m.result)
|
|
26
|
+
else p.reject(Object.assign(new Error(m.error), { code: m.code }) as OpenFailure)
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private call<T>(req: Call, onProgress?: (m: string) => void) {
|
|
31
|
+
const id = this.next++
|
|
32
|
+
return {
|
|
33
|
+
id,
|
|
34
|
+
promise: new Promise<T>((resolve, reject) => {
|
|
35
|
+
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject, onProgress })
|
|
36
|
+
this.worker.postMessage({ ...req, id } as Request)
|
|
37
|
+
}),
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
open(source: Source, onProgress?: (m: string) => void) {
|
|
42
|
+
const { id, promise } = this.call<OpenResult>({ type: 'open', source }, onProgress)
|
|
43
|
+
return { promise, cancel: () => this.worker.postMessage({ type: 'cancel', id } as Request) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async bundle(opId: string): Promise<unknown> {
|
|
47
|
+
const json = await this.call<string>({ type: 'bundle', opId }).promise
|
|
48
|
+
return JSON.parse(json)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async definitions(): Promise<unknown> {
|
|
52
|
+
return JSON.parse(await this.call<string>({ type: 'definitions' }).promise)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `specbook.js`: a page that already serves an API's spec (a framework's docs route) loads this to get the
|
|
3
|
+
* viewer, on its own origin, with no CORS and with the visitor's cookies.
|
|
4
|
+
*
|
|
5
|
+
* <div id="app"></div>
|
|
6
|
+
* <script type="application/json" id="specbook-config">{"spec": "/v3/api-docs"}</script>
|
|
7
|
+
* <script type="module" src="https://ui.specbook.dev/v1/specbook.js"></script>
|
|
8
|
+
*
|
|
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.
|
|
11
|
+
*/
|
|
12
|
+
import { handleOAuthCallback } from '../oauth-callback'
|
|
13
|
+
import { SpecWorker } from './client'
|
|
14
|
+
import { mountOpened } from './mount'
|
|
15
|
+
|
|
16
|
+
interface EmbedConfig {
|
|
17
|
+
spec?: string
|
|
18
|
+
baseUrl?: string
|
|
19
|
+
target?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readConfig(): EmbedConfig {
|
|
23
|
+
const el = document.getElementById('specbook-config')
|
|
24
|
+
if (el?.textContent) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(el.textContent) as EmbedConfig
|
|
27
|
+
} catch {
|
|
28
|
+
/* fall through to the script's own attributes */
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const script = document.querySelector<HTMLScriptElement>('script[src$="specbook.js"][data-spec]')
|
|
32
|
+
return { spec: script?.dataset.spec, baseUrl: script?.dataset.baseUrl }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function container(sel?: string): HTMLElement {
|
|
36
|
+
const found = sel ? document.querySelector<HTMLElement>(sel) : document.getElementById('app')
|
|
37
|
+
if (found) return found
|
|
38
|
+
const made = document.createElement('div')
|
|
39
|
+
made.id = 'app'
|
|
40
|
+
document.body.append(made)
|
|
41
|
+
return made
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const say = (el: HTMLElement, text: string, error = false) => {
|
|
45
|
+
el.textContent = text
|
|
46
|
+
el.style.cssText = `font:16px system-ui,sans-serif;padding:24px;color:${error ? '#b42318' : '#5b6270'}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function start() {
|
|
50
|
+
// A sign-in's popup comes back to this page, so the viewer is what has to hand the code on. The address
|
|
51
|
+
// is left alone unless a viewer tab claims it: the page is the host's, and its `?code=&state=` may be
|
|
52
|
+
// the host application's own sign-in.
|
|
53
|
+
if (await handleOAuthCallback({ stripWhenUnclaimed: false })) return
|
|
54
|
+
const cfg = readConfig()
|
|
55
|
+
const target = container(cfg.target)
|
|
56
|
+
if (!cfg.spec)
|
|
57
|
+
return say(target, 'Specbook: no spec was given (set "spec" in #specbook-config).', true)
|
|
58
|
+
let url: string
|
|
59
|
+
try {
|
|
60
|
+
url = new URL(cfg.spec, location.href).toString()
|
|
61
|
+
} catch {
|
|
62
|
+
return say(target, `Specbook: "${cfg.spec}" is not a valid address.`, true)
|
|
63
|
+
}
|
|
64
|
+
say(target, 'Loading the API…')
|
|
65
|
+
const worker = new SpecWorker()
|
|
66
|
+
try {
|
|
67
|
+
const result = await worker.open({ kind: 'url', url }).promise
|
|
68
|
+
target.textContent = ''
|
|
69
|
+
target.removeAttribute('style')
|
|
70
|
+
await mountOpened({
|
|
71
|
+
result,
|
|
72
|
+
worker,
|
|
73
|
+
source: { kind: 'url', url },
|
|
74
|
+
baseUrl: cfg.baseUrl,
|
|
75
|
+
shareable: false,
|
|
76
|
+
setTitle: false,
|
|
77
|
+
})
|
|
78
|
+
} catch (e) {
|
|
79
|
+
say(target, `Specbook: ${e instanceof Error ? e.message : String(e)}`, true)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
void start()
|