@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
|
@@ -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
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
/**
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
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: ``,
|
|
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(
|
|
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.");
|
package/src/services/media.ts
CHANGED
|
@@ -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",
|
package/src/services/staging.ts
CHANGED
|
@@ -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("
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
|
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", `
|
|
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
|
|
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
|
|
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
|
|
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."
|
package/src/workspace/policy.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|
package/src/workspace/tree.ts
CHANGED
|
@@ -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
|
-
/**
|
|
161
|
-
export
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return
|
|
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
|
/**
|