@micacms/runtime 0.6.0 → 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.
Files changed (70) hide show
  1. package/dist/app.d.ts.map +1 -1
  2. package/dist/app.js +5 -0
  3. package/dist/app.js.map +1 -1
  4. package/dist/context.d.ts +17 -2
  5. package/dist/context.d.ts.map +1 -1
  6. package/dist/context.js +62 -18
  7. package/dist/context.js.map +1 -1
  8. package/dist/http.d.ts +2 -0
  9. package/dist/http.d.ts.map +1 -1
  10. package/dist/http.js +5 -0
  11. package/dist/http.js.map +1 -1
  12. package/dist/lib/file-type.d.ts +2 -0
  13. package/dist/lib/file-type.d.ts.map +1 -1
  14. package/dist/lib/file-type.js +3 -1
  15. package/dist/lib/file-type.js.map +1 -1
  16. package/dist/routes/admin-content.d.ts +1 -1
  17. package/dist/routes/admin-content.d.ts.map +1 -1
  18. package/dist/routes/admin-content.js +15 -0
  19. package/dist/routes/admin-content.js.map +1 -1
  20. package/dist/routes/admin-media.d.ts +5 -3
  21. package/dist/routes/admin-media.d.ts.map +1 -1
  22. package/dist/routes/admin-media.js +72 -2
  23. package/dist/routes/admin-media.js.map +1 -1
  24. package/dist/routes/admin-staging.d.ts.map +1 -1
  25. package/dist/routes/admin-staging.js +18 -7
  26. package/dist/routes/admin-staging.js.map +1 -1
  27. package/dist/routes/admin-system.d.ts.map +1 -1
  28. package/dist/routes/admin-system.js +4 -0
  29. package/dist/routes/admin-system.js.map +1 -1
  30. package/dist/services/changes.d.ts +19 -4
  31. package/dist/services/changes.d.ts.map +1 -1
  32. package/dist/services/changes.js +60 -1
  33. package/dist/services/changes.js.map +1 -1
  34. package/dist/services/content-store.d.ts +27 -6
  35. package/dist/services/content-store.d.ts.map +1 -1
  36. package/dist/services/content-store.js +231 -18
  37. package/dist/services/content-store.js.map +1 -1
  38. package/dist/services/media.d.ts.map +1 -1
  39. package/dist/services/media.js +4 -1
  40. package/dist/services/media.js.map +1 -1
  41. package/dist/services/staging.d.ts.map +1 -1
  42. package/dist/services/staging.js +26 -14
  43. package/dist/services/staging.js.map +1 -1
  44. package/dist/workspace/developer.d.ts +2 -1
  45. package/dist/workspace/developer.d.ts.map +1 -1
  46. package/dist/workspace/developer.js +19 -4
  47. package/dist/workspace/developer.js.map +1 -1
  48. package/dist/workspace/policy.d.ts +1 -1
  49. package/dist/workspace/policy.d.ts.map +1 -1
  50. package/dist/workspace/policy.js.map +1 -1
  51. package/dist/workspace/tree.d.ts +15 -2
  52. package/dist/workspace/tree.d.ts.map +1 -1
  53. package/dist/workspace/tree.js +29 -6
  54. package/dist/workspace/tree.js.map +1 -1
  55. package/package.json +2 -2
  56. package/src/app.ts +5 -0
  57. package/src/context.ts +69 -20
  58. package/src/http.ts +6 -0
  59. package/src/lib/file-type.ts +4 -1
  60. package/src/routes/admin-content.ts +16 -2
  61. package/src/routes/admin-media.ts +74 -3
  62. package/src/routes/admin-staging.ts +22 -7
  63. package/src/routes/admin-system.ts +4 -0
  64. package/src/services/changes.ts +84 -5
  65. package/src/services/content-store.ts +243 -25
  66. package/src/services/media.ts +7 -1
  67. package/src/services/staging.ts +30 -14
  68. package/src/workspace/developer.ts +22 -5
  69. package/src/workspace/policy.ts +1 -1
  70. package/src/workspace/tree.ts +35 -6
@@ -1,14 +1,45 @@
1
1
  /**
2
- * Admin media routes. Media is files in the content repository, and there is no delete endpoint.
3
- * An upload is written on its own, before the entry save, because the editor needs its path first.
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 { Env } from "../http.js";
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 { staging } = c.get("ctx").services;
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,
@@ -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
- /** Commits created and not yet pushed. */
78
- ahead: number | null;
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
- discard(paths: string[]): Promise<{ discarded: string[] }>;
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("/");