@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,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()
@@ -0,0 +1,3 @@
1
+ // English only: the other locales are not named here, so the bundler leaves them out. (The size is the
2
+ // English data itself, which is most of faker; the locale list in the header is hidden for one locale.)
3
+ export { base, en, Faker } from '@faker-js/faker'