@glw907/cairn-cms 0.56.2 → 0.57.1

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 (173) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/dist/components/AdminLayout.svelte +3 -0
  3. package/dist/components/CairnAdmin.svelte +8 -1
  4. package/dist/components/CairnAdmin.svelte.d.ts +2 -0
  5. package/dist/components/CairnMediaLibrary.svelte +949 -0
  6. package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
  7. package/dist/components/EditPage.svelte +348 -7
  8. package/dist/components/EditPage.svelte.d.ts +2 -0
  9. package/dist/components/MarkdownEditor.svelte +283 -1
  10. package/dist/components/MarkdownEditor.svelte.d.ts +37 -1
  11. package/dist/components/MediaCaptureCard.svelte +135 -0
  12. package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
  13. package/dist/components/MediaFigureControl.svelte +247 -0
  14. package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
  15. package/dist/components/MediaHeroField.svelte +578 -0
  16. package/dist/components/MediaHeroField.svelte.d.ts +75 -0
  17. package/dist/components/MediaInsertPopover.svelte +449 -0
  18. package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
  19. package/dist/components/MediaPicker.svelte +257 -0
  20. package/dist/components/MediaPicker.svelte.d.ts +41 -0
  21. package/dist/components/admin-icons.d.ts +12 -0
  22. package/dist/components/admin-icons.js +12 -0
  23. package/dist/components/cairn-admin.css +901 -9
  24. package/dist/components/client-ingest.d.ts +142 -0
  25. package/dist/components/client-ingest.js +297 -0
  26. package/dist/components/editor-media.d.ts +11 -0
  27. package/dist/components/editor-media.js +206 -0
  28. package/dist/components/editor-placeholder.d.ts +26 -0
  29. package/dist/components/editor-placeholder.js +166 -0
  30. package/dist/components/index.d.ts +1 -0
  31. package/dist/components/index.js +1 -0
  32. package/dist/components/markdown-directives.d.ts +12 -0
  33. package/dist/components/markdown-directives.js +42 -0
  34. package/dist/components/markdown-format.d.ts +89 -0
  35. package/dist/components/markdown-format.js +255 -0
  36. package/dist/components/media-upload-outcome.d.ts +52 -0
  37. package/dist/components/media-upload-outcome.js +48 -0
  38. package/dist/content/compose.js +3 -0
  39. package/dist/content/frontmatter.js +22 -0
  40. package/dist/content/manifest.d.ts +4 -0
  41. package/dist/content/manifest.js +41 -1
  42. package/dist/content/media-refs.d.ts +7 -0
  43. package/dist/content/media-refs.js +52 -0
  44. package/dist/content/schema.d.ts +5 -2
  45. package/dist/content/schema.js +17 -0
  46. package/dist/content/types.d.ts +64 -11
  47. package/dist/content/validate.js +31 -0
  48. package/dist/delivery/public-routes.d.ts +16 -0
  49. package/dist/delivery/public-routes.js +46 -3
  50. package/dist/delivery/seo-fields.js +7 -1
  51. package/dist/delivery/seo.d.ts +2 -0
  52. package/dist/delivery/seo.js +3 -0
  53. package/dist/doctor/checks-local.d.ts +1 -0
  54. package/dist/doctor/checks-local.js +21 -0
  55. package/dist/doctor/index.d.ts +3 -1
  56. package/dist/doctor/index.js +11 -2
  57. package/dist/doctor/types.d.ts +3 -0
  58. package/dist/doctor/wrangler-config.d.ts +3 -0
  59. package/dist/doctor/wrangler-config.js +20 -0
  60. package/dist/env.d.ts +19 -0
  61. package/dist/env.js +26 -0
  62. package/dist/index.d.ts +1 -1
  63. package/dist/log/events.d.ts +1 -1
  64. package/dist/media/config.d.ts +24 -0
  65. package/dist/media/config.js +69 -0
  66. package/dist/media/delivery-bucket.d.ts +34 -0
  67. package/dist/media/delivery-bucket.js +10 -0
  68. package/dist/media/index.d.ts +6 -0
  69. package/dist/media/index.js +13 -0
  70. package/dist/media/library-entry.d.ts +30 -0
  71. package/dist/media/library-entry.js +17 -0
  72. package/dist/media/manifest.d.ts +44 -0
  73. package/dist/media/manifest.js +105 -0
  74. package/dist/media/naming.d.ts +18 -0
  75. package/dist/media/naming.js +112 -0
  76. package/dist/media/reconcile.d.ts +36 -0
  77. package/dist/media/reconcile.js +45 -0
  78. package/dist/media/reference.d.ts +12 -0
  79. package/dist/media/reference.js +33 -0
  80. package/dist/media/sniff.d.ts +18 -0
  81. package/dist/media/sniff.js +106 -0
  82. package/dist/media/store.d.ts +25 -0
  83. package/dist/media/store.js +16 -0
  84. package/dist/media/transform-url.d.ts +26 -0
  85. package/dist/media/transform-url.js +38 -0
  86. package/dist/media/usage.d.ts +48 -0
  87. package/dist/media/usage.js +90 -0
  88. package/dist/render/pipeline.d.ts +2 -0
  89. package/dist/render/pipeline.js +13 -2
  90. package/dist/render/registry.js +3 -0
  91. package/dist/render/remark-figure.d.ts +4 -0
  92. package/dist/render/remark-figure.js +103 -0
  93. package/dist/render/resolve-media.d.ts +34 -0
  94. package/dist/render/resolve-media.js +78 -0
  95. package/dist/render/sanitize-schema.d.ts +4 -2
  96. package/dist/render/sanitize-schema.js +5 -3
  97. package/dist/sveltekit/admin-dispatch.d.ts +2 -0
  98. package/dist/sveltekit/admin-dispatch.js +5 -0
  99. package/dist/sveltekit/cairn-admin.d.ts +8 -1
  100. package/dist/sveltekit/cairn-admin.js +10 -2
  101. package/dist/sveltekit/content-routes.d.ts +77 -2
  102. package/dist/sveltekit/content-routes.js +470 -10
  103. package/dist/sveltekit/csrf.d.ts +16 -0
  104. package/dist/sveltekit/csrf.js +18 -0
  105. package/dist/sveltekit/guard.js +10 -3
  106. package/dist/sveltekit/index.d.ts +2 -1
  107. package/dist/sveltekit/index.js +1 -0
  108. package/dist/sveltekit/media-route.d.ts +12 -0
  109. package/dist/sveltekit/media-route.js +137 -0
  110. package/dist/vite/index.d.ts +3 -0
  111. package/dist/vite/index.js +7 -2
  112. package/package.json +7 -1
  113. package/src/lib/components/AdminLayout.svelte +3 -0
  114. package/src/lib/components/CairnAdmin.svelte +8 -1
  115. package/src/lib/components/CairnMediaLibrary.svelte +949 -0
  116. package/src/lib/components/EditPage.svelte +348 -7
  117. package/src/lib/components/MarkdownEditor.svelte +283 -1
  118. package/src/lib/components/MediaCaptureCard.svelte +135 -0
  119. package/src/lib/components/MediaFigureControl.svelte +247 -0
  120. package/src/lib/components/MediaHeroField.svelte +578 -0
  121. package/src/lib/components/MediaInsertPopover.svelte +449 -0
  122. package/src/lib/components/MediaPicker.svelte +257 -0
  123. package/src/lib/components/admin-icons.ts +12 -0
  124. package/src/lib/components/cairn-admin.css +37 -0
  125. package/src/lib/components/client-ingest.ts +380 -0
  126. package/src/lib/components/editor-media.ts +248 -0
  127. package/src/lib/components/editor-placeholder.ts +213 -0
  128. package/src/lib/components/index.ts +1 -0
  129. package/src/lib/components/markdown-directives.ts +46 -0
  130. package/src/lib/components/markdown-format.ts +307 -1
  131. package/src/lib/components/media-upload-outcome.ts +83 -0
  132. package/src/lib/content/compose.ts +3 -0
  133. package/src/lib/content/frontmatter.ts +20 -1
  134. package/src/lib/content/manifest.ts +44 -1
  135. package/src/lib/content/media-refs.ts +58 -0
  136. package/src/lib/content/schema.ts +31 -7
  137. package/src/lib/content/types.ts +80 -13
  138. package/src/lib/content/validate.ts +29 -1
  139. package/src/lib/delivery/public-routes.ts +52 -3
  140. package/src/lib/delivery/seo-fields.ts +6 -1
  141. package/src/lib/delivery/seo.ts +5 -0
  142. package/src/lib/doctor/checks-local.ts +22 -0
  143. package/src/lib/doctor/index.ts +21 -3
  144. package/src/lib/doctor/types.ts +3 -0
  145. package/src/lib/doctor/wrangler-config.ts +23 -0
  146. package/src/lib/env.ts +28 -0
  147. package/src/lib/index.ts +2 -0
  148. package/src/lib/log/events.ts +8 -1
  149. package/src/lib/media/config.ts +103 -0
  150. package/src/lib/media/delivery-bucket.ts +41 -0
  151. package/src/lib/media/index.ts +22 -0
  152. package/src/lib/media/library-entry.ts +58 -0
  153. package/src/lib/media/manifest.ts +122 -0
  154. package/src/lib/media/naming.ts +130 -0
  155. package/src/lib/media/reconcile.ts +79 -0
  156. package/src/lib/media/reference.ts +40 -0
  157. package/src/lib/media/sniff.ts +114 -0
  158. package/src/lib/media/store.ts +57 -0
  159. package/src/lib/media/transform-url.ts +58 -0
  160. package/src/lib/media/usage.ts +152 -0
  161. package/src/lib/render/pipeline.ts +17 -3
  162. package/src/lib/render/registry.ts +5 -0
  163. package/src/lib/render/remark-figure.ts +132 -0
  164. package/src/lib/render/resolve-media.ts +96 -0
  165. package/src/lib/render/sanitize-schema.ts +5 -3
  166. package/src/lib/sveltekit/admin-dispatch.ts +6 -1
  167. package/src/lib/sveltekit/cairn-admin.ts +13 -3
  168. package/src/lib/sveltekit/content-routes.ts +589 -12
  169. package/src/lib/sveltekit/csrf.ts +18 -0
  170. package/src/lib/sveltekit/guard.ts +12 -3
  171. package/src/lib/sveltekit/index.ts +6 -0
  172. package/src/lib/sveltekit/media-route.ts +158 -0
  173. package/src/lib/vite/index.ts +9 -2
