@micacms/runtime 0.5.0 → 0.6.0-rc.2
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/README.md +3 -1
- package/dist/app.js +1 -1
- package/dist/app.js.map +1 -1
- package/dist/config.d.ts +13 -13
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -9
- package/dist/config.js.map +1 -1
- package/dist/context.js +2 -2
- package/dist/context.js.map +1 -1
- package/dist/http.d.ts +1 -3
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +2 -9
- package/dist/http.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/errors.d.ts +1 -2
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +0 -2
- package/dist/lib/errors.js.map +1 -1
- package/dist/lib/file-type.d.ts.map +1 -1
- package/dist/lib/file-type.js +2 -8
- package/dist/lib/file-type.js.map +1 -1
- package/dist/lib/id.d.ts +0 -10
- package/dist/lib/id.d.ts.map +1 -1
- package/dist/lib/id.js +0 -50
- package/dist/lib/id.js.map +1 -1
- package/dist/lib/rate-limit.d.ts +0 -2
- package/dist/lib/rate-limit.d.ts.map +1 -1
- package/dist/lib/rate-limit.js +0 -3
- package/dist/lib/rate-limit.js.map +1 -1
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +2 -2
- package/dist/middleware/index.js.map +1 -1
- package/dist/routes/admin-content.d.ts.map +1 -1
- package/dist/routes/admin-content.js +3 -2
- package/dist/routes/admin-content.js.map +1 -1
- package/dist/routes/admin-system.js +4 -4
- package/dist/routes/admin-system.js.map +1 -1
- package/dist/schema.d.ts +6 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +19 -1
- package/dist/schema.js.map +1 -1
- package/dist/services/changes.d.ts.map +1 -1
- package/dist/services/changes.js +89 -13
- package/dist/services/changes.js.map +1 -1
- package/dist/services/content-store.d.ts.map +1 -1
- package/dist/services/content-store.js +9 -10
- package/dist/services/content-store.js.map +1 -1
- package/dist/services/staging.d.ts.map +1 -1
- package/dist/services/staging.js +7 -4
- package/dist/services/staging.js.map +1 -1
- package/dist/workspace/developer.d.ts +2 -1
- package/dist/workspace/developer.d.ts.map +1 -1
- package/dist/workspace/developer.js +17 -3
- package/dist/workspace/developer.js.map +1 -1
- package/dist/workspace/tree.d.ts +6 -0
- package/dist/workspace/tree.d.ts.map +1 -1
- package/dist/workspace/tree.js +28 -0
- package/dist/workspace/tree.js.map +1 -1
- package/package.json +12 -4
- package/src/app.ts +127 -0
- package/src/config.ts +113 -0
- package/src/context.ts +167 -0
- package/src/http.ts +46 -0
- package/src/index.ts +141 -0
- package/src/lib/errors.ts +84 -0
- package/src/lib/file-type.ts +102 -0
- package/src/lib/id.ts +6 -0
- package/src/lib/logger.ts +87 -0
- package/src/lib/ports.ts +17 -0
- package/src/lib/rate-limit.ts +72 -0
- package/src/lib/schema-stats.ts +183 -0
- package/src/lib/version.ts +29 -0
- package/src/middleware/index.ts +234 -0
- package/src/routes/admin-content.ts +197 -0
- package/src/routes/admin-media.ts +96 -0
- package/src/routes/admin-spa.ts +82 -0
- package/src/routes/admin-staging.ts +126 -0
- package/src/routes/admin-system.ts +146 -0
- package/src/routes/health.ts +67 -0
- package/src/schema.ts +217 -0
- package/src/server.ts +68 -0
- package/src/services/changes.ts +605 -0
- package/src/services/content-store.ts +492 -0
- package/src/services/media.ts +229 -0
- package/src/services/staging.ts +330 -0
- package/src/workspace/developer.ts +58 -0
- package/src/workspace/files.ts +243 -0
- package/src/workspace/git.ts +108 -0
- package/src/workspace/locks.ts +103 -0
- package/src/workspace/policy.ts +77 -0
- package/src/workspace/tree.ts +314 -0
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collections and singletons over the working tree's files: validation, slugs,
|
|
3
|
+
* entry files and `_order.json`. An entry is its slug and path, Git keeps the
|
|
4
|
+
* history, a save is a draft, and only Publish makes a commit.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
ContentPathError,
|
|
11
|
+
DOCUMENT_EXTENSION,
|
|
12
|
+
INDEX_FILE,
|
|
13
|
+
ORDER_FILE,
|
|
14
|
+
collectionEntryPath,
|
|
15
|
+
entryFiles,
|
|
16
|
+
entryLayout,
|
|
17
|
+
entryTitle,
|
|
18
|
+
isValidSlug,
|
|
19
|
+
collectReferences,
|
|
20
|
+
readCollection,
|
|
21
|
+
readSingleton,
|
|
22
|
+
relativizeMedia,
|
|
23
|
+
resolveSlugField,
|
|
24
|
+
serializeEntry,
|
|
25
|
+
singletonPath,
|
|
26
|
+
slugify,
|
|
27
|
+
uniqueSlug,
|
|
28
|
+
validateFields,
|
|
29
|
+
withDefaults,
|
|
30
|
+
type Collection,
|
|
31
|
+
type ContentEntry,
|
|
32
|
+
type FieldIssue,
|
|
33
|
+
type FieldMap,
|
|
34
|
+
type Singleton,
|
|
35
|
+
type Site,
|
|
36
|
+
} from "@micacms/core";
|
|
37
|
+
|
|
38
|
+
import { ConflictError, FilesError, type ContentFiles, type FileChange } from "../workspace/files.js";
|
|
39
|
+
|
|
40
|
+
export class ContentError extends FilesError {
|
|
41
|
+
override name = "ContentError";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A write to a collection or singleton that this build of Mica cannot interpret. */
|
|
45
|
+
export class ReadOnlyError extends ContentError {
|
|
46
|
+
override name = "ReadOnlyError";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class ValidationError extends ContentError {
|
|
50
|
+
override name = "ValidationError";
|
|
51
|
+
constructor(
|
|
52
|
+
message: string,
|
|
53
|
+
readonly issues: FieldIssue[],
|
|
54
|
+
) {
|
|
55
|
+
super(message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface SaveEntryInput {
|
|
60
|
+
data: Record<string, unknown>;
|
|
61
|
+
/** The entry being replaced. Absent when creating. */
|
|
62
|
+
slug?: string;
|
|
63
|
+
/** The revision `slug` had when the editor opened it. Absent writes unconditionally. */
|
|
64
|
+
expectedRevision?: string | null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface SavedEntry {
|
|
68
|
+
slug: string;
|
|
69
|
+
title: string;
|
|
70
|
+
/** The entry's revision after this save, for the next save. A file revision, not a commit. */
|
|
71
|
+
revision: string;
|
|
72
|
+
/** True when the save moved the entry's files. */
|
|
73
|
+
renamed: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** `readOnly` comes from `readOnlyParts`: its collections and singletons are read, never written. */
|
|
77
|
+
export function createContentStore(site: Site, workspace: ContentFiles, readOnly: ReadonlyMap<string, string> = new Map()) {
|
|
78
|
+
const requireCollection = (name: string): Collection => {
|
|
79
|
+
const def = site.collections[name];
|
|
80
|
+
if (!def) throw new ContentError(`Unknown collection "${name}".`);
|
|
81
|
+
return def;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const requireSingleton = (name: string): Singleton => {
|
|
85
|
+
const def = site.singletons[name];
|
|
86
|
+
if (!def) throw new ContentError(`Unknown singleton "${name}".`);
|
|
87
|
+
return def;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const writable = <T>(def: T, key: string): T => {
|
|
91
|
+
const reason = readOnly.get(key);
|
|
92
|
+
if (reason) throw new ReadOnlyError(reason);
|
|
93
|
+
return def;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const validate = (
|
|
97
|
+
def: Collection | Singleton,
|
|
98
|
+
data: unknown,
|
|
99
|
+
what: string,
|
|
100
|
+
): Record<string, unknown> => {
|
|
101
|
+
const result = validateFields(def.fields, data);
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
throw new ValidationError(
|
|
104
|
+
`${what} cannot be saved until these fields are correct.`,
|
|
105
|
+
result.issues,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
return result.data as Record<string, unknown>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Deletes for `.mdoc` files not in `keep`, so a cleared rich-text field does
|
|
113
|
+
* not come back on the next read. Media stays: an unreferenced upload is not
|
|
114
|
+
* garbage.
|
|
115
|
+
*/
|
|
116
|
+
const staleDocuments = async (base: string, keep: Set<string>): Promise<FileChange[]> => {
|
|
117
|
+
const changes: FileChange[] = [];
|
|
118
|
+
|
|
119
|
+
for (const item of await workspace.list(base)) {
|
|
120
|
+
if (item.type !== "file") continue;
|
|
121
|
+
if (!item.name.endsWith(DOCUMENT_EXTENSION)) continue;
|
|
122
|
+
if (keep.has(item.name)) continue;
|
|
123
|
+
changes.push({ type: "delete", path: `${base}/${item.name}` });
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return changes;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The document's revision after a write. Throws when the file is absent,
|
|
131
|
+
* because an empty token could never match.
|
|
132
|
+
*/
|
|
133
|
+
const revisionAfterWrite = async (dataFile: string, folder: string | null): Promise<string> => {
|
|
134
|
+
const revision = await documentRevision(dataFile, folder);
|
|
135
|
+
if (revision === null) {
|
|
136
|
+
throw new ContentError(`Saved, but "${dataFile}" is not in the repository afterwards.`);
|
|
137
|
+
}
|
|
138
|
+
return revision;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** The folder whose files make up one document, or null for a one-file document. */
|
|
142
|
+
const folderOf = (fields: FieldMap, base: string): string | null =>
|
|
143
|
+
entryLayout(fields) === "directory" ? base : null;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The revision of a whole document: its data file and, in the folder layout,
|
|
147
|
+
* every file of its folder. A change to any one of them makes the next save a
|
|
148
|
+
* conflict, not an overwrite.
|
|
149
|
+
*/
|
|
150
|
+
const documentRevision = async (dataFile: string, folder: string | null): Promise<string | null> => {
|
|
151
|
+
const files = await documentFiles(dataFile, folder);
|
|
152
|
+
if (files === null) return null;
|
|
153
|
+
if (folder === null) return files.get(dataFile)!;
|
|
154
|
+
const parts = [...files].map(([path, revision]) => `${path}\u0000${revision}`);
|
|
155
|
+
return createHash("sha256").update(parts.sort().join("\n")).digest("hex").slice(0, 16);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/** Every file of a document with its revision, or null when its data file is absent. */
|
|
159
|
+
const documentFiles = async (dataFile: string, folder: string | null): Promise<Map<string, string> | null> => {
|
|
160
|
+
const data = await workspace.revision(dataFile);
|
|
161
|
+
if (data === null) return null;
|
|
162
|
+
const files = new Map([[dataFile, data]]);
|
|
163
|
+
if (folder === null) return files;
|
|
164
|
+
const walk = async (dir: string): Promise<void> => {
|
|
165
|
+
for (const item of await workspace.list(dir)) {
|
|
166
|
+
const child = `${dir}/${item.name}`;
|
|
167
|
+
if (item.type === "directory") await walk(child);
|
|
168
|
+
else files.set(child, (await workspace.revision(child)) ?? "");
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
await walk(folder);
|
|
172
|
+
return files;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Refuses when the document changed since the editor read `expected`, and
|
|
177
|
+
* returns every file's revision for `apply`, which checks them all again
|
|
178
|
+
* under the write lock.
|
|
179
|
+
*/
|
|
180
|
+
const expectDocument = async (
|
|
181
|
+
expected: string | null | undefined,
|
|
182
|
+
dataFile: string,
|
|
183
|
+
folder: string | null,
|
|
184
|
+
): Promise<Record<string, string | null> | undefined> => {
|
|
185
|
+
if (expected === undefined) return undefined;
|
|
186
|
+
const files = await documentFiles(dataFile, folder);
|
|
187
|
+
if ((await documentRevision(dataFile, folder)) !== expected) {
|
|
188
|
+
throw new ConflictError("This content changed after you opened it. Reload to see the current version.", [
|
|
189
|
+
dataFile,
|
|
190
|
+
]);
|
|
191
|
+
}
|
|
192
|
+
return files === null ? { [dataFile]: null } : Object.fromEntries(files);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The order file with a renamed slug in its old place, so the entry does not
|
|
197
|
+
* drop to the end of the list.
|
|
198
|
+
*/
|
|
199
|
+
const renameInOrder = async (name: string, from: string, to: string): Promise<FileChange | null> => {
|
|
200
|
+
const path = `${name}/${ORDER_FILE}`;
|
|
201
|
+
const raw = await workspace.read(path);
|
|
202
|
+
if (raw === null) return null;
|
|
203
|
+
|
|
204
|
+
let listed: unknown;
|
|
205
|
+
try {
|
|
206
|
+
listed = JSON.parse(raw);
|
|
207
|
+
} catch {
|
|
208
|
+
// Reading the collection reports a malformed order file; a save need not.
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
if (!Array.isArray(listed) || !listed.includes(from)) return null;
|
|
212
|
+
|
|
213
|
+
const order = listed.map((slug: unknown) => (slug === from ? to : slug));
|
|
214
|
+
return { type: "write", path, contents: `${JSON.stringify(order, null, 2)}\n` };
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/** Slugs already taken in a collection, so a new one can be made unique. */
|
|
218
|
+
const takenSlugs = async (def: Collection, name: string, except?: string): Promise<string[]> => {
|
|
219
|
+
const entries = await readCollection(workspace, def, name);
|
|
220
|
+
return entries.map((entry) => entry.slug).filter((slug) => slug !== except);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
async list(collectionName: string): Promise<ContentEntry[]> {
|
|
225
|
+
return readCollection(workspace, requireCollection(collectionName), collectionName);
|
|
226
|
+
},
|
|
227
|
+
|
|
228
|
+
async get(collectionName: string, slug: string): Promise<ContentEntry | null> {
|
|
229
|
+
const entries = await this.list(collectionName);
|
|
230
|
+
return entries.find((entry) => entry.slug === slug) ?? null;
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
/** The revision an editor must send back with a save of this entry. */
|
|
234
|
+
async revisionOf(collectionName: string, slug: string): Promise<string | null> {
|
|
235
|
+
const def = requireCollection(collectionName);
|
|
236
|
+
return documentRevision(
|
|
237
|
+
dataPath(def, collectionName, slug),
|
|
238
|
+
folderOf(def.fields, collectionEntryPath(def.fields, collectionName, slug)),
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
/** The revision an editor must send back with a save of this singleton. */
|
|
243
|
+
async revisionOfSingleton(name: string): Promise<string | null> {
|
|
244
|
+
const def = requireSingleton(name);
|
|
245
|
+
return documentRevision(singletonDataPath(def, name), folderOf(def.fields, singletonPath(def.fields, name)));
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async getSingleton(name: string): Promise<Record<string, unknown>> {
|
|
249
|
+
const def = requireSingleton(name);
|
|
250
|
+
const data = await readSingleton(workspace, def, name);
|
|
251
|
+
// A singleton with no file yet opens as an empty form, not a 404.
|
|
252
|
+
return withDefaults(def.fields, data);
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
async save(collectionName: string, input: SaveEntryInput): Promise<SavedEntry> {
|
|
256
|
+
const def = writable(requireCollection(collectionName), `collections.${collectionName}`);
|
|
257
|
+
const data = validate(def, input.data, "This entry");
|
|
258
|
+
|
|
259
|
+
const slugKey = resolveSlugField(def)!;
|
|
260
|
+
const requested = pickSlug(def, data, input.slug);
|
|
261
|
+
const slug =
|
|
262
|
+
requested === input.slug
|
|
263
|
+
? requested
|
|
264
|
+
: uniqueSlug(requested, await takenSlugs(def, collectionName, input.slug));
|
|
265
|
+
|
|
266
|
+
data[slugKey] = slug;
|
|
267
|
+
|
|
268
|
+
const base = collectionEntryPath(def.fields, collectionName, slug);
|
|
269
|
+
const previousBase =
|
|
270
|
+
input.slug && input.slug !== slug
|
|
271
|
+
? collectionEntryPath(def.fields, collectionName, input.slug)
|
|
272
|
+
: null;
|
|
273
|
+
|
|
274
|
+
// The editor uses root-relative media paths; store them relative to the
|
|
275
|
+
// entry (the old directory on a rename).
|
|
276
|
+
const serialized = serializeEntry(
|
|
277
|
+
def.fields,
|
|
278
|
+
mediaForDisk(def.fields, data, previousBase ?? base),
|
|
279
|
+
);
|
|
280
|
+
const files = entryFiles(def.fields, base, serialized);
|
|
281
|
+
|
|
282
|
+
const changes: FileChange[] = Object.entries(files).map(([path, contents]) => ({
|
|
283
|
+
type: "write",
|
|
284
|
+
path,
|
|
285
|
+
contents,
|
|
286
|
+
}));
|
|
287
|
+
|
|
288
|
+
if (previousBase) {
|
|
289
|
+
changes.push(
|
|
290
|
+
entryLayout(def.fields) === "directory"
|
|
291
|
+
? { type: "deleteTree", path: previousBase }
|
|
292
|
+
: { type: "delete", path: previousBase },
|
|
293
|
+
);
|
|
294
|
+
await moveMedia(workspace, previousBase, base, changes);
|
|
295
|
+
const order = await renameInOrder(collectionName, input.slug!, slug);
|
|
296
|
+
if (order) changes.push(order);
|
|
297
|
+
} else if (entryLayout(def.fields) === "directory") {
|
|
298
|
+
changes.push(...(await staleDocuments(base, new Set(Object.keys(serialized.documents)))));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const title = entryTitle(def, data);
|
|
302
|
+
await workspace.apply({
|
|
303
|
+
changes,
|
|
304
|
+
expect: await expectDocument(
|
|
305
|
+
input.expectedRevision,
|
|
306
|
+
dataPath(def, collectionName, input.slug ?? slug),
|
|
307
|
+
folderOf(def.fields, previousBase ?? base),
|
|
308
|
+
),
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const revision = await revisionAfterWrite(dataPath(def, collectionName, slug), folderOf(def.fields, base));
|
|
312
|
+
|
|
313
|
+
return { slug, title, revision, renamed: previousBase !== null };
|
|
314
|
+
},
|
|
315
|
+
|
|
316
|
+
async remove(
|
|
317
|
+
collectionName: string,
|
|
318
|
+
slug: string,
|
|
319
|
+
options: { expectedRevision?: string | null } = {},
|
|
320
|
+
): Promise<void> {
|
|
321
|
+
const def = writable(requireCollection(collectionName), `collections.${collectionName}`);
|
|
322
|
+
// One path segment: an empty slug or `a/b` would delete more than one entry.
|
|
323
|
+
if (slug === "" || slug.includes("/") || slug === "." || slug === "..") {
|
|
324
|
+
throw new ContentPathError(`"${slug}" is not an entry of "${collectionName}".`);
|
|
325
|
+
}
|
|
326
|
+
const base = collectionEntryPath(def.fields, collectionName, slug);
|
|
327
|
+
|
|
328
|
+
await workspace.apply({
|
|
329
|
+
changes: [
|
|
330
|
+
entryLayout(def.fields) === "directory"
|
|
331
|
+
? { type: "deleteTree", path: base }
|
|
332
|
+
: { type: "delete", path: base },
|
|
333
|
+
],
|
|
334
|
+
expect: await expectDocument(
|
|
335
|
+
options.expectedRevision,
|
|
336
|
+
dataPath(def, collectionName, slug),
|
|
337
|
+
folderOf(def.fields, base),
|
|
338
|
+
),
|
|
339
|
+
});
|
|
340
|
+
},
|
|
341
|
+
|
|
342
|
+
async saveSingleton(
|
|
343
|
+
name: string,
|
|
344
|
+
payload: unknown,
|
|
345
|
+
options: { expectedRevision?: string | null } = {},
|
|
346
|
+
): Promise<{ revision: string }> {
|
|
347
|
+
const def = writable(requireSingleton(name), `singletons.${name}`);
|
|
348
|
+
const data = validate(def, payload, `The "${def.label}" settings`);
|
|
349
|
+
|
|
350
|
+
const base = singletonPath(def.fields, name);
|
|
351
|
+
const serialized = serializeEntry(def.fields, mediaForDisk(def.fields, data, base));
|
|
352
|
+
|
|
353
|
+
const changes: FileChange[] = Object.entries(entryFiles(def.fields, base, serialized)).map(
|
|
354
|
+
([path, contents]) => ({ type: "write", path, contents }),
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
if (entryLayout(def.fields) === "directory") {
|
|
358
|
+
changes.push(...(await staleDocuments(base, new Set(Object.keys(serialized.documents)))));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
await workspace.apply({
|
|
362
|
+
changes,
|
|
363
|
+
expect: await expectDocument(options.expectedRevision, singletonDataPath(def, name), folderOf(def.fields, base)),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return { revision: await revisionAfterWrite(singletonDataPath(def, name), folderOf(def.fields, base)) };
|
|
367
|
+
},
|
|
368
|
+
|
|
369
|
+
async reorder(
|
|
370
|
+
collectionName: string,
|
|
371
|
+
slugs: string[],
|
|
372
|
+
): Promise<void> {
|
|
373
|
+
const def = writable(requireCollection(collectionName), `collections.${collectionName}`);
|
|
374
|
+
if (!def.sortable) {
|
|
375
|
+
throw new ContentError(`The "${def.label}" collection is not manually sortable.`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
await workspace.apply({
|
|
379
|
+
changes: [
|
|
380
|
+
{
|
|
381
|
+
type: "write",
|
|
382
|
+
path: `${collectionName}/${ORDER_FILE}`,
|
|
383
|
+
contents: `${JSON.stringify(slugs, null, 2)}\n`,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
});
|
|
387
|
+
},
|
|
388
|
+
|
|
389
|
+
requireCollection,
|
|
390
|
+
requireSingleton,
|
|
391
|
+
|
|
392
|
+
/** Slug the server would assign, so the editor's URL preview matches it. */
|
|
393
|
+
async suggestSlug(collectionName: string, source: string, except?: string): Promise<string> {
|
|
394
|
+
const def = requireCollection(collectionName);
|
|
395
|
+
return uniqueSlug(slugify(source), await takenSlugs(def, collectionName, except));
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
/** Entries pointing at `slug`, so the editor can warn before a delete. */
|
|
399
|
+
async findReferences(collectionName: string, slug: string) {
|
|
400
|
+
const results: Array<{ collection: string; slug: string; title: string }> = [];
|
|
401
|
+
|
|
402
|
+
for (const [name, def] of Object.entries(site.collections)) {
|
|
403
|
+
if (!linksTo(def.fields, collectionName)) continue;
|
|
404
|
+
for (const entry of await readCollection(workspace, def, name)) {
|
|
405
|
+
const points = collectReferences(def.fields, entry.data).some(
|
|
406
|
+
(reference) =>
|
|
407
|
+
reference.kind === "relation" && reference.collection === collectionName && reference.slug === slug,
|
|
408
|
+
);
|
|
409
|
+
if (points) {
|
|
410
|
+
results.push({ collection: name, slug: entry.slug, title: entryTitle(def, entry.data) });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return results;
|
|
416
|
+
},
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export type ContentStore = ReturnType<typeof createContentStore>;
|
|
421
|
+
|
|
422
|
+
/** Whether a field map has a relation to `collection`, nested fields included. */
|
|
423
|
+
function linksTo(fields: FieldMap, collection: string): boolean {
|
|
424
|
+
return Object.values(fields).some(
|
|
425
|
+
(field) =>
|
|
426
|
+
(field.kind === "relation" && field.collection === collection) ||
|
|
427
|
+
((field.kind === "object" || field.kind === "repeater") && linksTo(field.fields, collection)),
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Media paths as the repository stores them. Only a directory entry has
|
|
433
|
+
* colocated media to relativize.
|
|
434
|
+
*/
|
|
435
|
+
function mediaForDisk(
|
|
436
|
+
fields: FieldMap,
|
|
437
|
+
data: Record<string, unknown>,
|
|
438
|
+
base: string,
|
|
439
|
+
): Record<string, unknown> {
|
|
440
|
+
return entryLayout(fields) === "directory" ? relativizeMedia(fields, data, base) : data;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function dataPath(def: Collection, collection: string, slug: string): string {
|
|
444
|
+
const base = collectionEntryPath(def.fields, collection, slug);
|
|
445
|
+
return entryLayout(def.fields) === "directory" ? `${base}/${INDEX_FILE}` : base;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function singletonDataPath(def: Singleton, name: string): string {
|
|
449
|
+
const base = singletonPath(def.fields, name);
|
|
450
|
+
return entryLayout(def.fields) === "directory" ? `${base}/${INDEX_FILE}` : base;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The slug from the payload's slug field, or one derived from the title. */
|
|
454
|
+
function pickSlug(
|
|
455
|
+
def: Collection,
|
|
456
|
+
data: Record<string, unknown>,
|
|
457
|
+
current: string | undefined,
|
|
458
|
+
): string {
|
|
459
|
+
const slugKey = resolveSlugField(def)!;
|
|
460
|
+
const fromData = data[slugKey];
|
|
461
|
+
|
|
462
|
+
const candidate =
|
|
463
|
+
(typeof fromData === "string" && fromData.trim()) || slugify(entryTitle(def, data));
|
|
464
|
+
|
|
465
|
+
if (!candidate) {
|
|
466
|
+
throw new ContentError("An entry needs a title or a URL before it can be saved.");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const normalised = isValidSlug(candidate) ? candidate : slugify(candidate);
|
|
470
|
+
if (!normalised) {
|
|
471
|
+
throw new ContentError(`"${candidate}" cannot be turned into a URL. Use letters and numbers.`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return normalised === current ? current : normalised;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Copies colocated media to the renamed entry; the caller deletes the old directory. */
|
|
478
|
+
async function moveMedia(
|
|
479
|
+
files: ContentFiles,
|
|
480
|
+
from: string,
|
|
481
|
+
to: string,
|
|
482
|
+
changes: FileChange[],
|
|
483
|
+
): Promise<void> {
|
|
484
|
+
for (const item of await files.list(`${from}/media`)) {
|
|
485
|
+
if (item.type !== "file") continue;
|
|
486
|
+
|
|
487
|
+
const bytes = await files.readBinary(`${from}/media/${item.name}`);
|
|
488
|
+
if (bytes === null) continue;
|
|
489
|
+
|
|
490
|
+
changes.push({ type: "write", path: `${to}/media/${item.name}`, contents: bytes });
|
|
491
|
+
}
|
|
492
|
+
}
|