@glw907/cairn-cms 0.57.1 → 0.58.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.
@@ -4,6 +4,8 @@ import { type LinkTarget, type InboundLink } from '../content/manifest.js';
4
4
  import type { MediaEntry } from '../media/manifest.js';
5
5
  import type { MediaLibrary, MediaLibraryEntry } from '../media/library-entry.js';
6
6
  import type { UsageEntry } from '../media/usage.js';
7
+ import type { RepointPlacement, AltPlacement } from '../content/media-rewrite.js';
8
+ import type { BranchRef } from '../media/rewrite-plan.js';
7
9
  import type { CookieJar, EventBase } from './types.js';
8
10
  import type { CairnRuntime, FrontmatterField, ResolvedPreview } from '../content/types.js';
9
11
  import type { Role } from '../auth/types.js';
@@ -133,8 +135,9 @@ export interface MediaLibraryData {
133
135
  * redirected commit conflict never overwrite each other. */
134
136
  error: string | null;
135
137
  /** The success flash a redirected action carries: `deleted` from `?deleted=1`, `updated` from
136
- * `?updated=1`, null otherwise. The component renders a polite success strip for each. */
137
- flash: 'deleted' | 'updated' | null;
138
+ * `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`, null
139
+ * otherwise. The component renders a polite success strip for each. */
140
+ flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | null;
138
141
  /** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
139
142
  * its own slot rather than the degraded-load `error` above, so the two never collide. */
140
143
  flashError: string | null;
@@ -194,11 +197,85 @@ export interface MediaUpdateFailure {
194
197
  /** The one-line human summary every action failure carries. */
195
198
  error: string;
196
199
  }
200
+ /** A refused media replace: `fail(409)` when a fresh usage read finds the asset still in use and the
201
+ * typed-slug override was not given, or `fail(503)` when usage cannot be verified (fail closed) or the
202
+ * bucket is unbound. Mirrors MediaDeleteRefusal: the asset hash, the where-used rows, and the count. */
203
+ export interface MediaReplaceFailure {
204
+ error: string;
205
+ hash: string;
206
+ usage: UsageEntry[];
207
+ foundIn: number;
208
+ }
209
+ /** A refused media alt-propagation: `fail(503)` when usage cannot be verified across main and every
210
+ * open branch (fail closed), or the bucket is unbound. Just the one-line summary; alt fill has no
211
+ * typed-slug gate. */
212
+ export interface MediaAltPropagateFailure {
213
+ error: string;
214
+ }
215
+ /** One entry the replace preview will rewrite, enriched with its display title and permalink from the
216
+ * content manifest (the planner's PlannedEntry carries neither). The screen lists these as the
217
+ * confirm dialog's where-touched preview, and the apply re-derives its own plan rather than trusting
218
+ * this. Admin-internal: exported from content-routes for the bundled Media Library component, not
219
+ * added to the package's sveltekit subpath, so it carries no reference page. */
220
+ export interface MediaReplacePreviewEntry {
221
+ /** The concept id, e.g. "posts". */
222
+ concept: string;
223
+ /** The entry id (its filename stem). */
224
+ id: string;
225
+ /** The entry's display title, from the content manifest. */
226
+ title: string;
227
+ /** The entry's public permalink, from the content manifest. */
228
+ permalink?: string;
229
+ /** The per-reference diff for this entry: one placement per repointed `media:` token. */
230
+ placements: RepointPlacement[];
231
+ }
232
+ /** The replace preview plan: the affected main entries (enriched), the distinct affected count, and
233
+ * the report-only cross-branch delta (open cairn/* branches that reference the same bytes; an apply
234
+ * rewrites main only). Display-only: the apply re-derives a fresh plan and never trusts this. */
235
+ export interface MediaReplacePreviewPlan {
236
+ affectedCount: number;
237
+ entries: MediaReplacePreviewEntry[];
238
+ branchDelta: BranchRef[];
239
+ }
240
+ /** One entry the alt-propagation preview reports, enriched with its display title and permalink from
241
+ * the content manifest. Its placements carry every reference of the asset on this entry, each tagged
242
+ * with the bucket it falls in (a will-fill, a customized alt left as-is, or a decorative hero), so
243
+ * the screen can show what would change. Admin-internal: exported from content-routes for the bundled
244
+ * Media Library component, not added to the package's sveltekit subpath, so it carries no reference
245
+ * page. */
246
+ export interface MediaAltPreviewEntry {
247
+ /** The concept id, e.g. "posts". */
248
+ concept: string;
249
+ /** The entry id (its filename stem). */
250
+ id: string;
251
+ /** The entry's display title, from the content manifest. */
252
+ title: string;
253
+ /** The entry's public permalink, from the content manifest. */
254
+ permalink?: string;
255
+ /** The per-reference diff for this entry: one placement per reference of the asset. */
256
+ placements: AltPlacement[];
257
+ }
258
+ /** The alt-propagation preview plan: every entry that references the asset (enriched), the report-only
259
+ * cross-branch delta, and the bucket counts aggregated across every placement. Display-only: the
260
+ * apply re-derives a fresh plan and never trusts this. The preview reports an entry even when its
261
+ * only placements are reported-but-unchanged (a kept custom alt, a decorative hero), so the screen
262
+ * can show every bucket; the apply commits only the entries it actually changes. */
263
+ export interface MediaAltPreviewPlan {
264
+ entries: MediaAltPreviewEntry[];
265
+ branchDelta: BranchRef[];
266
+ /** The placement counts by bucket, summed across all entries. */
267
+ counts: {
268
+ willFill: number;
269
+ customized: number;
270
+ decorativeSkipped: number;
271
+ };
272
+ }
197
273
  /** What a route's single `form` export presents to a view component: whichever content action
198
274
  * last failed, merged with every field optional. `error` is always set on a failure; the richer
199
275
  * keys identify which guard refused. The media refusals ride here too, so the Media Library's one
200
- * `form` prop carries a `?/mediaDelete` or `?/mediaUpdate` refusal without a second type. */
201
- export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure>;
276
+ * `form` prop carries a `?/mediaDelete`, `?/mediaUpdate`, `?/mediaReplace`, or `?/mediaAltPropagate`
277
+ * refusal without a second type. */
278
+ export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure>;
202
279
  /** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
203
280
  * optimistic client state and commits with the entry at Save (the upload itself commits nothing).
204
281
  * `reused` is true when identical bytes were already stored, so the second upload did no second put;
@@ -226,5 +303,9 @@ export declare function createContentRoutes(runtime: CairnRuntime, deps?: Conten
226
303
  uploadAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | UploadResult>;
227
304
  mediaDeleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
228
305
  mediaUpdateAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
306
+ mediaReplacePreview: (event: ContentEvent) => Promise<ReturnType<typeof fail> | MediaReplacePreviewPlan>;
307
+ mediaReplaceApply: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
308
+ mediaAltPreview: (event: ContentEvent) => Promise<ReturnType<typeof fail> | MediaAltPreviewPlan>;
309
+ mediaAltApply: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
229
310
  mintToken: (env: GithubKeyEnv) => string | Promise<string>;
230
311
  };
@@ -26,6 +26,8 @@ import { r2Store } from '../media/store.js';
26
26
  import { parseMediaEntries, parseMediaManifest, upsertMediaEntry, removeMediaEntry, serializeMediaManifest } from '../media/manifest.js';
27
27
  import { mediaLibraryEntry } from '../media/library-entry.js';
28
28
  import { buildUsageIndex } from '../media/usage.js';
29
+ import { repointMediaRef, fillAltForHash } from '../content/media-rewrite.js';
30
+ import { planMediaRewrite } from '../media/rewrite-plan.js';
29
31
  /** Resolve the effective preview for one concept: its `byConcept` override wins per key, with
30
32
  * nullish coalescing so an override key that is present but undefined keeps the top-level value.
31
33
  * Stylesheets are always shared, and the `byConcept` map never reaches the client. */
@@ -227,6 +229,10 @@ export function createContentRoutes(runtime, deps = {}) {
227
229
  flash = 'deleted';
228
230
  else if (event.url.searchParams.get('updated') === '1')
229
231
  flash = 'updated';
232
+ else if (event.url.searchParams.get('replaced') === '1')
233
+ flash = 'replaced';
234
+ else if (event.url.searchParams.get('altPropagated') === '1')
235
+ flash = 'altPropagated';
230
236
  const flashError = event.url.searchParams.get('error');
231
237
  let token;
232
238
  try {
@@ -1100,7 +1106,326 @@ export function createContentRoutes(runtime, deps = {}) {
1100
1106
  }
1101
1107
  throw redirect(303, '/admin/media?updated=1');
1102
1108
  }
1103
- return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mintToken };
1109
+ /** Build the canonical `media:` token for a replacement, treating a slug that fails the grammar (or
1110
+ * an empty one) as absent so the bare-hash form is used. The slug is cosmetic: the resolver keys on
1111
+ * the hash, so a missing slug still resolves. Shared by the preview and apply token construction. */
1112
+ function replacementToken(slug, hash) {
1113
+ return mediaToken({ slug: MEDIA_SLUG_RE.test(slug) ? slug : null, hash });
1114
+ }
1115
+ /** Preview a replace-in-place: the display-only fetch action (the 2a transport). It plans the rewrite
1116
+ * of every published main entry that references `oldHash` to the new asset's `media:` token, enriches
1117
+ * each with its title and permalink, and returns the plan plus the report-only cross-branch delta.
1118
+ * It commits nothing. The plan runs strict (fail-closed): an unverifiable usage read returns a 503
1119
+ * rather than a partial plan, so the confirm dialog never shows a count it cannot stand behind.
1120
+ *
1121
+ * Wire contract: a fetch POST with the JSON body `{ oldHash, newHash, slug }`, the CSRF token in
1122
+ * the `X-Cairn-CSRF` header (the raw-body transport, no form-CSRF), and a `MediaReplacePreviewPlan`
1123
+ * returned as the 200 ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope
1124
+ * with the MediaReplaceFailure shape (the same fail shape the apply uses), so the client reads
1125
+ * `type`/`status` from the body, never the HTTP status. */
1126
+ async function mediaReplacePreview(event) {
1127
+ // CSRF first: this is a raw-body (JSON) POST, so the header witness is the authority, like the
1128
+ // upload action. A failed check refuses before the session read or any GitHub call.
1129
+ if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
1130
+ return fail(403, { error: 'csrf', hash: '', usage: [], foundIn: 0 });
1131
+ }
1132
+ requireSession(event);
1133
+ // Parse the JSON body. A malformed body or a hash that fails the 16-hex grammar refuses with a 400
1134
+ // before any GitHub read. The slug is the OLD asset's: a replace keeps the name and changes only the
1135
+ // content hash, so the repointed token carries the existing slug (an invalid slug falls back to a
1136
+ // bare-hash token below). It is cosmetic for the preview display; the apply re-derives it server-side.
1137
+ let payload;
1138
+ try {
1139
+ payload = JSON.parse(await event.request.text());
1140
+ }
1141
+ catch {
1142
+ return fail(400, { error: 'Could not read the replace request.', hash: '', usage: [], foundIn: 0 });
1143
+ }
1144
+ const oldHash = String(payload.oldHash ?? '');
1145
+ const newHash = String(payload.newHash ?? '');
1146
+ const slug = String(payload.slug ?? '');
1147
+ if (!MEDIA_HASH_RE.test(oldHash) || !MEDIA_HASH_RE.test(newHash)) {
1148
+ return fail(400, { error: 'Invalid media hash.', hash: oldHash, usage: [], foundIn: 0 });
1149
+ }
1150
+ const token = await mintToken(event.platform?.env ?? {});
1151
+ const contentManifest = await readManifest(token);
1152
+ const newToken = replacementToken(slug, newHash);
1153
+ // Plan the rewrite. The planner runs buildUsageIndex in STRICT mode, so an unverifiable branch read
1154
+ // throws out of here rather than degrading to an absent reference; catch it and fail closed, the
1155
+ // same posture the delete gate takes.
1156
+ let plan;
1157
+ try {
1158
+ plan = await planMediaRewrite({
1159
+ backend: runtime.backend,
1160
+ token,
1161
+ concepts: runtime.concepts,
1162
+ contentManifest,
1163
+ hash: oldHash,
1164
+ transform: (md) => repointMediaRef(md, oldHash, newToken),
1165
+ });
1166
+ }
1167
+ catch {
1168
+ return fail(503, {
1169
+ error: 'Could not verify where this asset is used. Try again.',
1170
+ hash: oldHash,
1171
+ usage: [],
1172
+ foundIn: 0,
1173
+ });
1174
+ }
1175
+ // Enrich each planned entry with its title and permalink from the content manifest (the planner
1176
+ // carries neither). A planned entry always has a manifest row (the usage index is built from the
1177
+ // manifest), so the lookup hits; an id-only fallback keeps the type total if a row is ever absent.
1178
+ const byKey = new Map(contentManifest.entries.map((e) => [`${e.concept}/${e.id}`, e]));
1179
+ const entries = plan.entries.map((e) => {
1180
+ const row = byKey.get(`${e.concept}/${e.id}`);
1181
+ return {
1182
+ concept: e.concept,
1183
+ id: e.id,
1184
+ title: row?.title ?? e.id,
1185
+ permalink: row?.permalink,
1186
+ placements: e.placements,
1187
+ };
1188
+ });
1189
+ return { affectedCount: plan.affectedCount, entries, branchDelta: plan.branchDelta };
1190
+ }
1191
+ /** Apply a replace-in-place: rewrite every published main entry that references the old asset to the
1192
+ * new asset's `media:` token, and add the new media.json row, in ONE atomic commit. The plan is
1193
+ * re-derived here from a FRESH read (never a client-passed plan), so a concurrent edit between the
1194
+ * preview and the apply is rewritten too. EVERY replace is gated behind the typed-slug confirm
1195
+ * (unlike delete, which only gates an in-use asset): a replace silently repoints published content,
1196
+ * so it always demands the type-to-confirm. An empty stored slug is never satisfiable, exactly like
1197
+ * delete. The plan runs strict, so an unverifiable usage read fails the replace closed (commits
1198
+ * nothing) rather than rewriting some references and leaving others.
1199
+ *
1200
+ * No R2 operation: the new bytes were already stored put-first by the upload action, and the old
1201
+ * bytes are KEPT (the old row stays in media.json), so this action writes only to git and never
1202
+ * resolves the bucket binding. It guards `resolvedAssets.enabled` for the media-off case only. */
1203
+ async function mediaReplaceApply(event) {
1204
+ const editor = requireSession(event);
1205
+ const token = await mintToken(event.platform?.env ?? {});
1206
+ const form = await event.request.formData();
1207
+ const oldHash = String(form.get('oldHash') ?? '');
1208
+ const newHash = String(form.get('newHash') ?? '');
1209
+ if (!MEDIA_HASH_RE.test(oldHash) || !MEDIA_HASH_RE.test(newHash))
1210
+ throw error(400, 'Invalid media hash');
1211
+ const confirmSlug = String(form.get('confirmSlug') ?? '');
1212
+ // The new asset's optimistic record rides the post (the same untrusted-record contract as save).
1213
+ // Find the row for newHash; its absence is a malformed or missing replacement, a 400.
1214
+ const record = parseMediaEntries(form.get('media')).find((r) => r.hash === newHash);
1215
+ if (!record) {
1216
+ return fail(400, {
1217
+ error: 'The replacement upload is missing or invalid.',
1218
+ hash: oldHash,
1219
+ usage: [],
1220
+ foundIn: 0,
1221
+ });
1222
+ }
1223
+ // The old asset must be committed on main to be replaceable here. A branch-only upload has no main
1224
+ // row; it is replaced by editing its draft, not here.
1225
+ const manifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1226
+ const row = manifest[oldHash];
1227
+ if (!row) {
1228
+ return fail(404, {
1229
+ error: 'That asset is not committed. Discard its draft to remove an unpublished upload.',
1230
+ hash: oldHash,
1231
+ usage: [],
1232
+ foundIn: 0,
1233
+ });
1234
+ }
1235
+ // Media-enabled guard only: replace does no R2 write (the new bytes are already stored, the old
1236
+ // bytes are kept), so there is no bucket binding to resolve. Media-off still refuses before any
1237
+ // git write.
1238
+ if (!runtime.resolvedAssets.enabled) {
1239
+ return fail(503, { error: 'Media is not enabled for this site.', hash: oldHash, usage: [], foundIn: 0 });
1240
+ }
1241
+ // Re-derive the plan from a FRESH content-manifest read (never trust a client plan). The planner
1242
+ // runs strict, so an unverifiable branch read throws; catch it and fail the replace closed (commit
1243
+ // nothing) rather than rewriting a partial set of references. The repointed token keeps the OLD
1244
+ // asset's slug (server-authoritative `row.slug`): a replace changes only the content hash, so the
1245
+ // name in every reference stays the same (the new bytes resolve by hash regardless of the slug).
1246
+ const newToken = replacementToken(row.slug, record.hash);
1247
+ let plan;
1248
+ try {
1249
+ plan = await planMediaRewrite({
1250
+ backend: runtime.backend,
1251
+ token,
1252
+ concepts: runtime.concepts,
1253
+ contentManifest: await readManifest(token),
1254
+ hash: oldHash,
1255
+ transform: (md) => repointMediaRef(md, oldHash, newToken),
1256
+ });
1257
+ }
1258
+ catch {
1259
+ return fail(503, {
1260
+ error: 'Could not verify where this asset is used. Try again.',
1261
+ hash: oldHash,
1262
+ usage: [],
1263
+ foundIn: 0,
1264
+ });
1265
+ }
1266
+ // The typed-slug gate, ALWAYS required for replace. A blank stored slug can never be satisfied by
1267
+ // the empty default, so it is treated as never-confirmed (the confirm cannot be bypassed).
1268
+ if (row.slug === '' || confirmSlug !== row.slug) {
1269
+ log.warn('media.replace_blocked', { editor: editor.email, hash: oldHash, foundIn: plan.affectedCount });
1270
+ return fail(409, {
1271
+ error: `Type ${row.slug} to confirm replacing it in ${plan.affectedCount} ${plan.affectedCount === 1 ? 'entry' : 'entries'}.`,
1272
+ hash: oldHash,
1273
+ usage: [],
1274
+ foundIn: plan.affectedCount,
1275
+ });
1276
+ }
1277
+ // Commit atomically: every rewritten entry plus the new media.json row (the OLD row stays, so the
1278
+ // old bytes keep a row). One commit, the same conflict handling as delete.
1279
+ const changes = plan.entries.map((e) => ({ path: e.path, content: e.newMarkdown }));
1280
+ changes.push({ path: runtime.mediaManifestPath, content: serializeMediaManifest(upsertMediaEntry(manifest, record)) });
1281
+ const commitFields = { concept: 'media', id: oldHash, editor: editor.email };
1282
+ try {
1283
+ await commitFiles(runtime.backend, changes, { message: `Replace media: ${row.slug}`, author: { name: editor.displayName, email: editor.email } }, token);
1284
+ log.info('media.replaced', { editor: editor.email, oldHash, newHash, affected: plan.affectedCount });
1285
+ }
1286
+ catch (err) {
1287
+ commitFailure(commitFields, err, '/admin/media', 'The site changed since you opened it. Reload and try again.');
1288
+ }
1289
+ throw redirect(303, '/admin/media?replaced=1');
1290
+ }
1291
+ /** Preview an alt-propagation: the display-only fetch action (the 2a transport). It plans filling the
1292
+ * asset's default alt across every published main entry that references it, bucketing each placement
1293
+ * (a will-fill empty alt, a customized alt left as-is, a decorative hero skipped), and returns the
1294
+ * enriched entries, the report-only cross-branch delta, and the bucket counts. It commits nothing.
1295
+ * The plan runs strict (fail-closed): an unverifiable usage read returns a 503 rather than a partial
1296
+ * plan, so the dialog never shows a count it cannot stand behind.
1297
+ *
1298
+ * Wire contract: a fetch POST with the JSON body `{ hash }`, the CSRF token in the `X-Cairn-CSRF`
1299
+ * header (the raw-body transport, no form-CSRF), and a `MediaAltPreviewPlan` returned as the 200
1300
+ * ActionResult the client reads. A refusal rides a `fail(status, ...)` envelope with the
1301
+ * MediaAltPropagateFailure shape, so the client reads `type`/`status` from the body. */
1302
+ async function mediaAltPreview(event) {
1303
+ // CSRF first: a raw-body (JSON) POST, so the header witness is the authority, like the upload and
1304
+ // replace-preview actions. A failed check refuses before the session read or any GitHub call.
1305
+ if (!event.cookies || !validateCsrfHeader({ url: event.url, request: event.request, cookies: event.cookies })) {
1306
+ return fail(403, { error: 'csrf' });
1307
+ }
1308
+ requireSession(event);
1309
+ let payload;
1310
+ try {
1311
+ payload = JSON.parse(await event.request.text());
1312
+ }
1313
+ catch {
1314
+ return fail(400, { error: 'Could not read the request.' });
1315
+ }
1316
+ const hash = String(payload.hash ?? '');
1317
+ if (!MEDIA_HASH_RE.test(hash)) {
1318
+ return fail(400, { error: 'Invalid media hash.' });
1319
+ }
1320
+ const token = await mintToken(event.platform?.env ?? {});
1321
+ // The default alt to propagate is the asset's manifest row value (set via mediaUpdateAction). An
1322
+ // asset with no committed row has no default alt to push, so refuse.
1323
+ const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1324
+ const row = mediaManifest[hash];
1325
+ if (!row) {
1326
+ return fail(404, { error: 'That asset is not committed.' });
1327
+ }
1328
+ // Plan the fill. The planner runs strict, so an unverifiable branch read throws out of here; catch
1329
+ // it and fail closed, the same posture replace and delete take.
1330
+ const contentManifest = await readManifest(token);
1331
+ let plan;
1332
+ try {
1333
+ plan = await planMediaRewrite({
1334
+ backend: runtime.backend,
1335
+ token,
1336
+ concepts: runtime.concepts,
1337
+ contentManifest,
1338
+ hash,
1339
+ transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite: false }),
1340
+ });
1341
+ }
1342
+ catch {
1343
+ return fail(503, { error: 'Could not verify where this asset is used. Try again.' });
1344
+ }
1345
+ // Enrich each planned entry with its title and permalink from the content manifest (the planner
1346
+ // carries neither), and aggregate the bucket counts across every placement.
1347
+ const byKey = new Map(contentManifest.entries.map((e) => [`${e.concept}/${e.id}`, e]));
1348
+ const counts = { willFill: 0, customized: 0, decorativeSkipped: 0 };
1349
+ const entries = plan.entries.map((e) => {
1350
+ for (const p of e.placements) {
1351
+ if (p.bucket === 'will-fill')
1352
+ counts.willFill += 1;
1353
+ else if (p.bucket === 'customized')
1354
+ counts.customized += 1;
1355
+ else
1356
+ counts.decorativeSkipped += 1;
1357
+ }
1358
+ const manifestRow = byKey.get(`${e.concept}/${e.id}`);
1359
+ return {
1360
+ concept: e.concept,
1361
+ id: e.id,
1362
+ title: manifestRow?.title ?? e.id,
1363
+ permalink: manifestRow?.permalink,
1364
+ placements: e.placements,
1365
+ };
1366
+ });
1367
+ return { entries, branchDelta: plan.branchDelta, counts };
1368
+ }
1369
+ /** Apply an alt-propagation: fill the asset's default alt into every empty placement across the
1370
+ * published corpus (and, on the `overwrite` opt-in, customized placements too), in ONE atomic
1371
+ * commit. The plan is re-derived from a FRESH read (never a client plan). Three deliberate
1372
+ * differences from replace: there is NO typed-slug gate (alt fill is reversible and frequent), there
1373
+ * is NO media.json change (the default alt is READ from the row, never rewritten there), and a
1374
+ * decorative hero is never written regardless of `overwrite` (enforced inside fillAltForHash). A run
1375
+ * that changes nothing commits nothing and still redirects (a no-op success). It fails the operation
1376
+ * closed on an unverifiable usage read, and writes only entry files in git (no R2 op). */
1377
+ async function mediaAltApply(event) {
1378
+ const editor = requireSession(event);
1379
+ const token = await mintToken(event.platform?.env ?? {});
1380
+ const form = await event.request.formData();
1381
+ const hash = String(form.get('hash') ?? '');
1382
+ if (!MEDIA_HASH_RE.test(hash))
1383
+ throw error(400, 'Invalid media hash');
1384
+ // The opt-in to also overwrite customized alts; absent (the default) leaves custom alts alone.
1385
+ const overwrite = form.get('overwrite') === 'on' || form.get('overwrite') === 'true';
1386
+ const mediaManifest = parseMediaManifest(parseMediaJson(await readRaw(runtime.backend, runtime.mediaManifestPath, token)));
1387
+ const row = mediaManifest[hash];
1388
+ if (!row) {
1389
+ return fail(404, { error: 'That asset is not committed.' });
1390
+ }
1391
+ // Media-enabled guard only: alt fill does no R2 write, so there is no bucket binding to resolve.
1392
+ if (!runtime.resolvedAssets.enabled) {
1393
+ return fail(503, { error: 'Media is not enabled for this site.' });
1394
+ }
1395
+ // Re-derive from a FRESH content-manifest read with the actual overwrite choice. Strict, so an
1396
+ // unverifiable branch read throws; catch it and fail closed (commit nothing).
1397
+ let plan;
1398
+ try {
1399
+ plan = await planMediaRewrite({
1400
+ backend: runtime.backend,
1401
+ token,
1402
+ concepts: runtime.concepts,
1403
+ contentManifest: await readManifest(token),
1404
+ hash,
1405
+ transform: (md) => fillAltForHash(md, hash, row.alt, { overwrite }),
1406
+ });
1407
+ }
1408
+ catch {
1409
+ return fail(503, { error: 'Could not verify where this asset is used. Try again.' });
1410
+ }
1411
+ // Commit only the entries the transform actually changed. A reported-but-unchanged placement (a
1412
+ // kept custom alt, a decorative hero) has after === before, so an entry with only those is a no-op
1413
+ // and is excluded. Nothing changed at all is a successful no-op: skip the commit, still redirect.
1414
+ const changed = plan.entries.filter((e) => e.placements.some((p) => p.after !== p.before));
1415
+ if (changed.length === 0)
1416
+ throw redirect(303, '/admin/media?altPropagated=1');
1417
+ const changes = changed.map((e) => ({ path: e.path, content: e.newMarkdown }));
1418
+ const commitFields = { concept: 'media', id: hash, editor: editor.email };
1419
+ try {
1420
+ await commitFiles(runtime.backend, changes, { message: `Propagate alt: ${row.slug}`, author: { name: editor.displayName, email: editor.email } }, token);
1421
+ log.info('media.alt_propagated', { editor: editor.email, hash, overwrite, written: changed.length });
1422
+ }
1423
+ catch (err) {
1424
+ commitFailure(commitFields, err, '/admin/media', 'The site changed since you opened it. Reload and try again.');
1425
+ }
1426
+ throw redirect(303, '/admin/media?altPropagated=1');
1427
+ }
1428
+ return { layoutLoad, indexRedirect, listLoad, mediaLibraryLoad, createAction, editLoad, saveAction, publishAction, publishAllAction, discardAction, deleteAction, listDeleteAction, renameAction, uploadAction, mediaDeleteAction, mediaUpdateAction, mediaReplacePreview, mediaReplaceApply, mediaAltPreview, mediaAltApply, mintToken };
1104
1429
  }
