@micacms/runtime 0.6.0-rc.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +5 -0
- package/dist/app.js.map +1 -1
- package/dist/context.d.ts +17 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +62 -18
- package/dist/context.js.map +1 -1
- package/dist/http.d.ts +2 -0
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +5 -0
- package/dist/http.js.map +1 -1
- package/dist/lib/file-type.d.ts +2 -0
- package/dist/lib/file-type.d.ts.map +1 -1
- package/dist/lib/file-type.js +3 -1
- package/dist/lib/file-type.js.map +1 -1
- package/dist/routes/admin-content.d.ts +1 -1
- package/dist/routes/admin-content.d.ts.map +1 -1
- package/dist/routes/admin-content.js +15 -0
- package/dist/routes/admin-content.js.map +1 -1
- package/dist/routes/admin-media.d.ts +5 -3
- package/dist/routes/admin-media.d.ts.map +1 -1
- package/dist/routes/admin-media.js +72 -2
- package/dist/routes/admin-media.js.map +1 -1
- package/dist/routes/admin-staging.d.ts.map +1 -1
- package/dist/routes/admin-staging.js +18 -7
- package/dist/routes/admin-staging.js.map +1 -1
- package/dist/routes/admin-system.d.ts.map +1 -1
- package/dist/routes/admin-system.js +4 -0
- package/dist/routes/admin-system.js.map +1 -1
- package/dist/services/changes.d.ts +19 -4
- package/dist/services/changes.d.ts.map +1 -1
- package/dist/services/changes.js +60 -1
- package/dist/services/changes.js.map +1 -1
- package/dist/services/content-store.d.ts +27 -6
- package/dist/services/content-store.d.ts.map +1 -1
- package/dist/services/content-store.js +231 -18
- package/dist/services/content-store.js.map +1 -1
- package/dist/services/media.d.ts.map +1 -1
- package/dist/services/media.js +4 -1
- package/dist/services/media.js.map +1 -1
- package/dist/services/staging.d.ts.map +1 -1
- package/dist/services/staging.js +26 -14
- 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 +19 -4
- package/dist/workspace/developer.js.map +1 -1
- package/dist/workspace/policy.d.ts +1 -1
- package/dist/workspace/policy.d.ts.map +1 -1
- package/dist/workspace/policy.js.map +1 -1
- package/dist/workspace/tree.d.ts +15 -2
- package/dist/workspace/tree.d.ts.map +1 -1
- package/dist/workspace/tree.js +29 -6
- package/dist/workspace/tree.js.map +1 -1
- package/package.json +2 -2
- package/src/app.ts +5 -0
- package/src/context.ts +69 -20
- package/src/http.ts +6 -0
- package/src/lib/file-type.ts +4 -1
- package/src/routes/admin-content.ts +16 -2
- package/src/routes/admin-media.ts +74 -3
- package/src/routes/admin-staging.ts +22 -7
- package/src/routes/admin-system.ts +4 -0
- package/src/services/changes.ts +84 -5
- package/src/services/content-store.ts +243 -25
- package/src/services/media.ts +7 -1
- package/src/services/staging.ts +30 -14
- package/src/workspace/developer.ts +22 -5
- package/src/workspace/policy.ts +1 -1
- package/src/workspace/tree.ts +35 -6
|
@@ -1,14 +1,45 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Admin media routes. Media is files in the content repository
|
|
3
|
-
*
|
|
2
|
+
* Admin media routes. Media is files in the content repository. An upload is
|
|
3
|
+
* written on its own, before the entry save, because the editor needs its path
|
|
4
|
+
* first. A delete is a pending change like any other, and is refused while
|
|
5
|
+
* something uses the file.
|
|
4
6
|
*/
|
|
5
7
|
|
|
8
|
+
import { contentPath, ContentPathError, MEDIA_DIR } from "@micacms/core";
|
|
6
9
|
import { Hono } from "hono";
|
|
10
|
+
import { z } from "zod";
|
|
7
11
|
|
|
8
12
|
import { ApiError, badRequest, notFound } from "../lib/errors.js";
|
|
9
13
|
import { isImageType } from "../lib/file-type.js";
|
|
10
14
|
import { MAX_FILES_PER_UPLOAD } from "../middleware/index.js";
|
|
11
|
-
import type
|
|
15
|
+
import { editorOf, type Env } from "../http.js";
|
|
16
|
+
import type { Referrer } from "../services/content-store.js";
|
|
17
|
+
|
|
18
|
+
const deleteSchema = z.object({ paths: z.array(z.string().min(1)).min(1).max(500) });
|
|
19
|
+
|
|
20
|
+
/** Content paths, or a 400 for one that leaves the content root. */
|
|
21
|
+
function cleanPaths(paths: string[]): string[] {
|
|
22
|
+
try {
|
|
23
|
+
return [...new Set(paths.map((path) => contentPath(path)))];
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error instanceof ContentPathError) throw badRequest(error.message);
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A file in a `media/` folder, as an upload writes it. Nothing else is deleted here. */
|
|
31
|
+
function mediaFilesOnly(paths: string[]): string[] {
|
|
32
|
+
for (const path of paths) {
|
|
33
|
+
if (path.split("/").at(-2) !== MEDIA_DIR) throw badRequest(`"${path}" is not a media file.`);
|
|
34
|
+
}
|
|
35
|
+
return paths;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function describeUses(path: string, referrers: Referrer[]): string {
|
|
39
|
+
const names = referrers.map((item) => item.title);
|
|
40
|
+
const listed = names.slice(0, 3).join(", ") + (names.length > 3 ? ` and ${names.length - 3} more` : "");
|
|
41
|
+
return `"${path.split("/").at(-1)}" is still used by ${listed}. Remove it there first.`;
|
|
42
|
+
}
|
|
12
43
|
|
|
13
44
|
export function adminMediaRoutes(): Hono<Env> {
|
|
14
45
|
const app = new Hono<Env>();
|
|
@@ -18,6 +49,39 @@ export function adminMediaRoutes(): Hono<Env> {
|
|
|
18
49
|
return c.json({ items: await media.list() });
|
|
19
50
|
});
|
|
20
51
|
|
|
52
|
+
/** Where files are used, before a delete: `?path=a&path=b`. */
|
|
53
|
+
app.get("/usage", async (c) => {
|
|
54
|
+
const { content } = c.get("ctx").services;
|
|
55
|
+
const paths = cleanPaths(c.req.queries("path") ?? []);
|
|
56
|
+
if (paths.length === 0) throw badRequest("Name at least one file.");
|
|
57
|
+
return c.json(await content.mediaUses(paths));
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
app.post("/delete", async (c) => {
|
|
61
|
+
const ctx = c.get("ctx");
|
|
62
|
+
const parsed = deleteSchema.safeParse(await c.req.json().catch(() => null));
|
|
63
|
+
if (!parsed.success) throw badRequest("Expected a list of file paths.");
|
|
64
|
+
const paths = mediaFilesOnly(cleanPaths(parsed.data.paths));
|
|
65
|
+
|
|
66
|
+
for (const path of paths) {
|
|
67
|
+
const owner = path.split("/")[0] ?? "";
|
|
68
|
+
const readOnly = ctx.site.readOnly.get(`collections.${owner}`) ?? ctx.site.readOnly.get(`singletons.${owner}`);
|
|
69
|
+
if (readOnly) throw new ApiError("conflict", readOnly);
|
|
70
|
+
if ((await ctx.services.media.read(path)) === null) throw notFound(`There is no file "${path}".`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { uses, unreadable } = await ctx.services.content.mediaUses(paths);
|
|
74
|
+
if (unreadable.length > 0) {
|
|
75
|
+
throw new ApiError("conflict", `Mica cannot read ${unreadable.join(", ")}, so it cannot tell whether these files are used.`);
|
|
76
|
+
}
|
|
77
|
+
const used = paths.filter((path) => (uses[path] ?? []).length > 0);
|
|
78
|
+
if (used.length > 0) throw new ApiError("conflict", used.map((path) => describeUses(path, uses[path]!)).join(" "));
|
|
79
|
+
|
|
80
|
+
await ctx.site.contentRepository.apply({ changes: paths.map((path) => ({ type: "delete" as const, path })) });
|
|
81
|
+
ctx.logger.info("Deleted media", { paths, editor: editorOf(c) });
|
|
82
|
+
return c.json({ deleted: paths });
|
|
83
|
+
});
|
|
84
|
+
|
|
21
85
|
app.post("/", async (c) => {
|
|
22
86
|
const ctx = c.get("ctx");
|
|
23
87
|
const form = await c.req.formData().catch(() => {
|
|
@@ -41,6 +105,13 @@ export function adminMediaRoutes(): Hono<Env> {
|
|
|
41
105
|
const owner = (directory ?? "").replace(/^[/\\]+/, "").split(/[/\\]/)[0] ?? "";
|
|
42
106
|
const readOnly = ctx.site.readOnly.get(`collections.${owner}`) ?? ctx.site.readOnly.get(`singletons.${owner}`);
|
|
43
107
|
if (readOnly) throw new ApiError("conflict", readOnly);
|
|
108
|
+
// A new entry uploads into the folder of the URL in its form; never into a saved entry's.
|
|
109
|
+
const folder = /^([^/]+)\/([^/]+)\/media$/.exec((directory ?? "").replace(/^\/+/, ""));
|
|
110
|
+
if (form.get("newEntry") === "true" && folder && ctx.site.model.collections[folder[1]!]) {
|
|
111
|
+
if ((await ctx.services.content.revisionOf(folder[1]!, folder[2]!)) !== null) {
|
|
112
|
+
throw new ApiError("conflict", `Another entry already has the URL “${folder[2]}”. Change the URL, then upload.`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
44
115
|
|
|
45
116
|
const prepared = await Promise.all(
|
|
46
117
|
files.map((file) =>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { Hono } from "hono";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
|
|
11
|
-
import { actorOf, type Env } from "../http.js";
|
|
11
|
+
import { actorOf, editorOf, type Env } from "../http.js";
|
|
12
12
|
import { ApiError, badRequest } from "../lib/errors.js";
|
|
13
13
|
import { ConflictError } from "../workspace/files.js";
|
|
14
14
|
|
|
@@ -52,12 +52,17 @@ async function handled<T>(run: () => Promise<T>): Promise<T> {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
/** Which model is in use, so that the editor can tell a stale form, and why a newer one is not. */
|
|
56
|
+
function modelState(ctx: Env["Variables"]["ctx"]) {
|
|
57
|
+
return { revision: ctx.site.schemaRevision, warning: ctx.site.modelWarning?.message ?? null };
|
|
58
|
+
}
|
|
59
|
+
|
|
55
60
|
export function adminStagingRoutes(): Hono<Env> {
|
|
56
61
|
const app = new Hono<Env>();
|
|
57
62
|
|
|
58
63
|
app.get("/changes", async (c) => {
|
|
59
|
-
const
|
|
60
|
-
return c.json(await staging.status());
|
|
64
|
+
const ctx = c.get("ctx");
|
|
65
|
+
return c.json({ ...(await ctx.services.staging.status()), model: modelState(ctx) });
|
|
61
66
|
});
|
|
62
67
|
|
|
63
68
|
// Raw data before and after one pending change; the editor renders the comparison.
|
|
@@ -77,6 +82,15 @@ export function adminStagingRoutes(): Hono<Env> {
|
|
|
77
82
|
});
|
|
78
83
|
|
|
79
84
|
app.post("/publish", async (c) => {
|
|
85
|
+
// Publish validates with the newest model, so a model change never needs a restart first.
|
|
86
|
+
await c.get("ctx").refreshModel({ force: true });
|
|
87
|
+
const warning = c.get("ctx").site.modelWarning;
|
|
88
|
+
if (warning) {
|
|
89
|
+
throw new ApiError(
|
|
90
|
+
"conflict",
|
|
91
|
+
`The website's new content model cannot be read, so Mica cannot publish yet. Tell the developer: ${warning.message}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
80
94
|
const { staging } = c.get("ctx").services;
|
|
81
95
|
const parsed = publishSchema.safeParse(await jsonBody(c));
|
|
82
96
|
if (!parsed.success) throw badRequest("The publish payload is malformed.");
|
|
@@ -87,6 +101,7 @@ export function adminStagingRoutes(): Hono<Env> {
|
|
|
87
101
|
message: parsed.data.message,
|
|
88
102
|
paths: parsed.data.paths,
|
|
89
103
|
author: actor ? { name: actor.name || actor.email, email: actor.email } : undefined,
|
|
104
|
+
editor: editorOf(c),
|
|
90
105
|
}),
|
|
91
106
|
);
|
|
92
107
|
|
|
@@ -102,7 +117,7 @@ export function adminStagingRoutes(): Hono<Env> {
|
|
|
102
117
|
app.post("/sync", async (c) => {
|
|
103
118
|
const { staging } = c.get("ctx").services;
|
|
104
119
|
await staging.sync();
|
|
105
|
-
return c.json(await staging.status());
|
|
120
|
+
return c.json({ ...(await staging.status()), model: modelState(c.get("ctx")) });
|
|
106
121
|
});
|
|
107
122
|
|
|
108
123
|
app.post("/changes/resolve", async (c) => {
|
|
@@ -110,8 +125,8 @@ export function adminStagingRoutes(): Hono<Env> {
|
|
|
110
125
|
const parsed = resolveSchema.safeParse(await jsonBody(c));
|
|
111
126
|
if (!parsed.success) throw badRequest("Expected a group and a choice: mine or website.");
|
|
112
127
|
|
|
113
|
-
await handled(() => staging.resolve(parsed.data.group, parsed.data.choice));
|
|
114
|
-
return c.json(await staging.status());
|
|
128
|
+
await handled(() => staging.resolve(parsed.data.group, parsed.data.choice, editorOf(c)));
|
|
129
|
+
return c.json({ ...(await staging.status()), model: modelState(c.get("ctx")) });
|
|
115
130
|
});
|
|
116
131
|
|
|
117
132
|
app.post("/changes/discard", async (c) => {
|
|
@@ -119,7 +134,7 @@ export function adminStagingRoutes(): Hono<Env> {
|
|
|
119
134
|
const parsed = discardSchema.safeParse(await jsonBody(c));
|
|
120
135
|
if (!parsed.success) throw badRequest("Expected a list of change paths.");
|
|
121
136
|
|
|
122
|
-
return c.json(await handled(() => staging.discard(parsed.data.paths)));
|
|
137
|
+
return c.json(await handled(() => staging.discard(parsed.data.paths, editorOf(c))));
|
|
123
138
|
});
|
|
124
139
|
|
|
125
140
|
return app;
|
|
@@ -41,6 +41,8 @@ export function adminSystemRoutes(): Hono<Env> {
|
|
|
41
41
|
fields: def.fields,
|
|
42
42
|
readOnly: site.readOnly.get(`singletons.${def.name}`) ?? null,
|
|
43
43
|
})),
|
|
44
|
+
/** Which model this is, so that a page can tell that a newer one is in use. */
|
|
45
|
+
revision: site.schemaRevision,
|
|
44
46
|
});
|
|
45
47
|
});
|
|
46
48
|
|
|
@@ -113,6 +115,8 @@ export function adminSystemRoutes(): Hono<Env> {
|
|
|
113
115
|
entries: counts[item.name] ?? 0,
|
|
114
116
|
})),
|
|
115
117
|
singletons: stats.singletons,
|
|
118
|
+
/** Why a newer model in the working tree is not in use, or null. */
|
|
119
|
+
warning: ctx.site.modelWarning?.message ?? null,
|
|
116
120
|
},
|
|
117
121
|
repository: {
|
|
118
122
|
origin: ctx.site.contentRepository.origin,
|
package/src/services/changes.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
|
|
29
29
|
import { badRequest } from "../lib/errors.js";
|
|
30
30
|
import type { SyncStatus } from "../workspace/policy.js";
|
|
31
|
+
import type { UnpushedCommits } from "../workspace/tree.js";
|
|
31
32
|
|
|
32
33
|
/** Where the change happened, in words an editor recognises. */
|
|
33
34
|
export type ChangeOwner =
|
|
@@ -51,6 +52,13 @@ export interface AnnotatedChange extends ContentChange {
|
|
|
51
52
|
* saved after the review is not published unseen.
|
|
52
53
|
*/
|
|
53
54
|
revision?: string | null;
|
|
55
|
+
/** When the file was last saved, for "changed 2 hours ago". Null for a deletion. */
|
|
56
|
+
savedAt?: string | null;
|
|
57
|
+
/**
|
|
58
|
+
* New pending files that this document uses (a linked entry, an image), so
|
|
59
|
+
* that it cannot be published without them. On the document's data file.
|
|
60
|
+
*/
|
|
61
|
+
needs?: string[];
|
|
54
62
|
}
|
|
55
63
|
|
|
56
64
|
/** One thing that would make the staged result unpublishable. */
|
|
@@ -74,8 +82,12 @@ export interface StagingStatus {
|
|
|
74
82
|
head: string | null;
|
|
75
83
|
/** The branch a publication lands on, where the workspace knows it. */
|
|
76
84
|
branch: string | null;
|
|
77
|
-
/**
|
|
78
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Commits that no remote has, in the developer's checkout: Mica's waits for a
|
|
87
|
+
* retry, another one blocks Publish. Null for a clone that Mica keeps, or
|
|
88
|
+
* without a remote.
|
|
89
|
+
*/
|
|
90
|
+
unpushed: UnpushedCommits | null;
|
|
79
91
|
changes: AnnotatedChange[];
|
|
80
92
|
validation: StagingValidation;
|
|
81
93
|
/** Where a clone that Mica keeps stands against its remote. Null for the developer's checkout. */
|
|
@@ -92,6 +104,8 @@ export interface PublishInput {
|
|
|
92
104
|
*/
|
|
93
105
|
paths: Record<string, string | null>;
|
|
94
106
|
author?: Author;
|
|
107
|
+
/** Who published, for the log. */
|
|
108
|
+
editor?: string;
|
|
95
109
|
}
|
|
96
110
|
|
|
97
111
|
/** What the System page shows about the repository. */
|
|
@@ -108,7 +122,8 @@ export interface RepositoryInfo {
|
|
|
108
122
|
export interface ContentStagingService {
|
|
109
123
|
status(): Promise<StagingStatus>;
|
|
110
124
|
publish(input: PublishInput): Promise<PublishResult>;
|
|
111
|
-
|
|
125
|
+
/** `editor` names who discarded, for the log. */
|
|
126
|
+
discard(paths: string[], editor?: string): Promise<{ discarded: string[] }>;
|
|
112
127
|
/** Retries the push after `committed-pending-push`. */
|
|
113
128
|
push(): Promise<PublishResult>;
|
|
114
129
|
/**
|
|
@@ -118,7 +133,7 @@ export interface ContentStagingService {
|
|
|
118
133
|
compare(target: CompareTarget, against?: "head" | "origin"): Promise<Comparison>;
|
|
119
134
|
/** Brings a clone that Mica keeps up to date now. Refused for the developer's checkout. */
|
|
120
135
|
sync(): Promise<void>;
|
|
121
|
-
resolve(group: string, choice: "mine" | "website"): Promise<void>;
|
|
136
|
+
resolve(group: string, choice: "mine" | "website", editor?: string): Promise<void>;
|
|
122
137
|
repository(): Promise<RepositoryInfo>;
|
|
123
138
|
}
|
|
124
139
|
|
|
@@ -240,6 +255,21 @@ export async function analyseChanges(
|
|
|
240
255
|
await detectRenames(model, published, source, changes, owners, entriesByCollection);
|
|
241
256
|
}
|
|
242
257
|
|
|
258
|
+
const created = new Set(changes.filter((change) => change.kind === "create").map((change) => change.path));
|
|
259
|
+
const needsByPath = new Map<string, string[]>();
|
|
260
|
+
/** How to read each changed document as the website has it, by its data file. */
|
|
261
|
+
const publishedOf = new Map<string, () => Promise<Record<string, unknown> | null>>();
|
|
262
|
+
const addNeeds = (dataPath: string, fields: Collection["fields"], data: Record<string, unknown>): void => {
|
|
263
|
+
const needed = new Set<string>();
|
|
264
|
+
for (const reference of collectReferences(fields, data)) {
|
|
265
|
+
const def = reference.kind === "relation" ? model.collections[reference.collection] : undefined;
|
|
266
|
+
const path =
|
|
267
|
+
reference.kind === "media" ? reference.path : def ? entryDataPath(def, reference.collection, reference.slug) : null;
|
|
268
|
+
if (path && path !== dataPath && created.has(path)) needed.add(path);
|
|
269
|
+
}
|
|
270
|
+
if (needed.size > 0) needsByPath.set(dataPath, [...needed]);
|
|
271
|
+
};
|
|
272
|
+
|
|
243
273
|
const validated = new Set<string>();
|
|
244
274
|
const targets = createReferenceCheck(model, source, (name) => {
|
|
245
275
|
const entries = entriesByCollection.get(name);
|
|
@@ -257,6 +287,11 @@ export async function analyseChanges(
|
|
|
257
287
|
const data = entriesByCollection.get(owner.collection)?.get(owner.slug);
|
|
258
288
|
if (!data) continue;
|
|
259
289
|
|
|
290
|
+
addNeeds(entryDataPath(def, owner.collection, owner.slug), def.fields, data);
|
|
291
|
+
if (published) {
|
|
292
|
+
const { collection, slug } = owner;
|
|
293
|
+
publishedOf.set(entryDataPath(def, collection, slug), () => readCollectionEntry(published, def, collection, slug).catch(() => null));
|
|
294
|
+
}
|
|
260
295
|
const result = validateFields(def.fields, data);
|
|
261
296
|
const issues = [...(result.ok ? [] : result.issues), ...(await targets.issues(def.fields, data))];
|
|
262
297
|
if (issues.length > 0) {
|
|
@@ -277,6 +312,13 @@ export async function analyseChanges(
|
|
|
277
312
|
const data = await readSingleton(source, def, owner.name);
|
|
278
313
|
if (data === null) continue;
|
|
279
314
|
|
|
315
|
+
// The data file, not the folder: a folder-layout singleton's change is `<name>/index.json`.
|
|
316
|
+
const dataFile = entryLayout(def.fields) === "directory" ? `${dataPath}/index.json` : dataPath;
|
|
317
|
+
addNeeds(dataFile, def.fields, data);
|
|
318
|
+
if (published) {
|
|
319
|
+
const name = owner.name;
|
|
320
|
+
publishedOf.set(dataFile, () => readSingleton(published, def, name).catch(() => null));
|
|
321
|
+
}
|
|
280
322
|
const result = validateFields(def.fields, data);
|
|
281
323
|
const issues = [...(result.ok ? [] : result.issues), ...(await targets.issues(def.fields, data))];
|
|
282
324
|
if (issues.length > 0) problems.push({ path: dataPath, label: def.label, issues });
|
|
@@ -290,18 +332,47 @@ export async function analyseChanges(
|
|
|
290
332
|
}
|
|
291
333
|
}
|
|
292
334
|
|
|
335
|
+
// A URL change and the links that follow it are published together, both ways. A
|
|
336
|
+
// document follows when the website's version of it links to the old URL; a new
|
|
337
|
+
// draft that links to the new URL only needs the change, and does not hold it back.
|
|
338
|
+
const renamed = new Map(
|
|
339
|
+
owners.flatMap((owner) =>
|
|
340
|
+
owner.kind === "entry" && owner.renamedFrom
|
|
341
|
+
? [[entryDataPath(model.collections[owner.collection]!, owner.collection, owner.slug), owner] as const]
|
|
342
|
+
: [],
|
|
343
|
+
),
|
|
344
|
+
);
|
|
345
|
+
for (const [document, needed] of [...needsByPath]) {
|
|
346
|
+
for (const path of needed) {
|
|
347
|
+
const target = renamed.get(path);
|
|
348
|
+
if (!target || target.kind !== "entry") continue;
|
|
349
|
+
const before = await publishedOf.get(document)?.();
|
|
350
|
+
const fields = before && fieldsOfDocument(model, document);
|
|
351
|
+
const linkedBefore =
|
|
352
|
+
before &&
|
|
353
|
+
fields &&
|
|
354
|
+
collectReferences(fields, before).some(
|
|
355
|
+
(reference) => reference.kind === "relation" && reference.collection === target.collection && reference.slug === target.renamedFrom,
|
|
356
|
+
);
|
|
357
|
+
if (linkedBefore) needsByPath.set(path, [...new Set([...(needsByPath.get(path) ?? []), document])]);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
293
361
|
problems.push(...(await brokenByRemoval(model, source, changes, owners, entriesByCollection, titles)));
|
|
294
362
|
|
|
295
363
|
const annotated = changes.map((change, index): AnnotatedChange => {
|
|
296
364
|
const owner = owners[index]!;
|
|
365
|
+
const needs = needsByPath.get(change.path);
|
|
366
|
+
const extra = needs ? { needs } : {};
|
|
297
367
|
if (owner.kind === "entry") {
|
|
298
368
|
const def = model.collections[owner.collection]!;
|
|
299
369
|
return {
|
|
300
370
|
...change,
|
|
371
|
+
...extra,
|
|
301
372
|
owner: { ...owner, label: labelEntry(def, owner.slug, titles.get(`${owner.collection}/${owner.slug}`)) },
|
|
302
373
|
};
|
|
303
374
|
}
|
|
304
|
-
return { ...change, owner };
|
|
375
|
+
return { ...change, ...extra, owner };
|
|
305
376
|
});
|
|
306
377
|
|
|
307
378
|
return {
|
|
@@ -310,6 +381,14 @@ export async function analyseChanges(
|
|
|
310
381
|
};
|
|
311
382
|
}
|
|
312
383
|
|
|
384
|
+
/** The fields of the entry or singleton whose data file is `path`. */
|
|
385
|
+
function fieldsOfDocument(model: Site, path: string): Collection["fields"] | null {
|
|
386
|
+
const owner = classifyPath(model, path);
|
|
387
|
+
if (owner.kind === "entry") return model.collections[owner.collection]?.fields ?? null;
|
|
388
|
+
if (owner.kind === "singleton") return model.singletons[owner.name]?.fields ?? null;
|
|
389
|
+
return null;
|
|
390
|
+
}
|
|
391
|
+
|
|
313
392
|
/** Which piece of content a file belongs to, read from the model's path layout. */
|
|
314
393
|
export function classifyPath(model: Site, path: string): ChangeOwner {
|
|
315
394
|
const segments = path.split("/");
|