@nuasite/collections-admin 0.43.0-beta.3 → 0.43.0-beta.8

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/src/client.ts DELETED
@@ -1,405 +0,0 @@
1
- /**
2
- * Typed client over the cms-sidecar `/cms/v1` HTTP contract (reads + mutations).
3
- *
4
- * The host (webmaster BFF, or a local dev proxy in F7) mounts the sidecar under
5
- * an `apiBase` and adds the `/cms/v1` prefix itself — so this client requests
6
- * `${apiBase}/project`, `${apiBase}/collections`, etc. (never `/cms/v1/...`).
7
- *
8
- * The structural model (collections/entries/fields) is reused 1:1 from
9
- * `@nuasite/cms-types`. The thin HTTP envelope (project model, sparse entries
10
- * list, error codes, mutation bodies, conflict response) mirrors the sidecar's
11
- * wire types; it is declared here because those types are not part of the
12
- * `@nuasite/cms-types` contract surface.
13
- */
14
-
15
- import type {
16
- CollectionDefinition,
17
- CollectionEntry,
18
- CollectionEntryInfo,
19
- MediaListResult,
20
- MediaUploadResult,
21
- MutationResult,
22
- } from '@nuasite/cms-types'
23
-
24
- /** HTTP status the sidecar uses for an optimistic-concurrency conflict. */
25
- const STATUS_CONFLICT = 409
26
-
27
- // ============================================================================
28
- // Wire envelope (mirrors @nuasite/cms-sidecar's `/cms/v1` contract)
29
- // ============================================================================
30
-
31
- /** Stable error codes the sidecar exposes, each mapped to an HTTP status. */
32
- export type CmsErrorCode =
33
- | 'not_found'
34
- | 'conflict'
35
- | 'validation'
36
- | 'parse_error'
37
- | 'io_error'
38
- | 'unsupported'
39
- | 'unauthorized'
40
-
41
- /** JSON body returned for every non-2xx response that is not a conflict. */
42
- export interface CmsApiError {
43
- error: string
44
- code: CmsErrorCode
45
- sourcePath?: string
46
- }
47
-
48
- /** A static page route discovered under `src/pages` (pathname-only). */
49
- export interface CmsPageEntry {
50
- pathname: string
51
- title?: string
52
- }
53
-
54
- /** Features the sidecar advertises so the UI can degrade gracefully. */
55
- export interface CmsCapabilities {
56
- coreVersion: string
57
- features: string[]
58
- }
59
-
60
- /** `GET /project` — the whole structural model in one call. */
61
- export interface CmsProjectModel {
62
- collections: CollectionDefinition[]
63
- pages: CmsPageEntry[]
64
- capabilities: CmsCapabilities
65
- }
66
-
67
- /** `GET …/entries` — projected entries plus an opaque continuation cursor. */
68
- export interface CmsEntriesListResult {
69
- entries: CollectionEntryInfo[]
70
- cursor?: string
71
- hasMore: boolean
72
- }
73
-
74
- /**
75
- * `409` body for a `PATCH` whose `baseHash` no longer matches disk (an agent or a
76
- * human wrote in between). Carries the current server version so the UI can offer
77
- * "use server" vs "use ours". Mirrors the sidecar `ConflictResponse`.
78
- */
79
- export interface CmsConflict {
80
- code: 'conflict'
81
- serverHash: string
82
- /** Raw (non-stringified) server frontmatter — unlike the line-keyed GET-detail shape. */
83
- serverFrontmatter: Record<string, unknown>
84
- serverBody?: string
85
- }
86
-
87
- /** `PATCH …/entries/:slug` — frontmatter keys are merged (not replaced). */
88
- export interface UpdateEntryInput {
89
- frontmatter?: Record<string, unknown>
90
- body?: string
91
- /** Hash of the source the client edited; drives optimistic concurrency. */
92
- baseHash?: string
93
- }
94
-
95
- export interface CreateEntryInput {
96
- slug: string
97
- frontmatter: Record<string, unknown>
98
- body?: string
99
- /** File extension override for data collections (e.g. 'json', 'yaml'). */
100
- fileExtension?: string
101
- }
102
-
103
- /** Context passed to media operations so uploads can be filed against an entry/field. */
104
- export interface MediaContext {
105
- collection?: string
106
- entry?: string
107
- field?: string
108
- /** Subfolder under the media root. */
109
- folder?: string
110
- }
111
-
112
- /**
113
- * Either a successful `MutationResult` or a `409` conflict the caller must
114
- * resolve. Returned (not thrown) by `updateEntry` so the editor can branch
115
- * without exception flow.
116
- */
117
- export type UpdateEntryResult =
118
- | { status: 'ok'; result: MutationResult }
119
- | { status: 'conflict'; conflict: CmsConflict }
120
-
121
- // ============================================================================
122
- // Client error
123
- // ============================================================================
124
-
125
- /**
126
- * Thrown for any non-2xx response. Carries the parsed sidecar error code so the
127
- * UI can distinguish auth failures (`unauthorized`/`forbidden`) from a missing
128
- * collection/entry (`not_found`) or a generic failure.
129
- */
130
- export class CmsClientError extends Error {
131
- constructor(
132
- readonly status: number,
133
- readonly code: CmsErrorCode | 'forbidden' | 'unknown',
134
- message: string,
135
- ) {
136
- super(message)
137
- this.name = 'CmsClientError'
138
- }
139
-
140
- /** Session cookie missing/expired upstream — the user must re-authenticate. */
141
- get isUnauthorized(): boolean {
142
- return this.code === 'unauthorized' || this.status === 401
143
- }
144
-
145
- /** Authenticated but lacks access to this project. */
146
- get isForbidden(): boolean {
147
- return this.code === 'forbidden' || this.status === 403
148
- }
149
-
150
- get isNotFound(): boolean {
151
- return this.code === 'not_found' || this.status === 404
152
- }
153
- }
154
-
155
- // ============================================================================
156
- // Query options
157
- // ============================================================================
158
-
159
- export interface GetEntriesOptions {
160
- /** "slug,title" | "*" ; absent = light header (slug/title/draft/pathname). */
161
- fields?: string
162
- /** Draft filter — defaults to `'false'` (published only) on the sidecar. */
163
- draft?: 'true' | 'false' | 'all'
164
- /** Opaque continuation cursor from a previous page's `cursor`. */
165
- cursor?: string
166
- limit?: number
167
- }
168
-
169
- // ============================================================================
170
- // Client
171
- // ============================================================================
172
-
173
- function isApiError(value: unknown): value is CmsApiError {
174
- return isRecord(value)
175
- && typeof value.error === 'string'
176
- && typeof value.code === 'string'
177
- }
178
-
179
- /** Narrow `unknown` to a record so property reads typecheck without casts. */
180
- function isRecord(value: unknown): value is Record<string, unknown> {
181
- return typeof value === 'object' && value !== null
182
- }
183
-
184
- function isConflict(value: unknown): value is CmsConflict {
185
- if (!isRecord(value)) return false
186
- return value.code === 'conflict'
187
- && typeof value.serverHash === 'string'
188
- && isRecord(value.serverFrontmatter)
189
- }
190
-
191
- const KNOWN_ERROR_CODES: readonly CmsErrorCode[] = [
192
- 'not_found',
193
- 'conflict',
194
- 'validation',
195
- 'parse_error',
196
- 'io_error',
197
- 'unsupported',
198
- 'unauthorized',
199
- ]
200
-
201
- function isErrorCode(value: string): value is CmsErrorCode {
202
- return (KNOWN_ERROR_CODES as readonly string[]).includes(value)
203
- }
204
-
205
- export interface CmsClient {
206
- getProject(): Promise<CmsProjectModel>
207
- getCollections(): Promise<CollectionDefinition[]>
208
- getEntries(collection: string, options?: GetEntriesOptions): Promise<CmsEntriesListResult>
209
- getEntry(collection: string, slug: string): Promise<CollectionEntry>
210
-
211
- // --- Mutations ---
212
-
213
- /**
214
- * Merge-patch an entry's frontmatter/body. Returns a discriminated result: a
215
- * `409` is surfaced as `{ status: 'conflict' }` (not thrown) so the editor can
216
- * open the conflict dialog. The new `baseHash` is on `result.sourceHash`.
217
- */
218
- updateEntry(collection: string, slug: string, input: UpdateEntryInput): Promise<UpdateEntryResult>
219
- createEntry(collection: string, input: CreateEntryInput): Promise<MutationResult>
220
- deleteEntry(collection: string, slug: string): Promise<MutationResult>
221
- renameEntry(collection: string, slug: string, to: string): Promise<MutationResult>
222
- addArrayItem(collection: string, slug: string, field: string, value: unknown, index?: number): Promise<MutationResult>
223
- removeArrayItem(collection: string, slug: string, field: string, index: number): Promise<MutationResult>
224
-
225
- // --- Media (degrades gracefully when the sidecar has no adapter wired: 501). ---
226
-
227
- listMedia(options?: { folder?: string; cursor?: string; limit?: number }): Promise<MediaListResult>
228
- uploadMedia(file: File, context?: MediaContext): Promise<MediaUploadResult>
229
- deleteMedia(id: string): Promise<{ success: boolean; error?: string }>
230
- }
231
-
232
- /**
233
- * Whether a thrown `CmsClientError` means "media is not available" — the deployed
234
- * sidecar may have no media adapter wired (`501 unsupported`). The picker uses
235
- * this to degrade gracefully instead of surfacing a hard error.
236
- */
237
- export function isMediaUnavailable(error: unknown): boolean {
238
- return error instanceof CmsClientError && (error.status === 501 || error.code === 'unsupported')
239
- }
240
-
241
- export function createClient(apiBase: string): CmsClient {
242
- // Normalise: drop a trailing slash so `${base}${path}` joins cleanly.
243
- const base = apiBase.endsWith('/') ? apiBase.slice(0, -1) : apiBase
244
-
245
- async function request<T>(path: string): Promise<T> {
246
- const response = await fetch(`${base}${path}`, {
247
- method: 'GET',
248
- credentials: 'include',
249
- headers: { accept: 'application/json' },
250
- })
251
-
252
- if (!response.ok) {
253
- throw await toError(response)
254
- }
255
-
256
- // Successful responses are always JSON in the read-only surface.
257
- const value: T = await response.json()
258
- return value
259
- }
260
-
261
- async function toError(response: Response): Promise<CmsClientError> {
262
- // 403 is produced by the BFF (project scope), not the sidecar, so it has no
263
- // sidecar `code`; `errorFromBody` surfaces it as a distinct `forbidden`.
264
- const body: unknown = await response.json().catch(() => null)
265
- return errorFromBody(response.status, body)
266
- }
267
-
268
- function errorMessageFromBody(body: unknown, fallback: string): string {
269
- if (isApiError(body)) return body.error
270
- if (isRecord(body)) {
271
- const err = body.error
272
- if (isRecord(err) && typeof err.message === 'string') return err.message
273
- }
274
- return fallback
275
- }
276
-
277
- /** Build a `CmsClientError` from an already-parsed body (no re-read of the stream). */
278
- function errorFromBody(status: number, body: unknown): CmsClientError {
279
- if (status === 403) {
280
- return new CmsClientError(403, 'forbidden', errorMessageFromBody(body, 'You do not have access to this project.'))
281
- }
282
- if (isApiError(body) && isErrorCode(body.code)) {
283
- return new CmsClientError(status, body.code, body.error)
284
- }
285
- if (status === 401) {
286
- return new CmsClientError(401, 'unauthorized', 'Your session has expired. Please reload.')
287
- }
288
- return new CmsClientError(status, 'unknown', errorMessageFromBody(body, `Request failed (${status})`))
289
- }
290
-
291
- function mutationInit(method: string, body?: unknown): RequestInit {
292
- const init: RequestInit = {
293
- method,
294
- credentials: 'include',
295
- headers: { accept: 'application/json' },
296
- }
297
- if (body !== undefined) {
298
- init.body = JSON.stringify(body)
299
- init.headers = { accept: 'application/json', 'content-type': 'application/json' }
300
- }
301
- return init
302
- }
303
-
304
- /**
305
- * Send a JSON-body mutation (POST/PATCH/DELETE). Throws `CmsClientError` on any
306
- * non-2xx — used by the mutations that have no conflict branch. The
307
- * conflict-aware update has its own path below.
308
- */
309
- async function mutate<T>(path: string, method: string, body?: unknown): Promise<T> {
310
- const response = await fetch(`${base}${path}`, mutationInit(method, body))
311
- if (!response.ok) throw await toError(response)
312
- // Mutation responses are documented JSON; the asserted shape is trusted
313
- // (`response.json()` widens to the declared `T`, mirroring `request`).
314
- const value: T = await response.json()
315
- return value
316
- }
317
-
318
- function entryPath(collection: string, slug: string): string {
319
- return `/collections/${encodeURIComponent(collection)}/entries/${encodeURIComponent(slug)}`
320
- }
321
-
322
- return {
323
- getProject() {
324
- return request<CmsProjectModel>('/project')
325
- },
326
- getCollections() {
327
- return request<CollectionDefinition[]>('/collections')
328
- },
329
- getEntries(collection, options = {}) {
330
- const params = new URLSearchParams()
331
- if (options.fields !== undefined) params.set('fields', options.fields)
332
- if (options.draft !== undefined) params.set('draft', options.draft)
333
- if (options.cursor !== undefined) params.set('cursor', options.cursor)
334
- if (options.limit !== undefined) params.set('limit', String(options.limit))
335
- const query = params.toString()
336
- const suffix = query === '' ? '' : `?${query}`
337
- return request<CmsEntriesListResult>(`/collections/${encodeURIComponent(collection)}/entries${suffix}`)
338
- },
339
- getEntry(collection, slug) {
340
- return request<CollectionEntry>(entryPath(collection, slug))
341
- },
342
-
343
- async updateEntry(collection, slug, input) {
344
- const response = await fetch(`${base}${entryPath(collection, slug)}`, mutationInit('PATCH', input))
345
- // A `409` carries the server version; parse and return it for the dialog.
346
- if (response.status === STATUS_CONFLICT) {
347
- const body: unknown = await response.json().catch(() => null)
348
- if (isConflict(body)) return { status: 'conflict', conflict: body }
349
- throw errorFromBody(response.status, body)
350
- }
351
- if (!response.ok) throw await toError(response)
352
- const result: MutationResult = await response.json()
353
- return { status: 'ok', result }
354
- },
355
- createEntry(collection, input) {
356
- return mutate<MutationResult>(`/collections/${encodeURIComponent(collection)}/entries`, 'POST', input)
357
- },
358
- deleteEntry(collection, slug) {
359
- return mutate<MutationResult>(entryPath(collection, slug), 'DELETE')
360
- },
361
- renameEntry(collection, slug, to) {
362
- return mutate<MutationResult>(`${entryPath(collection, slug)}/rename`, 'POST', { to })
363
- },
364
- addArrayItem(collection, slug, field, value, index) {
365
- const body = index === undefined ? { field, value } : { field, value, index }
366
- return mutate<MutationResult>(`${entryPath(collection, slug)}/array`, 'POST', body)
367
- },
368
- removeArrayItem(collection, slug, field, index) {
369
- return mutate<MutationResult>(`${entryPath(collection, slug)}/array`, 'DELETE', { field, index })
370
- },
371
-
372
- listMedia(options = {}) {
373
- const params = new URLSearchParams()
374
- if (options.folder !== undefined) params.set('folder', options.folder)
375
- if (options.cursor !== undefined) params.set('cursor', options.cursor)
376
- if (options.limit !== undefined) params.set('limit', String(options.limit))
377
- const query = params.toString()
378
- return request<MediaListResult>(`/media${query === '' ? '' : `?${query}`}`)
379
- },
380
- async uploadMedia(file, context = {}) {
381
- // The sidecar reads upload context (collection/entry/field/folder) from the
382
- // query string; the file rides in multipart form data under `file`.
383
- const params = new URLSearchParams()
384
- if (context.collection !== undefined) params.set('collection', context.collection)
385
- if (context.entry !== undefined) params.set('entry', context.entry)
386
- if (context.field !== undefined) params.set('field', context.field)
387
- if (context.folder !== undefined) params.set('folder', context.folder)
388
- const query = params.toString()
389
- const form = new FormData()
390
- form.append('file', file)
391
- const response = await fetch(`${base}/media${query === '' ? '' : `?${query}`}`, {
392
- method: 'POST',
393
- credentials: 'include',
394
- headers: { accept: 'application/json' },
395
- body: form,
396
- })
397
- if (!response.ok) throw await toError(response)
398
- const result: MediaUploadResult = await response.json()
399
- return result
400
- },
401
- deleteMedia(id) {
402
- return mutate<{ success: boolean; error?: string }>(`/media/${encodeURIComponent(id)}`, 'DELETE')
403
- },
404
- }
405
- }
package/src/form-model.ts DELETED
@@ -1,182 +0,0 @@
1
- /**
2
- * Pure draft model + field coercion for the entry editor (cms-headless F3.2).
3
- *
4
- * The sidecar speaks two slightly different frontmatter shapes:
5
- * - `GET …/entries/:slug` returns `frontmatter: Record<string, { value: string; line: number }>`,
6
- * where `value` is already stringified (objects/arrays are JSON).
7
- * - `PATCH …` accepts `frontmatter?: Record<string, unknown>` of *native* values (merged), and a
8
- * `409` `serverFrontmatter` is likewise native (not stringified).
9
- *
10
- * The editor works on a single native draft (`EntryDraft`): `frontmatter` is a
11
- * `Record<string, unknown>` of native JS values keyed by field name, plus the
12
- * markdown `body`. This module converts to/from the wire and coerces raw input
13
- * (form strings) into the native value a `FieldType` expects. Keeping it pure
14
- * (no React/DOM) makes the mapping unit-testable.
15
- */
16
-
17
- import type { CollectionEntry, FieldDefinition, FieldType } from '@nuasite/cms-types'
18
-
19
- /** The editor's in-memory state: native frontmatter values + the markdown body. */
20
- export interface EntryDraft {
21
- frontmatter: Record<string, unknown>
22
- body: string
23
- }
24
-
25
- function isRecord(value: unknown): value is Record<string, unknown> {
26
- return typeof value === 'object' && value !== null && !Array.isArray(value)
27
- }
28
-
29
- /**
30
- * Parse one stringified frontmatter `value` (from `GET …/entries/:slug`) into the
31
- * native value a field of `type` expects. Structural types (object/array) and
32
- * unknowns fall back to a best-effort `JSON.parse`; scalars are coerced per type.
33
- */
34
- export function parseWireValue(type: FieldType, raw: string): unknown {
35
- switch (type) {
36
- case 'boolean':
37
- return raw === 'true' || raw === '1' || raw.toLowerCase() === 'yes'
38
- case 'number':
39
- case 'year':
40
- case 'month': {
41
- const n = Number(raw)
42
- return raw.trim() === '' || Number.isNaN(n) ? raw : n
43
- }
44
- case 'array':
45
- case 'object':
46
- return parseJsonLoose(raw)
47
- default:
48
- return raw
49
- }
50
- }
51
-
52
- /** `JSON.parse` for structural values, falling back to the raw string when invalid. */
53
- function parseJsonLoose(raw: string): unknown {
54
- const trimmed = raw.trim()
55
- if (trimmed === '') return undefined
56
- if (!(trimmed.startsWith('{') || trimmed.startsWith('[') || trimmed.startsWith('"'))) return raw
57
- try {
58
- return JSON.parse(trimmed)
59
- } catch {
60
- return raw
61
- }
62
- }
63
-
64
- /**
65
- * Build a native draft from a loaded entry, driven by the collection's fields.
66
- * Frontmatter keys present on the entry but absent from the inferred schema are
67
- * preserved verbatim (as raw strings) so a save never silently drops them.
68
- */
69
- export function draftFromEntry(entry: CollectionEntry, fields: FieldDefinition[]): EntryDraft {
70
- const byName = new Map(fields.map(f => [f.name, f] as const))
71
- const frontmatter: Record<string, unknown> = {}
72
- for (const [key, cell] of Object.entries(entry.frontmatter)) {
73
- const field = byName.get(key)
74
- frontmatter[key] = field ? parseWireValue(field.type, cell.value) : cell.value
75
- }
76
- return { frontmatter, body: entry.body }
77
- }
78
-
79
- /**
80
- * Build a fresh draft for a create form from the collection's fields, seeding
81
- * each field with its `defaultValue` (when present) or a type-appropriate blank.
82
- */
83
- export function draftForCreate(fields: FieldDefinition[]): EntryDraft {
84
- const frontmatter: Record<string, unknown> = {}
85
- for (const field of fields) {
86
- if (field.hidden) continue
87
- if (field.defaultValue !== undefined) {
88
- frontmatter[field.name] = field.defaultValue
89
- continue
90
- }
91
- frontmatter[field.name] = blankValue(field.type)
92
- }
93
- return { frontmatter, body: '' }
94
- }
95
-
96
- /** A type-appropriate empty value used to seed create forms. */
97
- export function blankValue(type: FieldType): unknown {
98
- switch (type) {
99
- case 'boolean':
100
- return false
101
- case 'array':
102
- return []
103
- case 'object':
104
- return {}
105
- default:
106
- return ''
107
- }
108
- }
109
-
110
- /**
111
- * Adopt a server-provided native frontmatter map (from a `409` `serverFrontmatter`)
112
- * into a draft, re-coercing per field where a definition exists.
113
- */
114
- export function draftFromServerFrontmatter(
115
- serverFrontmatter: Record<string, unknown>,
116
- serverBody: string | undefined,
117
- fields: FieldDefinition[],
118
- ): EntryDraft {
119
- const byName = new Map(fields.map(f => [f.name, f] as const))
120
- const frontmatter: Record<string, unknown> = {}
121
- for (const [key, value] of Object.entries(serverFrontmatter)) {
122
- const field = byName.get(key)
123
- // Server values are already native; only re-coerce when the value arrived as
124
- // a string for a numeric/boolean field (e.g. YAML quirks).
125
- frontmatter[key] = field && typeof value === 'string' ? parseWireValue(field.type, value) : value
126
- }
127
- return { frontmatter, body: serverBody ?? '' }
128
- }
129
-
130
- /**
131
- * Coerce a raw form-control string into the native value a field expects. Used by
132
- * the widgets, whose `<input>` values are always strings.
133
- */
134
- export function coerceInput(type: FieldType, raw: string): unknown {
135
- switch (type) {
136
- case 'boolean':
137
- return raw === 'true'
138
- case 'number': {
139
- if (raw.trim() === '') return undefined
140
- const n = Number(raw)
141
- return Number.isNaN(n) ? raw : n
142
- }
143
- case 'year':
144
- case 'month': {
145
- if (raw.trim() === '') return undefined
146
- const n = Number(raw)
147
- return Number.isNaN(n) ? raw : n
148
- }
149
- default:
150
- return raw
151
- }
152
- }
153
-
154
- /** Render a native value back to a string for a text/number/date/select control. */
155
- export function valueToInput(value: unknown): string {
156
- if (value === undefined || value === null) return ''
157
- if (typeof value === 'string') return value
158
- if (typeof value === 'number' || typeof value === 'boolean') return String(value)
159
- return JSON.stringify(value)
160
- }
161
-
162
- /** Read a value as a boolean for toggle widgets, tolerating string encodings. */
163
- export function valueToBoolean(value: unknown): boolean {
164
- if (typeof value === 'boolean') return value
165
- if (typeof value === 'string') return value === 'true' || value === '1' || value.toLowerCase() === 'yes'
166
- return Boolean(value)
167
- }
168
-
169
- /** Read a value as an array of items for repeater widgets. */
170
- export function valueToArray(value: unknown): unknown[] {
171
- return Array.isArray(value) ? value : []
172
- }
173
-
174
- /** Read a value as an object for nested-group widgets. */
175
- export function valueToObject(value: unknown): Record<string, unknown> {
176
- return isRecord(value) ? value : {}
177
- }
178
-
179
- /** Immutably set a top-level frontmatter key in a draft. */
180
- export function setDraftField(draft: EntryDraft, name: string, value: unknown): EntryDraft {
181
- return { ...draft, frontmatter: { ...draft.frontmatter, [name]: value } }
182
- }