@nuasite/cms-sidecar 0.43.0-beta.1
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/dist/cli.js +25032 -0
- package/dist/types/cli.d.ts +3 -0
- package/dist/types/cli.d.ts.map +1 -0
- package/dist/types/cli.js +77 -0
- package/dist/types/concurrency.d.ts +26 -0
- package/dist/types/concurrency.d.ts.map +1 -0
- package/dist/types/concurrency.js +47 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +4 -0
- package/dist/types/media-from-env.d.ts +32 -0
- package/dist/types/media-from-env.d.ts.map +1 -0
- package/dist/types/media-from-env.js +57 -0
- package/dist/types/server.d.ts +22 -0
- package/dist/types/server.d.ts.map +1 -0
- package/dist/types/server.js +598 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/dist/types/types.d.ts +99 -0
- package/dist/types/types.d.ts.map +1 -0
- package/dist/types/types.js +10 -0
- package/package.json +57 -0
- package/src/cli.ts +105 -0
- package/src/concurrency.ts +51 -0
- package/src/index.ts +24 -0
- package/src/media-from-env.ts +96 -0
- package/src/server.ts +650 -0
- package/src/tsconfig.json +10 -0
- package/src/types.ts +141 -0
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import { hashSource, KeyedMutex } from './concurrency';
|
|
2
|
+
import { STATUS_BY_CODE, } from './types';
|
|
3
|
+
/** Features the sidecar advertises so older/newer clients can degrade gracefully. */
|
|
4
|
+
export const SIDECAR_FEATURES = [
|
|
5
|
+
'collections',
|
|
6
|
+
'entries.fields-projection',
|
|
7
|
+
'entries.draft-filter',
|
|
8
|
+
'entries.pagination',
|
|
9
|
+
'entry.crud',
|
|
10
|
+
'entry.rename',
|
|
11
|
+
'entry.array',
|
|
12
|
+
'entry.optimistic-concurrency',
|
|
13
|
+
'pages.crud',
|
|
14
|
+
'pages.list',
|
|
15
|
+
'pages.layouts',
|
|
16
|
+
'redirects.crud',
|
|
17
|
+
'media',
|
|
18
|
+
];
|
|
19
|
+
const API_PREFIX = '/cms/v1';
|
|
20
|
+
const DEFAULT_LIMIT = 50;
|
|
21
|
+
const MAX_LIMIT = 1000;
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Response helpers
|
|
24
|
+
// ============================================================================
|
|
25
|
+
function json(body, status = 200) {
|
|
26
|
+
return new Response(JSON.stringify(body), {
|
|
27
|
+
status,
|
|
28
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function error(code, message, sourcePath) {
|
|
32
|
+
const body = { error: message, code };
|
|
33
|
+
if (sourcePath !== undefined)
|
|
34
|
+
body.sourcePath = sourcePath;
|
|
35
|
+
return json(body, STATUS_BY_CODE[code]);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Map a cms-core `MutationResult` to an HTTP response. A failed result becomes a
|
|
39
|
+
* typed `ApiError`: "not found" → 404, everything else (validation, already
|
|
40
|
+
* exists, write failures) → the supplied default code.
|
|
41
|
+
*/
|
|
42
|
+
function mutationResponse(result, fallback = 'validation') {
|
|
43
|
+
if (result.success)
|
|
44
|
+
return json(result);
|
|
45
|
+
const message = result.error ?? 'Mutation failed';
|
|
46
|
+
const code = /not found/i.test(message) ? 'not_found' : fallback;
|
|
47
|
+
return error(code, message, result.sourcePath);
|
|
48
|
+
}
|
|
49
|
+
async function parseJson(req) {
|
|
50
|
+
try {
|
|
51
|
+
const value = (await req.json());
|
|
52
|
+
return { ok: true, value };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { ok: false, response: error('validation', 'Invalid JSON body') };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function parseEntriesQuery(url) {
|
|
59
|
+
const fields = url.searchParams.get('fields') ?? undefined;
|
|
60
|
+
const rawDraft = url.searchParams.get('draft');
|
|
61
|
+
const draft = rawDraft === 'true' || rawDraft === 'all' ? rawDraft : 'false';
|
|
62
|
+
const rawLimit = url.searchParams.get('limit');
|
|
63
|
+
let limit;
|
|
64
|
+
if (rawLimit !== null) {
|
|
65
|
+
const parsed = Number.parseInt(rawLimit, 10);
|
|
66
|
+
if (Number.isFinite(parsed) && parsed > 0)
|
|
67
|
+
limit = Math.min(parsed, MAX_LIMIT);
|
|
68
|
+
}
|
|
69
|
+
const cursor = url.searchParams.get('cursor') ?? undefined;
|
|
70
|
+
return { fields, draft, limit, cursor };
|
|
71
|
+
}
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Entry projection (fields)
|
|
74
|
+
// ============================================================================
|
|
75
|
+
/**
|
|
76
|
+
* Project a scanned `CollectionEntryInfo` down to the requested `fields`.
|
|
77
|
+
*
|
|
78
|
+
* - absent: light header (slug/title/draft/pathname/sourcePath), never the body.
|
|
79
|
+
* - `*`: all frontmatter (via `data`), still no body.
|
|
80
|
+
* - `a,b`: those frontmatter keys (plus the always-present slug/sourcePath).
|
|
81
|
+
*
|
|
82
|
+
* The body lives only behind the entry-detail route, so a list stays small even
|
|
83
|
+
* for large/data collections.
|
|
84
|
+
*/
|
|
85
|
+
function projectEntry(entry, fields) {
|
|
86
|
+
if (fields === '*') {
|
|
87
|
+
// All frontmatter, still no body. `data` already carries the full frontmatter.
|
|
88
|
+
const projected = { slug: entry.slug, sourcePath: entry.sourcePath };
|
|
89
|
+
if (entry.title !== undefined)
|
|
90
|
+
projected.title = entry.title;
|
|
91
|
+
if (entry.draft !== undefined)
|
|
92
|
+
projected.draft = entry.draft;
|
|
93
|
+
if (entry.pathname !== undefined)
|
|
94
|
+
projected.pathname = entry.pathname;
|
|
95
|
+
if (entry.data !== undefined)
|
|
96
|
+
projected.data = entry.data;
|
|
97
|
+
return projected;
|
|
98
|
+
}
|
|
99
|
+
if (fields === undefined || fields.trim() === '') {
|
|
100
|
+
const projected = { slug: entry.slug, sourcePath: entry.sourcePath };
|
|
101
|
+
if (entry.title !== undefined)
|
|
102
|
+
projected.title = entry.title;
|
|
103
|
+
if (entry.draft !== undefined)
|
|
104
|
+
projected.draft = entry.draft;
|
|
105
|
+
if (entry.pathname !== undefined)
|
|
106
|
+
projected.pathname = entry.pathname;
|
|
107
|
+
return projected;
|
|
108
|
+
}
|
|
109
|
+
const keys = fields.split(',').map(k => k.trim()).filter(Boolean);
|
|
110
|
+
// slug + sourcePath are always-present identity fields.
|
|
111
|
+
const projected = { slug: entry.slug, sourcePath: entry.sourcePath };
|
|
112
|
+
const data = {};
|
|
113
|
+
for (const key of keys) {
|
|
114
|
+
switch (key) {
|
|
115
|
+
case 'slug':
|
|
116
|
+
case 'sourcePath':
|
|
117
|
+
break;
|
|
118
|
+
case 'title':
|
|
119
|
+
if (entry.title !== undefined)
|
|
120
|
+
projected.title = entry.title;
|
|
121
|
+
break;
|
|
122
|
+
case 'draft':
|
|
123
|
+
if (entry.draft !== undefined)
|
|
124
|
+
projected.draft = entry.draft;
|
|
125
|
+
break;
|
|
126
|
+
case 'pathname':
|
|
127
|
+
if (entry.pathname !== undefined)
|
|
128
|
+
projected.pathname = entry.pathname;
|
|
129
|
+
break;
|
|
130
|
+
default: {
|
|
131
|
+
const value = entry.data?.[key];
|
|
132
|
+
if (value !== undefined)
|
|
133
|
+
data[key] = value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (Object.keys(data).length > 0)
|
|
138
|
+
projected.data = data;
|
|
139
|
+
return projected;
|
|
140
|
+
}
|
|
141
|
+
/** Apply the draft filter to a scanned entry list. */
|
|
142
|
+
function filterByDraft(entries, draft) {
|
|
143
|
+
if (draft === 'all')
|
|
144
|
+
return entries;
|
|
145
|
+
const wantDraft = draft === 'true';
|
|
146
|
+
return entries.filter(e => (e.draft === true) === wantDraft);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Opaque-but-real cursor: a base64url offset into the (stably scan-ordered) entry
|
|
150
|
+
* list. We never silently cap — when more remain, the next cursor is returned and
|
|
151
|
+
* `hasMore` is set; absent a cursor we start at offset 0.
|
|
152
|
+
*/
|
|
153
|
+
function decodeCursor(cursor) {
|
|
154
|
+
if (!cursor)
|
|
155
|
+
return 0;
|
|
156
|
+
try {
|
|
157
|
+
const decoded = Buffer.from(cursor, 'base64url').toString('utf-8');
|
|
158
|
+
const offset = Number.parseInt(decoded, 10);
|
|
159
|
+
return Number.isFinite(offset) && offset >= 0 ? offset : 0;
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function encodeCursor(offset) {
|
|
166
|
+
return Buffer.from(String(offset), 'utf-8').toString('base64url');
|
|
167
|
+
}
|
|
168
|
+
// ============================================================================
|
|
169
|
+
// Page list (sidecar-layer fs walk; cms-core has no page-listing capability)
|
|
170
|
+
// ============================================================================
|
|
171
|
+
const PAGE_EXTENSIONS = ['.astro', '.md', '.mdx'];
|
|
172
|
+
/**
|
|
173
|
+
* Walk `src/pages` through the `CmsFileSystem` port and derive static page
|
|
174
|
+
* routes. Skips underscore/dot files, dynamic segments (`[...]`) and non-page
|
|
175
|
+
* extensions — mirroring the dev server's filesystem page discovery. Yields
|
|
176
|
+
* `pathname` only; an SEO `title` would require the render manifest (out of scope).
|
|
177
|
+
*/
|
|
178
|
+
async function listPages(fs) {
|
|
179
|
+
const pathnames = [];
|
|
180
|
+
async function walk(dir, urlPrefix) {
|
|
181
|
+
const entries = await fs.list(dir);
|
|
182
|
+
for (const entry of entries) {
|
|
183
|
+
if (entry.name.startsWith('_') || entry.name.startsWith('.'))
|
|
184
|
+
continue;
|
|
185
|
+
if (entry.name.includes('['))
|
|
186
|
+
continue;
|
|
187
|
+
const full = `${dir}/${entry.name}`;
|
|
188
|
+
if (entry.isDirectory) {
|
|
189
|
+
await walk(full, `${urlPrefix}${entry.name}/`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const dot = entry.name.lastIndexOf('.');
|
|
193
|
+
const ext = dot >= 0 ? entry.name.slice(dot) : '';
|
|
194
|
+
if (!PAGE_EXTENSIONS.includes(ext))
|
|
195
|
+
continue;
|
|
196
|
+
const baseName = entry.name.slice(0, entry.name.length - ext.length);
|
|
197
|
+
const pathname = baseName === 'index'
|
|
198
|
+
? (urlPrefix.replace(/\/$/, '') || '/')
|
|
199
|
+
: `${urlPrefix}${baseName}`;
|
|
200
|
+
pathnames.push(pathname);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
await walk('src/pages', '/');
|
|
204
|
+
pathnames.sort((a, b) => a.localeCompare(b));
|
|
205
|
+
return pathnames.map(pathname => ({ pathname }));
|
|
206
|
+
}
|
|
207
|
+
export function createServer(opts) {
|
|
208
|
+
const { core, fs, root, coreVersion } = opts;
|
|
209
|
+
const contentDir = opts.contentDir ?? 'src/content';
|
|
210
|
+
const maxUploadSize = opts.maxUploadSize ?? 20 * 1024 * 1024;
|
|
211
|
+
const mutex = new KeyedMutex();
|
|
212
|
+
async function scanList() {
|
|
213
|
+
const map = await core.scanCollections();
|
|
214
|
+
return Object.values(map);
|
|
215
|
+
}
|
|
216
|
+
async function resolveCollection(name) {
|
|
217
|
+
const map = await core.scanCollections();
|
|
218
|
+
return map[name] ?? null;
|
|
219
|
+
}
|
|
220
|
+
// --- Entry detail (assembles the CollectionEntry wire shape) ---
|
|
221
|
+
async function entryDetail(collection, slug) {
|
|
222
|
+
const result = await core.getEntry(collection, slug);
|
|
223
|
+
if (!result)
|
|
224
|
+
return error('not_found', `Entry not found: ${collection}/${slug}`);
|
|
225
|
+
const def = await resolveCollection(collection);
|
|
226
|
+
// frontmatter values are stringified for the line-keyed wire shape; line
|
|
227
|
+
// numbers are not tracked headlessly (the inline widget owns line-precise edits).
|
|
228
|
+
const frontmatter = {};
|
|
229
|
+
for (const [key, value] of Object.entries(result.frontmatter)) {
|
|
230
|
+
frontmatter[key] = { value: typeof value === 'string' ? value : JSON.stringify(value), line: 0 };
|
|
231
|
+
}
|
|
232
|
+
const entry = {
|
|
233
|
+
collectionName: def?.name ?? collection,
|
|
234
|
+
collectionSlug: slug,
|
|
235
|
+
sourcePath: result.sourcePath,
|
|
236
|
+
frontmatter,
|
|
237
|
+
body: result.content,
|
|
238
|
+
bodyStartLine: 0,
|
|
239
|
+
};
|
|
240
|
+
return json(entry);
|
|
241
|
+
}
|
|
242
|
+
// --- PATCH entry (optimistic concurrency + per-file mutex) ---
|
|
243
|
+
async function patchEntry(collection, slug, body) {
|
|
244
|
+
const existing = await core.getEntry(collection, slug);
|
|
245
|
+
if (!existing)
|
|
246
|
+
return error('not_found', `Entry not found: ${collection}/${slug}`);
|
|
247
|
+
const sourcePath = existing.sourcePath;
|
|
248
|
+
return mutex.runExclusive(sourcePath, async () => {
|
|
249
|
+
// Re-hash under the lock so a concurrent writer cannot slip in between.
|
|
250
|
+
const serverHash = await hashSource(fs, sourcePath);
|
|
251
|
+
if (body.baseHash !== undefined && serverHash !== null && body.baseHash !== serverHash) {
|
|
252
|
+
const current = await core.getEntry(collection, slug);
|
|
253
|
+
const conflict = {
|
|
254
|
+
code: 'conflict',
|
|
255
|
+
serverHash,
|
|
256
|
+
serverFrontmatter: current?.frontmatter ?? existing.frontmatter,
|
|
257
|
+
};
|
|
258
|
+
if (current && current.content !== '')
|
|
259
|
+
conflict.serverBody = current.content;
|
|
260
|
+
return json(conflict, STATUS_BY_CODE.conflict);
|
|
261
|
+
}
|
|
262
|
+
const result = await core.updateEntry({
|
|
263
|
+
collection,
|
|
264
|
+
slug,
|
|
265
|
+
frontmatter: body.frontmatter,
|
|
266
|
+
body: body.body,
|
|
267
|
+
});
|
|
268
|
+
if (!result.success)
|
|
269
|
+
return mutationResponse(result);
|
|
270
|
+
const newHash = await hashSource(fs, result.sourcePath ?? sourcePath);
|
|
271
|
+
const enriched = { ...result };
|
|
272
|
+
if (newHash !== null)
|
|
273
|
+
enriched.sourceHash = newHash;
|
|
274
|
+
return json(enriched);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
async function fetchHandler(req) {
|
|
278
|
+
const url = new URL(req.url);
|
|
279
|
+
let pathname = url.pathname;
|
|
280
|
+
if (pathname.length > 1 && pathname.endsWith('/'))
|
|
281
|
+
pathname = pathname.slice(0, -1);
|
|
282
|
+
// /health is unversioned (liveness probe).
|
|
283
|
+
if (pathname === '/health' && req.method === 'GET') {
|
|
284
|
+
return json({ ok: true, coreVersion, root });
|
|
285
|
+
}
|
|
286
|
+
if (!pathname.startsWith(API_PREFIX)) {
|
|
287
|
+
return error('not_found', `No route: ${req.method} ${pathname}`);
|
|
288
|
+
}
|
|
289
|
+
const rest = pathname.slice(API_PREFIX.length) || '/';
|
|
290
|
+
const segments = rest.split('/').filter(Boolean).map(decodeURIComponent);
|
|
291
|
+
const method = req.method;
|
|
292
|
+
return route(method, segments, req, url);
|
|
293
|
+
}
|
|
294
|
+
async function route(method, segments, req, url) {
|
|
295
|
+
const [head, ...tail] = segments;
|
|
296
|
+
switch (head) {
|
|
297
|
+
case 'health':
|
|
298
|
+
if (method === 'GET')
|
|
299
|
+
return json({ ok: true, coreVersion, root });
|
|
300
|
+
break;
|
|
301
|
+
case 'project':
|
|
302
|
+
if (method === 'GET') {
|
|
303
|
+
const [collections, pages] = await Promise.all([scanList(), listPages(fs)]);
|
|
304
|
+
const capabilities = { coreVersion, features: [...SIDECAR_FEATURES] };
|
|
305
|
+
const model = { collections, pages, capabilities };
|
|
306
|
+
return json(model);
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
309
|
+
case 'collections':
|
|
310
|
+
return routeCollections(method, tail, req, url);
|
|
311
|
+
case 'pages':
|
|
312
|
+
return routePages(method, tail, req);
|
|
313
|
+
case 'redirects':
|
|
314
|
+
return routeRedirects(method, tail, req);
|
|
315
|
+
case 'media':
|
|
316
|
+
return routeMedia(method, tail, req, url);
|
|
317
|
+
}
|
|
318
|
+
return error('not_found', `No route: ${method} /cms/v1/${segments.join('/')}`);
|
|
319
|
+
}
|
|
320
|
+
async function routeCollections(method, tail, req, url) {
|
|
321
|
+
// GET /collections
|
|
322
|
+
if (tail.length === 0) {
|
|
323
|
+
if (method === 'GET')
|
|
324
|
+
return json(await scanList());
|
|
325
|
+
return error('unsupported', `Unsupported: ${method} /collections`);
|
|
326
|
+
}
|
|
327
|
+
const [collection, sub, slug, action] = tail;
|
|
328
|
+
// /collections/:c/entries[...]
|
|
329
|
+
if (sub === 'entries' && collection) {
|
|
330
|
+
// GET /collections/:c/entries (sparse list)
|
|
331
|
+
if (slug === undefined) {
|
|
332
|
+
if (method === 'GET')
|
|
333
|
+
return listEntries(collection, url);
|
|
334
|
+
if (method === 'POST')
|
|
335
|
+
return createEntry(collection, req);
|
|
336
|
+
return error('unsupported', `Unsupported: ${method} /collections/${collection}/entries`);
|
|
337
|
+
}
|
|
338
|
+
// /collections/:c/entries/:slug[...]
|
|
339
|
+
if (action === undefined) {
|
|
340
|
+
if (method === 'GET')
|
|
341
|
+
return entryDetail(collection, slug);
|
|
342
|
+
if (method === 'PATCH')
|
|
343
|
+
return updateEntryRoute(collection, slug, req);
|
|
344
|
+
if (method === 'DELETE')
|
|
345
|
+
return deleteEntryRoute(collection, slug);
|
|
346
|
+
return error('unsupported', `Unsupported: ${method} /collections/${collection}/entries/${slug}`);
|
|
347
|
+
}
|
|
348
|
+
if (action === 'rename' && method === 'POST')
|
|
349
|
+
return renameEntryRoute(collection, slug, req);
|
|
350
|
+
if (action === 'array' && method === 'POST')
|
|
351
|
+
return addArrayRoute(collection, slug, req);
|
|
352
|
+
if (action === 'array' && method === 'DELETE')
|
|
353
|
+
return removeArrayRoute(collection, slug, req);
|
|
354
|
+
}
|
|
355
|
+
return error('not_found', `No route: ${method} /cms/v1/collections/${tail.join('/')}`);
|
|
356
|
+
}
|
|
357
|
+
async function listEntries(collection, url) {
|
|
358
|
+
const def = await resolveCollection(collection);
|
|
359
|
+
if (!def)
|
|
360
|
+
return error('not_found', `Collection not found: ${collection}`);
|
|
361
|
+
const query = parseEntriesQuery(url);
|
|
362
|
+
const all = def.entries ?? [];
|
|
363
|
+
const filtered = filterByDraft(all, query.draft);
|
|
364
|
+
const offset = decodeCursor(query.cursor);
|
|
365
|
+
const limit = query.limit ?? DEFAULT_LIMIT;
|
|
366
|
+
const page = filtered.slice(offset, offset + limit);
|
|
367
|
+
const hasMore = offset + limit < filtered.length;
|
|
368
|
+
const entries = page.map(e => projectEntry(e, query.fields));
|
|
369
|
+
const result = { entries, hasMore };
|
|
370
|
+
if (hasMore)
|
|
371
|
+
result.cursor = encodeCursor(offset + limit);
|
|
372
|
+
return json(result);
|
|
373
|
+
}
|
|
374
|
+
async function createEntry(collection, req) {
|
|
375
|
+
const parsed = await parseJson(req);
|
|
376
|
+
if (!parsed.ok)
|
|
377
|
+
return parsed.response;
|
|
378
|
+
const body = parsed.value;
|
|
379
|
+
if (typeof body.slug !== 'string' || body.slug.trim() === '') {
|
|
380
|
+
return error('validation', 'A non-empty "slug" is required');
|
|
381
|
+
}
|
|
382
|
+
if (body.frontmatter === undefined || typeof body.frontmatter !== 'object') {
|
|
383
|
+
return error('validation', 'A "frontmatter" object is required');
|
|
384
|
+
}
|
|
385
|
+
const result = await core.createEntry({
|
|
386
|
+
collection,
|
|
387
|
+
slug: body.slug,
|
|
388
|
+
frontmatter: body.frontmatter,
|
|
389
|
+
body: body.body,
|
|
390
|
+
fileExtension: body.fileExtension,
|
|
391
|
+
});
|
|
392
|
+
if (!result.success)
|
|
393
|
+
return mutationResponse(result);
|
|
394
|
+
const newHash = result.sourcePath ? await hashSource(fs, result.sourcePath) : null;
|
|
395
|
+
const enriched = { ...result };
|
|
396
|
+
if (newHash !== null)
|
|
397
|
+
enriched.sourceHash = newHash;
|
|
398
|
+
return json(enriched);
|
|
399
|
+
}
|
|
400
|
+
async function updateEntryRoute(collection, slug, req) {
|
|
401
|
+
const parsed = await parseJson(req);
|
|
402
|
+
if (!parsed.ok)
|
|
403
|
+
return parsed.response;
|
|
404
|
+
return patchEntry(collection, slug, parsed.value);
|
|
405
|
+
}
|
|
406
|
+
async function deleteEntryRoute(collection, slug) {
|
|
407
|
+
const existing = await core.getEntry(collection, slug);
|
|
408
|
+
if (!existing)
|
|
409
|
+
return error('not_found', `Entry not found: ${collection}/${slug}`);
|
|
410
|
+
return mutex.runExclusive(existing.sourcePath, async () => {
|
|
411
|
+
const result = await core.deleteEntry(collection, slug);
|
|
412
|
+
return mutationResponse(result);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
async function renameEntryRoute(collection, slug, req) {
|
|
416
|
+
const parsed = await parseJson(req);
|
|
417
|
+
if (!parsed.ok)
|
|
418
|
+
return parsed.response;
|
|
419
|
+
if (typeof parsed.value.to !== 'string' || parsed.value.to.trim() === '') {
|
|
420
|
+
return error('validation', 'A non-empty "to" slug is required');
|
|
421
|
+
}
|
|
422
|
+
const existing = await core.getEntry(collection, slug);
|
|
423
|
+
if (!existing)
|
|
424
|
+
return error('not_found', `Entry not found: ${collection}/${slug}`);
|
|
425
|
+
return mutex.runExclusive(existing.sourcePath, async () => {
|
|
426
|
+
const result = await core.renameEntry(collection, slug, parsed.value.to);
|
|
427
|
+
if (!result.success)
|
|
428
|
+
return mutationResponse(result);
|
|
429
|
+
const newHash = result.sourcePath ? await hashSource(fs, result.sourcePath) : null;
|
|
430
|
+
const enriched = { ...result };
|
|
431
|
+
if (newHash !== null)
|
|
432
|
+
enriched.sourceHash = newHash;
|
|
433
|
+
return json(enriched);
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
async function addArrayRoute(collection, slug, req) {
|
|
437
|
+
const parsed = await parseJson(req);
|
|
438
|
+
if (!parsed.ok)
|
|
439
|
+
return parsed.response;
|
|
440
|
+
const body = parsed.value;
|
|
441
|
+
if (typeof body.field !== 'string' || body.field.trim() === '') {
|
|
442
|
+
return error('validation', 'A non-empty "field" is required');
|
|
443
|
+
}
|
|
444
|
+
const existing = await core.getEntry(collection, slug);
|
|
445
|
+
if (!existing)
|
|
446
|
+
return error('not_found', `Entry not found: ${collection}/${slug}`);
|
|
447
|
+
return mutex.runExclusive(existing.sourcePath, async () => {
|
|
448
|
+
const result = await core.addArrayItem({ collection, slug, field: body.field, value: body.value, index: body.index });
|
|
449
|
+
return mutationResponse(result);
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
async function removeArrayRoute(collection, slug, req) {
|
|
453
|
+
const parsed = await parseJson(req);
|
|
454
|
+
if (!parsed.ok)
|
|
455
|
+
return parsed.response;
|
|
456
|
+
const body = parsed.value;
|
|
457
|
+
if (typeof body.field !== 'string' || body.field.trim() === '') {
|
|
458
|
+
return error('validation', 'A non-empty "field" is required');
|
|
459
|
+
}
|
|
460
|
+
if (typeof body.index !== 'number' || !Number.isInteger(body.index)) {
|
|
461
|
+
return error('validation', 'An integer "index" is required');
|
|
462
|
+
}
|
|
463
|
+
const existing = await core.getEntry(collection, slug);
|
|
464
|
+
if (!existing)
|
|
465
|
+
return error('not_found', `Entry not found: ${collection}/${slug}`);
|
|
466
|
+
return mutex.runExclusive(existing.sourcePath, async () => {
|
|
467
|
+
const result = await core.removeArrayItem({ collection, slug, field: body.field, index: body.index });
|
|
468
|
+
return mutationResponse(result);
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
async function routePages(method, tail, req) {
|
|
472
|
+
const [sub] = tail;
|
|
473
|
+
// GET /pages → fs-derived page list (cms-core has no page listing).
|
|
474
|
+
if (sub === undefined) {
|
|
475
|
+
if (method === 'GET')
|
|
476
|
+
return json({ pages: await listPages(fs) });
|
|
477
|
+
if (method === 'POST') {
|
|
478
|
+
const parsed = await parseJson(req);
|
|
479
|
+
if (!parsed.ok)
|
|
480
|
+
return parsed.response;
|
|
481
|
+
const result = await core.createPage(parsed.value);
|
|
482
|
+
return result.success ? json(result) : error('validation', result.error ?? 'Failed to create page');
|
|
483
|
+
}
|
|
484
|
+
if (method === 'DELETE') {
|
|
485
|
+
const parsed = await parseJson(req);
|
|
486
|
+
if (!parsed.ok)
|
|
487
|
+
return parsed.response;
|
|
488
|
+
const result = await core.deletePage(parsed.value);
|
|
489
|
+
if (result.success)
|
|
490
|
+
return json(result);
|
|
491
|
+
const code = /not found/i.test(result.error ?? '') ? 'not_found' : 'validation';
|
|
492
|
+
return error(code, result.error ?? 'Failed to delete page');
|
|
493
|
+
}
|
|
494
|
+
return error('unsupported', `Unsupported: ${method} /pages`);
|
|
495
|
+
}
|
|
496
|
+
// GET /pages/layouts
|
|
497
|
+
if (sub === 'layouts' && method === 'GET') {
|
|
498
|
+
return json({ layouts: await core.getLayouts() });
|
|
499
|
+
}
|
|
500
|
+
// POST /pages/duplicate
|
|
501
|
+
if (sub === 'duplicate' && method === 'POST') {
|
|
502
|
+
const parsed = await parseJson(req);
|
|
503
|
+
if (!parsed.ok)
|
|
504
|
+
return parsed.response;
|
|
505
|
+
const result = await core.duplicatePage(parsed.value);
|
|
506
|
+
if (result.success)
|
|
507
|
+
return json(result);
|
|
508
|
+
const code = /not found/i.test(result.error ?? '') ? 'not_found' : 'validation';
|
|
509
|
+
return error(code, result.error ?? 'Failed to duplicate page');
|
|
510
|
+
}
|
|
511
|
+
return error('not_found', `No route: ${method} /cms/v1/pages/${tail.join('/')}`);
|
|
512
|
+
}
|
|
513
|
+
async function routeRedirects(method, tail, req) {
|
|
514
|
+
const [sub] = tail;
|
|
515
|
+
if (sub === undefined) {
|
|
516
|
+
if (method === 'GET')
|
|
517
|
+
return json(await core.listRedirects());
|
|
518
|
+
if (method === 'POST') {
|
|
519
|
+
const parsed = await parseJson(req);
|
|
520
|
+
if (!parsed.ok)
|
|
521
|
+
return parsed.response;
|
|
522
|
+
const result = await core.addRedirect(parsed.value);
|
|
523
|
+
return result.success ? json(result) : error('validation', result.error ?? 'Failed to add redirect');
|
|
524
|
+
}
|
|
525
|
+
if (method === 'PATCH') {
|
|
526
|
+
const parsed = await parseJson(req);
|
|
527
|
+
if (!parsed.ok)
|
|
528
|
+
return parsed.response;
|
|
529
|
+
const result = await core.updateRedirect(parsed.value);
|
|
530
|
+
return result.success ? json(result) : error('validation', result.error ?? 'Failed to update redirect');
|
|
531
|
+
}
|
|
532
|
+
if (method === 'DELETE') {
|
|
533
|
+
const parsed = await parseJson(req);
|
|
534
|
+
if (!parsed.ok)
|
|
535
|
+
return parsed.response;
|
|
536
|
+
const result = await core.deleteRedirect(parsed.value);
|
|
537
|
+
return result.success ? json(result) : error('validation', result.error ?? 'Failed to delete redirect');
|
|
538
|
+
}
|
|
539
|
+
return error('unsupported', `Unsupported: ${method} /redirects`);
|
|
540
|
+
}
|
|
541
|
+
return error('not_found', `No route: ${method} /cms/v1/redirects/${tail.join('/')}`);
|
|
542
|
+
}
|
|
543
|
+
async function routeMedia(method, tail, req, url) {
|
|
544
|
+
const adapter = core.media;
|
|
545
|
+
if (!adapter)
|
|
546
|
+
return error('unsupported', 'Media storage not configured');
|
|
547
|
+
const [id] = tail;
|
|
548
|
+
// GET /media
|
|
549
|
+
if (id === undefined && method === 'GET') {
|
|
550
|
+
const rawLimit = url.searchParams.get('limit');
|
|
551
|
+
const parsedLimit = rawLimit !== null ? Number.parseInt(rawLimit, 10) : Number.NaN;
|
|
552
|
+
const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, MAX_LIMIT) : DEFAULT_LIMIT;
|
|
553
|
+
const result = await adapter.list({
|
|
554
|
+
limit,
|
|
555
|
+
cursor: url.searchParams.get('cursor') ?? undefined,
|
|
556
|
+
folder: url.searchParams.get('folder') ?? undefined,
|
|
557
|
+
});
|
|
558
|
+
return json(result);
|
|
559
|
+
}
|
|
560
|
+
// POST /media (multipart upload, or JSON create-folder)
|
|
561
|
+
if (id === undefined && method === 'POST') {
|
|
562
|
+
const contentType = req.headers.get('content-type') ?? '';
|
|
563
|
+
if (contentType.includes('application/json')) {
|
|
564
|
+
const parsed = await parseJson(req);
|
|
565
|
+
if (!parsed.ok)
|
|
566
|
+
return parsed.response;
|
|
567
|
+
if (!adapter.createFolder)
|
|
568
|
+
return error('unsupported', 'Folder creation not supported by this adapter');
|
|
569
|
+
if (typeof parsed.value.folder !== 'string' || parsed.value.folder.includes('..')) {
|
|
570
|
+
return error('validation', 'A valid "folder" is required');
|
|
571
|
+
}
|
|
572
|
+
const result = await adapter.createFolder(parsed.value.folder);
|
|
573
|
+
return result.success ? json(result) : error('io_error', result.error ?? 'Failed to create folder');
|
|
574
|
+
}
|
|
575
|
+
if (!contentType.includes('multipart/form-data')) {
|
|
576
|
+
return error('validation', 'Expected multipart/form-data or application/json');
|
|
577
|
+
}
|
|
578
|
+
const form = await req.formData();
|
|
579
|
+
const file = form.get('file');
|
|
580
|
+
if (!(file instanceof File))
|
|
581
|
+
return error('validation', 'No "file" found in the form data');
|
|
582
|
+
if (file.size > maxUploadSize) {
|
|
583
|
+
return error('validation', `File too large (max ${Math.round(maxUploadSize / (1024 * 1024))} MB)`);
|
|
584
|
+
}
|
|
585
|
+
const buffer = Buffer.from(await file.arrayBuffer());
|
|
586
|
+
const folder = url.searchParams.get('folder') ?? undefined;
|
|
587
|
+
const result = await adapter.upload(buffer, file.name, file.type || 'application/octet-stream', { folder });
|
|
588
|
+
return result.success ? json(result) : error('io_error', result.error ?? 'Upload failed');
|
|
589
|
+
}
|
|
590
|
+
// DELETE /media/:id
|
|
591
|
+
if (id !== undefined && method === 'DELETE') {
|
|
592
|
+
const result = await adapter.delete(id);
|
|
593
|
+
return result.success ? json({ success: true }) : error('io_error', result.error ?? 'Delete failed');
|
|
594
|
+
}
|
|
595
|
+
return error('not_found', `No route: ${method} /cms/v1/media${id ? `/${id}` : ''}`);
|
|
596
|
+
}
|
|
597
|
+
return { fetch: fetchHandler };
|
|
598
|
+
}
|