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.
- package/README.md +92 -2
- package/dist/generated/clientAssets.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/lib/blockTypes.d.ts +22 -0
- package/dist/lib/blockTypes.js +113 -0
- package/dist/lib/blocks.d.ts +102 -0
- package/dist/lib/blocks.js +151 -0
- package/dist/lib/guard.d.ts +33 -2
- package/dist/lib/guard.js +62 -10
- package/dist/lib/store.d.ts +31 -6
- package/dist/lib/store.js +55 -6
- package/dist/routes/generate-image.js +4 -5
- package/dist/routes/save.d.ts +2 -1
- package/dist/routes/save.js +16 -15
- package/dist/routes/status.js +4 -2
- package/dist/routes/suggest.js +7 -3
- package/dist/routes/swap-image.d.ts +2 -1
- package/dist/routes/swap-image.js +10 -6
- package/dist/types.d.ts +16 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/lib/store.js
CHANGED
|
@@ -1,16 +1,65 @@
|
|
|
1
1
|
import { mayPublish } from './access.js';
|
|
2
2
|
import { publishIfLive } from './fields.js';
|
|
3
|
+
import { resolve, setAt, textProblem } from './blocks.js';
|
|
4
|
+
import { blocksProblem } from './blockTypes.js';
|
|
5
|
+
import { readEntry } from './guard.js';
|
|
3
6
|
import { json } from './respond.js';
|
|
4
7
|
const PENDING_DRAFT = 'Lagret som utkast: innlegget har andre endringer som ikke er publisert. Publiser i EmDash.';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
+
const GONE = 'Seksjonen er endret eller fjernet i mellomtiden. Last siden på nytt.';
|
|
9
|
+
/** A route's `update` refusing the change, with the answer's status and message. */
|
|
10
|
+
export class Refusal {
|
|
11
|
+
constructor(status, error) {
|
|
12
|
+
this.status = status;
|
|
13
|
+
this.error = error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
const refuse = (status, error) => ({ refused: json({ error }, status) });
|
|
17
|
+
/**
|
|
18
|
+
* Stores what `update` makes of the checked field's value now; the answer's
|
|
19
|
+
* body, or the answer for a refused or failed write.
|
|
20
|
+
*/
|
|
21
|
+
export async function storeField(ctx, opts, checked, update) {
|
|
22
|
+
const { collection, id, field, blocks } = checked;
|
|
23
|
+
const fresh = await readEntry(ctx, collection, id);
|
|
24
|
+
if ('error' in fresh)
|
|
25
|
+
return refuse(fresh.status, fresh.error);
|
|
26
|
+
const item = fresh;
|
|
27
|
+
let data;
|
|
28
|
+
if (!blocks) {
|
|
29
|
+
const next = update(item.data[field]);
|
|
30
|
+
if (next instanceof Refusal)
|
|
31
|
+
return refuse(next.status, next.error);
|
|
32
|
+
data = { [field]: next };
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const before = item.data[field];
|
|
36
|
+
const found = resolve(before, blocks.path, blocks.schema);
|
|
37
|
+
if (!('kind' in found) || found.kind !== checked.kind)
|
|
38
|
+
return refuse(409, GONE);
|
|
39
|
+
const next = update(found.value);
|
|
40
|
+
if (next instanceof Refusal)
|
|
41
|
+
return refuse(next.status, next.error);
|
|
42
|
+
if (found.kind === 'text') {
|
|
43
|
+
const problem = textProblem(found.rules, typeof next === 'string' ? next : '');
|
|
44
|
+
if (problem)
|
|
45
|
+
return refuse(400, problem);
|
|
46
|
+
}
|
|
47
|
+
const after = setAt(before, blocks.path, next);
|
|
48
|
+
if (!after)
|
|
49
|
+
return refuse(409, GONE);
|
|
50
|
+
const problem = blocks.schema.types ? await blocksProblem(blocks.field, after, before, blocks.schema.types) : null;
|
|
51
|
+
if (problem) {
|
|
52
|
+
ctx.log.warn('EmDash would refuse the blocks value', { collection, id, field, problem });
|
|
53
|
+
return refuse(422, `EmDash godtar ikke endringen (${problem}).`);
|
|
54
|
+
}
|
|
55
|
+
data = { [field]: after };
|
|
56
|
+
}
|
|
8
57
|
const wasPublished = item.status === 'published';
|
|
9
58
|
const otherDraft = !!item.draftRevisionId;
|
|
10
59
|
// The plugin declares content:write and content:publish, so EmDash gives it these.
|
|
11
60
|
const content = ctx.content;
|
|
12
61
|
try {
|
|
13
|
-
await content.update(collection, id,
|
|
62
|
+
await content.update(collection, id, data);
|
|
14
63
|
let published = false;
|
|
15
64
|
if (opts.publish !== false && !otherDraft && mayPublish(ctx.user, item)) {
|
|
16
65
|
published = await publishIfLive(content, collection, id, wasPublished);
|
|
@@ -18,7 +67,7 @@ export async function storeField(ctx, opts, checked, value) {
|
|
|
18
67
|
return { ok: true, published, ...(wasPublished && otherDraft ? { warning: PENDING_DRAFT } : {}) };
|
|
19
68
|
}
|
|
20
69
|
catch (err) {
|
|
21
|
-
ctx.log.warn('Saving a field failed', { collection, id, field, error: err instanceof Error ? err.message : String(err) });
|
|
22
|
-
return
|
|
70
|
+
ctx.log.warn('Saving a field failed', { collection, id, field: blocks ? `${field}.${blocks.path.key}` : field, error: err instanceof Error ? err.message : String(err) });
|
|
71
|
+
return refuse(409, 'Klarte ikke å lagre. Prøv igjen.');
|
|
23
72
|
}
|
|
24
73
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { resolveKey } from '../lib/access.js';
|
|
2
2
|
import { checkField, str } from '../lib/guard.js';
|
|
3
|
-
import { asImage, libraryId, libraryValue } from '../lib/images.js';
|
|
3
|
+
import { asImage, FILE_ROUTE, libraryId, libraryValue } from '../lib/images.js';
|
|
4
4
|
import { json } from '../lib/respond.js';
|
|
5
5
|
import { createService, failure, REFERENCE_MAX } from '../lib/service.js';
|
|
6
6
|
import { storeField } from '../lib/store.js';
|
|
@@ -26,7 +26,7 @@ export function generateImageRoute(opts) {
|
|
|
26
26
|
const checked = await checkField(ctx, opts, input, ['image']);
|
|
27
27
|
if ('refused' in checked)
|
|
28
28
|
return checked.refused;
|
|
29
|
-
const current = asImage(checked.
|
|
29
|
+
const current = asImage(checked.current);
|
|
30
30
|
const media = ctx.media;
|
|
31
31
|
let reference = null;
|
|
32
32
|
const refId = libraryId(current);
|
|
@@ -65,9 +65,8 @@ export function generateImageRoute(opts) {
|
|
|
65
65
|
}
|
|
66
66
|
// EmDash measured the picture on upload; its size goes in the field as for any library picture.
|
|
67
67
|
const item = (await media.get(uploaded.mediaId).catch(() => null)) || { id: uploaded.mediaId, mimeType: picture.mime };
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
return 'refused' in stored ? stored.refused : json({ ...stored, url: value.src, mode: picture.mode });
|
|
68
|
+
const stored = await storeField(ctx, opts, checked, (now) => libraryValue(now, item, uploaded.storageKey, asImage(now).alt || ''));
|
|
69
|
+
return 'refused' in stored ? stored.refused : json({ ...stored, url: FILE_ROUTE + uploaded.storageKey, mode: picture.mode });
|
|
71
70
|
},
|
|
72
71
|
};
|
|
73
72
|
}
|
package/dist/routes/save.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ import type { VibeEditorOptions } from '../types.js';
|
|
|
4
4
|
* POST save { collection, id, field, value, original? }
|
|
5
5
|
* POST save { collection, id, field, alt } (an image's alt text)
|
|
6
6
|
* Saves a text, or a picture's alt text, to the entry; see storeField for
|
|
7
|
-
* when it goes live.
|
|
7
|
+
* when it goes live. `field` may be a path inside a blocks field
|
|
8
|
+
* ("sections.<blockKey>.title"); see lib/blocks.ts.
|
|
8
9
|
*/
|
|
9
10
|
export declare function saveRoute(opts: VibeEditorOptions): {
|
|
10
11
|
permission: "content:edit_own";
|
package/dist/routes/save.js
CHANGED
|
@@ -2,13 +2,14 @@ import { applyText } from '../lib/fields.js';
|
|
|
2
2
|
import { checkField, str } from '../lib/guard.js';
|
|
3
3
|
import { asImage } from '../lib/images.js';
|
|
4
4
|
import { json } from '../lib/respond.js';
|
|
5
|
-
import { storeField } from '../lib/store.js';
|
|
5
|
+
import { Refusal, storeField } from '../lib/store.js';
|
|
6
6
|
import { TEXT_KINDS } from './suggest.js';
|
|
7
7
|
/**
|
|
8
8
|
* POST save { collection, id, field, value, original? }
|
|
9
9
|
* POST save { collection, id, field, alt } (an image's alt text)
|
|
10
10
|
* Saves a text, or a picture's alt text, to the entry; see storeField for
|
|
11
|
-
* when it goes live.
|
|
11
|
+
* when it goes live. `field` may be a path inside a blocks field
|
|
12
|
+
* ("sections.<blockKey>.title"); see lib/blocks.ts.
|
|
12
13
|
*/
|
|
13
14
|
export function saveRoute(opts) {
|
|
14
15
|
return {
|
|
@@ -21,19 +22,19 @@ export function saveRoute(opts) {
|
|
|
21
22
|
const checked = await checkField(ctx, opts, input, isAlt ? ['image'] : TEXT_KINDS);
|
|
22
23
|
if ('refused' in checked)
|
|
23
24
|
return checked.refused;
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
25
|
+
const alt = str(input.alt).trim();
|
|
26
|
+
const original = str(input.original);
|
|
27
|
+
const value = str(input.value);
|
|
28
|
+
// Made from the field as it is at the write, so a change made meanwhile isn't undone.
|
|
29
|
+
const stored = await storeField(ctx, opts, checked, (current) => {
|
|
30
|
+
if (isAlt) {
|
|
31
|
+
if (!asImage(current).src && !asImage(current).id)
|
|
32
|
+
return new Refusal(400, 'Feltet har ikke noe bilde.');
|
|
33
|
+
return { ...asImage(current), alt };
|
|
34
|
+
}
|
|
35
|
+
const next = applyText(current, original, value, checked.kind);
|
|
36
|
+
return next === null ? new Refusal(400, 'Fant ikke teksten i innholdet. Last siden på nytt.') : next;
|
|
37
|
+
});
|
|
37
38
|
return 'refused' in stored ? stored.refused : json(stored);
|
|
38
39
|
},
|
|
39
40
|
};
|
package/dist/routes/status.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { isAllowed, resolveKey } from '../lib/access.js';
|
|
2
2
|
import { json } from '../lib/respond.js';
|
|
3
3
|
import { createService, NOT_CONNECTED } from '../lib/service.js';
|
|
4
|
-
// Field types the overlay can edit: plain text, rich text (Portable Text) and
|
|
5
|
-
|
|
4
|
+
// Field types the overlay can edit: plain text, rich text (Portable Text) and
|
|
5
|
+
// images, and page-builder sections (`blocks`), whose texts and images are
|
|
6
|
+
// marked with a dotted path (see lib/blocks.ts).
|
|
7
|
+
export const EDITABLE_TYPES = ['string', 'text', 'portableText', 'image', 'blocks'];
|
|
6
8
|
/**
|
|
7
9
|
* Per collection: whether its SEO title and description can be edited here,
|
|
8
10
|
* and its editable fields with their types. The allow list names SEO as the
|
package/dist/routes/suggest.js
CHANGED
|
@@ -20,7 +20,7 @@ export function suggestRoute(opts) {
|
|
|
20
20
|
response: 'raw',
|
|
21
21
|
handler: async (ctx) => {
|
|
22
22
|
const input = (ctx.input || {});
|
|
23
|
-
|
|
23
|
+
let instruction = str(input.instruction).trim();
|
|
24
24
|
if (!instruction)
|
|
25
25
|
return json({ error: 'Skriv hva som skal endres.' }, 400);
|
|
26
26
|
let original = str(input.original);
|
|
@@ -39,7 +39,7 @@ export function suggestRoute(opts) {
|
|
|
39
39
|
const checked = await checkField(ctx, opts, input, TEXT_KINDS);
|
|
40
40
|
if ('refused' in checked)
|
|
41
41
|
return checked.refused;
|
|
42
|
-
const current = checked.
|
|
42
|
+
const current = checked.current;
|
|
43
43
|
if (checked.kind === 'portableText') {
|
|
44
44
|
if (findBlock(current, original) < 0)
|
|
45
45
|
return json({ error: 'Fant ikke teksten i innholdet. Last siden på nytt.' }, 400);
|
|
@@ -47,7 +47,11 @@ export function suggestRoute(opts) {
|
|
|
47
47
|
else {
|
|
48
48
|
original = textOf(current) || original;
|
|
49
49
|
}
|
|
50
|
-
field =
|
|
50
|
+
field = checked.label;
|
|
51
|
+
// A text in a section may have a length limit in EmDash; the AI is told it.
|
|
52
|
+
const max = checked.blocks?.rules.maxLength;
|
|
53
|
+
if (max !== undefined && checked.kind === 'text')
|
|
54
|
+
instruction += ` (Høyst ${max} tegn.)`;
|
|
51
55
|
}
|
|
52
56
|
try {
|
|
53
57
|
const service = createService({ apiKey: await resolveKey(ctx), baseUrl: opts.baseUrl, fetch: ctx.http.fetch.bind(ctx.http) });
|
|
@@ -5,7 +5,8 @@ import type { VibeEditorOptions } from '../types.js';
|
|
|
5
5
|
* Puts another picture in an image field. `uploadId` is either a pick from
|
|
6
6
|
* EmDash's media library, "<mediaId>|<storageKey>" (the overlay lists the
|
|
7
7
|
* library through EmDash's own admin API, which has the storage key), or an
|
|
8
|
-
* http(s) address the editor pasted, kept as it is
|
|
8
|
+
* http(s) address the editor pasted, kept as it is (as EmDash's own editor
|
|
9
|
+
* keeps one: { id: '', provider: 'external', src }).
|
|
9
10
|
*/
|
|
10
11
|
export declare function swapImageRoute(opts: VibeEditorOptions): {
|
|
11
12
|
permission: "content:edit_own";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { checkField, str } from '../lib/guard.js';
|
|
2
|
-
import { asImage, isStorageKey, keyFits, libraryValue, parsePick, webAddress } from '../lib/images.js';
|
|
2
|
+
import { asImage, FILE_ROUTE, isStorageKey, keyFits, libraryValue, parsePick, webAddress } from '../lib/images.js';
|
|
3
3
|
import { json } from '../lib/respond.js';
|
|
4
4
|
import { storeField } from '../lib/store.js';
|
|
5
5
|
/**
|
|
@@ -7,7 +7,8 @@ import { storeField } from '../lib/store.js';
|
|
|
7
7
|
* Puts another picture in an image field. `uploadId` is either a pick from
|
|
8
8
|
* EmDash's media library, "<mediaId>|<storageKey>" (the overlay lists the
|
|
9
9
|
* library through EmDash's own admin API, which has the storage key), or an
|
|
10
|
-
* http(s) address the editor pasted, kept as it is
|
|
10
|
+
* http(s) address the editor pasted, kept as it is (as EmDash's own editor
|
|
11
|
+
* keeps one: { id: '', provider: 'external', src }).
|
|
11
12
|
*/
|
|
12
13
|
export function swapImageRoute(opts) {
|
|
13
14
|
return {
|
|
@@ -20,8 +21,8 @@ export function swapImageRoute(opts) {
|
|
|
20
21
|
const checked = await checkField(ctx, opts, input, ['image']);
|
|
21
22
|
if ('refused' in checked)
|
|
22
23
|
return checked.refused;
|
|
23
|
-
const current = checked.item.data[checked.field];
|
|
24
24
|
let value;
|
|
25
|
+
let url;
|
|
25
26
|
const pick = parsePick(uploadId);
|
|
26
27
|
const address = pick ? null : webAddress(uploadId);
|
|
27
28
|
if (pick) {
|
|
@@ -32,16 +33,19 @@ export function swapImageRoute(opts) {
|
|
|
32
33
|
return json({ error: 'Fant ikke bildet i mediebiblioteket.' }, 404);
|
|
33
34
|
if (!keyFits(pick.storageKey, media))
|
|
34
35
|
return json({ error: 'Ugyldig bilde.' }, 400);
|
|
35
|
-
value = libraryValue(current, media, pick.storageKey);
|
|
36
|
+
value = (current) => libraryValue(current, media, pick.storageKey);
|
|
37
|
+
url = FILE_ROUTE + pick.storageKey;
|
|
36
38
|
}
|
|
37
39
|
else if (address) {
|
|
38
|
-
|
|
40
|
+
// The shape EmDash's own editor gives a picture by address: its schema wants an id.
|
|
41
|
+
value = (current) => ({ id: '', provider: 'external', src: address, alt: asImage(current).alt || '' });
|
|
42
|
+
url = address;
|
|
39
43
|
}
|
|
40
44
|
else {
|
|
41
45
|
return json({ error: 'Velg et bilde fra mediebiblioteket eller lim inn en bildeadresse (https://…).' }, 400);
|
|
42
46
|
}
|
|
43
47
|
const stored = await storeField(ctx, opts, checked, value);
|
|
44
|
-
return 'refused' in stored ? stored.refused : json({ ...stored, url
|
|
48
|
+
return 'refused' in stored ? stored.refused : json({ ...stored, url });
|
|
45
49
|
},
|
|
46
50
|
};
|
|
47
51
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -5,6 +5,14 @@ export interface EditableField {
|
|
|
5
5
|
/** Field slug, e.g. `title`; `seo` for the entry's title and description in search engines. */
|
|
6
6
|
field: string;
|
|
7
7
|
}
|
|
8
|
+
/** A text field inside a section, with what EmDash requires of it. */
|
|
9
|
+
export interface BlockTextField {
|
|
10
|
+
type: 'text';
|
|
11
|
+
maxLength?: number;
|
|
12
|
+
required?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** What a field inside a section is: text, rich text or a picture; a repeater as its rows' fields. */
|
|
15
|
+
export type BlockFieldDeclaration = 'text' | 'portableText' | 'image' | BlockTextField | Record<string, 'text' | 'image' | BlockTextField>;
|
|
8
16
|
/**
|
|
9
17
|
* What `vibeEditor()` takes in astro.config. These are written into the
|
|
10
18
|
* site's build, so they never hold a key: the site connects to Vibe Editor
|
|
@@ -15,6 +23,14 @@ export interface VibeEditorOptions {
|
|
|
15
23
|
baseUrl?: string;
|
|
16
24
|
/** Restrict what can be edited. Default: every text and image field, and the SEO title and description, of every collection. */
|
|
17
25
|
allow?: EditableField[] | null;
|
|
26
|
+
/**
|
|
27
|
+
* The fields inside page-builder sections that can be edited, per block
|
|
28
|
+
* type: `{ hero: { title: 'text', bilde: 'image', steg: { tittel: 'text' } } }`.
|
|
29
|
+
* When EmDash's block types can be read here they decide what each field
|
|
30
|
+
* is, and this only narrows it; when they can't, only what is declared here
|
|
31
|
+
* can be edited. Default: every text, rich text and picture field.
|
|
32
|
+
*/
|
|
33
|
+
blocks?: Record<string, Record<string, BlockFieldDeclaration>> | null;
|
|
18
34
|
/** Publish an entry again after a change, when it was published before. Default: true. */
|
|
19
35
|
publish?: boolean;
|
|
20
36
|
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export declare const PLUGIN_ID = "vibe-editor";
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.2.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export const PLUGIN_ID = 'vibe-editor';
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '0.2.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "emdash-vibe-editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Front-end \"vibe editing\" for EmDash CMS: click text and images on your live site and edit them with AI, paid from your Vibe Editor credits.",
|
|
5
5
|
"license": "GPL-2.0-or-later",
|
|
6
6
|
"author": "GetOnNet",
|