@glw907/cairn-cms 0.57.1 → 0.59.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/components/CairnMediaLibrary.svelte +2070 -26
  3. package/dist/components/CairnMediaLibrary.svelte.d.ts +10 -2
  4. package/dist/components/admin-icons.d.ts +5 -0
  5. package/dist/components/admin-icons.js +5 -0
  6. package/dist/components/cairn-admin.css +402 -3
  7. package/dist/content/media-rewrite.d.ts +65 -0
  8. package/dist/content/media-rewrite.js +442 -0
  9. package/dist/log/events.d.ts +1 -1
  10. package/dist/media/bulk-delete-plan.d.ts +24 -0
  11. package/dist/media/bulk-delete-plan.js +25 -0
  12. package/dist/media/orphan-scan.d.ts +37 -0
  13. package/dist/media/orphan-scan.js +42 -0
  14. package/dist/media/reconcile.d.ts +3 -0
  15. package/dist/media/reconcile.js +3 -2
  16. package/dist/media/rewrite-plan.d.ts +65 -0
  17. package/dist/media/rewrite-plan.js +61 -0
  18. package/dist/sveltekit/cairn-admin.d.ts +8 -0
  19. package/dist/sveltekit/cairn-admin.js +15 -0
  20. package/dist/sveltekit/content-routes.d.ts +118 -4
  21. package/dist/sveltekit/content-routes.js +572 -1
  22. package/dist/sveltekit/index.d.ts +1 -1
  23. package/package.json +1 -1
  24. package/src/lib/components/CairnMediaLibrary.svelte +2070 -26
  25. package/src/lib/components/admin-icons.ts +5 -0
  26. package/src/lib/content/media-rewrite.ts +555 -0
  27. package/src/lib/log/events.ts +6 -1
  28. package/src/lib/media/bulk-delete-plan.ts +54 -0
  29. package/src/lib/media/orphan-scan.ts +74 -0
  30. package/src/lib/media/reconcile.ts +3 -2
  31. package/src/lib/media/rewrite-plan.ts +122 -0
  32. package/src/lib/sveltekit/cairn-admin.ts +15 -0
  33. package/src/lib/sveltekit/content-routes.ts +722 -5
  34. package/src/lib/sveltekit/index.ts +3 -0
@@ -1,6 +1,7 @@
1
1
  import { log } from '../log/index.js';
2
- /** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. */
3
- const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
2
+ /** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. Exported so
3
+ * the orphan-scan projection derives the same hash from an orphaned key without a second grammar. */
4
+ export const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
4
5
  /** The pure core: compare the stored R2 keys against the manifest's content-hash keys and report
5
6
  * both orphan directions. A stored key that does not match the media-key grammar is ignored, since
6
7
  * it is not a content-addressed media object this reconcile owns. */