@@ -0,0 +1,48 @@
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
+ /** Where a reference lives: the published corpus on main, or a named open edit branch. */
5
+ export type UsageOrigin = {
6
+ kind: 'published';
7
+ } | {
8
+ kind: 'branch';
9
+ branch: string;
10
+ };
11
+ /** One entry that references an asset, in a shape the screen links and groups by. */
12
+ export interface UsageEntry {
13
+ /** The concept id, e.g. "posts". */
14
+ concept: string;
15
+ /** The entry id (its filename stem). */
16
+ id: string;
17
+ /** The entry title for display, from the manifest (published) or frontmatter (branch). */
18
+ title: string;
19
+ /** The public permalink, present for a published entry (carried from the manifest). */
20
+ permalink?: string;
21
+ /** Published vs the cairn/* branch the edit lives on. */
22
+ origin: UsageOrigin;
23
+ }
24
+ /** Content hash to the distinct entries that reference it. A hash with no row is "no references
25
+ * found" (see the raw-HTML caveat above), never a proven orphan. */
26
+ export type UsageIndex = Map<string, UsageEntry[]>;
27
+ /** Build options. `branches` lets a caller that already listed the open cairn/* branches pass them
28
+ * in so the index does not list them a second time (the load path lists once for the media-union).
29
+ * `strict` flips the per-branch read from degrade-and-skip to fail-closed: a delete gate must not
30
+ * treat a transient branch-read failure as an absent reference, so it rethrows instead. */
31
+ export interface BuildUsageOptions {
32
+ /** The open cairn/* branch names, already listed. When present the index skips its own listing. */
33
+ branches?: string[];
34
+ /** When true a branch read that throws rejects the whole build, so the caller can fail closed. */
35
+ strict?: boolean;
36
+ }
37
+ /**
38
+ * Build the hash-keyed usage index over main (from the manifest's per-entry mediaRefs) plus every
39
+ * open cairn/* branch (parsed from its edited markdown).
40
+ *
41
+ * By default a single branch read that throws degrades that one branch and is skipped, the way the
42
+ * admin loaders degrade a failed read, rather than sinking the whole screen. That tolerance is right
43
+ * for the Library DISPLAY, but wrong for the delete gate: a transient branch-read failure would make
44
+ * a still-referenced asset look orphaned. Pass `strict: true` (the delete path) to rethrow a branch
45
+ * failure so the caller fails closed. Pass `branches` to reuse a branch list the caller already has
46
+ * (the load path lists once for the media-union) rather than listing them a second time.
47
+ */
48
+ export declare function buildUsageIndex(repo: RepoRef, token: string, concepts: ConceptDescriptor[], manifest: Manifest, opts?: BuildUsageOptions): Promise<UsageIndex>;
@@ -0,0 +1,90 @@
1
+ import { listBranches } from '../github/branches.js';
2
+ import { readRaw } from '../github/repo.js';
3
+ import { PENDING_PREFIX, parsePendingBranch } from '../content/pending.js';
4
+ import { findConcept } from '../content/concepts.js';
5
+ import { isValidId, filenameFromId } from '../content/ids.js';
6
+ import { parseMarkdown } from '../content/frontmatter.js';
7
+ import { extractMediaRefs } from '../content/media-refs.js';
8
+ /** Append a row under its hash, creating the bucket on first use. */
9
+ function push(index, hash, entry) {
10
+ const rows = index.get(hash);
11
+ if (rows)
12
+ rows.push(entry);
13
+ else
14
+ index.set(hash, [entry]);
15
+ }
16
+ /**
17
+ * Build the hash-keyed usage index over main (from the manifest's per-entry mediaRefs) plus every
18
+ * open cairn/* branch (parsed from its edited markdown).
19
+ *
20
+ * By default a single branch read that throws degrades that one branch and is skipped, the way the
21
+ * admin loaders degrade a failed read, rather than sinking the whole screen. That tolerance is right
22
+ * for the Library DISPLAY, but wrong for the delete gate: a transient branch-read failure would make
23
+ * a still-referenced asset look orphaned. Pass `strict: true` (the delete path) to rethrow a branch
24
+ * failure so the caller fails closed. Pass `branches` to reuse a branch list the caller already has
25
+ * (the load path lists once for the media-union) rather than listing them a second time.
26
+ */
27
+ export async function buildUsageIndex(repo, token, concepts, manifest, opts = {}) {
28
+ const index = new Map();
29
+ // The main arm: the manifest already carries each entry's mediaRefs, so this is a pure reverse
30
+ // map with no per-file read.
31
+ for (const entry of manifest.entries) {
32
+ for (const hash of entry.mediaRefs ?? []) {
33
+ push(index, hash, {
34
+ concept: entry.concept,
35
+ id: entry.id,
36
+ title: entry.title,
37
+ permalink: entry.permalink,
38
+ origin: { kind: 'published' },
39
+ });
40
+ }
41
+ }
42
+ // The branch arm: read each open cairn/* branch's one edited file. The path is derivable from the
43
+ // branch name, so no tree-listing is needed. The branch list is reused when the caller passes it.
44
+ const names = opts.branches ?? (await listBranches(repo, PENDING_PREFIX, token));
45
+ // Read the branches in parallel rather than one at a time, so the latency floor is one round trip
46
+ // instead of N. workerd self-throttles to 6 simultaneous outbound connections, so this batch and
47
+ // the load path's media-union batch each stay under the limit; do NOT merge the two into one
48
+ // wider Promise.all, since the combined fan-out would queue behind that throttle.
49
+ const perBranch = await Promise.all(names.map(async (name) => {
50
+ // Resolve the branch name to a configured entry with the same guard the branch tooling uses: a
51
+ // malformed name, an id that fails the slug rule (entry paths are built from it, so this is the
52
+ // path confinement), or a concept this site does not configure is skipped, no read attempted.
53
+ const ref = parsePendingBranch(name);
54
+ if (!ref || !isValidId(ref.id))
55
+ return [];
56
+ const concept = findConcept(concepts, ref.concept);
57
+ if (!concept)
58
+ return [];
59
+ const path = `${concept.dir}/${filenameFromId(ref.id)}`;
60
+ try {
61
+ const raw = await readRaw({ ...repo, branch: name }, path, token);
62
+ if (raw === null)
63
+ return []; // The file is absent on the branch: nothing to extract.
64
+ const { frontmatter, body } = parseMarkdown(raw);
65
+ const fmTitle = frontmatter.title;
66
+ const title = typeof fmTitle === 'string' && fmTitle.trim() ? fmTitle : ref.id;
67
+ const rows = [];
68
+ for (const hash of extractMediaRefs(frontmatter, body, concept.fields)) {
69
+ rows.push({
70
+ hash,
71
+ entry: { concept: concept.id, id: ref.id, title, origin: { kind: 'branch', branch: name } },
72
+ });
73
+ }
74
+ return rows;
75
+ }
76
+ catch (err) {
77
+ // In strict mode a branch failure fails the whole build so the delete gate can fail closed;
78
+ // otherwise degrade this one branch rather than sinking the screen.
79
+ if (opts.strict)
80
+ throw err;
81
+ return [];
82
+ }
83
+ }));
84
+ // Fold the per-branch rows back in, preserving the branch order so the index reads stably.
85
+ for (const rows of perBranch) {
86
+ for (const { hash, entry } of rows)
87
+ push(index, hash, entry);
88
+ }
89
+ return index;
90
+ }
@@ -1,5 +1,6 @@
1
1
  import { type PluggableList } from 'unified';
