rvlib-shipkit 0.31.0 → 0.32.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/CHANGELOG.md +9 -0
- package/README.md +3 -0
- package/package.json +1 -1
- package/src/cli/cmd_Bump.ts +2 -0
- package/src/cli/cmd_Ci.ts +3 -1
- package/src/cli/cmd_Pb.ts +27 -0
- package/src/cli/cmd_Release.ts +1 -1
- package/src/cli/cmd_Wiki.ts +2 -2
- package/src/cli.ts +49 -39
- package/src/collections/providers.ts +7 -2
- package/src/contracts/contracts-once.ts +43 -0
- package/src/core/canonical-scripts.ts +33 -0
- package/src/core/check-ctx.ts +13 -0
- package/src/core/check-suite.ts +121 -53
- package/src/core/floor.ts +82 -0
- package/src/core/motherhome.ts +14 -1
- package/src/core/run-check.ts +34 -0
- package/src/core/run-ci.ts +63 -2
- package/src/core/run-fixers.ts +6 -1
- package/src/ledger/toolchain-identity.ts +134 -0
- package/src/pb/collection-scaffold.ts +299 -0
- package/src/pb/pb-types.ts +233 -0
- package/src/pb/run-pb.ts +157 -0
- package/src/release/git-facts.ts +3 -2
- package/src/release/release-scope.ts +52 -0
- package/src/release/releases-share.ts +85 -0
- package/src/release/run-bump.ts +31 -8
- package/src/release/run-release.ts +8 -1
- package/src/release/run-releases.ts +20 -6
- package/src/repo-config.ts +22 -65
- package/src/rules/SK001-package-json-shape.ts +65 -2
- package/src/rules/SK002-contracts-fulfillness.ts +2 -2
- package/src/rules/SK004-gitignore-policy.ts +27 -3
- package/src/rules/SK012-config-barrel-import.ts +60 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/untrack-generated.ts +117 -0
- package/src/scaffold/project-scaffold.ts +1 -1
- package/src/serve/hub-port.ts +17 -0
- package/src/serve/run-serve.ts +2 -5
- package/src/wiki/run-wiki.ts +18 -8
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// the file bodies `shipkit pb collection <name>` writes into an app's
|
|
2
|
+
// src/collections/<name>/.
|
|
3
|
+
//
|
|
4
|
+
// THE SHAPE IS NOT THIS FILE'S TO INVENT. It reproduces the house collection
|
|
5
|
+
// folder: `X.` Mantine globals rather than @mantine/core imports, `observer`
|
|
6
|
+
// around anything reading observables, a form that is
|
|
7
|
+
// `ComponentType<{ record?, onClose }>` writing through `db` fire and forget,
|
|
8
|
+
// and an index that CALLS defineCollectionUI as a statement (it returns void).
|
|
9
|
+
// A generator that emits a second dialect is worse than no generator: the code
|
|
10
|
+
// it writes is what the next developer copies.
|
|
11
|
+
//
|
|
12
|
+
// Pure: schema in, {rel, body} out, so the emitter is testable and the fs half
|
|
13
|
+
// stays in run-pb.ts. Anything the schema cannot decide (how a relation reads,
|
|
14
|
+
// what a file url is) is emitted COMMENTED with the reason, never guessed and
|
|
15
|
+
// never dropped: a missing column is invisible, a commented one takes ten
|
|
16
|
+
// seconds to accept or delete.
|
|
17
|
+
import type { PbCollection, PbField } from './pb-types.ts'
|
|
18
|
+
|
|
19
|
+
export type ScaffoldedFile = { rel: string; body: string }
|
|
20
|
+
|
|
21
|
+
/** the app's own modules, relative to src/collections/<name>/ */
|
|
22
|
+
const TYPES = '../../pb.types'
|
|
23
|
+
const PB = '../../pb'
|
|
24
|
+
|
|
25
|
+
/** `client_name` → `Client name`, `emailVisibility` → `Email visibility` */
|
|
26
|
+
export function humanLabel(name: string): string {
|
|
27
|
+
const words = name.replace(/_/g, ' ').replace(/([a-z0-9])([A-Z])/g, '$1 $2').trim().toLowerCase()
|
|
28
|
+
return words.charAt(0).toUpperCase() + words.slice(1)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isIdent = (s: string): boolean => /^[A-Za-z_$][\w$]*$/.test(s)
|
|
32
|
+
const prop = (s: string): string => (isIdent(s) ? `.${s}` : `[${JSON.stringify(s)}]`)
|
|
33
|
+
|
|
34
|
+
/** the label a list row shows, by convention over the schema */
|
|
35
|
+
const LABEL_PREFERENCE = ['name', 'label', 'title', 'number', 'code', 'email']
|
|
36
|
+
|
|
37
|
+
export function labelField(col: PbCollection): PbField {
|
|
38
|
+
for (const want of LABEL_PREFERENCE) {
|
|
39
|
+
const hit = col.fields.find((f) => f.name === want && !f.secret)
|
|
40
|
+
if (hit != null) return hit
|
|
41
|
+
}
|
|
42
|
+
const id = col.fields.find((f) => f.name === 'id')
|
|
43
|
+
if (id == null) throw new Error(`collection '${col.name}' has no id field`)
|
|
44
|
+
return id
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** a value already a string needs no String() around it */
|
|
48
|
+
const readsAsString = (f: PbField): boolean =>
|
|
49
|
+
!f.array && (f.kind === 'string' || f.kind === 'select' || f.kind === 'date' || f.kind === 'autodate')
|
|
50
|
+
|
|
51
|
+
/** columns whose value the schema alone cannot turn into a cell */
|
|
52
|
+
const NOT_RENDERABLE: Partial<Record<PbField['kind'], string>> = {
|
|
53
|
+
relation: 'relation: <RefCell> it, or render the expanded record',
|
|
54
|
+
file: 'file: build a url with pb.files.getURL(r, …)',
|
|
55
|
+
json: 'json: pick the fields you want out of the blob',
|
|
56
|
+
html: 'html markup: render it deliberately, or strip the tags',
|
|
57
|
+
unknown: 'unrecognised generated type',
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** system plumbing that is never a column worth showing */
|
|
61
|
+
const NOISE = new Set(['collectionId', 'collectionName', 'expand'])
|
|
62
|
+
|
|
63
|
+
export type ColumnPlan = { field: PbField; render: string | null; sortable: boolean; why: string }
|
|
64
|
+
|
|
65
|
+
export function planColumns(col: PbCollection): ColumnPlan[] {
|
|
66
|
+
const plans: ColumnPlan[] = []
|
|
67
|
+
for (const f of col.fields) {
|
|
68
|
+
if (f.secret) continue
|
|
69
|
+
const acc = `r${prop(f.name)}`
|
|
70
|
+
const push = (render: string | null, why: string, sortable = false): void => void plans.push({ field: f, render, sortable, why })
|
|
71
|
+
if (NOISE.has(f.name)) { push(null, 'system field, rarely worth a column', true); continue }
|
|
72
|
+
const blocked = NOT_RENDERABLE[f.kind]
|
|
73
|
+
if (blocked != null) { push(null, blocked, !f.array); continue }
|
|
74
|
+
// `key` means the SERVER sorts this column, so a derived cell drops it:
|
|
75
|
+
// a multi-value field sorts lexically on its raw storage, which is not
|
|
76
|
+
// what the joined text on screen says
|
|
77
|
+
if (f.array) { push(`${acc}.join(", ")`, ''); continue }
|
|
78
|
+
// react renders a boolean as nothing at all, so it has to be spelled out
|
|
79
|
+
if (f.kind === 'boolean') { push(`String(${acc})`, '', true); continue }
|
|
80
|
+
push(acc, '', true)
|
|
81
|
+
}
|
|
82
|
+
return plans
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** the writable scalar fields a Mantine form can carry on its own */
|
|
86
|
+
export function planForm(col: PbCollection): PbField[] {
|
|
87
|
+
return col.fields.filter((f) => {
|
|
88
|
+
if (f.readOnly || f.secret) return false
|
|
89
|
+
// a multi-value field is editable when its values are free text or a
|
|
90
|
+
// named union; an array of relations or files still needs a picker
|
|
91
|
+
if (f.array) return f.kind === 'select' || f.kind === 'string'
|
|
92
|
+
return f.kind === 'string' || f.kind === 'number' || f.kind === 'boolean' || f.kind === 'date' || f.kind === 'select' || f.kind === 'html'
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function columnsFile(col: PbCollection): string {
|
|
97
|
+
const lines: string[] = []
|
|
98
|
+
lines.push(`// what a row of \`${col.name}\` looks like in a grid, and in an export. ONE`)
|
|
99
|
+
lines.push('// definition, so the table, the card and the csv can never disagree.')
|
|
100
|
+
lines.push('// `key` is the SERVER sort field: present means the header sorts, absent')
|
|
101
|
+
lines.push('// means the cell is derived and the database cannot order by it.')
|
|
102
|
+
lines.push('// labels are plain text, swap them for i18n.t.<collection>.<field> when the')
|
|
103
|
+
lines.push('// app carries a dict.')
|
|
104
|
+
lines.push(`import type { Column } from "rvlib-pb-shell"`)
|
|
105
|
+
lines.push(`import type { ${col.prefix}Response } from "${TYPES}"`)
|
|
106
|
+
lines.push('')
|
|
107
|
+
lines.push(`export const columns: Column<${col.prefix}Response>[] = [`)
|
|
108
|
+
for (const p of planColumns(col)) {
|
|
109
|
+
const key = p.sortable ? `key: ${JSON.stringify(p.field.name)}, ` : ''
|
|
110
|
+
const align = p.field.kind === 'number' && !p.field.array ? 'align: "right", ' : ''
|
|
111
|
+
const entry = `{ ${key}label: ${JSON.stringify(humanLabel(p.field.name))}, ${align}render: (r) => ${p.render ?? `r${prop(p.field.name)}`} },`
|
|
112
|
+
if (p.render == null) {
|
|
113
|
+
lines.push(` // ${p.why}`)
|
|
114
|
+
lines.push(` // ${entry}`)
|
|
115
|
+
} else {
|
|
116
|
+
lines.push(` ${entry}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
lines.push(']')
|
|
120
|
+
return `${lines.join('\n')}\n`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function cardFile(col: PbCollection, cardFields: PbField[]): string {
|
|
124
|
+
const [first, second, third] = cardFields
|
|
125
|
+
const show = (f: PbField): string =>
|
|
126
|
+
f.array ? `{r${prop(f.name)}.join(", ")}` : readsAsString(f) ? `{r${prop(f.name)}}` : `{String(r${prop(f.name)})}`
|
|
127
|
+
const lines: string[] = []
|
|
128
|
+
lines.push('// the same record as a card, for a LiveCards view or a mobile layout')
|
|
129
|
+
lines.push(`import type { ${col.prefix}Response } from "${TYPES}"`)
|
|
130
|
+
lines.push('')
|
|
131
|
+
lines.push(`export const ${col.prefix}Card = (r: ${col.prefix}Response) => (`)
|
|
132
|
+
lines.push(' <X.Paper withBorder p="md" radius="md">')
|
|
133
|
+
lines.push(' <X.Group justify="space-between" wrap="nowrap">')
|
|
134
|
+
if (first != null) lines.push(` <X.Text fw={600} truncate>${show(first)}</X.Text>`)
|
|
135
|
+
if (second != null) lines.push(` <X.Text size="sm" c="dimmed">${show(second)}</X.Text>`)
|
|
136
|
+
lines.push(' </X.Group>')
|
|
137
|
+
if (third != null) lines.push(` <X.Text size="sm">${show(third)}</X.Text>`)
|
|
138
|
+
lines.push(' </X.Paper>')
|
|
139
|
+
lines.push(')')
|
|
140
|
+
return `${lines.join('\n')}\n`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function gridFile(col: PbCollection): string {
|
|
144
|
+
return [
|
|
145
|
+
`// the default grid for \`${col.name}\`. A page that wants the plain table renders`,
|
|
146
|
+
'// this; a page that wants extra columns passes its own to LiveGrid directly.',
|
|
147
|
+
`import type { LiveList } from "rvlib-pb-mobx"`,
|
|
148
|
+
`import { LiveGrid } from "rvlib-pb-shell"`,
|
|
149
|
+
`import type { ${col.prefix}Response } from "${TYPES}"`,
|
|
150
|
+
`import { columns } from "./columns"`,
|
|
151
|
+
'',
|
|
152
|
+
`export const ${col.prefix}Grid = (p: { list: LiveList<${col.prefix}Response>; selection?: boolean }) => (`,
|
|
153
|
+
' <LiveGrid list={p.list} columns={columns} selection={p.selection} />',
|
|
154
|
+
')',
|
|
155
|
+
'',
|
|
156
|
+
].join('\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function inputFor(f: PbField): string {
|
|
160
|
+
const label = JSON.stringify(humanLabel(f.name))
|
|
161
|
+
const key = JSON.stringify(f.name)
|
|
162
|
+
// a select whose options union typegen did not name has no data source, so
|
|
163
|
+
// it falls through to a free text input rather than to a broken reference
|
|
164
|
+
if (f.kind === 'select' && f.optionsType != null) {
|
|
165
|
+
const control = f.array ? 'MultiSelect' : 'Select'
|
|
166
|
+
return `<X.${control} label=${label} data={Object.values(${f.optionsType})} {...form.getInputProps(${key})} />`
|
|
167
|
+
}
|
|
168
|
+
if (f.array && f.kind === 'string') return `<X.TagsInput label=${label} {...form.getInputProps(${key})} />`
|
|
169
|
+
if (f.kind === 'boolean') return `<X.Checkbox label=${label} {...form.getInputProps(${key}, { type: "checkbox" })} />`
|
|
170
|
+
if (f.kind === 'number') return `<X.NumberInput label=${label} {...form.getInputProps(${key})} />`
|
|
171
|
+
if (f.kind === 'html') return `<X.Textarea label=${label} autosize minRows={3} {...form.getInputProps(${key})} />`
|
|
172
|
+
if (f.kind === 'date') return `<X.TextInput label=${label} placeholder="YYYY-MM-DD" {...form.getInputProps(${key})} />`
|
|
173
|
+
return `<X.TextInput label=${label} {...form.getInputProps(${key})} />`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** what an unset field is, in the app's own terms */
|
|
177
|
+
function emptyValue(f: PbField): string {
|
|
178
|
+
if (f.array) return '[]'
|
|
179
|
+
if (f.kind === 'boolean') return 'false'
|
|
180
|
+
if (f.kind === 'number') return '0'
|
|
181
|
+
// an optional select starts EMPTY, which is pocketbase's own default, so a
|
|
182
|
+
// new record never gets a value nobody picked. The cast is the price of the
|
|
183
|
+
// generated union not admitting the unset state it actually stores
|
|
184
|
+
if (f.kind === 'select' && f.optionsType != null) {
|
|
185
|
+
return f.optional ? `("" as ${f.optionsType})` : `Object.values(${f.optionsType})[0]!`
|
|
186
|
+
}
|
|
187
|
+
return '""'
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function formFile(col: PbCollection, fields: PbField[], left: PbField[]): string {
|
|
191
|
+
const options = [...new Set(fields.filter((f) => f.kind === 'select').map((f) => f.optionsType).filter((o): o is string => o != null))].sort()
|
|
192
|
+
const lines: string[] = []
|
|
193
|
+
lines.push('// create and edit, as a modal. Validators mirror the server rules verbatim:')
|
|
194
|
+
lines.push("// the UI is a courtesy, the collection's API rules are the enforcement.")
|
|
195
|
+
lines.push(`import { useForm } from "@mantine/form"`)
|
|
196
|
+
lines.push(`import { observer } from "rvlib-mobx/src/obs.ts"`)
|
|
197
|
+
lines.push(`import { db } from "${PB}"`)
|
|
198
|
+
if (options.length > 0) lines.push(`import { ${options.join(', ')} } from "${TYPES}"`)
|
|
199
|
+
lines.push(`import type { ${col.prefix}Response } from "${TYPES}"`)
|
|
200
|
+
lines.push('')
|
|
201
|
+
if (left.length > 0) {
|
|
202
|
+
lines.push(`// no input for ${left.map((f) => `${f.name} (${f.kind})`).join(', ')}:`)
|
|
203
|
+
lines.push('// those need a picker or an uploader of their own.')
|
|
204
|
+
}
|
|
205
|
+
lines.push(`type Draft = Pick<`)
|
|
206
|
+
lines.push(` ${col.prefix}Response,`)
|
|
207
|
+
lines.push(fields.map((f) => ` | ${JSON.stringify(f.name)}`).join('\n'))
|
|
208
|
+
lines.push('>')
|
|
209
|
+
lines.push('')
|
|
210
|
+
lines.push(`export const ${col.prefix}Form = observer(function ${col.prefix}Form_(p: { record?: ${col.prefix}Response; onClose: () => void }) {`)
|
|
211
|
+
lines.push(' const form = useForm<Draft>({')
|
|
212
|
+
lines.push(' initialValues: {')
|
|
213
|
+
for (const f of fields) {
|
|
214
|
+
const key = isIdent(f.name) ? f.name : JSON.stringify(f.name)
|
|
215
|
+
lines.push(` ${key}: p.record?${prop(f.name)} ?? ${emptyValue(f)},`)
|
|
216
|
+
}
|
|
217
|
+
lines.push(' },')
|
|
218
|
+
lines.push(' })')
|
|
219
|
+
lines.push('')
|
|
220
|
+
lines.push(' // fire and forget: the data layer notifies and rolls back on rejection')
|
|
221
|
+
lines.push(' const submit = form.onSubmit((values) => {')
|
|
222
|
+
lines.push(` if (p.record) db${prop(col.name)}.update(p.record.id, values)`)
|
|
223
|
+
lines.push(` else db${prop(col.name)}.create(values)`)
|
|
224
|
+
lines.push(' p.onClose()')
|
|
225
|
+
lines.push(' })')
|
|
226
|
+
lines.push('')
|
|
227
|
+
lines.push(' return (')
|
|
228
|
+
lines.push(` <X.Modal opened onClose={p.onClose} title=${JSON.stringify(humanLabel(col.name))}>`)
|
|
229
|
+
lines.push(' <form onSubmit={submit}>')
|
|
230
|
+
lines.push(' <X.Stack>')
|
|
231
|
+
for (const f of fields) lines.push(` ${inputFor(f)}`)
|
|
232
|
+
lines.push(' <X.Group justify="flex-end">')
|
|
233
|
+
lines.push(' <X.Button variant="default" onClick={p.onClose}>Cancel</X.Button>')
|
|
234
|
+
lines.push(' <X.Button type="submit">Save</X.Button>')
|
|
235
|
+
lines.push(' </X.Group>')
|
|
236
|
+
lines.push(' </X.Stack>')
|
|
237
|
+
lines.push(' </form>')
|
|
238
|
+
lines.push(' </X.Modal>')
|
|
239
|
+
lines.push(' )')
|
|
240
|
+
lines.push('})')
|
|
241
|
+
return `${lines.join('\n')}\n`
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function indexFile(col: PbCollection, hasForm: boolean): string {
|
|
245
|
+
const label = labelField(col)
|
|
246
|
+
const lines: string[] = []
|
|
247
|
+
lines.push('// registration: after this import, a LiveGrid or LiveCards over')
|
|
248
|
+
lines.push(`// \`${col.name}\` with no columns of its own knows what to render, and any`)
|
|
249
|
+
lines.push('// relation cell pointing AT one knows how to name it.')
|
|
250
|
+
lines.push(`import { defineCollectionUI } from "rvlib-pb-shell"`)
|
|
251
|
+
lines.push(`import { db } from "${PB}"`)
|
|
252
|
+
lines.push(`import { ${col.prefix}Card } from "./card"`)
|
|
253
|
+
lines.push(`import { columns } from "./columns"`)
|
|
254
|
+
if (hasForm) lines.push(`import { ${col.prefix}Form } from "./form"`)
|
|
255
|
+
lines.push('')
|
|
256
|
+
lines.push(`defineCollectionUI(db${prop(col.name)}, {`)
|
|
257
|
+
lines.push(' columns,')
|
|
258
|
+
lines.push(` card: ${col.prefix}Card,`)
|
|
259
|
+
lines.push(` label: (r) => ${readsAsString(label) ? `r${prop(label.name)}` : `String(r${prop(label.name)})`},`)
|
|
260
|
+
if (hasForm) lines.push(` form: ${col.prefix}Form,`)
|
|
261
|
+
lines.push('})')
|
|
262
|
+
lines.push('')
|
|
263
|
+
lines.push(`export { columns, ${col.prefix}Card${hasForm ? `, ${col.prefix}Form` : ''} }`)
|
|
264
|
+
lines.push(`export { ${col.prefix}Grid } from "./grid"`)
|
|
265
|
+
return `${lines.join('\n')}\n`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** what a card leads with. Generated record blocks are ALPHABETICAL, so the
|
|
269
|
+
* first three fields of a schema are meaningless: rank by how much a human
|
|
270
|
+
* reads a value instead, and keep `id` as the last resort. */
|
|
271
|
+
export function rankForCard(col: PbCollection, plans: ColumnPlan[]): PbField[] {
|
|
272
|
+
const label = labelField(col).name
|
|
273
|
+
const tier = (f: PbField): number => {
|
|
274
|
+
if (f.name === 'id') return 9
|
|
275
|
+
if (f.name === label) return 0
|
|
276
|
+
if (f.kind === 'string' || f.kind === 'select') return 1
|
|
277
|
+
if (f.kind === 'date') return 2
|
|
278
|
+
return 3
|
|
279
|
+
}
|
|
280
|
+
return plans.map((p) => p.field).map((f, i) => ({ f, i, t: tier(f) })).sort((a, b) => a.t - b.t || a.i - b.i).map((x) => x.f)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** every file the collection folder gets, in write order */
|
|
284
|
+
export function scaffoldCollectionUI(col: PbCollection): ScaffoldedFile[] {
|
|
285
|
+
const dir = `src/collections/${col.name}`
|
|
286
|
+
const shown = planColumns(col).filter((p) => p.render != null && !NOISE.has(p.field.name) && p.field.kind !== 'autodate')
|
|
287
|
+
const cardFields = rankForCard(col, shown).slice(0, 3)
|
|
288
|
+
const formFields = planForm(col)
|
|
289
|
+
const left = col.fields.filter((f) => !f.readOnly && !f.secret && !formFields.includes(f))
|
|
290
|
+
const files: ScaffoldedFile[] = [
|
|
291
|
+
{ rel: `${dir}/columns.tsx`, body: columnsFile(col) },
|
|
292
|
+
{ rel: `${dir}/card.tsx`, body: cardFile(col, cardFields) },
|
|
293
|
+
{ rel: `${dir}/grid.tsx`, body: gridFile(col) },
|
|
294
|
+
]
|
|
295
|
+
// a collection with nothing writable gets NO form rather than an empty one
|
|
296
|
+
if (formFields.length > 0) files.push({ rel: `${dir}/form.tsx`, body: formFile(col, formFields, left) })
|
|
297
|
+
files.push({ rel: `${dir}/index.ts`, body: indexFile(col, formFields.length > 0) })
|
|
298
|
+
return files
|
|
299
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// reading a PocketBase schema out of an app's GENERATED types file
|
|
2
|
+
// (pocketbase-typegen output).
|
|
3
|
+
//
|
|
4
|
+
// CONSTRAINT: this is a LINE parse of generated code, not a TypeScript AST.
|
|
5
|
+
// shipkit carries no typescript dependency, and the input is machine written
|
|
6
|
+
// with exactly one field per line, so a line parse is exact for the shape
|
|
7
|
+
// typegen emits and worthless for anything else. Two consequences the callers
|
|
8
|
+
// depend on: a file that yields zero collections is reported as "not typegen
|
|
9
|
+
// output" rather than as an empty schema, and every line inside a record block
|
|
10
|
+
// that does not parse is returned in `unparsed` so it can be shown, never
|
|
11
|
+
// dropped.
|
|
12
|
+
|
|
13
|
+
/** what a generated field type maps to, which decides how it renders and
|
|
14
|
+
* whether a form can edit it at all */
|
|
15
|
+
export type PbFieldKind =
|
|
16
|
+
| 'string'
|
|
17
|
+
| 'number'
|
|
18
|
+
| 'boolean'
|
|
19
|
+
| 'date'
|
|
20
|
+
| 'autodate'
|
|
21
|
+
| 'relation'
|
|
22
|
+
| 'file'
|
|
23
|
+
| 'select'
|
|
24
|
+
| 'json'
|
|
25
|
+
| 'html'
|
|
26
|
+
| 'unknown'
|
|
27
|
+
|
|
28
|
+
export type PbField = {
|
|
29
|
+
name: string
|
|
30
|
+
/** the type expression exactly as generated, `[]` included */
|
|
31
|
+
type: string
|
|
32
|
+
kind: PbFieldKind
|
|
33
|
+
/** the generated field carried a `?` */
|
|
34
|
+
optional: boolean
|
|
35
|
+
array: boolean
|
|
36
|
+
/** select fields: the generated `<Prefix><Field>Options` union to import */
|
|
37
|
+
optionsType?: string
|
|
38
|
+
/** written by the server or the auth layer, never by a form */
|
|
39
|
+
readOnly: boolean
|
|
40
|
+
/** a credential the scaffold must never put on screen */
|
|
41
|
+
secret: boolean
|
|
42
|
+
/** added from the API's response shape, not declared in the record block */
|
|
43
|
+
system: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type PbCollection = {
|
|
47
|
+
/** the name PocketBase knows, `order_lines` */
|
|
48
|
+
name: string
|
|
49
|
+
/** the generated type prefix, `OrderLines` */
|
|
50
|
+
prefix: string
|
|
51
|
+
/** an auth collection, so the response carries email/username/verified */
|
|
52
|
+
auth: boolean
|
|
53
|
+
/** false when the `<Prefix>Record` block is absent or unreadable, which is
|
|
54
|
+
* a PARSE failure and not an empty collection */
|
|
55
|
+
readable: boolean
|
|
56
|
+
fields: PbField[]
|
|
57
|
+
/** record-block lines this parser could not read */
|
|
58
|
+
unparsed: string[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** an auth collection's identity fields do not move through a plain patch:
|
|
62
|
+
* `verified` is superuser-only, and email and username have their own
|
|
63
|
+
* endpoints, so a form control over them submits into the void */
|
|
64
|
+
const AUTH_MANAGED = new Set(['email', 'emailVisibility', 'username', 'verified'])
|
|
65
|
+
|
|
66
|
+
/** pocketbase's own internal collections, never an app surface */
|
|
67
|
+
export const isSystemCollection = (name: string): boolean => name.startsWith('_')
|
|
68
|
+
|
|
69
|
+
/** fields the API adds to every response, absent from the record block */
|
|
70
|
+
const BASE_SYSTEM: [string, PbFieldKind][] = [
|
|
71
|
+
['id', 'string'],
|
|
72
|
+
['collectionId', 'string'],
|
|
73
|
+
['collectionName', 'string'],
|
|
74
|
+
]
|
|
75
|
+
const AUTH_SYSTEM: [string, PbFieldKind][] = [
|
|
76
|
+
['email', 'string'],
|
|
77
|
+
['emailVisibility', 'boolean'],
|
|
78
|
+
['username', 'string'],
|
|
79
|
+
['verified', 'boolean'],
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
const SYSTEM_NAMES = new Set(['id', 'collectionId', 'collectionName', 'expand'])
|
|
83
|
+
const SECRET_NAMES = new Set(['password', 'passwordConfirm', 'oldPassword', 'tokenKey'])
|
|
84
|
+
|
|
85
|
+
/** the body between a `{` and its matching `}`, or null when unbalanced */
|
|
86
|
+
function braceBlock(src: string, openIdx: number): string | null {
|
|
87
|
+
let depth = 0
|
|
88
|
+
for (let i = openIdx; i < src.length; i++) {
|
|
89
|
+
const c = src[i]
|
|
90
|
+
if (c === '{') depth++
|
|
91
|
+
else if (c === '}') {
|
|
92
|
+
depth--
|
|
93
|
+
if (depth === 0) return src.slice(openIdx + 1, i)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return null
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** the body of `export type <name><generics?> = { … }`. The open brace is
|
|
100
|
+
* found by scanning forward rather than by regex, because typegen
|
|
101
|
+
* parameterises a record carrying a json field (`<Tdata = unknown>`) and the
|
|
102
|
+
* default value swallows any `=\\s*{` pattern */
|
|
103
|
+
function typeBlock(src: string, name: string): string | null {
|
|
104
|
+
const head = new RegExp(`export\\s+(?:type|interface)\\s+${name}\\b`).exec(src)
|
|
105
|
+
if (head == null) return null
|
|
106
|
+
const from = head.index + head[0].length
|
|
107
|
+
const open = src.indexOf('{', from)
|
|
108
|
+
if (open === -1) return null
|
|
109
|
+
// a declaration with no object body at all (an alias) must not borrow the
|
|
110
|
+
// braces of whatever is declared next
|
|
111
|
+
const nextDecl = src.indexOf('\nexport ', from)
|
|
112
|
+
if (nextDecl !== -1 && nextDecl < open) return null
|
|
113
|
+
return braceBlock(src, open)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function kindOf(typeExpr: string): { kind: PbFieldKind; array: boolean; optionsType?: string } {
|
|
117
|
+
const stripped = typeExpr.replace(/\s+/g, ' ').trim()
|
|
118
|
+
// typegen writes a json field as `null | Tdata`, the generic the record
|
|
119
|
+
// type is parameterised by
|
|
120
|
+
if (/\bnull\s*\|/.test(stripped)) return { kind: 'json', array: /\[\]/.test(stripped) }
|
|
121
|
+
const array = /\[\]$/.test(stripped)
|
|
122
|
+
const base = stripped.replace(/\[\]$/, '').trim()
|
|
123
|
+
if (base === 'IsoAutoDateString') return { kind: 'autodate', array }
|
|
124
|
+
if (base === 'IsoDateString') return { kind: 'date', array }
|
|
125
|
+
if (base === 'RecordIdString') return { kind: 'relation', array }
|
|
126
|
+
if (base === 'FileNameString') return { kind: 'file', array }
|
|
127
|
+
if (base === 'HTMLString') return { kind: 'html', array }
|
|
128
|
+
if (base === 'GeoPoint') return { kind: 'json', array }
|
|
129
|
+
if (base === 'number') return { kind: 'number', array }
|
|
130
|
+
if (base === 'boolean') return { kind: 'boolean', array }
|
|
131
|
+
if (base === 'string') return { kind: 'string', array }
|
|
132
|
+
if (/^[A-Za-z_$][\w$]*Options$/.test(base)) return { kind: 'select', array, optionsType: base }
|
|
133
|
+
return { kind: 'unknown', array }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** one `name?: Type` line of a record block */
|
|
137
|
+
function parseFieldLine(line: string): PbField | null {
|
|
138
|
+
const m = /^\s*(?:"([^"]+)"|([A-Za-z_$][\w$]*))(\?)?\s*:\s*(.+?)\s*[,;]?\s*$/.exec(line)
|
|
139
|
+
if (m == null) return null
|
|
140
|
+
const name = m[1] ?? m[2]
|
|
141
|
+
if (name == null) return null
|
|
142
|
+
const type = m[4]
|
|
143
|
+
if (type == null) return null
|
|
144
|
+
const { kind, array, optionsType } = kindOf(type)
|
|
145
|
+
return {
|
|
146
|
+
name,
|
|
147
|
+
type,
|
|
148
|
+
kind,
|
|
149
|
+
optional: m[3] === '?',
|
|
150
|
+
array,
|
|
151
|
+
...(optionsType == null ? {} : { optionsType }),
|
|
152
|
+
readOnly: kind === 'autodate' || SYSTEM_NAMES.has(name) || SECRET_NAMES.has(name),
|
|
153
|
+
secret: SECRET_NAMES.has(name),
|
|
154
|
+
system: false,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** collection name → generated type prefix, from `CollectionRecords` when the
|
|
159
|
+
* file has one and from the `Collections` const or enum otherwise */
|
|
160
|
+
function collectionPrefixes(src: string): Map<string, string> {
|
|
161
|
+
const out = new Map<string, string>()
|
|
162
|
+
const recordsHead = /export\s+type\s+CollectionRecords\s*=\s*\{/.exec(src)
|
|
163
|
+
if (recordsHead != null) {
|
|
164
|
+
const body = braceBlock(src, recordsHead.index + recordsHead[0].length - 1) ?? ''
|
|
165
|
+
for (const line of body.split('\n')) {
|
|
166
|
+
const m = /^\s*(?:"([^"]+)"|([A-Za-z_$][\w$]*))\s*:\s*([A-Za-z_$][\w$]*)Record\b/.exec(line)
|
|
167
|
+
const name = m?.[1] ?? m?.[2]
|
|
168
|
+
const prefix = m?.[3]
|
|
169
|
+
if (name != null && prefix != null) out.set(name, prefix)
|
|
170
|
+
}
|
|
171
|
+
if (out.size > 0) return out
|
|
172
|
+
}
|
|
173
|
+
const constHead = /export\s+(?:const|enum)\s+Collections\s*=?\s*\{/.exec(src)
|
|
174
|
+
if (constHead != null) {
|
|
175
|
+
const body = braceBlock(src, constHead.index + constHead[0].length - 1) ?? ''
|
|
176
|
+
for (const line of body.split('\n')) {
|
|
177
|
+
const m = /^\s*(?:"([^"]+)"|([A-Za-z_$][\w$]*))\s*[:=]\s*"([^"]+)"/.exec(line)
|
|
178
|
+
const prefix = m?.[1] ?? m?.[2]
|
|
179
|
+
const name = m?.[3]
|
|
180
|
+
if (name != null && prefix != null) out.set(name, prefix)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** every collection the generated file describes, in declaration order */
|
|
187
|
+
export function parsePbTypes(src: string): PbCollection[] {
|
|
188
|
+
const out: PbCollection[] = []
|
|
189
|
+
for (const [name, prefix] of collectionPrefixes(src)) {
|
|
190
|
+
const block = typeBlock(src, `${prefix}Record`)
|
|
191
|
+
const auth0 = new RegExp(`export\\s+type\\s+${prefix}Response\\b[^\\n]*AuthSystemFields`).test(src)
|
|
192
|
+
if (block == null) {
|
|
193
|
+
out.push({ name, prefix, auth: auth0, readable: false, fields: [], unparsed: [] })
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
const fields: PbField[] = []
|
|
197
|
+
const unparsed: string[] = []
|
|
198
|
+
const seen = new Set<string>()
|
|
199
|
+
for (const raw of block.split('\n')) {
|
|
200
|
+
const line = raw.trim()
|
|
201
|
+
if (line === '' || line.startsWith('//') || line.startsWith('*') || line.startsWith('/*')) continue
|
|
202
|
+
const field = parseFieldLine(raw)
|
|
203
|
+
if (field == null) {
|
|
204
|
+
unparsed.push(line)
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
if (seen.has(field.name)) continue
|
|
208
|
+
seen.add(field.name)
|
|
209
|
+
fields.push(field)
|
|
210
|
+
}
|
|
211
|
+
const auth = auth0
|
|
212
|
+
// the record block holds the collection's OWN fields, the response adds
|
|
213
|
+
// the API's, so a scaffold built off the record alone would miss half
|
|
214
|
+
// the columns the list actually has
|
|
215
|
+
for (const [sysName, sysKind] of [...BASE_SYSTEM, ...(auth ? AUTH_SYSTEM : [])]) {
|
|
216
|
+
if (seen.has(sysName)) continue
|
|
217
|
+
seen.add(sysName)
|
|
218
|
+
fields.push({
|
|
219
|
+
name: sysName,
|
|
220
|
+
type: sysKind === 'boolean' ? 'boolean' : 'string',
|
|
221
|
+
kind: sysKind,
|
|
222
|
+
optional: false,
|
|
223
|
+
array: false,
|
|
224
|
+
readOnly: SYSTEM_NAMES.has(sysName) || SECRET_NAMES.has(sysName) || (auth && AUTH_MANAGED.has(sysName)),
|
|
225
|
+
secret: SECRET_NAMES.has(sysName),
|
|
226
|
+
system: true,
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
if (auth) for (const f of fields) if (AUTH_MANAGED.has(f.name)) f.readOnly = true
|
|
230
|
+
out.push({ name, prefix, auth, readable: true, fields, unparsed })
|
|
231
|
+
}
|
|
232
|
+
return out
|
|
233
|
+
}
|