@glw907/cairn-cms 0.38.0 → 0.40.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/CHANGELOG.md +60 -0
- package/README.md +6 -5
- package/dist/components/AdminLayout.svelte +53 -0
- package/dist/components/ComponentInsertDialog.svelte +27 -13
- package/dist/components/ComponentInsertDialog.svelte.d.ts +13 -2
- package/dist/components/ConceptList.svelte +13 -3
- package/dist/components/DeleteDialog.svelte +18 -7
- package/dist/components/DeleteDialog.svelte.d.ts +11 -1
- package/dist/components/EditPage.svelte +575 -70
- package/dist/components/EditPage.svelte.d.ts +8 -1
- package/dist/components/EditorToolbar.svelte +202 -29
- package/dist/components/EditorToolbar.svelte.d.ts +12 -4
- package/dist/components/LinkPicker.svelte +14 -6
- package/dist/components/LinkPicker.svelte.d.ts +9 -2
- package/dist/components/MarkdownEditor.svelte +80 -34
- package/dist/components/MarkdownEditor.svelte.d.ts +9 -3
- package/dist/components/MarkdownHelpDialog.svelte +58 -0
- package/dist/components/MarkdownHelpDialog.svelte.d.ts +11 -0
- package/dist/components/RenameDialog.svelte +13 -4
- package/dist/components/RenameDialog.svelte.d.ts +9 -1
- package/dist/components/WebLinkDialog.svelte +89 -0
- package/dist/components/WebLinkDialog.svelte.d.ts +23 -0
- package/dist/components/cairn-admin.css +353 -4
- package/dist/components/editor-highlight.d.ts +9 -0
- package/dist/components/editor-highlight.js +62 -0
- package/dist/components/markdown-directives.d.ts +7 -0
- package/dist/components/markdown-directives.js +22 -0
- package/dist/components/markdown-format.d.ts +1 -1
- package/dist/components/markdown-format.js +91 -12
- package/dist/content/pending.d.ts +9 -0
- package/dist/content/pending.js +24 -0
- package/dist/github/branches.d.ts +11 -0
- package/dist/github/branches.js +75 -0
- package/dist/log/events.d.ts +1 -1
- package/dist/sveltekit/content-routes.d.ts +22 -1
- package/dist/sveltekit/content-routes.js +312 -72
- package/package.json +3 -2
- package/src/lib/components/AdminLayout.svelte +53 -0
- package/src/lib/components/ComponentInsertDialog.svelte +27 -13
- package/src/lib/components/ConceptList.svelte +13 -3
- package/src/lib/components/DeleteDialog.svelte +18 -7
- package/src/lib/components/EditPage.svelte +575 -70
- package/src/lib/components/EditorToolbar.svelte +202 -29
- package/src/lib/components/LinkPicker.svelte +14 -6
- package/src/lib/components/MarkdownEditor.svelte +80 -34
- package/src/lib/components/MarkdownHelpDialog.svelte +58 -0
- package/src/lib/components/RenameDialog.svelte +13 -4
- package/src/lib/components/WebLinkDialog.svelte +89 -0
- package/src/lib/components/cairn-admin.css +26 -4
- package/src/lib/components/editor-highlight.ts +67 -0
- package/src/lib/components/markdown-directives.ts +23 -0
- package/src/lib/components/markdown-format.ts +118 -13
- package/src/lib/content/pending.ts +24 -0
- package/src/lib/github/branches.ts +83 -0
- package/src/lib/log/events.ts +3 -0
- package/src/lib/sveltekit/content-routes.ts +391 -73
|
@@ -10,8 +10,10 @@ import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameI
|
|
|
10
10
|
import { rewriteCairnLink } from '../components/markdown-format.js';
|
|
11
11
|
import { appCredentials, type GithubKeyEnv } from '../github/credentials.js';
|
|
12
12
|
import { listMarkdown, readRaw, commitFiles, type FileChange } from '../github/repo.js';
|
|
13
|
+
import { branchHeadSha, createBranch, deleteBranch, listBranches } from '../github/branches.js';
|
|
14
|
+
import { PENDING_PREFIX, pendingBranch, parsePendingBranch } from '../content/pending.js';
|
|
13
15
|
import { cachedInstallationToken } from '../github/signing.js';
|
|
14
|
-
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
16
|
+
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
15
17
|
import { CommitConflictError } from '../github/types.js';
|
|
16
18
|
import { log } from '../log/index.js';
|
|
17
19
|
import { issueCsrfToken } from './csrf.js';
|
|
@@ -41,6 +43,9 @@ export interface LayoutData {
|
|
|
41
43
|
collapsedNav: string[];
|
|
42
44
|
/** The session's CSRF double-submit token, rendered as a hidden field in every admin form. */
|
|
43
45
|
csrf: string;
|
|
46
|
+
/** Every entry with unpublished edits (a `cairn/` ref), for the topbar's publish-all action.
|
|
47
|
+
* Null when GitHub is unreachable, so the topbar hides the action rather than lying. */
|
|
48
|
+
pendingEntries: { concept: string; id: string }[] | null;
|
|
44
49
|
}
|
|
45
50
|
|
|
46
51
|
/** One row in a concept's list view. */
|
|
@@ -49,6 +54,8 @@ export interface EntrySummary {
|
|
|
49
54
|
title: string;
|
|
50
55
|
date: string | null;
|
|
51
56
|
draft: boolean;
|
|
57
|
+
/** Publish state derived from the ref set: live as-is, live with pending edits, or branch-only. */
|
|
58
|
+
status: 'published' | 'edited' | 'new';
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
/** The concept list view's data. */
|
|
@@ -62,6 +69,8 @@ export interface ListData {
|
|
|
62
69
|
error: string | null;
|
|
63
70
|
/** A create-form bounce error read from `?error`. */
|
|
64
71
|
formError: string | null;
|
|
72
|
+
/** The entry count from a publish-all redirect (`?publishedAll=`), for the list page's flash. */
|
|
73
|
+
publishedAll: number | null;
|
|
65
74
|
}
|
|
66
75
|
|
|
67
76
|
/** The editor's data. `frontmatter` holds form-ready values (dates already `YYYY-MM-DD`). */
|
|
@@ -84,6 +93,14 @@ export interface EditData {
|
|
|
84
93
|
linkTargets: LinkTarget[];
|
|
85
94
|
/** The entries that link to this one, for the delete guard. Empty when nothing links here. */
|
|
86
95
|
inboundLinks: InboundLink[];
|
|
96
|
+
/** True when the entry has a pending branch, so the body above came from that branch. */
|
|
97
|
+
pending: boolean;
|
|
98
|
+
/** True when the entry file exists on the default branch (the live site shows it). */
|
|
99
|
+
published: boolean;
|
|
100
|
+
/** True after a publish redirect (`?published=1`), for the confirmation strip. */
|
|
101
|
+
publishedFlash: boolean;
|
|
102
|
+
/** True after a discard redirect (`?discarded=1`), for the confirmation strip. */
|
|
103
|
+
discardedFlash: boolean;
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
/** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
|
|
@@ -122,8 +139,28 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
122
139
|
const mintToken =
|
|
123
140
|
deps.mintToken ?? ((env: GithubKeyEnv) => cachedInstallationToken(appCredentials(runtime.backend, env)));
|
|
124
141
|
|
|
125
|
-
/**
|
|
126
|
-
|
|
142
|
+
/** Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
|
|
143
|
+
* Always read from main: pending branches carry no manifest copy. */
|
|
144
|
+
async function readManifest(token: string): Promise<Manifest> {
|
|
145
|
+
const raw = await readRaw(runtime.backend, runtime.manifestPath, token);
|
|
146
|
+
return raw === null ? emptyManifest() : parseManifest(raw);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The pending entry a `cairn/` ref names, or null for a ref the engine must ignore: a
|
|
150
|
+
* malformed name, an id that fails the slug rule (entry paths are built from it, so this is
|
|
151
|
+
* the path confinement), or a concept this site does not configure. Every ref consumer
|
|
152
|
+
* (the layout count, the list view, publish-all) applies this one predicate, so a stray
|
|
153
|
+
* hand-pushed ref cannot inflate a count it can never clear or reach a contents read. */
|
|
154
|
+
function pendingEntryOf(name: string): { concept: ConceptDescriptor; id: string } | null {
|
|
155
|
+
const ref = parsePendingBranch(name);
|
|
156
|
+
if (!ref || !isValidId(ref.id)) return null;
|
|
157
|
+
const concept = findConcept(runtime.concepts, ref.concept);
|
|
158
|
+
return concept ? { concept, id: ref.id } : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Layout load for every admin page: the nav, the user, the active path, the resolved theme,
|
|
162
|
+
* and the pending entries behind the topbar's publish-all action. */
|
|
163
|
+
async function layoutLoad(event: ContentEvent): Promise<LayoutData> {
|
|
127
164
|
const editor = sessionOf(event);
|
|
128
165
|
const cookieTheme = event.cookies?.get('cairn-admin-theme');
|
|
129
166
|
const theme = cookieTheme === 'cairn-admin-dark' ? 'cairn-admin-dark' : 'cairn-admin';
|
|
@@ -131,6 +168,19 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
131
168
|
const collapsedNav = cookieCollapsed
|
|
132
169
|
? cookieCollapsed.split(',').map((part) => decodeURIComponent(part)).filter(Boolean)
|
|
133
170
|
: [];
|
|
171
|
+
// Any failure here (the token mint, the network, a non-ok response) degrades to null rather
|
|
172
|
+
// than failing the whole admin shell or showing a wrong publish-all count.
|
|
173
|
+
let pendingEntries: { concept: string; id: string }[] | null = null;
|
|
174
|
+
try {
|
|
175
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
176
|
+
const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
|
|
177
|
+
pendingEntries = names.flatMap((name) => {
|
|
178
|
+
const entry = pendingEntryOf(name);
|
|
179
|
+
return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
|
|
180
|
+
});
|
|
181
|
+
} catch {
|
|
182
|
+
pendingEntries = null;
|
|
183
|
+
}
|
|
134
184
|
return {
|
|
135
185
|
siteName: runtime.siteName,
|
|
136
186
|
user: { displayName: editor.displayName, email: editor.email, role: editor.role },
|
|
@@ -141,6 +191,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
141
191
|
theme,
|
|
142
192
|
collapsedNav,
|
|
143
193
|
csrf: event.cookies ? issueCsrfToken({ url: event.url, cookies: event.cookies }) : '',
|
|
194
|
+
pendingEntries,
|
|
144
195
|
};
|
|
145
196
|
}
|
|
146
197
|
|
|
@@ -151,26 +202,36 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
151
202
|
throw redirect(307, `/admin/${first.id}`);
|
|
152
203
|
}
|
|
153
204
|
|
|
154
|
-
/** Read a file's frontmatter for its list row, degrading to the id on any read failure.
|
|
155
|
-
|
|
205
|
+
/** Read a file's frontmatter for its list row, degrading to the id on any read failure. The
|
|
206
|
+
* repo defaults to main; a pending entry (edited or branch-only) passes its pending branch. */
|
|
207
|
+
async function summarize(
|
|
208
|
+
file: { id: string; path: string },
|
|
209
|
+
token: string,
|
|
210
|
+
status: EntrySummary['status'],
|
|
211
|
+
repo = runtime.backend,
|
|
212
|
+
): Promise<EntrySummary> {
|
|
156
213
|
try {
|
|
157
|
-
const raw = await readRaw(
|
|
158
|
-
if (raw === null) return { id: file.id, title: file.id, date: null, draft: false };
|
|
214
|
+
const raw = await readRaw(repo, file.path, token);
|
|
215
|
+
if (raw === null) return { id: file.id, title: file.id, date: null, draft: false, status };
|
|
159
216
|
const { frontmatter } = parseMarkdown(raw);
|
|
160
217
|
const title = typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title : file.id;
|
|
161
218
|
const date = dateInputValue(frontmatter.date) || null;
|
|
162
|
-
return { id: file.id, title, date, draft: frontmatter.draft === true };
|
|
219
|
+
return { id: file.id, title, date, draft: frontmatter.draft === true, status };
|
|
163
220
|
} catch {
|
|
164
|
-
return { id: file.id, title: file.id, date: null, draft: false };
|
|
221
|
+
return { id: file.id, title: file.id, date: null, draft: false, status };
|
|
165
222
|
}
|
|
166
223
|
}
|
|
167
224
|
|
|
168
|
-
/** List a concept's entries
|
|
225
|
+
/** List a concept's entries with their publish status. Main's files carry `edited` when a
|
|
226
|
+
* pending ref exists, else `published`; a ref with no main file appends a `new` row read from
|
|
227
|
+
* its branch. A listing failure degrades to an inline error, not a thrown 500. */
|
|
169
228
|
async function listLoad(event: ContentEvent): Promise<ListData> {
|
|
170
229
|
sessionOf(event);
|
|
171
230
|
const concept = conceptOf(runtime, event.params);
|
|
172
231
|
const formError = event.url.searchParams.get('error');
|
|
173
|
-
const
|
|
232
|
+
const publishedAllRaw = event.url.searchParams.get('publishedAll');
|
|
233
|
+
const publishedAll = publishedAllRaw !== null && /^\d+$/.test(publishedAllRaw) ? Number(publishedAllRaw) : null;
|
|
234
|
+
const base = { conceptId: concept.id, label: concept.label, dated: concept.routing.dated, formError, publishedAll };
|
|
174
235
|
let token: string;
|
|
175
236
|
try {
|
|
176
237
|
token = await mintToken(event.platform?.env ?? {});
|
|
@@ -178,9 +239,39 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
178
239
|
return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
|
|
179
240
|
}
|
|
180
241
|
try {
|
|
181
|
-
const files = await
|
|
182
|
-
|
|
183
|
-
|
|
242
|
+
const [files, refs] = await Promise.all([
|
|
243
|
+
listMarkdown(runtime.backend, concept.dir, token),
|
|
244
|
+
listBranches(runtime.backend, `${PENDING_PREFIX}${concept.id}/`, token),
|
|
245
|
+
]);
|
|
246
|
+
const pendingIds = new Set(
|
|
247
|
+
refs.flatMap((name) => {
|
|
248
|
+
const entry = pendingEntryOf(name);
|
|
249
|
+
return entry && entry.concept.id === concept.id ? [entry.id] : [];
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
// An edited row reads branch-first like a new row, so a pending title or draft change
|
|
253
|
+
// shows in the list instead of reading as a lost save.
|
|
254
|
+
const entries = await Promise.all(
|
|
255
|
+
files.map((f) =>
|
|
256
|
+
pendingIds.has(f.id)
|
|
257
|
+
? summarize(f, token, 'edited', { ...runtime.backend, branch: pendingBranch(concept.id, f.id) })
|
|
258
|
+
: summarize(f, token, 'published'),
|
|
259
|
+
),
|
|
260
|
+
);
|
|
261
|
+
// A ref with no main file is a never-published entry; its row reads from its branch, and
|
|
262
|
+
// summarize already degrades a failed read to an id-only row.
|
|
263
|
+
const listed = new Set(files.map((f) => f.id));
|
|
264
|
+
const newRows = await Promise.all(
|
|
265
|
+
[...pendingIds]
|
|
266
|
+
.filter((id) => !listed.has(id))
|
|
267
|
+
.map((id) =>
|
|
268
|
+
summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, token, 'new', {
|
|
269
|
+
...runtime.backend,
|
|
270
|
+
branch: pendingBranch(concept.id, id),
|
|
271
|
+
}),
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
return { ...base, entries: [...entries, ...newRows], error: null };
|
|
184
275
|
} catch {
|
|
185
276
|
return { ...base, entries: [], error: 'Could not load this content type from GitHub.' };
|
|
186
277
|
}
|
|
@@ -210,6 +301,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
210
301
|
const token = await mintToken(event.platform?.env ?? {});
|
|
211
302
|
const existing = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
|
|
212
303
|
if (existing !== null) return bounce('An entry with that slug already exists.');
|
|
304
|
+
// A pending branch is an entry too (saved but not yet published); refuse to clobber it.
|
|
305
|
+
if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
|
|
306
|
+
return bounce('An unpublished entry with that slug already exists.');
|
|
307
|
+
}
|
|
213
308
|
|
|
214
309
|
throw redirect(303, `/admin/${concept.id}/${id}?new=1`);
|
|
215
310
|
}
|
|
@@ -236,12 +331,19 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
236
331
|
const isNew = event.url.searchParams.get('new') === '1';
|
|
237
332
|
const token = await mintToken(event.platform?.env ?? {});
|
|
238
333
|
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
334
|
+
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
335
|
+
// A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
|
|
336
|
+
// (link targets and the inbound-link guard) always reads main, the authoritative copy, and a
|
|
337
|
+
// pending entry adds a main read of its own path to derive its published state.
|
|
338
|
+
const branch = pendingBranch(concept.id, id);
|
|
339
|
+
const pending = (await branchHeadSha(runtime.backend, branch, token)) !== null;
|
|
340
|
+
const [raw, manifestRaw, mainRaw] = await Promise.all([
|
|
341
|
+
readRaw(pending ? { ...runtime.backend, branch } : runtime.backend, path, token),
|
|
242
342
|
readRaw(runtime.backend, runtime.manifestPath, token),
|
|
343
|
+
pending ? readRaw(runtime.backend, path, token) : Promise.resolve(null),
|
|
243
344
|
]);
|
|
244
345
|
if (raw === null && !isNew) throw error(404, 'Entry not found');
|
|
346
|
+
const published = pending ? mainRaw !== null : raw !== null;
|
|
245
347
|
|
|
246
348
|
const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
|
|
247
349
|
const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
|
|
@@ -276,6 +378,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
276
378
|
slug: slugFromId(id, datePrefix),
|
|
277
379
|
linkTargets,
|
|
278
380
|
inboundLinks: inbound,
|
|
381
|
+
pending,
|
|
382
|
+
published,
|
|
383
|
+
publishedFlash: event.url.searchParams.get('published') === '1',
|
|
384
|
+
discardedFlash: event.url.searchParams.get('discarded') === '1',
|
|
279
385
|
};
|
|
280
386
|
}
|
|
281
387
|
|
|
@@ -285,26 +391,65 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
285
391
|
}
|
|
286
392
|
|
|
287
393
|
/** Log a failed commit: a conflict is the expected last-writer-wins outcome, so it warns with a
|
|
288
|
-
* reason; any other error is unexpected and logs at error with the stringified cause.
|
|
289
|
-
*
|
|
290
|
-
function logCommitFailed(
|
|
394
|
+
* reason; any other error is unexpected and logs at error with the stringified cause. Publish
|
|
395
|
+
* failures carry the same shape under their own event name. */
|
|
396
|
+
function logCommitFailed(
|
|
397
|
+
fields: { concept: string; id: string; editor: string },
|
|
398
|
+
err: unknown,
|
|
399
|
+
event: 'commit.failed' | 'publish.failed' = 'commit.failed',
|
|
400
|
+
): void {
|
|
291
401
|
if (isConflict(err)) {
|
|
292
|
-
log.warn(
|
|
402
|
+
log.warn(event, { ...fields, reason: 'conflict' });
|
|
293
403
|
} else {
|
|
294
|
-
log.error(
|
|
404
|
+
log.error(event, { ...fields, error: String(err) });
|
|
295
405
|
}
|
|
296
406
|
}
|
|
297
407
|
|
|
298
|
-
/**
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
408
|
+
/** The shared commit catch for the entry actions: log the failure, bounce a conflict back to
|
|
409
|
+
* `page` with `message` as the inline error, and rethrow anything else. `query` keeps any extra
|
|
410
|
+
* params the bounce must carry (saveAction's `&new=1`). */
|
|
411
|
+
function commitFailure(
|
|
412
|
+
fields: { concept: string; id: string; editor: string },
|
|
413
|
+
err: unknown,
|
|
414
|
+
page: string,
|
|
415
|
+
message: string,
|
|
416
|
+
opts: { event?: 'commit.failed' | 'publish.failed'; query?: string } = {},
|
|
417
|
+
): never {
|
|
418
|
+
logCommitFailed(fields, err, opts.event);
|
|
419
|
+
if (isConflict(err)) {
|
|
420
|
+
throw redirect(303, `${page}?error=${encodeURIComponent(message)}${opts.query ?? ''}`);
|
|
421
|
+
}
|
|
422
|
+
throw err;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** The held outcome of a validated save: everything publish needs to copy the same markdown
|
|
426
|
+
* to main without re-reading the branch. `branchSha` is the branch commit saveToBranch just
|
|
427
|
+
* made, the guard for the post-publish branch delete; `manifest` is main's manifest with
|
|
428
|
+
* this entry's row upserted from the new markdown (the same last-writer-wins manifest race
|
|
429
|
+
* as delete and rename applies, caught by the build's fail-closed backstop). */
|
|
430
|
+
interface SaveHold {
|
|
431
|
+
path: string;
|
|
432
|
+
markdown: string;
|
|
433
|
+
branch: string;
|
|
434
|
+
branchSha: string;
|
|
435
|
+
manifest: Manifest;
|
|
436
|
+
/** The draft-target tokens the body links to, for save's warning query. */
|
|
437
|
+
draftLinks: string[];
|
|
438
|
+
token: string;
|
|
439
|
+
}
|
|
307
440
|
|
|
441
|
+
/** The shared core of save and publish: parse the posted form, validate the frontmatter,
|
|
442
|
+
* guard the body's cairn links, ensure the pending branch, and commit the entry file there
|
|
443
|
+
* with the session editor as author. Returns the broken-link fail for the page to render,
|
|
444
|
+
* or the held state; throws the redirect bounces save has always thrown (invalid
|
|
445
|
+
* frontmatter, a branch-commit conflict). Main stays untouched. */
|
|
446
|
+
async function saveToBranch(
|
|
447
|
+
event: ContentEvent,
|
|
448
|
+
editor: Editor,
|
|
449
|
+
concept: ConceptDescriptor,
|
|
450
|
+
id: string,
|
|
451
|
+
): Promise<ReturnType<typeof fail> | SaveHold> {
|
|
452
|
+
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
308
453
|
const form = await event.request.formData();
|
|
309
454
|
const body = String(form.get('body') ?? '');
|
|
310
455
|
const isNew = form.get('new') === '1';
|
|
@@ -319,59 +464,217 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
319
464
|
const markdown = serializeMarkdown(result.data, body);
|
|
320
465
|
const token = await mintToken(event.platform?.env ?? {});
|
|
321
466
|
|
|
322
|
-
//
|
|
323
|
-
// commit.
|
|
324
|
-
|
|
325
|
-
// 422 retry commitFiles re-sends this manifest blob last-writer-wins. A concurrent save can then
|
|
326
|
-
// leave the committed manifest stale, which the next build rejects via verifyManifest; regenerate
|
|
327
|
-
// it with npm run cairn:manifest to recover.
|
|
328
|
-
const manifestRaw = await readRaw(runtime.backend, runtime.manifestPath, token);
|
|
329
|
-
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
467
|
+
// Upsert this entry's row into main's manifest in memory, for the link guard here and for
|
|
468
|
+
// the publish commit. The save commits no manifest change; publish lands the upsert on main.
|
|
469
|
+
const manifest = await readManifest(token);
|
|
330
470
|
const row = manifestEntryFromFile(concept, { path, raw: markdown });
|
|
331
471
|
const upserted = upsertEntry(manifest, row);
|
|
332
|
-
const nextManifest = serializeManifest(upserted);
|
|
333
472
|
|
|
334
|
-
// Save guard: resolve the body's cairn links against
|
|
335
|
-
// self-link and a link to any
|
|
336
|
-
// the save (
|
|
337
|
-
// target commits with a warning, since it is valid and resolves once
|
|
473
|
+
// Save guard: resolve the body's cairn links against main's manifest with this entry upserted,
|
|
474
|
+
// so a self-link and a link to any published target resolves. A link to a target absent from
|
|
475
|
+
// main hard-blocks the save (publishing this entry before its target would red the deploy
|
|
476
|
+
// build); a link to a draft target commits with a warning, since it is valid and resolves once
|
|
477
|
+
// the target is published.
|
|
338
478
|
const byKey = new Map(upserted.entries.map((e) => [`${e.concept}/${e.id}`, e]));
|
|
339
479
|
const absent: string[] = [];
|
|
340
|
-
const
|
|
480
|
+
const draftLinks: string[] = [];
|
|
341
481
|
for (const ref of extractCairnLinks(body)) {
|
|
342
482
|
// A self-link is valid by construction (the upserted manifest holds this very entry), so
|
|
343
483
|
// skip it before classifying. Mirrors inboundLinks's self-exclusion.
|
|
344
484
|
if (ref.concept === concept.id && ref.id === id) continue;
|
|
345
485
|
const target = byKey.get(`${ref.concept}/${ref.id}`);
|
|
346
486
|
if (!target) absent.push(formatCairnToken(ref));
|
|
347
|
-
else if (target.draft)
|
|
487
|
+
else if (target.draft) draftLinks.push(formatCairnToken(ref));
|
|
348
488
|
}
|
|
349
489
|
if (absent.length) {
|
|
350
490
|
return fail(400, { brokenLinks: absent, body });
|
|
351
491
|
}
|
|
352
492
|
|
|
493
|
+
// Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
|
|
494
|
+
// commit only the entry file there. Main stays untouched until publish, so the branch differs
|
|
495
|
+
// from main at exactly this entry's path.
|
|
496
|
+
const branch = pendingBranch(concept.id, id);
|
|
497
|
+
if ((await branchHeadSha(runtime.backend, branch, token)) === null) {
|
|
498
|
+
const mainHead = await branchHeadSha(runtime.backend, runtime.backend.branch, token);
|
|
499
|
+
if (mainHead === null) throw error(500, 'Cannot read the default branch');
|
|
500
|
+
await createBranch(runtime.backend, branch, mainHead, token);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const commitFields = { concept: concept.id, id, editor: editor.email, branch };
|
|
504
|
+
let branchSha: string;
|
|
505
|
+
try {
|
|
506
|
+
branchSha = await commitFiles(
|
|
507
|
+
{ ...runtime.backend, branch },
|
|
508
|
+
[{ path, content: markdown }],
|
|
509
|
+
{ message: `Update ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
|
|
510
|
+
token,
|
|
511
|
+
);
|
|
512
|
+
log.info('commit.succeeded', commitFields);
|
|
513
|
+
} catch (err) {
|
|
514
|
+
commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
|
|
515
|
+
'This file changed since you opened it. Reload and reapply your edits.', { query: suffix });
|
|
516
|
+
}
|
|
517
|
+
return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, token };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Save an edit: validate, then commit to the entry's pending branch with the session editor
|
|
521
|
+
* as author. Main and its manifest stay untouched until publish. Fails safe on 409. */
|
|
522
|
+
async function saveAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
523
|
+
const editor = sessionOf(event);
|
|
524
|
+
const concept = conceptOf(runtime, event.params);
|
|
525
|
+
const id = event.params.id ?? '';
|
|
526
|
+
// Confine the commit path to the concept dir, built from a validated id (the App token can
|
|
527
|
+
// write anywhere in the repo). Reject before touching GitHub.
|
|
528
|
+
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
529
|
+
const held = await saveToBranch(event, editor, concept, id);
|
|
530
|
+
if (!('branchSha' in held)) return held;
|
|
531
|
+
const savedQuery = held.draftLinks.length
|
|
532
|
+
? `saved=1&drafts=${encodeURIComponent(held.draftLinks.join(','))}`
|
|
533
|
+
: 'saved=1';
|
|
534
|
+
throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Publish an entry: validate and hold the posted form exactly like save (the branch gets the
|
|
538
|
+
* same commit), then copy that markdown to main with the manifest row upserted in one atomic
|
|
539
|
+
* commit. Publish-what-you-see: the posted form is the published content, so text typed
|
|
540
|
+
* after the last save goes live too, and publish works regardless of prior branch state.
|
|
541
|
+
* The branch is deleted only when its head still matches the commit this action made; a
|
|
542
|
+
* concurrent save moved it, so the entry stays pending and the next publish picks it up. */
|
|
543
|
+
async function publishAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
544
|
+
const editor = sessionOf(event);
|
|
545
|
+
const concept = conceptOf(runtime, event.params);
|
|
546
|
+
const id = event.params.id ?? '';
|
|
547
|
+
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
548
|
+
const held = await saveToBranch(event, editor, concept, id);
|
|
549
|
+
if (!('branchSha' in held)) return held;
|
|
550
|
+
const { path, markdown, branch, branchSha, manifest, token } = held;
|
|
551
|
+
|
|
353
552
|
const commitFields = { concept: concept.id, id, editor: editor.email };
|
|
354
553
|
try {
|
|
355
554
|
await commitFiles(
|
|
356
555
|
runtime.backend,
|
|
357
556
|
[
|
|
358
557
|
{ path, content: markdown },
|
|
359
|
-
{ path: runtime.manifestPath, content:
|
|
558
|
+
{ path: runtime.manifestPath, content: serializeManifest(manifest) },
|
|
360
559
|
],
|
|
361
|
-
{ message: `
|
|
560
|
+
{ message: `Publish ${concept.label.toLowerCase()}: ${id}`, author: { name: editor.displayName, email: editor.email } },
|
|
362
561
|
token,
|
|
363
562
|
);
|
|
364
|
-
log.info('
|
|
563
|
+
log.info('entry.published', { ...commitFields, batch: false });
|
|
365
564
|
} catch (err) {
|
|
366
|
-
|
|
565
|
+
// The branch already holds the just-committed edits, so a conflict here loses nothing.
|
|
566
|
+
commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
|
|
567
|
+
'Your edits are saved. Reload and publish again.', { event: 'publish.failed' });
|
|
568
|
+
}
|
|
569
|
+
// Only after the main commit lands, and only when the branch head is still the commit this
|
|
570
|
+
// action made: a head that moved is a concurrent save, and deleting it would destroy edits.
|
|
571
|
+
// No log event for the skip; the pending badge is the surface.
|
|
572
|
+
if ((await branchHeadSha(runtime.backend, branch, token)) === branchSha) {
|
|
573
|
+
await deleteBranch(runtime.backend, branch, token);
|
|
574
|
+
}
|
|
575
|
+
throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** Publish every pending entry site-wide: one atomic commit on main carrying each branch's
|
|
579
|
+
* entry file plus the manifest with every row upserted, then delete the consumed branches.
|
|
580
|
+
* Mounted on the concept list shim, but the topbar posts here from anywhere, so the route's
|
|
581
|
+
* concept param is ignored and the redirect lands on the first configured concept. */
|
|
582
|
+
async function publishAllAction(event: ContentEvent): Promise<never> {
|
|
583
|
+
const editor = sessionOf(event);
|
|
584
|
+
const first = runtime.concepts[0];
|
|
585
|
+
if (!first) throw error(404, 'No content types configured');
|
|
586
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
587
|
+
const listPage = `/admin/${first.id}`;
|
|
588
|
+
|
|
589
|
+
// Each cairn/ ref names a pending entry; the shared predicate skips a stray ref rather
|
|
590
|
+
// than failing the whole batch on it.
|
|
591
|
+
const names = await listBranches(runtime.backend, PENDING_PREFIX, token);
|
|
592
|
+
const pending = names.flatMap((name) => {
|
|
593
|
+
const entry = pendingEntryOf(name);
|
|
594
|
+
return entry ? [{ ...entry, branch: name, path: `${entry.concept.dir}/${filenameFromId(entry.id)}` }] : [];
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Read every branch in parallel, capturing each head sha BEFORE its file read: the sha
|
|
598
|
+
// guards the post-publish delete, and probing first fails safe (a save landing between the
|
|
599
|
+
// probe and the read moves the head past the capture, so the delete is skipped and the
|
|
600
|
+
// entry stays pending). A ghost ref whose entry file is missing is skipped (discard can
|
|
601
|
+
// clean it up); it carries nothing to publish.
|
|
602
|
+
const reads = await Promise.all(
|
|
603
|
+
pending.map(async (entry) => {
|
|
604
|
+
const sha = await branchHeadSha(runtime.backend, entry.branch, token);
|
|
605
|
+
const raw = await readRaw({ ...runtime.backend, branch: entry.branch }, entry.path, token);
|
|
606
|
+
return { ...entry, sha, raw };
|
|
607
|
+
}),
|
|
608
|
+
);
|
|
609
|
+
|
|
610
|
+
// Fold main's manifest once over every row, so the batch lands content and index together,
|
|
611
|
+
// the same shape as a single publish.
|
|
612
|
+
let next = await readManifest(token);
|
|
613
|
+
const changes: FileChange[] = [];
|
|
614
|
+
const published: { concept: string; id: string; branch: string; sha: string }[] = [];
|
|
615
|
+
for (const entry of reads) {
|
|
616
|
+
if (entry.raw === null || entry.sha === null) continue;
|
|
617
|
+
changes.push({ path: entry.path, content: entry.raw });
|
|
618
|
+
next = upsertEntry(next, manifestEntryFromFile(entry.concept, { path: entry.path, raw: entry.raw }));
|
|
619
|
+
published.push({ concept: entry.concept.id, id: entry.id, branch: entry.branch, sha: entry.sha });
|
|
620
|
+
}
|
|
621
|
+
if (published.length === 0) throw redirect(303, listPage);
|
|
622
|
+
changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
await commitFiles(
|
|
626
|
+
runtime.backend,
|
|
627
|
+
changes,
|
|
628
|
+
{ message: `Publish ${published.length} entries`, author: { name: editor.displayName, email: editor.email } },
|
|
629
|
+
token,
|
|
630
|
+
);
|
|
631
|
+
for (const entry of published) {
|
|
632
|
+
log.info('entry.published', { concept: entry.concept, id: entry.id, editor: editor.email, batch: true });
|
|
633
|
+
}
|
|
634
|
+
} catch (err) {
|
|
635
|
+
// One record per entry in the failed batch, so the log names what did not go live.
|
|
636
|
+
for (const entry of published) {
|
|
637
|
+
logCommitFailed({ concept: entry.concept, id: entry.id, editor: editor.email }, err, 'publish.failed');
|
|
638
|
+
}
|
|
367
639
|
if (isConflict(err)) {
|
|
368
|
-
const message = '
|
|
369
|
-
throw redirect(303,
|
|
640
|
+
const message = 'The site changed while publishing. Reload and try again.';
|
|
641
|
+
throw redirect(303, `${listPage}?error=${encodeURIComponent(message)}`);
|
|
370
642
|
}
|
|
371
643
|
throw err;
|
|
372
644
|
}
|
|
373
|
-
|
|
374
|
-
|
|
645
|
+
// Only after the main commit lands: a failure above keeps every branch and its edits. Each
|
|
646
|
+
// branch deletes only when its head still matches the captured sha; a moved head is a
|
|
647
|
+
// concurrent save, so the entry stays pending and the next publish picks it up (no log
|
|
648
|
+
// event for the skip; the pending badge is the surface). A failed delete leaves an
|
|
649
|
+
// idempotent straggler (re-publishing copies the same content), so one failure does not
|
|
650
|
+
// abort the remaining deletes.
|
|
651
|
+
for (const entry of published) {
|
|
652
|
+
try {
|
|
653
|
+
if ((await branchHeadSha(runtime.backend, entry.branch, token)) === entry.sha) {
|
|
654
|
+
await deleteBranch(runtime.backend, entry.branch, token);
|
|
655
|
+
}
|
|
656
|
+
} catch {
|
|
657
|
+
// The entry is live; the straggler just shows as still pending until the next publish.
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
throw redirect(303, `${listPage}?publishedAll=${published.length}`);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Discard an entry's pending edits: delete the branch (tolerant of already-gone) and return to
|
|
664
|
+
* the edit page when the entry lives on main, else to the list (the entry is gone entirely). */
|
|
665
|
+
async function discardAction(event: ContentEvent): Promise<never> {
|
|
666
|
+
const editor = sessionOf(event);
|
|
667
|
+
const concept = conceptOf(runtime, event.params);
|
|
668
|
+
const id = event.params.id ?? '';
|
|
669
|
+
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
670
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
671
|
+
|
|
672
|
+
await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
|
|
673
|
+
log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
|
|
674
|
+
|
|
675
|
+
const onMain = await readRaw(runtime.backend, `${concept.dir}/${filenameFromId(id)}`, token);
|
|
676
|
+
if (onMain !== null) throw redirect(303, `/admin/${concept.id}/${id}?discarded=1`);
|
|
677
|
+
throw redirect(303, `/admin/${concept.id}`);
|
|
375
678
|
}
|
|
376
679
|
|
|
377
680
|
/** The shared delete core. Block-until-clean: refuse while inbound links exist (naming them), else
|
|
@@ -390,13 +693,22 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
390
693
|
|
|
391
694
|
// An absent manifest degrades the inbound gate to "allow": with no manifest there is nothing to
|
|
392
695
|
// check, and the build's cairn: backstop still catches any dangling token, mirroring saveAction.
|
|
393
|
-
const
|
|
394
|
-
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
696
|
+
const manifest = await readManifest(token);
|
|
395
697
|
const inbound = inboundLinks(manifest, concept.id, id);
|
|
396
698
|
if (inbound.length) {
|
|
397
699
|
return fail(409, { inboundLinks: inbound, id });
|
|
398
700
|
}
|
|
399
701
|
|
|
702
|
+
// When the entry was never published (absent from main), the branch delete is the whole
|
|
703
|
+
// operation; main has nothing to commit, so the only honest log record is the discard of
|
|
704
|
+
// the pending edits.
|
|
705
|
+
const onMain = await readRaw(runtime.backend, path, token);
|
|
706
|
+
if (onMain === null) {
|
|
707
|
+
await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
|
|
708
|
+
log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
|
|
709
|
+
throw redirect(303, `/admin/${concept.id}`);
|
|
710
|
+
}
|
|
711
|
+
|
|
400
712
|
const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
|
|
401
713
|
const commitFields = { concept: concept.id, id, editor: editor.email };
|
|
402
714
|
try {
|
|
@@ -411,12 +723,17 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
411
723
|
);
|
|
412
724
|
log.info('commit.succeeded', commitFields);
|
|
413
725
|
} catch (err) {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
726
|
+
commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
|
|
727
|
+
'This file changed since you opened it. Reload and try again.');
|
|
728
|
+
}
|
|
729
|
+
// Cascade to the pending branch only after the removal lands on main, so a commit conflict
|
|
730
|
+
// keeps the unpublished edits. A straggler ref left by a failure here is idempotent and
|
|
731
|
+
// recoverable (it lists as a never-published row a discard can clean up), matching
|
|
732
|
+
// publish's posture, so the entry's deletion still completes.
|
|
733
|
+
try {
|
|
734
|
+
await deleteBranch(runtime.backend, pendingBranch(concept.id, id), token);
|
|
735
|
+
} catch {
|
|
736
|
+
// The entry is gone from main; the straggler shows as a pending row until discarded.
|
|
420
737
|
}
|
|
421
738
|
throw redirect(303, `/admin/${concept.id}`);
|
|
422
739
|
}
|
|
@@ -449,6 +766,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
449
766
|
const concept = conceptOf(runtime, event.params);
|
|
450
767
|
const id = event.params.id ?? '';
|
|
451
768
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
769
|
+
const token = await mintToken(event.platform?.env ?? {});
|
|
770
|
+
|
|
771
|
+
// Pending edits on the branch are keyed to the old id; renaming underneath them would strand
|
|
772
|
+
// them, so refuse until the editor publishes or discards.
|
|
773
|
+
if ((await branchHeadSha(runtime.backend, pendingBranch(concept.id, id), token)) !== null) {
|
|
774
|
+
return fail(409, { renameError: 'This entry has unpublished edits. Publish or discard them, then rename.' });
|
|
775
|
+
}
|
|
452
776
|
|
|
453
777
|
const form = await event.request.formData();
|
|
454
778
|
const newSlug = String(form.get('slug') ?? '').trim();
|
|
@@ -465,7 +789,6 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
465
789
|
const newId = renameId(id, newSlug, datePrefix);
|
|
466
790
|
const oldPath = `${concept.dir}/${filenameFromId(id)}`;
|
|
467
791
|
const newPath = `${concept.dir}/${filenameFromId(newId)}`;
|
|
468
|
-
const token = await mintToken(event.platform?.env ?? {});
|
|
469
792
|
|
|
470
793
|
// Collision guard: refuse if a file already exists at the new path. This 409 covers two cases a
|
|
471
794
|
// single readRaw cannot tell apart: a static collision with an existing entry, and a
|
|
@@ -475,12 +798,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
475
798
|
return fail(409, { renameError: 'An entry with that slug already exists.' });
|
|
476
799
|
}
|
|
477
800
|
|
|
478
|
-
const [entryRaw,
|
|
801
|
+
const [entryRaw, manifest] = await Promise.all([
|
|
479
802
|
readRaw(runtime.backend, oldPath, token),
|
|
480
|
-
|
|
803
|
+
readManifest(token),
|
|
481
804
|
]);
|
|
482
805
|
if (entryRaw === null) throw error(404, 'Entry not found');
|
|
483
|
-
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
484
806
|
|
|
485
807
|
const oldHref = formatCairnToken({ concept: concept.id, id });
|
|
486
808
|
const newHref = formatCairnToken({ concept: concept.id, id: newId });
|
|
@@ -520,15 +842,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
520
842
|
);
|
|
521
843
|
log.info('commit.succeeded', commitFields);
|
|
522
844
|
} catch (err) {
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
const message = 'This file changed since you opened it. Reload and try again.';
|
|
526
|
-
throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}`);
|
|
527
|
-
}
|
|
528
|
-
throw err;
|
|
845
|
+
commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
|
|
846
|
+
'This file changed since you opened it. Reload and try again.');
|
|
529
847
|
}
|
|
530
848
|
throw redirect(303, `/admin/${concept.id}/${newId}?renamed=1`);
|
|
531
849
|
}
|
|
532
850
|
|
|
533
|
-
return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, deleteAction, listDeleteAction, renameAction, mintToken };
|
|
851
|
+
return { layoutLoad, indexRedirect, listLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, mintToken };
|
|
534
852
|
}
|