@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
package/src/extension.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createFetchTransport,
|
|
3
|
+
requestToWire,
|
|
4
|
+
type Transport,
|
|
5
|
+
WIRE_VERSION,
|
|
6
|
+
type WireCall,
|
|
7
|
+
type WireReply,
|
|
8
|
+
wireToResponse,
|
|
9
|
+
} from '@specbook/core'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The page's side of the browser extension. The extension (its own package, not built yet) makes the
|
|
13
|
+
* outbound call from its service worker with the user's grant, using `handleWireFetch` from core, so the
|
|
14
|
+
* policy and secret substitution are the ones the dev server and a hosted proxy run.
|
|
15
|
+
*
|
|
16
|
+
* A bridge is anything that can carry a `WireCall` to it and bring a `WireReply` back: a content script
|
|
17
|
+
* relaying `postMessage`, or a test double.
|
|
18
|
+
*/
|
|
19
|
+
export interface ExtensionBridge {
|
|
20
|
+
/** Protocol version the extension speaks. */
|
|
21
|
+
version: number
|
|
22
|
+
send(call: WireCall, signal?: AbortSignal): Promise<WireReply>
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
declare global {
|
|
26
|
+
// a content script (or a test) may place its bridge here
|
|
27
|
+
var __SPECBOOK_EXTENSION__: ExtensionBridge | undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const CHANNEL = 'specbook'
|
|
31
|
+
|
|
32
|
+
/** `postMessage` to a content script that answers on the same window. */
|
|
33
|
+
function windowBridge(version: number): ExtensionBridge {
|
|
34
|
+
return {
|
|
35
|
+
version,
|
|
36
|
+
send(call, signal) {
|
|
37
|
+
return new Promise<WireReply>((resolve, reject) => {
|
|
38
|
+
const id = (call as { id?: string }).id
|
|
39
|
+
const on = (e: MessageEvent) => {
|
|
40
|
+
const m = e.data as { channel?: string; reply?: WireReply } | undefined
|
|
41
|
+
if (e.source !== window || m?.channel !== `${CHANNEL}:reply` || !m.reply) return
|
|
42
|
+
const r = m.reply as { id?: string; type: string }
|
|
43
|
+
if (id !== undefined && r.id !== id) return
|
|
44
|
+
cleanup()
|
|
45
|
+
resolve(m.reply)
|
|
46
|
+
}
|
|
47
|
+
const abort = () => {
|
|
48
|
+
cleanup()
|
|
49
|
+
window.postMessage({ channel: `${CHANNEL}:call`, call: { v: 1, type: 'abort', id } }, '*')
|
|
50
|
+
reject(new DOMException('aborted', 'AbortError'))
|
|
51
|
+
}
|
|
52
|
+
const cleanup = () => {
|
|
53
|
+
window.removeEventListener('message', on)
|
|
54
|
+
signal?.removeEventListener('abort', abort)
|
|
55
|
+
}
|
|
56
|
+
window.addEventListener('message', on)
|
|
57
|
+
signal?.addEventListener('abort', abort)
|
|
58
|
+
window.postMessage({ channel: `${CHANNEL}:call`, call }, '*')
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The bridge when an extension is installed and enabled for this page, within a short wait. */
|
|
65
|
+
export async function detectExtension(waitMs = 250): Promise<ExtensionBridge | undefined> {
|
|
66
|
+
const injected = globalThis.__SPECBOOK_EXTENSION__
|
|
67
|
+
if (injected) return injected
|
|
68
|
+
if (typeof window === 'undefined') return undefined
|
|
69
|
+
const bridge = windowBridge(WIRE_VERSION)
|
|
70
|
+
const timeout = new Promise<undefined>((r) => setTimeout(() => r(undefined), waitMs))
|
|
71
|
+
const pong = await Promise.race([
|
|
72
|
+
bridge.send({ v: 1, type: 'ping' }).catch(() => undefined),
|
|
73
|
+
timeout,
|
|
74
|
+
])
|
|
75
|
+
return pong?.type === 'pong' ? { ...bridge, version: pong.version } : undefined
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let counter = 0
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A `Transport` that sends each request to the extension. Built on the ordinary fetch transport with a
|
|
82
|
+
* `fetch` that carries the request over the bridge, so bodies, query strings and response parsing are
|
|
83
|
+
* the same as for a direct call.
|
|
84
|
+
*/
|
|
85
|
+
export function extensionTransport(
|
|
86
|
+
bridge: ExtensionBridge,
|
|
87
|
+
baseOf: (service: string) => string,
|
|
88
|
+
): Transport {
|
|
89
|
+
const carry: typeof fetch = async (input, init) => {
|
|
90
|
+
const req = new Request(input as RequestInfo, init)
|
|
91
|
+
const id = `c${Date.now().toString(36)}${counter++}`
|
|
92
|
+
const reply = await bridge.send(
|
|
93
|
+
{ v: 1, type: 'fetch', id, req: await requestToWire(req) },
|
|
94
|
+
init?.signal ?? undefined,
|
|
95
|
+
)
|
|
96
|
+
if (reply.type === 'error') throw new Error(reply.message)
|
|
97
|
+
if (reply.type !== 'response') throw new Error('unexpected reply from the extension')
|
|
98
|
+
return wireToResponse(reply.res)
|
|
99
|
+
}
|
|
100
|
+
return createFetchTransport({ baseUrl: baseOf, fetch: carry })
|
|
101
|
+
}
|
package/src/fielddoc.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { type FieldSection, noteKey } from './panel/fieldnotes-split'
|
|
2
|
+
import {
|
|
3
|
+
type Defs,
|
|
4
|
+
describe,
|
|
5
|
+
type JsonSchema,
|
|
6
|
+
type KeyPath,
|
|
7
|
+
kindOf,
|
|
8
|
+
pathString,
|
|
9
|
+
propsOf,
|
|
10
|
+
schemaAt,
|
|
11
|
+
unwrap,
|
|
12
|
+
} from './schema'
|
|
13
|
+
|
|
14
|
+
export interface FieldDoc {
|
|
15
|
+
path: string
|
|
16
|
+
type: string
|
|
17
|
+
required: boolean
|
|
18
|
+
enum?: string[]
|
|
19
|
+
description?: string
|
|
20
|
+
/** the project's own note for this field (HTML), shown after the spec's description */
|
|
21
|
+
note?: string
|
|
22
|
+
/** what the enclosing object says about itself, shown when the field has no description */
|
|
23
|
+
parent?: string
|
|
24
|
+
deprecated?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** What the API documents about the field at `path` (relative to `prefix`, e.g. `['data']` for a body). */
|
|
28
|
+
export function fieldDoc(
|
|
29
|
+
root: JsonSchema | undefined,
|
|
30
|
+
defs: Defs,
|
|
31
|
+
path: KeyPath,
|
|
32
|
+
prefix: KeyPath = [],
|
|
33
|
+
): FieldDoc | undefined {
|
|
34
|
+
if (!root) return undefined
|
|
35
|
+
const full = [...prefix, ...path]
|
|
36
|
+
const schema = full.length === 0 ? root : schemaAt(root, full, defs)
|
|
37
|
+
if (!schema) return undefined
|
|
38
|
+
const u = unwrap(schema, defs)
|
|
39
|
+
// the cursor is on the outermost brace: say what the whole body is, when the spec does
|
|
40
|
+
if (path.length === 0) {
|
|
41
|
+
const text = typeof u.description === 'string' ? u.description.trim() : ''
|
|
42
|
+
if (!text) return undefined
|
|
43
|
+
return {
|
|
44
|
+
path: typeof u.title === 'string' && u.title.trim() ? u.title.trim() : 'body',
|
|
45
|
+
type: kindOf(u),
|
|
46
|
+
required: false,
|
|
47
|
+
description: text,
|
|
48
|
+
deprecated: u.deprecated === true || undefined,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const key = path[path.length - 1]
|
|
52
|
+
const parent = schemaAt(root, full.slice(0, -1), defs)
|
|
53
|
+
const kind = kindOf(u)
|
|
54
|
+
const item = kind === 'array' ? kindOf(unwrap(u.items, defs)) : undefined
|
|
55
|
+
return {
|
|
56
|
+
path: pathString(path),
|
|
57
|
+
type: item ? `${item}[]` : u.format ? `${kind} · ${u.format}` : kind,
|
|
58
|
+
required:
|
|
59
|
+
typeof key === 'string' &&
|
|
60
|
+
!!parent &&
|
|
61
|
+
propsOf(parent, defs).some((p) => p.name === key && p.required),
|
|
62
|
+
enum: Array.isArray(u.enum) ? u.enum.map(String) : undefined,
|
|
63
|
+
description: describe(u),
|
|
64
|
+
parent: parent ? describe(unwrap(parent, defs)) : undefined,
|
|
65
|
+
deprecated: u.deprecated === true || undefined,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** `metadata[order]` / `items[0][price]` -> ['metadata', 'order'] / ['items', 0, 'price'] */
|
|
70
|
+
export function keyPath(key: string): KeyPath {
|
|
71
|
+
return (key.match(/[^[\]]+/g) ?? [key]).map((s) => (/^\d+$/.test(s) ? Number(s) : s))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Adds the project's note for `path` in `section` (see `README.md` field sections), when there is one. */
|
|
75
|
+
export function withNote(
|
|
76
|
+
doc: FieldDoc | undefined,
|
|
77
|
+
notes: Map<string, string> | undefined,
|
|
78
|
+
section: FieldSection,
|
|
79
|
+
path: string,
|
|
80
|
+
): FieldDoc | undefined {
|
|
81
|
+
const note = doc && notes?.get(noteKey(section, path))
|
|
82
|
+
return doc && note ? { ...doc, note } : doc
|
|
83
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { render } from 'preact'
|
|
2
|
+
import { App } from './app'
|
|
3
|
+
import { handleOAuthCallback } from './oauth-callback'
|
|
4
|
+
import { applyTheme } from './theme'
|
|
5
|
+
import 'prism-code-editor/layout.css'
|
|
6
|
+
import 'prism-code-editor/autocomplete.css'
|
|
7
|
+
import 'prism-code-editor/autocomplete-icons.css'
|
|
8
|
+
import './styles/tokens.css'
|
|
9
|
+
import './styles/app.css'
|
|
10
|
+
import './styles/panel.css'
|
|
11
|
+
|
|
12
|
+
applyTheme()
|
|
13
|
+
// A sign-in's return leg is handed to the tab that started it; this page then has nothing to draw. Under the
|
|
14
|
+
// viewer shell or an embed (which import this) that was settled before they got here, and running it again
|
|
15
|
+
// would strip a host page's own `?code=&state=` that the embed deliberately left alone.
|
|
16
|
+
const underShell =
|
|
17
|
+
(globalThis as { __SPECBOOK_VIEWER__?: unknown }).__SPECBOOK_VIEWER__ !== undefined
|
|
18
|
+
void (underShell ? Promise.resolve(false) : handleOAuthCallback()).then((handled) => {
|
|
19
|
+
if (!handled) render(<App />, document.getElementById('app') as HTMLElement)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
if (import.meta.hot) {
|
|
23
|
+
import.meta.hot.on('specbook:registry-changed', () => location.reload())
|
|
24
|
+
}
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
const esc = (s: string) =>
|
|
2
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
3
|
+
|
|
4
|
+
/** Inline markup on already-escaped text: `code`, **bold**, *italic*, and http(s)/mailto links. */
|
|
5
|
+
function inline(text: string): string {
|
|
6
|
+
const codes: string[] = []
|
|
7
|
+
let s = text.replace(/`([^`]+)`/g, (_m, c: string) => {
|
|
8
|
+
codes.push(`<code>${c}</code>`)
|
|
9
|
+
return `\uE000${codes.length - 1}\uE000`
|
|
10
|
+
})
|
|
11
|
+
s = s
|
|
12
|
+
.replace(
|
|
13
|
+
/\[([^\]]+)\]\(((?:https?:\/\/|mailto:)[^)\s]+)\)/g,
|
|
14
|
+
(_m, label: string, href: string) => {
|
|
15
|
+
return `<a href="${href}" target="_blank" rel="noreferrer">${label}</a>`
|
|
16
|
+
},
|
|
17
|
+
)
|
|
18
|
+
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
19
|
+
.replace(/(^|[\s(])\*([^*\s][^*]*)\*(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>')
|
|
20
|
+
.replace(/(^|[\s(])_([^_\s][^_]*)_(?=[\s).,;:!?]|$)/g, '$1<em>$2</em>')
|
|
21
|
+
return s.replace(/\uE000(\d+)\uE000/g, (_m, i: string) => codes[Number(i)] ?? '')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A small, safe markdown subset for descriptions: paragraphs, headings, bullet and numbered lists, fenced
|
|
26
|
+
* code, and inline code, bold, italics and links. Everything is HTML-escaped first, and only http(s) and
|
|
27
|
+
* mailto links are linked, so descriptions from a spec or a sequence can be rendered without trusting them.
|
|
28
|
+
*/
|
|
29
|
+
export function markdownToHtml(src: string | undefined): string {
|
|
30
|
+
if (!src) return ''
|
|
31
|
+
const lines = src.replace(/\r\n?/g, '\n').split('\n')
|
|
32
|
+
const out: string[] = []
|
|
33
|
+
let para: string[] = []
|
|
34
|
+
let list: { tag: 'ul' | 'ol'; items: string[] } | undefined
|
|
35
|
+
|
|
36
|
+
const flushPara = () => {
|
|
37
|
+
if (para.length) out.push(`<p>${inline(esc(para.join(' ')))}</p>`)
|
|
38
|
+
para = []
|
|
39
|
+
}
|
|
40
|
+
const flushList = () => {
|
|
41
|
+
if (list)
|
|
42
|
+
out.push(
|
|
43
|
+
`<${list.tag}>${list.items.map((i) => `<li>${inline(esc(i))}</li>`).join('')}</${list.tag}>`,
|
|
44
|
+
)
|
|
45
|
+
list = undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < lines.length; i++) {
|
|
49
|
+
const line = lines[i] as string
|
|
50
|
+
if (/^```/.test(line)) {
|
|
51
|
+
flushPara()
|
|
52
|
+
flushList()
|
|
53
|
+
const code: string[] = []
|
|
54
|
+
i++
|
|
55
|
+
while (i < lines.length && !/^```/.test(lines[i] as string)) code.push(lines[i++] as string)
|
|
56
|
+
out.push(`<pre><code>${esc(code.join('\n'))}</code></pre>`)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
const heading = /^(#{1,4})\s+(.*)$/.exec(line)
|
|
60
|
+
if (heading) {
|
|
61
|
+
flushPara()
|
|
62
|
+
flushList()
|
|
63
|
+
const level = Math.min(4, (heading[1] as string).length + 1)
|
|
64
|
+
out.push(`<h${level}>${inline(esc(heading[2] as string))}</h${level}>`)
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const quote = /^>\s?(.*)$/.exec(line)
|
|
68
|
+
if (quote) {
|
|
69
|
+
flushPara()
|
|
70
|
+
flushList()
|
|
71
|
+
const text: string[] = [quote[1] as string]
|
|
72
|
+
while (i + 1 < lines.length && /^>\s?/.test(lines[i + 1] as string))
|
|
73
|
+
text.push((lines[++i] as string).replace(/^>\s?/, ''))
|
|
74
|
+
out.push(`<blockquote>${inline(esc(text.join(' ')))}</blockquote>`)
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
const bullet = /^\s*[-*+]\s+(.*)$/.exec(line)
|
|
78
|
+
const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line)
|
|
79
|
+
if (bullet || numbered) {
|
|
80
|
+
flushPara()
|
|
81
|
+
const tag = bullet ? 'ul' : 'ol'
|
|
82
|
+
if (list && list.tag !== tag) flushList()
|
|
83
|
+
list ??= { tag, items: [] }
|
|
84
|
+
list.items.push((bullet ?? numbered)?.[1] as string)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
if (line.trim() === '') {
|
|
88
|
+
flushPara()
|
|
89
|
+
flushList()
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
flushList()
|
|
93
|
+
para.push(line.trim())
|
|
94
|
+
}
|
|
95
|
+
flushPara()
|
|
96
|
+
flushList()
|
|
97
|
+
return out.join('')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Markdown reduced to readable plain text: links keep their label, markers and fences disappear. */
|
|
101
|
+
export function plainText(src: string | undefined): string {
|
|
102
|
+
if (!src) return ''
|
|
103
|
+
return src
|
|
104
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
105
|
+
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
|
106
|
+
.replace(/^#{1,6}\s+/gm, '')
|
|
107
|
+
.replace(/^\s*(?:[-*+]|\d+[.)])\s+/gm, '')
|
|
108
|
+
.replace(/^>\s?/gm, '')
|
|
109
|
+
.replace(/[`*]|(^|\s)_|_(?=\s|$)/g, '$1')
|
|
110
|
+
.replace(/\s+/g, ' ')
|
|
111
|
+
.trim()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function firstParagraph(src: string | undefined): string {
|
|
115
|
+
if (!src) return ''
|
|
116
|
+
return (src.trim().split(/\n\s*\n/)[0] ?? '').replace(/\s*\n\s*/g, ' ').trim()
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Inline markup only (code, bold, italics, links) in already-safe HTML, for a single line or cell. */
|
|
120
|
+
export function inlineMarkdown(src: string | undefined): string {
|
|
121
|
+
return src ? inline(esc(src)) : ''
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* A one-paragraph summary for table cells: inline markdown when it is short, plain text with an ellipsis
|
|
126
|
+
* when it is long (cutting markdown mid-link would break it).
|
|
127
|
+
*/
|
|
128
|
+
export function inlineSummary(src: string | undefined, max = 300): string {
|
|
129
|
+
const para = firstParagraph(src)
|
|
130
|
+
if (!para) return ''
|
|
131
|
+
const text = plainText(para)
|
|
132
|
+
return text.length > max ? esc(`${text.slice(0, max - 1).trimEnd()}…`) : inlineMarkdown(para)
|
|
133
|
+
}
|
package/src/nav.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { signal } from '@preact/signals'
|
|
2
|
+
|
|
3
|
+
export type PanelTab = 'docs' | 'try'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The page is always the catalog; an operation or sequence route opens the side panel on top of it.
|
|
7
|
+
* `play` is the example to open ('' = the first), `tab` picks Docs or Try (default Try).
|
|
8
|
+
*/
|
|
9
|
+
export type Route =
|
|
10
|
+
| { kind: 'home' }
|
|
11
|
+
/** the catalog itself, without the overview: what a phone shows after its splash screen */
|
|
12
|
+
| { kind: 'browse' }
|
|
13
|
+
| { kind: 'op'; key: string; play?: string; tab?: PanelTab }
|
|
14
|
+
| { kind: 'seq'; id: string; play?: string; tab?: PanelTab }
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A project with one API needs no service in its links: `#/op/FindPetsByStatus`, not
|
|
18
|
+
* `#/op/swagger-petstore-openapi-3-0/FindPetsByStatus`. The app names that service once it knows the
|
|
19
|
+
* registry; a longer link (an older one, or one from a project with several APIs) still opens.
|
|
20
|
+
*/
|
|
21
|
+
let defaultService: string | undefined
|
|
22
|
+
|
|
23
|
+
export function setDefaultService(name: string | undefined) {
|
|
24
|
+
defaultService = name
|
|
25
|
+
if (typeof location !== 'undefined') route.value = parseHash(location.hash)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const keyOut = (key: string) =>
|
|
29
|
+
defaultService && key.startsWith(`${defaultService}/`)
|
|
30
|
+
? key.slice(defaultService.length + 1)
|
|
31
|
+
: key
|
|
32
|
+
const keyIn = (key: string) =>
|
|
33
|
+
defaultService && !key.includes('/') ? `${defaultService}/${key}` : key
|
|
34
|
+
/** Slashes read fine in a fragment, so only what would be ambiguous is escaped. */
|
|
35
|
+
const soft = (s: string) => encodeURIComponent(s).replace(/%2F/gi, '/')
|
|
36
|
+
|
|
37
|
+
export function parseHash(hash: string): Route {
|
|
38
|
+
const [path = '', query = ''] = hash.split('?')
|
|
39
|
+
const q = new URLSearchParams(query)
|
|
40
|
+
const play = q.get('play') ?? undefined
|
|
41
|
+
const t = q.get('tab')
|
|
42
|
+
const tab = t === 'docs' || t === 'try' ? t : undefined
|
|
43
|
+
const op = /^#\/op\/(.+)$/.exec(path)
|
|
44
|
+
if (op) return { kind: 'op', key: keyIn(decodeURIComponent(op[1] as string)), play, tab }
|
|
45
|
+
const seq = /^#\/seq\/(.+)$/.exec(path)
|
|
46
|
+
if (seq) return { kind: 'seq', id: decodeURIComponent(seq[1] as string), play, tab }
|
|
47
|
+
if (path === '#/browse') return { kind: 'browse' }
|
|
48
|
+
return { kind: 'home' }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function hashFor(r: Route): string {
|
|
52
|
+
if (r.kind === 'home') return '#/'
|
|
53
|
+
if (r.kind === 'browse') return '#/browse'
|
|
54
|
+
const base = r.kind === 'op' ? `#/op/${soft(keyOut(r.key))}` : `#/seq/${encodeURIComponent(r.id)}`
|
|
55
|
+
const q = new URLSearchParams()
|
|
56
|
+
if (r.play !== undefined) q.set('play', r.play)
|
|
57
|
+
if (r.tab) q.set('tab', r.tab)
|
|
58
|
+
const s = q.toString()
|
|
59
|
+
return s ? `${base}?${s}` : base
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A phone's home is its splash screen, so closing a panel lands on the list rather than back on it. */
|
|
63
|
+
export const isPhone = () =>
|
|
64
|
+
typeof matchMedia === 'function' && matchMedia('(max-width: 820px)').matches
|
|
65
|
+
export const pageOf = (_r: Route): Route => (isPhone() ? { kind: 'browse' } : { kind: 'home' })
|
|
66
|
+
|
|
67
|
+
export const route = signal<Route>(parseHash(typeof location === 'undefined' ? '' : location.hash))
|
|
68
|
+
export const paletteOpen = signal(false)
|
|
69
|
+
/** The catalog's service pill ('' = overview) and filter text. */
|
|
70
|
+
export const catalogService = signal('')
|
|
71
|
+
export const catalogFilter = signal('')
|
|
72
|
+
|
|
73
|
+
export function go(r: Route) {
|
|
74
|
+
location.hash = hashFor(r)
|
|
75
|
+
route.value = r
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface Entry {
|
|
79
|
+
route: Route
|
|
80
|
+
title: string
|
|
81
|
+
/** group label shown muted, e.g. "pet" */
|
|
82
|
+
group: string
|
|
83
|
+
service?: string
|
|
84
|
+
tag?: string
|
|
85
|
+
/** operations */
|
|
86
|
+
method?: string
|
|
87
|
+
path?: string
|
|
88
|
+
/** the spec requires credentials */
|
|
89
|
+
secured?: boolean
|
|
90
|
+
/** sequences */
|
|
91
|
+
tags?: string[]
|
|
92
|
+
/** the flow group heading */
|
|
93
|
+
flowGroup?: string
|
|
94
|
+
description?: string
|
|
95
|
+
/** extra text matched by search, never shown */
|
|
96
|
+
hay: string
|
|
97
|
+
kind: 'seq' | 'op'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function search(entries: Entry[], query: string, limit = 40): Entry[] {
|
|
101
|
+
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
|
102
|
+
if (terms.length === 0) return entries.slice(0, limit)
|
|
103
|
+
const scored: [number, Entry][] = []
|
|
104
|
+
for (const e of entries) {
|
|
105
|
+
const title = e.title.toLowerCase()
|
|
106
|
+
const hay = `${title} ${e.group.toLowerCase()} ${e.hay.toLowerCase()}`
|
|
107
|
+
if (!terms.every((t) => hay.includes(t))) continue
|
|
108
|
+
// prefer title matches and sequences
|
|
109
|
+
let score = 0
|
|
110
|
+
for (const t of terms) score += title.startsWith(t) ? 3 : title.includes(t) ? 2 : 1
|
|
111
|
+
if (e.kind === 'seq') score += 0.5
|
|
112
|
+
scored.push([score, e])
|
|
113
|
+
}
|
|
114
|
+
return scored
|
|
115
|
+
.sort((a, b) => b[0] - a[0])
|
|
116
|
+
.slice(0, limit)
|
|
117
|
+
.map(([, e]) => e)
|
|
118
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The return leg of a sign-in. The identity provider redirects the popup to a page with `?code=&state=`
|
|
3
|
+
* (or `?error=`). Run first on boot: when the tab that started the sign-in is waiting for this `state`
|
|
4
|
+
* it takes the result and this popup closes without drawing the app.
|
|
5
|
+
*
|
|
6
|
+
* Two ways to reach the waiting tab, because each survives what the other does not:
|
|
7
|
+
* - a same-origin `BroadcastChannel`: providers that send `Cross-Origin-Opener-Policy` sever
|
|
8
|
+
* `window.opener`, but a channel still connects the two pages;
|
|
9
|
+
* - `window.opener.postMessage`: a viewer inside an iframe on another site has partitioned storage, so its
|
|
10
|
+
* channel never hears a top-level popup, but its window is still the popup's opener.
|
|
11
|
+
*
|
|
12
|
+
* A stray visit to a URL that merely looks like a callback is told nobody is waiting and carries on as a
|
|
13
|
+
* normal page load. The hosted site's own callback page (`landing/oauth/callback/callback.js`) does the same
|
|
14
|
+
* in plain JS; keep the two in step.
|
|
15
|
+
*/
|
|
16
|
+
export const OAUTH_CHANNEL = 'specbook-oauth'
|
|
17
|
+
|
|
18
|
+
export type OAuthMessage =
|
|
19
|
+
| { type: 'callback'; state: string; code?: string; error?: string; description?: string }
|
|
20
|
+
| { type: 'ack'; state: string }
|
|
21
|
+
|
|
22
|
+
/** How long a callback page waits to be told that someone is waiting for it. */
|
|
23
|
+
const ACK_WAIT_MS = 1500
|
|
24
|
+
|
|
25
|
+
export interface CallbackOptions {
|
|
26
|
+
/**
|
|
27
|
+
* Remove `?code=&state=` from the address even when no tab claims it (default). An embedded viewer sets
|
|
28
|
+
* this to false: the page belongs to someone else, and its query may be their own application's sign-in.
|
|
29
|
+
*/
|
|
30
|
+
stripWhenUnclaimed?: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** true when this page load was a sign-in's return and has been handed over: do not boot the app. */
|
|
34
|
+
export function handleOAuthCallback(options: CallbackOptions = {}): Promise<boolean> {
|
|
35
|
+
const strip = options.stripWhenUnclaimed ?? true
|
|
36
|
+
const here = globalThis.location
|
|
37
|
+
if (!here || (typeof BroadcastChannel === 'undefined' && !globalThis.opener))
|
|
38
|
+
return Promise.resolve(false)
|
|
39
|
+
const q = new URLSearchParams(here.search)
|
|
40
|
+
const state = q.get('state')
|
|
41
|
+
if (!state || !(q.has('code') || q.has('error'))) return Promise.resolve(false)
|
|
42
|
+
|
|
43
|
+
const message: OAuthMessage = {
|
|
44
|
+
type: 'callback',
|
|
45
|
+
state,
|
|
46
|
+
...(q.get('code') ? { code: q.get('code') as string } : {}),
|
|
47
|
+
...(q.get('error') ? { error: q.get('error') as string } : {}),
|
|
48
|
+
...(q.get('error_description') ? { description: q.get('error_description') as string } : {}),
|
|
49
|
+
}
|
|
50
|
+
// the code and state are single-use and do not belong in history or a copied address
|
|
51
|
+
const clean = () => history.replaceState(null, '', here.pathname + here.hash)
|
|
52
|
+
if (strip) clean()
|
|
53
|
+
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const channel =
|
|
56
|
+
typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(OAUTH_CHANNEL)
|
|
57
|
+
let finished = false
|
|
58
|
+
const finish = (handled: boolean) => {
|
|
59
|
+
if (finished) return
|
|
60
|
+
finished = true
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
channel?.close()
|
|
63
|
+
removeEventListener('message', onMessage)
|
|
64
|
+
resolve(handled)
|
|
65
|
+
}
|
|
66
|
+
const claimed = () => {
|
|
67
|
+
if (finished) return
|
|
68
|
+
clean()
|
|
69
|
+
finish(true)
|
|
70
|
+
document.body.textContent = 'Signed in. You can close this window.'
|
|
71
|
+
window.close()
|
|
72
|
+
}
|
|
73
|
+
const isAck = (d: OAuthMessage | undefined) => d?.type === 'ack' && d.state === state
|
|
74
|
+
const onMessage = (e: MessageEvent<OAuthMessage>) => {
|
|
75
|
+
if (e.origin === here.origin && isAck(e.data)) claimed()
|
|
76
|
+
}
|
|
77
|
+
const timer = setTimeout(() => finish(false), ACK_WAIT_MS)
|
|
78
|
+
if (channel) channel.onmessage = (e: MessageEvent<OAuthMessage>) => isAck(e.data) && claimed()
|
|
79
|
+
addEventListener('message', onMessage)
|
|
80
|
+
channel?.postMessage(message)
|
|
81
|
+
try {
|
|
82
|
+
window.opener?.postMessage(message, here.origin)
|
|
83
|
+
} catch {
|
|
84
|
+
/* the opener is cross-origin or gone: the channel is the only way */
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|