@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
@@ -0,0 +1,74 @@
1
+ // cairn-cms: the orphan-scan projection, the pure model behind the admin Media Library's scan
2
+ // surface. It folds reconcileMedia's two directions together with the usage index into the two rows
3
+ // the screen renders: the purgeable byte-rows and the read-only broken-reference rows (manifest rows
4
+ // whose bytes are gone). It only projects; no path here reads R2, the manifest, or git. The module
5
+ // is engine-internal and on no public subpath.
6
+ //
7
+ // An orphaned byte is a stored R2 object whose hash has NO manifest row AND appears in NO usage row,
8
+ // so it is referenced nowhere across main and every open branch. Reconcile only checks main's
9
+ // manifest, so a branch-only upload (bytes in R2, manifest row only on the open cairn/* branch) gets
10
+ // flagged as an orphaned object even though a colleague's in-progress draft references it. The byte
11
+ // purge is irreversible, so we intersect reconcile's verdict with the strict cross-branch usage
12
+ // index here: any hash the index references is in use and is dropped from orphanedBytes, which keeps
13
+ // a live draft's bytes from ever reaching the purge surface.
14
+ import { MEDIA_KEY_RE, type ReconcileResult } from './reconcile.js';
15
+ import type { MediaManifest } from './manifest.js';
16
+ import type { UsageEntry, UsageIndex } from './usage.js';
17
+
18
+ /** A purgeable orphan: a stored R2 key with no manifest row, plus the 16-hex hash parsed from it. */
19
+ export interface OrphanByteRow {
20
+ /** The full R2 object key, e.g. "media/ff/ffffffffffffffff.webp". */
21
+ key: string;
22
+ /** The 16-hex content hash parsed from the key. */
23
+ hash: string;
24
+ }
25
+
26
+ /** A broken reference: a manifest row whose bytes are gone. Read-only, since purging it would drop a
27
+ * still-referenced asset's record; the screen shows where it is used so an operator can re-ingest. */
28
+ export interface BrokenRefRow {
29
+ /** The 16-hex content hash of the manifest row whose bytes are missing. */
30
+ hash: string;
31
+ /** The manifest row's display slug, or '' when the row is somehow absent. */
32
+ slug: string;
33
+ /** Where the asset is referenced, from the usage index. Empty when no reference was found. */
34
+ usage: UsageEntry[];
35
+ }
36
+
37
+ /** The scan surface model: the two row sets the Library renders. */
38
+ export interface OrphanScan {
39
+ orphanedBytes: OrphanByteRow[];
40
+ brokenRefs: BrokenRefRow[];
41
+ }
42
+
43
+ /**
44
+ * Project a reconcile read plus the usage index into the scan surface model.
45
+ *
46
+ * `orphanedBytes` come from `reconcile.orphanedObjects`: each key is parsed to its hash via the
47
+ * shared media-key grammar, and a key that does not match (so it is not a content-addressed media
48
+ * object) is skipped. A key whose hash the usage index references is also skipped: it is referenced
49
+ * on main or some open branch, so its bytes are in use, not orphaned. `brokenRefs` come from
50
+ * `reconcile.missingObjects`: each hash carries its
51
+ * manifest slug (falling back to '' when the row is absent) and its where-used rows from the index
52
+ * (an empty list when no reference was found). Both directions keep their input order.
53
+ */
54
+ export function buildOrphanScan(
55
+ reconcile: ReconcileResult,
56
+ manifest: MediaManifest,
57
+ index: UsageIndex,
58
+ ): OrphanScan {
59
+ const orphanedBytes: OrphanByteRow[] = [];
60
+ for (const key of reconcile.orphanedObjects) {
61
+ const hash = MEDIA_KEY_RE.exec(key)?.[1];
62
+ if (hash === undefined) continue;
63
+ if (index.has(hash)) continue;
64
+ orphanedBytes.push({ key, hash });
65
+ }
66
+
67
+ const brokenRefs: BrokenRefRow[] = reconcile.missingObjects.map((hash) => ({
68
+ hash,
69
+ slug: manifest[hash]?.slug ?? '',
70
+ usage: index.get(hash) ?? [],
71
+ }));
72
+
73
+ return { orphanedBytes, brokenRefs };
74
+ }
@@ -8,8 +8,9 @@
8
8
  import type { MediaManifest } from './manifest.js';
9
9
  import { log } from '../log/index.js';
10
10
 
11
- /** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. */
12
- const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
11
+ /** A stored media object key parses to its short hash via `media/<aa>/<shortHash>.<ext>`. Exported so
12
+ * the orphan-scan projection derives the same hash from an orphaned key without a second grammar. */
13
+ export const MEDIA_KEY_RE = /^media\/[0-9a-f]{2}\/([0-9a-f]{16})\.[a-z0-9]{1,5}$/;
13
14
 
