@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
|
@@ -76,7 +76,7 @@ function isContainer(node: RootContent): node is RootContent & DirectiveNode {
|
|
|
76
76
|
|
|
77
77
|
// Pin the bullet to `-` so a markdown body or slot that uses dash bullets round-trips unchanged
|
|
78
78
|
// rather than drifting to remark-stringify's default `*`, which would silently mutate author content.
|
|
79
|
-
const toMd = unified().use(remarkStringify, { bullet: '-' });
|
|
79
|
+
const toMd = unified().use(remarkDirective).use(remarkStringify, { bullet: '-' });
|
|
80
80
|
|
|
81
81
|
/** Render mdast children back to trimmed markdown text. */
|
|
82
82
|
function childrenToText(children: RootContent[]): string {
|
|
@@ -149,6 +149,48 @@ export function parseRawAttributeKeys(markdown: string, def: ComponentDef): stri
|
|
|
149
149
|
return rawKeysFromRoot(findComponentRoot(markdown, def));
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
/** The result of {@link componentRoundTripSafety}: whether re-opening a placed block into the
|
|
153
|
+
* guided form and re-serializing it is provably lossless. */
|
|
154
|
+
export type RoundTripSafety =
|
|
155
|
+
| { safe: true }
|
|
156
|
+
| { safe: false; reason: 'unknown-attribute' | 'undeclared-child' | 'not-idempotent' | 'not-a-component' };
|
|
157
|
+
|
|
158
|
+
/** Decide whether guided edit of this placed block is provably lossless. A block a person typed by
|
|
159
|
+
* hand can carry more than the schema models (an attribute the def does not list, a child container
|
|
160
|
+
* the def does not declare, slot content the form cannot represent stably), and parsing such a block
|
|
161
|
+
* into the form then re-serializing would silently drop it. The edit affordance is offered only when
|
|
162
|
+
* this returns `{ safe: true }`. Checks run in order and return the first failure:
|
|
163
|
+
*
|
|
164
|
+
* 1. `not-a-component`: the def's opening container is not present.
|
|
165
|
+
* 2. `unknown-attribute`: the block carries an attribute key the def does not declare.
|
|
166
|
+
* 3. `undeclared-child`: the root has a direct child container directive that is not a declared
|
|
167
|
+
* nested slot. Such a child would otherwise fold into the body slot and move on re-serialize.
|
|
168
|
+
* 4. `not-idempotent`: `parse -> serialize -> parse` does not recover the same values. */
|
|
169
|
+
export async function componentRoundTripSafety(markdown: string, def: ComponentDef): Promise<RoundTripSafety> {
|
|
170
|
+
const root = findComponentRoot(markdown, def);
|
|
171
|
+
if (!root) return { safe: false, reason: 'not-a-component' };
|
|
172
|
+
|
|
173
|
+
const declaredKeys = new Set((def.attributes ?? []).map((f) => f.key));
|
|
174
|
+
for (const key of parseRawAttributeKeys(markdown, def)) {
|
|
175
|
+
if (!declaredKeys.has(key)) return { safe: false, reason: 'unknown-attribute' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const slotNames = new Set(nestedSlots(def).map((s) => s.name));
|
|
179
|
+
for (const child of root.children) {
|
|
180
|
+
if (isContainer(child) && !slotNames.has((child as DirectiveNode).name)) {
|
|
181
|
+
return { safe: false, reason: 'undeclared-child' };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// The values are plain strings, booleans, and string arrays in declared (object-key) order, so a
|
|
186
|
+
// stable JSON.stringify is a sufficient deep-equal.
|
|
187
|
+
const v1 = await parseComponent(markdown, def);
|
|
188
|
+
const v2 = await parseComponent(serializeComponent(def, v1), def);
|
|
189
|
+
if (JSON.stringify(v1) !== JSON.stringify(v2)) return { safe: false, reason: 'not-idempotent' };
|
|
190
|
+
|
|
191
|
+
return { safe: true };
|
|
192
|
+
}
|
|
193
|
+
|
|
152
194
|
/** Parse the component once and derive both the guided-form values and the raw attribute keys.
|
|
153
195
|
* Validation needs both, so this seam spares it the double parse that calling
|
|
154
196
|
* {@link parseComponent} and {@link parseRawAttributeKeys} separately would cost. */
|
|
@@ -178,8 +220,22 @@ function isDirectiveLabel(node: RootContent): boolean {
|
|
|
178
220
|
|
|
179
221
|
function readLabel(root: DirectiveNode): string | undefined {
|
|
180
222
|
for (const child of root.children) {
|
|
181
|
-
const p = child as {
|
|
182
|
-
|
|
223
|
+
const p = child as {
|
|
224
|
+
type: string;
|
|
225
|
+
data?: { directiveLabel?: boolean };
|
|
226
|
+
children?: RootContent[];
|
|
227
|
+
};
|
|
228
|
+
if (p.type !== 'paragraph' || !p.data?.directiveLabel) continue;
|
|
229
|
+
const kids = p.children ?? [];
|
|
230
|
+
// When every label child is a plain text node, join the raw `.value`s. That keeps the pure-text
|
|
231
|
+
// path identical to before, so a literal `[` or `]` in the title is not re-escaped by the
|
|
232
|
+
// stringifier (serializeComponent already escapes brackets, and remark un-escapes them on parse).
|
|
233
|
+
// When the label carries inline markdown (a link, bold, emphasis), the text-only join would drop
|
|
234
|
+
// the markup, so stringify the children to recover the full inline source losslessly.
|
|
235
|
+
if (kids.every((c) => (c as { type: string }).type === 'text')) {
|
|
236
|
+
return kids.map((c) => (c as { value?: string }).value ?? '').join('');
|
|
237
|
+
}
|
|
238
|
+
return childrenToText(kids);
|
|
183
239
|
}
|
|
184
240
|
return undefined;
|
|
185
241
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseComponentWithRawKeys } from './component-grammar.js';
|
|
2
|
-
import type { ComponentDef } from './registry.js';
|
|
2
|
+
import type { ComponentDef, ComponentValues } from './registry.js';
|
|
3
3
|
|
|
4
4
|
/** A validation verdict: ok, or field-keyed error messages. */
|
|
5
5
|
export type ComponentValidation = { ok: true } | { ok: false; errors: Record<string, string> };
|
|
@@ -18,6 +18,15 @@ export async function validateComponent(markdown: string, def: ComponentDef): Pr
|
|
|
18
18
|
}
|
|
19
19
|
if (field.type === 'select' && typeof v === 'string' && v !== '' && !(field.options ?? []).includes(v)) {
|
|
20
20
|
errors[field.key] = `${field.label} must be one of: ${(field.options ?? []).join(', ')}.`;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (field.pattern && typeof v === 'string' && v !== '' && !new RegExp(field.pattern.source).test(v)) {
|
|
24
|
+
errors[field.key] = field.pattern.message;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (field.validate) {
|
|
28
|
+
const message = runFieldValidator(def, field.key, () => field.validate!(v, values));
|
|
29
|
+
if (typeof message === 'string') errors[field.key] = message;
|
|
21
30
|
}
|
|
22
31
|
}
|
|
23
32
|
|
|
@@ -34,3 +43,15 @@ export async function validateComponent(markdown: string, def: ComponentDef): Pr
|
|
|
34
43
|
|
|
35
44
|
return Object.keys(errors).length ? { ok: false, errors } : { ok: true };
|
|
36
45
|
}
|
|
46
|
+
|
|
47
|
+
// Run a site-supplied attribute validator. The validator is author code, so a throw is contained:
|
|
48
|
+
// the field is treated as valid and a dev-time warning names the component and field so the author
|
|
49
|
+
// can find the bug. A returned string is the field error; anything else (null) is clean.
|
|
50
|
+
function runFieldValidator(def: ComponentDef, key: string, call: () => string | null): string | null {
|
|
51
|
+
try {
|
|
52
|
+
return call();
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.warn(`cairn: validate() for component "${def.name}" field "${key}" threw; treating the field as valid.`, err);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -11,7 +11,9 @@ import type { Schema } from 'hast-util-sanitize';
|
|
|
11
11
|
import { VFile } from 'vfile';
|
|
12
12
|
import { buildSanitizeSchema, rehypeAnchorRel, rehypeSinkGuard } from './sanitize-schema.js';
|
|
13
13
|
import { remarkDirectiveStamp } from './remark-directives.js';
|
|
14
|
+
import { remarkFigure } from './remark-figure.js';
|
|
14
15
|
import { remarkResolveCairnLinks, CAIRN_RESOLVE } from './resolve-links.js';
|
|
16
|
+
import { remarkResolveMedia, MEDIA_RESOLVE, type MediaResolve } from './resolve-media.js';
|
|
15
17
|
import { rehypeDispatch } from './rehype-dispatch.js';
|
|
16
18
|
import { defineRegistry, type ComponentRegistry } from './registry.js';
|
|
17
19
|
import type { LinkResolve } from '../content/links.js';
|
|
@@ -43,7 +45,13 @@ export function createRenderer(
|
|
|
43
45
|
registry: ComponentRegistry = defineRegistry({ components: [] }),
|
|
44
46
|
options: RendererOptions = {},
|
|
45
47
|
) {
|
|
46
|
-
const remarkPlugins: PluggableList = [
|
|
48
|
+
const remarkPlugins: PluggableList = [
|
|
49
|
+
remarkDirective,
|
|
50
|
+
[remarkDirectiveStamp, registry],
|
|
51
|
+
remarkResolveCairnLinks,
|
|
52
|
+
remarkFigure,
|
|
53
|
+
remarkResolveMedia,
|
|
54
|
+
];
|
|
47
55
|
// The sanitize floor runs after rehype-raw (so author raw HTML is parsed, then cleaned) and
|
|
48
56
|
// before the dispatch (so the site's trusted build() output and its inline SVG icons are never
|
|
49
57
|
// sanitized). The anchor-rel hardening runs last so it also covers component-built anchors.
|
|
@@ -71,8 +79,14 @@ export function createRenderer(
|
|
|
71
79
|
return {
|
|
72
80
|
remarkPlugins,
|
|
73
81
|
rehypePlugins,
|
|
74
|
-
renderMarkdown: async (
|
|
75
|
-
|
|
82
|
+
renderMarkdown: async (
|
|
83
|
+
content: string,
|
|
84
|
+
opts: { resolve?: LinkResolve; resolveMedia?: MediaResolve } = {},
|
|
85
|
+
): Promise<string> => {
|
|
86
|
+
const file = new VFile({
|
|
87
|
+
value: content,
|
|
88
|
+
data: { [CAIRN_RESOLVE]: opts.resolve, [MEDIA_RESOLVE]: opts.resolveMedia },
|
|
89
|
+
});
|
|
76
90
|
return String(await processor.process(file));
|
|
77
91
|
},
|
|
78
92
|
};
|
|
@@ -22,6 +22,12 @@ export interface AttributeField {
|
|
|
22
22
|
options?: readonly string[];
|
|
23
23
|
/** Helper text shown under the field. */
|
|
24
24
|
help?: string;
|
|
25
|
+
/** A RegExp `source` to validate the value against, plus the message to show on a mismatch. */
|
|
26
|
+
pattern?: { source: string; message: string };
|
|
27
|
+
/** A pure, browser-safe cross-field validator. Returns an error string, or null when valid.
|
|
28
|
+
* Receives the field's value and the full {@link ComponentValues} so a rule can read sibling
|
|
29
|
+
* fields. The picker wraps the call in try/catch so an author's throw never crashes the form. */
|
|
30
|
+
validate?: (value: string | boolean, all: ComponentValues) => string | null;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export type SlotKind = 'markdown' | 'inline' | 'repeatable';
|
|
@@ -36,6 +42,9 @@ export interface SlotDef {
|
|
|
36
42
|
help?: string;
|
|
37
43
|
/** For `kind: 'repeatable'`: the fields composing each list item (v1 uses the first field). */
|
|
38
44
|
itemFields?: AttributeField[];
|
|
45
|
+
/** For `kind: 'repeatable'`: derives a row's label from its item values and zero-based index.
|
|
46
|
+
* When it returns nothing, the picker falls back to `${label} ${index + 1}`. */
|
|
47
|
+
itemLabel?: (item: Record<string, string | boolean>, index: number) => string;
|
|
39
48
|
}
|
|
40
49
|
|
|
41
50
|
/** The structured input a component's `build` receives. The engine stamps the component's
|
|
@@ -75,6 +84,18 @@ export interface ComponentDef {
|
|
|
75
84
|
attributes?: AttributeField[];
|
|
76
85
|
/** The named content regions this component accepts. */
|
|
77
86
|
slots?: SlotDef[];
|
|
87
|
+
/** A glyph key from the site IconSet, shown beside the label in the picker. */
|
|
88
|
+
icon?: string;
|
|
89
|
+
/** A category heading for the picker. Components order by declaration within a group. */
|
|
90
|
+
group?: string;
|
|
91
|
+
/** Omit from the top-level picker (for a nested or round-trip-only component). */
|
|
92
|
+
hidden?: boolean;
|
|
93
|
+
/** A structured sample the picker seeds the form with and renders through the same path a real
|
|
94
|
+
* insert takes. Declaring `preview` is what opts the component into the two-pane configure layout. */
|
|
95
|
+
preview?: {
|
|
96
|
+
attributes?: Record<string, string | boolean>;
|
|
97
|
+
slots?: Record<string, string | string[]>;
|
|
98
|
+
};
|
|
78
99
|
}
|
|
79
100
|
|
|
80
101
|
export interface ComponentRegistry {
|
|
@@ -106,6 +127,11 @@ function findIconField(def: ComponentDef): AttributeField | undefined {
|
|
|
106
127
|
*/
|
|
107
128
|
export function defineRegistry({ components }: { components: ComponentDef[] }): ComponentRegistry {
|
|
108
129
|
for (const c of components) {
|
|
130
|
+
if (c.name === 'figure') {
|
|
131
|
+
throw new Error(
|
|
132
|
+
'cairn: "figure" is a reserved directive name handled by the engine render step; a component cannot use it',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
109
135
|
if (c.defaultIconByRole && Object.keys(c.defaultIconByRole).length > 0 && !findIconField(c)) {
|
|
110
136
|
throw new Error(
|
|
111
137
|
`cairn: component "${c.name}" sets defaultIconByRole but declares no type:'icon' attribute, so the default icon can never render`,
|
|
@@ -145,3 +171,15 @@ export function emptyValues(def: ComponentDef): ComponentValues {
|
|
|
145
171
|
}
|
|
146
172
|
return { attributes, slots };
|
|
147
173
|
}
|
|
174
|
+
|
|
175
|
+
/** Seed {@link ComponentValues} from a component's `preview` sample: the {@link emptyValues} base
|
|
176
|
+
* with `def.preview.attributes` and `def.preview.slots` overlaid (a shallow merge per side). When
|
|
177
|
+
* the def declares no `preview`, returns exactly the {@link emptyValues} output. */
|
|
178
|
+
export function previewValues(def: ComponentDef): ComponentValues {
|
|
179
|
+
const base = emptyValues(def);
|
|
180
|
+
if (!def.preview) return base;
|
|
181
|
+
return {
|
|
182
|
+
attributes: { ...base.attributes, ...def.preview.attributes },
|
|
183
|
+
slots: { ...base.slots, ...def.preview.slots },
|
|
184
|
+
};
|
|
185
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// cairn-cms: the figure render step, an mdast step in the shared render pipeline. It rewrites the
|
|
2
|
+
// cairn-reserved `figure` container directive into a <figure><img><figcaption> structure. The
|
|
3
|
+
// directive wraps a real media image (``), so the image stays a child node
|
|
4
|
+
// and remarkResolveMedia resolves it untouched, exactly as a bare inline image. The caption is the
|
|
5
|
+
// directive's body text, and the placement role rides the directive's class attribute as a value
|
|
6
|
+
// from a closed set. This step runs before remarkResolveMedia and before remark-rehype flattens the
|
|
7
|
+
// tree. It is engine-internal and reserved, the sibling of resolveMedia and resolveLinks, exported
|
|
8
|
+
// from no public subpath. A site cannot register a component named `figure`, so it cannot shadow it.
|
|
9
|
+
import type { Root, Paragraph, PhrasingContent } from 'mdast';
|
|
10
|
+
import type { ContainerDirective } from 'mdast-util-directive';
|
|
11
|
+
import { visit } from 'unist-util-visit';
|
|
12
|
+
import { parseMediaToken } from '../media/reference.js';
|
|
13
|
+
|
|
14
|
+
// The directive's children are block content. The unwrap lifts the media image to a direct child of
|
|
15
|
+
// the figure, which is a phrasing node in block position: legal in the rendered <figure> and handled
|
|
16
|
+
// by mdast-util-to-hast, but outside mdast's block-content union. This alias names that child slot.
|
|
17
|
+
type FigureChild = ContainerDirective['children'][number];
|
|
18
|
+
|
|
19
|
+
/** The closed placement role set. A class outside this set is ignored, never passed through. */
|
|
20
|
+
const ROLES = new Set(['center', 'wide', 'full']);
|
|
21
|
+
|
|
22
|
+
// mdast-util-to-hast reads hName/hProperties off node.data to override the element. The shipped
|
|
23
|
+
// mdast Data type does not carry them, so this mirrors the local cast idiom in remark-directives.ts.
|
|
24
|
+
interface HastData {
|
|
25
|
+
hName?: string;
|
|
26
|
+
hProperties?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setData(node: { data?: unknown }, patch: HastData): void {
|
|
30
|
+
const data = (node.data ?? (node.data = {})) as HastData;
|
|
31
|
+
Object.assign(data, patch);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A node whose subtree carries non-whitespace text is a caption candidate.
|
|
35
|
+
function hasText(node: FigureChild): boolean {
|
|
36
|
+
let found = false;
|
|
37
|
+
visit(node, 'text', (text) => {
|
|
38
|
+
if (text.value.trim() !== '') found = true;
|
|
39
|
+
});
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Find the first descendant image node whose url is a media: reference, with its enclosing direct
|
|
44
|
+
// child of the directive (the paragraph holding it) and that child's index.
|
|
45
|
+
function findMediaImage(
|
|
46
|
+
directive: ContainerDirective,
|
|
47
|
+
): { image: PhrasingContent; childIndex: number } | null {
|
|
48
|
+
for (let i = 0; i < directive.children.length; i++) {
|
|
49
|
+
const child = directive.children[i];
|
|
50
|
+
if (child.type !== 'paragraph') continue;
|
|
51
|
+
const image = child.children.find(
|
|
52
|
+
(n) => n.type === 'image' && parseMediaToken(n.url) !== null,
|
|
53
|
+
);
|
|
54
|
+
if (image) return { image, childIndex: i };
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Strip a leading newline or all-whitespace prefix from the first phrasing child, so a caption
|
|
60
|
+
// split off the image line reads cleanly without a stray softbreak.
|
|
61
|
+
function trimLeadingNewline(children: PhrasingContent[]): PhrasingContent[] {
|
|
62
|
+
if (children.length === 0) return children;
|
|
63
|
+
const [first, ...rest] = children;
|
|
64
|
+
if (first.type === 'text') {
|
|
65
|
+
const trimmed = first.value.replace(/^\s+/, '');
|
|
66
|
+
if (trimmed === '') return rest;
|
|
67
|
+
return [{ ...first, value: trimmed }, ...rest];
|
|
68
|
+
}
|
|
69
|
+
return children;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Rewrite the reserved `figure` container directive into a placed <figure>. Every other directive
|
|
73
|
+
* is left to remarkDirectiveStamp, which already skips unregistered names. */
|
|
74
|
+
export function remarkFigure() {
|
|
75
|
+
return (tree: Root): void => {
|
|
76
|
+
visit(tree, 'containerDirective', (node: ContainerDirective) => {
|
|
77
|
+
if (node.name !== 'figure') return;
|
|
78
|
+
|
|
79
|
+
// The role rides the class attribute, kept only when it is exactly one closed-set value.
|
|
80
|
+
const className = node.attributes?.class ?? undefined;
|
|
81
|
+
const role = className && ROLES.has(className) ? className : undefined;
|
|
82
|
+
setData(node, {
|
|
83
|
+
hName: 'figure',
|
|
84
|
+
...(role ? { hProperties: { className: ['cairn-place-' + role] } } : {}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const found = findMediaImage(node);
|
|
88
|
+
// A figure with no media image is a degraded authoring state: leave its children, invent no
|
|
89
|
+
// image, never throw. The hName is already set, so it still renders as a <figure>.
|
|
90
|
+
if (!found) return;
|
|
91
|
+
|
|
92
|
+
const { image, childIndex } = found;
|
|
93
|
+
const paragraph = node.children[childIndex] as Paragraph;
|
|
94
|
+
|
|
95
|
+
// The image lifts into block position (the unwrap), so it carries the FigureChild slot type.
|
|
96
|
+
const imageChild = image as FigureChild;
|
|
97
|
+
|
|
98
|
+
// Unwrap the image to a direct child of the directive, handling both paragraph forms.
|
|
99
|
+
let captionNode: FigureChild | undefined;
|
|
100
|
+
if (paragraph.children.length === 1) {
|
|
101
|
+
// Blank-line form: the image is alone in its paragraph. The bare image replaces it; a
|
|
102
|
+
// separate following text-bearing paragraph is the caption.
|
|
103
|
+
node.children.splice(childIndex, 1, imageChild);
|
|
104
|
+
} else {
|
|
105
|
+
// No-blank-line form: the image and the caption share one paragraph. Split it into the bare
|
|
106
|
+
// image followed by a paragraph holding the remaining children as the caption.
|
|
107
|
+
const imageIndex = paragraph.children.indexOf(image);
|
|
108
|
+
const rest = trimLeadingNewline(paragraph.children.slice(imageIndex + 1));
|
|
109
|
+
const replacement: FigureChild[] = [imageChild];
|
|
110
|
+
if (rest.length > 0) {
|
|
111
|
+
const captionParagraph: Paragraph = { type: 'paragraph', children: rest };
|
|
112
|
+
replacement.push(captionParagraph);
|
|
113
|
+
captionNode = captionParagraph;
|
|
114
|
+
}
|
|
115
|
+
node.children.splice(childIndex, 1, ...replacement);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The caption is the first text-bearing block after the image. In the split case it is the
|
|
119
|
+
// paragraph just appended; otherwise scan the blocks following the image.
|
|
120
|
+
const imagePos = node.children.indexOf(imageChild);
|
|
121
|
+
if (!captionNode) {
|
|
122
|
+
for (let i = imagePos + 1; i < node.children.length; i++) {
|
|
123
|
+
if (hasText(node.children[i])) {
|
|
124
|
+
captionNode = node.children[i];
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (captionNode) setData(captionNode, { hName: 'figcaption' });
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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 type { VFile } from 'vfile';
|
|
8
|
+
import { parseMediaToken, type MediaRef } from '../media/reference.js';
|
|
9
|
+
import { findByHash, type MediaManifest } from '../media/manifest.js';
|
|
10
|
+
import { publicPath } from '../media/naming.js';
|
|
11
|
+
import { presetUrl } from '../media/transform-url.js';
|
|
12
|
+
import type { ResolvedAssetConfig } from '../media/config.js';
|
|
13
|
+
import { log } from '../log/index.js';
|
|
14
|
+
|
|
15
|
+
/** The VFile data key the renderer sets the per-call media resolver under. */
|
|
16
|
+
export const MEDIA_RESOLVE = 'mediaResolve';
|
|
17
|
+
|
|
18
|
+
/** Resolve a media reference to its delivery URL. `undefined` is a preview miss (the plugin marks
|
|
19
|
+
* the image broken); a resolver that throws is the build backstop (the error propagates out of
|
|
20
|
+
* render and fails the build), exactly like LinkResolve. */
|
|
21
|
+
export type MediaResolve = (ref: MediaRef) => string | undefined;
|
|
22
|
+
|
|
23
|
+
/** Build the per-call media resolver, closing over the manifest and the resolved config. The
|
|
24
|
+
* returned resolver looks a ref's content hash up in the manifest and builds the canonical delivery
|
|
25
|
+
* path from the manifest entry's slug and ext, not the token's, so a rename never breaks the
|
|
26
|
+
* reference. With a preset and zone transformations on it returns the variant URL; without a preset,
|
|
27
|
+
* or when transformations are off, it returns the bare full-size path so a fresh zone with Image
|
|
28
|
+
* Transformations disabled serves correct thumbnails rather than dead /cdn-cgi/image URLs. It returns
|
|
29
|
+
* undefined when media is off or no entry carries the hash (the preview-miss backstop). */
|
|
30
|
+
export function makeMediaResolver(
|
|
31
|
+
manifest: MediaManifest,
|
|
32
|
+
resolved: ResolvedAssetConfig,
|
|
33
|
+
opts?: { preset?: string },
|
|
34
|
+
): MediaResolve {
|
|
35
|
+
return (ref: MediaRef): string | undefined => {
|
|
36
|
+
if (!resolved.enabled) return undefined;
|
|
37
|
+
const entry = findByHash(manifest, ref.hash);
|
|
38
|
+
if (!entry) {
|
|
39
|
+
// A real miss: media is on but the hash has no manifest row, the broken-reference case. The
|
|
40
|
+
// media-off path above stays silent, since an unresolved token there is expected, not a fault.
|
|
41
|
+
log.warn('media.resolve_missing', { hash: ref.hash });
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const path = publicPath(entry.slug, entry.hash, entry.ext, resolved.urlForm, resolved.publicBase);
|
|
45
|
+
if (opts?.preset && resolved.transformations) {
|
|
46
|
+
return presetUrl(path, opts.preset, resolved.variants);
|
|
47
|
+
}
|
|
48
|
+
return path;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A resolver backed by the lean `mediaTargets` projection, for the admin preview. It mirrors
|
|
53
|
+
* manifestLinkResolver: a hash present in the projection builds the slug delivery path
|
|
54
|
+
* (`/media/<slug>.<hash>.<ext>`); a miss returns undefined, so the render step marks the image
|
|
55
|
+
* broken rather than throwing. Pure over the projection, with no manifest and no config, so the
|
|
56
|
+
* edit page reaches it with the data it actually has. */
|
|
57
|
+
export function manifestMediaResolver(
|
|
58
|
+
targets: Record<string, { slug: string; ext: string; contentType: string }>,
|
|
59
|
+
): MediaResolve {
|
|
60
|
+
return (ref: MediaRef): string | undefined => {
|
|
61
|
+
const entry = targets[ref.hash];
|
|
62
|
+
if (!entry) return undefined;
|
|
63
|
+
return publicPath(entry.slug, ref.hash, entry.ext, 'slug');
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ImageNode {
|
|
68
|
+
url: string;
|
|
69
|
+
data?: { hProperties?: Record<string, unknown> };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Resolve media: image nodes against the VFile's resolver. A non-media src and a malformed token
|
|
73
|
+
* pass through. A missing target is marked with the cairn-broken-media class (the resolver returns
|
|
74
|
+
* undefined) or, when the resolver throws, the error propagates and fails the build. */
|
|
75
|
+
export function remarkResolveMedia() {
|
|
76
|
+
return (tree: unknown, file: VFile): void => {
|
|
77
|
+
const resolve = file.data[MEDIA_RESOLVE] as MediaResolve | undefined;
|
|
78
|
+
if (!resolve) return;
|
|
79
|
+
visit(tree as Parameters<typeof visit>[0], 'image', (node: ImageNode) => {
|
|
80
|
+
const ref = parseMediaToken(node.url);
|
|
81
|
+
if (!ref) return;
|
|
82
|
+
const url = resolve(ref); // may throw (build backstop); propagates out of render
|
|
83
|
+
if (url) {
|
|
84
|
+
node.url = url;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Missing asset in the preview: mark it broken and neutralize the src, keeping the alt.
|
|
88
|
+
node.url = '#';
|
|
89
|
+
node.data = node.data ?? {};
|
|
90
|
+
const props = (node.data.hProperties = node.data.hProperties ?? {});
|
|
91
|
+
const existing = Array.isArray(props.className) ? (props.className as string[]) : [];
|
|
92
|
+
props.className = [...existing, 'cairn-broken-media'];
|
|
93
|
+
props.title = 'Missing media asset';
|
|
94
|
+
});
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -13,8 +13,10 @@ const FIXED_MARKERS = ['dataPrimitive', 'dataSlot', 'dataRole', 'dataRise'];
|
|
|
13
13
|
* then adds exactly what cairn's render needs. The directive markers (the fixed ones plus the
|
|
14
14
|
* dataAttr<Key> markers derived from the registry) survive so the dispatch reads its stamps after
|
|
15
15
|
* the floor. The benign author tags real content uses (nav, details, summary) and class/target/rel
|
|
16
|
-
* on anchors are admitted.
|
|
17
|
-
*
|
|
16
|
+
* on anchors are admitted. figure/figcaption join the base so the engine's placed figure survives
|
|
17
|
+
* the floor on every site, including one that supplies its own `sanitizeSchema` extension. A site
|
|
18
|
+
* extends the result through `extend`, always starting from this safe base, so it can add to the
|
|
19
|
+
* allowlist but not weaken the core strip.
|
|
18
20
|
*/
|
|
19
21
|
export function buildSanitizeSchema(
|
|
20
22
|
registry: ComponentRegistry,
|
|
@@ -36,7 +38,7 @@ export function buildSanitizeSchema(
|
|
|
36
38
|
const protocols = defaultSchema.protocols ?? {};
|
|
37
39
|
const schema: Schema = {
|
|
38
40
|
...defaultSchema,
|
|
39
|
-
tagNames: [...(defaultSchema.tagNames ?? []), 'nav', 'details', 'summary'],
|
|
41
|
+
tagNames: [...(defaultSchema.tagNames ?? []), 'nav', 'details', 'summary', 'figure', 'figcaption'],
|
|
40
42
|
attributes: {
|
|
41
43
|
...attributes,
|
|
42
44
|
'*': [...(attributes['*'] ?? []), 'className', ...markers],
|
|
@@ -15,7 +15,8 @@ export type AdminView =
|
|
|
15
15
|
| { view: 'list'; concept: ConceptDescriptor }
|
|
16
16
|
| { view: 'edit'; concept: ConceptDescriptor; id: string }
|
|
17
17
|
| { view: 'editors' }
|
|
18
|
-
| { view: 'nav' }
|
|
18
|
+
| { view: 'nav' }
|
|
19
|
+
| { view: 'media' };
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Fixed first segments that never resolve as concepts. The engine only allows posts and pages
|
|
@@ -57,6 +58,10 @@ export function parseAdminPath(
|
|
|
57
58
|
if (head === 'login') return { view: 'login' };
|
|
58
59
|
if (head === 'editors') return { view: 'editors' };
|
|
59
60
|
if (head === 'nav') return { view: 'nav' };
|
|
61
|
+
// media is its own view, a peer of editors and nav, so it is decided here, not added to the
|
|
62
|
+
// reserved-no-view set. /admin/media/<anything> 404s naturally (media is not a configured
|
|
63
|
+
// concept), which is the correct shape.
|
|
64
|
+
if (head === 'media') return { view: 'media' };
|
|
60
65
|
if (RESERVED_SEGMENTS.has(head)) return null;
|
|
61
66
|
const concept = findConcept(concepts, head);
|
|
62
67
|
return concept ? { view: 'list', concept } : null;
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type LayoutData,
|
|
15
15
|
type ListData,
|
|
16
16
|
type EditData,
|
|
17
|
+
type MediaLibraryData,
|
|
17
18
|
} from './content-routes.js';
|
|
18
19
|
import { createEditorRoutes } from './editors-routes.js';
|
|
19
20
|
import { createNavRoutes, type NavLoadData } from './nav-routes.js';
|
|
@@ -53,7 +54,8 @@ export type AdminData =
|
|
|
53
54
|
| { view: 'list'; layout: LayoutData; page: ListData }
|
|
54
55
|
| { view: 'edit'; layout: LayoutData; page: EditData }
|
|
55
56
|
| { view: 'editors'; layout: LayoutData; page: { editors: Editor[]; self: string } }
|
|
56
|
-
| { view: 'nav'; layout: LayoutData; page: NavLoadData }
|
|
57
|
+
| { view: 'nav'; layout: LayoutData; page: NavLoadData }
|
|
58
|
+
| { view: 'media'; layout: LayoutData; page: MediaLibraryData };
|
|
57
59
|
|
|
58
60
|
export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {}) {
|
|
59
61
|
// The runtime already composes the site name and the sender identity, so the magic-link
|
|
@@ -122,6 +124,11 @@ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {
|
|
|
122
124
|
const [layout, page] = await Promise.all([content.layoutLoad(delegated), nav.navLoad(delegated)]);
|
|
123
125
|
return { view: 'nav', layout, page };
|
|
124
126
|
}
|
|
127
|
+
case 'media': {
|
|
128
|
+
const delegated = contentEvent(event, {});
|
|
129
|
+
const [layout, page] = await Promise.all([content.layoutLoad(delegated), content.mediaLibraryLoad(delegated)]);
|
|
130
|
+
return { view: 'media', layout, page };
|
|
131
|
+
}
|
|
125
132
|
}
|
|
126
133
|
}
|
|
127
134
|
|
|
@@ -141,9 +148,9 @@ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {
|
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
// The topbar posts publishAll from every authed admin page; login and confirm may not.
|
|
144
|
-
const authedViews = ['list', 'edit', 'editors', 'nav'] as const;
|
|
151
|
+
const authedViews = ['list', 'edit', 'editors', 'nav', 'media'] as const;
|
|
145
152
|
// An editor signs out from wherever they are, so logout accepts any parsed view.
|
|
146
|
-
const anyView = ['index', 'login', 'confirm', 'list', 'edit', 'editors', 'nav'] as const;
|
|
153
|
+
const anyView = ['index', 'login', 'confirm', 'list', 'edit', 'editors', 'nav', 'media'] as const;
|
|
147
154
|
|
|
148
155
|
/** The full admin action vocabulary, one named async function per action, so a site's
|
|
149
156
|
* catch-all route exports `admin.actions` directly. Each wrapper stays thin: parse,
|
|
@@ -159,6 +166,7 @@ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {
|
|
|
159
166
|
if (!nav) throw error(404, 'Not found');
|
|
160
167
|
return nav.navSave(contentEvent(event, {}));
|
|
161
168
|
}),
|
|
169
|
+
upload: viewAction(['edit'], (event, view) => content.uploadAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
|
|
162
170
|
publish: viewAction(['edit'], (event, view) => content.publishAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
|
|
163
171
|
discard: viewAction(['edit'], (event, view) => content.discardAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
|
|
164
172
|
rename: viewAction(['edit'], (event, view) => content.renameAction(contentEvent(event, { concept: view.concept.id, id: view.id }))),
|
|
@@ -167,6 +175,8 @@ export function createCairnAdmin(runtime: CairnRuntime, deps: CairnAdminDeps = {
|
|
|
167
175
|
? content.deleteAction(contentEvent(event, { concept: view.concept.id, id: view.id }))
|
|
168
176
|
: content.listDeleteAction(contentEvent(event, { concept: view.concept.id })),
|
|
169
177
|
),
|
|
178
|
+
mediaDelete: viewAction(['media'], (event) => content.mediaDeleteAction(contentEvent(event, {}))),
|
|
179
|
+
mediaUpdate: viewAction(['media'], (event) => content.mediaUpdateAction(contentEvent(event, {}))),
|
|
170
180
|
publishAll: viewAction(authedViews, (event) => content.publishAllAction(contentEvent(event, {}))),
|
|
171
181
|
addEditor: viewAction(['editors'], (event) => editors.addEditorAction(event)),
|
|
172
182
|
removeEditor: viewAction(['editors'], (event) => editors.removeEditorAction(event)),
|