@glw907/cairn-cms 0.57.0 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/dist/components/CairnMediaLibrary.svelte +997 -7
  3. package/dist/components/EditPage.svelte +1 -0
  4. package/dist/components/MediaHeroField.svelte +17 -8
  5. package/dist/components/MediaHeroField.svelte.d.ts +13 -5
  6. package/dist/components/admin-icons.d.ts +4 -0
  7. package/dist/components/admin-icons.js +4 -0
  8. package/dist/components/cairn-admin.css +255 -3
  9. package/dist/content/frontmatter.js +5 -0
  10. package/dist/content/media-rewrite.d.ts +65 -0
  11. package/dist/content/media-rewrite.js +442 -0
  12. package/dist/content/types.d.ts +2 -0
  13. package/dist/content/validate.js +4 -0
  14. package/dist/log/events.d.ts +1 -1
  15. package/dist/media/rewrite-plan.d.ts +65 -0
  16. package/dist/media/rewrite-plan.js +61 -0
  17. package/dist/render/registry.js +1 -1
  18. package/dist/sveltekit/cairn-admin.d.ts +5 -0
  19. package/dist/sveltekit/cairn-admin.js +9 -0
  20. package/dist/sveltekit/content-routes.d.ts +92 -2
  21. package/dist/sveltekit/content-routes.js +338 -4
  22. package/dist/sveltekit/index.d.ts +1 -1
  23. package/package.json +1 -1
  24. package/src/lib/components/CairnMediaLibrary.svelte +997 -7
  25. package/src/lib/components/EditPage.svelte +1 -0
  26. package/src/lib/components/MediaHeroField.svelte +17 -8
  27. package/src/lib/components/admin-icons.ts +4 -0
  28. package/src/lib/content/frontmatter.ts +4 -0
  29. package/src/lib/content/media-rewrite.ts +555 -0
  30. package/src/lib/content/types.ts +2 -0
  31. package/src/lib/content/validate.ts +3 -0
  32. package/src/lib/log/events.ts +4 -1
  33. package/src/lib/media/rewrite-plan.ts +122 -0
  34. package/src/lib/render/registry.ts +1 -1
  35. package/src/lib/sveltekit/cairn-admin.ts +9 -0
  36. package/src/lib/sveltekit/content-routes.ts +451 -6
  37. package/src/lib/sveltekit/index.ts +2 -0
@@ -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
+ }
@@ -129,7 +129,7 @@ export function defineRegistry({ components }: { components: ComponentDef[] }):
129
129
  for (const c of components) {
130
130
  if (c.name === 'figure') {
131
131
  throw new Error(
132
- 'cairn: "figure" is a reserved directive name handled by the engine render step; a component cannot use it',
132
+ `cairn: component "${c.name}" uses "figure", a reserved directive name handled by the engine render step: remove it if the engine's built-in figure now covers your use, or rename it otherwise`,
133
133
  );
134
134
  }
135
135
  if (c.defaultIconByRole && Object.keys(c.defaultIconByRole).length > 0 && !findIconField(c)) {
@@ -177,6 +177,15 @@ 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, {}))),
180
189
  publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
181
190
  addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
182
191
  removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),