14
15
  /** What a reconcile read found in either direction. `orphanedObjects` are stored R2 keys whose hash
15
16
  * has no manifest row; `missingObjects` are manifest hashes with no stored object. */
@@ -0,0 +1,122 @@
1
+ // cairn-cms: the server-side media rewrite planner, the shared core behind the media-library bulk
2
+ // rewrite preview and apply (replace-in-place and fill-alt). Given an asset content hash and a per
3
+ // entry transform (the closure already carries the new token or the default alt), it resolves which
4
+ // published main entries reference the hash, runs the transform over each, and returns a preview
5
+ // plan: the affected entries with their rewritten markdown and per-placement diff, plus the affected
6
+ // count. It also returns a report-only cross-branch delta, the open cairn/* edit branches that
7
+ // reference the same bytes, so the screen can warn that an apply touches main only and the drafts
8
+ // keep their own copy of the reference until they publish.
9
+ //
10
+ // It is fail-closed. The usage read runs in strict mode, so a transient branch-read failure throws
11
+ // out of here rather than degrading to an "absent reference" the way the Library display tolerates.
12
+ // A planner that fed a partial usage view into an apply would rewrite some references and silently
13
+ // leave others, so it must reject instead. This is the same gate the 3c safe-delete uses.
14
+ //
15
+ // It lives in its own node-safe module (no @codemirror, no DOM, no @sveltejs/kit): the transform is
16
+ // injected, so the planner never imports the editor surface. It is internal, exported from no package
17
+ // subpath, so it carries no reference page.
18
+ import type { ConceptDescriptor } from '../content/types.js';
19
+ import type { RepoRef } from '../github/types.js';
20
+ import type { Manifest } from '../content/manifest.js';
21
+ import { findConcept } from '../content/concepts.js';
22
+ import { filenameFromId } from '../content/ids.js';
23
+ import { readRaw } from '../github/repo.js';
24
+ import { buildUsageIndex } from './usage.js';
25
+
26
+ /** One main entry the rewrite will touch: its identity, its file path, the transform's per-placement
27
+ * diff, and the rewritten markdown a later apply commits. `P` is the transform's placement type
28
+ * (a RepointPlacement for replace, an AltPlacement for fill-alt). */
29
+ export interface PlannedEntry<P = unknown> {
30
+ /** The concept id, e.g. "posts". */
31
+ concept: string;
32
+ /** The entry id (its filename stem). */
33
+ id: string;
34
+ /** The entry's repo path, `${concept.dir}/${filenameFromId(id)}`. */
35
+ path: string;
36
+ /** The transform's diff for this entry: one placement per rewritten reference. */
37
+ placements: P[];
38
+ /** The entry's markdown after the transform, byte-identical to the source apart from the rewrite. */
39
+ newMarkdown: string;
40
+ }
41
+
42
+ /** One open edit branch that also references the asset, with the entries on it. Report-only: an apply
43
+ * rewrites main, never a branch, so the screen surfaces these as a delta the editor handles by
44
+ * republishing the draft. */
45
+ export interface BranchRef {
46
+ /** The cairn/* branch name. */
47
+ branch: string;
48
+ /** The entries on that branch that reference the asset. */
49
+ entries: { concept: string; id: string }[];
50
+ }
51
+
52
+ /** The preview plan: the main entries to rewrite, the report-only branch delta, and the distinct
53
+ * count of affected main entries (the entries the transform actually changed). */
54
+ export interface RewritePlan<P = unknown> {
55
+ entries: PlannedEntry<P>[];
56
+ branchDelta: BranchRef[];
57
+ affectedCount: number;
58
+ }
59
+
60
+ /**
61
+ * Plan a media rewrite for one asset hash. Builds the cross-branch usage index in strict mode (so an
62
+ * unverifiable branch read rejects, failing closed), then splits the rows for `args.hash` by origin:
63
+ *
64
+ * - Published rows are the main work. Each entry's file is read in parallel and run through
65
+ * `args.transform`. An entry is included only when the transform reports at least one placement, so
66
+ * a row whose body holds the token in a non-image position (a code span, raw HTML) drops out rather
67
+ * than committing an unchanged file. A row whose concept is not configured, or whose file read
68
+ * returns null (a stale manifest row), is skipped.
69
+ * - Branch rows are the report-only delta, grouped by branch in first-seen order. Branch rows are
70
+ * never the published origin, so main never appears in the delta.
71
+ *
72
+ * `affectedCount` is the number of distinct entries in `entries` (the ones the transform changed). The
73
+ * planner does not read the media manifest: the transform closure already carries the new token or
74
+ * the default alt, so the planner needs only the entry markdown and the usage index. Pure of the
75
+ * editor surface and node-safe; the only IO is the usage index build and the per-entry reads.
76
+ */
77
+ export async function planMediaRewrite<P = unknown>(args: {
78
+ backend: RepoRef;
79
+ token: string;
80
+ concepts: ConceptDescriptor[];
81
+ contentManifest: Manifest;
82
+ hash: string;
83
+ transform: (markdown: string) => { markdown: string; placements: P[] };
84
+ }): Promise<RewritePlan<P>> {
85
+ // Strict so an unverifiable branch read rejects here rather than degrading to an absent reference.
86
+ // Do NOT wrap this: the throw is the fail-closed contract the apply relies on.
87
+ const index = await buildUsageIndex(args.backend, args.token, args.concepts, args.contentManifest, {
88
+ strict: true,
89
+ });
90
+ const rows = index.get(args.hash) ?? [];
91
+
92
+ // The main arm: read each referencing published entry in parallel (one round-trip latency floor,
93
+ // mirroring buildUsageIndex's per-branch batch), run the transform, and keep only the entries it
94
+ // changed. A null is a row whose concept is not configured or whose file is absent: it is skipped.
95
+ const published = rows.filter((row) => row.origin.kind === 'published');
96
+ const planned = await Promise.all(
97
+ published.map(async (row): Promise<PlannedEntry<P> | null> => {
98
+ const concept = findConcept(args.concepts, row.concept);
99
+ if (!concept) return null;
100
+ const path = `${concept.dir}/${filenameFromId(row.id)}`;
101
+ const markdown = await readRaw(args.backend, path, args.token);
102
+ if (markdown === null) return null;
103
+ const result = args.transform(markdown);
104
+ if (result.placements.length === 0) return null;
105
+ return { concept: row.concept, id: row.id, path, placements: result.placements, newMarkdown: result.markdown };
106
+ }),
107
+ );
108
+ const entries = planned.filter((entry): entry is PlannedEntry<P> => entry !== null);
109
+
110
+ // The branch arm: group the branch rows by branch in first-seen order, preserving the row order the
111
+ // index emits within each group. Branch rows are never the published origin, so main never appears.
112
+ const byBranch = new Map<string, { concept: string; id: string }[]>();
113
+ for (const row of rows) {
114
+ if (row.origin.kind !== 'branch') continue;
115
+ const list = byBranch.get(row.origin.branch);
116
+ if (list) list.push({ concept: row.concept, id: row.id });
117
+ else byBranch.set(row.origin.branch, [{ concept: row.concept, id: row.id }]);
118
+ }
119
+ const branchDelta: BranchRef[] = [...byBranch].map(([branch, branchEntries]) => ({ branch, entries: branchEntries }));
120
+
121
+ return { entries, branchDelta, affectedCount: entries.length };
122
+ }
@@ -177,6 +177,21 @@ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {
177
177
  ),
