@glw907/cairn-cms 0.56.1 → 0.57.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.
- package/CHANGELOG.md +148 -0
- package/README.md +10 -4
- package/dist/components/AdminLayout.svelte +3 -0
- package/dist/components/CairnAdmin.svelte +8 -1
- package/dist/components/CairnAdmin.svelte.d.ts +2 -0
- package/dist/components/CairnMediaLibrary.svelte +929 -0
- package/dist/components/CairnMediaLibrary.svelte.d.ts +37 -0
- package/dist/components/ComponentForm.svelte +175 -46
- package/dist/components/ComponentForm.svelte.d.ts +22 -8
- package/dist/components/ComponentInsertDialog.svelte +379 -26
- package/dist/components/ComponentInsertDialog.svelte.d.ts +31 -2
- package/dist/components/EditPage.svelte +477 -15
- package/dist/components/EditPage.svelte.d.ts +2 -0
- package/dist/components/MarkdownEditor.svelte +358 -1
- package/dist/components/MarkdownEditor.svelte.d.ts +51 -1
- package/dist/components/MediaCaptureCard.svelte +135 -0
- package/dist/components/MediaCaptureCard.svelte.d.ts +40 -0
- package/dist/components/MediaFigureControl.svelte +247 -0
- package/dist/components/MediaFigureControl.svelte.d.ts +40 -0
- package/dist/components/MediaHeroField.svelte +569 -0
- package/dist/components/MediaHeroField.svelte.d.ts +67 -0
- package/dist/components/MediaInsertPopover.svelte +449 -0
- package/dist/components/MediaInsertPopover.svelte.d.ts +58 -0
- package/dist/components/MediaPicker.svelte +257 -0
- package/dist/components/MediaPicker.svelte.d.ts +41 -0
- package/dist/components/admin-icons.d.ts +12 -0
- package/dist/components/admin-icons.js +12 -0
- package/dist/components/cairn-admin.css +1045 -28
- package/dist/components/client-ingest.d.ts +142 -0
- package/dist/components/client-ingest.js +297 -0
- package/dist/components/editor-media.d.ts +11 -0
- package/dist/components/editor-media.js +206 -0
- package/dist/components/editor-placeholder.d.ts +26 -0
- package/dist/components/editor-placeholder.js +166 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +19 -0
- package/dist/components/markdown-directives.js +52 -0
- package/dist/components/markdown-format.d.ts +89 -0
- package/dist/components/markdown-format.js +255 -0
- package/dist/components/media-upload-outcome.d.ts +52 -0
- package/dist/components/media-upload-outcome.js +48 -0
- package/dist/content/compose.js +3 -0
- package/dist/content/frontmatter.js +17 -0
- package/dist/content/manifest.d.ts +4 -0
- package/dist/content/manifest.js +41 -1
- package/dist/content/media-refs.d.ts +7 -0
- package/dist/content/media-refs.js +52 -0
- package/dist/content/schema.d.ts +5 -2
- package/dist/content/schema.js +17 -0
- package/dist/content/types.d.ts +62 -11
- package/dist/content/validate.js +27 -0
- package/dist/delivery/public-routes.d.ts +16 -0
- package/dist/delivery/public-routes.js +46 -3
- package/dist/delivery/seo-fields.js +7 -1
- package/dist/delivery/seo.d.ts +2 -0
- package/dist/delivery/seo.js +3 -0
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +21 -0
- package/dist/doctor/index.d.ts +3 -1
- package/dist/doctor/index.js +11 -2
- package/dist/doctor/types.d.ts +3 -0
- package/dist/doctor/wrangler-config.d.ts +3 -0
- package/dist/doctor/wrangler-config.js +20 -0
- package/dist/env.d.ts +19 -0
- package/dist/env.js +26 -0
- package/dist/index.d.ts +1 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/media/config.d.ts +24 -0
- package/dist/media/config.js +69 -0
- package/dist/media/delivery-bucket.d.ts +34 -0
- package/dist/media/delivery-bucket.js +10 -0
- package/dist/media/index.d.ts +6 -0
- package/dist/media/index.js +13 -0
- package/dist/media/library-entry.d.ts +30 -0
- package/dist/media/library-entry.js +17 -0
- package/dist/media/manifest.d.ts +44 -0
- package/dist/media/manifest.js +105 -0
- package/dist/media/naming.d.ts +18 -0
- package/dist/media/naming.js +112 -0
- package/dist/media/reconcile.d.ts +36 -0
- package/dist/media/reconcile.js +45 -0
- package/dist/media/reference.d.ts +12 -0
- package/dist/media/reference.js +33 -0
- package/dist/media/sniff.d.ts +18 -0
- package/dist/media/sniff.js +106 -0
- package/dist/media/store.d.ts +25 -0
- package/dist/media/store.js +16 -0
- package/dist/media/transform-url.d.ts +26 -0
- package/dist/media/transform-url.js +38 -0
- package/dist/media/usage.d.ts +48 -0
- package/dist/media/usage.js +90 -0
- package/dist/render/component-grammar.d.ts +20 -0
- package/dist/render/component-grammar.js +47 -3
- package/dist/render/component-validate.js +22 -0
- package/dist/render/pipeline.d.ts +2 -0
- package/dist/render/pipeline.js +13 -2
- package/dist/render/registry.d.ts +28 -0
- package/dist/render/registry.js +15 -0
- package/dist/render/remark-figure.d.ts +4 -0
- package/dist/render/remark-figure.js +103 -0
- package/dist/render/resolve-media.d.ts +34 -0
- package/dist/render/resolve-media.js +78 -0
- package/dist/render/sanitize-schema.d.ts +4 -2
- package/dist/render/sanitize-schema.js +5 -3
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +5 -0
- package/dist/sveltekit/cairn-admin.d.ts +8 -1
- package/dist/sveltekit/cairn-admin.js +10 -2
- package/dist/sveltekit/content-routes.d.ts +68 -2
- package/dist/sveltekit/content-routes.js +461 -10
- package/dist/sveltekit/csrf.d.ts +16 -0
- package/dist/sveltekit/csrf.js +18 -0
- package/dist/sveltekit/guard.js +10 -3
- package/dist/sveltekit/index.d.ts +2 -1
- package/dist/sveltekit/index.js +1 -0
- package/dist/sveltekit/media-route.d.ts +12 -0
- package/dist/sveltekit/media-route.js +137 -0
- package/dist/vite/index.d.ts +3 -0
- package/dist/vite/index.js +7 -2
- package/package.json +8 -1
- package/src/lib/components/AdminLayout.svelte +3 -0
- package/src/lib/components/CairnAdmin.svelte +8 -1
- package/src/lib/components/CairnMediaLibrary.svelte +929 -0
- package/src/lib/components/ComponentForm.svelte +175 -46
- package/src/lib/components/ComponentInsertDialog.svelte +379 -26
- package/src/lib/components/EditPage.svelte +477 -15
- package/src/lib/components/MarkdownEditor.svelte +358 -1
- package/src/lib/components/MediaCaptureCard.svelte +135 -0
- package/src/lib/components/MediaFigureControl.svelte +247 -0
- package/src/lib/components/MediaHeroField.svelte +569 -0
- package/src/lib/components/MediaInsertPopover.svelte +449 -0
- package/src/lib/components/MediaPicker.svelte +257 -0
- package/src/lib/components/admin-icons.ts +12 -0
- package/src/lib/components/cairn-admin.css +37 -0
- package/src/lib/components/client-ingest.ts +380 -0
- package/src/lib/components/editor-media.ts +248 -0
- package/src/lib/components/editor-placeholder.ts +213 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +57 -0
- package/src/lib/components/markdown-format.ts +307 -1
- package/src/lib/components/media-upload-outcome.ts +83 -0
- package/src/lib/content/compose.ts +3 -0
- package/src/lib/content/frontmatter.ts +16 -1
- package/src/lib/content/manifest.ts +44 -1
- package/src/lib/content/media-refs.ts +58 -0
- package/src/lib/content/schema.ts +31 -7
- package/src/lib/content/types.ts +78 -13
- package/src/lib/content/validate.ts +26 -1
- package/src/lib/delivery/public-routes.ts +52 -3
- package/src/lib/delivery/seo-fields.ts +6 -1
- package/src/lib/delivery/seo.ts +5 -0
- package/src/lib/doctor/checks-local.ts +22 -0
- package/src/lib/doctor/index.ts +21 -3
- package/src/lib/doctor/types.ts +3 -0
- package/src/lib/doctor/wrangler-config.ts +23 -0
- package/src/lib/env.ts +28 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/log/events.ts +8 -1
- package/src/lib/media/config.ts +103 -0
- package/src/lib/media/delivery-bucket.ts +41 -0
- package/src/lib/media/index.ts +22 -0
- package/src/lib/media/library-entry.ts +58 -0
- package/src/lib/media/manifest.ts +122 -0
- package/src/lib/media/naming.ts +130 -0
- package/src/lib/media/reconcile.ts +79 -0
- package/src/lib/media/reference.ts +40 -0
- package/src/lib/media/sniff.ts +114 -0
- package/src/lib/media/store.ts +57 -0
- package/src/lib/media/transform-url.ts +58 -0
- package/src/lib/media/usage.ts +152 -0
- package/src/lib/render/component-grammar.ts +59 -3
- package/src/lib/render/component-validate.ts +22 -1
- package/src/lib/render/pipeline.ts +17 -3
- package/src/lib/render/registry.ts +38 -0
- package/src/lib/render/remark-figure.ts +132 -0
- package/src/lib/render/resolve-media.ts +96 -0
- package/src/lib/render/sanitize-schema.ts +5 -3
- package/src/lib/sveltekit/admin-dispatch.ts +6 -1
- package/src/lib/sveltekit/cairn-admin.ts +13 -3
- package/src/lib/sveltekit/content-routes.ts +573 -12
- package/src/lib/sveltekit/csrf.ts +18 -0
- package/src/lib/sveltekit/guard.ts +12 -3
- package/src/lib/sveltekit/index.ts +6 -0
- package/src/lib/sveltekit/media-route.ts +158 -0
- package/src/lib/vite/index.ts +9 -2
|
@@ -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.
|
|
11
|
-
*
|
|
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.
|
|
14
|
-
*
|
|
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],
|
|
@@ -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)),
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { fail } from '@sveltejs/kit';
|
|
2
2
|
import { type GithubKeyEnv } from '../github/credentials.js';
|
|
3
3
|
import { type LinkTarget, type InboundLink } from '../content/manifest.js';
|
|
4
|
+
import type { MediaEntry } from '../media/manifest.js';
|
|
5
|
+
import type { MediaLibrary, MediaLibraryEntry } from '../media/library-entry.js';
|
|
6
|
+
import type { UsageEntry } from '../media/usage.js';
|
|
4
7
|
import type { CookieJar, EventBase } from './types.js';
|
|
5
8
|
import type { CairnRuntime, FrontmatterField, ResolvedPreview } from '../content/types.js';
|
|
6
9
|
import type { Role } from '../auth/types.js';
|
|
@@ -83,6 +86,18 @@ export interface EditData {
|
|
|
83
86
|
slug: string;
|
|
84
87
|
/** The site's link targets, for the preview resolver and the link picker; from the committed manifest. */
|
|
85
88
|
linkTargets: LinkTarget[];
|
|
89
|
+
/** The minimal media-resolver input the edit page builds its preview `resolveMedia` from, keyed by
|
|
90
|
+
* the 16-hex content hash and parallel to `linkTargets`. Empty when media is off or the read fails. */
|
|
91
|
+
mediaTargets: Record<string, {
|
|
92
|
+
slug: string;
|
|
93
|
+
ext: string;
|
|
94
|
+
contentType: string;
|
|
95
|
+
}>;
|
|
96
|
+
/** The picker's human layer for each stored asset, keyed by the 16-hex content hash and projected
|
|
97
|
+
* from the same committed media manifest read that populates `mediaTargets`. The `hash` field
|
|
98
|
+
* duplicates the key, so the picker can iterate `Object.values`. Empty when media is off or the
|
|
99
|
+
* read fails (the same degradation path as `mediaTargets`). */
|
|
100
|
+
mediaLibrary: MediaLibrary;
|
|
86
101
|
/** The entries that link to this one, for the delete guard. Empty when nothing links here. */
|
|
87
102
|
inboundLinks: InboundLink[];
|
|
88
103
|
/** True when the entry has a pending branch, so the body above came from that branch. */
|
|
@@ -98,6 +113,23 @@ export interface EditData {
|
|
|
98
113
|
* leaves the frame rendering unstyled markup behind a hint. */
|
|
99
114
|
preview: ResolvedPreview | null;
|
|
100
115
|
}
|
|
116
|
+
/** One asset's where-used overlay, kept separate from MediaLibraryEntry so the picker's shared
|
|
117
|
+
* projection stays decoupled from the Library-only usage facts. */
|
|
118
|
+
export interface MediaUsageInfo {
|
|
119
|
+
/** Distinct content entries that reference the asset (count by distinct concept+id). */
|
|
120
|
+
count: number;
|
|
121
|
+
/** Every where-used row (published and edit-branch origins), for the detail's grouped list. */
|
|
122
|
+
entries: UsageEntry[];
|
|
123
|
+
}
|
|
124
|
+
/** The Media Library screen's data: the unioned assets, the per-hash usage overlay, and the
|
|
125
|
+
* degraded-load error. The usage overlay is keyed by content hash; an asset with no references
|
|
126
|
+
* simply has no key, which the screen renders as "no references found". */
|
|
127
|
+
export interface MediaLibraryData {
|
|
128
|
+
assets: MediaLibraryEntry[];
|
|
129
|
+
/** Per-hash usage overlay, kept separate from MediaLibraryEntry so the popover stays decoupled. */
|
|
130
|
+
usage: Record<string, MediaUsageInfo>;
|
|
131
|
+
error: string | null;
|
|
132
|
+
}
|
|
101
133
|
/** The structural event the content routes read; a real SvelteKit RequestEvent satisfies it. */
|
|
102
134
|
export interface ContentEvent extends EventBase<GithubKeyEnv> {
|
|
103
135
|
params: Record<string, string>;
|
|
@@ -134,14 +166,45 @@ export interface RenameFailure {
|
|
|
134
166
|
/** The one-line human summary every content action failure carries. */
|
|
135
167
|
error: string;
|
|
136
168
|
}
|
|
169
|
+
/** A refused media delete: `fail(404)` for an asset not committed on the default branch, or
|
|
170
|
+
* `fail(409)` when a fresh usage read finds the asset still in use and the typed-slug override
|
|
171
|
+
* was not given. `fail(503)` covers media-off or a missing bucket binding. */
|
|
172
|
+
export interface MediaDeleteRefusal {
|
|
173
|
+
/** The one-line human summary every action failure carries. */
|
|
174
|
+
error: string;
|
|
175
|
+
/** The refused asset's content hash, so the dialog marks the right asset. */
|
|
176
|
+
hash: string;
|
|
177
|
+
/** The where-used rows (published first, then by branch) the in-use face lists; empty otherwise. */
|
|
178
|
+
usage: UsageEntry[];
|
|
179
|
+
/** The distinct-entry count behind the refusal; zero when the asset is uncommitted. */
|
|
180
|
+
foundIn: number;
|
|
181
|
+
}
|
|
182
|
+
/** A refused media metadata edit: `fail(404)` for an asset not committed on the default branch, or
|
|
183
|
+
* `fail(400)` for an invalid slug. */
|
|
184
|
+
export interface MediaUpdateFailure {
|
|
185
|
+
/** The one-line human summary every action failure carries. */
|
|
186
|
+
error: string;
|
|
187
|
+
}
|
|
137
188
|
/** What a route's single `form` export presents to a view component: whichever content action
|
|
138
189
|
* last failed, merged with every field optional. `error` is always set on a failure; the richer
|
|
139
|
-
* keys identify which guard refused.
|
|
140
|
-
|
|
190
|
+
* keys identify which guard refused. The media refusals ride here too, so the Media Library's one
|
|
191
|
+
* `form` prop carries a `?/mediaDelete` or `?/mediaUpdate` refusal without a second type. */
|
|
192
|
+
export type ContentFormFailure = Partial<SaveFailure & DeleteRefusal & RenameFailure & MediaDeleteRefusal & MediaUpdateFailure>;
|
|
193
|
+
/** The successful upload's response (`uploadAction`). The server-owned `record` rides the editor's
|
|
194
|
+
* optimistic client state and commits with the entry at Save (the upload itself commits nothing).
|
|
195
|
+
* `reused` is true when identical bytes were already stored, so the second upload did no second put;
|
|
196
|
+
* `mismatch` flags an existing object whose stored content type differs from this sniff. */
|
|
197
|
+
export interface UploadResult {
|
|
198
|
+
reference: string;
|
|
199
|
+
record: MediaEntry;
|
|
200
|
+
reused: boolean;
|
|
201
|
+
mismatch: boolean;
|
|
202
|
+
}
|
|
141
203
|
export declare function createContentRoutes(runtime: CairnRuntime, deps?: ContentRoutesDeps): {
|
|
142
204
|
layoutLoad: (event: ContentEvent) => Promise<LayoutData>;
|
|
143
205
|
indexRedirect: () => never;
|
|
144
206
|
listLoad: (event: ContentEvent) => Promise<ListData>;
|
|
207
|
+
mediaLibraryLoad: (event: ContentEvent) => Promise<MediaLibraryData>;
|
|
145
208
|
createAction: (event: ContentEvent) => Promise<never>;
|
|
146
209
|
editLoad: (event: ContentEvent) => Promise<EditData>;
|
|
147
210
|
saveAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
|
|
@@ -151,5 +214,8 @@ export declare function createContentRoutes(runtime: CairnRuntime, deps?: Conten
|
|
|
151
214
|
deleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
|
|
152
215
|
listDeleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
|
|
153
216
|
renameAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
|
|
217
|
+
uploadAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | UploadResult>;
|
|
218
|
+
mediaDeleteAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
|
|
219
|
+
mediaUpdateAction: (event: ContentEvent) => Promise<ReturnType<typeof fail> | never>;
|
|
154
220
|
mintToken: (env: GithubKeyEnv) => string | Promise<string>;
|
|
155
221
|
};
|