2
2
  import type { Schema } from 'hast-util-sanitize';
3
+ import { type MediaResolve } from './resolve-media.js';
3
4
  import { type ComponentRegistry } from './registry.js';
4
5
  import type { LinkResolve } from '../content/links.js';
5
6
  export interface RendererOptions {
@@ -29,5 +30,6 @@ export declare function createRenderer(registry?: ComponentRegistry, options?: R
29
30
  rehypePlugins: PluggableList;
30
31
  renderMarkdown: (content: string, opts?: {
31
32
  resolve?: LinkResolve;
33
+ resolveMedia?: MediaResolve;
32
34
  }) => Promise<string>;
33
35
  };
@@ -10,14 +10,22 @@ import rehypeSanitize from 'rehype-sanitize';
10
10
  import { VFile } from 'vfile';
11
11
  import { buildSanitizeSchema, rehypeAnchorRel, rehypeSinkGuard } from './sanitize-schema.js';
12
12
  import { remarkDirectiveStamp } from './remark-directives.js';
13
+ import { remarkFigure } from './remark-figure.js';
13
14
  import { remarkResolveCairnLinks, CAIRN_RESOLVE } from './resolve-links.js';
15
+ import { remarkResolveMedia, MEDIA_RESOLVE } from './resolve-media.js';
14
16
  import { rehypeDispatch } from './rehype-dispatch.js';
15
17
  import { defineRegistry } from './registry.js';
16
18
  /** Compose a site's render pipeline from its component registry: directive syntax to
17
19
  * stamped markers to registry-built hast. Returns `renderMarkdown` plus the remark/
18
20
  * rehype plugin arrays (so the admin editor preview can reuse the exact same set). */
19
21
  export function createRenderer(registry = defineRegistry({ components: [] }), options = {}) {
20
- const remarkPlugins = [remarkDirective, [remarkDirectiveStamp, registry], remarkResolveCairnLinks];
22
+ const remarkPlugins = [
23
+ remarkDirective,
24
+ [remarkDirectiveStamp, registry],
25
+ remarkResolveCairnLinks,
26
+ remarkFigure,
27
+ remarkResolveMedia,
28
+ ];
21
29
  // The sanitize floor runs after rehype-raw (so author raw HTML is parsed, then cleaned) and
22
30
  // before the dispatch (so the site's trusted build() output and its inline SVG icons are never
23
31
  // sanitized). The anchor-rel hardening runs last so it also covers component-built anchors.
@@ -48,7 +56,10 @@ export function createRenderer(registry = defineRegistry({ components: [] }), op
48
56
  remarkPlugins,
49
57
  rehypePlugins,
50
58
  renderMarkdown: async (content, opts = {}) => {
51
- const file = new VFile({ value: content, data: { [CAIRN_RESOLVE]: opts.resolve } });
59
+ const file = new VFile({
60
+ value: content,
61
+ data: { [CAIRN_RESOLVE]: opts.resolve, [MEDIA_RESOLVE]: opts.resolveMedia },
62
+ });
52
63
  return String(await processor.process(file));
53
64
  },
54
65
  };
@@ -16,6 +16,9 @@ function findIconField(def) {
16
16
  */
17
17
  export function defineRegistry({ components }) {
18
18
  for (const c of components) {
19
+ if (c.name === 'figure') {
20
+ throw new Error(`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`);
21
+ }
19
22
  if (c.defaultIconByRole && Object.keys(c.defaultIconByRole).length > 0 && !findIconField(c)) {
20
23
  throw new Error(`cairn: component "${c.name}" sets defaultIconByRole but declares no type:'icon' attribute, so the default icon can never render`);
21
24
  }
@@ -0,0 +1,4 @@
1
+ import type { Root } from 'mdast';
2
+ /** Rewrite the reserved `figure` container directive into a placed <figure>. Every other directive
3
+ * is left to remarkDirectiveStamp, which already skips unregistered names. */
4
+ export declare function remarkFigure(): (tree: Root) => void;
@@ -0,0 +1,103 @@
1
+ import { visit } from 'unist-util-visit';
2
+ import { parseMediaToken } from '../media/reference.js';
3
+ /** The closed placement role set. A class outside this set is ignored, never passed through. */
4
+ const ROLES = new Set(['center', 'wide', 'full']);
5
+ function setData(node, patch) {
6
+ const data = (node.data ?? (node.data = {}));
7
+ Object.assign(data, patch);
8
+ }
9
+ // A node whose subtree carries non-whitespace text is a caption candidate.
10
+ function hasText(node) {
11
+ let found = false;
12
+ visit(node, 'text', (text) => {
13
+ if (text.value.trim() !== '')
14
+ found = true;
15
+ });
16
+ return found;
17
+ }
18
+ // Find the first descendant image node whose url is a media: reference, with its enclosing direct
19
+ // child of the directive (the paragraph holding it) and that child's index.
20
+ function findMediaImage(directive) {
21
+ for (let i = 0; i < directive.children.length; i++) {
22
+ const child = directive.children[i];
23
+ if (child.type !== 'paragraph')
24
+ continue;
25
+ const image = child.children.find((n) => n.type === 'image' && parseMediaToken(n.url) !== null);
26
+ if (image)
27
+ return { image, childIndex: i };
28
+ }
29
+ return null;
30
+ }
31
+ // Strip a leading newline or all-whitespace prefix from the first phrasing child, so a caption
32
+ // split off the image line reads cleanly without a stray softbreak.
33
+ function trimLeadingNewline(children) {
34
+ if (children.length === 0)
35
+ return children;
36
+ const [first, ...rest] = children;
37
+ if (first.type === 'text') {
38
+ const trimmed = first.value.replace(/^\s+/, '');
39
+ if (trimmed === '')
40
+ return rest;
41
+ return [{ ...first, value: trimmed }, ...rest];
42
+ }
43
+ return children;
44
+ }
45
+ /** Rewrite the reserved `figure` container directive into a placed <figure>. Every other directive
46
+ * is left to remarkDirectiveStamp, which already skips unregistered names. */
47
+ export function remarkFigure() {
48
+ return (tree) => {
49
+ visit(tree, 'containerDirective', (node) => {
50
+ if (node.name !== 'figure')
51
+ return;
52
+ // The role rides the class attribute, kept only when it is exactly one closed-set value.
53
+ const className = node.attributes?.class ?? undefined;
54
+ const role = className && ROLES.has(className) ? className : undefined;
55
+ setData(node, {
56
+ hName: 'figure',
57
+ ...(role ? { hProperties: { className: ['cairn-place-' + role] } } : {}),
58
+ });
59
+ const found = findMediaImage(node);
60
+ // A figure with no media image is a degraded authoring state: leave its children, invent no
61
+ // image, never throw. The hName is already set, so it still renders as a <figure>.
62
+ if (!found)
63
+ return;
64
+ const { image, childIndex } = found;
65
+ const paragraph = node.children[childIndex];
66
+ // The image lifts into block position (the unwrap), so it carries the FigureChild slot type.
67
+ const imageChild = image;
68
+ // Unwrap the image to a direct child of the directive, handling both paragraph forms.
69
+ let captionNode;
70
+ if (paragraph.children.length === 1) {
71
+ // Blank-line form: the image is alone in its paragraph. The bare image replaces it; a
72
+ // separate following text-bearing paragraph is the caption.
73
+ node.children.splice(childIndex, 1, imageChild);
74
+ }
75
+ else {
76
+ // No-blank-line form: the image and the caption share one paragraph. Split it into the bare
77
+ // image followed by a paragraph holding the remaining children as the caption.
78
+ const imageIndex = paragraph.children.indexOf(image);
79
+ const rest = trimLeadingNewline(paragraph.children.slice(imageIndex + 1));
80
+ const replacement = [imageChild];
81
+ if (rest.length > 0) {
82
+ const captionParagraph = { type: 'paragraph', children: rest };
83
+ replacement.push(captionParagraph);
84
+ captionNode = captionParagraph;
85
+ }
86
+ node.children.splice(childIndex, 1, ...replacement);
87
+ }
88
+ // The caption is the first text-bearing block after the image. In the split case it is the
89
+ // paragraph just appended; otherwise scan the blocks following the image.
90
+ const imagePos = node.children.indexOf(imageChild);
91
+ if (!captionNode) {
92
+ for (let i = imagePos + 1; i < node.children.length; i++) {
93
+ if (hasText(node.children[i])) {
94
+ captionNode = node.children[i];
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ if (captionNode)
100
+ setData(captionNode, { hName: 'figcaption' });
101
+ });
102
+ };
103
+ }
@@ -0,0 +1,34 @@
1
+ import type { VFile } from 'vfile';
2
+ import { type MediaRef } from '../media/reference.js';
3
+ import { type MediaManifest } from '../media/manifest.js';
4
+ import type { ResolvedAssetConfig } from '../media/config.js';
5
+ /** The VFile data key the renderer sets the per-call media resolver under. */
6
+ export declare const MEDIA_RESOLVE = "mediaResolve";
7
+ /** Resolve a media reference to its delivery URL. `undefined` is a preview miss (the plugin marks
8
+ * the image broken); a resolver that throws is the build backstop (the error propagates out of
9
+ * render and fails the build), exactly like LinkResolve. */
10
+ export type MediaResolve = (ref: MediaRef) => string | undefined;
11
+ /** Build the per-call media resolver, closing over the manifest and the resolved config. The
12
+ * returned resolver looks a ref's content hash up in the manifest and builds the canonical delivery
13
+ * path from the manifest entry's slug and ext, not the token's, so a rename never breaks the
14
+ * reference. With a preset and zone transformations on it returns the variant URL; without a preset,
15
+ * or when transformations are off, it returns the bare full-size path so a fresh zone with Image
16
+ * Transformations disabled serves correct thumbnails rather than dead /cdn-cgi/image URLs. It returns
17
+ * undefined when media is off or no entry carries the hash (the preview-miss backstop). */
18
+ export declare function makeMediaResolver(manifest: MediaManifest, resolved: ResolvedAssetConfig, opts?: {
19
+ preset?: string;
20
+ }): MediaResolve;
21
+ /** A resolver backed by the lean `mediaTargets` projection, for the admin preview. It mirrors
22
+ * manifestLinkResolver: a hash present in the projection builds the slug delivery path
23
+ * (`/media/<slug>.<hash>.<ext>`); a miss returns undefined, so the render step marks the image
24
+ * broken rather than throwing. Pure over the projection, with no manifest and no config, so the
25
+ * edit page reaches it with the data it actually has. */
26
+ export declare function manifestMediaResolver(targets: Record<string, {
27
+ slug: string;
28
+ ext: string;
29
+ contentType: string;
30
+ }>): MediaResolve;
31
+ /** Resolve media: image nodes against the VFile's resolver. A non-media src and a malformed token
32
+ * pass through. A missing target is marked with the cairn-broken-media class (the resolver returns
33
+ * undefined) or, when the resolver throws, the error propagates and fails the build. */
34
+ export declare function remarkResolveMedia(): (tree: unknown, file: VFile) => void;
@@ -0,0 +1,78 @@
1
+ // cairn-cms: the media: reference resolver, an mdast step in the render pipeline. It mirrors the
2
+ // cairn: link resolver in ./resolve-links.ts: it runs before remark-rehype, so the rewritten src
3
+ // passes through the sanitize floor exactly as any other image. The per-call resolver is read off
4
+ // the VFile (set by renderMarkdown), so the processor is still built once. A miss either marks the
5
+ // image broken (preview) or throws (build), decided by the injected resolver.
6
+ import { visit } from 'unist-util-visit';
7
+ import { parseMediaToken } from '../media/reference.js';
8
+ import { findByHash } from '../media/manifest.js';
9
+ import { publicPath } from '../media/naming.js';
10
+ import { presetUrl } from '../media/transform-url.js';
11
+ import { log } from '../log/index.js';
12
+ /** The VFile data key the renderer sets the per-call media resolver under. */
13
+ export const MEDIA_RESOLVE = 'mediaResolve';
14
+ /** Build the per-call media resolver, closing over the manifest and the resolved config. The
15
+ * returned resolver looks a ref's content hash up in the manifest and builds the canonical delivery
16
+ * path from the manifest entry's slug and ext, not the token's, so a rename never breaks the
17
+ * reference. With a preset and zone transformations on it returns the variant URL; without a preset,
18
+ * or when transformations are off, it returns the bare full-size path so a fresh zone with Image
19
+ * Transformations disabled serves correct thumbnails rather than dead /cdn-cgi/image URLs. It returns
20
+ * undefined when media is off or no entry carries the hash (the preview-miss backstop). */
21
+ export function makeMediaResolver(manifest, resolved, opts) {
22
+ return (ref) => {
23
+ if (!resolved.enabled)
24
+ return undefined;
25
+ const entry = findByHash(manifest, ref.hash);
26
+ if (!entry) {
27
+ // A real miss: media is on but the hash has no manifest row, the broken-reference case. The
28
+ // media-off path above stays silent, since an unresolved token there is expected, not a fault.
29
+ log.warn('media.resolve_missing', { hash: ref.hash });
30
+ return undefined;
31
+ }
32
+ const path = publicPath(entry.slug, entry.hash, entry.ext, resolved.urlForm, resolved.publicBase);
33
+ if (opts?.preset && resolved.transformations) {
34
+ return presetUrl(path, opts.preset, resolved.variants);
35
+ }
36
+ return path;
37
+ };
38
+ }
39
+ /** A resolver backed by the lean `mediaTargets` projection, for the admin preview. It mirrors
40
+ * manifestLinkResolver: a hash present in the projection builds the slug delivery path
41
+ * (`/media/<slug>.<hash>.<ext>`); a miss returns undefined, so the render step marks the image
42
+ * broken rather than throwing. Pure over the projection, with no manifest and no config, so the
43
+ * edit page reaches it with the data it actually has. */
44
+ export function manifestMediaResolver(targets) {
45
+ return (ref) => {
46
+ const entry = targets[ref.hash];
47
+ if (!entry)
48
+ return undefined;
49
+ return publicPath(entry.slug, ref.hash, entry.ext, 'slug');
50
+ };
51
+ }
52
+ /** Resolve media: image nodes against the VFile's resolver. A non-media src and a malformed token
53
+ * pass through. A missing target is marked with the cairn-broken-media class (the resolver returns
54
+ * undefined) or, when the resolver throws, the error propagates and fails the build. */
55
+ export function remarkResolveMedia() {
56
+ return (tree, file) => {
57
+ const resolve = file.data[MEDIA_RESOLVE];
58
+ if (!resolve)
59
+ return;
60
+ visit(tree, 'image', (node) => {
61
+ const ref = parseMediaToken(node.url);
62
+ if (!ref)
63
+ return;
64
+ const url = resolve(ref); // may throw (build backstop); propagates out of render
65
+ if (url) {
66
+ node.url = url;
67
+ return;
68
+ }
69
+ // Missing asset in the preview: mark it broken and neutralize the src, keeping the alt.
70
+ node.url = '#';
71
+ node.data = node.data ?? {};
72
+ const props = (node.data.hProperties = node.data.hProperties ?? {});
73
+ const existing = Array.isArray(props.className) ? props.className : [];
74
+ props.className = [...existing, 'cairn-broken-media'];
75
+ props.title = 'Missing media asset';
76
+ });
77
+ };
78
+ }
@@ -7,8 +7,10 @@ import { type ComponentRegistry } from './registry.js';
7
7
  * then adds exactly what cairn's render needs. The directive markers (the fixed ones plus the
8
8
  * dataAttr<Key> markers derived from the registry) survive so the dispatch reads its stamps after
9
9
  * the floor. The benign author tags real content uses (nav, details, summary) and class/target/rel
10
- * on anchors are admitted. A site extends the result through `extend`, always starting from this
11
- * safe base, so it can add to the allowlist but not weaken the core strip.
10
+ * on anchors are admitted. figure/figcaption join the base so the engine's placed figure survives
11
+ * the floor on every site, including one that supplies its own `sanitizeSchema` extension. A site
12
+ * extends the result through `extend`, always starting from this safe base, so it can add to the
13
+ * allowlist but not weaken the core strip.
12
14
  */
13
15
  export declare function buildSanitizeSchema(registry: ComponentRegistry, extend?: (defaults: Schema) => Schema): Schema;
14
16
  /**
@@ -10,8 +10,10 @@ const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataRole', 'dataRise'];
10
10
  * then adds exactly what cairn's render needs. The directive markers (the fixed ones plus the
11
11
  * dataAttr<Key> markers derived from the registry) survive so the dispatch reads its stamps after
12
12
  * the floor. The benign author tags real content uses (nav, details, summary) and class/target/rel
13
- * on anchors are admitted. A site extends the result through `extend`, always starting from this
14
- * safe base, so it can add to the allowlist but not weaken the core strip.
13
+ * on anchors are admitted. figure/figcaption join the base so the engine's placed figure survives
14
+ * the floor on every site, including one that supplies its own `sanitizeSchema` extension. A site
15
+ * extends the result through `extend`, always starting from this safe base, so it can add to the
16
+ * allowlist but not weaken the core strip.
15
17
  */
16
18
  export function buildSanitizeSchema(registry, extend) {
17
19
  const attrMarkers = registry.defs.flatMap((d) => (d.attributes ?? []).map((a) => dataAttrProp(a.key)));
@@ -28,7 +30,7 @@ export function buildSanitizeSchema(registry, extend) {
28
30
  const protocols = defaultSchema.protocols ?? {};
29
31
  const schema = {
30
32
  ...defaultSchema,
31
- tagNames: [...(defaultSchema.tagNames ?? []), 'nav', 'details', 'summary'],
33
+ tagNames: [...(defaultSchema.tagNames ?? []), 'nav', 'details', 'summary', 'figure', 'figcaption'],
32
34
  attributes: {
33
35
  ...attributes,
34
36
  '*': [...(attributes['*'] ?? []), 'className', ...markers],
@@ -17,6 +17,8 @@ export type AdminView = {
17
17
  view: 'editors';
18
18
  } | {
19
19
  view: 'nav';
20
+ } | {
21
+ view: 'media';
20
22
  };
21
23
  /**
22
24
  * Parse a raw `URL.pathname` (the caller passes `event.url.pathname`, never a SvelteKit rest
@@ -42,6 +42,11 @@ export function parseAdminPath(pathname, concepts) {
42
42
  return { view: 'editors' };
43
43
  if (head === 'nav')
44
44
  return { view: 'nav' };
45
+ // media is its own view, a peer of editors and nav, so it is decided here, not added to the
46
+ // reserved-no-view set. /admin/media/<anything> 404s naturally (media is not a configured
47
+ // concept), which is the correct shape.
48
+ if (head === 'media')
49
+ return { view: 'media' };
45
50
  if (RESERVED_SEGMENTS.has(head))
46
51
  return null;
47
52
  const concept = findConcept(concepts, head);
@@ -1,4 +1,4 @@
1
- import { type ContentRoutesDeps, type LayoutData, type ListData, type EditData } from './content-routes.js';
1
+ import { type ContentRoutesDeps, type LayoutData, type ListData, type EditData, type MediaLibraryData } from './content-routes.js';
2
2
  import { type NavLoadData } from './nav-routes.js';
3
3
  import type { AuthBranding, SendMagicLink } from '../email.js';
4
4
  import type { AuthEnv, Editor } from '../auth/types.js';
@@ -61,6 +61,10 @@ export type AdminData = {
61
61
  view: 'nav';
62
62
  layout: LayoutData;
63
63
  page: NavLoadData;
64
+ } | {
65
+ view: 'media';
66
+ layout: LayoutData;
67
+ page: MediaLibraryData;
64
68
  };
65
69
  export declare function createCairnAdmin(runtime: CairnRuntime, deps?: CairnAdminDeps): {
66
70
  load: (event: AdminEvent) => Promise<AdminData>;
@@ -70,10 +74,13 @@ export declare function createCairnAdmin(runtime: CairnRuntime, deps?: CairnAdmi
70
74
  logout: (event: AdminEvent) => Promise<never>;
71
75
  create: (event: AdminEvent) => Promise<never>;
72
76
  save: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
77
+ upload: (event: AdminEvent) => Promise<import("./content-routes.js").UploadResult | import("@sveltejs/kit").ActionFailure<unknown>>;
73
78
  publish: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
74
79
  discard: (event: AdminEvent) => Promise<never>;
75
80
  rename: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
76
81
  delete: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
82
+ mediaDelete: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
83
+ mediaUpdate: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<unknown>>;
77
84
  publishAll: (event: AdminEvent) => Promise<never>;
78
85
  addEditor: (event: AdminEvent) => Promise<import("@sveltejs/kit").ActionFailure<{
79
86
  error: string;
@@ -77,6 +77,11 @@ export function createCairnAdmin(runtime, deps = {}) {
77
77
  const [layout, page] = await Promise.all([content.layoutLoad(delegated), nav.navLoad(delegated)]);
78
78
  return { view: 'nav', layout, page };
79
79
  }
80
+ case 'media': {
81
+ const delegated = contentEvent(event, {});
82
+ const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.mediaLibraryLoad(delegated)]);
83
+ return { view: 'media', layout, page };
84
+ }
80
85
  }
81
86
  }
82
87
  /** Wrap a delegate in the parse-and-check every action shares: parse the pathname exactly
@@ -92,9 +97,9 @@ export function createCairnAdmin(runtime, deps = {}) {
92
97
  };
93
98
  }
94
99
  // The topbar posts publishAll from every authed admin page; login and confirm may not.
95
- const authedViews = ['list', 'edit', 'editors', 'nav'];
100
+ const authedViews = ['list', 'edit', 'editors', 'nav', 'media'];
96
101
  // An editor signs out from wherever they are, so logout accepts any parsed view.
97
- const anyView = ['index', 'login', 'confirm', 'list', 'edit', 'editors', 'nav'];
102
+ const anyView = ['index', 'login', 'confirm', 'list', 'edit', 'editors', 'nav', 'media'];
98
103
  /** The full admin action vocabulary, one named async function per action, so a site's
99
104
  * catch-all route exports `admin.actions` directly. Each wrapper stays thin: parse,
100
105
  * validate the view, synthesize the params the wrapped action reads, delegate. The
@@ -111,12 +116,15 @@ export function createCairnAdmin(runtime, deps = {}) {
111
116
  throw error(404, 'Not found');
112
117
  return nav.navSave(contentEvent(event, {}));
113
118
  }),
119
+ upload: viewAction(['edit'], (event, view) => content.uploadAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
114
120
  publish: viewAction(['edit'], (event, view) => content.publishAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
115
121
  discard: viewAction(['edit'], (event, view) => content.discardAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
116
122
  rename: viewAction(['edit'], (event, view) => content.renameAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
117
123
  delete: viewAction(['edit', 'list'], (event, view) => view.view === 'edit'
118
124
  ? content.deleteAction(contentEvent(event, { concept: view.concept.id, id: view.id }))
119
125
  : content.listDeleteAction(contentEvent(event, { concept: view.concept.id }))),
126
+ mediaDelete: viewAction(['media'], (event) => content.mediaDeleteAction(contentEvent(event, {}))),
127
+ mediaUpdate: viewAction(['media'], (event) => content.mediaUpdateAction(contentEvent(event, {}))),
120
128
  publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
121
129
  addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
122
130
  removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),