@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
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { createHash } from "node:crypto";
8
+ import { posix } from "node:path";
8
9
 
9
10
  import {
10
11
  ContentPathError,
@@ -18,9 +19,13 @@ import {
18
19
  isValidSlug,
19
20
  collectReferences,
20
21
  readCollection,
22
+ mapMedia,
23
+ readCollectionEntry,
21
24
  readSingleton,
22
25
  relativizeMedia,
26
+ relinkReferences,
23
27
  resolveSlugField,
28
+ resolveTitleField,
24
29
  serializeEntry,
25
30
  singletonPath,
26
31
  slugify,
@@ -62,8 +67,15 @@ export interface SaveEntryInput {
62
67
  slug?: string;
63
68
  /** The revision `slug` had when the editor opened it. Absent writes unconditionally. */
64
69
  expectedRevision?: string | null;
70
+ /** On a URL change: point the relations in other entries and settings at the new URL, in the same write. */
71
+ updateLinks?: boolean;
65
72
  }
66
73
 
74
+ /** An entry or settings page that links to an entry. `readOnly`: this build cannot change its links. */
75
+ export type Referrer =
76
+ | { kind: "entry"; collection: string; slug: string; title: string; readOnly?: true }
77
+ | { kind: "singleton"; name: string; title: string; readOnly?: true };
78
+
67
79
  export interface SavedEntry {
68
80
  slug: string;
69
81
  title: string;
@@ -71,6 +83,8 @@ export interface SavedEntry {
71
83
  revision: string;
72
84
  /** True when the save moved the entry's files. */
73
85
  renamed: boolean;
86
+ /** How many other entries and settings pages now link to the new URL. */
87
+ relinked: number;
74
88
  }
75
89
 
76
90
  /** `readOnly` comes from `readOnlyParts`: its collections and singletons are read, never written. */
@@ -98,7 +112,8 @@ export function createContentStore(site: Site, workspace: ContentFiles, readOnly
98
112
  data: unknown,
99
113
  what: string,
100
114
  ): Record<string, unknown> => {
101
- const result = validateFields(def.fields, data);
115
+ // Save keeps a half-finished entry; Publish checks `required` (decision "Publish a selection").
116
+ const result = validateFields(def.fields, data, { enforceRequired: false });
102
117
  if (!result.ok) {
103
118
  throw new ValidationError(
104
119
  `${what} cannot be saved until these fields are correct.`,
@@ -214,6 +229,84 @@ export function createContentStore(site: Site, workspace: ContentFiles, readOnly
214
229
  return { type: "write", path, contents: `${JSON.stringify(order, null, 2)}\n` };
215
230
  };
216
231
 
232
+ /** Entries and settings pages whose relations point at `collection`/`slug`, with what a relink needs. */
233
+ const referrers = async (collection: string, slug: string) => {
234
+ const found: Array<{
235
+ referrer: Referrer;
236
+ fields: FieldMap;
237
+ base: string;
238
+ dataFile: string;
239
+ /** Reads the data again, so that a relink starts from what is on disk now. */
240
+ read: () => Promise<Record<string, unknown> | null>;
241
+ }> = [];
242
+ const readOnlyMark = (key: string) => (readOnly.has(key) ? { readOnly: true as const } : {});
243
+ const points = (fields: FieldMap, data: Record<string, unknown>) =>
244
+ collectReferences(fields, data).some(
245
+ (reference) => reference.kind === "relation" && reference.collection === collection && reference.slug === slug,
246
+ );
247
+
248
+ for (const [name, def] of Object.entries(site.collections)) {
249
+ if (!linksTo(def.fields, collection)) continue;
250
+ for (const entry of await readCollection(workspace, def, name)) {
251
+ if (!points(def.fields, entry.data)) continue;
252
+ found.push({
253
+ referrer: {
254
+ kind: "entry",
255
+ collection: name,
256
+ slug: entry.slug,
257
+ title: entryTitle(def, entry.data),
258
+ ...readOnlyMark(`collections.${name}`),
259
+ },
260
+ fields: def.fields,
261
+ base: collectionEntryPath(def.fields, name, entry.slug),
262
+ dataFile: dataPath(def, name, entry.slug),
263
+ read: () => readCollectionEntry(workspace, def, name, entry.slug),
264
+ });
265
+ }
266
+ }
267
+ for (const [name, def] of Object.entries(site.singletons)) {
268
+ if (!linksTo(def.fields, collection)) continue;
269
+ const data = await readSingleton(workspace, def, name);
270
+ if (!data || !points(def.fields, data)) continue;
271
+ found.push({
272
+ referrer: { kind: "singleton", name, title: def.label, ...readOnlyMark(`singletons.${name}`) },
273
+ fields: def.fields,
274
+ base: singletonPath(def.fields, name),
275
+ dataFile: singletonDataPath(def, name),
276
+ read: () => readSingleton(workspace, def, name),
277
+ });
278
+ }
279
+ return found;
280
+ };
281
+
282
+ /**
283
+ * Files that a new entry uses from `<collection>/<other slug>/media/`:
284
+ * uploads made before the URL was final. Each gets a free name in the
285
+ * entry's own `media/` folder. From a folder where no entry lives they move;
286
+ * from another entry's folder (a colleague saved that URL first) they are
287
+ * copied, because that entry may use them too.
288
+ */
289
+ const stagedUploads = async (def: Collection, collection: string, data: Record<string, unknown>, base: string) => {
290
+ const moves: Array<{ from: string; to: string; bytes: Uint8Array; keep: boolean }> = [];
291
+ const taken = new Set((await workspace.list(`${base}/media`)).map((item) => item.name));
292
+ for (const reference of collectReferences(def.fields, data)) {
293
+ if (reference.kind !== "media") continue;
294
+ const match = /^([^/]+)\/([^/]+)\/media\/([^/]+)$/.exec(reference.path);
295
+ if (!match || match[1] !== collection || `${collection}/${match[2]}` === base) continue;
296
+ if (moves.some((move) => move.from === reference.path)) continue;
297
+ const keep = (await workspace.read(dataPath(def, collection, match[2]!))) !== null;
298
+ const bytes = await workspace.readBinary(reference.path);
299
+ if (bytes === null) continue;
300
+ const extension = match[3]!.includes(".") ? match[3]!.slice(match[3]!.lastIndexOf(".")) : "";
301
+ const stem = match[3]!.slice(0, match[3]!.length - extension.length);
302
+ let name = match[3]!;
303
+ for (let n = 2; taken.has(name); n += 1) name = `${stem}-${n}${extension}`;
304
+ taken.add(name);
305
+ moves.push({ from: reference.path, to: `${base}/media/${name}`, bytes, keep });
306
+ }
307
+ return moves;
308
+ };
309
+
217
310
  /** Slugs already taken in a collection, so a new one can be made unique. */
218
311
  const takenSlugs = async (def: Collection, name: string, except?: string): Promise<string[]> => {
219
312
  const entries = await readCollection(workspace, def, name);
@@ -264,8 +357,17 @@ export function createContentStore(site: Site, workspace: ContentFiles, readOnly
264
357
  : uniqueSlug(requested, await takenSlugs(def, collectionName, input.slug));
265
358
 
266
359
  data[slugKey] = slug;
267
-
268
360
  const base = collectionEntryPath(def.fields, collectionName, slug);
361
+ // A new entry's uploads went into the folder of the URL it had then.
362
+ const staged = input.slug === undefined ? await stagedUploads(def, collectionName, data, base) : [];
363
+ if (staged.length > 0) {
364
+ const moved = new Map(staged.map((item) => [item.from, item.to]));
365
+ Object.assign(data, mapMedia(def.fields, data, (value) => (typeof value === "string" ? (moved.get(value) ?? value) : value)));
366
+ }
367
+ // An entry that links to itself follows its own new URL.
368
+ const own = previousLinks(input, slug) ? relinkReferences(def.fields, data, collectionName, input.slug!, slug) : null;
369
+ if (own) Object.assign(data, own);
370
+
269
371
  const previousBase =
270
372
  input.slug && input.slug !== slug
271
373
  ? collectionEntryPath(def.fields, collectionName, input.slug)
@@ -284,6 +386,10 @@ export function createContentStore(site: Site, workspace: ContentFiles, readOnly
284
386
  path,
285
387
  contents,
286
388
  }));
389
+ for (const item of staged) {
390
+ changes.push({ type: "write", path: item.to, contents: item.bytes });
391
+ if (!item.keep) changes.push({ type: "delete", path: item.from });
392
+ }
287
393
 
288
394
  if (previousBase) {
289
395
  changes.push(
@@ -298,19 +404,47 @@ export function createContentStore(site: Site, workspace: ContentFiles, readOnly
298
404
  changes.push(...(await staleDocuments(base, new Set(Object.keys(serialized.documents)))));
299
405
  }
300
406
 
407
+ // Only the data file of each referrer changes; its rich text and media stay as they are.
408
+ // A read-only part is left alone: this build may not write it.
409
+ let relinked = 0;
410
+ const referrerRevisions: Record<string, string | null> = {};
411
+ if (previousLinks(input, slug)) {
412
+ for (const found of await referrers(collectionName, input.slug!)) {
413
+ if (found.referrer.readOnly) continue;
414
+ if (found.referrer.kind === "entry" && found.referrer.collection === collectionName && found.referrer.slug === input.slug) continue;
415
+ // The revision before the read: a save in between makes the write refuse, not overwrite it.
416
+ const revision = await workspace.revision(found.dataFile);
417
+ const current = await found.read();
418
+ const next = current && relinkReferences(found.fields, current, collectionName, input.slug!, slug);
419
+ if (!next) continue;
420
+ const written = entryFiles(found.fields, found.base, serializeEntry(found.fields, mediaForDisk(found.fields, next, found.base)));
421
+ changes.push({ type: "write", path: found.dataFile, contents: written[found.dataFile]! });
422
+ referrerRevisions[found.dataFile] = revision;
423
+ relinked += 1;
424
+ }
425
+ }
426
+
301
427
  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
- });
428
+ const expected = await expectDocument(
429
+ input.expectedRevision,
430
+ dataPath(def, collectionName, input.slug ?? slug),
431
+ folderOf(def.fields, previousBase ?? base),
432
+ );
433
+ try {
434
+ await workspace.apply({
435
+ changes,
436
+ expect: relinked > 0 ? { ...expected, ...referrerRevisions } : expected,
437
+ });
438
+ } catch (error) {
439
+ if (error instanceof ConflictError && error.paths.some((path) => path in referrerRevisions)) {
440
+ throw new ConflictError("An entry or page that links here changed while you saved. Save again.", error.paths);
441
+ }
442
+ throw error;
443
+ }
310
444
 
311
445
  const revision = await revisionAfterWrite(dataPath(def, collectionName, slug), folderOf(def.fields, base));
312
446
 
313
- return { slug, title, revision, renamed: previousBase !== null };
447
+ return { slug, title, revision, renamed: previousBase !== null, relinked };
314
448
  },
315
449
 
316
450
  async remove(
@@ -395,30 +529,111 @@ export function createContentStore(site: Site, workspace: ContentFiles, readOnly
395
529
  return uniqueSlug(slugify(source), await takenSlugs(def, collectionName, except));
396
530
  },
397
531
 
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 }> = [];
532
+ /**
533
+ * Where each file is used: in a media field, or as an image or a link in rich
534
+ * text. A file that something uses is not deleted.
535
+ */
536
+ async mediaUses(paths: string[]): Promise<{ uses: Record<string, Referrer[]>; unreadable: string[] }> {
537
+ const wanted = new Set(paths);
538
+ /** Collections and settings that could not be read: a use there cannot be ruled out. */
539
+ const unreadable: string[] = [];
540
+ const uses: Record<string, Referrer[]> = Object.fromEntries(paths.map((path) => [path, []]));
541
+ const check = (referrer: Referrer, fields: FieldMap, data: Record<string, unknown>, folder: string) => {
542
+ const found = new Set<string>();
543
+ for (const reference of collectReferences(fields, data)) {
544
+ if (reference.kind === "media" && wanted.has(reference.path)) found.add(reference.path);
545
+ }
546
+ for (const text of richTextValues(fields, data)) {
547
+ for (const target of markdocTargets(text)) {
548
+ const path = resolveTarget(target, folder);
549
+ if (path && wanted.has(path)) found.add(path);
550
+ }
551
+ }
552
+ for (const path of found) uses[path]!.push(referrer);
553
+ };
401
554
 
402
555
  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
- }
556
+ const entries = await readCollection(workspace, def, name).catch(() => null);
557
+ if (!entries) {
558
+ unreadable.push(def.label);
559
+ continue;
560
+ }
561
+ for (const entry of entries) {
562
+ const base = collectionEntryPath(def.fields, name, entry.slug);
563
+ const folder = entryLayout(def.fields) === "directory" ? base : name;
564
+ check({ kind: "entry", collection: name, slug: entry.slug, title: entryTitle(def, entry.data) }, def.fields, entry.data, folder);
412
565
  }
413
566
  }
567
+ for (const [name, def] of Object.entries(site.singletons)) {
568
+ const data = await readSingleton(workspace, def, name).catch(() => undefined);
569
+ if (data === undefined) unreadable.push(def.label);
570
+ if (!data) continue;
571
+ const folder = entryLayout(def.fields) === "directory" ? singletonPath(def.fields, name) : "";
572
+ check({ kind: "singleton", name, title: def.label }, def.fields, data, folder);
573
+ }
574
+ return { uses, unreadable };
575
+ },
414
576
 
415
- return results;
577
+ /** Entries and settings pages that link to `slug`: the warning before a delete or a URL change. */
578
+ async findReferences(collectionName: string, slug: string): Promise<Referrer[]> {
579
+ requireCollection(collectionName);
580
+ return (await referrers(collectionName, slug)).map((found) => found.referrer);
416
581
  },
417
582
  };
418
583
  }
419
584
 
420
585
  export type ContentStore = ReturnType<typeof createContentStore>;
421
586
 
587
+ /** Whether a save is a URL change whose links should follow. */
588
+ function previousLinks(input: SaveEntryInput, slug: string): boolean {
589
+ return input.updateLinks === true && input.slug !== undefined && input.slug !== slug;
590
+ }
591
+
592
+ /** Every rich-text value in a payload, nested fields included. */
593
+ function richTextValues(fields: FieldMap, data: Record<string, unknown>): string[] {
594
+ const found: string[] = [];
595
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
596
+ value !== null && typeof value === "object" && !Array.isArray(value);
597
+ const visit = (map: FieldMap, record: Record<string, unknown>) => {
598
+ for (const [key, field] of Object.entries(map)) {
599
+ const value = record[key];
600
+ if (field.kind === "richText" && typeof value === "string") found.push(value);
601
+ else if (field.kind === "object" && isRecord(value)) visit(field.fields, value);
602
+ else if (field.kind === "repeater" && Array.isArray(value)) for (const row of value) if (isRecord(row)) visit(field.fields, row);
603
+ }
604
+ };
605
+ visit(fields, data);
606
+ return found;
607
+ }
608
+
609
+ /**
610
+ * The targets of the images and links in Markdoc: `![alt](media/x.jpg)`,
611
+ * `[Price list](/media/prices.pdf)`, a reference `[x]: media/x.jpg`, and a tag
612
+ * attribute such as `{% figure src="media/x.jpg" %}`.
613
+ */
614
+ function markdocTargets(text: string): string[] {
615
+ const inline = [...text.matchAll(/\]\(\s*<?([^)\s>]+)/g)].map((match) => match[1]!);
616
+ const references = [...text.matchAll(/^\s{0,3}\[[^\]]+\]:\s*<?([^\s>]+)/gm)].map((match) => match[1]!);
617
+ const attributes = [...text.matchAll(/\{%[^%]*%\}/g)].flatMap((tag) =>
618
+ [...tag[0].matchAll(/\b[\w-]+\s*=\s*"([^"]+)"/g)].map((match) => match[1]!),
619
+ );
620
+ return [...inline, ...references, ...attributes];
621
+ }
622
+
623
+ /** A rich-text target as a content path: from the content root with `/`, else from the document's folder. */
624
+ function resolveTarget(target: string, folder: string): string | null {
625
+ if (/^[a-z][a-z0-9+.-]*:/i.test(target) || target.startsWith("#")) return null;
626
+ let decoded: string;
627
+ try {
628
+ decoded = decodeURI(target.split(/[?#]/)[0]!);
629
+ } catch {
630
+ return null;
631
+ }
632
+ const joined = decoded.startsWith("/") ? decoded.slice(1) : folder === "" ? decoded : `${folder}/${decoded}`;
633
+ const normal = posix.normalize(joined);
634
+ return normal.startsWith("..") ? null : normal;
635
+ }
636
+
422
637
  /** Whether a field map has a relation to `collection`, nested fields included. */
423
638
  function linksTo(fields: FieldMap, collection: string): boolean {
424
639
  return Object.values(fields).some(
@@ -459,8 +674,11 @@ function pickSlug(
459
674
  const slugKey = resolveSlugField(def)!;
460
675
  const fromData = data[slugKey];
461
676
 
677
+ // Not `entryTitle`, whose "Untitled" would give every entry without a title the same URL.
678
+ const titleKey = resolveTitleField(def);
679
+ const title = titleKey ? data[titleKey] : undefined;
462
680
  const candidate =
463
- (typeof fromData === "string" && fromData.trim()) || slugify(entryTitle(def, data));
681
+ (typeof fromData === "string" && fromData.trim()) || (typeof title === "string" ? slugify(title.trim()) : "");
464
682
 
465
683
  if (!candidate) {
466
684
  throw new ContentError("An entry needs a title or a URL before it can be saved.");
@@ -11,7 +11,7 @@ import type { ContentFiles } from "../workspace/files.js";
11
11
  import { imageSize } from "image-size";
12
12
 
13
13
  import { ApiError, badRequest } from "../lib/errors.js";
14
- import { detectFileType, isAccepted, isImageType, SVG_TYPE } from "../lib/file-type.js";
14
+ import { detectFileType, isAccepted, isImageType, SVG_TYPE, VIDEO_TYPE } from "../lib/file-type.js";
15
15
 
16
16
  /** Where uploads go when they are not attached to a particular entry. */
17
17
  export const SHARED_MEDIA_DIR = "media";
@@ -75,6 +75,12 @@ export function createMediaService(deps: MediaServiceDeps) {
75
75
  throw new ApiError("unsupported_media_type", "This file type is not supported.");
76
76
  }
77
77
  if (detected.mimeType === SVG_TYPE) throw refuseSvg();
78
+ if (detected.mimeType === VIDEO_TYPE) {
79
+ throw new ApiError(
80
+ "unsupported_media_type",
81
+ "Mica does not keep videos: they make the website's files large. Put the video on a video service, and link to it.",
82
+ );
83
+ }
78
84
  if (!isAccepted(detected.mimeType, input.accept ?? "any")) {
79
85
  throw new ApiError(
80
86
  "unsupported_media_type",
@@ -10,7 +10,10 @@
10
10
  * retry pushes and never commits again.
11
11
  */
12
12
 
13
+ import { stat } from "node:fs/promises";
14
+
13
15
  import { MANIFEST_PATH, memorySource, type ContentSource, type PublishResult, type Site } from "@micacms/core";
16
+ import { within } from "@micacms/core/node";
14
17
 
15
18
  import { ApiError, badRequest, notFound } from "../lib/errors.js";
16
19
  import type { Logger } from "../lib/logger.js";
@@ -20,7 +23,6 @@ import { GitError } from "../workspace/git.js";
20
23
  import { createWorkspaceLocks, type WorkspaceLocks } from "../workspace/locks.js";
21
24
  import type { OwnerPolicy } from "../workspace/policy.js";
22
25
  import {
23
- aheadOfUpstream,
24
26
  commitPaths,
25
27
  currentBranch,
26
28
  discardChange,
@@ -29,6 +31,8 @@ import {
29
31
  overlaySource,
30
32
  listTreeChanges,
31
33
  locateTree,
34
+ publicationMessage,
35
+ unpushedCommits,
32
36
  type GitTree,
33
37
  type TreeChange,
34
38
  } from "../workspace/tree.js";
@@ -143,9 +147,19 @@ export function createWorkingTreeStagingService(
143
147
  })),
144
148
  );
145
149
 
150
+ /** When each file was last saved; a deletion has no file to ask. */
151
+ const withSavedAt = async (changes: AnnotatedChange[]): Promise<AnnotatedChange[]> =>
152
+ Promise.all(
153
+ changes.map(async (change) => {
154
+ if (change.kind === "delete") return { ...change, savedAt: null };
155
+ const saved = await stat(within(contentRoot, change.path)).catch(() => null);
156
+ return { ...change, savedAt: saved ? saved.mtime.toISOString() : null };
157
+ }),
158
+ );
159
+
146
160
  /** Refused for the developer's checkout: Mica never fetches or merges there. */
147
161
  const managedOnly = <T>(action: T | undefined): T => {
148
- if (action === undefined) throw notFound("This is the developer's checkout; Git there is theirs.");
162
+ if (action === undefined) throw notFound("Only a self-hosted Mica keeps its own copy of the website; the local editor leaves Git to you.");
149
163
  return action;
150
164
  };
151
165
 
@@ -160,7 +174,7 @@ export function createWorkingTreeStagingService(
160
174
  publish: { enabled: false, reason: tree.reason },
161
175
  head: null,
162
176
  branch: null,
163
- ahead: null,
177
+ unpushed: null,
164
178
  changes: [],
165
179
  validation: emptyValidation(),
166
180
  sync,
@@ -171,7 +185,7 @@ export function createWorkingTreeStagingService(
171
185
  const analysed = await analyse(tree, await listTreeChanges(tree));
172
186
  return {
173
187
  ...analysed,
174
- changes: await withRevisions(analysed.changes),
188
+ changes: await withSavedAt(await withRevisions(analysed.changes)),
175
189
  head: await headRevision(tree),
176
190
  branch: await currentBranch(tree),
177
191
  };
@@ -182,7 +196,8 @@ export function createWorkingTreeStagingService(
182
196
  publish: policy.publishAvailability(),
183
197
  head,
184
198
  branch,
185
- ahead: await aheadOfUpstream(tree),
199
+ // A clone that Mica keeps holds only Mica's commits, and retries its own push.
200
+ unpushed: policy.owner === "developer" ? await unpushedCommits(tree) : null,
186
201
  changes,
187
202
  validation,
188
203
  sync,
@@ -191,7 +206,7 @@ export function createWorkingTreeStagingService(
191
206
 
192
207
  async publish(input: PublishInput): Promise<PublishResult> {
193
208
  const message = input.message.trim();
194
- if (!message) throw badRequest("A publication needs a commit message.");
209
+ if (!message) throw badRequest("Say what changed before you publish.");
195
210
  const reviewed = Object.keys(input.paths);
196
211
  if (reviewed.length === 0) throw badRequest("Choose at least one change to publish.");
197
212
 
@@ -229,8 +244,7 @@ export function createWorkingTreeStagingService(
229
244
  if ((await files.revision(MANIFEST_PATH)) !== deps.schemaRevision) {
230
245
  throw new ApiError(
231
246
  "conflict",
232
- "The content model changed after this server started. Restart Mica to load " +
233
- "the new model, then review the pending changes and publish again.",
247
+ "The content model changed just now. Review the changes again, then publish.",
234
248
  );
235
249
  }
236
250
 
@@ -245,14 +259,14 @@ export function createWorkingTreeStagingService(
245
259
  }
246
260
 
247
261
  try {
248
- const revision = await commitPaths(tree, reviewed, message, policy.commitAuthor(input.author));
249
- logger.info("Published", { revision, files: reviewed.length, owner: policy.owner });
262
+ const revision = await commitPaths(tree, reviewed, publicationMessage(message), policy.commitAuthor(input.author));
263
+ logger.info("Published", { revision, files: reviewed.length, owner: policy.owner, editor: input.editor });
250
264
  return revision;
251
265
  } catch (error) {
252
266
  if (error instanceof GitError) {
253
267
  // Git's message is the actionable part: no identity, a hook that
254
268
  // refused, a rebase in progress.
255
- throw new ApiError("conflict", `Git refused the commit: ${error.message}`);
269
+ throw new ApiError("conflict", `Publishing failed: ${error.message}`);
256
270
  }
257
271
  throw error;
258
272
  }
@@ -267,12 +281,12 @@ export function createWorkingTreeStagingService(
267
281
 
268
282
  return locks.remote.run(async () => {
269
283
  const revision = await headRevision(tree);
270
- if (revision === null) throw badRequest("There is no commit to push yet.");
284
+ if (revision === null) throw badRequest("There is nothing to send to the website yet.");
271
285
  return policy.push(tree, revision);
272
286
  });
273
287
  },
274
288
 
275
- async discard(requested: string[]): Promise<{ discarded: string[] }> {
289
+ async discard(requested: string[], editor?: string): Promise<{ discarded: string[] }> {
276
290
  const tree = await located();
277
291
 
278
292
  return locks.tree.write(async () => {
@@ -287,6 +301,7 @@ export function createWorkingTreeStagingService(
287
301
  discarded.push(path);
288
302
  }
289
303
 
304
+ if (discarded.length > 0) logger.info("Discarded", { paths: discarded, editor });
290
305
  return { discarded };
291
306
  });
292
307
  },
@@ -306,8 +321,9 @@ export function createWorkingTreeStagingService(
306
321
  await managedOnly(policy.sync)();
307
322
  },
308
323
 
309
- async resolve(group: string, choice: "mine" | "website"): Promise<void> {
324
+ async resolve(group: string, choice: "mine" | "website", editor?: string): Promise<void> {
310
325
  await managedOnly(policy.resolve)(group, choice);
326
+ logger.info("Conflict resolved", { group, choice, editor });
311
327
  },
312
328
 
313
329
  async repository(): Promise<RepositoryInfo> {
@@ -2,15 +2,16 @@
2
2
  * The policy for the developer's own checkout. Mica never fetches, merges or
3
3
  * resets here. Publish pushes with the developer's `git`, credentials and
4
4
  * upstream; a new branch without one is pushed to `origin` and tracks it. A
5
- * failed push keeps the commit.
5
+ * failed push keeps the commit. A push sends only Mica's own commits: other
6
+ * commits that no remote has make Publish refuse.
6
7
  */
7
8
 
8
9
  import type { PublishResult } from "@micacms/core";
9
10
 
11
+ import { ApiError } from "../lib/errors.js";
10
12
  import type { Logger } from "../lib/logger.js";
11
- import { GitError } from "./git.js";
12
13
  import type { OwnerPolicy, PublishAvailability } from "./policy.js";
13
- import type { GitTree } from "./tree.js";
14
+ import { unpushedCommits, type GitTree } from "./tree.js";
14
15
 
15
16
  export interface DeveloperPolicyOptions {
16
17
  /** `MICA_LOCAL_PUBLISH`. */
@@ -28,13 +29,17 @@ export function developerPolicy(options: DeveloperPolicyOptions): OwnerPolicy {
28
29
  : { enabled: false, reason: "Publishing from local mode is disabled (MICA_LOCAL_PUBLISH=false)." };
29
30
  },
30
31
 
31
- async prepare() {},
32
+ async prepare(tree: GitTree) {
33
+ await refuseOtherCommits(tree);
34
+ },
32
35
 
33
36
  // The commit keeps the developer's own Git identity.
34
37
  commitAuthor: () => undefined,
35
38
 
36
39
  async push(tree: GitTree, revision: string): Promise<PublishResult> {
37
40
  try {
41
+ // Checked again: a commit made in a terminal since `prepare` would go along.
42
+ await refuseOtherCommits(tree);
38
43
  // Publishing is the request to push, so a new branch gets its upstream here.
39
44
  const upstream = await tree.git.tryRun(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"]);
40
45
  const remotes = (await tree.git.tryRun(["remote"]))?.split("\n").filter(Boolean) ?? [];
@@ -43,7 +48,7 @@ export function developerPolicy(options: DeveloperPolicyOptions): OwnerPolicy {
43
48
  else await tree.git.run(["push"]);
44
49
  return { state: "published", revision };
45
50
  } catch (error) {
46
- const pushError = error instanceof GitError || error instanceof Error ? error.message : String(error);
51
+ const pushError = error instanceof Error ? error.message : String(error);
47
52
  options.logger.warn("Publish committed but the push failed", { revision, pushError });
48
53
  return { state: "committed-pending-push", revision, pushError };
49
54
  }
@@ -51,6 +56,18 @@ export function developerPolicy(options: DeveloperPolicyOptions): OwnerPolicy {
51
56
  };
52
57
  }
53
58
 
59
+ /** A push sends every commit that no remote has; the developer's own commits are theirs to send. */
60
+ async function refuseOtherCommits(tree: GitTree): Promise<void> {
61
+ const other = (await unpushedCommits(tree))?.other ?? 0;
62
+ if (other === 0) return;
63
+ throw new ApiError(
64
+ "conflict",
65
+ `Publishing would also send ${other} change${other === 1 ? "" : "s"} that ${other === 1 ? "was" : "were"} not made in Mica ` +
66
+ "and that the website does not have yet. Send them with Git first (`git push`), or move them to another branch. " +
67
+ "Then publish again.",
68
+ );
69
+ }
70
+
54
71
  function noUpstream(remotes: string[]): string {
55
72
  return remotes.length === 0
56
73
  ? "This repository has no remote. Add one with `git remote add origin <url>`, then publish again."
@@ -30,7 +30,7 @@ export interface SyncStatus {
30
30
  /** Upstream commits not yet in the clone. */
31
31
  behind: number;
32
32
  error: { code: string; message: string; since: string } | null;
33
- /** The content model changed upstream; a restart applies it. */
33
+ /** A `.gitattributes` changed upstream; a restart applies it. A new content model needs none. */
34
34
  restartRequired: boolean;
35
35
  /** A publication committed and not yet pushed. Mica retries it. */
36
36
  pendingPush: { revision: string; reason: string } | null;
@@ -157,12 +157,41 @@ export function currentBranch(tree: GitTree): Promise<string | null> {
157
157
  return tree.git.tryRun(["rev-parse", "--abbrev-ref", "HEAD"]);
158
158
  }
159
159
 
160
- /** Commits waiting to be pushed, or null when there is no upstream to say. */
161
- export async function aheadOfUpstream(tree: GitTree): Promise<number | null> {
162
- const counted = await tree.git.tryRun(["rev-list", "--count", "@{upstream}..HEAD"]);
163
- if (counted === null) return null;
164
- const ahead = Number(counted);
165
- return Number.isFinite(ahead) ? ahead : null;
160
+ /** The trailer that marks a commit that Mica made for a publication. */
161
+ export const PUBLISHED_WITH_MICA = "Published-with: Mica";
162
+
163
+ /** A publication's commit message: the editor's summary, and Mica's trailer. */
164
+ export function publicationMessage(summary: string): string {
165
+ return `${summary}\n\n${PUBLISHED_WITH_MICA}`;
166
+ }
167
+
168
+ /** Commits on HEAD that no remote has, split into Mica's publications and other commits. */
169
+ export interface UnpushedCommits {
170
+ mica: number;
171
+ other: number;
172
+ }
173
+
174
+ /**
175
+ * What a push of HEAD would send: the commits after the upstream branch, or,
176
+ * for a branch without one, the commits that no remote has. Null without a
177
+ * remote, because then nothing can be pushed and everything would count.
178
+ */
179
+ export async function unpushedCommits(tree: GitTree): Promise<UnpushedCommits | null> {
180
+ const remotes = await tree.git.tryRun(["remote"]);
181
+ if (!remotes) return null;
182
+ // A commit can be on another remote branch and still be new to the upstream.
183
+ const upstream = await tree.git.tryRun(["rev-parse", "--verify", "--quiet", "@{upstream}"]);
184
+ const range = upstream ? [`${upstream}..HEAD`] : ["HEAD", "--not", "--remotes"];
185
+ // %x1e ends a commit, so a multi-line message stays one record.
186
+ const log = await tree.git.tryRun(["log", "--max-count=1000", "--format=%B%x1e", ...range]);
187
+ if (log === null) return { mica: 0, other: 0 };
188
+ const counted = { mica: 0, other: 0 };
189
+ for (const message of log.split("\x1e")) {
190
+ if (message.trim() === "") continue;
191
+ const lines = message.trim().split("\n");
192
+ counted[lines.includes(PUBLISHED_WITH_MICA) ? "mica" : "other"] += 1;
193
+ }
194
+ return counted;
166
195
  }
167
196
 
168
197
  /**