178
178
  mediaDelete: viewAction(['media'], (event) => content.mediaDeleteAction(contentEvent(event, {}))),
179
179
  mediaUpdate: viewAction(['media'], (event) => content.mediaUpdateAction(contentEvent(event, {}))),
180
+ // The Library is not entry-scoped, so a replace uploads its new file through the same content-
181
+ // addressed ingest mounted media-scoped (uploadAction reads no concept/id), then previews and
182
+ // applies the repoint. Alt propagation previews and applies the alt fill. The preview pair are 2a
183
+ // fetch actions; the apply pair are form posts. All gate on the media view.
184
+ mediaUpload: viewAction(['media'], (event) => content.uploadAction(contentEvent(event, {}))),
185
+ mediaReplacePreview: viewAction(['media'], (event) => content.mediaReplacePreview(contentEvent(event, {}))),
186
+ mediaReplace: viewAction(['media'], (event) => content.mediaReplaceApply(contentEvent(event, {}))),
187
+ mediaAltPreview: viewAction(['media'], (event) => content.mediaAltPreview(contentEvent(event, {}))),
188
+ mediaAltPropagate: viewAction(['media'], (event) => content.mediaAltApply(contentEvent(event, {}))),
189
+ // Pass C library actions: a multi-select bulk delete, the on-demand orphan scan, and the
190
+ // irreversible byte purge. The component posts to `?/mediaBulkDelete`, `?/mediaOrphanScan`, and
191
+ // `?/mediaPurge` (the purge key is short of its content method name). All gate on the media view.
192
+ mediaBulkDelete: viewAction(['media'], (event) => content.mediaBulkDelete(contentEvent(event, {}))),
193
+ mediaOrphanScan: viewAction(['media'], (event) => content.mediaOrphanScan(contentEvent(event, {}))),
194
+ mediaPurge: viewAction(['media'], (event) => content.mediaPurgeOrphans(contentEvent(event, {}))),
180
195
  publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
181
196
  addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
182
197
  removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),