@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,605 @@
|
|
|
1
|
+
/** The staging API types, the before/after comparison and the analysis of a pending change set. */
|
|
2
|
+
|
|
3
|
+
import type { Author } from "../workspace/files.js";
|
|
4
|
+
import {
|
|
5
|
+
LayoutError,
|
|
6
|
+
ORDER_FILE,
|
|
7
|
+
collectReferences,
|
|
8
|
+
collectionEntryPath,
|
|
9
|
+
createReferenceCheck,
|
|
10
|
+
entryLayout,
|
|
11
|
+
entryTitle,
|
|
12
|
+
isValidSlug,
|
|
13
|
+
readCollection,
|
|
14
|
+
readCollectionEntry,
|
|
15
|
+
readSingleton,
|
|
16
|
+
relativizeMedia,
|
|
17
|
+
resolveSlugField,
|
|
18
|
+
singletonPath,
|
|
19
|
+
validateFields,
|
|
20
|
+
type Collection,
|
|
21
|
+
type ContentChange,
|
|
22
|
+
type ContentSource,
|
|
23
|
+
type FieldIssue,
|
|
24
|
+
type FieldMap,
|
|
25
|
+
type PublishResult,
|
|
26
|
+
type Site,
|
|
27
|
+
} from "@micacms/core";
|
|
28
|
+
|
|
29
|
+
import { badRequest } from "../lib/errors.js";
|
|
30
|
+
import type { SyncStatus } from "../workspace/policy.js";
|
|
31
|
+
|
|
32
|
+
/** Where the change happened, in words an editor recognises. */
|
|
33
|
+
export type ChangeOwner =
|
|
34
|
+
| {
|
|
35
|
+
kind: "entry";
|
|
36
|
+
collection: string;
|
|
37
|
+
slug: string;
|
|
38
|
+
label: string;
|
|
39
|
+
/** The slug before a pending rename. Set on the files of both slugs. */
|
|
40
|
+
renamedFrom?: string;
|
|
41
|
+
}
|
|
42
|
+
| { kind: "singleton"; name: string; label: string }
|
|
43
|
+
| { kind: "order"; collection: string; label: string }
|
|
44
|
+
| { kind: "media"; label: string }
|
|
45
|
+
| { kind: "other"; label: string };
|
|
46
|
+
|
|
47
|
+
export interface AnnotatedChange extends ContentChange {
|
|
48
|
+
owner: ChangeOwner;
|
|
49
|
+
/**
|
|
50
|
+
* The file's revision, null for a deletion. Publish sends it back, so a file
|
|
51
|
+
* saved after the review is not published unseen.
|
|
52
|
+
*/
|
|
53
|
+
revision?: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** One thing that would make the staged result unpublishable. */
|
|
57
|
+
export interface StagingProblem {
|
|
58
|
+
/** The file the problem is about, relative to the content root. */
|
|
59
|
+
path: string;
|
|
60
|
+
label: string;
|
|
61
|
+
issues: FieldIssue[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface StagingValidation {
|
|
65
|
+
ok: boolean;
|
|
66
|
+
problems: StagingProblem[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface StagingStatus {
|
|
70
|
+
/** Whose working tree it is. The UI wording changes with it. */
|
|
71
|
+
owner: "developer" | "managed";
|
|
72
|
+
publish: { enabled: boolean; reason?: string };
|
|
73
|
+
/** The commit the pending changes sit on. */
|
|
74
|
+
head: string | null;
|
|
75
|
+
/** The branch a publication lands on, where the workspace knows it. */
|
|
76
|
+
branch: string | null;
|
|
77
|
+
/** Commits created and not yet pushed. */
|
|
78
|
+
ahead: number | null;
|
|
79
|
+
changes: AnnotatedChange[];
|
|
80
|
+
validation: StagingValidation;
|
|
81
|
+
/** Where a clone that Mica keeps stands against its remote. Null for the developer's checkout. */
|
|
82
|
+
sync: SyncStatus | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface PublishInput {
|
|
86
|
+
/** Subject line of the commit. */
|
|
87
|
+
message: string;
|
|
88
|
+
/**
|
|
89
|
+
* The reviewed paths, with the revision each had in the review. Only these
|
|
90
|
+
* are published; a file saved again since, or another file of the same entry,
|
|
91
|
+
* makes Publish refuse.
|
|
92
|
+
*/
|
|
93
|
+
paths: Record<string, string | null>;
|
|
94
|
+
author?: Author;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** What the System page shows about the repository. */
|
|
98
|
+
export interface RepositoryInfo {
|
|
99
|
+
owner: "developer" | "managed";
|
|
100
|
+
branch: string | null;
|
|
101
|
+
head: string | null;
|
|
102
|
+
/** The remote, for a clone that Mica keeps. */
|
|
103
|
+
url: string | null;
|
|
104
|
+
cloneBytes: number | null;
|
|
105
|
+
sync: SyncStatus | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface ContentStagingService {
|
|
109
|
+
status(): Promise<StagingStatus>;
|
|
110
|
+
publish(input: PublishInput): Promise<PublishResult>;
|
|
111
|
+
discard(paths: string[]): Promise<{ discarded: string[] }>;
|
|
112
|
+
/** Retries the push after `committed-pending-push`. */
|
|
113
|
+
push(): Promise<PublishResult>;
|
|
114
|
+
/**
|
|
115
|
+
* A document before and after a pending change. `origin` compares against the
|
|
116
|
+
* website's version, not the last commit.
|
|
117
|
+
*/
|
|
118
|
+
compare(target: CompareTarget, against?: "head" | "origin"): Promise<Comparison>;
|
|
119
|
+
/** Brings a clone that Mica keeps up to date now. Refused for the developer's checkout. */
|
|
120
|
+
sync(): Promise<void>;
|
|
121
|
+
resolve(group: string, choice: "mine" | "website"): Promise<void>;
|
|
122
|
+
repository(): Promise<RepositoryInfo>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** What to compare on the Publish page. */
|
|
126
|
+
export type CompareTarget =
|
|
127
|
+
/** `from` names the published slug of a renamed entry. */
|
|
128
|
+
| { kind: "entry"; collection: string; slug: string; from?: string }
|
|
129
|
+
| { kind: "singleton"; name: string }
|
|
130
|
+
| { kind: "order"; collection: string };
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Raw field data on both sides of a pending change. The editor renders the
|
|
134
|
+
* comparison from the model.
|
|
135
|
+
*/
|
|
136
|
+
export interface Comparison {
|
|
137
|
+
/** As the repository has it — null when the change creates the document. */
|
|
138
|
+
before: Record<string, unknown> | null;
|
|
139
|
+
/** As staged for publication — null when the change deletes it. */
|
|
140
|
+
after: Record<string, unknown> | null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Reads one document from `published` and from `staged`. An order target
|
|
145
|
+
* becomes `{ order: [{ slug, title }] }` on each side, in position order.
|
|
146
|
+
*/
|
|
147
|
+
export async function compareSources(
|
|
148
|
+
model: Site,
|
|
149
|
+
published: ContentSource,
|
|
150
|
+
staged: ContentSource,
|
|
151
|
+
target: CompareTarget,
|
|
152
|
+
): Promise<Comparison> {
|
|
153
|
+
// During a layout migration HEAD still has the old layout: then there is no "before".
|
|
154
|
+
const before = <T>(read: () => Promise<T>): Promise<T | null> =>
|
|
155
|
+
read().catch((error: unknown) => (error instanceof LayoutError ? null : Promise.reject(error)));
|
|
156
|
+
|
|
157
|
+
if (target.kind === "singleton") {
|
|
158
|
+
const def = model.singletons[target.name];
|
|
159
|
+
if (!def) throw badRequest(`There is no singleton called "${target.name}".`);
|
|
160
|
+
return {
|
|
161
|
+
before: await before(() => readSingleton(published, def, target.name)),
|
|
162
|
+
after: await readSingleton(staged, def, target.name),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const def = model.collections[target.collection];
|
|
167
|
+
if (!def) throw badRequest(`There is no collection called "${target.collection}".`);
|
|
168
|
+
|
|
169
|
+
if (target.kind === "entry") {
|
|
170
|
+
const from = target.from ?? target.slug;
|
|
171
|
+
for (const slug of [target.slug, from]) {
|
|
172
|
+
if (!isValidSlug(slug)) throw badRequest(`"${slug}" is not a slug.`);
|
|
173
|
+
}
|
|
174
|
+
const old = await readCollectionEntry(published, def, target.collection, from);
|
|
175
|
+
const after = await readCollectionEntry(staged, def, target.collection, target.slug);
|
|
176
|
+
if (from === target.slug) return { before: old, after };
|
|
177
|
+
|
|
178
|
+
// A rename: compare the on-disk form, where colocated media paths do not
|
|
179
|
+
// name the entry's folder.
|
|
180
|
+
return {
|
|
181
|
+
before: old && onDisk(def, target.collection, from, old),
|
|
182
|
+
after: after && onDisk(def, target.collection, target.slug, after),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ordering = async (source: ContentSource) => {
|
|
187
|
+
const entries = await readCollection(source, def, target.collection);
|
|
188
|
+
if (entries.length === 0) return null;
|
|
189
|
+
return {
|
|
190
|
+
order: entries.map((entry) => ({ slug: entry.slug, title: entryTitle(def, entry.data) })),
|
|
191
|
+
};
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
return { before: await before(() => ordering(published)), after: await ordering(staged) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Annotates a change set with owners and validates the entries it touches, in
|
|
199
|
+
* one read of the affected collections. Only changed documents are validated: a
|
|
200
|
+
* broken entry committed earlier must not block an unrelated publication.
|
|
201
|
+
*/
|
|
202
|
+
export async function analyseChanges(
|
|
203
|
+
model: Site,
|
|
204
|
+
source: ContentSource,
|
|
205
|
+
changes: ContentChange[],
|
|
206
|
+
/** The repository's side. When given, renamed entries are detected. */
|
|
207
|
+
published?: ContentSource,
|
|
208
|
+
): Promise<{ changes: AnnotatedChange[]; validation: StagingValidation }> {
|
|
209
|
+
const owners = changes.map((change) => classifyPath(model, change.path));
|
|
210
|
+
|
|
211
|
+
const collectionsToRead = new Set<string>();
|
|
212
|
+
for (const owner of owners) {
|
|
213
|
+
if (owner.kind === "entry" || owner.kind === "order") collectionsToRead.add(owner.collection);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const problems: StagingProblem[] = [];
|
|
217
|
+
const titles = new Map<string, string>();
|
|
218
|
+
const entriesByCollection = new Map<string, Map<string, Record<string, unknown>>>();
|
|
219
|
+
|
|
220
|
+
for (const name of collectionsToRead) {
|
|
221
|
+
const def = model.collections[name]!;
|
|
222
|
+
try {
|
|
223
|
+
const entries = await readCollection(source, def, name);
|
|
224
|
+
const bySlug = new Map(entries.map((entry) => [entry.slug, entry.data]));
|
|
225
|
+
entriesByCollection.set(name, bySlug);
|
|
226
|
+
for (const [slug, data] of bySlug) titles.set(`${name}/${slug}`, entryTitle(def, data));
|
|
227
|
+
} catch (error) {
|
|
228
|
+
// An unreadable collection, possibly from an untouched file, is a problem
|
|
229
|
+
// at the path it names.
|
|
230
|
+
const path = (error as { path?: string }).path ?? name;
|
|
231
|
+
problems.push({
|
|
232
|
+
path,
|
|
233
|
+
label: def.label,
|
|
234
|
+
issues: [{ path: "", message: (error as Error).message }],
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (published) {
|
|
240
|
+
await detectRenames(model, published, source, changes, owners, entriesByCollection);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const validated = new Set<string>();
|
|
244
|
+
const targets = createReferenceCheck(model, source, (name) => {
|
|
245
|
+
const entries = entriesByCollection.get(name);
|
|
246
|
+
return entries ? new Set(entries.keys()) : undefined;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
for (const owner of owners) {
|
|
250
|
+
if (owner.kind === "entry") {
|
|
251
|
+
const key = `${owner.collection}/${owner.slug}`;
|
|
252
|
+
if (validated.has(key)) continue;
|
|
253
|
+
validated.add(key);
|
|
254
|
+
|
|
255
|
+
const def = model.collections[owner.collection]!;
|
|
256
|
+
// Absent means the change set deletes this entry.
|
|
257
|
+
const data = entriesByCollection.get(owner.collection)?.get(owner.slug);
|
|
258
|
+
if (!data) continue;
|
|
259
|
+
|
|
260
|
+
const result = validateFields(def.fields, data);
|
|
261
|
+
const issues = [...(result.ok ? [] : result.issues), ...(await targets.issues(def.fields, data))];
|
|
262
|
+
if (issues.length > 0) {
|
|
263
|
+
problems.push({
|
|
264
|
+
path: entryDataPath(def, owner.collection, owner.slug),
|
|
265
|
+
label: labelEntry(def, owner.slug, titles.get(key)),
|
|
266
|
+
issues,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (owner.kind === "singleton" && !validated.has(`singleton:${owner.name}`)) {
|
|
272
|
+
validated.add(`singleton:${owner.name}`);
|
|
273
|
+
|
|
274
|
+
const def = model.singletons[owner.name]!;
|
|
275
|
+
const dataPath = singletonPath(def.fields, owner.name);
|
|
276
|
+
try {
|
|
277
|
+
const data = await readSingleton(source, def, owner.name);
|
|
278
|
+
if (data === null) continue;
|
|
279
|
+
|
|
280
|
+
const result = validateFields(def.fields, data);
|
|
281
|
+
const issues = [...(result.ok ? [] : result.issues), ...(await targets.issues(def.fields, data))];
|
|
282
|
+
if (issues.length > 0) problems.push({ path: dataPath, label: def.label, issues });
|
|
283
|
+
} catch (error) {
|
|
284
|
+
problems.push({
|
|
285
|
+
path: (error as { path?: string }).path ?? dataPath,
|
|
286
|
+
label: def.label,
|
|
287
|
+
issues: [{ path: "", message: (error as Error).message }],
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
problems.push(...(await brokenByRemoval(model, source, changes, owners, entriesByCollection, titles)));
|
|
294
|
+
|
|
295
|
+
const annotated = changes.map((change, index): AnnotatedChange => {
|
|
296
|
+
const owner = owners[index]!;
|
|
297
|
+
if (owner.kind === "entry") {
|
|
298
|
+
const def = model.collections[owner.collection]!;
|
|
299
|
+
return {
|
|
300
|
+
...change,
|
|
301
|
+
owner: { ...owner, label: labelEntry(def, owner.slug, titles.get(`${owner.collection}/${owner.slug}`)) },
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
return { ...change, owner };
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
changes: annotated,
|
|
309
|
+
validation: { ok: problems.length === 0, problems },
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Which piece of content a file belongs to, read from the model's path layout. */
|
|
314
|
+
export function classifyPath(model: Site, path: string): ChangeOwner {
|
|
315
|
+
const segments = path.split("/");
|
|
316
|
+
const head = segments[0] ?? "";
|
|
317
|
+
|
|
318
|
+
const collection = model.collections[head];
|
|
319
|
+
if (collection && segments.length > 1) {
|
|
320
|
+
if (segments.length === 2 && segments[1] === ORDER_FILE) {
|
|
321
|
+
return { kind: "order", collection: head, label: `${collection.label}: order` };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (entryLayout(collection.fields) === "directory") {
|
|
325
|
+
const slug = segments[1]!;
|
|
326
|
+
if (segments.length >= 3 && isValidSlug(slug)) {
|
|
327
|
+
return { kind: "entry", collection: head, slug, label: slug };
|
|
328
|
+
}
|
|
329
|
+
} else if (segments.length === 2 && segments[1]!.endsWith(".json")) {
|
|
330
|
+
const slug = segments[1]!.slice(0, -".json".length);
|
|
331
|
+
if (isValidSlug(slug)) return { kind: "entry", collection: head, slug, label: slug };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const singletonDir = model.singletons[head];
|
|
336
|
+
if (singletonDir && segments.length > 1) {
|
|
337
|
+
return { kind: "singleton", name: head, label: singletonDir.label };
|
|
338
|
+
}
|
|
339
|
+
if (head.endsWith(".json")) {
|
|
340
|
+
const name = head.slice(0, -".json".length);
|
|
341
|
+
const singleton = model.singletons[name];
|
|
342
|
+
if (singleton && segments.length === 1) {
|
|
343
|
+
return { kind: "singleton", name, label: singleton.label };
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (head === "media") return { kind: "media", label: "Shared media" };
|
|
348
|
+
|
|
349
|
+
return { kind: "other", label: path };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Content share that two versions of an entry need to count as one moved entry, as in Git. */
|
|
353
|
+
const RENAME_SIMILARITY = 0.5;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Marks renamed entries in place in `owners`. An entry has no identity but its
|
|
357
|
+
* slug, so deleted and created entries are paired by content, like Git's rename
|
|
358
|
+
* detection. An order file whose only change is the renamed slug joins the
|
|
359
|
+
* entry, so a discard cannot leave the order naming a slug that does not exist.
|
|
360
|
+
*/
|
|
361
|
+
async function detectRenames(
|
|
362
|
+
model: Site,
|
|
363
|
+
published: ContentSource,
|
|
364
|
+
source: ContentSource,
|
|
365
|
+
changes: ContentChange[],
|
|
366
|
+
owners: ChangeOwner[],
|
|
367
|
+
staged: Map<string, Map<string, Record<string, unknown>>>,
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
const kinds = new Map<string, Map<string, Set<ContentChange["kind"]>>>();
|
|
370
|
+
owners.forEach((owner, index) => {
|
|
371
|
+
if (owner.kind !== "entry") return;
|
|
372
|
+
const bySlug = kinds.get(owner.collection) ?? new Map<string, Set<ContentChange["kind"]>>();
|
|
373
|
+
kinds.set(owner.collection, bySlug);
|
|
374
|
+
const set = bySlug.get(owner.slug) ?? new Set<ContentChange["kind"]>();
|
|
375
|
+
bySlug.set(owner.slug, set);
|
|
376
|
+
set.add(changes[index]!.kind);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
for (const [name, bySlug] of kinds) {
|
|
380
|
+
const after = staged.get(name);
|
|
381
|
+
if (!after) continue;
|
|
382
|
+
|
|
383
|
+
const only = (kind: ContentChange["kind"]) =>
|
|
384
|
+
[...bySlug].filter(([, set]) => set.size === 1 && set.has(kind)).map(([slug]) => slug);
|
|
385
|
+
const deleted = only("delete").filter((slug) => !after.has(slug));
|
|
386
|
+
const created = only("create").filter((slug) => after.has(slug));
|
|
387
|
+
if (deleted.length === 0 || created.length === 0) continue;
|
|
388
|
+
|
|
389
|
+
const def = model.collections[name]!;
|
|
390
|
+
let before: Map<string, Record<string, unknown>>;
|
|
391
|
+
try {
|
|
392
|
+
const entries = await readCollection(published, def, name);
|
|
393
|
+
before = new Map(entries.map((entry) => [entry.slug, entry.data]));
|
|
394
|
+
} catch {
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const candidates: Array<{ from: string; to: string; score: number }> = [];
|
|
399
|
+
for (const from of deleted) {
|
|
400
|
+
const old = before.get(from);
|
|
401
|
+
if (!old) continue;
|
|
402
|
+
for (const to of created) {
|
|
403
|
+
if (before.has(to)) continue;
|
|
404
|
+
const score = similarity(def, onDisk(def, name, from, old), onDisk(def, name, to, after.get(to)!));
|
|
405
|
+
if (score >= RENAME_SIMILARITY) candidates.push({ from, to, score });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const renamedTo = new Map<string, string>();
|
|
410
|
+
const paired = new Set<string>();
|
|
411
|
+
for (const { from, to } of candidates.sort((a, b) => b.score - a.score)) {
|
|
412
|
+
if (renamedTo.has(from) || paired.has(to)) continue;
|
|
413
|
+
renamedTo.set(from, to);
|
|
414
|
+
paired.add(to);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (renamedTo.size === 0) continue;
|
|
418
|
+
|
|
419
|
+
const renamedFrom = new Map([...renamedTo].map(([from, to]) => [to, from]));
|
|
420
|
+
const orderMovesOnlySlugs = await onlyRenamedInOrder(published, source, name, renamedTo);
|
|
421
|
+
const [firstFrom, firstTo] = [...renamedTo][0]!;
|
|
422
|
+
|
|
423
|
+
owners.forEach((owner, index) => {
|
|
424
|
+
if (owner.kind === "order" && owner.collection === name && orderMovesOnlySlugs) {
|
|
425
|
+
owners[index] = {
|
|
426
|
+
kind: "entry",
|
|
427
|
+
collection: name,
|
|
428
|
+
slug: firstTo,
|
|
429
|
+
label: firstTo,
|
|
430
|
+
renamedFrom: firstFrom,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (owner.kind !== "entry" || owner.collection !== name) return;
|
|
434
|
+
const to = renamedTo.get(owner.slug);
|
|
435
|
+
if (to) owners[index] = { ...owner, slug: to, label: to, renamedFrom: owner.slug };
|
|
436
|
+
const from = renamedFrom.get(owner.slug);
|
|
437
|
+
if (from) owners[index] = { ...owner, renamedFrom: from };
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** True when a collection's staged order is the published one with the renamed slugs replaced. */
|
|
443
|
+
async function onlyRenamedInOrder(
|
|
444
|
+
published: ContentSource,
|
|
445
|
+
staged: ContentSource,
|
|
446
|
+
name: string,
|
|
447
|
+
renamedTo: Map<string, string>,
|
|
448
|
+
): Promise<boolean> {
|
|
449
|
+
const path = `${name}/${ORDER_FILE}`;
|
|
450
|
+
const parse = (raw: string | null): unknown => {
|
|
451
|
+
try {
|
|
452
|
+
return raw === null ? null : JSON.parse(raw);
|
|
453
|
+
} catch {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const before = parse(await published.read(path));
|
|
459
|
+
const after = parse(await staged.read(path));
|
|
460
|
+
if (!Array.isArray(before) || !Array.isArray(after)) return false;
|
|
461
|
+
|
|
462
|
+
const expected = before.map((slug: unknown) =>
|
|
463
|
+
typeof slug === "string" ? (renamedTo.get(slug) ?? slug) : slug,
|
|
464
|
+
);
|
|
465
|
+
return canonical(expected) === canonical(after);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** The share of non-empty fields, the slug aside, that two versions of an entry agree on. */
|
|
469
|
+
function similarity(def: Collection, a: Record<string, unknown>, b: Record<string, unknown>): number {
|
|
470
|
+
const slugKey = resolveSlugField(def);
|
|
471
|
+
let compared = 0;
|
|
472
|
+
let equal = 0;
|
|
473
|
+
for (const key of Object.keys(def.fields)) {
|
|
474
|
+
if (key === slugKey || (isEmpty(a[key]) && isEmpty(b[key]))) continue;
|
|
475
|
+
compared += 1;
|
|
476
|
+
if (canonical(a[key]) === canonical(b[key])) equal += 1;
|
|
477
|
+
}
|
|
478
|
+
return compared === 0 ? 0 : equal / compared;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Entry data with media paths as the files store them: relative to the entry,
|
|
483
|
+
* where it has a folder.
|
|
484
|
+
*/
|
|
485
|
+
function onDisk(
|
|
486
|
+
def: Collection,
|
|
487
|
+
collection: string,
|
|
488
|
+
slug: string,
|
|
489
|
+
data: Record<string, unknown>,
|
|
490
|
+
): Record<string, unknown> {
|
|
491
|
+
return entryLayout(def.fields) === "directory"
|
|
492
|
+
? relativizeMedia(def.fields, data, collectionEntryPath(def.fields, collection, slug))
|
|
493
|
+
: data;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function isEmpty(value: unknown): boolean {
|
|
497
|
+
return value === undefined || value === null || value === "" || (Array.isArray(value) && value.length === 0);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** JSON with sorted keys, so two equal objects compare equal. */
|
|
501
|
+
function canonical(value: unknown): string {
|
|
502
|
+
return JSON.stringify(value ?? null, (_key, entry: unknown) => {
|
|
503
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return entry;
|
|
504
|
+
const record = entry as Record<string, unknown>;
|
|
505
|
+
return Object.fromEntries(Object.keys(record).sort().map((key) => [key, record[key]]));
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Entries and files that the change set removes while another entry still
|
|
511
|
+
* points at them. Publishing them would break a link or an image on the site.
|
|
512
|
+
*/
|
|
513
|
+
async function brokenByRemoval(
|
|
514
|
+
model: Site,
|
|
515
|
+
source: ContentSource,
|
|
516
|
+
changes: ContentChange[],
|
|
517
|
+
owners: ChangeOwner[],
|
|
518
|
+
entriesByCollection: Map<string, Map<string, Record<string, unknown>>>,
|
|
519
|
+
titles: Map<string, string>,
|
|
520
|
+
): Promise<StagingProblem[]> {
|
|
521
|
+
const removedEntries = new Map<string, { collection: string; slug: string; renamedTo?: string }>();
|
|
522
|
+
const removedFiles = new Set<string>();
|
|
523
|
+
changes.forEach((change, index) => {
|
|
524
|
+
const owner = owners[index]!;
|
|
525
|
+
if (change.kind !== "delete") return;
|
|
526
|
+
if (owner.kind === "entry") {
|
|
527
|
+
// A URL change removes the old slug: links to it break as with a delete.
|
|
528
|
+
const gone = owner.renamedFrom ?? owner.slug;
|
|
529
|
+
if (!entriesByCollection.get(owner.collection)?.has(gone)) {
|
|
530
|
+
const renamedTo = owner.renamedFrom ? owner.slug : undefined;
|
|
531
|
+
removedEntries.set(`${owner.collection}/${gone}`, { collection: owner.collection, slug: gone, ...(renamedTo ? { renamedTo } : {}) });
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// Only a media file can be a target; a cleared rich-text file or an order file cannot.
|
|
536
|
+
if (!change.path.endsWith(".json") && !change.path.endsWith(".mdoc")) removedFiles.add(change.path);
|
|
537
|
+
});
|
|
538
|
+
if (removedEntries.size === 0 && removedFiles.size === 0) return [];
|
|
539
|
+
|
|
540
|
+
const linkedFrom = new Map<string, string[]>();
|
|
541
|
+
const note = (target: string, from: string) => linkedFrom.set(target, [...(linkedFrom.get(target) ?? []), from]);
|
|
542
|
+
|
|
543
|
+
const scan = (label: string, fields: FieldMap, data: Record<string, unknown>) => {
|
|
544
|
+
for (const reference of collectReferences(fields, data)) {
|
|
545
|
+
const target = reference.kind === "relation" ? `${reference.collection}/${reference.slug}` : reference.path;
|
|
546
|
+
if (removedEntries.has(target) || removedFiles.has(target)) note(target, label);
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
for (const [name, def] of Object.entries(model.collections)) {
|
|
551
|
+
let entries = entriesByCollection.get(name);
|
|
552
|
+
if (!entries) {
|
|
553
|
+
try {
|
|
554
|
+
entries = new Map((await readCollection(source, def, name)).map((entry) => [entry.slug, entry.data]));
|
|
555
|
+
} catch {
|
|
556
|
+
continue; // An unreadable collection is reported where it is changed.
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
for (const [slug, data] of entries) scan(labelEntry(def, slug, entryTitle(def, data)), def.fields, data);
|
|
560
|
+
}
|
|
561
|
+
for (const [name, def] of Object.entries(model.singletons)) {
|
|
562
|
+
const data = await readSingleton(source, def, name).catch(() => null);
|
|
563
|
+
if (data) scan(def.label, def.fields, data);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const problems: StagingProblem[] = [];
|
|
567
|
+
for (const [target, from] of linkedFrom) {
|
|
568
|
+
const entry = removedEntries.get(target);
|
|
569
|
+
const def = entry ? model.collections[entry.collection]! : null;
|
|
570
|
+
const message = entry?.renamedTo
|
|
571
|
+
? `The URL changes from "${entry.slug}" to "${entry.renamedTo}", and ${from.join(", ")} still link to "${entry.slug}". Change those links first, or publish them together.`
|
|
572
|
+
: `Still linked from ${from.join(", ")}. Remove those links first, or publish them together.`;
|
|
573
|
+
problems.push({
|
|
574
|
+
path: entry && def ? entryDataPath(def, entry.collection, entry.slug) : target,
|
|
575
|
+
label: entry && def ? labelEntry(def, entry.slug, titles.get(target)) : target,
|
|
576
|
+
issues: [{ path: "", message }],
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return problems;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function labelEntry(def: Collection, slug: string, title: string | undefined): string {
|
|
583
|
+
return `${def.labelSingular ?? def.label}: ${title ?? slug}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function entryDataPath(def: Collection, collection: string, slug: string): string {
|
|
587
|
+
const base = collectionEntryPath(def.fields, collection, slug);
|
|
588
|
+
return entryLayout(def.fields) === "directory" ? `${base}/index.json` : base;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export function describeProblems(problems: StagingProblem[]): string {
|
|
592
|
+
const names = problems.map((problem) => problem.label);
|
|
593
|
+
const listed = names.slice(0, 3).join(", ") + (names.length > 3 ? ", …" : "");
|
|
594
|
+
return `The pending changes cannot be published until they are valid: ${listed}.`;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Field issues with the file path folded in, for the flat API envelope. */
|
|
598
|
+
export function flattenProblems(problems: StagingProblem[]): FieldIssue[] {
|
|
599
|
+
return problems.flatMap((problem) =>
|
|
600
|
+
problem.issues.map((issue) => ({
|
|
601
|
+
path: issue.path ? `${problem.path}: ${issue.path}` : problem.path,
|
|
602
|
+
message: issue.message,
|
|
603
|
+
})),
|
|
604
|
+
);
|
|
605
|
+
}
|