@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 @@
|
|
|
1
|
+
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' role='img' aria-label='Specbook'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#8b5cf6'/><stop offset='1' stop-color='#5b21b6'/></linearGradient></defs><rect width='64' height='64' rx='15' fill='url(#g)'/><path d='M32 19.5c-4.5-3-10-3.6-15.5-2.4v28.4c5.500-1.200 11-.6 15.500 2.400 4.500-3 10-3.600 15.500-2.400V17.100c-5.500-1.200-11-.6-15.500 2.400z' fill='#fff' fill-opacity='.95'/><path d='M32 19.500v28.400' stroke='#6d28d9' stroke-width='2' stroke-linecap='round'/><path d='M38 27.500v9.500l7.500-4.750z' fill='#6d28d9'/><path d='M26.500 28.500l-4 3.750 4 3.750' fill='none' stroke='#6d28d9' stroke-width='2.200' stroke-linecap='round' stroke-linejoin='round'/></svg>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
6
|
+
<meta name="referrer" content="no-referrer" />
|
|
7
|
+
<title>Specbook</title>
|
|
8
|
+
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
|
9
|
+
<link rel="apple-touch-icon" href="./apple-touch-icon.png" />
|
|
10
|
+
<script type="module" crossorigin src="./assets/index-DnYdlDXQ.js"></script>
|
|
11
|
+
<link rel="modulepreload" crossorigin href="./assets/panel-CoQ6iQpX.js">
|
|
12
|
+
<link rel="modulepreload" crossorigin href="./assets/mount-B-qFOcWp.js">
|
|
13
|
+
<link rel="stylesheet" crossorigin href="./assets/panel-Coi9H1nv.css">
|
|
14
|
+
<link rel="stylesheet" crossorigin href="./assets/index-Ds6DZpGP.css">
|
|
15
|
+
</head>
|
|
16
|
+
<body>
|
|
17
|
+
<div id="app"></div>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{r as e,t}from"./assets/mount-B-qFOcWp.js";function n(){let e=document.getElementById(`specbook-config`);if(e?.textContent)try{return JSON.parse(e.textContent)}catch{}let t=document.querySelector(`script[src$="specbook.js"][data-spec]`);return{spec:t?.dataset.spec,baseUrl:t?.dataset.baseUrl}}function r(e){let t=e?document.querySelector(e):document.getElementById(`app`);if(t)return t;let n=document.createElement(`div`);return n.id=`app`,document.body.append(n),n}var i=(e,t,n=!1)=>{e.textContent=t,e.style.cssText=`font:16px system-ui,sans-serif;padding:24px;color:${n?`#b42318`:`#5b6270`}`};async function a(){let a=n(),o=r(a.target);if(!a.spec)return i(o,`Specbook: no spec was given (set "spec" in #specbook-config).`,!0);let s;try{s=new URL(a.spec,location.href).toString()}catch{return i(o,`Specbook: "${a.spec}" is not a valid address.`,!0)}i(o,`Loading the API…`);let c=new e;try{let e=await c.open({kind:`url`,url:s}).promise;o.textContent=``,o.removeAttribute(`style`),await t({result:e,worker:c,source:{kind:`url`,url:s},baseUrl:a.baseUrl,shareable:!1,setTitle:!1})}catch(e){i(o,`Specbook: ${e instanceof Error?e.message:String(e)}`,!0)}}a();
|
package/index.html
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Specbook</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="app"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@specbook/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The Specbook docs app, spec viewer and embeddable specbook.js.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"specbook",
|
|
7
|
+
"openapi",
|
|
8
|
+
"swagger",
|
|
9
|
+
"api-documentation",
|
|
10
|
+
"api-explorer",
|
|
11
|
+
"openapi-viewer"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://ui.specbook.dev",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/specbook-dev/specbook/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/specbook-dev/specbook.git",
|
|
21
|
+
"directory": "packages/ui"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"exports": {
|
|
28
|
+
"./package.json": "./package.json"
|
|
29
|
+
},
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"@preact/signals": "2.11.3",
|
|
32
|
+
"@specbook/codegen": "0.1.0",
|
|
33
|
+
"@specbook/core": "0.1.0",
|
|
34
|
+
"preact": "11.0.1",
|
|
35
|
+
"prism-code-editor": "^5.4.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@faker-js/faker": "9.3.0",
|
|
39
|
+
"@specbook/vite": "0.1.0",
|
|
40
|
+
"@vitest/browser-playwright": "5.0.3",
|
|
41
|
+
"playwright": "1.64.0",
|
|
42
|
+
"typescript": "7.0.2",
|
|
43
|
+
"vite": "8.3.4",
|
|
44
|
+
"vitest": "5.0.3"
|
|
45
|
+
},
|
|
46
|
+
"files": [
|
|
47
|
+
"src",
|
|
48
|
+
"index.html",
|
|
49
|
+
"dist-viewer",
|
|
50
|
+
"!**/*.test.*"
|
|
51
|
+
],
|
|
52
|
+
"scripts": {
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"build": "tsc --noEmit",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"test:browser": "pnpm --filter @specbook/vite build && vitest run --config vitest.browser.config.ts",
|
|
57
|
+
"build:viewer": "vite build --config vite.viewer.config.ts",
|
|
58
|
+
"dev:viewer": "vite --config vite.viewer.config.ts",
|
|
59
|
+
"test:viewer": "pnpm run build:viewer && vitest run --config vitest.viewer.config.ts",
|
|
60
|
+
"build:site": "pnpm run build:viewer && node scripts/assemble-site.mjs",
|
|
61
|
+
"deploy:site": "pnpm run build:viewer && node scripts/assemble-site.mjs --previous https://ui.specbook.dev && npx --yes wrangler@4 pages deploy dist-site --project-name specbook-ui --branch main"
|
|
62
|
+
}
|
|
6
63
|
}
|
package/src/app.tsx
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { signal } from '@preact/signals'
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
3
|
+
import markUrl from './assets/mark.svg'
|
|
4
|
+
import { applyBranding, brand } from './branding'
|
|
5
|
+
import { CatalogList, Overview, type ServiceInfo } from './components/Catalog'
|
|
6
|
+
import { EnvBadge } from './components/EnvBadge'
|
|
7
|
+
import { EnvironmentMenu } from './components/EnvironmentMenu'
|
|
8
|
+
import { GitHubStar } from './components/GitHubStar'
|
|
9
|
+
import { ListResizer, listWidth } from './components/ListResizer'
|
|
10
|
+
import { Palette } from './components/Palette'
|
|
11
|
+
import { ShareMenu } from './components/ShareMenu'
|
|
12
|
+
import { ThemeToggle } from './components/ThemeToggle'
|
|
13
|
+
import type { SequenceInfo, Variant } from './engine'
|
|
14
|
+
import { type Entry, go, pageOf, paletteOpen, parseHash, route, setDefaultService } from './nav'
|
|
15
|
+
import { engine, registry } from './runtime'
|
|
16
|
+
import { env, environmentRisk, environments, locale, seed, version } from './store'
|
|
17
|
+
|
|
18
|
+
// the hosted viewer is Specbook itself, so its header carries our mark unless the API brings a logo
|
|
19
|
+
const ownMark = globalThis.__SPECBOOK_VIEWER__?.source ? markUrl : undefined
|
|
20
|
+
|
|
21
|
+
// one API: its links leave the service out
|
|
22
|
+
{
|
|
23
|
+
const names = Object.keys(registry.manifests)
|
|
24
|
+
setDefaultService(names.length === 1 ? names[0] : undefined)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const fakerLocales = signal<string[]>([])
|
|
28
|
+
|
|
29
|
+
type HostModule = typeof import('./panel/Host')
|
|
30
|
+
|
|
31
|
+
/** The side panel is a separate chunk: it holds the editor and is only fetched on first Play. */
|
|
32
|
+
function usePanelHost() {
|
|
33
|
+
const [host, setHost] = useState<HostModule>()
|
|
34
|
+
const wanted = route.value.kind === 'op' || route.value.kind === 'seq'
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (wanted && !host) import('./panel/Host').then(setHost)
|
|
37
|
+
}, [wanted, host])
|
|
38
|
+
return wanted ? host : undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function App() {
|
|
42
|
+
const [variants, setVariants] = useState<Record<string, Variant[]>>({})
|
|
43
|
+
const [sequences, setSequences] = useState<SequenceInfo[] | undefined>(undefined)
|
|
44
|
+
const [menuOpen, setMenuOpen] = useState(false)
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
const onHash = () => {
|
|
48
|
+
route.value = parseHash(location.hash)
|
|
49
|
+
}
|
|
50
|
+
addEventListener('hashchange', onHash)
|
|
51
|
+
// The project's config comes first: the overview waits for it. Saved requests and sequences are
|
|
52
|
+
// only needed for search and the Try picker, so they load once the first screen can be drawn.
|
|
53
|
+
const loadRest = () => {
|
|
54
|
+
engine.loadVariants(registry.requests, version.value).then(setVariants)
|
|
55
|
+
// Headers are read from the files' text; only ones that aren't plain literals are imported.
|
|
56
|
+
const odd = registry.sequences.filter((e) => registry.unresolvedSequences.includes(e.file))
|
|
57
|
+
if (odd.length) engine.loadSequences(odd, version.value).then(setSequences)
|
|
58
|
+
}
|
|
59
|
+
engine
|
|
60
|
+
.config(version.value)
|
|
61
|
+
.then(async (c) => {
|
|
62
|
+
fakerLocales.value = Object.keys(c.faker?.locales ?? {})
|
|
63
|
+
if (c.faker?.default) locale.value = c.faker.default
|
|
64
|
+
const b = c.branding
|
|
65
|
+
const assets = registry.brandingAssets
|
|
66
|
+
const only = Object.values(registry.manifests)
|
|
67
|
+
// with one API, the spec's own `info.x-logo` is the fallback after the project's files
|
|
68
|
+
const spec = only.length === 1 ? only[0]?.logo : undefined
|
|
69
|
+
const file = b?.logo ?? (await assets.logo?.().catch(() => undefined))
|
|
70
|
+
const resolved = {
|
|
71
|
+
ready: true,
|
|
72
|
+
name: b?.name,
|
|
73
|
+
title: b?.title,
|
|
74
|
+
tagline: b?.tagline,
|
|
75
|
+
links: b?.links ?? [],
|
|
76
|
+
badge: b?.badge,
|
|
77
|
+
logo: file ?? spec?.url,
|
|
78
|
+
logoBackground: file ? undefined : spec?.backgroundColor,
|
|
79
|
+
favicon: b?.favicon ?? (await assets.favicon?.().catch(() => undefined)),
|
|
80
|
+
}
|
|
81
|
+
brand.value = resolved
|
|
82
|
+
applyBranding(b, resolved, only.length === 1 ? (only[0]?.title ?? 'Specbook') : 'Specbook')
|
|
83
|
+
})
|
|
84
|
+
.catch(() => {
|
|
85
|
+
// a project without a readable config still gets its overview, from the APIs' own titles
|
|
86
|
+
brand.value = { ...brand.value, ready: true }
|
|
87
|
+
})
|
|
88
|
+
.finally(loadRest)
|
|
89
|
+
engine.environments(version.value).then((e) => {
|
|
90
|
+
environments.value = e
|
|
91
|
+
})
|
|
92
|
+
engine.environmentRisks(version.value).then((r) => {
|
|
93
|
+
environmentRisk.value = r
|
|
94
|
+
})
|
|
95
|
+
return () => removeEventListener('hashchange', onHash)
|
|
96
|
+
}, [])
|
|
97
|
+
|
|
98
|
+
// the settings menu (a phone) closes when you go somewhere else, or press Escape
|
|
99
|
+
const lastRoute = useRef(route.value)
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (lastRoute.current === route.value) return
|
|
102
|
+
lastRoute.current = route.value
|
|
103
|
+
setMenuOpen(false)
|
|
104
|
+
}, [route.value])
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!menuOpen) return
|
|
107
|
+
const key = (e: KeyboardEvent) => e.key === 'Escape' && setMenuOpen(false)
|
|
108
|
+
addEventListener('keydown', key)
|
|
109
|
+
return () => removeEventListener('keydown', key)
|
|
110
|
+
}, [menuOpen])
|
|
111
|
+
|
|
112
|
+
const multiService = Object.keys(registry.manifests).length > 1
|
|
113
|
+
const entries = useMemo<Entry[]>(() => {
|
|
114
|
+
const out: Entry[] = []
|
|
115
|
+
const metas = [
|
|
116
|
+
...Object.values(registry.sequenceMeta),
|
|
117
|
+
...(sequences ?? []).map((s) => ({ ...s.seq.meta })),
|
|
118
|
+
]
|
|
119
|
+
for (const m of metas) {
|
|
120
|
+
out.push({
|
|
121
|
+
kind: 'seq',
|
|
122
|
+
route: { kind: 'seq', id: m.id },
|
|
123
|
+
title: m.title,
|
|
124
|
+
group: m.tags?.[0] ?? 'sequence',
|
|
125
|
+
tags: m.tags,
|
|
126
|
+
flowGroup: m.group,
|
|
127
|
+
description: m.description,
|
|
128
|
+
hay: `${m.description ?? ''} ${(m.tags ?? []).join(' ')}`,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
for (const m of Object.values(registry.manifests)) {
|
|
132
|
+
for (const o of m.operations) {
|
|
133
|
+
const key = `${m.service}/${o.id}`
|
|
134
|
+
const saved = (variants[key] ?? []).map((v) => v.name).join(' ')
|
|
135
|
+
out.push({
|
|
136
|
+
kind: 'op',
|
|
137
|
+
route: { kind: 'op', key },
|
|
138
|
+
title: o.summary?.replace(/\.$/, '') || o.path,
|
|
139
|
+
group: `${multiService ? `${m.service} · ` : ''}${o.tags[0] ?? 'default'}`,
|
|
140
|
+
service: m.service,
|
|
141
|
+
tag: o.tags[0] ?? 'default',
|
|
142
|
+
method: o.method,
|
|
143
|
+
path: o.path,
|
|
144
|
+
secured: o.secured,
|
|
145
|
+
hay: `${o.id} ${o.method} ${o.path} ${o.tags.join(' ')} ${saved}`,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return out
|
|
150
|
+
}, [sequences, variants, multiService])
|
|
151
|
+
|
|
152
|
+
const r = route.value
|
|
153
|
+
const opInfo = r.kind === 'op' ? findOp(r.key) : undefined
|
|
154
|
+
const seqId = r.kind === 'seq' ? r.id : undefined
|
|
155
|
+
const [opened, setOpened] = useState<SequenceInfo>()
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
if (!seqId) return
|
|
158
|
+
const file = registry.sequenceMeta[seqId]?.file
|
|
159
|
+
if (file) {
|
|
160
|
+
const entry = registry.sequences.filter((e) => e.file === file)
|
|
161
|
+
engine.loadSequences(entry, version.value).then((l) => setOpened(l[0]))
|
|
162
|
+
} else setOpened(sequences?.find((s) => s.seq.meta.id === seqId))
|
|
163
|
+
}, [seqId, sequences, version.value])
|
|
164
|
+
const seqInfo = opened?.seq.meta.id === seqId ? opened : undefined
|
|
165
|
+
const panel = usePanelHost()
|
|
166
|
+
const close = () => go(pageOf(route.value))
|
|
167
|
+
|
|
168
|
+
const services = useMemo<ServiceInfo[]>(
|
|
169
|
+
() =>
|
|
170
|
+
Object.values(registry.manifests)
|
|
171
|
+
.map((m) => ({
|
|
172
|
+
id: m.service,
|
|
173
|
+
title: m.title,
|
|
174
|
+
version: m.version,
|
|
175
|
+
ops: m.operations.length,
|
|
176
|
+
}))
|
|
177
|
+
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
178
|
+
[],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
// previous/next within the group the open item belongs to
|
|
182
|
+
const neighbors = useMemo(() => {
|
|
183
|
+
if (r.kind !== 'op' && r.kind !== 'seq') return {}
|
|
184
|
+
const active = entries.find(
|
|
185
|
+
(e) =>
|
|
186
|
+
(e.route.kind === 'op' && r.kind === 'op' && e.route.key === r.key) ||
|
|
187
|
+
(e.route.kind === 'seq' && r.kind === 'seq' && e.route.id === r.id),
|
|
188
|
+
)
|
|
189
|
+
if (!active) return {}
|
|
190
|
+
const sibs = entries.filter((e) =>
|
|
191
|
+
active.kind === 'seq'
|
|
192
|
+
? e.kind === 'seq'
|
|
193
|
+
: e.service === active.service && e.tag === active.tag,
|
|
194
|
+
)
|
|
195
|
+
const at = sibs.indexOf(active)
|
|
196
|
+
const to = (e?: Entry) => (e ? () => go({ ...e.route, play: '' } as typeof r) : undefined)
|
|
197
|
+
return { prev: to(sibs[at - 1]), next: to(sibs[at + 1]) }
|
|
198
|
+
}, [entries, r.kind === 'op' ? r.key : r.kind === 'seq' ? r.id : ''])
|
|
199
|
+
|
|
200
|
+
const navigate = (to: { tab: 'docs' | 'try'; example?: string }) =>
|
|
201
|
+
go(
|
|
202
|
+
r.kind === 'op'
|
|
203
|
+
? { kind: 'op', key: r.key, play: to.example ?? r.play ?? '', tab: to.tab }
|
|
204
|
+
: r.kind === 'seq'
|
|
205
|
+
? { kind: 'seq', id: r.id, play: '', tab: to.tab }
|
|
206
|
+
: r,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div
|
|
211
|
+
class="app"
|
|
212
|
+
style={listWidth.value ? { '--sb-list-w': `${listWidth.value}px` } : undefined}
|
|
213
|
+
>
|
|
214
|
+
<header class="header">
|
|
215
|
+
<button type="button" class="brand" onClick={() => go({ kind: 'home' })}>
|
|
216
|
+
{(brand.value.logo ?? ownMark) && (
|
|
217
|
+
<img
|
|
218
|
+
class="brand-logo"
|
|
219
|
+
src={brand.value.logo ?? ownMark}
|
|
220
|
+
alt=""
|
|
221
|
+
style={
|
|
222
|
+
brand.value.logoBackground
|
|
223
|
+
? {
|
|
224
|
+
background: brand.value.logoBackground,
|
|
225
|
+
padding: '3px 6px',
|
|
226
|
+
borderRadius: '4px',
|
|
227
|
+
}
|
|
228
|
+
: undefined
|
|
229
|
+
}
|
|
230
|
+
/>
|
|
231
|
+
)}
|
|
232
|
+
<span class="brand-name">{brand.value.name ?? 'Specbook'}</span>
|
|
233
|
+
</button>
|
|
234
|
+
<button type="button" class="palette-btn" onClick={() => (paletteOpen.value = true)}>
|
|
235
|
+
<span>Jump to an operation or flow…</span>
|
|
236
|
+
<kbd>⌘K</kbd>
|
|
237
|
+
</button>
|
|
238
|
+
<span class="spacer" />
|
|
239
|
+
<span class="header-env">
|
|
240
|
+
<EnvBadge />
|
|
241
|
+
</span>
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
class="menu-btn icon"
|
|
245
|
+
aria-label="Settings"
|
|
246
|
+
aria-expanded={menuOpen}
|
|
247
|
+
onClick={() => setMenuOpen(!menuOpen)}
|
|
248
|
+
>
|
|
249
|
+
⋯
|
|
250
|
+
</button>
|
|
251
|
+
<div class={`header-controls ${menuOpen ? 'open' : ''}`}>
|
|
252
|
+
{registry.loadFaker && (
|
|
253
|
+
<span
|
|
254
|
+
class="row muted"
|
|
255
|
+
title="Generated values such as {{faker.person.fullName}}: new on every run, or fixed so the same number always gives the same data"
|
|
256
|
+
>
|
|
257
|
+
<label class="row">
|
|
258
|
+
Synthetic data
|
|
259
|
+
<select
|
|
260
|
+
aria-label="Synthetic data"
|
|
261
|
+
value={seed.value === undefined ? 'random' : 'fixed'}
|
|
262
|
+
onChange={(e) => {
|
|
263
|
+
const fixed = (e.target as HTMLSelectElement).value === 'fixed'
|
|
264
|
+
seed.value = fixed ? (seed.value ?? 42) : undefined
|
|
265
|
+
}}
|
|
266
|
+
>
|
|
267
|
+
<option value="random">New each run</option>
|
|
268
|
+
<option value="fixed">Fixed</option>
|
|
269
|
+
</select>
|
|
270
|
+
</label>
|
|
271
|
+
{seed.value !== undefined && (
|
|
272
|
+
<input
|
|
273
|
+
type="number"
|
|
274
|
+
class="seed-input"
|
|
275
|
+
aria-label="Seed number"
|
|
276
|
+
title="The same number gives the same data every run"
|
|
277
|
+
value={seed.value}
|
|
278
|
+
onInput={(e) => {
|
|
279
|
+
const n = (e.target as HTMLInputElement).valueAsNumber
|
|
280
|
+
seed.value = Number.isFinite(n) ? n : undefined
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
)}
|
|
284
|
+
</span>
|
|
285
|
+
)}
|
|
286
|
+
{fakerLocales.value.length > 1 && (
|
|
287
|
+
<label class="row muted">
|
|
288
|
+
locale
|
|
289
|
+
<select
|
|
290
|
+
value={locale.value ?? ''}
|
|
291
|
+
onChange={(e) =>
|
|
292
|
+
(locale.value = (e.target as HTMLSelectElement).value || undefined)
|
|
293
|
+
}
|
|
294
|
+
>
|
|
295
|
+
{fakerLocales.value.map((l) => (
|
|
296
|
+
<option value={l}>{l}</option>
|
|
297
|
+
))}
|
|
298
|
+
</select>
|
|
299
|
+
</label>
|
|
300
|
+
)}
|
|
301
|
+
<EnvironmentMenu />
|
|
302
|
+
<label class="row">
|
|
303
|
+
Environment
|
|
304
|
+
<select
|
|
305
|
+
value={env.value}
|
|
306
|
+
onChange={(e) => (env.value = (e.target as HTMLSelectElement).value)}
|
|
307
|
+
>
|
|
308
|
+
{environments.value.map((e) => (
|
|
309
|
+
<option value={e}>{e}</option>
|
|
310
|
+
))}
|
|
311
|
+
</select>
|
|
312
|
+
</label>
|
|
313
|
+
<GitHubStar />
|
|
314
|
+
<ShareMenu />
|
|
315
|
+
<ThemeToggle />
|
|
316
|
+
</div>
|
|
317
|
+
</header>
|
|
318
|
+
<div class="layout" data-view={r.kind === 'home' ? 'splash' : 'list'}>
|
|
319
|
+
<nav class="list-col" aria-label="Operations and flows">
|
|
320
|
+
<CatalogList entries={entries} services={services} />
|
|
321
|
+
</nav>
|
|
322
|
+
<ListResizer />
|
|
323
|
+
<main class="stage">
|
|
324
|
+
{(r.kind === 'home' || r.kind === 'browse') && <Overview services={services} />}
|
|
325
|
+
</main>
|
|
326
|
+
</div>
|
|
327
|
+
<Palette entries={entries} />
|
|
328
|
+
{panel && r.kind === 'op' && opInfo && (
|
|
329
|
+
<panel.Host
|
|
330
|
+
kind="op"
|
|
331
|
+
modal={false}
|
|
332
|
+
service={opInfo.service}
|
|
333
|
+
op={opInfo.op}
|
|
334
|
+
variants={variants[r.key] ?? []}
|
|
335
|
+
example={r.play ?? ''}
|
|
336
|
+
tab={r.tab}
|
|
337
|
+
onNavigate={navigate}
|
|
338
|
+
onPrev={neighbors.prev}
|
|
339
|
+
onNext={neighbors.next}
|
|
340
|
+
onClose={close}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
{panel && r.kind === 'seq' && seqInfo && (
|
|
344
|
+
<panel.Host
|
|
345
|
+
kind="seq"
|
|
346
|
+
modal={false}
|
|
347
|
+
info={seqInfo}
|
|
348
|
+
tab={r.tab}
|
|
349
|
+
onNavigate={navigate}
|
|
350
|
+
onPrev={neighbors.prev}
|
|
351
|
+
onNext={neighbors.next}
|
|
352
|
+
onClose={close}
|
|
353
|
+
/>
|
|
354
|
+
)}
|
|
355
|
+
</div>
|
|
356
|
+
)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function findOp(key: string) {
|
|
360
|
+
const [service, id] = key.split('/') as [string, string]
|
|
361
|
+
const op = registry.manifests[service]?.operations.find((o) => o.id === id)
|
|
362
|
+
return op ? { service, op } : undefined
|
|
363
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64' role='img' aria-label='Specbook'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='#8b5cf6'/><stop offset='1' stop-color='#5b21b6'/></linearGradient></defs><rect width='64' height='64' rx='15' fill='url(#g)'/><path d='M32 19.5c-4.5-3-10-3.6-15.5-2.4v28.4c5.500-1.200 11-.6 15.500 2.400 4.500-3 10-3.600 15.500-2.400V17.100c-5.500-1.200-11-.6-15.500 2.400z' fill='#fff' fill-opacity='.95'/><path d='M32 19.500v28.400' stroke='#6d28d9' stroke-width='2' stroke-linecap='round'/><path d='M38 27.500v9.500l7.500-4.750z' fill='#6d28d9'/><path d='M26.500 28.500l-4 3.750 4 3.750' fill='none' stroke='#6d28d9' stroke-width='2.200' stroke-linecap='round' stroke-linejoin='round'/></svg>
|
package/src/badge.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const OWN = /(^|\.)specbook\.dev$/i
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* "Powered by Specbook" shows on a published site (a build, or the viewer on someone's own host) and
|
|
5
|
+
* not for the author running the dev server, nor on specbook.dev itself, where the name is already
|
|
6
|
+
* everywhere. A project turns it off with `branding.badge: false`.
|
|
7
|
+
*/
|
|
8
|
+
export function showBadge(o: { host: string; published: boolean; badge?: boolean }): boolean {
|
|
9
|
+
return o.published && o.badge !== false && !OWN.test(o.host)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The link back carries the host, so a site's referrals can be told apart without any tracking script. */
|
|
13
|
+
export const badgeHref = (host: string) =>
|
|
14
|
+
`https://ui.specbook.dev/?ref=${encodeURIComponent(host)}`
|
package/src/branding.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { signal } from '@preact/signals'
|
|
2
|
+
import type { BrandingConfig } from '@specbook/core'
|
|
3
|
+
|
|
4
|
+
export interface Brand {
|
|
5
|
+
/** the project's config has been read; until then the overview would show its fallback text */
|
|
6
|
+
ready?: boolean
|
|
7
|
+
name?: string
|
|
8
|
+
title?: string
|
|
9
|
+
tagline?: string
|
|
10
|
+
logo?: string
|
|
11
|
+
/** background behind a logo that needs one (from the spec's x-logo) */
|
|
12
|
+
logoBackground?: string
|
|
13
|
+
favicon?: string
|
|
14
|
+
links: { label: string; href: string }[]
|
|
15
|
+
/** `false` hides the "Powered by Specbook" line */
|
|
16
|
+
badge?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The resolved branding: config text and colours, plus logo/favicon from URLs or `.specbook/branding/`. */
|
|
20
|
+
export const brand = signal<Brand>({ links: [], ready: false })
|
|
21
|
+
|
|
22
|
+
function rgb(hex: string): [number, number, number] | undefined {
|
|
23
|
+
const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim())
|
|
24
|
+
if (!m) return undefined
|
|
25
|
+
const h =
|
|
26
|
+
(m[1] as string).length === 3
|
|
27
|
+
? [...(m[1] as string)].map((c) => c + c).join('')
|
|
28
|
+
: (m[1] as string)
|
|
29
|
+
return [0, 2, 4].map((i) => Number.parseInt(h.slice(i, i + 2), 16)) as [number, number, number]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Whichever of white and near-black has the better contrast ratio on the accent. */
|
|
33
|
+
export function onAccent(hex: string): string | undefined {
|
|
34
|
+
const c = rgb(hex)
|
|
35
|
+
if (!c) return undefined
|
|
36
|
+
const lin = c.map((v) => {
|
|
37
|
+
const s = v / 255
|
|
38
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4
|
|
39
|
+
})
|
|
40
|
+
const l = 0.2126 * (lin[0] as number) + 0.7152 * (lin[1] as number) + 0.0722 * (lin[2] as number)
|
|
41
|
+
const dark = 0.006 // #0b1020
|
|
42
|
+
const onWhite = 1.05 / (l + 0.05)
|
|
43
|
+
const onDark = (l + 0.05) / (dark + 0.05)
|
|
44
|
+
return onDark > onWhite ? '#0b1020' : '#ffffff'
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const tokens = (accent: string) =>
|
|
48
|
+
`--sb-accent:${accent};--sb-accent-hover:color-mix(in srgb,${accent} 82%,black);` +
|
|
49
|
+
`--sb-focus-ring:color-mix(in srgb,${accent} 45%,white);` +
|
|
50
|
+
(onAccent(accent) ? `--sb-text-on-accent:${onAccent(accent)};` : '')
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The accent as CSS: unlayered, so it wins over the default tokens. A separate dark value applies
|
|
54
|
+
* under `prefers-color-scheme: dark` (unless the light theme is forced) and `data-theme="dark"`.
|
|
55
|
+
*/
|
|
56
|
+
export function brandCss(b: Pick<BrandingConfig, 'accent' | 'accentDark'>): string {
|
|
57
|
+
if (!b.accent && !b.accentDark) return ''
|
|
58
|
+
const light = b.accent ? `:root{${tokens(b.accent)}}` : ''
|
|
59
|
+
if (!b.accentDark) return light
|
|
60
|
+
return (
|
|
61
|
+
`${light}` +
|
|
62
|
+
`@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){${tokens(b.accentDark)}}}` +
|
|
63
|
+
`:root[data-theme="dark"]{${tokens(b.accentDark)}}`
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Applies the branding to the page: accent tokens, tab title and favicon. */
|
|
68
|
+
export function applyBranding(
|
|
69
|
+
cfg: BrandingConfig | undefined,
|
|
70
|
+
resolved: Brand,
|
|
71
|
+
fallbackName: string,
|
|
72
|
+
) {
|
|
73
|
+
if (typeof document === 'undefined') return
|
|
74
|
+
let style = document.getElementById('specbook-brand') as HTMLStyleElement | null
|
|
75
|
+
if (!style) {
|
|
76
|
+
style = document.createElement('style')
|
|
77
|
+
style.id = 'specbook-brand'
|
|
78
|
+
document.head.append(style)
|
|
79
|
+
}
|
|
80
|
+
style.textContent = cfg ? brandCss(cfg) : ''
|
|
81
|
+
const name = resolved.name ?? fallbackName
|
|
82
|
+
document.title = resolved.title ? `${name} · ${resolved.title}` : name
|
|
83
|
+
if (resolved.favicon || resolved.logo) {
|
|
84
|
+
let link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
|
|
85
|
+
if (!link) {
|
|
86
|
+
link = document.createElement('link')
|
|
87
|
+
link.rel = 'icon'
|
|
88
|
+
document.head.append(link)
|
|
89
|
+
}
|
|
90
|
+
link.href = (resolved.favicon ?? resolved.logo) as string
|
|
91
|
+
}
|
|
92
|
+
}
|