@panomc/client-gen 0.0.0-stage → 1.0.0-dev.62

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.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js'
3
+
4
+ process.exitCode = await run(process.argv.slice(2))
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@panomc/client-gen",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0-dev.62",
4
+ "description": "Generates a typed JavaScript client from a Pano OpenAPI document and scaffolds headless sites (pano-client).",
5
+ "author": "PanoMC",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "repository": {
9
+ "url": "https://github.com/PanoMC/sdk"
10
+ },
11
+ "exports": {
12
+ ".": "./src/index.js",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "bin": {
16
+ "pano-client": "./bin/pano-client.js"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "bin",
21
+ "templates"
22
+ ],
23
+ "publishConfig": {
24
+ "access": "public"
25
+ }
26
+ }
@@ -0,0 +1,36 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ /**
4
+ * Deep copy with every object's keys sorted. Arrays keep their order.
5
+ * @param {any} value
6
+ * @returns {any}
7
+ */
8
+ export function sortKeys(value) {
9
+ if (Array.isArray(value)) return value.map(sortKeys)
10
+ if (value && typeof value === 'object') {
11
+ /** @type {Record<string, any>} */
12
+ const out = {}
13
+ for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key])
14
+ return out
15
+ }
16
+ return value
17
+ }
18
+
19
+ /**
20
+ * The one text form of an OpenAPI document: sorted keys, two spaces, trailing newline.
21
+ * Generated `openapi.json` files and the byte comparison of `pull` / `check` use it.
22
+ * @param {any} doc
23
+ */
24
+ export function canonicalJson(doc) {
25
+ return JSON.stringify(sortKeys(doc), null, 2) + '\n'
26
+ }
27
+
28
+ /** @param {string|Uint8Array} data */
29
+ export function sha256(data) {
30
+ return createHash('sha256').update(data).digest('hex')
31
+ }
32
+
33
+ /** Short stable fingerprint used in `plugins/index.json`. @param {string|Uint8Array} data */
34
+ export function shortHash(data) {
35
+ return sha256(data).slice(0, 16)
36
+ }
package/src/check.js ADDED
@@ -0,0 +1,143 @@
1
+ // check: compare a pulled directory with a running Pano (doc 06 §2.4). Exit 1 on removed / changed operations.
2
+ import { existsSync, readFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { join, resolve } from 'node:path'
5
+ import { shortHash } from './canonical.js'
6
+ import { ClientGenError } from './errors.js'
7
+ import { collectOperations, fingerprint } from './operations.js'
8
+ import { getText, isSafeName, parseJson, trimBase, unreachableMessage } from './http.js'
9
+
10
+ /**
11
+ * @typedef {Object} CheckOptions
12
+ * @property {string} url
13
+ * @property {string} [dir] default src/lib/pano
14
+ * @property {string} [cwd]
15
+ * @property {typeof fetch} [fetch]
16
+ * @property {string | null} [installedCore]
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} CheckReport
21
+ * @property {boolean} ok
22
+ * @property {string[]} problems removed / changed operations, removed plugins, changed controllers
23
+ * @property {string[]} notes additions and skipped parts (never fail the check)
24
+ */
25
+
26
+ /** @param {any} doc @param {string} label @param {any} oldDoc @param {string[]} problems @param {string[]} notes */
27
+ function diffDocs(label, oldDoc, doc, problems, notes) {
28
+ const before = new Map(collectOperations(oldDoc).map((o) => [o.name, o]))
29
+ const after = new Map(collectOperations(doc).map((o) => [o.name, o]))
30
+ for (const [name, op] of before) {
31
+ const now = after.get(name)
32
+ if (!now) problems.push(`${label}: removed ${name} (${op.method} ${op.path})`)
33
+ else if (fingerprint(oldDoc, op) !== fingerprint(doc, now)) {
34
+ const moved = now.method !== op.method || now.path !== op.path
35
+ problems.push(`${label}: changed ${name} (${op.method} ${op.path}${moved ? ` -> ${now.method} ${now.path}` : ''})`)
36
+ }
37
+ }
38
+ for (const [name, op] of after) if (!before.has(name)) notes.push(`${label}: new ${name} (${op.method} ${op.path})`)
39
+ }
40
+
41
+ /** @param {string} p */
42
+ function readJson(p) {
43
+ return JSON.parse(readFileSync(p, 'utf8'))
44
+ }
45
+
46
+ /**
47
+ * @param {CheckOptions} options
48
+ * @returns {Promise<CheckReport>}
49
+ */
50
+ export async function check(options) {
51
+ if (!options.url) throw new ClientGenError('check needs --url <pano>, e.g. --url http://localhost:8088')
52
+ const base = trimBase(options.url)
53
+ const cwd = resolve(options.cwd ?? process.cwd())
54
+ const dir = resolve(cwd, options.dir ?? 'src/lib/pano')
55
+ const fetchOpts = { fetch: options.fetch, onUnreachable: () => unreachableMessage(base) }
56
+ /** @type {string[]} */
57
+ const problems = []
58
+ /** @type {string[]} */
59
+ const notes = []
60
+
61
+ if (!existsSync(join(dir, 'plugins', 'index.json')) && !existsSync(join(dir, 'core', 'openapi.json'))) {
62
+ throw new ClientGenError(`Nothing to check in ${dir}. Run pano-client pull --url ${base} first.`)
63
+ }
64
+
65
+ // ---- core: the pulled snapshot, else the installed @panomc/client one
66
+ const coreRes = await getText(`${base}/api/v1/openapi.json`, fetchOpts)
67
+ if (!coreRes.ok) {
68
+ throw new ClientGenError(`Pano answered ${coreRes.status} at ${base}/api/v1/openapi.json. Is this a Pano with the /api/v1 API?`)
69
+ }
70
+ const coreDoc = parseJson(coreRes.text, `${base}/api/v1/openapi.json`)
71
+ let coreSnapshot = join(dir, 'core', 'openapi.json')
72
+ if (!existsSync(coreSnapshot)) {
73
+ let installed = options.installedCore
74
+ if (installed === undefined) {
75
+ try {
76
+ installed = createRequire(join(cwd, 'package.json')).resolve('@panomc/client/core/openapi.json')
77
+ } catch {
78
+ installed = null
79
+ }
80
+ }
81
+ coreSnapshot = installed ?? ''
82
+ }
83
+ if (coreSnapshot && existsSync(coreSnapshot)) diffDocs('core', readJson(coreSnapshot), coreDoc, problems, notes)
84
+ else notes.push('core: no snapshot to compare with, skipped')
85
+
86
+ // ---- plugins
87
+ /** @type {Record<string, any>} */
88
+ const index = existsSync(join(dir, 'plugins', 'index.json')) ? readJson(join(dir, 'plugins', 'index.json')) : {}
89
+ /** @type {Map<string, any>} */
90
+ const packages = new Map()
91
+ const listRes = await getText(`${base}/api/v1/plugin-packages`, fetchOpts)
92
+ if (listRes.ok) {
93
+ const plugins = parseJson(listRes.text, `${base}/api/v1/plugin-packages`)?.plugins ?? {}
94
+ for (const id of Object.keys(plugins)) packages.set(id, plugins[id] ?? {})
95
+ } else {
96
+ notes.push(`plugins: ${base}/api/v1/plugin-packages answered ${listRes.status}, plugins without a UI package are checked by their OpenAPI only`)
97
+ }
98
+
99
+ for (const ns of Object.keys(index).sort()) {
100
+ const entry = index[ns]
101
+ const pluginId = entry?.pluginId
102
+ if (!isSafeName(ns) || !isSafeName(pluginId)) continue
103
+ const id = encodeURIComponent(pluginId)
104
+ const pkg = packages.get(pluginId)
105
+
106
+ // OpenAPI
107
+ const snapshot = join(dir, 'plugins', ns, 'openapi.json')
108
+ const docRes = await getText(`${base}/api/v1/plugins/${id}/_/openapi.json`, fetchOpts)
109
+ if (docRes.ok) {
110
+ if (existsSync(snapshot)) diffDocs(ns, readJson(snapshot), parseJson(docRes.text, `${pluginId} openapi.json`), problems, notes)
111
+ else problems.push(`${ns}: now ships an OpenAPI document (run pano-client pull)`)
112
+ } else if (existsSync(snapshot)) {
113
+ if (listRes.ok && !pkg) problems.push(`${ns}: plugin ${pluginId} is no longer active on this Pano (run pano-client pull)`)
114
+ else problems.push(`${ns}: plugin ${pluginId} no longer ships an OpenAPI document (answered ${docRes.status}, run pano-client pull)`)
115
+ continue
116
+ } else if (listRes.ok && !pkg) {
117
+ problems.push(`${ns}: plugin ${pluginId} is no longer active on this Pano (run pano-client pull)`)
118
+ continue
119
+ }
120
+
121
+ // controllers
122
+ if (entry.controllersHash || (pkg && pkg.controllers)) {
123
+ let current = null
124
+ if (pkg && pkg.controllers) {
125
+ const mjs = await getText(`${base}/api/v1/plugins/${id}/_/ui/controllers/controllers.mjs`, fetchOpts)
126
+ const types = await getText(`${base}/api/v1/plugins/${id}/_/ui/contract/controllers.types.js`, fetchOpts)
127
+ if (mjs.ok) current = shortHash(mjs.text + '\0' + (types.ok ? types.text : ''))
128
+ }
129
+ if (current !== (entry.controllersHash ?? null)) {
130
+ problems.push(`${ns}: controllers changed (run pano-client pull)`)
131
+ }
132
+ }
133
+ }
134
+
135
+ // plugins that appeared since the pull
136
+ for (const [pluginId, pkg] of packages) {
137
+ const ns = pkg && typeof pkg.namespace === 'string' && pkg.namespace ? pkg.namespace : pluginId.replace(/^pano-plugin-/, '')
138
+ if (!index[ns]) notes.push(`${ns}: plugin ${pluginId} is active but was not pulled`)
139
+ }
140
+
141
+ return { ok: problems.length === 0, problems, notes }
142
+ }
143
+
package/src/cli.js ADDED
@@ -0,0 +1,106 @@
1
+ import { mkdirSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { parseArgs } from 'node:util'
4
+ import { check } from './check.js'
5
+ import { ClientGenError } from './errors.js'
6
+ import { generateClient } from './generate.js'
7
+ import { scaffold } from './new.js'
8
+ import { pull, writeFiles } from './pull.js'
9
+
10
+ export const USAGE = `pano-client - typed client generator for Pano
11
+
12
+ pano-client generate --input <openapi.json> --out <dir>
13
+ pano-client pull --url <pano> [--out src/lib/pano] [--plugin <pluginId>]...
14
+ pano-client check --url <pano> [--dir src/lib/pano]
15
+ pano-client new <dir> [--url <pano>] [--from <dir>] [--no-install]
16
+ `
17
+
18
+ /**
19
+ * @param {string[]} argv arguments after the program name
20
+ * @param {{ cwd?: string, stdout?: (s: string) => void, stderr?: (s: string) => void, fetch?: typeof fetch }} [io]
21
+ * @returns {Promise<number>} exit code
22
+ */
23
+ export async function run(argv, io = {}) {
24
+ const out = io.stdout ?? ((s) => process.stdout.write(s + '\n'))
25
+ const err = io.stderr ?? ((s) => process.stderr.write(s + '\n'))
26
+ const cwd = io.cwd ?? process.cwd()
27
+ const [command, ...rest] = argv
28
+
29
+ if (!command || command === '--help' || command === '-h' || command === 'help') {
30
+ out(USAGE)
31
+ return command ? 0 : 1
32
+ }
33
+
34
+ try {
35
+ switch (command) {
36
+ case 'generate': {
37
+ const { values } = parseArgs({ args: rest, options: { input: { type: 'string' }, out: { type: 'string' } } })
38
+ if (!values.input || !values.out) throw new ClientGenError('generate needs --input <openapi.json> and --out <dir>')
39
+ let text
40
+ try {
41
+ text = readFileSync(resolve(cwd, values.input), 'utf8')
42
+ } catch {
43
+ throw new ClientGenError(`Cannot read ${resolve(cwd, values.input)}`)
44
+ }
45
+ const gen = generateClient(text)
46
+ gen.warnings.forEach((w) => err(`warning: ${w}`))
47
+ const dir = resolve(cwd, values.out)
48
+ mkdirSync(dir, { recursive: true })
49
+ writeFiles(dir, gen.files)
50
+ out(`generated ${gen.operations.length} operations in ${dir}`)
51
+ return 0
52
+ }
53
+ case 'pull': {
54
+ const { values } = parseArgs({
55
+ args: rest,
56
+ options: { url: { type: 'string' }, out: { type: 'string' }, plugin: { type: 'string', multiple: true } }
57
+ })
58
+ await pull({ url: values.url ?? '', out: values.out, plugins: values.plugin, cwd, fetch: io.fetch, log: out })
59
+ return 0
60
+ }
61
+ case 'check': {
62
+ const { values } = parseArgs({ args: rest, options: { url: { type: 'string' }, dir: { type: 'string' } } })
63
+ const report = await check({ url: values.url ?? '', dir: values.dir, cwd, fetch: io.fetch })
64
+ report.notes.forEach((n) => out(n))
65
+ if (report.ok) {
66
+ out('ok: the generated client matches this Pano')
67
+ return 0
68
+ }
69
+ report.problems.forEach((p) => err(p))
70
+ err('The generated client is out of date. Run pano-client pull.')
71
+ return 1
72
+ }
73
+ case 'new': {
74
+ const { values, positionals } = parseArgs({
75
+ args: rest,
76
+ allowPositionals: true,
77
+ options: { url: { type: 'string' }, from: { type: 'string' }, 'no-install': { type: 'boolean' } }
78
+ })
79
+ const r = await scaffold({
80
+ dir: positionals[0] ?? '',
81
+ url: values.url,
82
+ from: values.from,
83
+ install: !values['no-install'],
84
+ cwd,
85
+ fetch: io.fetch,
86
+ log: out
87
+ })
88
+ out(`\nNext: cd ${positionals[0]} && bun dev`)
89
+ return r.dir ? 0 : 1
90
+ }
91
+ default:
92
+ err(`Unknown command "${command}".\n\n${USAGE}`)
93
+ return 1
94
+ }
95
+ } catch (e) {
96
+ if (e instanceof ClientGenError) {
97
+ err(e.message)
98
+ return 1
99
+ }
100
+ if (e && typeof e === 'object' && /** @type {any} */ (e).code?.startsWith?.('ERR_PARSE_ARGS')) {
101
+ err(`${/** @type {Error} */ (e).message}\n\n${USAGE}`)
102
+ return 1
103
+ }
104
+ throw e
105
+ }
106
+ }
package/src/errors.js ADDED
@@ -0,0 +1,8 @@
1
+ /** An expected failure: the CLI prints the message as is (no stack) and exits 1. */
2
+ export class ClientGenError extends Error {
3
+ /** @param {string} message */
4
+ constructor(message) {
5
+ super(message)
6
+ this.name = 'ClientGenError'
7
+ }
8
+ }
@@ -0,0 +1,201 @@
1
+ // OpenAPI 3.1 document -> index.js, types.js, operations.js, openapi.json (doc 06 §2.3). Pure and deterministic.
2
+ import { canonicalJson, sortKeys } from './canonical.js'
3
+ import { ClientGenError } from './errors.js'
4
+ import { collectOperations, pascal } from './operations.js'
5
+ import { isIdent, objectProperties, oneLine, propKey, quote, schemaType } from './schema.js'
6
+
7
+ const HEADER = '// Generated by pano-client from openapi.json. Do not edit; run pano-client again.\n'
8
+
9
+ /**
10
+ * @typedef {Object} GenerateResult
11
+ * @property {Record<string, string>} files file name -> content: index.js, types.js, operations.js, openapi.json
12
+ * @property {string[]} warnings
13
+ * @property {import('./operations.js').Operation[]} operations
14
+ */
15
+
16
+ /**
17
+ * @param {any} input parsed OpenAPI 3.1 document, or its JSON text
18
+ * @returns {GenerateResult}
19
+ */
20
+ export function generateClient(input) {
21
+ /** @type {any} */
22
+ let doc = input
23
+ if (typeof input === 'string') {
24
+ try {
25
+ doc = JSON.parse(input)
26
+ } catch (e) {
27
+ throw new ClientGenError(`The OpenAPI document is not valid JSON: ${/** @type {Error} */ (e).message}`)
28
+ }
29
+ }
30
+ if (!doc || typeof doc !== 'object' || typeof doc.paths !== 'object') {
31
+ throw new ClientGenError('The input is not an OpenAPI document (no "paths" object).')
32
+ }
33
+ doc = sortKeys(doc)
34
+
35
+ /** @type {string[]} */
36
+ const warnings = []
37
+ const warn = (/** @type {string} */ m) => warnings.push(m)
38
+ const operations = collectOperations(doc, warn)
39
+
40
+ // type names: components.schemas first, then the per-operation aliases
41
+ const schemas = doc.components && typeof doc.components.schemas === 'object' && doc.components.schemas ? doc.components.schemas : {}
42
+ /** @type {Map<string, string>} */
43
+ const typeNames = new Map()
44
+ const taken = new Set()
45
+ for (const raw of Object.keys(schemas)) {
46
+ let name = isIdent(raw) ? raw : pascal(raw) || 'Schema'
47
+ if (/^[0-9]/.test(name)) name = '_' + name
48
+ let unique = name
49
+ for (let n = 2; taken.has(unique); n++) unique = `${name}${n}`
50
+ if (unique !== name) warn(`schema "${raw}" maps to a type name that is taken, using "${unique}"`)
51
+ taken.add(unique)
52
+ typeNames.set(raw, unique)
53
+ }
54
+ const claim = (/** @type {string} */ base) => {
55
+ let name = base
56
+ if (taken.has(name)) name = base + 'Operation'
57
+ for (let n = 2; taken.has(name); n++) name = `${base}Operation${n}`
58
+ taken.add(name)
59
+ return name
60
+ }
61
+ const refIn = (/** @type {boolean} */ local) => (/** @type {string} */ name) => {
62
+ const t = typeNames.get(name)
63
+ if (t === undefined) {
64
+ warn(`reference to unknown schema "${name}", typed as any`)
65
+ return 'any'
66
+ }
67
+ return local ? t : `import('./types.js').${t}`
68
+ }
69
+ const localCtx = { ref: refIn(true) }
70
+ const importCtx = { ref: refIn(false) }
71
+
72
+ // ---- types.js
73
+ const typeBlocks = []
74
+ for (const raw of [...typeNames.keys()].sort()) {
75
+ const name = /** @type {string} */ (typeNames.get(raw))
76
+ typeBlocks.push(typedef(name, schemas[raw], localCtx))
77
+ }
78
+
79
+ /** @type {Map<import('./operations.js').Operation, { response: string, request: string | null }>} */
80
+ const opTypes = new Map()
81
+ for (const op of operations) {
82
+ const base = pascal(op.name)
83
+ let response
84
+ if (op.undocumented) response = 'any'
85
+ else if (op.response.kind === 'json') response = schemaType(op.response.schema, localCtx)
86
+ else if (op.response.kind === 'text') response = 'string'
87
+ else if (op.response.kind === 'blob') response = 'Blob'
88
+ else response = 'void'
89
+ const responseName = claim(`${base}Response`)
90
+ typeBlocks.push(`/** @typedef {${response}} ${responseName} */`)
91
+
92
+ let requestName = null
93
+ if (op.body && !op.undocumented) {
94
+ if (op.body.kind === 'json') {
95
+ requestName = claim(`${base}Request`)
96
+ typeBlocks.push(typedef(requestName, op.body.schema, localCtx))
97
+ }
98
+ }
99
+ opTypes.set(op, { response: responseName, request: requestName })
100
+ }
101
+ const typesJs = HEADER + '\n' + typeBlocks.join('\n\n') + (typeBlocks.length ? '\n\n' : '') + 'export {};\n'
102
+
103
+ // ---- operations.js
104
+ const opLines = operations.map(
105
+ (op) =>
106
+ ` ${propKey(op.name)}: { method: ${quote(op.method)}, path: ${quote(op.path)}, auth: ${quote(op.auth)}, mutation: ${op.mutation} }`
107
+ )
108
+ const operationsJs =
109
+ HEADER + '\n' + `export const operations = /** @type {const} */ ({\n${opLines.join(',\n')}${opLines.length ? '\n' : ''}});\n`
110
+
111
+ // ---- index.js
112
+ const fnBlocks = operations.map((op) => {
113
+ const t = /** @type {{ response: string, request: string | null }} */ (opTypes.get(op))
114
+ const { type: paramsType, required } = paramsOf(op, t.request, importCtx)
115
+ const lines = ['/**']
116
+ const doc1 = oneLine(op.summary) || oneLine(op.description) || `${op.method} ${op.path}`
117
+ lines.push(` * ${doc1}`)
118
+ if (op.summary && op.description && oneLine(op.description) !== oneLine(op.summary)) lines.push(` * ${oneLine(op.description)}`)
119
+ if (op.deprecated) lines.push(' * @deprecated')
120
+ lines.push(` * @param {import('@panomc/client').PanoClient} client`)
121
+ lines.push(` * @param {${paramsType}} ${required ? 'params' : '[params]'}`)
122
+ lines.push(
123
+ ` * @returns {Promise<import('@panomc/client').ApiResult<import('./types.js').${t.response}>>}`
124
+ )
125
+ lines.push(' */')
126
+ lines.push(`export const ${op.name} = (client, params) => client.request(operations.${op.name}, params);`)
127
+ return lines.join('\n')
128
+ })
129
+ const indexJs = HEADER + "import { operations } from './operations.js';\n\n" + fnBlocks.join('\n\n') + (fnBlocks.length ? '\n' : '')
130
+
131
+ return {
132
+ files: { 'index.js': indexJs, 'types.js': typesJs, 'operations.js': operationsJs, 'openapi.json': canonicalJson(doc) },
133
+ warnings,
134
+ operations
135
+ }
136
+ }
137
+
138
+ /**
139
+ * @param {import('./operations.js').Operation} op
140
+ * @param {string | null} requestName
141
+ * @param {import('./schema.js').TypeContext} ctx
142
+ */
143
+ function paramsOf(op, requestName, ctx) {
144
+ const members = []
145
+ let required = false
146
+ if (op.undocumented) {
147
+ if (/\{[^}]+\}/.test(op.path)) {
148
+ members.push('path: Record<string, string | number | boolean>')
149
+ required = true
150
+ }
151
+ members.push('query?: Record<string, any>')
152
+ if (op.mutation) members.push('body?: any')
153
+ } else {
154
+ const pathParams = op.parameters.filter((p) => p.in === 'path')
155
+ const queryParams = op.parameters.filter((p) => p.in === 'query')
156
+ if (pathParams.length) {
157
+ members.push(`path: { ${pathParams.map((p) => `${propKey(p.name)}: ${schemaType(p.schema, ctx)}`).join(', ')} }`)
158
+ required = true
159
+ }
160
+ if (queryParams.length) {
161
+ const anyRequired = queryParams.some((p) => p.required)
162
+ members.push(
163
+ `query${anyRequired ? '' : '?'}: { ${queryParams.map((p) => `${propKey(p.name)}${p.required ? '' : '?'}: ${schemaType(p.schema, ctx)}`).join(', ')} }`
164
+ )
165
+ if (anyRequired) required = true
166
+ }
167
+ if (op.body) {
168
+ const type =
169
+ op.body.kind === 'form' ? 'FormData' : op.body.kind === 'json' && requestName ? `import('./types.js').${requestName}` : 'any'
170
+ members.push(`body${op.body.required ? '' : '?'}: ${type}`)
171
+ if (op.body.required) required = true
172
+ }
173
+ }
174
+ members.push('signal?: AbortSignal')
175
+ return { type: `{ ${members.join(', ')} }`, required }
176
+ }
177
+
178
+ /**
179
+ * @param {string} name
180
+ * @param {any} schema
181
+ * @param {import('./schema.js').TypeContext} ctx
182
+ */
183
+ function typedef(name, schema, ctx) {
184
+ const props = objectProperties(schema, ctx)
185
+ const description = schema && typeof schema === 'object' ? oneLine(schema.description) : ''
186
+ const deprecated = schema && typeof schema === 'object' && schema.deprecated === true
187
+ if (props) {
188
+ const lines = ['/**']
189
+ if (description) lines.push(` * ${description}`)
190
+ if (deprecated) lines.push(' * @deprecated')
191
+ lines.push(` * @typedef {Object} ${name}`)
192
+ for (const p of props) {
193
+ lines.push(` * @property {${p.type}} ${p.optional ? `[${p.name}]` : p.name}${p.description ? ` ${p.description}` : ''}`)
194
+ }
195
+ lines.push(' */')
196
+ return lines.join('\n')
197
+ }
198
+ const type = schemaType(schema, ctx)
199
+ if (!description && !deprecated) return `/** @typedef {${type}} ${name} */`
200
+ return ['/**', ...(description ? [` * ${description}`] : []), ...(deprecated ? [' * @deprecated'] : []), ` * @typedef {${type}} ${name}`, ' */'].join('\n')
201
+ }