@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,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
+ }
@@ -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
+ }
@@ -0,0 +1,133 @@
1
+ const esc = (s: string) =>
2
+ s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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
+ }