@@ -0,0 +1,65 @@
1
+ import type { ConceptDescriptor } from '../content/types.js';
2
+ import type { RepoRef } from '../github/types.js';
3
+ import type { Manifest } from '../content/manifest.js';
4
+ /** One main entry the rewrite will touch: its identity, its file path, the transform's per-placement
5
+ * diff, and the rewritten markdown a later apply commits. `P` is the transform's placement type
6
+ * (a RepointPlacement for replace, an AltPlacement for fill-alt). */
7
+ export interface PlannedEntry<P = unknown> {
8
+ /** The concept id, e.g. "posts". */
9
+ concept: string;
10
+ /** The entry id (its filename stem). */
11
+ id: string;
12
+ /** The entry's repo path, `${concept.dir}/${filenameFromId(id)}`. */
13
+ path: string;
14
+ /** The transform's diff for this entry: one placement per rewritten reference. */
15
+ placements: P[];
16
+ /** The entry's markdown after the transform, byte-identical to the source apart from the rewrite. */
17
+ newMarkdown: string;
18
+ }
19
+ /** One open edit branch that also references the asset, with the entries on it. Report-only: an apply
20
+ * rewrites main, never a branch, so the screen surfaces these as a delta the editor handles by
21
+ * republishing the draft. */
22
+ export interface BranchRef {
23
+ /** The cairn/* branch name. */
24
+ branch: string;
25
+ /** The entries on that branch that reference the asset. */
26
+ entries: {
27
+ concept: string;
28
+ id: string;
29
+ }[];
30
+ }
31
+ /** The preview plan: the main entries to rewrite, the report-only branch delta, and the distinct
32
+ * count of affected main entries (the entries the transform actually changed). */
33
+ export interface RewritePlan<P = unknown> {
34
+ entries: PlannedEntry<P>[];
35
+ branchDelta: BranchRef[];
36
+ affectedCount: number;
37
+ }
38
+ /**
39
+ * Plan a media rewrite for one asset hash. Builds the cross-branch usage index in strict mode (so an
40
+ * unverifiable branch read rejects, failing closed), then splits the rows for `args.hash` by origin:
41
+ *
42
+ * - Published rows are the main work. Each entry's file is read in parallel and run through
43
+ * `args.transform`. An entry is included only when the transform reports at least one placement, so
44
+ * a row whose body holds the token in a non-image position (a code span, raw HTML) drops out rather
45
+ * than committing an unchanged file. A row whose concept is not configured, or whose file read
46
+ * returns null (a stale manifest row), is skipped.
47
+ * - Branch rows are the report-only delta, grouped by branch in first-seen order. Branch rows are
48
+ * never the published origin, so main never appears in the delta.
49
+ *
50
+ * `affectedCount` is the number of distinct entries in `entries` (the ones the transform changed). The
51
+ * planner does not read the media manifest: the transform closure already carries the new token or
52
+ * the default alt, so the planner needs only the entry markdown and the usage index. Pure of the
53
+ * editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
54
+ */
55
+ export declare function planMediaRewrite<P = unknown>(args: {
56
+ backend: RepoRef;
57
+ token: string;
58
+ concepts: ConceptDescriptor[];
59
+ contentManifest: Manifest;
60
+ hash: string;
61
+ transform: (markdown: string) => {
62
+ markdown: string;
63
+ placements: P[];
64
+ };
65
+ }): Promise<RewritePlan<P>>;
@@ -0,0 +1,61 @@
1
+ import { findConcept } from '../content/concepts.js';
2
+ import { filenameFromId } from '../content/ids.js';
3
+ import { readRaw } from '../github/repo.js';
4
+ import { buildUsageIndex } from './usage.js';
5
+ /**
6
+ * Plan a media rewrite for one asset hash. Builds the cross-branch usage index in strict mode (so an
7
+ * unverifiable branch read rejects, failing closed), then splits the rows for `args.hash` by origin:
8
+ *
9
+ * - Published rows are the main work. Each entry's file is read in parallel and run through
10
+ * `args.transform`. An entry is included only when the transform reports at least one placement, so
11
+ * a row whose body holds the token in a non-image position (a code span, raw HTML) drops out rather
12
+ * than committing an unchanged file. A row whose concept is not configured, or whose file read
13
+ * returns null (a stale manifest row), is skipped.
14
+ * - Branch rows are the report-only delta, grouped by branch in first-seen order. Branch rows are
15
+ * never the published origin, so main never appears in the delta.
16
+ *
17
+ * `affectedCount` is the number of distinct entries in `entries` (the ones the transform changed). The
18
+ * planner does not read the media manifest: the transform closure already carries the new token or
19
+ * the default alt, so the planner needs only the entry markdown and the usage index. Pure of the
20
+ * editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
21
+ */
22
+ export async function planMediaRewrite(args) {
23
+ // Strict so an unverifiable branch read rejects here rather than degrading to an absent reference.
24
+ // Do NOT wrap this: the throw is the fail-closed contract the apply relies on.
25
+ const index = await buildUsageIndex(args.backend, args.token, args.concepts, args.contentManifest, {
26
+ strict: true,
27
+ });
28
+ const rows = index.get(args.hash) ?? [];
29
+ // The main arm: read each referencing published entry in parallel (one round-trip latency floor,
30
+ // mirroring buildUsageIndex's per-branch batch), run the transform, and keep only the entries it
31
+ // changed. A null is a row whose concept is not configured or whose file is absent: it is skipped.
32
+ const published = rows.filter((row) => row.origin.kind === 'published');
33
+ const planned = await Promise.all(published.map(async (row) => {
34
+ const concept = findConcept(args.concepts, row.concept);
35
+ if (!concept)
36
+ return null;
37
+ const path = `${concept.dir}/${filenameFromId(row.id)}`;
38
+ const markdown = await readRaw(args.backend, path, args.token);
39
+ if (markdown === null)
40
+ return null;
41
+ const result = args.transform(markdown);
42
+ if (result.placements.length === 0)
43
+ return null;
44
+ return { concept: row.concept, id: row.id, path, placements: result.placements, newMarkdown: result.markdown };
45
+ }));
46
+ const entries = planned.filter((entry) => entry !== null);
47
+ // The branch arm: group the branch rows by branch in first-seen order, preserving the row order the
48
+ // index emits within each group. Branch rows are never the published origin, so main never appears.
49
+ const byBranch = new Map();
50
+ for (const row of rows) {
51
+ if (row.origin.kind !== 'branch')
52
+ continue;
53
+ const list = byBranch.get(row.origin.branch);
54
+ if (list)
55
+ list.push({ concept: row.concept, id: row.id });
56
+ else
57
+ byBranch.set(row.origin.branch, [{ concept: row.concept, id: row.id }]);
58
+ }
59
+ const branchDelta = [...byBranch].map(([branch, branchEntries]) => ({ branch, entries: branchEntries }));
60
+ return { entries, branchDelta, affectedCount: entries.length };
61
+ }
@@ -81,6 +81,14 @@ export declare function createCairnAdmin(runtime: CairnRuntime, deps?: CairnAdmi
81
81
  delete: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
82
82
  mediaDelete: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
83
83
  mediaUpdate: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
84
+ mediaUpload: (event: AdminEvent) => Promise<import("./content-routes.js").UploadResult | import("@sveltejs/kit").ActionFailure<unknown>>;
85
+ mediaReplacePreview: (event: AdminEvent) => Promise<import("./content-routes.js").MediaReplacePreviewPlan | import("@sveltejs/kit").ActionFailure<unknown>>;
86
+ mediaReplace: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
87
+ mediaAltPreview: (event: AdminEvent) => Promise<import("./content-routes.js").MediaAltPreviewPlan | import("@sveltejs/kit").ActionFailure<unknown>>;
88
+ mediaAltPropagate: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
89
+ mediaBulkDelete: (event: AdminEvent) => Promise<import("./content-routes.js").MediaBulkDeleteResult | import("@sveltejs/kit").ActionFailure<unknown>>;
90
+ mediaOrphanScan: (event: AdminEvent) => Promise<import("../media/orphan-scan.js").OrphanScan | import("@sveltejs/kit").ActionFailure<unknown>>;
91
+ mediaPurge: (event: AdminEvent) => Promise<import("./content-routes.js").MediaOrphanPurgeResult | import("@sveltejs/kit").ActionFailure<unknown>>;
84
92
  publishAll: (event: AdminEvent) => Promise<never>;
85
93
  addEditor: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<{
86
94
  error: string;
@@ -125,6 +125,21 @@ export function createCairnAdmin(runtime, deps = {}) {
125
125
  : content.listDeleteAction(contentEvent(event, { concept: view.concept.id }))),
126
126
  mediaDelete: viewAction(['media'], (event) => content.mediaDeleteAction(contentEvent(event, {}))),
127
127
  mediaUpdate: viewAction(['media'], (event) => content.mediaUpdateAction(contentEvent(event, {}))),
128
+ // The Library is not entry-scoped, so a replace uploads its new file through the same content-
129
+ // addressed ingest mounted media-scoped (uploadAction reads no concept/id), then previews and
130
+ // applies the repoint. Alt propagation previews and applies the alt fill. The preview pair are 2a
131
+ // fetch actions; the apply pair are form posts. All gate on the media view.
132
+ mediaUpload: viewAction(['media'], (event) => content.uploadAction(contentEvent(event, {}))),
133
+ mediaReplacePreview: viewAction(['media'], (event) => content.mediaReplacePreview(contentEvent(event, {}))),
134
+ mediaReplace: viewAction(['media'], (event) => content.mediaReplaceApply(contentEvent(event, {}))),
135
+ mediaAltPreview: viewAction(['media'], (event) => content.mediaAltPreview(contentEvent(event, {}))),
136
+ mediaAltPropagate: viewAction(['media'], (event) => content.mediaAltApply(contentEvent(event, {}))),
137
+ // Pass C library actions: a multi-select bulk delete, the on-demand orphan scan, and the
138
+ // irreversible byte purge. The component posts to `?/mediaBulkDelete`, `?/mediaOrphanScan`, and
139
+ // `?/mediaPurge` (the purge key is short of its content method name). All gate on the media view.
140
+ mediaBulkDelete: viewAction(['media'], (event) => content.mediaBulkDelete(contentEvent(event, {}))),
141
+ mediaOrphanScan: viewAction(['media'], (event) => content.mediaOrphanScan(contentEvent(event, {}))),
142
+ mediaPurge: viewAction(['media'], (event) => content.mediaPurgeOrphans(contentEvent(event, {}))),
128
143
  publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
129
144
  addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
130
145
  removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
@@ -4,6 +4,10 @@ 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 OrphanScan } from '../media/orphan-scan.js';
8
+ import type { RepointPlacement, AltPlacement } from '../content/media-rewrite.js';
9
+ import type { BranchRef } from '../media/rewrite-plan.js';
10
+ import type { BulkDeleteSkip } from '../media/bulk-delete-plan.js';
7
11
  import type { CookieJar, EventBase } from './types.js';
8
12
  import type { CairnRuntime, FrontmatterField, ResolvedPreview } from '../content/types.js';
9
13
  import type { Role } from '../auth/types.js';
@@ -133,8 +137,10 @@ export interface MediaLibraryData {
133
137
  * redirected commit conflict never overwrite each other. */
134
138
  error: string | null;
135
139
  /** 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;
140
+ * `?updated=1`, `replaced` from `?replaced=1`, `altPropagated` from `?altPropagated=1`,
141
+ * `bulkDeleted` from `?bulkDeleted=1`, `orphansPurged` from `?orphansPurged=1`, null otherwise.
142
+ * The component renders a polite success strip for each. */
143
+ flash: 'deleted' | 'updated' | 'replaced' | 'altPropagated' | 'bulkDeleted' | 'orphansPurged' | null;
138
144
  /** A redirected action's conflict error read from `?error=` (a commit-conflict bounce). Kept in
139
145
  * its own slot rather than the degraded-load `error` above, so the two never collide. */
140
146
  flashError: string | null;
@@ -194,11 +200,112 @@ export interface MediaUpdateFailure {
194
200
  /** The one-line human summary every action failure carries. */
195
201
  error: string;
196
202
  }
203
+ /** A refused media replace: `fail(409)` when a fresh usage read finds the asset still in use and the
204
+ * typed-slug override was not given, or `fail(503)` when usage cannot be verified (fail closed) or the
205
+ * bucket is unbound. Mirrors MediaDeleteRefusal: the asset hash, the where-used rows, and the count. */
206
+ export interface MediaReplaceFailure {
207
+ error: string;
208
+ hash: string;
209
+ usage: UsageEntry[];
210
+ foundIn: number;
211
+ }
212
+ /** A refused media alt-propagation: `fail(503)` when usage cannot be verified across main and every
213
+ * open branch (fail closed), or the bucket is unbound. Just the one-line summary; alt fill has no
214
+ * typed-slug gate. */
215
+ export interface MediaAltPropagateFailure {
216
+ error: string;
217
+ }
218
+ /** A refused media bulk delete or orphan purge: `fail(503)` for the fail-closed strict-usage refusal
219
+ * (the whole batch refuses) or media-off / a missing bucket binding. The per-item outcomes ride the
220
+ * returned summary, not a fail. */
221
+ export interface MediaBulkFailure {
222
+ error: string;
223
+ }
224
+ /** The bulk-delete outcome the component renders: the deleted hashes, the skipped rows from the
225
+ * partition (with their reason and where-used), and any per-object R2 delete failure. Admin-internal,
226
+ * not on the package subpath, so no reference page. */
227
+ export interface MediaBulkDeleteResult {
228
+ deleted: string[];
229
+ skipped: BulkDeleteSkip[];
230
+ failed: {
231
+ hash: string;
232
+ error: string;
233
+ }[];
234
+ }
235
+ /** The orphan-purge outcome: the purged R2 keys, the keys skipped because their hash was claimed by a
236
+ * manifest row since the scan, and any per-object delete failure. Admin-internal, no reference page. */
237
+ export interface MediaOrphanPurgeResult {
238
+ purged: string[];
239
+ skippedClaimed: string[];
240
+ failed: {
241
+ key: string;
242
+ error: string;
243
+ }[];
244
+ }
245
+ /** One entry the replace preview will rewrite, enriched with its display title and permalink from the
246
+ * content manifest (the planner's PlannedEntry carries neither). The screen lists these as the
247
+ * confirm dialog's where-touched preview, and the apply re-derives its own plan rather than trusting
248
+ * this. Admin-internal: exported from content-routes for the bundled Media Library component, not
249
+ * added to the package's sveltekit subpath, so it carries no reference page. */
250
+ export interface MediaReplacePreviewEntry {
251
+ /** The concept id, e.g. "posts". */
252
+ concept: string;
253
+ /** The entry id (its filename stem). */
254
+ id: string;
255
+ /** The entry's display title, from the content manifest. */
256
+ title: string;
257
+ /** The entry's public permalink, from the content manifest. */
258
+ permalink?: string;
259
+ /** The per-reference diff for this entry: one placement per repointed `media:` token. */
260
+ placements: RepointPlacement[];
261
+ }
262
+ /** The replace preview plan: the affected main entries (enriched), the distinct affected count, and
263
+ * the report-only cross-branch delta (open cairn/* branches that reference the same bytes; an apply
264
+ * rewrites main only). Display-only: the apply re-derives a fresh plan and never trusts this. */
265
+ export interface MediaReplacePreviewPlan {
266
+ affectedCount: number;
267
+ entries: MediaReplacePreviewEntry[];
268
+ branchDelta: BranchRef[];
269
+ }
270
+ /** One entry the alt-propagation preview reports, enriched with its display title and permalink from
271
+ * the content manifest. Its placements carry every reference of the asset on this entry, each tagged
272
+ * with the bucket it falls in (a will-fill, a customized alt left as-is, or a decorative hero), so
273
+ * the screen can show what would change. Admin-internal: exported from content-routes for the bundled
274
+ * Media Library component, not added to the package's sveltekit subpath, so it carries no reference
275
+ * page. */
276
+ export interface MediaAltPreviewEntry {
277
+ /** The concept id, e.g. "posts". */
278
+ concept: string;
279
+ /** The entry id (its filename stem). */
280
+ id: string;
281
+ /** The entry's display title, from the content manifest. */
282
+ title: string;
283
+ /** The entry's public permalink, from the content manifest. */
284
+ permalink?: string;
285
+ /** The per-reference diff for this entry: one placement per reference of the asset. */
286
+ placements: AltPlacement[];
287
+ }
288
+ /** The alt-propagation preview plan: every entry that references the asset (enriched), the report-only
289
+ * cross-branch delta, and the bucket counts aggregated across every placement. Display-only: the
290
+ * apply re-derives a fresh plan and never trusts this. The preview reports an entry even when its
291
+ * only placements are reported-but-unchanged (a kept custom alt, a decorative hero), so the screen
292
+ * can show every bucket; the apply commits only the entries it actually changes. */
293
+ export interface MediaAltPreviewPlan {
294
+ entries: MediaAltPreviewEntry[];
295
+ branchDelta: BranchRef[];
296
+ /** The placement counts by bucket, summed across all entries. */
297
+ counts: {
298
+ willFill: number;
299
+ customized: number;
300
+ decorativeSkipped: number;
301
+ };
302
+ }
197
303
  /** What a route's single `form` export presents to a view component: whichever content action
198
304
  * last failed, merged with every field optional. `error` is always set on a failure; the richer
199
305
  * 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>;
306
+ * `form` prop carries a `?/mediaDelete`, `?/mediaUpdate`, `?/mediaReplace`, or `?/mediaAltPropagate`
307
+ * refusal without a second type. */
308
+ export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure & MediaReplaceFailure & MediaAltPropagateFailure & MediaBulkFailure>;
202
309
  /** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
203
310
  * optimistic client state and commits with the entry at Save (the upload itself commits nothing).
204
311
  * `reused` is true when identical bytes were already stored, so the second upload did no second put;
@@ -225,6 +332,13 @@ export declare function createContentRoutes(runtime: CairnRuntime, deps?: Conten
225
332
  renameAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
226
333
  uploadAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | UploadResult>;
227
334
  mediaDeleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
335
+ mediaBulkDelete: (event: ContentEvent) => Promise<ReturnType<typeof fail> | MediaBulkDeleteResult>;
336
+ mediaOrphanScan: (event: ContentEvent) => Promise<ReturnType<typeof fail> | OrphanScan>;
337
+ mediaPurgeOrphans: (event: ContentEvent) => Promise<ReturnType<typeof fail> | MediaOrphanPurgeResult>;
228
338
  mediaUpdateAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
339
+ mediaReplacePreview: (event: ContentEvent) => Promise<ReturnType<typeof fail> | MediaReplacePreviewPlan>;
340
+ mediaReplaceApply: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
341
+ mediaAltPreview: (event: ContentEvent) => Promise<ReturnType<typeof fail> | MediaAltPreviewPlan>;
342
+ mediaAltApply: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
229
343
  mintToken: (env: GithubKeyEnv) => string | Promise<string>;
230
344
  };