@specbook/vite 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/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@specbook/vite",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "The Specbook plugin for Vite: the registry, the docs UI hooks and the dev server credential proxy.",
5
+ "keywords": [
6
+ "specbook",
7
+ "vite",
8
+ "vite-plugin",
9
+ "openapi",
10
+ "api-explorer"
11
+ ],
12
+ "license": "FSL-1.1-MIT",
13
+ "homepage": "https://ui.specbook.dev",
14
+ "bugs": {
15
+ "url": "https://github.com/specbook-dev/specbook/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/specbook-dev/specbook.git",
20
+ "directory": "packages/vite"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "node": ">=24"
27
+ },
28
+ "type": "module",
29
+ "sideEffects": false,
30
+ "exports": {
31
+ ".": {
32
+ "types": "./src/index.ts",
33
+ "default": "./dist/index.js"
34
+ }
35
+ },
36
+ "dependencies": {
37
+ "@specbook/codegen": "0.2.0",
38
+ "@specbook/core": "0.2.0",
39
+ "fast-glob": "3.3.2",
40
+ "marked": "^18.1.1"
41
+ },
42
+ "peerDependencies": {
43
+ "vite": ">=5.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "24.19.2",
47
+ "tsup": "8.5.1",
48
+ "typescript": "7.0.2",
49
+ "vite": "8.3.4"
50
+ },
51
+ "files": [
52
+ "dist",
53
+ "src",
54
+ "!**/*.test.*"
55
+ ],
56
+ "scripts": {
57
+ "build": "tsup && tsc --noEmit",
58
+ "typecheck": "tsc --noEmit",
59
+ "test": "vitest run"
60
+ }
6
61
  }