1105
1430
  /** The cap, in characters, on the stored alt text. The human fields are display copy, not content,
1106
1431
  * so a generous cap rejects only abuse-scale input. */
@@ -3,7 +3,7 @@ export { createAuthRoutes, type AuthRoutesConfig, type RequestResult } from './a
3
3
  export { createEditorRoutes } from './editors-routes.js';
4
4
  export { createContentRoutes } from './content-routes.js';
5
5
  export { createMediaRoute } from './media-route.js';
6
- export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, MediaUsageInfo, MediaLibraryData, ContentEvent, ContentRoutesDeps, SaveFailure, DeleteRefusal, RenameFailure, MediaDeleteRefusal, MediaUpdateFailure, ContentFormFailure, UploadResult, } from './content-routes.js';
6
+ export type { NavConcept, LayoutData, EntrySummary, ListData, EditData, MediaUsageInfo, MediaLibraryData, ContentEvent, ContentRoutesDeps, SaveFailure, DeleteRefusal, RenameFailure, MediaDeleteRefusal, MediaUpdateFailure, MediaReplaceFailure, MediaAltPropagateFailure, ContentFormFailure, UploadResult, } from './content-routes.js';
7
7
  export { createNavRoutes } from './nav-routes.js';
8
8
  export type { NavLoadData, NavPageOption, NavRoutesDeps } from './nav-routes.js';
9
9
  export { parseAdminPath, type AdminView } from './admin-dispatch.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.57.1",
3
+ "version": "0.58.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [