@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,19 @@
|
|
|
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, type AdvisoryNotice, type AddressEntry } from '../content/advisories.js';
|
|
12
15
|
import { isValidId, slugify, filenameFromId, composeDatedId, slugFromId, renameId } from '../content/ids.js';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
+
import type { BackendEnv } from '../github/credentials.js';
|
|
17
|
+
import type { Backend } from '../github/backend.js';
|
|
18
|
+
import type { FileChange } from '../github/repo.js';
|
|
16
19
|
import { PENDING_PREFIX, pendingBranch, parsePendingBranch } from '../content/pending.js';
|
|
17
|
-
import {
|
|
18
|
-
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
20
|
+
import { emptyManifest, manifestEntryFromFile, parseManifest, serializeManifest, upsertEntry, removeEntry, inboundLinks, inboundReferences, type Manifest, type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
19
21
|
import { deriveGettingStarted, type GettingStarted } from '../content/getting-started.js';
|
|
20
22
|
import { markdownReference, type MarkdownReferenceRow } from '../components/markdown-reference.js';
|
|
21
23
|
import { isConflict } from '../github/types.js';
|
|
@@ -51,7 +53,7 @@ import type { BranchRef } from '../media/rewrite-plan.js';
|
|
|
51
53
|
import { planBulkDelete } from '../media/bulk-delete-plan.js';
|
|
52
54
|
import type { BulkDeleteSkip } from '../media/bulk-delete-plan.js';
|
|
53
55
|
import type { CookieJar, EventBase } from './types.js';
|
|
54
|
-
import type { CairnRuntime, ConceptDescriptor,
|
|
56
|
+
import type { CairnRuntime, ConceptDescriptor, NamedField, PreviewConfig, ResolvedPreview } from '../content/types.js';
|
|
55
57
|
import type { Editor, Role } from '../auth/types.js';
|
|
56
58
|
// R2Bucket is named only inside uploadAction to cast the raw binding for r2Store. It is a type-only
|
|
57
59
|
// import that never appears in an exported signature, so it does not reach the public `.d.ts`.
|
|
@@ -132,7 +134,7 @@ export interface EditData {
|
|
|
132
134
|
conceptId: string;
|
|
133
135
|
id: string;
|
|
134
136
|
label: string;
|
|
135
|
-
fields:
|
|
137
|
+
fields: NamedField[];
|
|
136
138
|
frontmatter: Record<string, unknown>;
|
|
137
139
|
body: string;
|
|
138
140
|
title: string;
|
|
@@ -299,7 +301,7 @@ export interface HelpData {
|
|
|
299
301
|
}
|
|
300
302
|
|
|
301
303
|
/** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
|
|
302
|
-
export interface ContentEvent extends EventBase<
|
|
304
|
+
export interface ContentEvent extends EventBase<BackendEnv> {
|
|
303
305
|
params: Record<string, string>;
|
|
304
306
|
/**
|
|
305
307
|
* SvelteKit's cookie jar. The layout load reads the persisted admin theme and issues the CSRF
|
|
@@ -339,10 +341,12 @@ export interface TidyClient {
|
|
|
339
341
|
|
|
340
342
|
export interface ContentRoutesDeps {
|
|
341
343
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
+
* Override the resolved content backend. A test injects a live `Backend` (a `makeGithubBackend`
|
|
345
|
+
* over a fetch double, or an in-memory fake) so the read and commit paths run with no real token
|
|
346
|
+
* mint. When set it replaces the per-handler `locals.backend ?? runtime.backend.connect(env)`
|
|
347
|
+
* resolve; a production caller leaves it unset and the dev double rides `event.locals.backend`.
|
|
344
348
|
*/
|
|
345
|
-
|
|
349
|
+
backend?: Backend;
|
|
346
350
|
/**
|
|
347
351
|
* Build the Anthropic client for the tidy action from the resolved API key. Defaults to the real
|
|
348
352
|
* SDK client. Injected in tests so `messages.create` is stubbed and no network call (or real key)
|
|
@@ -656,8 +660,15 @@ function conceptOf(runtime: CairnRuntime, params: Record<string, string>): Conce
|
|
|
656
660
|
*
|
|
657
661
|
*/
|
|
658
662
|
export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDeps = {}) {
|
|
659
|
-
|
|
660
|
-
|
|
663
|
+
/**
|
|
664
|
+
* Resolve the live content backend for one request. A test seam (`deps.backend`) wins, then the
|
|
665
|
+
* dev double's `event.locals.backend`, then the production `runtime.backend.connect(env)`. The
|
|
666
|
+
* GitHub provider mints and caches its installation token lazily behind `connect`, so a
|
|
667
|
+
* per-request resolve re-signs only on a cache miss.
|
|
668
|
+
*/
|
|
669
|
+
function resolveBackend(event: ContentEvent): Backend {
|
|
670
|
+
return deps.backend ?? event.locals.backend ?? runtime.backend.connect(event.platform?.env ?? {});
|
|
671
|
+
}
|
|
661
672
|
|
|
662
673
|
// The default Anthropic factory builds the real SDK client from the resolved key. Tests inject a fake
|
|
663
674
|
// (deps.anthropic) so messages.create is stubbed and no network call or real key is ever needed. The
|
|
@@ -670,8 +681,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
670
681
|
* Main's manifest, parsed. A missing file starts empty (a fresh repo before the first commit).
|
|
671
682
|
* Always read from main: pending branches carry no manifest copy.
|
|
672
683
|
*/
|
|
673
|
-
async function readManifest(
|
|
674
|
-
const raw = await
|
|
684
|
+
async function readManifest(backend: Backend): Promise<Manifest> {
|
|
685
|
+
const raw = await backend.readFile(runtime.manifestPath, backend.defaultBranch);
|
|
675
686
|
return raw === null ? emptyManifest() : parseManifest(raw);
|
|
676
687
|
}
|
|
677
688
|
|
|
@@ -719,8 +730,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
719
730
|
// than failing the whole admin shell or showing a wrong publish-all count.
|
|
720
731
|
let pendingEntries: { concept: string; id: string }[] | null = null;
|
|
721
732
|
try {
|
|
722
|
-
const
|
|
723
|
-
const names = await listBranches(
|
|
733
|
+
const backend = resolveBackend(event);
|
|
734
|
+
const names = await backend.listBranches(PENDING_PREFIX);
|
|
724
735
|
pendingEntries = names.flatMap((name) => {
|
|
725
736
|
const entry = pendingEntryOf(name);
|
|
726
737
|
return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
|
|
@@ -753,9 +764,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
753
764
|
let manifest = emptyManifest();
|
|
754
765
|
let pending: { concept: string; id: string }[] = [];
|
|
755
766
|
try {
|
|
756
|
-
const
|
|
757
|
-
manifest = await readManifest(
|
|
758
|
-
const names = await listBranches(
|
|
767
|
+
const backend = resolveBackend(event);
|
|
768
|
+
manifest = await readManifest(backend);
|
|
769
|
+
const names = await backend.listBranches(PENDING_PREFIX);
|
|
759
770
|
pending = names.flatMap((name) => {
|
|
760
771
|
const entry = pendingEntryOf(name);
|
|
761
772
|
return entry ? [{ concept: entry.concept.id, id: entry.id }] : [];
|
|
@@ -783,12 +794,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
783
794
|
*/
|
|
784
795
|
async function summarize(
|
|
785
796
|
file: { id: string; path: string },
|
|
786
|
-
|
|
797
|
+
backend: Backend,
|
|
787
798
|
status: EntrySummary['status'],
|
|
788
|
-
|
|
799
|
+
ref = backend.defaultBranch,
|
|
789
800
|
): Promise<EntrySummary> {
|
|
790
801
|
try {
|
|
791
|
-
const raw = await
|
|
802
|
+
const raw = await backend.readFile(file.path, ref);
|
|
792
803
|
if (raw === null) return { id: file.id, title: file.id, date: null, draft: false, status, summary: null };
|
|
793
804
|
const { frontmatter, body } = parseMarkdown(raw);
|
|
794
805
|
const title = typeof frontmatter.title === 'string' && frontmatter.title.trim() ? frontmatter.title : file.id;
|
|
@@ -807,26 +818,23 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
807
818
|
* in the list instead of reading as a lost save. summarize degrades a failed or empty read to
|
|
808
819
|
* an id-only row, so a ghost ref still lists.
|
|
809
820
|
*/
|
|
810
|
-
function pendingRow(concept: ConceptDescriptor, id: string, status: EntrySummary['status'],
|
|
811
|
-
return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` },
|
|
812
|
-
...runtime.backend,
|
|
813
|
-
branch: pendingBranch(concept.id, id),
|
|
814
|
-
});
|
|
821
|
+
function pendingRow(concept: ConceptDescriptor, id: string, status: EntrySummary['status'], backend: Backend): Promise<EntrySummary> {
|
|
822
|
+
return summarize({ id, path: `${concept.dir}/${filenameFromId(id)}` }, backend, status, pendingBranch(concept.id, id));
|
|
815
823
|
}
|
|
816
824
|
|
|
817
825
|
/**
|
|
818
826
|
* The per-file crawl, kept only for a repo with no committed manifest yet: list main's files
|
|
819
827
|
* and read each one for its row, with edited and new rows reading branch-first.
|
|
820
828
|
*/
|
|
821
|
-
async function crawlEntries(concept: ConceptDescriptor, pendingIds: Set<string>,
|
|
822
|
-
const files = await
|
|
829
|
+
async function crawlEntries(concept: ConceptDescriptor, pendingIds: Set<string>, backend: Backend): Promise<EntrySummary[]> {
|
|
830
|
+
const files = await backend.readEntries(concept.dir, backend.defaultBranch);
|
|
823
831
|
const entries = await Promise.all(
|
|
824
|
-
files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited',
|
|
832
|
+
files.map((f) => (pendingIds.has(f.id) ? pendingRow(concept, f.id, 'edited', backend) : summarize(f, backend, 'published'))),
|
|
825
833
|
);
|
|
826
834
|
// A ref with no main file is a never-published entry; its row reads from its branch.
|
|
827
835
|
const listed = new Set(files.map((f) => f.id));
|
|
828
836
|
const newRows = await Promise.all(
|
|
829
|
-
[...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new',
|
|
837
|
+
[...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', backend)),
|
|
830
838
|
);
|
|
831
839
|
return [...entries, ...newRows];
|
|
832
840
|
}
|
|
@@ -846,16 +854,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
846
854
|
const publishedAllRaw = event.url.searchParams.get('publishedAll');
|
|
847
855
|
const publishedAll = publishedAllRaw !== null && /^\d+$/.test(publishedAllRaw) ? Number(publishedAllRaw) : null;
|
|
848
856
|
const base = { conceptId: concept.id, label: concept.label, singular: concept.singular, dated: concept.routing.dated, formError, publishedAll };
|
|
849
|
-
|
|
850
|
-
try {
|
|
851
|
-
token = await mintToken(event.platform?.env ?? {});
|
|
852
|
-
} catch {
|
|
853
|
-
return { ...base, entries: [], error: 'Could not authenticate with GitHub.' };
|
|
854
|
-
}
|
|
857
|
+
const backend = resolveBackend(event);
|
|
855
858
|
try {
|
|
856
859
|
const [manifestRaw, refs] = await Promise.all([
|
|
857
|
-
|
|
858
|
-
listBranches(
|
|
860
|
+
backend.readFile(runtime.manifestPath, backend.defaultBranch),
|
|
861
|
+
backend.listBranches(`${PENDING_PREFIX}${concept.id}/`),
|
|
859
862
|
]);
|
|
860
863
|
const pendingIds = new Set(
|
|
861
864
|
refs.flatMap((name) => {
|
|
@@ -866,7 +869,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
866
869
|
// A repo with no committed manifest yet (a fresh site before its first publish) falls back
|
|
867
870
|
// to the crawl; a manifest that parses but is empty is trusted as-is.
|
|
868
871
|
if (manifestRaw === null) {
|
|
869
|
-
return { ...base, entries: await crawlEntries(concept, pendingIds,
|
|
872
|
+
return { ...base, entries: await crawlEntries(concept, pendingIds, backend), error: null };
|
|
870
873
|
}
|
|
871
874
|
// Newest id first, the same order the crawl's file listing produced.
|
|
872
875
|
const rows = parseManifest(manifestRaw)
|
|
@@ -875,13 +878,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
875
878
|
const entries = await Promise.all(
|
|
876
879
|
rows.map((e) =>
|
|
877
880
|
pendingIds.has(e.id)
|
|
878
|
-
? pendingRow(concept, e.id, 'edited',
|
|
881
|
+
? pendingRow(concept, e.id, 'edited', backend)
|
|
879
882
|
: { id: e.id, title: e.title, date: e.date ?? null, draft: e.draft, status: 'published' as const, summary: e.summary ?? null },
|
|
880
883
|
),
|
|
881
884
|
);
|
|
882
885
|
const listed = new Set(rows.map((e) => e.id));
|
|
883
886
|
const newRows = await Promise.all(
|
|
884
|
-
[...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new',
|
|
887
|
+
[...pendingIds].filter((id) => !listed.has(id)).map((id) => pendingRow(concept, id, 'new', backend)),
|
|
885
888
|
);
|
|
886
889
|
return { ...base, entries: [...entries, ...newRows], error: null };
|
|
887
890
|
} catch {
|
|
@@ -910,30 +913,27 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
910
913
|
else if (event.url.searchParams.get('bulkDeleted') === '1') flash = 'bulkDeleted';
|
|
911
914
|
else if (event.url.searchParams.get('orphansPurged') === '1') flash = 'orphansPurged';
|
|
912
915
|
const flashError = event.url.searchParams.get('error');
|
|
913
|
-
|
|
914
|
-
try {
|
|
915
|
-
token = await mintToken(event.platform?.env ?? {});
|
|
916
|
-
} catch {
|
|
917
|
-
return { assets: [], usage: {}, error: 'Could not authenticate with GitHub.', flash, flashError };
|
|
918
|
-
}
|
|
916
|
+
const backend = resolveBackend(event);
|
|
919
917
|
|
|
920
918
|
// Union the media manifest by hash: main's rows first, then any branch hash not already present.
|
|
921
919
|
// Identical bytes share one row, so a hash on both branches prefers main's row. A failed or
|
|
922
920
|
// absent branch read degrades to no rows for that branch (the tolerant parse yields {} on null).
|
|
923
921
|
// The branch list is taken ONCE here and handed to buildUsageIndex below, so the load path does
|
|
924
922
|
// not enumerate the open branches twice (the per-page subrequest budget is tight at ~25+ branches).
|
|
923
|
+
// The token mint is now lazy inside the first read, so a token or a network failure both land in
|
|
924
|
+
// this one degrade rather than the old separate could-not-authenticate tier.
|
|
925
925
|
const union = new Map<string, MediaEntry>();
|
|
926
926
|
let branchNames: string[] = [];
|
|
927
927
|
try {
|
|
928
|
-
const mediaRaw = await
|
|
928
|
+
const mediaRaw = await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch);
|
|
929
929
|
for (const [hash, e] of Object.entries(parseMediaManifest(parseMediaJson(mediaRaw)))) {
|
|
930
930
|
union.set(hash, e);
|
|
931
931
|
}
|
|
932
|
-
const names = await listBranches(
|
|
932
|
+
const names = await backend.listBranches(PENDING_PREFIX);
|
|
933
933
|
branchNames = names;
|
|
934
934
|
const branchManifests = await Promise.all(
|
|
935
935
|
names.map((name) =>
|
|
936
|
-
|
|
936
|
+
backend.readFile(runtime.mediaManifestPath, name)
|
|
937
937
|
.then((raw) => parseMediaManifest(parseMediaJson(raw)))
|
|
938
938
|
.catch(() => ({}) as Record<string, MediaEntry>),
|
|
939
939
|
),
|
|
@@ -954,11 +954,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
954
954
|
// here keeps the asset list intact with an empty overlay, since the screen still lists assets.
|
|
955
955
|
let usage: Record<string, MediaUsageInfo> = {};
|
|
956
956
|
try {
|
|
957
|
-
const manifestRaw = await
|
|
957
|
+
const manifestRaw = await backend.readFile(runtime.manifestPath, backend.defaultBranch);
|
|
958
958
|
const manifest = manifestRaw === null ? emptyManifest() : parseManifest(manifestRaw);
|
|
959
959
|
// Reuse the branch list from the media-union above; the Library DISPLAY keeps the default
|
|
960
960
|
// best-effort behavior (a failed branch read degrades that one branch, not the screen).
|
|
961
|
-
const index = await buildUsageIndex(
|
|
961
|
+
const index = await buildUsageIndex(backend, runtime.concepts, manifest, { branches: branchNames });
|
|
962
962
|
for (const [hash, entries] of index) {
|
|
963
963
|
usage[hash] = { count: distinctEntryCount(entries), entries };
|
|
964
964
|
}
|
|
@@ -990,33 +990,17 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
990
990
|
id = composeDatedId(date, slug, concept.datePrefix);
|
|
991
991
|
}
|
|
992
992
|
|
|
993
|
-
const
|
|
994
|
-
const existing = await
|
|
993
|
+
const backend = resolveBackend(event);
|
|
994
|
+
const existing = await backend.readFile(`${concept.dir}/${filenameFromId(id)}`, backend.defaultBranch);
|
|
995
995
|
if (existing !== null) return bounce('An entry with that slug already exists.');
|
|
996
996
|
// A pending branch is an entry too (saved but not yet published); refuse to clobber it.
|
|
997
|
-
if ((await
|
|
997
|
+
if ((await backend.branchHead(pendingBranch(concept.id, id))) !== null) {
|
|
998
998
|
return bounce('An unpublished entry with that slug already exists.');
|
|
999
999
|
}
|
|
1000
1000
|
|
|
1001
1001
|
throw redirect(303, `/admin/${concept.id}/${id}?new=1`);
|
|
1002
1002
|
}
|
|
1003
1003
|
|
|
1004
|
-
/** Coerce parsed frontmatter to the form-ready values the editor inputs expect. */
|
|
1005
|
-
function formValues(fields: FrontmatterField[], frontmatter: Record<string, unknown>): Record<string, unknown> {
|
|
1006
|
-
const out: Record<string, unknown> = {};
|
|
1007
|
-
for (const field of fields) {
|
|
1008
|
-
const value = frontmatter[field.name];
|
|
1009
|
-
if (field.type === 'date') out[field.name] = dateInputValue(value);
|
|
1010
|
-
else if (field.type === 'boolean') out[field.name] = value === true;
|
|
1011
|
-
else if (field.type === 'tags' || field.type === 'freetags') out[field.name] = Array.isArray(value) ? value.map(String) : [];
|
|
1012
|
-
// A hero is a nested object; the default String() arm would corrupt it to '[object Object]'.
|
|
1013
|
-
// Hand the stored object back as-is so the editor reads .src/.alt/.caption on open.
|
|
1014
|
-
else if (field.type === 'image') out[field.name] = value !== null && typeof value === 'object' ? value : undefined;
|
|
1015
|
-
else out[field.name] = typeof value === 'string' ? value : value == null ? '' : String(value);
|
|
1016
|
-
}
|
|
1017
|
-
return out;
|
|
1018
|
-
}
|
|
1019
|
-
|
|
1020
1004
|
/** Open a file for editing. A `?new=1` miss yields a blank document; any other miss is a 404. */
|
|
1021
1005
|
async function editLoad(event: ContentEvent): Promise<EditData> {
|
|
1022
1006
|
requireSession(event);
|
|
@@ -1024,7 +1008,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1024
1008
|
const id = event.params.id ?? '';
|
|
1025
1009
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
1026
1010
|
const isNew = event.url.searchParams.get('new') === '1';
|
|
1027
|
-
const
|
|
1011
|
+
const backend = resolveBackend(event);
|
|
1028
1012
|
const datePrefix = concept.routing.dated ? concept.datePrefix : null;
|
|
1029
1013
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
1030
1014
|
// A pending entry reads branch-first: the editor shows the unpublished edits. The manifest
|
|
@@ -1041,20 +1025,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1041
1025
|
// rejected read degrades to null so the edit never throws on a missing or unreadable dictionary;
|
|
1042
1026
|
// the projection below treats null as an empty word list (the editor falls back to dialect-only).
|
|
1043
1027
|
const [headSha, mainRaw, manifestRaw, mediaRaw, dictionaryRaw] = await Promise.all([
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1028
|
+
backend.branchHead(branch),
|
|
1029
|
+
backend.readFile(path, backend.defaultBranch),
|
|
1030
|
+
backend.readFile(runtime.manifestPath, backend.defaultBranch),
|
|
1047
1031
|
runtime.resolvedAssets.enabled
|
|
1048
|
-
?
|
|
1032
|
+
? backend.readFile(runtime.mediaManifestPath, backend.defaultBranch).catch(() => null)
|
|
1049
1033
|
: Promise.resolve(null),
|
|
1050
|
-
|
|
1034
|
+
backend.readFile(dictionaryFilePath(), backend.defaultBranch).catch(() => null),
|
|
1051
1035
|
]);
|
|
1052
1036
|
const pending = headSha !== null;
|
|
1053
|
-
const raw = pending ? await
|
|
1037
|
+
const raw = pending ? await backend.readFile(path, branch) : mainRaw;
|
|
1054
1038
|
if (raw === null && !isNew) throw error(404, 'Entry not found');
|
|
1055
1039
|
const published = mainRaw !== null;
|
|
1056
1040
|
|
|
1057
1041
|
const parsed = raw === null ? { frontmatter: {}, body: '' } : parseMarkdown(raw);
|
|
1042
|
+
// A fresh entry opens prefilled from each field's `default`, resolving a `'today'` date against a
|
|
1043
|
+
// request-time clock. The defaults sit under the empty parsed frontmatter, never over a real read.
|
|
1044
|
+
const loadFrontmatter = isNew
|
|
1045
|
+
? { ...initialValues(concept.schema, new Date()), ...parsed.frontmatter }
|
|
1046
|
+
: parsed.frontmatter;
|
|
1058
1047
|
const title = typeof parsed.frontmatter.title === 'string' && parsed.frontmatter.title.trim() ? parsed.frontmatter.title : id;
|
|
1059
1048
|
|
|
1060
1049
|
const manifest = manifestRaw !== null ? parseManifest(manifestRaw) : null;
|
|
@@ -1115,7 +1104,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1115
1104
|
id,
|
|
1116
1105
|
label: concept.label,
|
|
1117
1106
|
fields: concept.fields,
|
|
1118
|
-
frontmatter: formValues(concept.fields,
|
|
1107
|
+
frontmatter: formValues(concept.fields, loadFrontmatter),
|
|
1119
1108
|
body: parsed.body,
|
|
1120
1109
|
title,
|
|
1121
1110
|
isNew,
|
|
@@ -1210,7 +1199,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1210
1199
|
manifest: Manifest;
|
|
1211
1200
|
/** The draft-target tokens the body links to, for save's warning query. */
|
|
1212
1201
|
draftLinks: string[];
|
|
1213
|
-
|
|
1202
|
+
/** The absent-or-draft reference targets, for save's non-blocking reference warning. */
|
|
1203
|
+
referenceWarnings: string[];
|
|
1204
|
+
/** The backend this save resolved, so publish reuses it without a second resolve. */
|
|
1205
|
+
backend: Backend;
|
|
1214
1206
|
/**
|
|
1215
1207
|
* The merged media.json change this save committed to the branch, when media is on and the
|
|
1216
1208
|
* post carried records. Publish reuses it verbatim so the main commit promotes the exact same
|
|
@@ -1246,7 +1238,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1246
1238
|
}
|
|
1247
1239
|
|
|
1248
1240
|
const markdown = serializeMarkdown(result.data, body);
|
|
1249
|
-
const
|
|
1241
|
+
const backend = resolveBackend(event);
|
|
1250
1242
|
|
|
1251
1243
|
// Merge the editor's optimistic media records into the media manifest, gated on media being on
|
|
1252
1244
|
// and at least one valid record posted. The base is read from the default branch (never the
|
|
@@ -1258,7 +1250,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1258
1250
|
if (runtime.resolvedAssets.enabled) {
|
|
1259
1251
|
const records = parseMediaEntries(form.get('media'));
|
|
1260
1252
|
if (records.length > 0) {
|
|
1261
|
-
const baseRaw = await
|
|
1253
|
+
const baseRaw = await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch);
|
|
1262
1254
|
let mediaManifest = parseMediaManifest(parseMediaJson(baseRaw));
|
|
1263
1255
|
for (const record of records) {
|
|
1264
1256
|
mediaManifest = upsertMediaEntry(mediaManifest, record);
|
|
@@ -1269,7 +1261,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1269
1261
|
|
|
1270
1262
|
// Upsert this entry's row into main's manifest in memory, for the link guard here and for
|
|
1271
1263
|
// the publish commit. The save commits no manifest change; publish lands the upsert on main.
|
|
1272
|
-
const manifest = await readManifest(
|
|
1264
|
+
const manifest = await readManifest(backend);
|
|
1273
1265
|
const row = manifestEntryFromFile(concept, { path, raw: markdown });
|
|
1274
1266
|
const upserted = upsertEntry(manifest, row);
|
|
1275
1267
|
|
|
@@ -1298,31 +1290,45 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1298
1290
|
} satisfies SaveFailure);
|
|
1299
1291
|
}
|
|
1300
1292
|
|
|
1293
|
+
// Frontmatter reference warning: classify each typed reference edge against the same upserted
|
|
1294
|
+
// manifest. This is best-effort against the committed (possibly stale) main manifest and advisory
|
|
1295
|
+
// like draftLinks, NEVER the integrity guarantee; references have no prerender re-resolve backstop,
|
|
1296
|
+
// so verifyReferences at the build is the only authority. A reference NEVER blocks the save: unlike
|
|
1297
|
+
// a body link, an absent or draft target only warns, since the build gate fails a true dangling.
|
|
1298
|
+
const referenceWarnings: string[] = [];
|
|
1299
|
+
for (const edge of extractReferenceEdges(result.data, concept.fields)) {
|
|
1300
|
+
if (edge.concept === concept.id && edge.id === id) continue;
|
|
1301
|
+
const target = byKey.get(`${edge.concept}/${edge.id}`);
|
|
1302
|
+
if (!target || target.draft) referenceWarnings.push(`${edge.concept}/${edge.id}`);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1301
1305
|
// Ensure the entry's pending branch exists (cut lazily from main's head on first save), then
|
|
1302
1306
|
// commit only the entry file there. Main stays untouched until publish, so the branch differs
|
|
1303
1307
|
// from main at exactly this entry's path.
|
|
1304
1308
|
const branch = pendingBranch(concept.id, id);
|
|
1305
|
-
if ((await
|
|
1306
|
-
|
|
1309
|
+
if ((await backend.branchHead(branch)) === null) {
|
|
1310
|
+
// The default-branch head read distinguishes a first save from a re-save; a null is the
|
|
1311
|
+
// unreadable-default-branch case the create cannot recover from, so fail with the 500.
|
|
1312
|
+
const mainHead = await backend.branchHead(backend.defaultBranch);
|
|
1307
1313
|
if (mainHead === null) throw error(500, 'Cannot read the default branch');
|
|
1308
|
-
await createBranch(
|
|
1314
|
+
await backend.createBranch(branch, backend.defaultBranch);
|
|
1309
1315
|
}
|
|
1310
1316
|
|
|
1311
1317
|
const commitFields = { concept: concept.id, id, editor: editor.email, branch };
|
|
1312
1318
|
let branchSha: string;
|
|
1313
1319
|
try {
|
|
1314
|
-
branchSha = await
|
|
1315
|
-
|
|
1320
|
+
branchSha = await backend.commit(
|
|
1321
|
+
branch,
|
|
1316
1322
|
mediaChange ? [{ path, content: markdown }, mediaChange] : [{ path, content: markdown }],
|
|
1317
|
-
{
|
|
1318
|
-
|
|
1323
|
+
{ name: editor.displayName, email: editor.email },
|
|
1324
|
+
`Update ${concept.label.toLowerCase()}: ${id}`,
|
|
1319
1325
|
);
|
|
1320
1326
|
log.info('commit.succeeded', commitFields);
|
|
1321
1327
|
} catch (err) {
|
|
1322
1328
|
commitFailure(commitFields, err, `/admin/${concept.id}/${id}`,
|
|
1323
1329
|
'This file changed since you opened it. Reload and reapply your edits.', { query: suffix });
|
|
1324
1330
|
}
|
|
1325
|
-
return { path, markdown, branch, branchSha, manifest: upserted, draftLinks,
|
|
1331
|
+
return { path, markdown, branch, branchSha, manifest: upserted, draftLinks, referenceWarnings, backend, mediaChange };
|
|
1326
1332
|
}
|
|
1327
1333
|
|
|
1328
1334
|
/**
|
|
@@ -1338,9 +1344,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1338
1344
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
1339
1345
|
const held = await saveToBranch(event, editor, concept, id);
|
|
1340
1346
|
if (!('branchSha' in held)) return held;
|
|
1341
|
-
|
|
1347
|
+
let savedQuery = held.draftLinks.length
|
|
1342
1348
|
? `saved=1&drafts=${encodeURIComponent(held.draftLinks.join(','))}`
|
|
1343
1349
|
: 'saved=1';
|
|
1350
|
+
if (held.referenceWarnings.length)
|
|
1351
|
+
savedQuery += `&refs=${encodeURIComponent(held.referenceWarnings.join(','))}`;
|
|
1344
1352
|
throw redirect(303, `/admin/${concept.id}/${id}?${savedQuery}`);
|
|
1345
1353
|
}
|
|
1346
1354
|
|
|
@@ -1359,7 +1367,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1359
1367
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
1360
1368
|
const held = await saveToBranch(event, editor, concept, id);
|
|
1361
1369
|
if (!('branchSha' in held)) return held;
|
|
1362
|
-
const { path, markdown, branch, branchSha, manifest,
|
|
1370
|
+
const { path, markdown, branch, branchSha, manifest, backend, mediaChange } = held;
|
|
1363
1371
|
|
|
1364
1372
|
// The publish commit reuses the exact merged media.json saveToBranch already built (decision 1:
|
|
1365
1373
|
// no re-read or re-merge here). Promote it to main alongside the body and the content manifest
|
|
@@ -1379,7 +1387,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1379
1387
|
try {
|
|
1380
1388
|
const { frontmatter } = parseMarkdown(markdown);
|
|
1381
1389
|
address = entryIdentity(concept, path, frontmatter).permalink;
|
|
1382
|
-
const addressIndex = await buildAddressIndex(
|
|
1390
|
+
const addressIndex = await buildAddressIndex(backend, runtime.concepts, manifest);
|
|
1383
1391
|
collision = addressCollision(addressIndex, { concept: concept.id, id }, address);
|
|
1384
1392
|
} catch (err) {
|
|
1385
1393
|
// Fail open, the same as editLoad: a thrown index build degrades to no event and the publish
|
|
@@ -1390,11 +1398,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1390
1398
|
|
|
1391
1399
|
const commitFields = { concept: concept.id, id, editor: editor.email };
|
|
1392
1400
|
try {
|
|
1393
|
-
await
|
|
1394
|
-
|
|
1401
|
+
await backend.commit(
|
|
1402
|
+
backend.defaultBranch,
|
|
1395
1403
|
changes,
|
|
1396
|
-
{
|
|
1397
|
-
|
|
1404
|
+
{ name: editor.displayName, email: editor.email },
|
|
1405
|
+
`Publish ${concept.label.toLowerCase()}: ${id}`,
|
|
1398
1406
|
);
|
|
1399
1407
|
log.info('entry.published', { ...commitFields, batch: false });
|
|
1400
1408
|
// Only after the publish lands: a diagnostic that a live address now has a new owner.
|
|
@@ -1414,8 +1422,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1414
1422
|
// Only after the main commit lands, and only when the branch head is still the commit this
|
|
1415
1423
|
// action made: a head that moved is a concurrent save, and deleting it would destroy edits.
|
|
1416
1424
|
// No log event for the skip; the pending badge is the surface.
|
|
1417
|
-
if ((await
|
|
1418
|
-
await deleteBranch(
|
|
1425
|
+
if ((await backend.branchHead(branch)) === branchSha) {
|
|
1426
|
+
await backend.deleteBranch(branch);
|
|
1419
1427
|
}
|
|
1420
1428
|
throw redirect(303, `/admin/${concept.id}/${id}?published=1`);
|
|
1421
1429
|
}
|
|
@@ -1430,12 +1438,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1430
1438
|
const editor = requireSession(event);
|
|
1431
1439
|
const first = runtime.concepts[0];
|
|
1432
1440
|
if (!first) throw error(404, 'No content types configured');
|
|
1433
|
-
const
|
|
1441
|
+
const backend = resolveBackend(event);
|
|
1434
1442
|
const listPage = `/admin/${first.id}`;
|
|
1435
1443
|
|
|
1436
1444
|
// Each cairn/ ref names a pending entry; the shared predicate skips a stray ref rather
|
|
1437
1445
|
// than failing the whole batch on it.
|
|
1438
|
-
const names = await listBranches(
|
|
1446
|
+
const names = await backend.listBranches(PENDING_PREFIX);
|
|
1439
1447
|
const pending = names.flatMap((name) => {
|
|
1440
1448
|
const entry = pendingEntryOf(name);
|
|
1441
1449
|
return entry ? [{ ...entry, branch: name, path: `${entry.concept.dir}/${filenameFromId(entry.id)}` }] : [];
|
|
@@ -1448,15 +1456,15 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1448
1456
|
// clean it up); it carries nothing to publish.
|
|
1449
1457
|
const reads = await Promise.all(
|
|
1450
1458
|
pending.map(async (entry) => {
|
|
1451
|
-
const sha = await
|
|
1452
|
-
const raw = await
|
|
1459
|
+
const sha = await backend.branchHead(entry.branch);
|
|
1460
|
+
const raw = await backend.readFile(entry.path, entry.branch);
|
|
1453
1461
|
return { ...entry, sha, raw };
|
|
1454
1462
|
}),
|
|
1455
1463
|
);
|
|
1456
1464
|
|
|
1457
1465
|
// Fold main's manifest once over every row, so the batch lands content and index together,
|
|
1458
1466
|
// the same shape as a single publish.
|
|
1459
|
-
let next = await readManifest(
|
|
1467
|
+
let next = await readManifest(backend);
|
|
1460
1468
|
const changes: FileChange[] = [];
|
|
1461
1469
|
const published: { concept: string; id: string; branch: string; sha: string }[] = [];
|
|
1462
1470
|
for (const entry of reads) {
|
|
@@ -1473,11 +1481,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1473
1481
|
|
|
1474
1482
|
const noun = published.length === 1 ? 'entry' : 'entries';
|
|
1475
1483
|
try {
|
|
1476
|
-
await
|
|
1477
|
-
|
|
1484
|
+
await backend.commit(
|
|
1485
|
+
backend.defaultBranch,
|
|
1478
1486
|
changes,
|
|
1479
|
-
{
|
|
1480
|
-
|
|
1487
|
+
{ name: editor.displayName, email: editor.email },
|
|
1488
|
+
`Publish ${published.length} ${noun}`,
|
|
1481
1489
|
);
|
|
1482
1490
|
for (const entry of published) {
|
|
1483
1491
|
log.info('entry.published', { concept: entry.concept, id: entry.id, editor: editor.email, batch: true });
|
|
@@ -1501,8 +1509,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1501
1509
|
// abort the remaining deletes.
|
|
1502
1510
|
for (const entry of published) {
|
|
1503
1511
|
try {
|
|
1504
|
-
if ((await
|
|
1505
|
-
await deleteBranch(
|
|
1512
|
+
if ((await backend.branchHead(entry.branch)) === entry.sha) {
|
|
1513
|
+
await backend.deleteBranch(entry.branch);
|
|
1506
1514
|
}
|
|
1507
1515
|
} catch {
|
|
1508
1516
|
// The entry is live; the straggler just shows as still pending until the next publish.
|
|
@@ -1520,12 +1528,12 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1520
1528
|
const concept = conceptOf(runtime, event.params);
|
|
1521
1529
|
const id = event.params.id ?? '';
|
|
1522
1530
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
1523
|
-
const
|
|
1531
|
+
const backend = resolveBackend(event);
|
|
1524
1532
|
|
|
1525
|
-
await deleteBranch(
|
|
1533
|
+
await backend.deleteBranch(pendingBranch(concept.id, id));
|
|
1526
1534
|
log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
|
|
1527
1535
|
|
|
1528
|
-
const onMain = await
|
|
1536
|
+
const onMain = await backend.readFile(`${concept.dir}/${filenameFromId(id)}`, backend.defaultBranch);
|
|
1529
1537
|
if (onMain !== null) throw redirect(303, `/admin/${concept.id}/${id}?discarded=1`);
|
|
1530
1538
|
throw redirect(303, `/admin/${concept.id}`);
|
|
1531
1539
|
}
|
|
@@ -1544,11 +1552,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1544
1552
|
editor: Editor,
|
|
1545
1553
|
): Promise<ReturnType<typeof fail> | never> {
|
|
1546
1554
|
const path = `${concept.dir}/${filenameFromId(id)}`;
|
|
1547
|
-
const
|
|
1555
|
+
const backend = resolveBackend(event);
|
|
1548
1556
|
|
|
1549
1557
|
// An absent manifest degrades the inbound gate to "allow": with no manifest there is nothing to
|
|
1550
1558
|
// check, and the build's cairn: backstop still catches any dangling token, mirroring saveAction.
|
|
1551
|
-
const manifest = await readManifest(
|
|
1559
|
+
const manifest = await readManifest(backend);
|
|
1552
1560
|
const inbound = inboundLinks(manifest, concept.id, id);
|
|
1553
1561
|
if (inbound.length) {
|
|
1554
1562
|
return fail(409, {
|
|
@@ -1558,12 +1566,45 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1558
1566
|
} satisfies DeleteRefusal);
|
|
1559
1567
|
}
|
|
1560
1568
|
|
|
1569
|
+
// Cross-branch reference gate (fail-closed). A strict reference index unions main's published edges
|
|
1570
|
+
// and every open cairn/* branch; unlike the main-only body-link gate above, it does NOT degrade to
|
|
1571
|
+
// allow when it cannot read, because the build's verifyReferences backstop only sees main. A
|
|
1572
|
+
// transient branch-read failure that looked like "no references" would let a delete strand an
|
|
1573
|
+
// inbound edge held in an unpublished draft, so refuse with a 503 rather than proceed.
|
|
1574
|
+
let refIndex: Awaited<ReturnType<typeof buildReferenceIndex>>;
|
|
1575
|
+
try {
|
|
1576
|
+
refIndex = await buildReferenceIndex(backend, runtime.concepts, manifest, { strict: true });
|
|
1577
|
+
} catch {
|
|
1578
|
+
return fail(503, {
|
|
1579
|
+
error: 'Could not verify where this entry is referenced. Try again.',
|
|
1580
|
+
inboundLinks: [],
|
|
1581
|
+
id,
|
|
1582
|
+
} satisfies DeleteRefusal);
|
|
1583
|
+
}
|
|
1584
|
+
const refRows = refIndex.get(`${concept.id}/${id}`) ?? [];
|
|
1585
|
+
if (refRows.length > 0) {
|
|
1586
|
+
// Carry each referencing entry into the InboundLink shape the blockers list renders. A branch row
|
|
1587
|
+
// has no permalink (the edit is unpublished), so default it to empty.
|
|
1588
|
+
const referencingEntries: InboundLink[] = refRows.map((row) => ({
|
|
1589
|
+
concept: row.concept,
|
|
1590
|
+
id: row.id,
|
|
1591
|
+
title: row.title,
|
|
1592
|
+
permalink: row.permalink ?? '',
|
|
1593
|
+
}));
|
|
1594
|
+
const n = referencingEntries.length;
|
|
1595
|
+
return fail(409, {
|
|
1596
|
+
error: `Cannot delete ${id}: ${n} ${n === 1 ? 'entry references' : 'entries reference'} it.`,
|
|
1597
|
+
inboundLinks: referencingEntries,
|
|
1598
|
+
id,
|
|
1599
|
+
} satisfies DeleteRefusal);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1561
1602
|
// When the entry was never published (absent from main), the branch delete is the whole
|
|
1562
1603
|
// operation; main has nothing to commit, so the only honest log record is the discard of
|
|
1563
1604
|
// the pending edits.
|
|
1564
|
-
const onMain = await
|
|
1605
|
+
const onMain = await backend.readFile(path, backend.defaultBranch);
|
|
1565
1606
|
if (onMain === null) {
|
|
1566
|
-
await deleteBranch(
|
|
1607
|
+
await backend.deleteBranch(pendingBranch(concept.id, id));
|
|
1567
1608
|
log.info('entry.discarded', { concept: concept.id, id, editor: editor.email });
|
|
1568
1609
|
throw redirect(303, `/admin/${concept.id}`);
|
|
1569
1610
|
}
|
|
@@ -1571,14 +1612,14 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1571
1612
|
const nextManifest = serializeManifest(removeEntry(manifest, concept.id, id));
|
|
1572
1613
|
const commitFields = { concept: concept.id, id, editor: editor.email };
|
|
1573
1614
|
try {
|
|
1574
|
-
await
|
|
1575
|
-
|
|
1615
|
+
await backend.commit(
|
|
1616
|
+
backend.defaultBranch,
|
|
1576
1617
|
[
|
|
1577
1618
|
{ path, content: null },
|
|
1578
1619
|
{ path: runtime.manifestPath, content: nextManifest },
|
|
1579
1620
|
],
|
|
1580
|
-
{
|
|
1581
|
-
|
|
1621
|
+
{ name: editor.displayName, email: editor.email },
|
|
1622
|
+
`Delete ${concept.label.toLowerCase()}: ${id}`,
|
|
1582
1623
|
);
|
|
1583
1624
|
log.info('commit.succeeded', commitFields);
|
|
1584
1625
|
} catch (err) {
|
|
@@ -1590,7 +1631,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1590
1631
|
// recoverable (it lists as a never-published row a discard can clean up), matching
|
|
1591
1632
|
// publish's posture, so the entry's deletion still completes.
|
|
1592
1633
|
try {
|
|
1593
|
-
await deleteBranch(
|
|
1634
|
+
await backend.deleteBranch(pendingBranch(concept.id, id));
|
|
1594
1635
|
} catch {
|
|
1595
1636
|
// The entry is gone from main; the straggler shows as a pending row until discarded.
|
|
1596
1637
|
}
|
|
@@ -1627,11 +1668,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1627
1668
|
const concept = conceptOf(runtime, event.params);
|
|
1628
1669
|
const id = event.params.id ?? '';
|
|
1629
1670
|
if (!isValidId(id)) throw error(400, 'Invalid entry id');
|
|
1630
|
-
const
|
|
1671
|
+
const backend = resolveBackend(event);
|
|
1631
1672
|
|
|
1632
1673
|
// Pending edits on the branch are keyed to the old id; renaming underneath them would strand
|
|
1633
1674
|
// them, so refuse until the editor publishes or discards.
|
|
1634
|
-
if ((await
|
|
1675
|
+
if ((await backend.branchHead(pendingBranch(concept.id, id))) !== null) {
|
|
1635
1676
|
return fail(409, { error: 'This entry has unpublished edits. Publish or discard them, then rename.' } satisfies RenameFailure);
|
|
1636
1677
|
}
|
|
1637
1678
|
|
|
@@ -1654,23 +1695,57 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1654
1695
|
// Collision guard: refuse if a file already exists at the new path. This 409 covers two cases a
|
|
1655
1696
|
// single readRaw cannot tell apart: a static collision with an existing entry, and a
|
|
1656
1697
|
// concurrent-rename race where another editor renamed onto this path between load and submit.
|
|
1657
|
-
const clobber = await
|
|
1698
|
+
const clobber = await backend.readFile(newPath, backend.defaultBranch);
|
|
1658
1699
|
if (clobber !== null) {
|
|
1659
1700
|
return fail(409, { error: 'An entry with that slug already exists.' } satisfies RenameFailure);
|
|
1660
1701
|
}
|
|
1661
1702
|
|
|
1662
1703
|
const [entryRaw, manifest] = await Promise.all([
|
|
1663
|
-
|
|
1664
|
-
readManifest(
|
|
1704
|
+
backend.readFile(oldPath, backend.defaultBranch),
|
|
1705
|
+
readManifest(backend),
|
|
1665
1706
|
]);
|
|
1666
1707
|
if (entryRaw === null) throw error(404, 'Entry not found');
|
|
1667
1708
|
|
|
1709
|
+
// Cross-branch reference gate (fail-closed). A reference index unions main's published edges and
|
|
1710
|
+
// every open cairn/* branch; if it cannot be built (a transient branch read failure), refuse
|
|
1711
|
+
// rather than rename a still-referenced target and strand the inbound edge.
|
|
1712
|
+
let refIndex: Awaited<ReturnType<typeof buildReferenceIndex>>;
|
|
1713
|
+
try {
|
|
1714
|
+
refIndex = await buildReferenceIndex(backend, runtime.concepts, manifest, { strict: true });
|
|
1715
|
+
} catch {
|
|
1716
|
+
return fail(409, { error: 'Could not verify references. Try again.' } satisfies RenameFailure);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// Refuse when a THIRD-PARTY open branch holds an inbound reference (symmetric with the pending-edits
|
|
1720
|
+
// guard). The strict index unions main and every branch, so filter before refusing: gate
|
|
1721
|
+
// origin.kind === 'branch' FIRST (a published row has no .branch, so a bare branch-name compare would
|
|
1722
|
+
// trip on every main-side inbound and over-refuse), then exclude the entry's OWN pending branch
|
|
1723
|
+
// (already refused above and absent by construction here). Published (main) inbound rows are NOT
|
|
1724
|
+
// refused; they are repointed below.
|
|
1725
|
+
const ownBranch = pendingBranch(concept.id, id);
|
|
1726
|
+
const conflictBranches = (refIndex.get(`${concept.id}/${id}`) ?? [])
|
|
1727
|
+
.filter((row) => row.origin.kind === 'branch' && row.origin.branch !== ownBranch)
|
|
1728
|
+
.map((row) => `${row.concept}/${row.id}`);
|
|
1729
|
+
if (conflictBranches.length > 0) {
|
|
1730
|
+
const names = [...new Set(conflictBranches)].join(', ');
|
|
1731
|
+
return fail(409, { error: `Another editor has unpublished edits referencing this entry: ${names}. Ask them to publish or discard, then rename.` } satisfies RenameFailure);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1668
1734
|
const oldHref = formatCairnToken({ concept: concept.id, id });
|
|
1669
1735
|
const newHref = formatCairnToken({ concept: concept.id, id: newId });
|
|
1670
1736
|
|
|
1671
|
-
// The moved file keeps its content, except a self-token rewrite
|
|
1672
|
-
|
|
1673
|
-
|
|
1737
|
+
// The moved file keeps its content, except a self-token rewrite and a self-reference rewrite.
|
|
1738
|
+
let movedRaw = rewriteCairnLink(entryRaw, oldHref, newHref);
|
|
1739
|
+
// The moved entry is excluded from inboundReferences, so it must repoint its OWN frontmatter
|
|
1740
|
+
// self-references (e.g. `related` listing its own old id), or the re-derived row would carry the
|
|
1741
|
+
// old id and verifyReferences would flag a dangling edge at the deploy gate.
|
|
1742
|
+
for (const f of concept.fields) {
|
|
1743
|
+
if (f.type === 'reference' || (f.type === 'array' && f.item.type === 'reference')) {
|
|
1744
|
+
movedRaw = rewriteFrontmatterReference(movedRaw, f.name, id, newId);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
// Re-derive its manifest row from the new path so the row carries the new id and permalink by
|
|
1748
|
+
// construction (and the rewritten self-reference edge at the new id).
|
|
1674
1749
|
const changes: FileChange[] = [
|
|
1675
1750
|
{ path: oldPath, content: null },
|
|
1676
1751
|
{ path: newPath, content: movedRaw },
|
|
@@ -1678,28 +1753,61 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1678
1753
|
let next = removeEntry(manifest, concept.id, id);
|
|
1679
1754
|
next = upsertEntry(next, manifestEntryFromFile(concept, { path: newPath, raw: movedRaw }));
|
|
1680
1755
|
|
|
1681
|
-
//
|
|
1682
|
-
//
|
|
1756
|
+
// Repoint every inbound linker so its outbound edges point at the new id, both body `cairn:` links
|
|
1757
|
+
// and frontmatter reference fields. One entry can hold BOTH kinds at the same target, and the Git
|
|
1758
|
+
// Trees API resolves a duplicate path to the LAST entry, so a separate FileChange per kind would let
|
|
1759
|
+
// the second clobber the first. Union the two inbound sets keyed by linker PATH, read each file once
|
|
1760
|
+
// from main, apply every rewrite to the SAME buffer, then push ONE FileChange per path and re-derive
|
|
1761
|
+
// its row from the merged buffer. inboundReferences reads the committed (last-writer-wins stale)
|
|
1762
|
+
// manifest, so a real inbound edge not yet recorded there is left to verifyReferences at the deploy
|
|
1763
|
+
// gate; third-party open-branch inbounds were already refused above, so these are main-only.
|
|
1764
|
+
interface InboundRepoint {
|
|
1765
|
+
concept: string;
|
|
1766
|
+
id: string;
|
|
1767
|
+
hasLink: boolean;
|
|
1768
|
+
fields: string[];
|
|
1769
|
+
}
|
|
1770
|
+
const repoints = new Map<string, InboundRepoint>();
|
|
1771
|
+
const linkerPathFor = (linkerConcept: ConceptDescriptor, linkerId: string): string =>
|
|
1772
|
+
`${linkerConcept.dir}/${filenameFromId(linkerId)}`;
|
|
1683
1773
|
for (const linker of inboundLinks(manifest, concept.id, id)) {
|
|
1684
1774
|
const linkerConcept = findConcept(runtime.concepts, linker.concept);
|
|
1685
1775
|
if (!linkerConcept) continue;
|
|
1686
|
-
const
|
|
1687
|
-
const
|
|
1776
|
+
const path = linkerPathFor(linkerConcept, linker.id);
|
|
1777
|
+
const existing = repoints.get(path);
|
|
1778
|
+
if (existing) existing.hasLink = true;
|
|
1779
|
+
else repoints.set(path, { concept: linker.concept, id: linker.id, hasLink: true, fields: [] });
|
|
1780
|
+
}
|
|
1781
|
+
for (const linker of inboundReferences(manifest, concept.id, id)) {
|
|
1782
|
+
const linkerConcept = findConcept(runtime.concepts, linker.concept);
|
|
1783
|
+
if (!linkerConcept) continue;
|
|
1784
|
+
const path = linkerPathFor(linkerConcept, linker.id);
|
|
1785
|
+
const existing = repoints.get(path);
|
|
1786
|
+
if (existing) existing.fields = [...new Set([...existing.fields, ...linker.fields])];
|
|
1787
|
+
else repoints.set(path, { concept: linker.concept, id: linker.id, hasLink: false, fields: linker.fields });
|
|
1788
|
+
}
|
|
1789
|
+
for (const [linkerPath, repoint] of repoints) {
|
|
1790
|
+
const linkerConcept = findConcept(runtime.concepts, repoint.concept);
|
|
1791
|
+
if (!linkerConcept) continue;
|
|
1792
|
+
let linkerRaw = await backend.readFile(linkerPath, backend.defaultBranch);
|
|
1688
1793
|
if (linkerRaw === null) continue;
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1794
|
+
if (repoint.hasLink) linkerRaw = rewriteCairnLink(linkerRaw, oldHref, newHref);
|
|
1795
|
+
for (const field of repoint.fields) {
|
|
1796
|
+
linkerRaw = rewriteFrontmatterReference(linkerRaw, field, id, newId);
|
|
1797
|
+
}
|
|
1798
|
+
changes.push({ path: linkerPath, content: linkerRaw });
|
|
1799
|
+
next = upsertEntry(next, manifestEntryFromFile(linkerConcept, { path: linkerPath, raw: linkerRaw }));
|
|
1692
1800
|
}
|
|
1693
1801
|
|
|
1694
1802
|
changes.push({ path: runtime.manifestPath, content: serializeManifest(next) });
|
|
1695
1803
|
|
|
1696
1804
|
const commitFields = { concept: concept.id, id: newId, editor: editor.email };
|
|
1697
1805
|
try {
|
|
1698
|
-
await
|
|
1699
|
-
|
|
1806
|
+
await backend.commit(
|
|
1807
|
+
backend.defaultBranch,
|
|
1700
1808
|
changes,
|
|
1701
|
-
{
|
|
1702
|
-
|
|
1809
|
+
{ name: editor.displayName, email: editor.email },
|
|
1810
|
+
`Rename ${concept.label.toLowerCase()}: ${id} to ${newId}`,
|
|
1703
1811
|
);
|
|
1704
1812
|
log.info('commit.succeeded', commitFields);
|
|
1705
1813
|
} catch (err) {
|
|
@@ -1871,7 +1979,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1871
1979
|
*/
|
|
1872
1980
|
async function mediaDeleteAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
1873
1981
|
const editor = requireSession(event);
|
|
1874
|
-
const
|
|
1982
|
+
const backend = resolveBackend(event);
|
|
1875
1983
|
|
|
1876
1984
|
const form = await event.request.formData();
|
|
1877
1985
|
const hash = String(form.get('hash') ?? '');
|
|
@@ -1879,7 +1987,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1879
1987
|
|
|
1880
1988
|
// The asset must be committed on the default branch to be deletable here. A branch-only upload
|
|
1881
1989
|
// (the common 2b case before publish) has no main row; removing it is a discard of the draft.
|
|
1882
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
1990
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
1883
1991
|
const row = manifest[hash];
|
|
1884
1992
|
if (!row) {
|
|
1885
1993
|
return fail(404, {
|
|
@@ -1896,7 +2004,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1896
2004
|
// make a still-referenced asset look orphaned and skip the typed-slug confirm.
|
|
1897
2005
|
let index: Awaited<ReturnType<typeof buildUsageIndex>>;
|
|
1898
2006
|
try {
|
|
1899
|
-
index = await buildUsageIndex(
|
|
2007
|
+
index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
|
|
1900
2008
|
} catch {
|
|
1901
2009
|
// Fail closed: we could not verify every place the asset is used, so refuse rather than risk
|
|
1902
2010
|
// deleting bytes a branch still references.
|
|
@@ -1946,11 +2054,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1946
2054
|
// Commit the manifest row removal FIRST. The order is load-bearing (see the docstring).
|
|
1947
2055
|
const commitFields = { concept: 'media', id: hash, editor: editor.email };
|
|
1948
2056
|
try {
|
|
1949
|
-
await
|
|
1950
|
-
|
|
2057
|
+
await backend.commit(
|
|
2058
|
+
backend.defaultBranch,
|
|
1951
2059
|
[{ path: runtime.mediaManifestPath, content: serializeMediaManifest(removeMediaEntry(manifest, hash)) }],
|
|
1952
|
-
{
|
|
1953
|
-
|
|
2060
|
+
{ name: editor.displayName, email: editor.email },
|
|
2061
|
+
`Delete media: ${row.slug}`,
|
|
1954
2062
|
);
|
|
1955
2063
|
log.info('commit.succeeded', commitFields);
|
|
1956
2064
|
} catch (err) {
|
|
@@ -1986,7 +2094,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
1986
2094
|
*/
|
|
1987
2095
|
async function mediaBulkDelete(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaBulkDeleteResult> {
|
|
1988
2096
|
const editor = requireSession(event);
|
|
1989
|
-
const
|
|
2097
|
+
const backend = resolveBackend(event);
|
|
1990
2098
|
|
|
1991
2099
|
// Read the selected hashes from the form. Accept the repeated `hash` field, falling back to a JSON
|
|
1992
2100
|
// `hashes` array. Each value must match the 16-hex content-hash grammar; a malformed value is
|
|
@@ -2007,7 +2115,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2007
2115
|
const selected = raw.filter((h) => MEDIA_HASH_RE.test(h));
|
|
2008
2116
|
|
|
2009
2117
|
// Read the fresh media manifest (the deletable rows come from here, by hash).
|
|
2010
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
2118
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
2011
2119
|
|
|
2012
2120
|
// Resolve the R2 bucket before any write, so a media-off site or a missing binding refuses before
|
|
2013
2121
|
// the commit, exactly like single delete.
|
|
@@ -2027,7 +2135,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2027
2135
|
// mistaking a still-referenced asset for an orphan. Build exactly one index, never one per item.
|
|
2028
2136
|
let index: Awaited<ReturnType<typeof buildUsageIndex>>;
|
|
2029
2137
|
try {
|
|
2030
|
-
index = await buildUsageIndex(
|
|
2138
|
+
index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
|
|
2031
2139
|
} catch {
|
|
2032
2140
|
return fail(503, { error: 'Could not verify where these assets are used. Try again.' } satisfies MediaBulkFailure);
|
|
2033
2141
|
}
|
|
@@ -2044,11 +2152,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2044
2152
|
for (const hash of plan.deletable) next = removeMediaEntry(next, hash);
|
|
2045
2153
|
const commitFields = { concept: 'media', id: 'bulk', editor: editor.email };
|
|
2046
2154
|
try {
|
|
2047
|
-
await
|
|
2048
|
-
|
|
2155
|
+
await backend.commit(
|
|
2156
|
+
backend.defaultBranch,
|
|
2049
2157
|
[{ path: runtime.mediaManifestPath, content: serializeMediaManifest(next) }],
|
|
2050
|
-
{
|
|
2051
|
-
|
|
2158
|
+
{ name: editor.displayName, email: editor.email },
|
|
2159
|
+
`Delete ${plan.deletable.length} media assets`,
|
|
2052
2160
|
);
|
|
2053
2161
|
log.info('commit.succeeded', commitFields);
|
|
2054
2162
|
} catch (err) {
|
|
@@ -2094,7 +2202,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2094
2202
|
*/
|
|
2095
2203
|
async function mediaOrphanScan(event: ContentEvent): Promise<ReturnType<typeof fail> | OrphanScan> {
|
|
2096
2204
|
requireSession(event);
|
|
2097
|
-
const
|
|
2205
|
+
const backend = resolveBackend(event);
|
|
2098
2206
|
|
|
2099
2207
|
// Resolve the R2 binding. The reconcile lists the raw bucket directly, so keep the raw binding;
|
|
2100
2208
|
// the MediaStore seam carries no list. A media-off site or a missing binding refuses the scan.
|
|
@@ -2109,7 +2217,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2109
2217
|
}
|
|
2110
2218
|
|
|
2111
2219
|
// Read the fresh media manifest for the reconcile's manifest side.
|
|
2112
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
2220
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
2113
2221
|
|
|
2114
2222
|
// THE detection-time fail-closed surface. The reconcile (an R2 list that must complete in full)
|
|
2115
2223
|
// and the strict usage build (a branch read that must complete in full) are both unsafe to use
|
|
@@ -2119,7 +2227,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2119
2227
|
let index: Awaited<ReturnType<typeof buildUsageIndex>>;
|
|
2120
2228
|
try {
|
|
2121
2229
|
reconcile = await runReconcile(rawBucket as unknown as ReconcileBucket, manifest);
|
|
2122
|
-
index = await buildUsageIndex(
|
|
2230
|
+
index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
|
|
2123
2231
|
} catch {
|
|
2124
2232
|
return fail(503, { error: 'Could not check where files are used, so the scan was not run. Try again.' } satisfies MediaBulkFailure);
|
|
2125
2233
|
}
|
|
@@ -2156,7 +2264,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2156
2264
|
*/
|
|
2157
2265
|
async function mediaPurgeOrphans(event: ContentEvent): Promise<ReturnType<typeof fail> | MediaOrphanPurgeResult> {
|
|
2158
2266
|
const editor = requireSession(event);
|
|
2159
|
-
const
|
|
2267
|
+
const backend = resolveBackend(event);
|
|
2160
2268
|
|
|
2161
2269
|
// Resolve the R2 binding, the same media-off / missing-binding refusals as the scan. The purge
|
|
2162
2270
|
// deletes through the MediaStore seam, so wrap the raw binding.
|
|
@@ -2183,7 +2291,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2183
2291
|
}
|
|
2184
2292
|
|
|
2185
2293
|
// Re-derive fresh against the current manifest, so a key claimed since the scan is never purged.
|
|
2186
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
2294
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
2187
2295
|
|
|
2188
2296
|
// THE fail-closed gate for the whole batch: one shared strict cross-branch usage index, symmetric
|
|
2189
2297
|
// with the scan and the bulk delete. STRICT mode rethrows a branch-read failure, so a transient
|
|
@@ -2191,7 +2299,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2191
2299
|
// treated as a true orphan. Build exactly one index, never one per key.
|
|
2192
2300
|
let index: Awaited<ReturnType<typeof buildUsageIndex>>;
|
|
2193
2301
|
try {
|
|
2194
|
-
index = await buildUsageIndex(
|
|
2302
|
+
index = await buildUsageIndex(backend, runtime.concepts, await readManifest(backend), { strict: true });
|
|
2195
2303
|
} catch {
|
|
2196
2304
|
return fail(503, { error: 'Could not verify where these files are used. Try again.' } satisfies MediaBulkFailure);
|
|
2197
2305
|
}
|
|
@@ -2234,13 +2342,13 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2234
2342
|
*/
|
|
2235
2343
|
async function mediaUpdateAction(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
2236
2344
|
const editor = requireSession(event);
|
|
2237
|
-
const
|
|
2345
|
+
const backend = resolveBackend(event);
|
|
2238
2346
|
|
|
2239
2347
|
const form = await event.request.formData();
|
|
2240
2348
|
const hash = String(form.get('hash') ?? '');
|
|
2241
2349
|
if (!MEDIA_HASH_RE.test(hash)) throw error(400, 'Invalid media hash');
|
|
2242
2350
|
|
|
2243
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
2351
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
2244
2352
|
const row = manifest[hash];
|
|
2245
2353
|
if (!row) {
|
|
2246
2354
|
return fail(404, { error: 'That asset is not committed.' } satisfies MediaUpdateFailure);
|
|
@@ -2256,11 +2364,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2256
2364
|
const edited: MediaEntry = { ...row, displayName: displayName || slug, slug, alt };
|
|
2257
2365
|
const commitFields = { concept: 'media', id: hash, editor: editor.email };
|
|
2258
2366
|
try {
|
|
2259
|
-
await
|
|
2260
|
-
|
|
2367
|
+
await backend.commit(
|
|
2368
|
+
backend.defaultBranch,
|
|
2261
2369
|
[{ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, edited)) }],
|
|
2262
|
-
{
|
|
2263
|
-
|
|
2370
|
+
{ name: editor.displayName, email: editor.email },
|
|
2371
|
+
`Update media: ${edited.slug}`,
|
|
2264
2372
|
);
|
|
2265
2373
|
log.info('commit.succeeded', commitFields);
|
|
2266
2374
|
} catch (err) {
|
|
@@ -2317,8 +2425,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2317
2425
|
return fail(400, { error: 'Invalid media hash.', hash: oldHash, usage: [], foundIn: 0 } satisfies MediaReplaceFailure);
|
|
2318
2426
|
}
|
|
2319
2427
|
|
|
2320
|
-
const
|
|
2321
|
-
const contentManifest = await readManifest(
|
|
2428
|
+
const backend = resolveBackend(event);
|
|
2429
|
+
const contentManifest = await readManifest(backend);
|
|
2322
2430
|
const newToken = replacementToken(slug, newHash);
|
|
2323
2431
|
|
|
2324
2432
|
// Plan the rewrite. The planner runs buildUsageIndex in STRICT mode, so an unverifiable branch read
|
|
@@ -2327,8 +2435,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2327
2435
|
let plan: Awaited<ReturnType<typeof planMediaRewrite<RepointPlacement>>>;
|
|
2328
2436
|
try {
|
|
2329
2437
|
plan = await planMediaRewrite<RepointPlacement>({
|
|
2330
|
-
backend
|
|
2331
|
-
token,
|
|
2438
|
+
backend,
|
|
2332
2439
|
concepts: runtime.concepts,
|
|
2333
2440
|
contentManifest,
|
|
2334
2441
|
hash: oldHash,
|
|
@@ -2377,7 +2484,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2377
2484
|
*/
|
|
2378
2485
|
async function mediaReplaceApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
2379
2486
|
const editor = requireSession(event);
|
|
2380
|
-
const
|
|
2487
|
+
const backend = resolveBackend(event);
|
|
2381
2488
|
|
|
2382
2489
|
const form = await event.request.formData();
|
|
2383
2490
|
const oldHash = String(form.get('oldHash') ?? '');
|
|
@@ -2399,7 +2506,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2399
2506
|
|
|
2400
2507
|
// The old asset must be committed on main to be replaceable here. A branch-only upload has no main
|
|
2401
2508
|
// row; it is replaced by editing its draft, not here.
|
|
2402
|
-
const manifest = parseMediaManifest(parseMediaJson(await
|
|
2509
|
+
const manifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
2403
2510
|
const row = manifest[oldHash];
|
|
2404
2511
|
if (!row) {
|
|
2405
2512
|
return fail(404, {
|
|
@@ -2426,10 +2533,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2426
2533
|
let plan: Awaited<ReturnType<typeof planMediaRewrite<RepointPlacement>>>;
|
|
2427
2534
|
try {
|
|
2428
2535
|
plan = await planMediaRewrite<RepointPlacement>({
|
|
2429
|
-
backend
|
|
2430
|
-
token,
|
|
2536
|
+
backend,
|
|
2431
2537
|
concepts: runtime.concepts,
|
|
2432
|
-
contentManifest: await readManifest(
|
|
2538
|
+
contentManifest: await readManifest(backend),
|
|
2433
2539
|
hash: oldHash,
|
|
2434
2540
|
transform: (md) => repointMediaRef(md, oldHash, newToken),
|
|
2435
2541
|
});
|
|
@@ -2461,11 +2567,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2461
2567
|
|
|
2462
2568
|
const commitFields = { concept: 'media', id: oldHash, editor: editor.email };
|
|
2463
2569
|
try {
|
|
2464
|
-
await
|
|
2465
|
-
|
|
2570
|
+
await backend.commit(
|
|
2571
|
+
backend.defaultBranch,
|
|
2466
2572
|
changes,
|
|
2467
|
-
{
|
|
2468
|
-
|
|
2573
|
+
{ name: editor.displayName, email: editor.email },
|
|
2574
|
+
`Replace media: ${row.slug}`,
|
|
2469
2575
|
);
|
|
2470
2576
|
log.info('media.replaced', { editor: editor.email, oldHash, newHash, affected: plan.affectedCount });
|
|
2471
2577
|
} catch (err) {
|
|
@@ -2507,10 +2613,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2507
2613
|
return fail(400, { error: 'Invalid media hash.' } satisfies MediaAltPropagateFailure);
|
|
2508
2614
|
}
|
|
2509
2615
|
|
|
2510
|
-
const
|
|
2616
|
+
const backend = resolveBackend(event);
|
|
2511
2617
|
// The default alt to propagate is the asset's manifest row value (set via mediaUpdateAction). An
|
|
2512
2618
|
// asset with no committed row has no default alt to push, so refuse.
|
|
2513
|
-
const mediaManifest = parseMediaManifest(parseMediaJson(await
|
|
2619
|
+
const mediaManifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
2514
2620
|
const row = mediaManifest[hash];
|
|
2515
2621
|
if (!row) {
|
|
2516
2622
|
return fail(404, { error: 'That asset is not committed.' } satisfies MediaAltPropagateFailure);
|
|
@@ -2518,12 +2624,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2518
2624
|
|
|
2519
2625
|
// Plan the fill. The planner runs strict, so an unverifiable branch read throws out of here; catch
|
|
2520
2626
|
// it and fail closed, the same posture replace and delete take.
|
|
2521
|
-
const contentManifest = await readManifest(
|
|
2627
|
+
const contentManifest = await readManifest(backend);
|
|
2522
2628
|
let plan: Awaited<ReturnType<typeof planMediaRewrite<AltPlacement>>>;
|
|
2523
2629
|
try {
|
|
2524
2630
|
plan = await planMediaRewrite<AltPlacement>({
|
|
2525
|
-
backend
|
|
2526
|
-
token,
|
|
2631
|
+
backend,
|
|
2527
2632
|
concepts: runtime.concepts,
|
|
2528
2633
|
contentManifest,
|
|
2529
2634
|
hash,
|
|
@@ -2568,7 +2673,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2568
2673
|
*/
|
|
2569
2674
|
async function mediaAltApply(event: ContentEvent): Promise<ReturnType<typeof fail> | never> {
|
|
2570
2675
|
const editor = requireSession(event);
|
|
2571
|
-
const
|
|
2676
|
+
const backend = resolveBackend(event);
|
|
2572
2677
|
|
|
2573
2678
|
const form = await event.request.formData();
|
|
2574
2679
|
const hash = String(form.get('hash') ?? '');
|
|
@@ -2576,7 +2681,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2576
2681
|
// The opt-in to also overwrite customized alts; absent (the default) leaves custom alts alone.
|
|
2577
2682
|
const overwrite = form.get('overwrite') === 'on' || form.get('overwrite') === 'true';
|
|
2578
2683
|
|
|
2579
|
-
const mediaManifest = parseMediaManifest(parseMediaJson(await
|
|
2684
|
+
const mediaManifest = parseMediaManifest(parseMediaJson(await backend.readFile(runtime.mediaManifestPath, backend.defaultBranch)));
|
|
2580
2685
|
const row = mediaManifest[hash];
|
|
2581
2686
|
if (!row) {
|
|
2582
2687
|
return fail(404, { error: 'That asset is not committed.' } satisfies MediaAltPropagateFailure);
|
|
@@ -2592,10 +2697,9 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2592
2697
|
let plan: Awaited<ReturnType<typeof planMediaRewrite<AltPlacement>>>;
|
|
2593
2698
|
try {
|
|
2594
2699
|
plan = await planMediaRewrite<AltPlacement>({
|
|
2595
|
-
backend
|
|
2596
|
-
token,
|
|
2700
|
+
backend,
|
|
2597
2701
|
concepts: runtime.concepts,
|
|
2598
|
-
contentManifest: await readManifest(
|
|
2702
|
+
contentManifest: await readManifest(backend),
|
|
2599
2703
|
hash,
|
|
2600
2704
|
transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite }),
|
|
2601
2705
|
});
|
|
@@ -2612,11 +2716,11 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2612
2716
|
const changes: FileChange[] = changed.map((e) => ({ path: e.path, content: e.newMarkdown }));
|
|
2613
2717
|
const commitFields = { concept: 'media', id: hash, editor: editor.email };
|
|
2614
2718
|
try {
|
|
2615
|
-
await
|
|
2616
|
-
|
|
2719
|
+
await backend.commit(
|
|
2720
|
+
backend.defaultBranch,
|
|
2617
2721
|
changes,
|
|
2618
|
-
{
|
|
2619
|
-
|
|
2722
|
+
{ name: editor.displayName, email: editor.email },
|
|
2723
|
+
`Propagate alt: ${row.slug}`,
|
|
2620
2724
|
);
|
|
2621
2725
|
log.info('media.alt_propagated', { editor: editor.email, hash, overwrite, written: changed.length });
|
|
2622
2726
|
} catch (err) {
|
|
@@ -2643,24 +2747,24 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2643
2747
|
* the canonical file back. Shared by the first attempt and the post-conflict retry, so both re-read
|
|
2644
2748
|
* the head and re-merge the same additions; the merge is order-independent, so a concurrent editor's
|
|
2645
2749
|
* word that already landed is preserved and the result is the same sorted set regardless of order.
|
|
2646
|
-
* Returns the merged word list. Throws CommitConflictError (via
|
|
2647
|
-
* under the commit, which the caller catches to retry once.
|
|
2750
|
+
* Returns the merged word list. Throws CommitConflictError (via backend.commit) when the branch
|
|
2751
|
+
* moves under the commit, which the caller catches to retry once.
|
|
2648
2752
|
*/
|
|
2649
|
-
async function mergeAndCommitDictionary(
|
|
2753
|
+
async function mergeAndCommitDictionary(backend: Backend, additions: string[], editor: Editor): Promise<string[]> {
|
|
2650
2754
|
const path = dictionaryFilePath();
|
|
2651
2755
|
// The existing file as its canonical sorted set, so a no-op add is detected against the same
|
|
2652
2756
|
// normalization the commit would write (an already-sorted file never re-commits just to reorder).
|
|
2653
|
-
const canonicalExisting = mergeDictionaryWords(parseDictionary(await
|
|
2757
|
+
const canonicalExisting = mergeDictionaryWords(parseDictionary(await backend.readFile(path, backend.defaultBranch)), []);
|
|
2654
2758
|
const merged = mergeDictionaryWords(canonicalExisting, additions);
|
|
2655
2759
|
// Nothing new (every addition was already present): skip the commit so an idempotent add never
|
|
2656
2760
|
// pushes an empty commit that would redeploy the site. The merged set is still returned so the
|
|
2657
2761
|
// client reconciles its pending additions away.
|
|
2658
2762
|
if (merged.length === canonicalExisting.length) return merged;
|
|
2659
|
-
await
|
|
2660
|
-
|
|
2763
|
+
await backend.commit(
|
|
2764
|
+
backend.defaultBranch,
|
|
2661
2765
|
[{ path, content: serializeDictionary(merged) }],
|
|
2662
|
-
{
|
|
2663
|
-
|
|
2766
|
+
{ name: editor.displayName, email: editor.email },
|
|
2767
|
+
`Add to dictionary: ${additions.join(', ')}`,
|
|
2664
2768
|
);
|
|
2665
2769
|
return merged;
|
|
2666
2770
|
}
|
|
@@ -2713,8 +2817,8 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2713
2817
|
/**
|
|
2714
2818
|
* Save the editor-tier tidy conventions: validate the posted block, then read-modify-commit it into
|
|
2715
2819
|
* the same committed YAML the nav editor writes, with the session editor as author. The transport is
|
|
2716
|
-
* the nav save's exactly: a form POST carrying the conventions JSON,
|
|
2717
|
-
* `
|
|
2820
|
+
* the nav save's exactly: a form POST carrying the conventions JSON, a head-guarded
|
|
2821
|
+
* `backend.commit`, and a stale-head `isConflict` bounced back as a reload prompt. Only the conventions
|
|
2718
2822
|
* block is written (setTidy leaves `tidy.enabled` and `tidy.model` untouched), so an editor's save can
|
|
2719
2823
|
* never flip the developer-tier deploy facts. The save refuses before any commit when tidy is not
|
|
2720
2824
|
* enabled, so the gate state's absent editor tier can never be saved past.
|
|
@@ -2735,20 +2839,25 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2735
2839
|
}
|
|
2736
2840
|
|
|
2737
2841
|
const path = siteConfigPath();
|
|
2738
|
-
const
|
|
2739
|
-
|
|
2842
|
+
const backend = resolveBackend(event);
|
|
2843
|
+
// Read the head BEFORE the content, so this expectedHead is at-or-before the bytes the commit
|
|
2844
|
+
// merges. The settings write lands on the default branch and triggers a deploy, so it is
|
|
2845
|
+
// fail-closed: a concurrent commit to the config moves the head off this value and the commit
|
|
2846
|
+
// throws a conflict, surfacing the reload-and-reapply prompt below rather than a last-writer-wins.
|
|
2847
|
+
const head = await backend.branchHead(backend.defaultBranch);
|
|
2848
|
+
const raw = await backend.readFile(path, backend.defaultBranch);
|
|
2740
2849
|
if (raw === null) throw error(404, 'Site config not found');
|
|
2741
2850
|
// Parse first so a malformed file fails before the write rather than committing onto a broken base.
|
|
2742
2851
|
parseSiteConfig(raw);
|
|
2743
2852
|
|
|
2744
2853
|
const commitFields = { concept: 'settings', id: 'tidy', editor: editor.email };
|
|
2745
2854
|
try {
|
|
2746
|
-
await
|
|
2747
|
-
|
|
2748
|
-
path,
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2855
|
+
await backend.commit(
|
|
2856
|
+
backend.defaultBranch,
|
|
2857
|
+
[{ path, content: setTidy(raw, conventions) }],
|
|
2858
|
+
{ name: editor.displayName, email: editor.email },
|
|
2859
|
+
'Update tidy settings',
|
|
2860
|
+
head ?? undefined,
|
|
2752
2861
|
);
|
|
2753
2862
|
log.info('commit.succeeded', commitFields);
|
|
2754
2863
|
} catch (err) {
|
|
@@ -2771,7 +2880,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2771
2880
|
* `{ words }`. It reads the current file from the default branch, inserts the validated words in
|
|
2772
2881
|
* sorted order if absent (idempotent), and commits through the GitHub-App pipeline.
|
|
2773
2882
|
*
|
|
2774
|
-
* The commit is SHA-guarded with commit-and-retry:
|
|
2883
|
+
* The commit is SHA-guarded with commit-and-retry: backend.commit throws CommitConflictError when the
|
|
2775
2884
|
* branch moved under it, which is caught here to re-read the new head, re-merge the same additions
|
|
2776
2885
|
* (the sorted insert is order-independent, so a concurrent editor's word is preserved), and retry
|
|
2777
2886
|
* once. The response is the merged word list, so the client drops the now-committed words from its
|
|
@@ -2808,10 +2917,10 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2808
2917
|
return fail(400, { error: 'No valid word to add to the dictionary.' } satisfies DictionaryAddFailure);
|
|
2809
2918
|
}
|
|
2810
2919
|
|
|
2811
|
-
const
|
|
2920
|
+
const backend = resolveBackend(event);
|
|
2812
2921
|
const commitFields = { concept: 'dictionary', id: additions[0]!, editor: editor.email };
|
|
2813
2922
|
try {
|
|
2814
|
-
const words = await mergeAndCommitDictionary(
|
|
2923
|
+
const words = await mergeAndCommitDictionary(backend, additions, editor);
|
|
2815
2924
|
log.info('dictionary.added', { editor: editor.email, words: additions });
|
|
2816
2925
|
return { words };
|
|
2817
2926
|
} catch (err) {
|
|
@@ -2820,7 +2929,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2820
2929
|
// retry once. The merge is order-independent, so a concurrent editor's word that landed in the
|
|
2821
2930
|
// window is preserved and the two adds converge on the same sorted set.
|
|
2822
2931
|
try {
|
|
2823
|
-
const words = await mergeAndCommitDictionary(
|
|
2932
|
+
const words = await mergeAndCommitDictionary(backend, additions, editor);
|
|
2824
2933
|
log.info('dictionary.added', { editor: editor.email, words: additions, retried: true });
|
|
2825
2934
|
return { words };
|
|
2826
2935
|
} catch (retryErr) {
|
|
@@ -2950,7 +3059,7 @@ export function createContentRoutes(runtime: CairnRuntime, deps: ContentRoutesDe
|
|
|
2950
3059
|
return { corrected, model: message.model, usage: message.usage };
|
|
2951
3060
|
}
|
|
2952
3061
|
|
|
2953
|
-
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
|
|
3062
|
+
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 };
|
|
2954
3063
|
}
|
|
2955
3064
|
|
|
2956
3065
|
/**
|