emdash-vibe-editor 0.1.0 → 0.2.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.
@@ -0,0 +1,22 @@
1
+ import type { VibeEditorOptions } from '../types.js';
2
+ import { type BlockSchema, type BlockTypes } from './blocks.js';
3
+ export type BlockTypeSource = () => Promise<BlockTypes | null>;
4
+ /** Where block types come from; tests give their own (there is no site database there). */
5
+ export declare function setBlockTypeSource(next: BlockTypeSource | null): void;
6
+ /**
7
+ * Where the fields of the site's sections are defined: EmDash's block types
8
+ * (narrowed to `opts.blocks` when given), else `opts.blocks` alone; null when
9
+ * neither is there.
10
+ */
11
+ export declare function loadBlockSchema(opts: VibeEditorOptions): Promise<BlockSchema | null>;
12
+ /** A blocks field as ctx.schema gives it. */
13
+ export interface BlocksField {
14
+ slug: string;
15
+ type: string;
16
+ validation?: unknown;
17
+ }
18
+ /**
19
+ * Why EmDash would refuse `next` as the field's new value (given what it is
20
+ * now), or null when it takes it.
21
+ */
22
+ export declare function blocksProblem(field: BlocksField, next: unknown[], previous: unknown, types: BlockTypes): Promise<string | null>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * What the fields inside page-builder sections are, and EmDash's own check
3
+ * of a blocks value.
4
+ *
5
+ * The schema EmDash gives plugins (ctx.schema) shows a blocks field without
6
+ * its block types. This plugin runs in the site's own process (a native
7
+ * plugin), so it reads them the way EmDash's content API does: its public
8
+ * resolveBlockTypes() on the site's database (getDb() from emdash/runtime).
9
+ * That works where the site's build bundles the plugin (Cloudflare Workers,
10
+ * or Node with the plugin in Vite's ssr.noExternal); where Node loads the
11
+ * plugin on its own, EmDash's database isn't reachable from it, and the site
12
+ * declares the editable fields in the plugin's `blocks` option instead.
13
+ *
14
+ * With EmDash's block types, a changed blocks value is checked with EmDash's
15
+ * own normalizeBlocksData() before it is written, so the plugin never stores
16
+ * something EmDash's admin would refuse to save (a link that isn't one, a
17
+ * picture without an id, a text over its length).
18
+ */
19
+ import { normalizeBlocksData, resolveBlockTypes } from 'emdash';
20
+ import { kindOf } from './blocks.js';
21
+ async function fromEmDash() {
22
+ try {
23
+ const { getDb } = await import('emdash/runtime');
24
+ return (await resolveBlockTypes(await getDb()));
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ let source = fromEmDash;
31
+ /** Where block types come from; tests give their own (there is no site database there). */
32
+ export function setBlockTypeSource(next) {
33
+ source = next || fromEmDash;
34
+ }
35
+ /** A declared field as EmDash would define it. */
36
+ function declared(slug, d) {
37
+ if (d === 'text')
38
+ return { slug, type: 'string' };
39
+ if (d === 'portableText' || d === 'image')
40
+ return { slug, type: d };
41
+ if (!d || typeof d !== 'object')
42
+ return null;
43
+ if (d.type === 'text') {
44
+ const t = d;
45
+ return { slug, type: 'string', required: t.required === true, validation: typeof t.maxLength === 'number' ? { maxLength: t.maxLength } : null };
46
+ }
47
+ const subFields = Object.entries(d)
48
+ .map(([sub, v]) => (v === 'portableText' || (v && typeof v === 'object' && v.type !== 'text') ? null : declared(sub, v)))
49
+ .filter((f) => !!f);
50
+ return { slug, type: 'repeater', validation: { subFields } };
51
+ }
52
+ function declaredFields(decl) {
53
+ return Object.entries(decl)
54
+ .map(([slug, d]) => declared(slug, d))
55
+ .filter((f) => !!f);
56
+ }
57
+ /** EmDash's fields, only those declared, each only when EmDash's type is what was declared. */
58
+ function narrow(real, decl) {
59
+ const out = [];
60
+ for (const d of decl) {
61
+ const r = real.find((f) => f.slug === d.slug);
62
+ if (!r)
63
+ continue;
64
+ if (d.type === 'repeater') {
65
+ if (r.type !== 'repeater')
66
+ continue;
67
+ out.push({ ...r, validation: { ...(r.validation || {}), subFields: narrow(r.validation?.subFields || [], d.validation?.subFields || []) } });
68
+ }
69
+ else if (kindOf(r.type) && kindOf(r.type) === kindOf(d.type)) {
70
+ out.push(r);
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ /**
76
+ * Where the fields of the site's sections are defined: EmDash's block types
77
+ * (narrowed to `opts.blocks` when given), else `opts.blocks` alone; null when
78
+ * neither is there.
79
+ */
80
+ export async function loadBlockSchema(opts) {
81
+ const decl = opts.blocks && typeof opts.blocks === 'object' ? opts.blocks : null;
82
+ const own = (type) => (decl && Object.prototype.hasOwnProperty.call(decl, type) ? declaredFields(decl[type]) : null);
83
+ const types = await source();
84
+ if (types) {
85
+ return {
86
+ types,
87
+ fieldsOf(type, version) {
88
+ const fields = types.get(type)?.versions.find((v) => v.version === version)?.fields || null;
89
+ if (!fields || !decl)
90
+ return fields;
91
+ const mine = own(type);
92
+ return mine ? narrow(fields, mine) : null;
93
+ },
94
+ };
95
+ }
96
+ if (!decl)
97
+ return null;
98
+ return { types: null, fieldsOf: (type) => own(type) };
99
+ }
100
+ /**
101
+ * Why EmDash would refuse `next` as the field's new value (given what it is
102
+ * now), or null when it takes it.
103
+ */
104
+ export async function blocksProblem(field, next, previous, types) {
105
+ try {
106
+ const collection = { fields: [field] };
107
+ await normalizeBlocksData(null, collection, { [field.slug]: next }, { [field.slug]: previous }, {}, true, types);
108
+ return null;
109
+ }
110
+ catch (err) {
111
+ return err instanceof Error ? err.message : String(err);
112
+ }
113
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Fields inside an EmDash `blocks` field (page-builder sections).
3
+ *
4
+ * The site marks them with a dotted path in the field of its edit mark:
5
+ *
6
+ * <blocksField>.<blockKey>.<fieldSlug> a block's own field
7
+ * <blocksField>.<blockKey>.<repeaterSlug>.<index>.<subSlug> a repeater row's field (index from 0)
8
+ *
9
+ * A block is stored as { _type, _version, ...fields, _key } and a repeater as
10
+ * an array of plain objects. What a field is comes from the block type's
11
+ * definition in EmDash (the version the block was written with): only text
12
+ * (`string`, `text`), rich text (`portableText`) and pictures (`image`) can
13
+ * be edited here; links, choices, dates, files, numbers and the rest can't.
14
+ */
15
+ export interface BlockPath {
16
+ /** The entry's blocks field. */
17
+ top: string;
18
+ /** The block's `_key`. */
19
+ key: string;
20
+ /** The field of the block (for a repeater row: the repeater). */
21
+ slug: string;
22
+ /** A repeater row's index and its field. */
23
+ index?: number;
24
+ sub?: string;
25
+ }
26
+ /** A field of a block type, or of a repeater's rows, as EmDash defines it. */
27
+ export interface FieldDef {
28
+ slug: string;
29
+ type: string;
30
+ required?: boolean;
31
+ validation?: {
32
+ maxLength?: number;
33
+ minLength?: number;
34
+ subFields?: FieldDef[];
35
+ } | null;
36
+ }
37
+ /** A block type as EmDash's block type registry gives it. */
38
+ export interface BlockTypeDef {
39
+ slug: string;
40
+ currentVersion: number;
41
+ versions: {
42
+ version: number;
43
+ fields: FieldDef[];
44
+ }[];
45
+ }
46
+ export type BlockTypes = Map<string, BlockTypeDef>;
47
+ /**
48
+ * Where a block's fields are defined: EmDash's block types when they can be
49
+ * read here (`types`), else what the site declared in the plugin's options.
50
+ */
51
+ export interface BlockSchema {
52
+ /** The fields of a block of this type, saved with this version; null when unknown. */
53
+ fieldsOf(type: string, version: unknown): FieldDef[] | null;
54
+ /** EmDash's block types, to check a new value with; null when only declared. */
55
+ types: BlockTypes | null;
56
+ }
57
+ export type Kind = 'text' | 'portableText' | 'image';
58
+ /** What the overlay edits for an EmDash field type; null for every other type. */
59
+ export declare function kindOf(type: string): Kind | null;
60
+ /** What the save must keep to: a text's length, and whether it may be empty. */
61
+ export interface Rules {
62
+ required: boolean;
63
+ maxLength?: number;
64
+ minLength?: number;
65
+ }
66
+ export type Parsed = {
67
+ path: BlockPath;
68
+ } | {
69
+ error: string;
70
+ };
71
+ /** Whether a field names something inside a blocks field (it has a dot). */
72
+ export declare function isPath(field: string): boolean;
73
+ /** The path a dotted field names, or why it can't be one. */
74
+ export declare function parsePath(field: string): Parsed;
75
+ type Obj = Record<string, unknown>;
76
+ /** The block with this `_key` in a blocks value, or null. */
77
+ export declare function findBlock(blocks: unknown, key: string): Obj | null;
78
+ export type Resolved = {
79
+ kind: Kind;
80
+ rules: Rules;
81
+ value: unknown;
82
+ label: string;
83
+ } | {
84
+ missing: true;
85
+ } | {
86
+ refused: string;
87
+ };
88
+ /**
89
+ * The field a path names in an entry's blocks value, by the block type's
90
+ * definition: what it is, what a save must keep to, its value now, and a
91
+ * name for it the AI understands (the block's type and the field's slug).
92
+ */
93
+ export declare function resolve(blocks: unknown, path: BlockPath, schema: BlockSchema): Resolved;
94
+ /** Why a new text can't be saved in a field with these rules, or null. */
95
+ export declare function textProblem(rules: Rules, value: string): string | null;
96
+ /**
97
+ * A copy of the blocks value with the one value at `path` replaced; every
98
+ * other block, key and version as it was. Null when the block or row isn't
99
+ * there.
100
+ */
101
+ export declare function setAt(blocks: unknown, path: BlockPath, value: unknown): unknown[] | null;
102
+ export {};
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Fields inside an EmDash `blocks` field (page-builder sections).
3
+ *
4
+ * The site marks them with a dotted path in the field of its edit mark:
5
+ *
6
+ * <blocksField>.<blockKey>.<fieldSlug> a block's own field
7
+ * <blocksField>.<blockKey>.<repeaterSlug>.<index>.<subSlug> a repeater row's field (index from 0)
8
+ *
9
+ * A block is stored as { _type, _version, ...fields, _key } and a repeater as
10
+ * an array of plain objects. What a field is comes from the block type's
11
+ * definition in EmDash (the version the block was written with): only text
12
+ * (`string`, `text`), rich text (`portableText`) and pictures (`image`) can
13
+ * be edited here; links, choices, dates, files, numbers and the rest can't.
14
+ */
15
+ /** What can be edited here, by EmDash field type. */
16
+ const KINDS = { string: 'text', text: 'text', portableText: 'portableText', image: 'image' };
17
+ /** What the overlay edits for an EmDash field type; null for every other type. */
18
+ export function kindOf(type) {
19
+ return Object.prototype.hasOwnProperty.call(KINDS, type) ? KINDS[type] : null;
20
+ }
21
+ function fits(kind, value) {
22
+ if (kind === 'text')
23
+ return typeof value === 'string';
24
+ if (kind === 'portableText')
25
+ return Array.isArray(value) && value.every((b) => isObject(b) && typeof b._type === 'string');
26
+ return isObject(value);
27
+ }
28
+ // Field slugs and block keys: letters, digits, _ and -; nothing that walks elsewhere.
29
+ const SEGMENT = /^[A-Za-z0-9_-]{1,100}$/;
30
+ const INDEX = /^(0|[1-9][0-9]{0,5})$/;
31
+ const CANT = 'Dette feltet kan ikke endres her.';
32
+ /** Whether a field names something inside a blocks field (it has a dot). */
33
+ export function isPath(field) {
34
+ return field.includes('.');
35
+ }
36
+ /** The path a dotted field names, or why it can't be one. */
37
+ export function parsePath(field) {
38
+ const parts = field.split('.');
39
+ if (parts.length !== 3 && parts.length !== 5)
40
+ return { error: 'Ugyldig felt.' };
41
+ const [top, key, slug, index, sub] = parts;
42
+ if (![top, key, slug].every((s) => SEGMENT.test(s)))
43
+ return { error: 'Ugyldig felt.' };
44
+ if (parts.length === 3)
45
+ return { path: { top, key, slug } };
46
+ if (!INDEX.test(index) || !SEGMENT.test(sub))
47
+ return { error: 'Ugyldig felt.' };
48
+ return { path: { top, key, slug, index: Number(index), sub } };
49
+ }
50
+ function isObject(v) {
51
+ return !!v && typeof v === 'object' && !Array.isArray(v);
52
+ }
53
+ /** The block with this `_key` in a blocks value, or null. */
54
+ export function findBlock(blocks, key) {
55
+ if (!Array.isArray(blocks))
56
+ return null;
57
+ const b = blocks.find((x) => isObject(x) && x._key === key);
58
+ return b || null;
59
+ }
60
+ // `_type`, `_version`, `_key` and the like are EmDash's, never content.
61
+ function defOf(fields, slug) {
62
+ if (slug.startsWith('_') || !Array.isArray(fields))
63
+ return null;
64
+ return fields.find((f) => f && f.slug === slug) || null;
65
+ }
66
+ /**
67
+ * The field a path names in an entry's blocks value, by the block type's
68
+ * definition: what it is, what a save must keep to, its value now, and a
69
+ * name for it the AI understands (the block's type and the field's slug).
70
+ */
71
+ export function resolve(blocks, path, schema) {
72
+ const block = findBlock(blocks, path.key);
73
+ if (!block)
74
+ return { missing: true };
75
+ const type = typeof block._type === 'string' && SEGMENT.test(block._type) ? block._type : null;
76
+ const fields = type ? schema.fieldsOf(type, block._version) : null;
77
+ if (!type || !fields)
78
+ return { refused: CANT };
79
+ const field = defOf(fields, path.slug);
80
+ if (!field)
81
+ return { refused: CANT };
82
+ let def = field;
83
+ let value = block[path.slug];
84
+ let label = `${type}.${field.slug}`;
85
+ if (path.sub !== undefined) {
86
+ // Only a repeater has rows; rich text is an array too, but of blocks.
87
+ if (field.type !== 'repeater')
88
+ return { refused: CANT };
89
+ const sub = defOf(field.validation?.subFields, path.sub);
90
+ if (!sub)
91
+ return { refused: CANT };
92
+ const rows = block[path.slug];
93
+ const row = Array.isArray(rows) && path.index < rows.length ? rows[path.index] : undefined;
94
+ if (!isObject(row))
95
+ return { refused: 'Fant ikke raden i seksjonen. Last siden på nytt.' };
96
+ def = sub;
97
+ value = row[path.sub];
98
+ label = `${label}.${sub.slug}`;
99
+ }
100
+ const kind = kindOf(def.type);
101
+ if (!kind)
102
+ return { refused: CANT };
103
+ // What is there must be what the field holds (EmDash checks its own writes; a declared field is trusted less).
104
+ if (value !== null && value !== undefined && !fits(kind, value))
105
+ return { refused: CANT };
106
+ const v = def.validation || {};
107
+ return {
108
+ kind,
109
+ rules: {
110
+ required: !!def.required,
111
+ ...(typeof v.maxLength === 'number' ? { maxLength: v.maxLength } : {}),
112
+ ...(typeof v.minLength === 'number' ? { minLength: v.minLength } : {}),
113
+ },
114
+ value,
115
+ label,
116
+ };
117
+ }
118
+ /** Why a new text can't be saved in a field with these rules, or null. */
119
+ export function textProblem(rules, value) {
120
+ if (!value.trim())
121
+ return 'Feltet kan ikke være tomt.';
122
+ const length = value.length;
123
+ if (rules.maxLength !== undefined && length > rules.maxLength)
124
+ return `Teksten er for lang: høyst ${rules.maxLength} tegn (nå ${length}).`;
125
+ if (rules.minLength !== undefined && length < rules.minLength)
126
+ return `Teksten er for kort: minst ${rules.minLength} tegn.`;
127
+ return null;
128
+ }
129
+ /**
130
+ * A copy of the blocks value with the one value at `path` replaced; every
131
+ * other block, key and version as it was. Null when the block or row isn't
132
+ * there.
133
+ */
134
+ export function setAt(blocks, path, value) {
135
+ if (!Array.isArray(blocks))
136
+ return null;
137
+ const out = structuredClone(blocks);
138
+ const block = findBlock(out, path.key);
139
+ if (!block)
140
+ return null;
141
+ if (path.sub === undefined) {
142
+ block[path.slug] = structuredClone(value);
143
+ return out;
144
+ }
145
+ const rows = block[path.slug];
146
+ const row = Array.isArray(rows) ? rows[path.index] : undefined;
147
+ if (!isObject(row))
148
+ return null;
149
+ row[path.sub] = structuredClone(value);
150
+ return out;
151
+ }
@@ -3,10 +3,13 @@
3
3
  * request that would be refused at save is refused before credits are spent:
4
4
  * the request names an entry and a field, the site allows that field, the
5
5
  * field is a kind the overlay edits, the entry exists, and the caller may
6
- * change it.
6
+ * change it (and for a field inside a blocks field, that it is there and is
7
+ * text, rich text or a picture).
7
8
  */
8
9
  import type { RouteContext } from 'emdash';
9
10
  import type { VibeEditorOptions } from '../types.js';
11
+ import { type BlockPath, type BlockSchema, type Rules } from './blocks.js';
12
+ import { type BlocksField } from './blockTypes.js';
10
13
  import { json } from './respond.js';
11
14
  export interface EntryItem {
12
15
  id: string;
@@ -19,15 +22,43 @@ export interface EntryItem {
19
22
  description?: string | null;
20
23
  } | null;
21
24
  }
25
+ /**
26
+ * A field the request may change. `current` is its value as edit mode shows
27
+ * it and `label` a name for it the AI understands. For a field inside a
28
+ * blocks field, `blocks` says where in it (see blocks.ts), what a save must
29
+ * keep to and where its fields are defined; `field` is the
30
+ * blocks field the entry is written back with.
31
+ */
22
32
  export type Checked = {
23
33
  item: EntryItem;
24
34
  kind: string;
25
35
  collection: string;
26
36
  id: string;
27
37
  field: string;
38
+ current: unknown;
39
+ label: string;
40
+ blocks?: {
41
+ path: BlockPath;
42
+ rules: Rules;
43
+ schema: BlockSchema;
44
+ field: BlocksField;
45
+ };
28
46
  };
47
+ export declare const NO_DRAFT = "Innlegget har upubliserte endringer som ikke kan leses her. Rediger i EmDash.";
48
+ export declare const NOT_FOUND = "Fant ikke innholdet.";
49
+ /** The entry as edit mode shows it, read again for a write; or why it can't be. */
50
+ export declare function readEntry(ctx: RouteContext, collection: string, id: string): Promise<EntryItem | {
51
+ error: string;
52
+ status: number;
53
+ }>;
29
54
  export declare function str(v: unknown): string;
30
- /** The entry and its field's type, or the answer that refuses the request. */
55
+ /**
56
+ * The entry and its field's type, or the answer that refuses the request.
57
+ * A dotted field names a field inside a blocks field: the blocks field must
58
+ * be allowed, the block must be in the entry, and the field and its type
59
+ * come from the block type's definition in EmDash; only text, rich text and
60
+ * pictures can be edited.
61
+ */
31
62
  export declare function checkField(ctx: RouteContext, opts: VibeEditorOptions, input: Record<string, unknown>, kinds: string[]): Promise<Checked | {
32
63
  refused: ReturnType<typeof json>;
33
64
  }>;
package/dist/lib/guard.js CHANGED
@@ -1,6 +1,12 @@
1
1
  import { isAllowed, mayEdit } from './access.js';
2
+ import { isPath, parsePath, resolve } from './blocks.js';
3
+ import { loadBlockSchema } from './blockTypes.js';
2
4
  import { json } from './respond.js';
3
- const NO_DRAFT = 'Innlegget har upubliserte endringer som ikke kan leses her. Rediger i EmDash.';
5
+ export const NO_DRAFT = 'Innlegget har upubliserte endringer som ikke kan leses her. Rediger i EmDash.';
6
+ export const NOT_FOUND = 'Fant ikke innholdet.';
7
+ const GONE = 'Fant ikke seksjonen. Last siden på nytt.';
8
+ const CANT = 'Dette feltet kan ikke endres her.';
9
+ const NO_TYPES = 'Seksjonene kan ikke redigeres her ennå: nettstedet har ikke oppgitt hvilke felt i dem som kan endres.';
4
10
  /**
5
11
  * The entry as edit mode shows it. EmDash's get() returns the entry's own row
6
12
  * (what is published), while update() writes onto its draft; so a field is
@@ -18,31 +24,77 @@ async function withDraft(ctx, collection, item) {
18
24
  return null;
19
25
  return { ...item, data: { ...item.data, ...draft.data } };
20
26
  }
27
+ /** The entry as edit mode shows it, read again for a write; or why it can't be. */
28
+ export async function readEntry(ctx, collection, id) {
29
+ const row = (await ctx.content.get(collection, id));
30
+ if (!row)
31
+ return { error: NOT_FOUND, status: 404 };
32
+ return (await withDraft(ctx, collection, row)) || { error: NO_DRAFT, status: 409 };
33
+ }
21
34
  export function str(v) {
22
35
  return typeof v === 'string' ? v : typeof v === 'number' ? String(v) : '';
23
36
  }
24
- /** The entry and its field's type, or the answer that refuses the request. */
37
+ /**
38
+ * The entry and its field's type, or the answer that refuses the request.
39
+ * A dotted field names a field inside a blocks field: the blocks field must
40
+ * be allowed, the block must be in the entry, and the field and its type
41
+ * come from the block type's definition in EmDash; only text, rich text and
42
+ * pictures can be edited.
43
+ */
25
44
  export async function checkField(ctx, opts, input, kinds) {
26
45
  const collection = str(input.collection);
27
46
  const id = str(input.id);
28
- const field = str(input.field);
29
- if (!collection || !id || !field)
47
+ const requested = str(input.field);
48
+ if (!collection || !id || !requested)
30
49
  return { refused: json({ error: 'collection, id og field kreves.' }, 400) };
50
+ let path;
51
+ if (isPath(requested)) {
52
+ const parsed = parsePath(requested);
53
+ if ('error' in parsed)
54
+ return { refused: json({ error: parsed.error }, 400) };
55
+ path = parsed.path;
56
+ }
57
+ const field = path ? path.top : requested;
31
58
  if (!isAllowed(opts.allow, collection, field))
32
- return { refused: json({ error: 'Dette feltet kan ikke endres her.' }, 403) };
59
+ return { refused: json({ error: CANT }, 403) };
33
60
  const schema = await ctx.schema.getCollection(collection);
34
- const kind = schema?.fields.find((f) => f.slug === field)?.type || '';
35
- if (!kinds.includes(kind))
36
- return { refused: json({ error: 'Dette feltet kan ikke endres her.' }, 400) };
61
+ const schemaField = schema?.fields.find((f) => f.slug === field);
62
+ const type = schemaField?.type || '';
63
+ if (path ? type !== 'blocks' : !kinds.includes(type))
64
+ return { refused: json({ error: CANT }, 400) };
37
65
  const row = (await ctx.content.get(collection, id));
38
66
  if (!row)
39
- return { refused: json({ error: 'Fant ikke innholdet.' }, 404) };
67
+ return { refused: json({ error: NOT_FOUND }, 404) };
40
68
  if (!mayEdit(ctx.user, row))
41
69
  return { refused: json({ error: 'Du har ikke tilgang til å endre dette.' }, 403) };
42
70
  const item = await withDraft(ctx, collection, row);
43
71
  if (!item)
44
72
  return { refused: json({ error: NO_DRAFT }, 409) };
45
- return { item, kind, collection, id, field };
73
+ if (!path)
74
+ return { item, kind: type, collection, id, field, current: item.data[field], label: `${collection}.${field}` };
75
+ const blockSchema = await loadBlockSchema(opts);
76
+ if (!blockSchema) {
77
+ ctx.log.warn("EmDash's block types can't be read from the plugin here: declare the editable fields in vibeEditor({ blocks }), or bundle the plugin (vite.ssr.noExternal)");
78
+ return { refused: json({ error: NO_TYPES }, 503) };
79
+ }
80
+ const found = resolve(item.data[field], path, blockSchema);
81
+ if ('missing' in found)
82
+ return { refused: json({ error: GONE }, 409) };
83
+ if ('refused' in found)
84
+ return { refused: json({ error: found.refused }, 400) };
85
+ if (!kinds.includes(found.kind))
86
+ return { refused: json({ error: found.kind === 'image' ? 'Feltet er et bilde.' : 'Feltet er ikke et bilde.' }, 422) };
87
+ const blocksField = { slug: field, type, ...(schemaField && 'validation' in schemaField ? { validation: schemaField.validation } : {}) };
88
+ return {
89
+ item,
90
+ kind: found.kind,
91
+ collection,
92
+ id,
93
+ field,
94
+ current: found.value,
95
+ label: `${collection}.${found.label}`,
96
+ blocks: { path, rules: found.rules, schema: blockSchema, field: blocksField },
97
+ };
46
98
  }
47
99
  /**
48
100
  * The same checks for an entry's SEO block: the request names an entry, the
@@ -3,16 +3,41 @@
3
3
  * again with the change, unless it already had unpublished changes of its
4
4
  * own: EmDash keeps one draft per entry, and publishing it would publish
5
5
  * those too. Then the change waits as a draft and the answer says so.
6
+ *
7
+ * The new value is made from the entry as it is just before the write (an
8
+ * AI call may have taken a while, and someone may have changed the field
9
+ * meanwhile), by the route's `update`: a picture's alt text goes on the
10
+ * picture there now, not on the one there when the request came.
11
+ *
12
+ * A field inside a blocks field is written as the whole blocks field, with
13
+ * only that one value changed and every block's key, version and type kept,
14
+ * after EmDash's own check of blocks values has accepted it (when EmDash's
15
+ * block types can be read here; see blockTypes.ts).
16
+ *
17
+ * EmDash gives plugins no compare-and-set write, so a save made in EmDash's
18
+ * admin in the few milliseconds between the read and the write can still be
19
+ * overwritten.
6
20
  */
7
21
  import type { RouteContext } from 'emdash';
8
22
  import type { VibeEditorOptions } from '../types.js';
9
- import type { Checked } from './guard.js';
23
+ import { type Checked } from './guard.js';
10
24
  import { json } from './respond.js';
11
- /** Stores `value` in the checked field; the answer's body, or the answer for a failed write. */
12
- export declare function storeField(ctx: RouteContext, opts: VibeEditorOptions, checked: Checked, value: unknown): Promise<{
25
+ /** A route's `update` refusing the change, with the answer's status and message. */
26
+ export declare class Refusal {
27
+ readonly status: number;
28
+ readonly error: string;
29
+ constructor(status: number, error: string);
30
+ }
31
+ type Refused = {
32
+ refused: ReturnType<typeof json>;
33
+ };
34
+ /**
35
+ * Stores what `update` makes of the checked field's value now; the answer's
36
+ * body, or the answer for a refused or failed write.
37
+ */
38
+ export declare function storeField(ctx: RouteContext, opts: VibeEditorOptions, checked: Checked, update: (current: unknown) => unknown): Promise<{
13
39
  ok: true;
14
40
  published: boolean;
15
41
  warning?: string;
16
- } | {
17
- refused: ReturnType<typeof json>;
18
- }>;
42
+ } | Refused>;
43
+ export {};