@@ -0,0 +1,60 @@
1
+ import { parseDiscovery } from '@specbook/core'
2
+
3
+ const FETCH_TIMEOUT_MS = 5_000
4
+
5
+ /**
6
+ * Learns, on the server, where an OpenID Connect provider's token endpoint is. The page needs the proxy to
7
+ * forward its code exchange there, and the proxy only forwards to origins it was told about by the
8
+ * project's own config; a discovery document is the config's pointer to that origin. Successes are kept
9
+ * for the life of the dev server; a failure is not retried for a short while, so a provider that is down
10
+ * costs one slow request, not one per call.
11
+ */
12
+ export function createDiscovery(
13
+ opts: {
14
+ fetch?: typeof fetch
15
+ retryAfterMs?: number
16
+ now?: () => number
17
+ warn?: (message: string) => void
18
+ } = {},
19
+ ) {
20
+ const f = opts.fetch ?? fetch
21
+ const now = opts.now ?? Date.now
22
+ const retryAfter = opts.retryAfterMs ?? 30_000
23
+ const found = new Map<string, string>()
24
+ const failedAt = new Map<string, number>()
25
+ const inFlight = new Map<string, Promise<string | undefined>>()
26
+
27
+ async function look(url: string): Promise<string | undefined> {
28
+ try {
29
+ const res = await f(url, {
30
+ headers: { accept: 'application/json' },
31
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
32
+ })
33
+ if (!res.ok) throw new Error(`answered ${res.status}`)
34
+ const origin = new URL(parseDiscovery(await res.json()).tokenUrl).origin
35
+ found.set(url, origin)
36
+ failedAt.delete(url)
37
+ return origin
38
+ } catch (e) {
39
+ failedAt.set(url, now())
40
+ opts.warn?.(
41
+ `Could not read the OpenID Connect discovery document at ${url} (${e instanceof Error ? e.message : String(e)}). Sign in is unavailable until it can; pasting a token still works.`,
42
+ )
43
+ return undefined
44
+ }
45
+ }
46
+
47
+ /** The origin of the provider's token endpoint, or `undefined` when discovery has failed. */
48
+ return function tokenOrigin(url: string): Promise<string | undefined> {
49
+ const known = found.get(url)
50
+ if (known) return Promise.resolve(known)
51
+ const failed = failedAt.get(url)
52
+ if (failed !== undefined && now() - failed < retryAfter) return Promise.resolve(undefined)
53
+ let pending = inFlight.get(url)
54
+ if (!pending) {
55
+ pending = look(url).finally(() => inFlight.delete(url))
56
+ inFlight.set(url, pending)
57
+ }
58
+ return pending
59
+ }
60
+ }
package/src/docs.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { relative, sep } from 'node:path'
3
+ import fg from 'fast-glob'
4
+ import { marked } from 'marked'
5
+
6
+ export interface DocPage {
7
+ file: string
8
+ /** `service/OperationId` */
9
+ operation?: string
10
+ sequence?: string
11
+ /** project-relative `.seq.ts` that sits beside a `<name>.seq.md` */
12
+ sequenceFile?: string
13
+ /** project-relative `.req.ts` that sits beside a `<name>.req.md` */
14
+ requestFile?: string
15
+ /** frontmatter `replace: true`: the page stands in for the spec's own description */
16
+ replace?: boolean
17
+ title?: string
18
+ html: string
19
+ }
20
+
21
+ export function frontmatter(src: string): { meta: Record<string, string>; body: string } {
22
+ const m = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(src)
23
+ if (!m) return { meta: {}, body: src }
24
+ const meta: Record<string, string> = {}
25
+ for (const line of (m[1] as string).split(/\r?\n/)) {
26
+ const i = line.indexOf(':')
27
+ if (i > 0)
28
+ meta[line.slice(0, i).trim()] = line
29
+ .slice(i + 1)
30
+ .trim()
31
+ .replace(/^["']|["']$/g, '')
32
+ }
33
+ return { meta, body: src.slice(m[0].length) }
34
+ }
35
+
36
+ const OP_FOLDER = /(?:^|\/)(?:requests|apis)\/([^/]+)\/([^/]+)\/(?:readme|index)\.md$/i
37
+ /** `requests/<service>/docs/<Operation>.md`: one folder per service, instead of one per operation. */
38
+ const OP_FLAT = /(?:^|\/)(?:requests|apis)\/([^/]+)\/docs\/([^/]+)\.md$/i
39
+ /** Names that are about the folder, not an operation. */
40
+ const NOT_AN_OPERATION = /^(readme|index)$/i
41
+
42
+ /**
43
+ * Where a page belongs, from its place on disk: `<name>.seq.md` beside `<name>.seq.ts`,
44
+ * `<name>.req.md` beside `<name>.req.ts`, `requests/<service>/<Operation>/README.md`, or the flat
45
+ * `requests/<service>/docs/<Operation>.md`.
46
+ */
47
+ export function colocated(
48
+ file: string,
49
+ ): Pick<DocPage, 'operation' | 'sequenceFile' | 'requestFile'> | undefined {
50
+ if (/\.seq\.md$/.test(file)) return { sequenceFile: file.replace(/\.md$/, '.ts') }
51
+ if (/\.req\.md$/.test(file)) return { requestFile: file.replace(/\.md$/, '.ts') }
52
+ // `docs` is the flat folder, not an operation named "docs"
53
+ const folder = OP_FOLDER.exec(file)
54
+ if (folder && folder[2] !== 'docs') return { operation: `${folder[1]}/${folder[2]}` }
55
+ const flat = OP_FLAT.exec(file)
56
+ if (flat && !NOT_AN_OPERATION.test(flat[2] as string))
57
+ return { operation: `${flat[1]}/${flat[2]}` }
58
+ return undefined
59
+ }
60
+
61
+ /**
62
+ * Markdown pages that belong to something: by place on disk (see `colocated`), or by naming an
63
+ * `operation:` / `sequence:` (or `page:`) in their frontmatter.
64
+ */
65
+ export async function scanDocs(root: string): Promise<DocPage[]> {
66
+ const files = await fg('**/*.md', {
67
+ cwd: root,
68
+ absolute: true,
69
+ ignore: ['**/node_modules/**', '**/.generated/**', '**/dist/**', '**/.git/**'],
70
+ })
71
+ const pages: DocPage[] = []
72
+ const claimed = new Map<string, string>()
73
+ for (const abs of files.sort()) {
74
+ const { meta, body } = frontmatter(await readFile(abs, 'utf8'))
75
+ const file = relative(root, abs).split(sep).join('/')
76
+ const near = colocated(file)
77
+ if (!near && !meta.operation && !meta.sequence && !meta.page) continue
78
+ const operation = meta.operation ?? near?.operation
79
+ if (operation) {
80
+ const first = claimed.get(operation)
81
+ if (first) {
82
+ // two pages for one operation: the first (by path) is used, and the other would be silently ignored
83
+ console.warn(
84
+ `[specbook] ${file} documents ${operation}, which ${first} already does; ignored`,
85
+ )
86
+ continue
87
+ }
88
+ claimed.set(operation, file)
89
+ }
90
+ pages.push({
91
+ file,
92
+ operation,
93
+ sequence: meta.sequence,
94
+ sequenceFile: near?.sequenceFile,
95
+ requestFile: near?.requestFile,
96
+ replace: meta.replace === 'true' || undefined,
97
+ title: meta.title ?? meta.page,
98
+ html: marked.parse(body, { async: false }) as string,
99
+ })
100
+ }
101
+ return pages
102
+ }
@@ -0,0 +1,114 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises'
3
+ import { join } from 'node:path'
4
+ import { parseDotenv } from '@specbook/core'
5
+
6
+ /**
7
+ * Per-environment values live in `.specbook/environments/`:
8
+ * staging.env non-secret variables, committed
9
+ * staging.secrets.env secrets, gitignored by the folder's own .gitignore
10
+ * `.specbook/secrets.local.env` stays as the shared, all-environments fallback for secrets.
11
+ */
12
+ export const ENV_NAME = /^[A-Za-z0-9_-]+$/
13
+ const VAR_NAME = /^[A-Za-z_][\w.-]*$/
14
+
15
+ export const environmentsDir = (root: string) => join(root, '.specbook', 'environments')
16
+ export const varsFile = (root: string, env: string) => join(environmentsDir(root), `${env}.env`)
17
+ export const secretsFileFor = (root: string, env: string) =>
18
+ join(environmentsDir(root), `${env}.secrets.env`)
19
+ export const sharedSecretsFile = (root: string) => join(root, '.specbook', 'secrets.local.env')
20
+
21
+ export const isEnvName = (s: unknown): s is string => typeof s === 'string' && ENV_NAME.test(s)
22
+ export const isVarName = (s: unknown): s is string => typeof s === 'string' && VAR_NAME.test(s)
23
+
24
+ async function readParsed(file: string): Promise<Record<string, string>> {
25
+ return existsSync(file) ? parseDotenv(await readFile(file, 'utf8')) : {}
26
+ }
27
+
28
+ export interface EnvironmentValues {
29
+ /** non-secret variables from `<env>.env` */
30
+ vars: Record<string, string>
31
+ /** secrets from `<env>.secrets.env` */
32
+ secrets: Record<string, string>
33
+ /** secrets shared by every environment (`secrets.local.env`) */
34
+ shared: Record<string, string>
35
+ }
36
+
37
+ export async function readEnvironment(root: string, env: string): Promise<EnvironmentValues> {
38
+ if (!isEnvName(env))
39
+ return { vars: {}, secrets: {}, shared: await readParsed(sharedSecretsFile(root)) }
40
+ const [vars, secrets, shared] = await Promise.all([
41
+ readParsed(varsFile(root, env)),
42
+ readParsed(secretsFileFor(root, env)),
43
+ readParsed(sharedSecretsFile(root)),
44
+ ])
45
+ return { vars, secrets, shared }
46
+ }
47
+
48
+ /** Names of environments that have a file, from `<name>.env` and `<name>.secrets.env`. */
49
+ export async function listEnvironmentFiles(root: string): Promise<string[]> {
50
+ const dir = environmentsDir(root)
51
+ if (!existsSync(dir)) return []
52
+ const names = new Set<string>()
53
+ for (const f of await readdir(dir)) {
54
+ const m = /^([A-Za-z0-9_-]+)(\.secrets)?\.env$/.exec(f)
55
+ if (m) names.add(m[1] as string)
56
+ }
57
+ return [...names].sort()
58
+ }
59
+
60
+ /** The committed variable files, for the registry and static builds. */
61
+ export async function readAllVars(root: string): Promise<Record<string, Record<string, string>>> {
62
+ const out: Record<string, Record<string, string>> = {}
63
+ const dir = environmentsDir(root)
64
+ if (!existsSync(dir)) return out
65
+ for (const f of await readdir(dir)) {
66
+ const m = /^([A-Za-z0-9_-]+)\.env$/.exec(f)
67
+ if (m) out[m[1] as string] = parseDotenv(await readFile(join(dir, f), 'utf8'))
68
+ }
69
+ return out
70
+ }
71
+
72
+ const quote = (v: string) => {
73
+ if (/[\r\n]/.test(v)) throw new Error('values must be a single line')
74
+ if (!/[\s#"'\\]/.test(v)) return v
75
+ if (!v.includes('"')) return `"${v}"`
76
+ if (!v.includes("'")) return `'${v}'`
77
+ return v
78
+ }
79
+
80
+ /** Insert, replace or (with `undefined`) delete one key, leaving every other line untouched. */
81
+ export function setDotenvKey(text: string, key: string, value: string | undefined): string {
82
+ const lines = text === '' ? [] : text.split('\n')
83
+ const at = lines.findIndex((l) => new RegExp(`^\\s*(?:export\\s+)?${escapeRe(key)}\\s*=`).test(l))
84
+ if (value === undefined) {
85
+ if (at >= 0) lines.splice(at, 1)
86
+ } else if (at >= 0) lines[at] = `${key}=${quote(value)}`
87
+ else {
88
+ if (lines.length && lines[lines.length - 1] === '') lines.pop()
89
+ lines.push(`${key}=${quote(value)}`)
90
+ }
91
+ const out = lines.join('\n')
92
+ return out === '' || out.endsWith('\n') ? out : `${out}\n`
93
+ }
94
+
95
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
96
+
97
+ /** Creates `environments/` with its own .gitignore, so secrets files can never be committed by accident. */
98
+ export async function ensureEnvironmentsDir(root: string): Promise<string> {
99
+ const dir = environmentsDir(root)
100
+ await mkdir(dir, { recursive: true })
101
+ const ignore = join(dir, '.gitignore')
102
+ if (!existsSync(ignore))
103
+ await writeFile(ignore, '# secrets never leave this machine\n*.secrets.env\n')
104
+ return dir
105
+ }
106
+
107
+ export async function writeEnvKey(
108
+ file: string,
109
+ key: string,
110
+ value: string | undefined,
111
+ ): Promise<void> {
112
+ const current = existsSync(file) ? await readFile(file, 'utf8') : ''
113
+ await writeFile(file, setDotenvKey(current, key, value))
114
+ }
@@ -0,0 +1,77 @@
1
+ import { relative, sep } from 'node:path'
2
+ import fg from 'fast-glob'
3
+
4
+ export interface ExampleEntry {
5
+ name: string
6
+ abs: string
7
+ source: 'generated' | 'project'
8
+ /** Key of the folder the example lives in; file-field paths are relative to it. */
9
+ dir: string
10
+ }
11
+
12
+ export interface ExampleScan {
13
+ /** `service/OperationId` -> examples, project files overriding generated ones of the same name */
14
+ examples: Record<string, ExampleEntry[]>
15
+ /** `<dir>/<file>` -> absolute path, for the non-JSON files an example may reference */
16
+ assets: Record<string, string>
17
+ }
18
+
19
+ const IGNORE = ['**/node_modules/**', '**/.generated/**', '**/dist/**', '**/.git/**']
20
+ const NAME = /\.example\.json$/
21
+
22
+ export async function scanExamples(
23
+ root: string,
24
+ outDir: string,
25
+ operations: Set<string>,
26
+ ): Promise<ExampleScan> {
27
+ const examples: Record<string, ExampleEntry[]> = {}
28
+ const assets: Record<string, string> = {}
29
+ const dirs = new Map<string, string>()
30
+
31
+ const add = (key: string, entry: ExampleEntry) => {
32
+ examples[key] ??= []
33
+ const list = examples[key] as ExampleEntry[]
34
+ const at = list.findIndex((e) => e.name === entry.name)
35
+ if (at >= 0) list[at] = entry
36
+ else list.push(entry)
37
+ dirs.set(entry.dir, entry.abs.slice(0, entry.abs.lastIndexOf(sep)))
38
+ }
39
+
40
+ const gen = `${root}/${outDir}`
41
+ for (const abs of await fg('*/examples/*/*.example.json', { cwd: gen, absolute: true })) {
42
+ const [svc, , id, file] = relative(gen, abs).split(sep) as [string, string, string, string]
43
+ add(`${svc}/${id}`, {
44
+ name: file.replace(NAME, ''),
45
+ abs,
46
+ source: 'generated',
47
+ dir: `${svc}/${id}@generated`,
48
+ })
49
+ }
50
+ for (const abs of await fg('**/*.example.json', { cwd: root, absolute: true, ignore: IGNORE })) {
51
+ const parts = relative(root, abs).split(sep)
52
+ const file = parts.pop() as string
53
+ const id = parts.pop()
54
+ const svc = parts.pop()
55
+ const key = `${svc}/${id}`
56
+ if (!svc || !id || !operations.has(key)) continue
57
+ add(key, {
58
+ name: file.replace(NAME, ''),
59
+ abs,
60
+ source: 'project',
61
+ dir: `${svc}/${id}@project`,
62
+ })
63
+ }
64
+
65
+ for (const [dir, abs] of dirs) {
66
+ for (const f of await fg('*', { cwd: abs, absolute: false, ignore: ['*.json'] })) {
67
+ assets[`${dir}/${f}`] = `${abs}/${f}`
68
+ }
69
+ }
70
+ for (const list of Object.values(examples))
71
+ list.sort((a, b) => order(a) - order(b) || a.name.localeCompare(b.name))
72
+ return { examples, assets }
73
+ }
74
+
75
+ /** Spec and project examples first, then the generated default and dynamic ones. */
76
+ const order = (e: ExampleEntry) =>
77
+ e.source === 'project' ? 0 : e.name === 'default' ? 2 : e.name === 'dynamic' ? 3 : 1
package/src/files.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { join, relative, resolve, sep } from 'node:path'
4
+ import fg from 'fast-glob'
5
+
6
+ const IGNORE = ['**/node_modules/**', '**/.generated/**', '**/dist/**', '**/.git/**']
7
+
8
+ export interface ProjectFiles {
9
+ requests: string[]
10
+ sequences: string[]
11
+ /** every .ts/.json file the editor may need for typings (relative to root) */
12
+ sources: string[]
13
+ }
14
+
15
+ export async function scanProject(root: string): Promise<ProjectFiles> {
16
+ const rel = (e: string[]) => e.map((f) => relative(root, f).split(sep).join('/')).sort()
17
+ const [requests, sequences, sources] = await Promise.all([
18
+ fg('**/*.req.ts', { cwd: root, absolute: true, ignore: IGNORE }),
19
+ fg('**/*.seq.ts', { cwd: root, absolute: true, ignore: IGNORE }),
20
+ fg(['**/*.ts', '**/*.json'], {
21
+ cwd: root,
22
+ absolute: true,
23
+ ignore: [
24
+ ...IGNORE,
25
+ '**/package.json',
26
+ '**/tsconfig*.json',
27
+ '**/openapi/**',
28
+ '**/*.d.ts',
29
+ '**/*.example.json',
30
+ ],
31
+ }),
32
+ ])
33
+ return { requests: rel(requests), sequences: rel(sequences), sources: rel(sources) }
34
+ }
35
+
36
+ /** Sources, plus the sample files an example may upload. */
37
+ const WRITABLE = /\.(ts|json|txt|csv|png|jpe?g|gif|webp|pdf|zip|bin)$/
38
+ const BLOCKED = /(^|\/)(node_modules|\.generated|\.git|dist)(\/|$)|secrets\.local\.env/
39
+
40
+ /** Resolve a project-relative path for reading/writing, or undefined when not allowed. */
41
+ export function safeProjectPath(root: string, relPath: string): string | undefined {
42
+ if (typeof relPath !== 'string' || relPath.includes('\0')) return undefined
43
+ const abs = resolve(root, relPath)
44
+ const rel = relative(root, abs).split(sep).join('/')
45
+ if (rel.startsWith('..') || rel === '' || resolve(rel) === rel) return undefined
46
+ if (BLOCKED.test(rel) || !WRITABLE.test(rel)) return undefined
47
+ return abs
48
+ }
49
+
50
+ export async function readDotenvFile(path: string): Promise<string> {
51
+ return existsSync(path) ? readFile(path, 'utf8') : ''
52
+ }
53
+
54
+ export const secretsFile = (root: string) => join(root, '.specbook', 'secrets.local.env')
55
+
56
+ /** Where new example files go: `requests/`, or `apis/` for projects that already use that folder name. */
57
+ export function requestsDirFor(root: string): 'requests' | 'apis' {
58
+ return existsSync(join(root, 'apis')) && !existsSync(join(root, 'requests')) ? 'apis' : 'requests'
59
+ }