@glw907/cairn-cms 0.68.0 → 0.76.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 +82 -0
- package/dist/ambient.d.ts +2 -0
- package/dist/components/CairnAdmin.svelte.d.ts +2 -7
- package/dist/components/ComponentForm.svelte +44 -27
- package/dist/components/ComponentInsertDialog.svelte +5 -5
- package/dist/components/ComponentInsertDialog.svelte.d.ts +2 -6
- package/dist/components/EditPage.svelte +29 -107
- package/dist/components/EditPage.svelte.d.ts +2 -7
- package/dist/components/EntryPicker.svelte +117 -0
- package/dist/components/EntryPicker.svelte.d.ts +35 -0
- package/dist/components/FieldInput.svelte +218 -0
- package/dist/components/FieldInput.svelte.d.ts +51 -0
- package/dist/components/IconPicker.svelte +2 -2
- package/dist/components/IconPicker.svelte.d.ts +2 -0
- package/dist/components/LinkPicker.svelte +8 -75
- package/dist/components/LinkPicker.svelte.d.ts +4 -5
- package/dist/components/MediaHeroField.svelte +8 -5
- package/dist/components/MediaHeroField.svelte.d.ts +4 -0
- package/dist/components/ObjectGroupField.svelte +54 -0
- package/dist/components/ObjectGroupField.svelte.d.ts +47 -0
- package/dist/components/ReferenceField.svelte +94 -0
- package/dist/components/ReferenceField.svelte.d.ts +27 -0
- package/dist/components/RepeatableField.svelte +221 -0
- package/dist/components/RepeatableField.svelte.d.ts +53 -0
- package/dist/components/cairn-admin.css +4 -0
- package/dist/components/preview-doc.js +5 -1
- package/dist/components/tidy-validate.js +1 -1
- package/dist/content/adapter.js +18 -0
- package/dist/content/advisories.d.ts +2 -2
- package/dist/content/advisories.js +3 -5
- package/dist/content/compose.d.ts +7 -6
- package/dist/content/compose.js +26 -20
- package/dist/content/concepts.d.ts +21 -15
- package/dist/content/concepts.js +55 -32
- package/dist/content/field-rules.js +3 -4
- package/dist/content/fields.d.ts +49 -1
- package/dist/content/fields.js +11 -0
- package/dist/content/fieldset.d.ts +31 -10
- package/dist/content/fieldset.js +262 -109
- package/dist/content/frontmatter-region.d.ts +38 -0
- package/dist/content/frontmatter-region.js +75 -0
- package/dist/content/frontmatter.d.ts +35 -2
- package/dist/content/frontmatter.js +232 -11
- package/dist/content/manifest.d.ts +34 -0
- package/dist/content/manifest.js +80 -4
- package/dist/content/media-refs.d.ts +2 -2
- package/dist/content/media-rewrite.js +1 -69
- package/dist/content/reference-index.d.ts +56 -0
- package/dist/content/reference-index.js +95 -0
- package/dist/content/references.d.ts +40 -0
- package/dist/content/references.js +0 -0
- package/dist/content/standard-schema.d.ts +30 -0
- package/dist/content/standard-schema.js +4 -0
- package/dist/content/types.d.ts +127 -178
- package/dist/delivery/data.d.ts +2 -2
- package/dist/delivery/data.js +1 -1
- package/dist/delivery/public-routes.d.ts +2 -5
- package/dist/delivery/public-routes.js +15 -1
- package/dist/delivery/site-descriptors.d.ts +5 -1
- package/dist/delivery/site-descriptors.js +8 -3
- package/dist/delivery/site-indexes.d.ts +2 -2
- package/dist/delivery/site-resolver.d.ts +25 -0
- package/dist/delivery/site-resolver.js +49 -0
- package/dist/doctor/checks-local.js +6 -11
- package/dist/github/backend.d.ts +83 -0
- package/dist/github/backend.js +76 -0
- package/dist/github/credentials.d.ts +11 -5
- package/dist/github/credentials.js +3 -3
- package/dist/github/repo.d.ts +8 -19
- package/dist/github/repo.js +69 -80
- package/dist/github/types.d.ts +1 -1
- package/dist/github/types.js +4 -4
- package/dist/index.d.ts +16 -12
- package/dist/index.js +7 -8
- package/dist/islands/index.d.ts +12 -0
- package/dist/islands/index.js +83 -0
- package/dist/islands/types.d.ts +7 -0
- package/dist/islands/types.js +1 -0
- package/dist/media/rewrite-plan.d.ts +2 -3
- package/dist/media/rewrite-plan.js +2 -3
- package/dist/media/usage.d.ts +2 -2
- package/dist/media/usage.js +3 -5
- package/dist/nav/site-config.d.ts +0 -6
- package/dist/nav/site-config.js +6 -4
- package/dist/render/component-grammar.js +11 -11
- package/dist/render/component-reference.js +5 -3
- package/dist/render/component-validate.d.ts +4 -1
- package/dist/render/component-validate.js +10 -35
- package/dist/render/pipeline.d.ts +0 -6
- package/dist/render/pipeline.js +1 -1
- package/dist/render/registry.d.ts +34 -34
- package/dist/render/registry.js +26 -5
- package/dist/render/rehype-dispatch.d.ts +4 -4
- package/dist/render/rehype-dispatch.js +36 -11
- package/dist/render/remark-directives.js +4 -5
- package/dist/render/sanitize-schema.js +1 -1
- package/dist/sveltekit/cairn-admin.d.ts +5 -5
- package/dist/sveltekit/cairn-admin.js +3 -4
- package/dist/sveltekit/content-routes.d.ts +10 -8
- package/dist/sveltekit/content-routes.js +269 -181
- package/dist/sveltekit/health.d.ts +7 -3
- package/dist/sveltekit/health.js +9 -3
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/nav-routes.d.ts +6 -5
- package/dist/sveltekit/nav-routes.js +22 -20
- package/dist/sveltekit/types.d.ts +2 -0
- package/dist/vite/index.d.ts +3 -3
- package/dist/vite/index.js +17 -8
- package/package.json +5 -1
- package/src/lib/ambient.ts +7 -0
- package/src/lib/components/CairnAdmin.svelte +2 -6
- package/src/lib/components/ComponentForm.svelte +48 -27
- package/src/lib/components/ComponentInsertDialog.svelte +9 -8
- package/src/lib/components/EditPage.svelte +43 -119
- package/src/lib/components/EntryPicker.svelte +154 -0
- package/src/lib/components/FieldInput.svelte +262 -0
- package/src/lib/components/IconPicker.svelte +4 -2
- package/src/lib/components/LinkPicker.svelte +10 -81
- package/src/lib/components/MediaHeroField.svelte +12 -5
- package/src/lib/components/ObjectGroupField.svelte +97 -0
- package/src/lib/components/ReferenceField.svelte +126 -0
- package/src/lib/components/RepeatableField.svelte +310 -0
- package/src/lib/components/preview-doc.ts +5 -1
- package/src/lib/components/tidy-validate.ts +1 -1
- package/src/lib/content/adapter.ts +21 -0
- package/src/lib/content/advisories.ts +4 -7
- package/src/lib/content/compose.ts +30 -23
- package/src/lib/content/concepts.ts +68 -40
- package/src/lib/content/field-rules.ts +3 -4
- package/src/lib/content/fields.ts +52 -1
- package/src/lib/content/fieldset.ts +291 -128
- package/src/lib/content/frontmatter-region.ts +90 -0
- package/src/lib/content/frontmatter.ts +231 -15
- package/src/lib/content/manifest.ts +101 -4
- package/src/lib/content/media-refs.ts +2 -2
- package/src/lib/content/media-rewrite.ts +7 -80
- package/src/lib/content/reference-index.ts +159 -0
- package/src/lib/content/references.ts +0 -0
- package/src/lib/content/standard-schema.ts +25 -0
- package/src/lib/content/types.ts +128 -195
- package/src/lib/delivery/data.ts +2 -2
- package/src/lib/delivery/public-routes.ts +17 -3
- package/src/lib/delivery/site-descriptors.ts +8 -3
- package/src/lib/delivery/site-indexes.ts +2 -2
- package/src/lib/delivery/site-resolver.ts +64 -0
- package/src/lib/doctor/checks-local.ts +6 -14
- package/src/lib/github/backend.ts +161 -0
- package/src/lib/github/credentials.ts +10 -7
- package/src/lib/github/repo.ts +79 -83
- package/src/lib/github/types.ts +5 -5
- package/src/lib/index.ts +38 -23
- package/src/lib/islands/index.ts +84 -0
- package/src/lib/islands/types.ts +11 -0
- package/src/lib/media/rewrite-plan.ts +4 -6
- package/src/lib/media/usage.ts +4 -7
- package/src/lib/nav/site-config.ts +8 -9
- package/src/lib/render/component-grammar.ts +10 -10
- package/src/lib/render/component-reference.ts +4 -3
- package/src/lib/render/component-validate.ts +10 -35
- package/src/lib/render/pipeline.ts +1 -7
- package/src/lib/render/registry.ts +58 -39
- package/src/lib/render/rehype-dispatch.ts +45 -10
- package/src/lib/render/remark-directives.ts +4 -5
- package/src/lib/render/sanitize-schema.ts +1 -1
- package/src/lib/sveltekit/cairn-admin.ts +8 -9
- package/src/lib/sveltekit/content-routes.ts +330 -221
- package/src/lib/sveltekit/health.ts +13 -6
- package/src/lib/sveltekit/index.ts +2 -2
- package/src/lib/sveltekit/nav-routes.ts +33 -29
- package/src/lib/sveltekit/types.ts +5 -1
- package/src/lib/vite/index.ts +20 -11
- package/dist/content/schema.d.ts +0 -87
- package/dist/content/schema.js +0 -85
- package/dist/content/validate.d.ts +0 -17
- package/dist/content/validate.js +0 -93
- package/src/lib/content/schema.ts +0 -163
- package/src/lib/content/validate.ts +0 -90
|
@@ -5,17 +5,16 @@
|
|
|
5
5
|
import { redirect, error, fail } from '@sveltejs/kit';
|
|
6
6
|
import { findConcept } from '../content/concepts.js';
|
|
7
7
|
import { extractCairnLinks, formatCairnToken, rewriteCairnLink } from '../content/links.js';
|
|
8
|
-
import {
|
|
8
|
+
import { extractReferenceEdges, rewriteFrontmatterReference } from '../content/references.js';
|
|
9
|
+
import { buildReferenceIndex } from '../content/reference-index.js';
|
|
10
|
+
import { frontmatterFromForm, formValues, parseMarkdown, dateInputValue, serializeMarkdown } from '../content/frontmatter.js';
|
|
11
|
+
import { initialValues } from '../content/fieldset.js';
|
|
9
12
|
import { deriveExcerpt } from '../content/excerpt.js';
|
|
10
13
|
import { asString, entryIdentity } from '../content/identity.js';
|
|
11
14
|
import { buildAddressIndex, mainAddressIndex, addressCollision } from '../content/advisories.js';
|
|
12
15
|
import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
|
|
13
|
-
import { appCredentials } from '../github/credentials.js';
|
|
14
|
-
import { listMarkdown, readRaw, commitFile, commitFiles } from '../github/repo.js';
|
|
15
|
-
import { branchHeadSha, createBranch, deleteBranch, listBranches } from '../github/branches.js';
|
|
16
16
|
import { PENDING_PREFIX, pendingBranch, parsePendingBranch } from '../content/pending.js';
|
|
17
|
-
import {
|
|
18
|
-
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks } from '../content/manifest.js';
|
|
17
|
+
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, inboundReferences } from '../content/manifest.js';
|
|
19
18
|
import { deriveGettingStarted } from '../content/getting-started.js';
|
|
20
19
|
import { markdownReference } from '../components/markdown-reference.js';
|
|
21
20
|
import { isConflict } from '../github/types.js';
|
|
@@ -103,7 +102,15 @@ function conceptOf(runtime, params) {
|
|
|
103
102
|
*
|
|
104
103
|
*/
|
|
105
104
|
export function createContentRoutes(runtime, deps = {}) {
|
|
106
|
-
|
|
105
|
+
/**
|
|
106
|
+
* Resolve the live content backend for one request. A test seam (`deps.backend`) wins, then the
|
|
107
|
+
* dev double's `event.locals.backend`, then the production `runtime.backend.connect(env)`. The
|
|
108
|
+
* GitHub provider mints and caches its installation token lazily behind `connect`, so a
|
|
109
|
+
* per-request resolve re-signs only on a cache miss.
|
|
110
|
+
*/
|
|
111
|
+
function resolveBackend(event) {
|
|
112
|
+
return deps.backend ?? event.locals.backend ?? runtime.backend.connect(event.platform?.env ?? {});
|
|
113
|
+
}
|
|
107
114
|
// The default Anthropic factory builds the real SDK client from the resolved key. Tests inject a fake
|
|
108
115
|
// (deps.anthropic) so messages.create is stubbed and no network call or real key is ever needed. The
|
|
109
116
|
// SDK client satisfies TidyClient structurally; the cast names that to the compiler.
|
|
@@ -113,8 +120,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
113
120
|
* Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
|
|
114
121
|
* Always read from main: pending branches carry no manifest copy.
|
|
115
122
|
*/
|
|
116
|
-
async function readManifest(
|
|
117
|
-
const raw = await
|
|
123
|
+
async function readManifest(backend) {
|
|
124
|
+
const raw = await backend.readFile(runtime.manifestPath, backend.defaultBranch);
|
|
118
125
|
return raw === null ? emptyManifest() : parseManifest(raw);
|
|
119
126
|
}
|
|
120
127
|
/**
|
|
@@ -162,8 +169,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
162
169
|
// than failing the whole admin shell or showing a wrong publish-all count.
|
|
163
170
|
let pendingEntries = null;
|
|
164
171
|
try {
|
|
165
|
-
const
|
|
166
|
-
const names = await listBranches(
|
|
172
|
+
const backend = resolveBackend(event);
|
|
173
|
+
const names = await backend.listBranches(PENDING_PREFIX);
|
|
167
174
|
pendingEntries = names.flatMap((name) => {
|
|
168
175
|
const entry = pendingEntryOf(name);
|
|
169
176
|
return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
|
|
@@ -196,9 +203,9 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
196
203
|
let manifest = emptyManifest();
|
|
197
204
|
let pending = [];
|
|
198
205
|
try {
|
|
199
|
-
const
|
|
200
|
-
manifest = await readManifest(
|
|
201
|
-
const names = await listBranches(
|
|
206
|
+
const backend = resolveBackend(event);
|
|
207
|
+
manifest = await readManifest(backend);
|
|
208
|
+
const names = await backend.listBranches(PENDING_PREFIX);
|
|
202
209
|
pending = names.flatMap((name) => {
|
|
203
210
|
const entry = pendingEntryOf(name);
|
|
204
211
|
return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
|
|
@@ -224,9 +231,9 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
224
231
|
* Read a file's frontmatter for its list row, degrading to the id on any read failure. The
|
|
225
232
|
* repo defaults to main; a pending entry (edited or branch-only) passes its pending branch.
|
|
226
233
|
*/
|
|
227
|
-
async function summarize(file,
|
|
234
|
+
async function summarize(file, backend, status, ref = backend.defaultBranch) {
|
|
228
235
|
try {
|
|
229
|
-
const raw = await
|
|
236
|
+
const raw = await backend.readFile(file.path, ref);
|
|
230
237
|
if (raw === null)
|
|
231
238
|
return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
|
|
232
239
|
const { frontmatter, body } = parseMarkdown(raw);
|
|
@@ -246,22 +253,19 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
246
253
|
* in the list instead of reading as a lost save. summarize degrades a failed or empty read to
|
|
247
254
|
* an id-only row, so a ghost ref still lists.
|
|
248
255
|
*/
|
|
249
|
-
function pendingRow(concept, id, status,
|
|
250
|
-
return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` },
|
|
251
|
-
...runtime.backend,
|
|
252
|
-
branch: pendingBranch(concept.id, id),
|
|
253
|
-
});
|
|
256
|
+
function pendingRow(concept, id, status, backend) {
|
|
257
|
+
return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, backend, status, pendingBranch(concept.id, id));
|
|
254
258
|
}
|
|
255
259
|
/**
|
|
256
260
|
* The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
|
|
257
261
|
* and read each one for its row, with edited and new rows reading branch-first.
|
|
258
262
|
*/
|
|
259
|
-
async function crawlEntries(concept, pendingIds,
|
|
260
|
-
const files = await
|
|
261
|
-
const entries = await Promise.all(files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited',
|
|
263
|
+
async function crawlEntries(concept, pendingIds, backend) {
|
|
264
|
+
const files = await backend.readEntries(concept.dir, backend.defaultBranch);
|
|
265
|
+
const entries = await Promise.all(files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', backend) : summarize(f, backend, 'published'))));
|
|
262
266
|
// A ref with no main file is a never-published entry; its row reads from its branch.
|
|
263
267
|
const listed = new Set(files.map((f) => f.id));
|
|
264
|
-
const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new',
|
|
268
|
+
const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', backend)));
|
|
265
269
|
return [...entries, ...newRows];
|
|
266
270
|
}
|
|
267
271
|
/**
|
|
@@ -279,17 +283,11 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
279
283
|
const publishedAllRaw = event.url.searchParams.get('publishedAll');
|
|
280
284
|
const publishedAll = publishedAllRaw !== null && /^\d+$/.test(publishedAllRaw) ? Number(publishedAllRaw) : null;
|
|
281
285
|
const base = { conceptId: concept.id, label: concept.label, singular: concept.singular, dated: concept.routing.dated, formError, publishedAll };
|
|
282
|
-
|
|
283
|
-
try {
|
|
284
|
-
token = await mintToken(event.platform?.env ?? {});
|
|
285
|
-
}
|
|
286
|
-
catch {
|
|
287
|
-
return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
|
|
288
|
-
}
|
|
286
|
+
const backend = resolveBackend(event);
|
|
289
287
|
try {
|
|
290
288
|
const [manifestRaw, refs] = await Promise.all([
|
|
291
|
-
|
|
292
|
-
listBranches(
|
|
289
|
+
backend.readFile(runtime.manifestPath, backend.defaultBranch),
|
|
290
|
+
backend.listBranches(`${PENDING_PREFIX}${concept.id}/`),
|
|
293
291
|
]);
|
|
294
292
|
const pendingIds = new Set(refs.flatMap((name) => {
|
|
295
293
|
const entry = pendingEntryOf(name);
|
|
@@ -298,17 +296,17 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
298
296
|
// A repo with no committed manifest yet (a fresh site before its first publish) falls back
|
|
299
297
|
// to the crawl; a manifest that parses but is empty is trusted as-is.
|
|
300
298
|
if (manifestRaw === null) {
|
|
301
|
-
return { ...base, entries: await crawlEntries(concept, pendingIds,
|
|
299
|
+
return { ...base, entries: await crawlEntries(concept, pendingIds, backend), error: null };
|
|
302
300
|
}
|
|
303
301
|
// Newest id first, the same order the crawl's file listing produced.
|
|
304
302
|
const rows = parseManifest(manifestRaw)
|
|
305
303
|
.entries.filter((e) => e.concept === concept.id)
|
|
306
304
|
.sort((a, b) => b.id.localeCompare(a.id));
|
|
307
305
|
const entries = await Promise.all(rows.map((e) => pendingIds.has(e.id)
|
|
308
|
-
? pendingRow(concept, e.id, 'edited',
|
|
306
|
+
? pendingRow(concept, e.id, 'edited', backend)
|
|
309
307
|
: { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published', summary: e.summary ?? null }));
|
|
310
308
|
const listed = new Set(rows.map((e) => e.id));
|
|
311
|
-
const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new',
|
|
309
|
+
const newRows = await Promise.all([...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', backend)));
|
|
312
310
|
return { ...base, entries: [...entries, ...newRows], error: null };
|
|
313
311
|
}
|
|
314
312
|
catch {
|
|
@@ -342,28 +340,24 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
342
340
|
else if (event.url.searchParams.get('orphansPurged') === '1')
|
|
343
341
|
flash = 'orphansPurged';
|
|
344
342
|
const flashError = event.url.searchParams.get('error');
|
|
345
|
-
|
|
346
|
-
try {
|
|
347
|
-
token = await mintToken(event.platform?.env ?? {});
|
|
348
|
-
}
|
|
349
|
-
catch {
|
|
350
|
-
return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.', flash, flashError };
|
|
351
|
-
}
|
|
343
|
+
const backend = resolveBackend(event);
|
|
352
344
|
// Union the media manifest by hash: main's rows first, then any branch hash not already present.
|
|
353
345
|
// Identical bytes share one row, so a hash on both branches prefers main's row. A failed or
|
|
354
346
|
// absent branch read degrades to no rows for that branch (the tolerant parse yields {} on null).
|
|
355
347
|
// The branch list is taken ONCE here and handed to buildUsageIndex below, so the load path does
|
|
356
348
|
// not enumerate the open branches twice (the per-page subrequest budget is tight at ~25+ branches).
|
|
349
|
+
// The token mint is now lazy inside the first read, so a token or a network failure both land in
|
|
350
|
+
// this one degrade rather than the old separate could-not-authenticate tier.
|
|
357
351
|
const union = new Map();
|
|
358
352
|
let branchNames = [];
|
|
359
353
|
try {
|
|
360
|
-
const mediaRaw = await
|
|
354
|
+
const mediaRaw = await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch);
|
|
361
355
|
for (const [hash, e] of Object.entries(parseMediaManifest(parseMediaJson(mediaRaw)))) {
|
|
362
356
|
union.set(hash, e);
|
|
363
357
|
}
|
|
364
|
-
const names = await listBranches(
|
|
358
|
+
const names = await backend.listBranches(PENDING_PREFIX);
|
|
365
359
|
branchNames = names;
|
|
366
|
-
const branchManifests = await Promise.all(names.map((name) =>
|
|
360
|
+
const branchManifests = await Promise.all(names.map((name) => backend.readFile(runtime.mediaManifestPath, name)
|
|
367
361
|
.then((raw) => parseMediaManifest(parseMediaJson(raw)))
|
|
368
362
|
.catch(() => ({}))));
|
|
369
363
|
for (const manifest of branchManifests) {
|
|
@@ -383,11 +377,11 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
383
377
|
// here keeps the asset list intact with an empty overlay, since the screen still lists assets.
|
|
384
378
|
let usage = {};
|
|
385
379
|
try {
|
|
386
|
-
const manifestRaw = await
|
|
380
|
+
const manifestRaw = await backend.readFile(runtime.manifestPath, backend.defaultBranch);
|
|
387
381
|
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
388
382
|
// Reuse the branch list from the media-union above; the Library DISPLAY keeps the default
|
|
389
383
|
// best-effort behavior (a failed branch read degrades that one branch, not the screen).
|
|
390
|
-
const index = await buildUsageIndex(
|
|
384
|
+
const index = await buildUsageIndex(backend, runtime.concepts, manifest, { branches: branchNames });
|
|
391
385
|
for (const [hash, entries] of index) {
|
|
392
386
|
usage[hash] = { count: distinctEntryCount(entries), entries };
|
|
393
387
|
}
|
|
@@ -418,36 +412,16 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
418
412
|
}
|
|
419
413
|
id = composeDatedId(date, slug, concept.datePrefix);
|
|
420
414
|
}
|
|
421
|
-
const
|
|
422
|
-
const existing = await
|
|
415
|
+
const backend = resolveBackend(event);
|
|
416
|
+
const existing = await backend.readFile(`${concept.dir}/${filenameFromId(id)}`, backend.defaultBranch);
|
|
423
417
|
if (existing !== null)
|
|
424
418
|
return bounce('An entry with that slug already exists.');
|
|
425
419
|
// A pending branch is an entry too (saved but not yet published); refuse to clobber it.
|
|
426
|
-
if ((await
|
|
420
|
+
if ((await backend.branchHead(pendingBranch(concept.id, id))) !== null) {
|
|
427
421
|
return bounce('An unpublished entry with that slug already exists.');
|
|
428
422
|
}
|
|
429
423
|
throw redirect(303, `/admin/${concept.id}/${id}?new=1`);
|
|
430
424
|
}
|
|
431
|
-
/** Coerce parsed frontmatter to the form-ready values the editor inputs expect. */
|
|
432
|
-
function formValues(fields, frontmatter) {
|
|
433
|
-
const out = {};
|
|
434
|
-
for (const field of fields) {
|
|
435
|
-
const value = frontmatter[field.name];
|
|
436
|
-
if (field.type === 'date')
|
|
437
|
-
out[field.name] = dateInputValue(value);
|
|
438
|
-
else if (field.type === 'boolean')
|
|
439
|
-
out[field.name] = value === true;
|
|
440
|
-
else if (field.type === 'tags' || field.type === 'freetags')
|
|
441
|
-
out[field.name] = Array.isArray(value) ? value.map(String) : [];
|
|
442
|
-
// A hero is a nested object; the default String() arm would corrupt it to '[object Object]'.
|
|
443
|
-
// Hand the stored object back as-is so the editor reads .src/.alt/.caption on open.
|
|
444
|
-
else if (field.type === 'image')
|
|
445
|
-
out[field.name] = value !== null && typeof value === 'object' ? value : undefined;
|
|
446
|
-
else
|
|
447
|
-
out[field.name] = typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
448
|
-
}
|
|
449
|
-
return out;
|
|
450
|
-
}
|
|
451
425
|
/** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
|
|
452
426
|
async function editLoad(event) {
|
|
453
427
|
requireSession(event);
|
|
@@ -456,7 +430,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
456
430
|
if (!isValidId(id))
|
|
457
431
|
throw error(400, 'Invalid entry id');
|
|
458
432
|
const isNew = event.url.searchParams.get('new') === '1';
|
|
459
|
-
const
|
|
433
|
+
const backend = resolveBackend(event);
|
|
460
434
|
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
461
435
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
462
436
|
// A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
|
|
@@ -473,20 +447,25 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
473
447
|
// rejected read degrades to null so the edit never throws on a missing or unreadable dictionary;
|
|
474
448
|
// the projection below treats null as an empty word list (the editor falls back to dialect-only).
|
|
475
449
|
const [headSha, mainRaw, manifestRaw, mediaRaw, dictionaryRaw] = await Promise.all([
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
450
|
+
backend.branchHead(branch),
|
|
451
|
+
backend.readFile(path, backend.defaultBranch),
|
|
452
|
+
backend.readFile(runtime.manifestPath, backend.defaultBranch),
|
|
479
453
|
runtime.resolvedAssets.enabled
|
|
480
|
-
?
|
|
454
|
+
? backend.readFile(runtime.mediaManifestPath, backend.defaultBranch).catch(() => null)
|
|
481
455
|
: Promise.resolve(null),
|
|
482
|
-
|
|
456
|
+
backend.readFile(dictionaryFilePath(), backend.defaultBranch).catch(() => null),
|
|
483
457
|
]);
|
|
484
458
|
const pending = headSha !== null;
|
|
485
|
-
const raw = pending ? await
|
|
459
|
+
const raw = pending ? await backend.readFile(path, branch) : mainRaw;
|
|
486
460
|
if (raw === null && !isNew)
|
|
487
461
|
throw error(404, 'Entry not found');
|
|
488
462
|
const published = mainRaw !== null;
|
|
489
463
|
const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
|
|
464
|
+
// A fresh entry opens prefilled from each field's `default`, resolving a `'today'` date against a
|
|
465
|
+
// request-time clock. The defaults sit under the empty parsed frontmatter, never over a real read.
|
|
466
|
+
const loadFrontmatter = isNew
|
|
467
|
+
? { ...initialValues(concept.schema, new Date()), ...parsed.frontmatter }
|
|
468
|
+
: parsed.frontmatter;
|
|
490
469
|
const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
|
|
491
470
|
const manifest = manifestRaw !== null ? parseManifest(manifestRaw) : null;
|
|
492
471
|
let linkTargets = [];
|
|
@@ -544,7 +523,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
544
523
|
id,
|
|
545
524
|
label: concept.label,
|
|
546
525
|
fields: concept.fields,
|
|
547
|
-
frontmatter: formValues(concept.fields,
|
|
526
|
+
frontmatter: formValues(concept.fields, loadFrontmatter),
|
|
548
527
|
body: parsed.body,
|
|
549
528
|
title,
|
|
550
529
|
isNew,
|
|
@@ -630,7 +609,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
630
609
|
throw redirect(303, `/admin/${concept.id}/${id}?error=${encodeURIComponent(message)}${suffix}`);
|
|
631
610
|
}
|
|
632
611
|
const markdown = serializeMarkdown(result.data, body);
|
|
633
|
-
const
|
|
612
|
+
const backend = resolveBackend(event);
|
|
634
613
|
// Merge the editor's optimistic media records into the media manifest, gated on media being on
|
|
635
614
|
// and at least one valid record posted. The base is read from the default branch (never the
|
|
636
615
|
// pending branch), so each save's union starts from main's committed rows, and decision 1's
|
|
@@ -641,7 +620,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
641
620
|
if (runtime.resolvedAssets.enabled) {
|
|
642
621
|
const records = parseMediaEntries(form.get('media'));
|
|
643
622
|
if (records.length > 0) {
|
|
644
|
-
const baseRaw = await
|
|
623
|
+
const baseRaw = await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch);
|
|
645
624
|
let mediaManifest = parseMediaManifest(parseMediaJson(baseRaw));
|
|
646
625
|
for (const record of records) {
|
|
647
626
|
mediaManifest = upsertMediaEntry(mediaManifest, record);
|
|
@@ -651,7 +630,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
651
630
|
}
|
|
652
631
|
// Upsert this entry's row into main's manifest in memory, for the link guard here and for
|
|
653
632
|
// the publish commit. The save commits no manifest change; publish lands the upsert on main.
|
|
654
|
-
const manifest = await readManifest(
|
|
633
|
+
const manifest = await readManifest(backend);
|
|
655
634
|
const row = manifestEntryFromFile(concept, { path, raw: markdown });
|
|
656
635
|
const upserted = upsertEntry(manifest, row);
|
|
657
636
|
// Save guard: resolve the body's cairn links against main's manifest with this entry upserted,
|
|
@@ -681,26 +660,41 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
681
660
|
body,
|
|
682
661
|
});
|
|
683
662
|
}
|
|
663
|
+
// Frontmatter reference warning: classify each typed reference edge against the same upserted
|
|
664
|
+
// manifest. This is best-effort against the committed (possibly stale) main manifest and advisory
|
|
665
|
+
// like draftLinks, NEVER the integrity guarantee; references have no prerender re-resolve backstop,
|
|
666
|
+
// so verifyReferences at the build is the only authority. A reference NEVER blocks the save: unlike
|
|
667
|
+
// a body link, an absent or draft target only warns, since the build gate fails a true dangling.
|
|
668
|
+
const referenceWarnings = [];
|
|
669
|
+
for (const edge of extractReferenceEdges(result.data, concept.fields)) {
|
|
670
|
+
if (edge.concept === concept.id && edge.id === id)
|
|
671
|
+
continue;
|
|
672
|
+
const target = byKey.get(`${edge.concept}/${edge.id}`);
|
|
673
|
+
if (!target || target.draft)
|
|
674
|
+
referenceWarnings.push(`${edge.concept}/${edge.id}`);
|
|
675
|
+
}
|
|
684
676
|
// Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
|
|
685
677
|
// commit only the entry file there. Main stays untouched until publish, so the branch differs
|
|
686
678
|
// from main at exactly this entry's path.
|
|
687
679
|
const branch = pendingBranch(concept.id, id);
|
|
688
|
-
if ((await
|
|
689
|
-
|
|
680
|
+
if ((await backend.branchHead(branch)) === null) {
|
|
681
|
+
// The default-branch head read distinguishes a first save from a re-save; a null is the
|
|
682
|
+
// unreadable-default-branch case the create cannot recover from, so fail with the 500.
|
|
683
|
+
const mainHead = await backend.branchHead(backend.defaultBranch);
|
|
690
684
|
if (mainHead === null)
|
|
691
685
|
throw error(500, 'Cannot read the default branch');
|
|
692
|
-
await createBranch(
|
|
686
|
+
await backend.createBranch(branch, backend.defaultBranch);
|
|
693
687
|
}
|
|
694
688
|
const commitFields = { concept: concept.id, id, editor: editor.email, branch };
|
|
695
689
|
let branchSha;
|
|
696
690
|
try {
|
|
697
|
-
branchSha = await
|
|
691
|
+
branchSha = await backend.commit(branch, mediaChange ? [{ path, content: markdown }, mediaChange] : [{ path, content: markdown }], { name: editor.displayName, email: editor.email }, `Update ${concept.label.toLowerCase()}: ${id}`);
|
|
698
692
|
log.info('commit.succeeded', commitFields);
|
|
699
693
|
}
|
|
700
694
|
catch (err) {
|
|
701
695
|
commitFailure(commitFields, err, `/admin/${concept.id}/${id}`, 'This file changed since you opened it. Reload and reapply your edits.', { query: suffix });
|
|
702
696
|
}
|
|
703
|
-
return { path, markdown, branch, branchSha, manifest: upserted, draftLinks,
|
|
697
|
+
return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, referenceWarnings, backend, mediaChange };
|
|
704
698
|
}
|
|
705
699
|
/**
|
|
706
700
|
* Save an edit: validate, then commit to the entry's pending branch with the session editor
|
|
@@ -717,9 +711,11 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
717
711
|
const held = await saveToBranch(event, editor, concept, id);
|
|
718
712
|
if (!('branchSha' in held))
|
|
719
713
|
return held;
|
|
720
|
-
|
|
714
|
+
let savedQuery = held.draftLinks.length
|
|
721
715
|
? `saved=1&drafts=${encodeURIComponent(held.draftLinks.join(','))}`
|
|
722
716
|
: 'saved=1';
|
|
717
|
+
if (held.referenceWarnings.length)
|
|
718
|
+
savedQuery += `&refs=${encodeURIComponent(held.referenceWarnings.join(','))}`;
|
|
723
719
|
throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
|
|
724
720
|
}
|
|
725
721
|
/**
|
|
@@ -739,7 +735,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
739
735
|
const held = await saveToBranch(event, editor, concept, id);
|
|
740
736
|
if (!('branchSha' in held))
|
|
741
737
|
return held;
|
|
742
|
-
const { path, markdown, branch, branchSha, manifest,
|
|
738
|
+
const { path, markdown, branch, branchSha, manifest, backend, mediaChange } = held;
|
|
743
739
|
// The publish commit reuses the exact merged media.json saveToBranch already built (decision 1:
|
|
744
740
|
// no re-read or re-merge here). Promote it to main alongside the body and the content manifest
|
|
745
741
|
// in one atomic commit, or commit those two alone when the save touched no media.
|
|
@@ -758,7 +754,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
758
754
|
try {
|
|
759
755
|
const { frontmatter } = parseMarkdown(markdown);
|
|
760
756
|
address = entryIdentity(concept, path, frontmatter).permalink;
|
|
761
|
-
const addressIndex = await buildAddressIndex(
|
|
757
|
+
const addressIndex = await buildAddressIndex(backend, runtime.concepts, manifest);
|
|
762
758
|
collision = addressCollision(addressIndex, { concept: concept.id, id }, address);
|
|
763
759
|
}
|
|
764
760
|
catch (err) {
|
|
@@ -769,7 +765,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
769
765
|
}
|
|
770
766
|
const commitFields = { concept: concept.id, id, editor: editor.email };
|
|
771
767
|
try {
|
|
772
|
-
await
|
|
768
|
+
await backend.commit(backend.defaultBranch, changes, { name: editor.displayName, email: editor.email }, `Publish ${concept.label.toLowerCase()}: ${id}`);
|
|
773
769
|
log.info('entry.published', { ...commitFields, batch: false });
|
|
774
770
|
// Only after the publish lands: a diagnostic that a live address now has a new owner.
|
|
775
771
|
if (collision) {
|
|
@@ -788,8 +784,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
788
784
|
// Only after the main commit lands, and only when the branch head is still the commit this
|
|
789
785
|
// action made: a head that moved is a concurrent save, and deleting it would destroy edits.
|
|
790
786
|
// No log event for the skip; the pending badge is the surface.
|
|
791
|
-
if ((await
|
|
792
|
-
await deleteBranch(
|
|
787
|
+
if ((await backend.branchHead(branch)) === branchSha) {
|
|
788
|
+
await backend.deleteBranch(branch);
|
|
793
789
|
}
|
|
794
790
|
throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
|
|
795
791
|
}
|
|
@@ -804,11 +800,11 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
804
800
|
const first = runtime.concepts[0];
|
|
805
801
|
if (!first)
|
|
806
802
|
throw error(404, 'No content types configured');
|
|
807
|
-
const
|
|
803
|
+
const backend = resolveBackend(event);
|
|
808
804
|
const listPage = `/admin/${first.id}`;
|
|
809
805
|
// Each cairn/ ref names a pending entry; the shared predicate skips a stray ref rather
|
|
810
806
|
// than failing the whole batch on it.
|
|
811
|
-
const names = await listBranches(
|
|
807
|
+
const names = await backend.listBranches(PENDING_PREFIX);
|
|
812
808
|
const pending = names.flatMap((name) => {
|
|
813
809
|
const entry = pendingEntryOf(name);
|
|
814
810
|
return entry ? [{ ...entry, branch: name, path: `${entry.concept.dir}/${filenameFromId(entry.id)}` }] : [];
|
|
@@ -819,13 +815,13 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
819
815
|
// entry stays pending). A ghost ref whose entry file is missing is skipped (discard can
|
|
820
816
|
// clean it up); it carries nothing to publish.
|
|
821
817
|
const reads = await Promise.all(pending.map(async (entry) => {
|
|
822
|
-
const sha = await
|
|
823
|
-
const raw = await
|
|
818
|
+
const sha = await backend.branchHead(entry.branch);
|
|
819
|
+
const raw = await backend.readFile(entry.path, entry.branch);
|
|
824
820
|
return { ...entry, sha, raw };
|
|
825
821
|
}));
|
|
826
822
|
// Fold main's manifest once over every row, so the batch lands content and index together,
|
|
827
823
|
// the same shape as a single publish.
|
|
828
|
-
let next = await readManifest(
|
|
824
|
+
let next = await readManifest(backend);
|
|
829
825
|
const changes = [];
|
|
830
826
|
const published = [];
|
|
831
827
|
for (const entry of reads) {
|
|
@@ -842,7 +838,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
842
838
|
changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
|
|
843
839
|
const noun = published.length === 1 ? 'entry' : 'entries';
|
|
844
840
|
try {
|
|
845
|
-
await
|
|
841
|
+
await backend.commit(backend.defaultBranch, changes, { name: editor.displayName, email: editor.email }, `Publish ${published.length} ${noun}`);
|
|
846
842
|
for (const entry of published) {
|
|
847
843
|
log.info('entry.published', { concept: entry.concept, id: entry.id, editor: editor.email, batch: true });
|
|
848
844
|
}
|
|
@@ -866,8 +862,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
866
862
|
// abort the remaining deletes.
|
|
867
863
|
for (const entry of published) {
|
|
868
864
|
try {
|
|
869
|
-
if ((await
|
|
870
|
-
await deleteBranch(
|
|
865
|
+
if ((await backend.branchHead(entry.branch)) === entry.sha) {
|
|
866
|
+
await backend.deleteBranch(entry.branch);
|
|
871
867
|
}
|
|
872
868
|
}
|
|
873
869
|
catch {
|
|
@@ -886,10 +882,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
886
882
|
const id = event.params.id ?? '';
|
|
887
883
|
if (!isValidId(id))
|
|
888
884
|
throw error(400, 'Invalid entry id');
|
|
889
|
-
const
|
|
890
|
-
await deleteBranch(
|
|
885
|
+
const backend = resolveBackend(event);
|
|
886
|
+
await backend.deleteBranch(pendingBranch(concept.id, id));
|
|
891
887
|
log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
|
|
892
|
-
const onMain = await
|
|
888
|
+
const onMain = await backend.readFile(`${concept.dir}/${filenameFromId(id)}`, backend.defaultBranch);
|
|
893
889
|
if (onMain !== null)
|
|
894
890
|
throw redirect(303, `/admin/${concept.id}/${id}?discarded=1`);
|
|
895
891
|
throw redirect(303, `/admin/${concept.id}`);
|
|
@@ -903,10 +899,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
903
899
|
*/
|
|
904
900
|
async function deleteEntry(event, concept, id, editor) {
|
|
905
901
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
906
|
-
const
|
|
902
|
+
const backend = resolveBackend(event);
|
|
907
903
|
// An absent manifest degrades the inbound gate to "allow": with no manifest there is nothing to
|
|
908
904
|
// check, and the build's cairn: backstop still catches any dangling token, mirroring saveAction.
|
|
909
|
-
const manifest = await readManifest(
|
|
905
|
+
const manifest = await readManifest(backend);
|
|
910
906
|
const inbound = inboundLinks(manifest, concept.id, id);
|
|
911
907
|
if (inbound.length) {
|
|
912
908
|
return fail(409, {
|
|
@@ -915,22 +911,55 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
915
911
|
id,
|
|
916
912
|
});
|
|
917
913
|
}
|
|
914
|
+
// Cross-branch reference gate (fail-closed). A strict reference index unions main's published edges
|
|
915
|
+
// and every open cairn/* branch; unlike the main-only body-link gate above, it does NOT degrade to
|
|
916
|
+
// allow when it cannot read, because the build's verifyReferences backstop only sees main. A
|
|
917
|
+
// transient branch-read failure that looked like "no references" would let a delete strand an
|
|
918
|
+
// inbound edge held in an unpublished draft, so refuse with a 503 rather than proceed.
|
|
919
|
+
let refIndex;
|
|
920
|
+
try {
|
|
921
|
+
refIndex = await buildReferenceIndex(backend, runtime.concepts, manifest, { strict: true });
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
return fail(503, {
|
|
925
|
+
error: 'Could not verify where this entry is referenced. Try again.',
|
|
926
|
+
inboundLinks: [],
|
|
927
|
+
id,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
const refRows = refIndex.get(`${concept.id}/${id}`) ?? [];
|
|
931
|
+
if (refRows.length > 0) {
|
|
932
|
+
// Carry each referencing entry into the InboundLink shape the blockers list renders. A branch row
|
|
933
|
+
// has no permalink (the edit is unpublished), so default it to empty.
|
|
934
|
+
const referencingEntries = refRows.map((row) => ({
|
|
935
|
+
concept: row.concept,
|
|
936
|
+
id: row.id,
|
|
937
|
+
title: row.title,
|
|
938
|
+
permalink: row.permalink ?? '',
|
|
939
|
+
}));
|
|
940
|
+
const n = referencingEntries.length;
|
|
941
|
+
return fail(409, {
|
|
942
|
+
error: `Cannot delete ${id}: ${n} ${n === 1 ? 'entry references' : 'entries reference'} it.`,
|
|
943
|
+
inboundLinks: referencingEntries,
|
|
944
|
+
id,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
918
947
|
// When the entry was never published (absent from main), the branch delete is the whole
|
|
919
948
|
// operation; main has nothing to commit, so the only honest log record is the discard of
|
|
920
949
|
// the pending edits.
|
|
921
|
-
const onMain = await
|
|
950
|
+
const onMain = await backend.readFile(path, backend.defaultBranch);
|
|
922
951
|
if (onMain === null) {
|
|
923
|
-
await deleteBranch(
|
|
952
|
+
await backend.deleteBranch(pendingBranch(concept.id, id));
|
|
924
953
|
log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
|
|
925
954
|
throw redirect(303, `/admin/${concept.id}`);
|
|
926
955
|
}
|
|
927
956
|
const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
|
|
928
957
|
const commitFields = { concept: concept.id, id, editor: editor.email };
|
|
929
958
|
try {
|
|
930
|
-
await
|
|
959
|
+
await backend.commit(backend.defaultBranch, [
|
|
931
960
|
{ path, content: null },
|
|
932
961
|
{ path: runtime.manifestPath, content: nextManifest },
|
|
933
|
-
], {
|
|
962
|
+
], { name: editor.displayName, email: editor.email }, `Delete ${concept.label.toLowerCase()}: ${id}`);
|
|
934
963
|
log.info('commit.succeeded', commitFields);
|
|
935
964
|
}
|
|
936
965
|
catch (err) {
|
|
@@ -941,7 +970,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
941
970
|
// recoverable (it lists as a never-published row a discard can clean up), matching
|
|
942
971
|
// publish's posture, so the entry's deletion still completes.
|
|
943
972
|
try {
|
|
944
|
-
await deleteBranch(
|
|
973
|
+
await backend.deleteBranch(pendingBranch(concept.id, id));
|
|
945
974
|
}
|
|
946
975
|
catch {
|
|
947
976
|
// The entry is gone from main; the straggler shows as a pending row until discarded.
|
|
@@ -979,10 +1008,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
979
1008
|
const id = event.params.id ?? '';
|
|
980
1009
|
if (!isValidId(id))
|
|
981
1010
|
throw error(400, 'Invalid entry id');
|
|
982
|
-
const
|
|
1011
|
+
const backend = resolveBackend(event);
|
|
983
1012
|
// Pending edits on the branch are keyed to the old id; renaming underneath them would strand
|
|
984
1013
|
// them, so refuse until the editor publishes or discards.
|
|
985
|
-
if ((await
|
|
1014
|
+
if ((await backend.branchHead(pendingBranch(concept.id, id))) !== null) {
|
|
986
1015
|
return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' });
|
|
987
1016
|
}
|
|
988
1017
|
const form = await event.request.formData();
|
|
@@ -1003,45 +1032,103 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1003
1032
|
// Collision guard: refuse if a file already exists at the new path. This 409 covers two cases a
|
|
1004
1033
|
// single readRaw cannot tell apart: a static collision with an existing entry, and a
|
|
1005
1034
|
// concurrent-rename race where another editor renamed onto this path between load and submit.
|
|
1006
|
-
const clobber = await
|
|
1035
|
+
const clobber = await backend.readFile(newPath, backend.defaultBranch);
|
|
1007
1036
|
if (clobber !== null) {
|
|
1008
1037
|
return fail(409, { error: 'An entry with that slug already exists.' });
|
|
1009
1038
|
}
|
|
1010
1039
|
const [entryRaw, manifest] = await Promise.all([
|
|
1011
|
-
|
|
1012
|
-
readManifest(
|
|
1040
|
+
backend.readFile(oldPath, backend.defaultBranch),
|
|
1041
|
+
readManifest(backend),
|
|
1013
1042
|
]);
|
|
1014
1043
|
if (entryRaw === null)
|
|
1015
1044
|
throw error(404, 'Entry not found');
|
|
1045
|
+
// Cross-branch reference gate (fail-closed). A reference index unions main's published edges and
|
|
1046
|
+
// every open cairn/* branch; if it cannot be built (a transient branch read failure), refuse
|
|
1047
|
+
// rather than rename a still-referenced target and strand the inbound edge.
|
|
1048
|
+
let refIndex;
|
|
1049
|
+
try {
|
|
1050
|
+
refIndex = await buildReferenceIndex(backend, runtime.concepts, manifest, { strict: true });
|
|
1051
|
+
}
|
|
1052
|
+
catch {
|
|
1053
|
+
return fail(409, { error: 'Could not verify references. Try again.' });
|
|
1054
|
+
}
|
|
1055
|
+
// Refuse when a THIRD-PARTY open branch holds an inbound reference (symmetric with the pending-edits
|
|
1056
|
+
// guard). The strict index unions main and every branch, so filter before refusing: gate
|
|
1057
|
+
// origin.kind === 'branch' FIRST (a published row has no .branch, so a bare branch-name compare would
|
|
1058
|
+
// trip on every main-side inbound and over-refuse), then exclude the entry's OWN pending branch
|
|
1059
|
+
// (already refused above and absent by construction here). Published (main) inbound rows are NOT
|
|
1060
|
+
// refused; they are repointed below.
|
|
1061
|
+
const ownBranch = pendingBranch(concept.id, id);
|
|
1062
|
+
const conflictBranches = (refIndex.get(`${concept.id}/${id}`) ?? [])
|
|
1063
|
+
.filter((row) => row.origin.kind === 'branch' && row.origin.branch !== ownBranch)
|
|
1064
|
+
.map((row) => `${row.concept}/${row.id}`);
|
|
1065
|
+
if (conflictBranches.length > 0) {
|
|
1066
|
+
const names = [...new Set(conflictBranches)].join(', ');
|
|
1067
|
+
return fail(409, { error: `Another editor has unpublished edits referencing this entry: ${names}. Ask them to publish or discard, then rename.` });
|
|
1068
|
+
}
|
|
1016
1069
|
const oldHref = formatCairnToken({ concept: concept.id, id });
|
|
1017
1070
|
const newHref = formatCairnToken({ concept: concept.id, id: newId });
|
|
1018
|
-
// The moved file keeps its content, except a self-token rewrite
|
|
1019
|
-
|
|
1020
|
-
|
|
1071
|
+
// The moved file keeps its content, except a self-token rewrite and a self-reference rewrite.
|
|
1072
|
+
let movedRaw = rewriteCairnLink(entryRaw, oldHref, newHref);
|
|
1073
|
+
// The moved entry is excluded from inboundReferences, so it must repoint its OWN frontmatter
|
|
1074
|
+
// self-references (e.g. `related` listing its own old id), or the re-derived row would carry the
|
|
1075
|
+
// old id and verifyReferences would flag a dangling edge at the deploy gate.
|
|
1076
|
+
for (const f of concept.fields) {
|
|
1077
|
+
if (f.type === 'reference' || (f.type === 'array' && f.item.type === 'reference')) {
|
|
1078
|
+
movedRaw = rewriteFrontmatterReference(movedRaw, f.name, id, newId);
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// Re-derive its manifest row from the new path so the row carries the new id and permalink by
|
|
1082
|
+
// construction (and the rewritten self-reference edge at the new id).
|
|
1021
1083
|
const changes = [
|
|
1022
1084
|
{ path: oldPath, content: null },
|
|
1023
1085
|
{ path: newPath, content: movedRaw },
|
|
1024
1086
|
];
|
|
1025
1087
|
let next = removeEntry(manifest, concept.id, id);
|
|
1026
1088
|
next = upsertEntry(next, manifestEntryFromFile(concept, { path: newPath, raw: movedRaw }));
|
|
1027
|
-
|
|
1028
|
-
|
|
1089
|
+
const repoints = new Map();
|
|
1090
|
+
const linkerPathFor = (linkerConcept, linkerId) => `${linkerConcept.dir}/${filenameFromId(linkerId)}`;
|
|
1029
1091
|
for (const linker of inboundLinks(manifest, concept.id, id)) {
|
|
1030
1092
|
const linkerConcept = findConcept(runtime.concepts, linker.concept);
|
|
1031
1093
|
if (!linkerConcept)
|
|
1032
1094
|
continue;
|
|
1033
|
-
const
|
|
1034
|
-
const
|
|
1095
|
+
const path = linkerPathFor(linkerConcept, linker.id);
|
|
1096
|
+
const existing = repoints.get(path);
|
|
1097
|
+
if (existing)
|
|
1098
|
+
existing.hasLink = true;
|
|
1099
|
+
else
|
|
1100
|
+
repoints.set(path, { concept: linker.concept, id: linker.id, hasLink: true, fields: [] });
|
|
1101
|
+
}
|
|
1102
|
+
for (const linker of inboundReferences(manifest, concept.id, id)) {
|
|
1103
|
+
const linkerConcept = findConcept(runtime.concepts, linker.concept);
|
|
1104
|
+
if (!linkerConcept)
|
|
1105
|
+
continue;
|
|
1106
|
+
const path = linkerPathFor(linkerConcept, linker.id);
|
|
1107
|
+
const existing = repoints.get(path);
|
|
1108
|
+
if (existing)
|
|
1109
|
+
existing.fields = [...new Set([...existing.fields, ...linker.fields])];
|
|
1110
|
+
else
|
|
1111
|
+
repoints.set(path, { concept: linker.concept, id: linker.id, hasLink: false, fields: linker.fields });
|
|
1112
|
+
}
|
|
1113
|
+
for (const [linkerPath, repoint] of repoints) {
|
|
1114
|
+
const linkerConcept = findConcept(runtime.concepts, repoint.concept);
|
|
1115
|
+
if (!linkerConcept)
|
|
1116
|
+
continue;
|
|
1117
|
+
let linkerRaw = await backend.readFile(linkerPath, backend.defaultBranch);
|
|
1035
1118
|
if (linkerRaw === null)
|
|
1036
1119
|
continue;
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1120
|
+
if (repoint.hasLink)
|
|
1121
|
+
linkerRaw = rewriteCairnLink(linkerRaw, oldHref, newHref);
|
|
1122
|
+
for (const field of repoint.fields) {
|
|
1123
|
+
linkerRaw = rewriteFrontmatterReference(linkerRaw, field, id, newId);
|
|
1124
|
+
}
|
|
1125
|
+
changes.push({ path: linkerPath, content: linkerRaw });
|
|
1126
|
+
next = upsertEntry(next, manifestEntryFromFile(linkerConcept, { path: linkerPath, raw: linkerRaw }));
|
|
1040
1127
|
}
|
|
1041
1128
|
changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
|
|
1042
1129
|
const commitFields = { concept: concept.id, id: newId, editor: editor.email };
|
|
1043
1130
|
try {
|
|
1044
|
-
await
|
|
1131
|
+
await backend.commit(backend.defaultBranch, changes, { name: editor.displayName, email: editor.email }, `Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`);
|
|
1045
1132
|
log.info('commit.succeeded', commitFields);
|
|
1046
1133
|
}
|
|
1047
1134
|
catch (err) {
|
|
@@ -1201,14 +1288,14 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1201
1288
|
*/
|
|
1202
1289
|
async function mediaDeleteAction(event) {
|
|
1203
1290
|
const editor = requireSession(event);
|
|
1204
|
-
const
|
|
1291
|
+
const backend = resolveBackend(event);
|
|
1205
1292
|
const form = await event.request.formData();
|
|
1206
1293
|
const hash = String(form.get('hash') ?? '');
|
|
1207
1294
|
if (!MEDIA_HASH_RE.test(hash))
|
|
1208
1295
|
throw error(400, 'Invalid media hash');
|
|
1209
1296
|
// The asset must be committed on the default branch to be deletable here. A branch-only upload
|
|
1210
1297
|
// (the common 2b case before publish) has no main row; removing it is a discard of the draft.
|
|
1211
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
1298
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
1212
1299
|
const row = manifest[hash];
|
|
1213
1300
|
if (!row) {
|
|
1214
1301
|
return fail(404, {
|
|
@@ -1224,7 +1311,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1224
1311
|
// make a still-referenced asset look orphaned and skip the typed-slug confirm.
|
|
1225
1312
|
let index;
|
|
1226
1313
|
try {
|
|
1227
|
-
index = await buildUsageIndex(
|
|
1314
|
+
index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
|
|
1228
1315
|
}
|
|
1229
1316
|
catch {
|
|
1230
1317
|
// Fail closed: we could not verify every place the asset is used, so refuse rather than risk
|
|
@@ -1272,7 +1359,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1272
1359
|
// Commit the manifest row removal FIRST. The order is load-bearing (see the docstring).
|
|
1273
1360
|
const commitFields = { concept: 'media', id: hash, editor: editor.email };
|
|
1274
1361
|
try {
|
|
1275
|
-
await
|
|
1362
|
+
await backend.commit(backend.defaultBranch, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(removeMediaEntry(manifest, hash)) }], { name: editor.displayName, email: editor.email }, `Delete media: ${row.slug}`);
|
|
1276
1363
|
log.info('commit.succeeded', commitFields);
|
|
1277
1364
|
}
|
|
1278
1365
|
catch (err) {
|
|
@@ -1306,7 +1393,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1306
1393
|
*/
|
|
1307
1394
|
async function mediaBulkDelete(event) {
|
|
1308
1395
|
const editor = requireSession(event);
|
|
1309
|
-
const
|
|
1396
|
+
const backend = resolveBackend(event);
|
|
1310
1397
|
// Read the selected hashes from the form. Accept the repeated `hash` field, falling back to a JSON
|
|
1311
1398
|
// `hashes` array. Each value must match the 16-hex content-hash grammar; a malformed value is
|
|
1312
1399
|
// dropped silently rather than surfaced as a skip (it was never a real selection).
|
|
@@ -1327,7 +1414,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1327
1414
|
}
|
|
1328
1415
|
const selected = raw.filter((h) => MEDIA_HASH_RE.test(h));
|
|
1329
1416
|
// Read the fresh media manifest (the deletable rows come from here, by hash).
|
|
1330
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
1417
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
1331
1418
|
// Resolve the R2 bucket before any write, so a media-off site or a missing binding refuses before
|
|
1332
1419
|
// the commit, exactly like single delete.
|
|
1333
1420
|
const resolved = runtime.resolvedAssets;
|
|
@@ -1345,7 +1432,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1345
1432
|
// mistaking a still-referenced asset for an orphan. Build exactly one index, never one per item.
|
|
1346
1433
|
let index;
|
|
1347
1434
|
try {
|
|
1348
|
-
index = await buildUsageIndex(
|
|
1435
|
+
index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
|
|
1349
1436
|
}
|
|
1350
1437
|
catch {
|
|
1351
1438
|
return fail(503, { error: 'Could not verify where these assets are used. Try again.' });
|
|
@@ -1362,7 +1449,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1362
1449
|
next = removeMediaEntry(next, hash);
|
|
1363
1450
|
const commitFields = { concept: 'media', id: 'bulk', editor: editor.email };
|
|
1364
1451
|
try {
|
|
1365
|
-
await
|
|
1452
|
+
await backend.commit(backend.defaultBranch, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(next) }], { name: editor.displayName, email: editor.email }, `Delete ${plan.deletable.length} media assets`);
|
|
1366
1453
|
log.info('commit.succeeded', commitFields);
|
|
1367
1454
|
}
|
|
1368
1455
|
catch (err) {
|
|
@@ -1405,7 +1492,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1405
1492
|
*/
|
|
1406
1493
|
async function mediaOrphanScan(event) {
|
|
1407
1494
|
requireSession(event);
|
|
1408
|
-
const
|
|
1495
|
+
const backend = resolveBackend(event);
|
|
1409
1496
|
// Resolve the R2 binding. The reconcile lists the raw bucket directly, so keep the raw binding;
|
|
1410
1497
|
// the MediaStore seam carries no list. A media-off site or a missing binding refuses the scan.
|
|
1411
1498
|
const resolved = runtime.resolvedAssets;
|
|
@@ -1418,7 +1505,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1418
1505
|
return fail(503, { error: 'The media bucket is not bound.' });
|
|
1419
1506
|
}
|
|
1420
1507
|
// Read the fresh media manifest for the reconcile's manifest side.
|
|
1421
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
1508
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
1422
1509
|
// THE detection-time fail-closed surface. The reconcile (an R2 list that must complete in full)
|
|
1423
1510
|
// and the strict usage build (a branch read that must complete in full) are both unsafe to use
|
|
1424
1511
|
// partially, so either throwing refuses the scan. A wrong orphan verdict from a partial read here
|
|
@@ -1427,7 +1514,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1427
1514
|
let index;
|
|
1428
1515
|
try {
|
|
1429
1516
|
reconcile = await runReconcile(rawBucket, manifest);
|
|
1430
|
-
index = await buildUsageIndex(
|
|
1517
|
+
index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
|
|
1431
1518
|
}
|
|
1432
1519
|
catch {
|
|
1433
1520
|
return fail(503, { error: 'Could not check where files are used, so the scan was not run. Try again.' });
|
|
@@ -1463,7 +1550,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1463
1550
|
*/
|
|
1464
1551
|
async function mediaPurgeOrphans(event) {
|
|
1465
1552
|
const editor = requireSession(event);
|
|
1466
|
-
const
|
|
1553
|
+
const backend = resolveBackend(event);
|
|
1467
1554
|
// Resolve the R2 binding, the same media-off / missing-binding refusals as the scan. The purge
|
|
1468
1555
|
// deletes through the MediaStore seam, so wrap the raw binding.
|
|
1469
1556
|
const resolved = runtime.resolvedAssets;
|
|
@@ -1486,14 +1573,14 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1486
1573
|
return fail(400, { error: 'Type the number of files to confirm the purge.' });
|
|
1487
1574
|
}
|
|
1488
1575
|
// Re-derive fresh against the current manifest, so a key claimed since the scan is never purged.
|
|
1489
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
1576
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
1490
1577
|
// THE fail-closed gate for the whole batch: one shared strict cross-branch usage index, symmetric
|
|
1491
1578
|
// with the scan and the bulk delete. STRICT mode rethrows a branch-read failure, so a transient
|
|
1492
1579
|
// branch read refuses the irreversible purge rather than letting a possibly-referenced byte be
|
|
1493
1580
|
// treated as a true orphan. Build exactly one index, never one per key.
|
|
1494
1581
|
let index;
|
|
1495
1582
|
try {
|
|
1496
|
-
index = await buildUsageIndex(
|
|
1583
|
+
index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
|
|
1497
1584
|
}
|
|
1498
1585
|
catch {
|
|
1499
1586
|
return fail(503, { error: 'Could not verify where these files are used. Try again.' });
|
|
@@ -1536,12 +1623,12 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1536
1623
|
*/
|
|
1537
1624
|
async function mediaUpdateAction(event) {
|
|
1538
1625
|
const editor = requireSession(event);
|
|
1539
|
-
const
|
|
1626
|
+
const backend = resolveBackend(event);
|
|
1540
1627
|
const form = await event.request.formData();
|
|
1541
1628
|
const hash = String(form.get('hash') ?? '');
|
|
1542
1629
|
if (!MEDIA_HASH_RE.test(hash))
|
|
1543
1630
|
throw error(400, 'Invalid media hash');
|
|
1544
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
1631
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
1545
1632
|
const row = manifest[hash];
|
|
1546
1633
|
if (!row) {
|
|
1547
1634
|
return fail(404, { error: 'That asset is not committed.' });
|
|
@@ -1555,7 +1642,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1555
1642
|
const edited = { ...row, displayName: displayName || slug, slug, alt };
|
|
1556
1643
|
const commitFields = { concept: 'media', id: hash, editor: editor.email };
|
|
1557
1644
|
try {
|
|
1558
|
-
await
|
|
1645
|
+
await backend.commit(backend.defaultBranch, [{ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, edited)) }], { name: editor.displayName, email: editor.email }, `Update media: ${edited.slug}`);
|
|
1559
1646
|
log.info('commit.succeeded', commitFields);
|
|
1560
1647
|
}
|
|
1561
1648
|
catch (err) {
|
|
@@ -1608,8 +1695,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1608
1695
|
if (!MEDIA_HASH_RE.test(oldHash) || !MEDIA_HASH_RE.test(newHash)) {
|
|
1609
1696
|
return fail(400, { error: 'Invalid media hash.', hash: oldHash, usage: [], foundIn: 0 });
|
|
1610
1697
|
}
|
|
1611
|
-
const
|
|
1612
|
-
const contentManifest = await readManifest(
|
|
1698
|
+
const backend = resolveBackend(event);
|
|
1699
|
+
const contentManifest = await readManifest(backend);
|
|
1613
1700
|
const newToken = replacementToken(slug, newHash);
|
|
1614
1701
|
// Plan the rewrite. The planner runs buildUsageIndex in STRICT mode, so an unverifiable branch read
|
|
1615
1702
|
// throws out of here rather than degrading to an absent reference; catch it and fail closed, the
|
|
@@ -1617,8 +1704,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1617
1704
|
let plan;
|
|
1618
1705
|
try {
|
|
1619
1706
|
plan = await planMediaRewrite({
|
|
1620
|
-
backend
|
|
1621
|
-
token,
|
|
1707
|
+
backend,
|
|
1622
1708
|
concepts: runtime.concepts,
|
|
1623
1709
|
contentManifest,
|
|
1624
1710
|
hash: oldHash,
|
|
@@ -1665,7 +1751,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1665
1751
|
*/
|
|
1666
1752
|
async function mediaReplaceApply(event) {
|
|
1667
1753
|
const editor = requireSession(event);
|
|
1668
|
-
const
|
|
1754
|
+
const backend = resolveBackend(event);
|
|
1669
1755
|
const form = await event.request.formData();
|
|
1670
1756
|
const oldHash = String(form.get('oldHash') ?? '');
|
|
1671
1757
|
const newHash = String(form.get('newHash') ?? '');
|
|
@@ -1685,7 +1771,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1685
1771
|
}
|
|
1686
1772
|
// The old asset must be committed on main to be replaceable here. A branch-only upload has no main
|
|
1687
1773
|
// row; it is replaced by editing its draft, not here.
|
|
1688
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
1774
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
1689
1775
|
const row = manifest[oldHash];
|
|
1690
1776
|
if (!row) {
|
|
1691
1777
|
return fail(404, {
|
|
@@ -1710,10 +1796,9 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1710
1796
|
let plan;
|
|
1711
1797
|
try {
|
|
1712
1798
|
plan = await planMediaRewrite({
|
|
1713
|
-
backend
|
|
1714
|
-
token,
|
|
1799
|
+
backend,
|
|
1715
1800
|
concepts: runtime.concepts,
|
|
1716
|
-
contentManifest: await readManifest(
|
|
1801
|
+
contentManifest: await readManifest(backend),
|
|
1717
1802
|
hash: oldHash,
|
|
1718
1803
|
transform: (md) => repointMediaRef(md, oldHash, newToken),
|
|
1719
1804
|
});
|
|
@@ -1743,7 +1828,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1743
1828
|
changes.push({ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, record)) });
|
|
1744
1829
|
const commitFields = { concept: 'media', id: oldHash, editor: editor.email };
|
|
1745
1830
|
try {
|
|
1746
|
-
await
|
|
1831
|
+
await backend.commit(backend.defaultBranch, changes, { name: editor.displayName, email: editor.email }, `Replace media: ${row.slug}`);
|
|
1747
1832
|
log.info('media.replaced', { editor: editor.email, oldHash, newHash, affected: plan.affectedCount });
|
|
1748
1833
|
}
|
|
1749
1834
|
catch (err) {
|
|
@@ -1782,22 +1867,21 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1782
1867
|
if (!MEDIA_HASH_RE.test(hash)) {
|
|
1783
1868
|
return fail(400, { error: 'Invalid media hash.' });
|
|
1784
1869
|
}
|
|
1785
|
-
const
|
|
1870
|
+
const backend = resolveBackend(event);
|
|
1786
1871
|
// The default alt to propagate is the asset's manifest row value (set via mediaUpdateAction). An
|
|
1787
1872
|
// asset with no committed row has no default alt to push, so refuse.
|
|
1788
|
-
const mediaManifest = parseMediaManifest(parseMediaJson(await
|
|
1873
|
+
const mediaManifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
1789
1874
|
const row = mediaManifest[hash];
|
|
1790
1875
|
if (!row) {
|
|
1791
1876
|
return fail(404, { error: 'That asset is not committed.' });
|
|
1792
1877
|
}
|
|
1793
1878
|
// Plan the fill. The planner runs strict, so an unverifiable branch read throws out of here; catch
|
|
1794
1879
|
// it and fail closed, the same posture replace and delete take.
|
|
1795
|
-
const contentManifest = await readManifest(
|
|
1880
|
+
const contentManifest = await readManifest(backend);
|
|
1796
1881
|
let plan;
|
|
1797
1882
|
try {
|
|
1798
1883
|
plan = await planMediaRewrite({
|
|
1799
|
-
backend
|
|
1800
|
-
token,
|
|
1884
|
+
backend,
|
|
1801
1885
|
concepts: runtime.concepts,
|
|
1802
1886
|
contentManifest,
|
|
1803
1887
|
hash,
|
|
@@ -1843,14 +1927,14 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1843
1927
|
*/
|
|
1844
1928
|
async function mediaAltApply(event) {
|
|
1845
1929
|
const editor = requireSession(event);
|
|
1846
|
-
const
|
|
1930
|
+
const backend = resolveBackend(event);
|
|
1847
1931
|
const form = await event.request.formData();
|
|
1848
1932
|
const hash = String(form.get('hash') ?? '');
|
|
1849
1933
|
if (!MEDIA_HASH_RE.test(hash))
|
|
1850
1934
|
throw error(400, 'Invalid media hash');
|
|
1851
1935
|
// The opt-in to also overwrite customized alts; absent (the default) leaves custom alts alone.
|
|
1852
1936
|
const overwrite = form.get('overwrite') === 'on' || form.get('overwrite') === 'true';
|
|
1853
|
-
const mediaManifest = parseMediaManifest(parseMediaJson(await
|
|
1937
|
+
const mediaManifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
1854
1938
|
const row = mediaManifest[hash];
|
|
1855
1939
|
if (!row) {
|
|
1856
1940
|
return fail(404, { error: 'That asset is not committed.' });
|
|
@@ -1864,10 +1948,9 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1864
1948
|
let plan;
|
|
1865
1949
|
try {
|
|
1866
1950
|
plan = await planMediaRewrite({
|
|
1867
|
-
backend
|
|
1868
|
-
token,
|
|
1951
|
+
backend,
|
|
1869
1952
|
concepts: runtime.concepts,
|
|
1870
|
-
contentManifest: await readManifest(
|
|
1953
|
+
contentManifest: await readManifest(backend),
|
|
1871
1954
|
hash,
|
|
1872
1955
|
transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite }),
|
|
1873
1956
|
});
|
|
@@ -1884,7 +1967,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1884
1967
|
const changes = changed.map((e) => ({ path: e.path, content: e.newMarkdown }));
|
|
1885
1968
|
const commitFields = { concept: 'media', id: hash, editor: editor.email };
|
|
1886
1969
|
try {
|
|
1887
|
-
await
|
|
1970
|
+
await backend.commit(backend.defaultBranch, changes, { name: editor.displayName, email: editor.email }, `Propagate alt: ${row.slug}`);
|
|
1888
1971
|
log.info('media.alt_propagated', { editor: editor.email, hash, overwrite, written: changed.length });
|
|
1889
1972
|
}
|
|
1890
1973
|
catch (err) {
|
|
@@ -1908,21 +1991,21 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1908
1991
|
* the canonical file back. Shared by the first attempt and the post-conflict retry, so both re-read
|
|
1909
1992
|
* the head and re-merge the same additions; the merge is order-independent, so a concurrent editor's
|
|
1910
1993
|
* word that already landed is preserved and the result is the same sorted set regardless of order.
|
|
1911
|
-
* Returns the merged word list. Throws CommitConflictError (via
|
|
1912
|
-
* under the commit, which the caller catches to retry once.
|
|
1994
|
+
* Returns the merged word list. Throws CommitConflictError (via backend.commit) when the branch
|
|
1995
|
+
* moves under the commit, which the caller catches to retry once.
|
|
1913
1996
|
*/
|
|
1914
|
-
async function mergeAndCommitDictionary(
|
|
1997
|
+
async function mergeAndCommitDictionary(backend, additions, editor) {
|
|
1915
1998
|
const path = dictionaryFilePath();
|
|
1916
1999
|
// The existing file as its canonical sorted set, so a no-op add is detected against the same
|
|
1917
2000
|
// normalization the commit would write (an already-sorted file never re-commits just to reorder).
|
|
1918
|
-
const canonicalExisting = mergeDictionaryWords(parseDictionary(await
|
|
2001
|
+
const canonicalExisting = mergeDictionaryWords(parseDictionary(await backend.readFile(path, backend.defaultBranch)), []);
|
|
1919
2002
|
const merged = mergeDictionaryWords(canonicalExisting, additions);
|
|
1920
2003
|
// Nothing new (every addition was already present): skip the commit so an idempotent add never
|
|
1921
2004
|
// pushes an empty commit that would redeploy the site. The merged set is still returned so the
|
|
1922
2005
|
// client reconciles its pending additions away.
|
|
1923
2006
|
if (merged.length === canonicalExisting.length)
|
|
1924
2007
|
return merged;
|
|
1925
|
-
await
|
|
2008
|
+
await backend.commit(backend.defaultBranch, [{ path, content: serializeDictionary(merged) }], { name: editor.displayName, email: editor.email }, `Add to dictionary: ${additions.join(', ')}`);
|
|
1926
2009
|
return merged;
|
|
1927
2010
|
}
|
|
1928
2011
|
/**
|
|
@@ -1970,8 +2053,8 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1970
2053
|
/**
|
|
1971
2054
|
* Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
|
|
1972
2055
|
* the same committed YAML the nav editor writes, with the session editor as author. The transport is
|
|
1973
|
-
* the nav save's exactly: a form POST carrying the conventions JSON,
|
|
1974
|
-
* `
|
|
2056
|
+
* the nav save's exactly: a form POST carrying the conventions JSON, a head-guarded
|
|
2057
|
+
* `backend.commit`, and a stale-head `isConflict` bounced back as a reload prompt. Only the conventions
|
|
1975
2058
|
* block is written (setTidy leaves `tidy.enabled` and `tidy.model` untouched), so an editor's save can
|
|
1976
2059
|
* never flip the developer-tier deploy facts. The save refuses before any commit when tidy is not
|
|
1977
2060
|
* enabled, so the gate state's absent editor tier can never be saved past.
|
|
@@ -1992,15 +2075,20 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
1992
2075
|
throw redirect(303, `/admin/settings?error=${encodeURIComponent(message)}`);
|
|
1993
2076
|
}
|
|
1994
2077
|
const path = siteConfigPath();
|
|
1995
|
-
const
|
|
1996
|
-
|
|
2078
|
+
const backend = resolveBackend(event);
|
|
2079
|
+
// Read the head BEFORE the content, so this expectedHead is at-or-before the bytes the commit
|
|
2080
|
+
// merges. The settings write lands on the default branch and triggers a deploy, so it is
|
|
2081
|
+
// fail-closed: a concurrent commit to the config moves the head off this value and the commit
|
|
2082
|
+
// throws a conflict, surfacing the reload-and-reapply prompt below rather than a last-writer-wins.
|
|
2083
|
+
const head = await backend.branchHead(backend.defaultBranch);
|
|
2084
|
+
const raw = await backend.readFile(path, backend.defaultBranch);
|
|
1997
2085
|
if (raw === null)
|
|
1998
2086
|
throw error(404, 'Site config not found');
|
|
1999
2087
|
// Parse first so a malformed file fails before the write rather than committing onto a broken base.
|
|
2000
2088
|
parseSiteConfig(raw);
|
|
2001
2089
|
const commitFields = { concept: 'settings', id: 'tidy', editor: editor.email };
|
|
2002
2090
|
try {
|
|
2003
|
-
await
|
|
2091
|
+
await backend.commit(backend.defaultBranch, [{ path, content: setTidy(raw, conventions) }], { name: editor.displayName, email: editor.email }, 'Update tidy settings', head ?? undefined);
|
|
2004
2092
|
log.info('commit.succeeded', commitFields);
|
|
2005
2093
|
}
|
|
2006
2094
|
catch (err) {
|
|
@@ -2021,7 +2109,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
2021
2109
|
* `{ words }`. It reads the current file from the default branch, inserts the validated words in
|
|
2022
2110
|
* sorted order if absent (idempotent), and commits through the GitHub-App pipeline.
|
|
2023
2111
|
*
|
|
2024
|
-
* The commit is SHA-guarded with commit-and-retry:
|
|
2112
|
+
* The commit is SHA-guarded with commit-and-retry: backend.commit throws CommitConflictError when the
|
|
2025
2113
|
* branch moved under it, which is caught here to re-read the new head, re-merge the same additions
|
|
2026
2114
|
* (the sorted insert is order-independent, so a concurrent editor's word is preserved), and retry
|
|
2027
2115
|
* once. The response is the merged word list, so the client drops the now-committed words from its
|
|
@@ -2056,10 +2144,10 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
2056
2144
|
if (additions.length === 0) {
|
|
2057
2145
|
return fail(400, { error: 'No valid word to add to the dictionary.' });
|
|
2058
2146
|
}
|
|
2059
|
-
const
|
|
2147
|
+
const backend = resolveBackend(event);
|
|
2060
2148
|
const commitFields = { concept: 'dictionary', id: additions[0], editor: editor.email };
|
|
2061
2149
|
try {
|
|
2062
|
-
const words = await mergeAndCommitDictionary(
|
|
2150
|
+
const words = await mergeAndCommitDictionary(backend, additions, editor);
|
|
2063
2151
|
log.info('dictionary.added', { editor: editor.email, words: additions });
|
|
2064
2152
|
return { words };
|
|
2065
2153
|
}
|
|
@@ -2070,7 +2158,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
2070
2158
|
// retry once. The merge is order-independent, so a concurrent editor's word that landed in the
|
|
2071
2159
|
// window is preserved and the two adds converge on the same sorted set.
|
|
2072
2160
|
try {
|
|
2073
|
-
const words = await mergeAndCommitDictionary(
|
|
2161
|
+
const words = await mergeAndCommitDictionary(backend, additions, editor);
|
|
2074
2162
|
log.info('dictionary.added', { editor: editor.email, words: additions, retried: true });
|
|
2075
2163
|
return { words };
|
|
2076
2164
|
}
|
|
@@ -2194,7 +2282,7 @@ export function createContentRoutes(runtime, deps = {}) {
|
|
|
2194
2282
|
log.info('tidy.done', { editor: editor.email, model: message.model, usage: message.usage });
|
|
2195
2283
|
return { corrected, model: message.model, usage: message.usage };
|
|
2196
2284
|
}
|
|
2197
|
-
return { layoutLoad, helpLoad, indexRedirect, listLoad, mediaLibraryLoad, settingsLoad, settingsSave, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, addDictionaryWord, tidyAction
|
|
2285
|
+
return { layoutLoad, helpLoad, indexRedirect, listLoad, mediaLibraryLoad, settingsLoad, settingsSave, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaBulkDelete, mediaOrphanScan, mediaPurgeOrphans, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, addDictionaryWord, tidyAction };
|
|
2198
2286
|
}
|
|
2199
2287
|
/**
|
|
2200
2288
|
* The cap, in characters, on the stored alt text. The human fields are display copy, not content,
|