@specbook/ui 0.0.0-stage → 0.1.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/apple-touch-icon.png +0 -0
- package/dist-viewer/assets/Host-DdTTHeXU.js +37 -0
- package/dist-viewer/assets/faker-B3c03STC.js +11 -0
- package/dist-viewer/assets/index-DnYdlDXQ.js +1 -0
- package/dist-viewer/assets/index-Ds6DZpGP.css +1 -0
- package/dist-viewer/assets/main-uV4JzQPd.js +14 -0
- package/dist-viewer/assets/mount-B-qFOcWp.js +2 -0
- package/dist-viewer/assets/panel-CoQ6iQpX.js +1 -0
- package/dist-viewer/assets/panel-Coi9H1nv.css +1 -0
- package/dist-viewer/assets/registry-DI74Wtj8.js +2 -0
- package/dist-viewer/assets/worker-Dw3Ps3hk.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 +60 -3
- package/src/app.tsx +363 -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 +46 -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 +114 -0
- package/src/embed/styles.ts +27 -0
- package/src/embed.ts +65 -0
- package/src/engine.ts +432 -0
- package/src/extension.ts +101 -0
- package/src/fielddoc.ts +83 -0
- package/src/main.tsx +16 -0
- package/src/markdown.ts +133 -0
- package/src/nav.ts +118 -0
- package/src/panel/Body.tsx +182 -0
- package/src/panel/BodyEditor.tsx +92 -0
- package/src/panel/CodeSample.tsx +237 -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/OperationDocs.tsx +181 -0
- package/src/panel/Panel.tsx +193 -0
- package/src/panel/ParamGrid.tsx +396 -0
- package/src/panel/RequestPanel.tsx +625 -0
- package/src/panel/RequestView.tsx +108 -0
- package/src/panel/ResponseView.tsx +182 -0
- package/src/panel/SecretsBar.tsx +156 -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 +10 -0
- package/src/styles/app.css +821 -0
- package/src/styles/panel.css +1897 -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 +78 -0
- package/src/viewer/faker-en.ts +3 -0
- package/src/viewer/main.tsx +285 -0
- package/src/viewer/mount.ts +52 -0
- package/src/viewer/protocol.ts +51 -0
- package/src/viewer/recent.ts +22 -0
- package/src/viewer/registry-data.ts +4 -0
- package/src/viewer/registry.ts +154 -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 +171 -0
- package/src/virtual.d.ts +104 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the page in front of someone can be shared as a link, and the links to do it with. A link is
|
|
3
|
+
* only offered when everyone who opens it would get the same result: the document came from a public
|
|
4
|
+
* address (a spec fetched by the browser from our origin, so it allows our origin for everyone), and
|
|
5
|
+
* the address carries no credential.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type ViewerSource = { kind: 'url'; url: string } | { kind: 'text'; name?: string }
|
|
9
|
+
|
|
10
|
+
const SECRETISH =
|
|
11
|
+
/^(token|access_token|id_token|refresh_token|key|api[-_]?key|apikey|secret|client_secret|password|passwd|pwd|auth|authorization|signature|sig|jwt|session|sid|code|x-amz-.*|x-goog-.*)$/i
|
|
12
|
+
|
|
13
|
+
const PRIVATE_HOST = /\.(local|internal|lan|home|corp|intranet|localhost)$/i
|
|
14
|
+
|
|
15
|
+
function privateAddress(host: string): boolean {
|
|
16
|
+
const h = host.replace(/^\[|\]$/g, '').toLowerCase()
|
|
17
|
+
if (h === 'localhost' || PRIVATE_HOST.test(h)) return true
|
|
18
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
|
|
19
|
+
if (v4) {
|
|
20
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])]
|
|
21
|
+
return (
|
|
22
|
+
a === 0 ||
|
|
23
|
+
a === 10 ||
|
|
24
|
+
a === 127 ||
|
|
25
|
+
(a === 169 && b === 254) ||
|
|
26
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
27
|
+
(a === 192 && b === 168) ||
|
|
28
|
+
(a === 100 && b >= 64 && b <= 127)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
return h === '::1' || /^f[cd]/.test(h) || /^fe[89ab]/.test(h)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A spec address in a link: the colon and slashes of a URL are legal in a query string and stay as they
|
|
36
|
+
* are, so the link reads as `?spec=https://host/openapi.json`; `&`, `#` and `%` are still escaped.
|
|
37
|
+
*/
|
|
38
|
+
export const softEncode = (s: string) =>
|
|
39
|
+
encodeURIComponent(s).replace(/%3A/gi, ':').replace(/%2F/gi, '/')
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The address of a file as GitHub serves it to programs. A file page (`github.com/o/r/blob/<ref>/<path>`) and
|
|
43
|
+
* its "Raw" link (`github.com/o/r/raw/<ref>/<path>`, which redirects without CORS headers) are HTML or a
|
|
44
|
+
* redirect; the document itself is at `raw.githubusercontent.com/o/r/<ref>/<path>`. Anything else is
|
|
45
|
+
* returned unchanged. The landing page keeps a copy of this (`landing/landing.js`, plain script).
|
|
46
|
+
*/
|
|
47
|
+
export function rawGithub(address: string): string {
|
|
48
|
+
let u: URL
|
|
49
|
+
try {
|
|
50
|
+
u = new URL(address)
|
|
51
|
+
} catch {
|
|
52
|
+
return address
|
|
53
|
+
}
|
|
54
|
+
if (u.protocol !== 'https:' || !/^(www\.)?github\.com$/i.test(u.hostname)) return address
|
|
55
|
+
const m = /^\/([^/]+)\/([^/]+)\/(?:blob|raw)\/(.+)$/.exec(u.pathname)
|
|
56
|
+
return m ? `https://raw.githubusercontent.com/${m[1]}/${m[2]}/${m[3]}` : address
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type Shareable = { ok: true; spec: string } | { ok: false; reason: string }
|
|
60
|
+
|
|
61
|
+
export function shareable(source: ViewerSource | undefined): Shareable {
|
|
62
|
+
if (!source || source.kind === 'text')
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
reason:
|
|
66
|
+
'This document was pasted or opened from a file, so there is no address to link to. Put the file somewhere public (a GitHub raw link or a Gist works) and open that address to get a link.',
|
|
67
|
+
}
|
|
68
|
+
let u: URL
|
|
69
|
+
try {
|
|
70
|
+
u = new URL(source.url)
|
|
71
|
+
} catch {
|
|
72
|
+
return { ok: false, reason: 'The document address is not a valid link.' }
|
|
73
|
+
}
|
|
74
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:')
|
|
75
|
+
return { ok: false, reason: 'Only http and https addresses can be linked.' }
|
|
76
|
+
if (privateAddress(u.hostname))
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
reason:
|
|
80
|
+
'That address is only reachable from your own machine or network, so a link would not work for anyone else.',
|
|
81
|
+
}
|
|
82
|
+
if (u.username || u.password)
|
|
83
|
+
return { ok: false, reason: 'The address contains a login, which a link would publish.' }
|
|
84
|
+
for (const k of u.searchParams.keys())
|
|
85
|
+
if (SECRETISH.test(k))
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: `The address has a "${k}" parameter that looks like a credential, which a link would publish.`,
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, spec: u.toString() }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ShareLinks {
|
|
94
|
+
link: string
|
|
95
|
+
title: string
|
|
96
|
+
targets: { id: string; label: string; href: string }[]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** `location` is the viewer's own (origin, path and the hash of the operation being looked at). */
|
|
100
|
+
export function shareLinks(
|
|
101
|
+
where: { origin: string; pathname: string; hash: string },
|
|
102
|
+
spec: string,
|
|
103
|
+
apiTitle: string,
|
|
104
|
+
): ShareLinks {
|
|
105
|
+
const link = `${where.origin}${where.pathname}?spec=${softEncode(spec)}${where.hash}`
|
|
106
|
+
const title = `Exploring ${apiTitle} in Specbook`
|
|
107
|
+
const q = (o: Record<string, string>) => new URLSearchParams(o).toString()
|
|
108
|
+
return {
|
|
109
|
+
link,
|
|
110
|
+
title,
|
|
111
|
+
targets: [
|
|
112
|
+
{
|
|
113
|
+
id: 'x',
|
|
114
|
+
label: 'X',
|
|
115
|
+
href: `https://twitter.com/intent/tweet?${q({ text: title, url: link })}`,
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
id: 'reddit',
|
|
119
|
+
label: 'Reddit',
|
|
120
|
+
href: `https://www.reddit.com/submit?${q({ url: link, title })}`,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: 'hn',
|
|
124
|
+
label: 'Hacker News',
|
|
125
|
+
href: `https://news.ycombinator.com/submitlink?${q({ u: link, t: title })}`,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'linkedin',
|
|
129
|
+
label: 'LinkedIn',
|
|
130
|
+
href: `https://www.linkedin.com/sharing/share-offsite/?${q({ url: link })}`,
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pasted and dropped documents, kept in this browser so a reload reopens them. They are never shared
|
|
3
|
+
* (a link cannot carry them) and only the last few are kept.
|
|
4
|
+
*/
|
|
5
|
+
const DB = 'specbook-viewer'
|
|
6
|
+
const STORE = 'uploads'
|
|
7
|
+
const KEEP = 5
|
|
8
|
+
|
|
9
|
+
interface Row {
|
|
10
|
+
id: string
|
|
11
|
+
name: string
|
|
12
|
+
text: string
|
|
13
|
+
at: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function db(): Promise<IDBDatabase> {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const r = indexedDB.open(DB, 1)
|
|
19
|
+
r.onupgradeneeded = () => r.result.createObjectStore(STORE, { keyPath: 'id' })
|
|
20
|
+
r.onsuccess = () => resolve(r.result)
|
|
21
|
+
r.onerror = () => reject(r.error)
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const done = <T>(r: IDBRequest<T>) =>
|
|
26
|
+
new Promise<T>((resolve, reject) => {
|
|
27
|
+
r.onsuccess = () => resolve(r.result)
|
|
28
|
+
r.onerror = () => reject(r.error)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export async function saveUpload(name: string, text: string): Promise<string | undefined> {
|
|
32
|
+
try {
|
|
33
|
+
const d = await db()
|
|
34
|
+
const id = Math.random().toString(36).slice(2, 10)
|
|
35
|
+
const s = d.transaction(STORE, 'readwrite').objectStore(STORE)
|
|
36
|
+
await done(s.put({ id, name, text, at: Date.now() } satisfies Row))
|
|
37
|
+
const all = (await done(s.getAll())) as Row[]
|
|
38
|
+
for (const old of all.sort((a, b) => b.at - a.at).slice(KEEP)) s.delete(old.id)
|
|
39
|
+
return id
|
|
40
|
+
} catch {
|
|
41
|
+
return undefined
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function loadUpload(id: string): Promise<{ name: string; text: string } | undefined> {
|
|
46
|
+
try {
|
|
47
|
+
const d = await db()
|
|
48
|
+
const row = (await done(d.transaction(STORE).objectStore(STORE).get(id))) as Row | undefined
|
|
49
|
+
return row ? { name: row.name, text: row.text } : undefined
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
.vw {
|
|
2
|
+
min-height: 100dvh;
|
|
3
|
+
display: grid;
|
|
4
|
+
place-items: center;
|
|
5
|
+
padding: 24px 16px;
|
|
6
|
+
background: var(--sb-bg);
|
|
7
|
+
color: var(--sb-text);
|
|
8
|
+
font-family: var(--sb-font-sans);
|
|
9
|
+
}
|
|
10
|
+
.vw-card {
|
|
11
|
+
width: min(640px, 100%);
|
|
12
|
+
display: grid;
|
|
13
|
+
gap: 16px;
|
|
14
|
+
}
|
|
15
|
+
.vw h1 {
|
|
16
|
+
margin: 0;
|
|
17
|
+
font-size: 28px;
|
|
18
|
+
letter-spacing: -0.01em;
|
|
19
|
+
}
|
|
20
|
+
.vw p {
|
|
21
|
+
margin: 0;
|
|
22
|
+
color: var(--sb-text-muted);
|
|
23
|
+
}
|
|
24
|
+
.vw form {
|
|
25
|
+
display: flex;
|
|
26
|
+
gap: 8px;
|
|
27
|
+
}
|
|
28
|
+
.vw input[type="url"],
|
|
29
|
+
.vw input[type="text"],
|
|
30
|
+
.vw textarea {
|
|
31
|
+
flex: 1;
|
|
32
|
+
min-width: 0;
|
|
33
|
+
font: inherit;
|
|
34
|
+
font-size: 16px;
|
|
35
|
+
padding: 10px 12px;
|
|
36
|
+
color: var(--sb-text);
|
|
37
|
+
background: var(--sb-surface);
|
|
38
|
+
border: 1px solid var(--sb-border);
|
|
39
|
+
border-radius: var(--sb-radius-control);
|
|
40
|
+
}
|
|
41
|
+
.vw textarea {
|
|
42
|
+
width: 100%;
|
|
43
|
+
min-height: 120px;
|
|
44
|
+
font-family: var(--sb-font-mono);
|
|
45
|
+
font-size: 13px;
|
|
46
|
+
}
|
|
47
|
+
.vw .vw-drop {
|
|
48
|
+
border: 1px dashed var(--sb-border);
|
|
49
|
+
border-radius: var(--sb-radius-control);
|
|
50
|
+
padding: 14px;
|
|
51
|
+
text-align: center;
|
|
52
|
+
color: var(--sb-text-muted);
|
|
53
|
+
}
|
|
54
|
+
.vw .vw-drop.over {
|
|
55
|
+
border-color: var(--sb-accent);
|
|
56
|
+
color: var(--sb-text);
|
|
57
|
+
}
|
|
58
|
+
.vw .vw-error {
|
|
59
|
+
color: var(--sb-danger);
|
|
60
|
+
background: var(--sb-danger-subtle);
|
|
61
|
+
border-radius: var(--sb-radius-control);
|
|
62
|
+
padding: 10px 12px;
|
|
63
|
+
}
|
|
64
|
+
.vw .vw-warn {
|
|
65
|
+
color: var(--sb-warning);
|
|
66
|
+
}
|
|
67
|
+
.vw ul {
|
|
68
|
+
margin: 0;
|
|
69
|
+
padding: 0;
|
|
70
|
+
list-style: none;
|
|
71
|
+
display: grid;
|
|
72
|
+
gap: 4px;
|
|
73
|
+
}
|
|
74
|
+
.vw li button.link {
|
|
75
|
+
max-width: 100%;
|
|
76
|
+
overflow: hidden;
|
|
77
|
+
text-overflow: ellipsis;
|
|
78
|
+
white-space: nowrap;
|
|
79
|
+
}
|
|
80
|
+
.vw .vw-progress {
|
|
81
|
+
color: var(--sb-text-muted);
|
|
82
|
+
}
|
|
83
|
+
@media (max-width: 480px) {
|
|
84
|
+
.vw form {
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* the opening screen: the app's header and columns, with placeholders where its content will be */
|
|
90
|
+
.sk .sk-brand {
|
|
91
|
+
display: inline-flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
gap: 8px;
|
|
94
|
+
font-size: 15px;
|
|
95
|
+
font-weight: 700;
|
|
96
|
+
letter-spacing: -0.01em;
|
|
97
|
+
color: var(--sb-text);
|
|
98
|
+
/* the real brand is a button, and carries its padding */
|
|
99
|
+
padding: 1px 6px;
|
|
100
|
+
}
|
|
101
|
+
.sk .sk-search {
|
|
102
|
+
min-width: min(360px, 40vw);
|
|
103
|
+
height: 34px;
|
|
104
|
+
border: 1px solid var(--sb-border);
|
|
105
|
+
border-radius: var(--sb-radius-control);
|
|
106
|
+
background: var(--sb-bg);
|
|
107
|
+
}
|
|
108
|
+
.sk .sk-pill {
|
|
109
|
+
height: 32px;
|
|
110
|
+
border-radius: var(--sb-radius-control);
|
|
111
|
+
background: var(--sb-surface-raised);
|
|
112
|
+
}
|
|
113
|
+
.sk .sk-menu {
|
|
114
|
+
display: none;
|
|
115
|
+
width: 36px;
|
|
116
|
+
height: 36px;
|
|
117
|
+
border-radius: var(--sb-radius-control);
|
|
118
|
+
background: var(--sb-surface-raised);
|
|
119
|
+
}
|
|
120
|
+
.sk .sk-filter {
|
|
121
|
+
display: block;
|
|
122
|
+
height: 34px;
|
|
123
|
+
border: 1px solid var(--sb-border);
|
|
124
|
+
border-radius: var(--sb-radius-control);
|
|
125
|
+
background: var(--sb-bg);
|
|
126
|
+
}
|
|
127
|
+
.sk .stage {
|
|
128
|
+
display: flex;
|
|
129
|
+
flex-direction: column;
|
|
130
|
+
align-items: center;
|
|
131
|
+
justify-content: center;
|
|
132
|
+
text-align: center;
|
|
133
|
+
}
|
|
134
|
+
.sk .sk-row {
|
|
135
|
+
display: block;
|
|
136
|
+
height: 34px;
|
|
137
|
+
margin: 12px 0 0;
|
|
138
|
+
border-radius: var(--sb-radius-control);
|
|
139
|
+
background: linear-gradient(
|
|
140
|
+
90deg,
|
|
141
|
+
var(--sb-surface-raised) 0%,
|
|
142
|
+
var(--sb-bg) 50%,
|
|
143
|
+
var(--sb-surface-raised) 100%
|
|
144
|
+
);
|
|
145
|
+
background-size: 200% 100%;
|
|
146
|
+
animation: sk-shimmer 1.4s linear infinite;
|
|
147
|
+
}
|
|
148
|
+
.sk .sk-status {
|
|
149
|
+
margin: 0;
|
|
150
|
+
font-size: 18px;
|
|
151
|
+
font-weight: 600;
|
|
152
|
+
color: var(--sb-text);
|
|
153
|
+
}
|
|
154
|
+
.sk .sk-what {
|
|
155
|
+
margin: 6px 0 0;
|
|
156
|
+
max-width: 100%;
|
|
157
|
+
overflow: hidden;
|
|
158
|
+
text-overflow: ellipsis;
|
|
159
|
+
white-space: nowrap;
|
|
160
|
+
color: var(--sb-text-muted);
|
|
161
|
+
}
|
|
162
|
+
@keyframes sk-shimmer {
|
|
163
|
+
to {
|
|
164
|
+
background-position: -200% 0;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
@media (prefers-reduced-motion: reduce) {
|
|
168
|
+
.sk .sk-row {
|
|
169
|
+
animation: none;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
@media (max-width: 900px) {
|
|
173
|
+
.sk .sk-search {
|
|
174
|
+
flex: 1;
|
|
175
|
+
min-width: 0;
|
|
176
|
+
}
|
|
177
|
+
.sk .sk-pill,
|
|
178
|
+
.sk .spacer {
|
|
179
|
+
display: none;
|
|
180
|
+
}
|
|
181
|
+
.sk .sk-menu {
|
|
182
|
+
display: block;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import workerUrl from './worker.ts?worker&url'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The spec worker. A browser will not start a `Worker` from another origin's URL, and the embed script
|
|
5
|
+
* is exactly that when a page loads it from the CDN, so a cross-origin worker is started from a blob
|
|
6
|
+
* that imports it (the module's own CORS headers make that allowed). A host page whose CSP forbids blob
|
|
7
|
+
* workers cannot use this path; it serves the viewer's files itself instead.
|
|
8
|
+
*/
|
|
9
|
+
export function startWorker(): Worker {
|
|
10
|
+
const url = new URL(workerUrl, import.meta.url)
|
|
11
|
+
if (url.origin === location.origin) return new Worker(url, { type: 'module' })
|
|
12
|
+
const blob = new Blob([`import ${JSON.stringify(url.href)}`], { type: 'text/javascript' })
|
|
13
|
+
return new Worker(URL.createObjectURL(blob), { type: 'module' })
|
|
14
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/// <reference lib="webworker" />
|
|
2
|
+
import { generateData, type OperationMeta, openSpec } from '@specbook/codegen/browser'
|
|
3
|
+
import { authFromSpec, liveBaseUrl } from '@specbook/core'
|
|
4
|
+
import { bundleJson, type Parts, sortFiles } from './bundles'
|
|
5
|
+
import type { OpenResult, Request, Response, Source } from './protocol'
|
|
6
|
+
|
|
7
|
+
const MAX_BYTES = 25 * 1024 * 1024
|
|
8
|
+
const TIMEOUT_MS = 30_000
|
|
9
|
+
|
|
10
|
+
const post = (r: Response) => (self as unknown as Worker).postMessage(r)
|
|
11
|
+
|
|
12
|
+
let parts: Parts | undefined
|
|
13
|
+
const aborts = new Map<number, AbortController>()
|
|
14
|
+
|
|
15
|
+
class Failure extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
readonly code?: 'cors' | 'http' | 'too-large' | 'timeout' | 'parse',
|
|
19
|
+
) {
|
|
20
|
+
super(message)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const slug = (s: string) =>
|
|
25
|
+
s
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
28
|
+
.replace(/^-+|-+$/g, '') || 'api'
|
|
29
|
+
|
|
30
|
+
/** The document's bytes, read as a stream so a cap applies before it is all in memory. */
|
|
31
|
+
async function download(url: string, signal: AbortSignal): Promise<string> {
|
|
32
|
+
let res: globalThis.Response
|
|
33
|
+
try {
|
|
34
|
+
res = await fetch(url, { signal, redirect: 'follow', credentials: 'omit' })
|
|
35
|
+
} catch {
|
|
36
|
+
if (signal.aborted) throw new Failure('The server took too long to answer.', 'timeout')
|
|
37
|
+
throw new Failure(
|
|
38
|
+
'The browser could not read that address. The server may not allow requests from other sites (CORS), or the address is not reachable from here.',
|
|
39
|
+
'cors',
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
if (!res.ok) throw new Failure(`The server answered ${res.status} for that address.`, 'http')
|
|
43
|
+
const declared = Number(res.headers.get('content-length') ?? 0)
|
|
44
|
+
if (declared > MAX_BYTES) throw new Failure('That document is larger than 25 MB.', 'too-large')
|
|
45
|
+
const reader = res.body?.getReader()
|
|
46
|
+
if (!reader) return res.text()
|
|
47
|
+
const chunks: Uint8Array[] = []
|
|
48
|
+
let size = 0
|
|
49
|
+
for (;;) {
|
|
50
|
+
const { done, value } = await reader.read()
|
|
51
|
+
if (done) break
|
|
52
|
+
size += value.length
|
|
53
|
+
if (size > MAX_BYTES) {
|
|
54
|
+
await reader.cancel()
|
|
55
|
+
throw new Failure('That document is larger than 25 MB.', 'too-large')
|
|
56
|
+
}
|
|
57
|
+
chunks.push(value)
|
|
58
|
+
}
|
|
59
|
+
const all = new Uint8Array(size)
|
|
60
|
+
let o = 0
|
|
61
|
+
for (const c of chunks) {
|
|
62
|
+
all.set(c, o)
|
|
63
|
+
o += c.length
|
|
64
|
+
}
|
|
65
|
+
return new TextDecoder().decode(all)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function open(id: number, source: Source, signal: AbortSignal): Promise<OpenResult> {
|
|
69
|
+
const t0 = performance.now()
|
|
70
|
+
let text: string
|
|
71
|
+
let fetched: number | undefined
|
|
72
|
+
let name: string | undefined
|
|
73
|
+
if (source.kind === 'url') {
|
|
74
|
+
post({ id, progress: 'Fetching the document…' })
|
|
75
|
+
const t = performance.now()
|
|
76
|
+
text = await download(source.url, signal)
|
|
77
|
+
fetched = performance.now() - t
|
|
78
|
+
name = source.url
|
|
79
|
+
} else {
|
|
80
|
+
text = source.text
|
|
81
|
+
name = source.name
|
|
82
|
+
}
|
|
83
|
+
post({ id, progress: 'Reading the document…' })
|
|
84
|
+
const tParse = performance.now()
|
|
85
|
+
let opened: Awaited<ReturnType<typeof openSpec>>
|
|
86
|
+
try {
|
|
87
|
+
opened = await openSpec(text)
|
|
88
|
+
} catch (e) {
|
|
89
|
+
throw new Failure(e instanceof Error ? e.message : String(e), 'parse')
|
|
90
|
+
}
|
|
91
|
+
const parse = performance.now() - tParse
|
|
92
|
+
const { doc, warnings } = opened
|
|
93
|
+
|
|
94
|
+
post({ id, progress: 'Preparing operations…' })
|
|
95
|
+
const title = (doc.info?.title as string | undefined) ?? 'API'
|
|
96
|
+
const service = slug(title)
|
|
97
|
+
const tGen = performance.now()
|
|
98
|
+
const generated = generateData(doc, { service })
|
|
99
|
+
const generate = performance.now() - tGen
|
|
100
|
+
|
|
101
|
+
parts = sortFiles(generated.files)
|
|
102
|
+
const manifest = JSON.parse(parts.manifest) as Record<string, any>
|
|
103
|
+
const slim = {
|
|
104
|
+
...manifest,
|
|
105
|
+
operations: manifest.operations.map(
|
|
106
|
+
({ description: _d, examples: _e, file: _f, ...o }: Record<string, unknown>) => o,
|
|
107
|
+
),
|
|
108
|
+
}
|
|
109
|
+
const exampleNames = Object.fromEntries(
|
|
110
|
+
manifest.operations.map((o: any) => [
|
|
111
|
+
o.id,
|
|
112
|
+
(o.examples as { name: string }[]).map((e) => e.name),
|
|
113
|
+
]),
|
|
114
|
+
)
|
|
115
|
+
const { auth, secrets, defaultProfile } = authFromSpec(doc)
|
|
116
|
+
const sourceUrl = source.kind === 'url' ? source.url : undefined
|
|
117
|
+
const baseUrl = liveBaseUrl(doc, sourceUrl ?? name ?? '')
|
|
118
|
+
const relativeServer =
|
|
119
|
+
typeof doc.servers?.[0]?.url === 'string' && !/^https?:\/\//i.test(doc.servers[0].url)
|
|
120
|
+
return {
|
|
121
|
+
service,
|
|
122
|
+
title,
|
|
123
|
+
manifest: slim,
|
|
124
|
+
metas: generated.metas as unknown as Record<string, Record<string, unknown>>,
|
|
125
|
+
exampleNames,
|
|
126
|
+
auth,
|
|
127
|
+
secrets,
|
|
128
|
+
defaultProfile,
|
|
129
|
+
baseUrl,
|
|
130
|
+
needsBaseUrl: !baseUrl && relativeServer,
|
|
131
|
+
warnings,
|
|
132
|
+
timings: {
|
|
133
|
+
...(fetched !== undefined ? { fetch: Math.round(fetched) } : {}),
|
|
134
|
+
parse: Math.round(parse),
|
|
135
|
+
generate: Math.round(generate),
|
|
136
|
+
total: Math.round(performance.now() - t0),
|
|
137
|
+
},
|
|
138
|
+
bytes: { spec: text.length, definitions: parts.definitions.length },
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
self.addEventListener('message', async (e: MessageEvent<Request>) => {
|
|
143
|
+
const m = e.data
|
|
144
|
+
try {
|
|
145
|
+
if (m.type === 'cancel') {
|
|
146
|
+
aborts.get(m.id)?.abort()
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (m.type === 'bundle') {
|
|
150
|
+
return post({ id: m.id, ok: true, result: bundleJson(parts?.ops.get(m.opId)) })
|
|
151
|
+
}
|
|
152
|
+
if (m.type === 'definitions') {
|
|
153
|
+
return post({ id: m.id, ok: true, result: parts?.definitions ?? '{}' })
|
|
154
|
+
}
|
|
155
|
+
const ctl = new AbortController()
|
|
156
|
+
aborts.set(m.id, ctl)
|
|
157
|
+
const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS)
|
|
158
|
+
try {
|
|
159
|
+
const result = await open(m.id, m.source, ctl.signal)
|
|
160
|
+
post({ id: m.id, ok: true, result })
|
|
161
|
+
} finally {
|
|
162
|
+
clearTimeout(timer)
|
|
163
|
+
aborts.delete(m.id)
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
if (err instanceof Failure) post({ id: m.id, ok: false, error: err.message, code: err.code })
|
|
167
|
+
else post({ id: m.id, ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
export type { OperationMeta }
|
package/src/virtual.d.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
declare module 'virtual:specbook/registry' {
|
|
2
|
+
interface Entry {
|
|
3
|
+
file: string
|
|
4
|
+
load: (version: number) => Promise<Record<string, any>>
|
|
5
|
+
/** static builds only */
|
|
6
|
+
source?: () => Promise<string>
|
|
7
|
+
}
|
|
8
|
+
export const mode: 'dev' | 'static'
|
|
9
|
+
export const project: { root: string; outDir: string; requestsDir: string }
|
|
10
|
+
export const manifests: Record<
|
|
11
|
+
string,
|
|
12
|
+
{
|
|
13
|
+
service: string
|
|
14
|
+
title?: string
|
|
15
|
+
version?: string
|
|
16
|
+
/** from the spec's `info.x-logo` */
|
|
17
|
+
logo?: { url: string; altText?: string; backgroundColor?: string }
|
|
18
|
+
tags: string[]
|
|
19
|
+
operations: {
|
|
20
|
+
id: string
|
|
21
|
+
method: string
|
|
22
|
+
path: string
|
|
23
|
+
summary?: string
|
|
24
|
+
tags: string[]
|
|
25
|
+
statuses: number[]
|
|
26
|
+
/** the spec requires credentials */
|
|
27
|
+
secured?: boolean
|
|
28
|
+
}[]
|
|
29
|
+
}
|
|
30
|
+
>
|
|
31
|
+
export const requests: Entry[]
|
|
32
|
+
export const sequences: Entry[]
|
|
33
|
+
/** Sequence headers read from the `*.seq.ts` text (no import), keyed by id. */
|
|
34
|
+
export const sequenceMeta: Record<
|
|
35
|
+
string,
|
|
36
|
+
{
|
|
37
|
+
id: string
|
|
38
|
+
title: string
|
|
39
|
+
description?: string
|
|
40
|
+
tags?: string[]
|
|
41
|
+
group?: string
|
|
42
|
+
file: string
|
|
43
|
+
}
|
|
44
|
+
>
|
|
45
|
+
/** Sequence files whose header is not plain literals; these are imported to be listed. */
|
|
46
|
+
export const unresolvedSequences: string[]
|
|
47
|
+
/** `.specbook/branding/logo.*` and `favicon.*`, resolved to URLs on demand. */
|
|
48
|
+
export const brandingAssets: Partial<Record<'logo' | 'favicon', () => Promise<string>>>
|
|
49
|
+
/** Non-secret variables from `.specbook/environments/<env>.env`, by environment. */
|
|
50
|
+
export const environmentVars: Record<string, Record<string, string>>
|
|
51
|
+
/** Long description and example titles per `service/OperationId`, loaded when its page opens. */
|
|
52
|
+
export const operationDocs: Record<
|
|
53
|
+
string,
|
|
54
|
+
() => Promise<{
|
|
55
|
+
default: { description?: string; examples: { name: string; title: string; source: string }[] }
|
|
56
|
+
}>
|
|
57
|
+
>
|
|
58
|
+
export const operations: Record<string, (version: number) => Promise<Record<string, any>>>
|
|
59
|
+
/** JSON Schema of the request object ({ path, query, headers, data }) per `service/OperationId` */
|
|
60
|
+
export const schemas: Record<string, () => Promise<{ default: Record<string, any> }>>
|
|
61
|
+
/** JSON Schema per response status, per `service/OperationId`: { "200": schema, ... } */
|
|
62
|
+
export const responseSchemas: Record<string, () => Promise<{ default: Record<string, any> }>>
|
|
63
|
+
/** Shared component definitions per service */
|
|
64
|
+
export const definitions: Record<string, () => Promise<{ default: Record<string, any> }>>
|
|
65
|
+
export const examples: Record<string, (version: number) => Promise<{ default: unknown }>>
|
|
66
|
+
/** Request examples per `service/OperationId`; project files override generated ones of the same name. */
|
|
67
|
+
export const requestExamples: Record<
|
|
68
|
+
string,
|
|
69
|
+
{
|
|
70
|
+
name: string
|
|
71
|
+
source: 'generated' | 'project'
|
|
72
|
+
dir: string
|
|
73
|
+
load: (version: number) => Promise<{ default: unknown }>
|
|
74
|
+
}[]
|
|
75
|
+
>
|
|
76
|
+
/** Non-JSON files an example uploads, keyed `<dir>/<file>`; resolves to a URL. */
|
|
77
|
+
export const assets: Record<string, () => Promise<string>>
|
|
78
|
+
/** Markdown pages (compiled at build time) tied to an operation or sequence. */
|
|
79
|
+
export const docs: {
|
|
80
|
+
file: string
|
|
81
|
+
operation?: string
|
|
82
|
+
sequence?: string
|
|
83
|
+
/** the `.seq.ts` this page sits beside */
|
|
84
|
+
sequenceFile?: string
|
|
85
|
+
/** the `.req.ts` this page sits beside */
|
|
86
|
+
requestFile?: string
|
|
87
|
+
/** the page replaces the operation's spec description */
|
|
88
|
+
replace?: boolean
|
|
89
|
+
title?: string
|
|
90
|
+
load: () => Promise<string>
|
|
91
|
+
}[]
|
|
92
|
+
export const loadFaker: ((version: number) => Promise<Record<string, any>>) | null
|
|
93
|
+
export const loadConfig: (version: number) => Promise<{ default: any }>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
declare module 'virtual:specbook/types' {
|
|
97
|
+
const types: { libs: Record<string, string>; sources: Record<string, string> }
|
|
98
|
+
export default types
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
declare module 'virtual:specbook/faker' {
|
|
102
|
+
const mod: Record<string, any>
|
|
103
|
+
export = mod
|
|
104
|
+
}
|