@bygga.dev/editor 1.3.0 → 1.5.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 +557 -56
- package/{TextEditor-JwRYUlJJ.js → TextEditor-CIqROgaO.js} +537 -387
- package/bygga-editor.js +1087 -1004
- package/element.d.ts +21 -2
- package/package.json +1 -1
- package/{urls-B4ECLEvt.js → urls-DoSoJydy.js} +3638 -3536
package/README.md
CHANGED
|
@@ -10,6 +10,15 @@ styles can't leak out.
|
|
|
10
10
|
|
|
11
11
|
The editor is built and maintained by [Memlist CRM](https://memlist.se)
|
|
12
12
|
|
|
13
|
+
**Contents** — [Install](#install) · [Licence key](#you-need-a-licence-key) ·
|
|
14
|
+
[Quick start](#quick-start) · [Host contract](#host-contract) ·
|
|
15
|
+
[Document kinds](#document-kinds-email-and-page) · [Saved blocks](#saved-blocks) ·
|
|
16
|
+
[Block plugins](#block-plugins) · [AI assistance](#ai-assistance) ·
|
|
17
|
+
[Dynamic content](#dynamic-content-per-recipient-blocks) ·
|
|
18
|
+
[Sending & publishing](#sending-and-publishing) · [Compile API](#the-compile-api) ·
|
|
19
|
+
[Theming](#theming) · [TypeScript](#typescript) ·
|
|
20
|
+
[Framework integration](#framework-integration)
|
|
21
|
+
|
|
13
22
|
## Install
|
|
14
23
|
|
|
15
24
|
```sh
|
|
@@ -60,6 +69,11 @@ editor.config = {
|
|
|
60
69
|
async uploadImage(blob) {
|
|
61
70
|
return await uploadToYourStorage(blob)
|
|
62
71
|
},
|
|
72
|
+
// Optional push-save. `compiled` is this version's sendable HTML — or null if the
|
|
73
|
+
// compile did not happen. Store the two together; see Sending and publishing.
|
|
74
|
+
async saveDocument(document, compiled) {
|
|
75
|
+
await api.saveTemplate({ document, compiled })
|
|
76
|
+
},
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
editor.locale = 'en'
|
|
@@ -74,47 +88,111 @@ editor.style.height = '100vh' // it is display:block/height:100% — give it a s
|
|
|
74
88
|
document.body.append(editor) // config is set, so it's safe to connect
|
|
75
89
|
```
|
|
76
90
|
|
|
91
|
+
**Sizing.** The element is `display: block; height: 100%`, which degrades to `auto`
|
|
92
|
+
if nothing sizes it — an unsized parent yields a collapsed editor. Give it a height
|
|
93
|
+
(a `100vh` container, a flex child with `min-height: 0`, or an explicit pixel height).
|
|
94
|
+
|
|
77
95
|
## Host contract
|
|
78
96
|
|
|
97
|
+
### `license-key` attribute
|
|
98
|
+
Your licence key (see [You need a licence key](#you-need-a-licence-key)). Reactive: changing it
|
|
99
|
+
re-activates, so a host that fetches its key can set it after the element is connected. Omit it on
|
|
100
|
+
`localhost`, which is licensed without one.
|
|
101
|
+
|
|
79
102
|
### `locale` attribute
|
|
80
103
|
`sv` (default) or `en`. Reactive, and also sets the shadow tree's `lang` for
|
|
81
|
-
assistive technology. Settable as the attribute or the `.locale` property.
|
|
104
|
+
assistive technology. Settable as the attribute or the `.locale` property. An
|
|
105
|
+
unsupported value warns and falls back to the default. The supported set is exported
|
|
106
|
+
as `SUPPORTED_LOCALES` / `DEFAULT_LOCALE`.
|
|
82
107
|
|
|
83
108
|
### `config` property *(required)*
|
|
84
109
|
Host callbacks. Assign it **before** connecting the element (the editor throws at
|
|
85
|
-
mount if `uploadImage` is missing)
|
|
110
|
+
mount if `uploadImage` is missing). It's a plain object, re-read on assignment, so
|
|
111
|
+
you can swap it at runtime.
|
|
86
112
|
|
|
87
113
|
- **`uploadImage(blob: Blob): Promise<string>`** *(required)* — store the image and
|
|
88
|
-
resolve with a public URL. The Blob arrives already compressed and scaled
|
|
114
|
+
resolve with a public URL. The Blob arrives already compressed and scaled (content
|
|
115
|
+
images to 600px wide, full-width backgrounds to 1920px, JPEG quality 0.82), so don't
|
|
89
116
|
re-encode it; it may be called concurrently. The URL is written into the document
|
|
90
117
|
and then into sent email, so it must be absolute, unauthenticated, and permanent (a
|
|
91
118
|
presigned GET that expires will break in the inbox). Rejecting shows the user an
|
|
92
119
|
alert, keeps the block's previous image, and reports through `onImageError`.
|
|
93
|
-
- **`saveDocument(document: Document): Promise<void>`**
|
|
94
|
-
invoked by `save()`; on success the editor marks the
|
|
95
|
-
|
|
120
|
+
- **`saveDocument(document: Document, compiled: string | null): Promise<void>`**
|
|
121
|
+
*(optional)* — push-save hook invoked by `save()`; on success the editor marks the
|
|
122
|
+
document saved. `compiled` is this version's sendable HTML, or `null` when the
|
|
123
|
+
compile did not happen (our server unreachable, or the licence's compile quota
|
|
124
|
+
spent). **Persist the pair together or persist nothing** — storing them separately
|
|
125
|
+
and updating only the non-null one is how a document ends up beside the previous
|
|
126
|
+
version's HTML, and the send goes out with content the author no longer sees.
|
|
127
|
+
Rejecting propagates out of `save()` and leaves the document dirty.
|
|
96
128
|
- **`onImageError(error: unknown): void`** *(optional)* — called after the editor has
|
|
97
|
-
already told the user and recovered, for your own logging.
|
|
129
|
+
already told the user and recovered, for your own logging. Without it, an upload
|
|
130
|
+
failure is invisible to your backend.
|
|
131
|
+
- **`saveBlock(saved: SavedBlock): Promise<void>`** *(optional)* — persist a Saved
|
|
132
|
+
block. Supplying it is what puts the Save control on a block's toolbar. See
|
|
133
|
+
[Saved blocks](#saved-blocks).
|
|
134
|
+
- **`deleteSavedBlock(id: string): Promise<void>`** *(optional)* — delete the Saved
|
|
135
|
+
block with that id. Supplying it puts a delete control on each library entry.
|
|
136
|
+
- **`plugins: readonly BlockPlugin[]`** *(optional)* — custom block types this embed
|
|
137
|
+
registers. See [Block plugins](#block-plugins).
|
|
138
|
+
- **`ai: AiAssistant`** *(optional)* — AI assistance for authors, powered by **your**
|
|
139
|
+
backend. See [AI assistance](#ai-assistance).
|
|
98
140
|
|
|
99
141
|
### `document` property
|
|
100
142
|
Assign a `Document` to load one; unset or `null` starts a blank document. It's a
|
|
101
143
|
property, not an attribute — objects can't pass through an HTML attribute.
|
|
102
144
|
|
|
145
|
+
Assigning always re-baselines the unsaved-changes state ("this is the truth now"), so
|
|
146
|
+
handing back exactly what you stored does not leave the editor dirty. The tree is only
|
|
147
|
+
replaced when the JSON actually differs, so re-assigning an identical document won't
|
|
148
|
+
remount live editors or drop undo history.
|
|
149
|
+
|
|
103
150
|
### `mergeTags` property
|
|
104
|
-
The personalization tags this host offers, keyed by the id the document stores
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
151
|
+
The personalization tags this host offers, keyed by the id the document stores. Ids
|
|
152
|
+
must match `[A-Za-z0-9._-]+`. The value carries per-locale labels plus two optional
|
|
153
|
+
fields:
|
|
154
|
+
|
|
155
|
+
```js
|
|
156
|
+
editor.mergeTags = {
|
|
157
|
+
firstName: { en: 'First name', sv: 'Förnamn' },
|
|
158
|
+
|
|
159
|
+
memberAttributes: {
|
|
160
|
+
en: 'Member attributes',
|
|
161
|
+
// Declared values. When a condition on this tag uses a set-membership operator,
|
|
162
|
+
// the condition UI offers these as a multiselect instead of a free-text field.
|
|
163
|
+
options: [
|
|
164
|
+
{ value: 'board', label: 'Board member' },
|
|
165
|
+
{ value: 'newsletter', label: 'Newsletter' },
|
|
166
|
+
],
|
|
167
|
+
// Offered for conditions, hidden from the content-insert picker — for tags whose
|
|
168
|
+
// value is machinery (an id list, a flag) that would read as garbage in a body.
|
|
169
|
+
conditionOnly: true,
|
|
170
|
+
},
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
A missing locale label falls back to the default locale, then to the bare id. Your
|
|
175
|
+
backend must later resolve every id it sees; see
|
|
176
|
+
[Sending and publishing](#sending-and-publishing).
|
|
108
177
|
|
|
109
178
|
### `brand` property
|
|
110
|
-
The colours and fonts your embed permits — a menu for the pickers, never a rewrite of
|
|
111
|
-
document
|
|
179
|
+
The colours and fonts your embed permits — a **menu for the pickers, never a rewrite of
|
|
180
|
+
a document**. It bounds what can be picked next; it never rejects or edits a document,
|
|
181
|
+
and a value already in a document that the Brand doesn't contain keeps rendering. All
|
|
182
|
+
halves are optional; an absent one is unrestricted.
|
|
112
183
|
|
|
113
184
|
```js
|
|
114
185
|
editor.brand = {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
186
|
+
// Replaces the free spectrum with exactly these swatches, everywhere a colour is
|
|
187
|
+
// picked. Hex with or without '#', shorthand expanded, case-insensitive.
|
|
188
|
+
colors: ['#2f2a72', '#a28c67'],
|
|
189
|
+
|
|
190
|
+
// Narrows the email-safe faces the font pickers offer, in your order. The FIRST is
|
|
191
|
+
// the font a new blank document starts on.
|
|
192
|
+
fonts: ['georgia', 'arial'],
|
|
193
|
+
|
|
194
|
+
// Appends your own faces after the email-safe set.
|
|
195
|
+
customFonts: [
|
|
118
196
|
{
|
|
119
197
|
name: 'Acme Sans', // picker label; @font-face family for a font-file href
|
|
120
198
|
href: 'https://static.example.com/fonts/acme-sans.css', // stylesheet URL (e.g. a Google Fonts embed), or a .woff2/.ttf file
|
|
@@ -124,43 +202,50 @@ editor.brand = {
|
|
|
124
202
|
}
|
|
125
203
|
```
|
|
126
204
|
|
|
205
|
+
The email-safe faces are `arial`, `tahoma`, `trebuchet`, `verdana`, `georgia`,
|
|
206
|
+
`times`, `courier`. Invalid entries (an unparseable colour, a non-http(s) font href)
|
|
207
|
+
are dropped with a `[bygga-editor]` console warning rather than throwing — one typo
|
|
208
|
+
must not take down your editor. The Transparent option stays available beside a
|
|
209
|
+
restricted palette, since "no fill" is a structural choice rather than a brand colour.
|
|
210
|
+
|
|
127
211
|
A custom face renders in the editor and wherever the compiled HTML is shown in a real
|
|
128
|
-
browser engine (a
|
|
212
|
+
browser engine (a published page, Apple Mail); most email clients strip remote fonts
|
|
129
213
|
and fall back through the stack, so give `fontFamily` sensible fallbacks. The document
|
|
130
|
-
carries what it uses
|
|
131
|
-
change the registration. Only http(s) hrefs register.
|
|
214
|
+
carries what it uses, so it keeps rendering even if you later change the registration.
|
|
132
215
|
|
|
133
|
-
### `
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
216
|
+
### `savedBlocks` property
|
|
217
|
+
The Saved block library this embed offers — see [Saved blocks](#saved-blocks). An
|
|
218
|
+
array property (arrays can't arrive through an attribute). This is the **only** thing
|
|
219
|
+
the Saved tab renders; the editor keeps no library of its own.
|
|
137
220
|
|
|
138
221
|
### Events
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
- **`
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
222
|
+
Events do not bubble — listen on the element itself.
|
|
223
|
+
|
|
224
|
+
- **`change`** — fired whenever the document changes; `event.detail[0]` is a detached
|
|
225
|
+
JSON snapshot. A *notification*, not a request: a host that echoes every payload back
|
|
226
|
+
into the `document` property is declaring each keystroke saved and will never see the
|
|
227
|
+
editor dirty.
|
|
228
|
+
- **`dirtychange`** — fired when the unsaved-changes state flips; `event.detail[0]` is
|
|
229
|
+
a boolean. Drives, e.g., a Save button's enabled state.
|
|
230
|
+
- **`licenseerror`** — fired once when the editor refuses to open; `event.detail[0]` is
|
|
231
|
+
`{ reason }`, one of `key`, `domain`, `origin`, `malformed`, `unreachable`,
|
|
232
|
+
`invalid`, `insecure-context`. The panel and a fuller `console.error` diagnostic are
|
|
233
|
+
already handled — this is so you can route it into your own telemetry.
|
|
148
234
|
|
|
149
235
|
### Methods
|
|
150
|
-
- **`getDocument(): Document`** — the current document.
|
|
236
|
+
- **`getDocument(): Document`** — the current document, detached.
|
|
151
237
|
- **`isDirty(): boolean`** — the current unsaved-changes state.
|
|
152
238
|
- **`markSaved(): void`** — clear the unsaved-changes state after you persist.
|
|
153
239
|
- **`save(): Promise<void>`** — routes through `config.saveDocument`, then marks
|
|
154
|
-
saved. Overlapping calls share one
|
|
240
|
+
saved. Rejects if `config.saveDocument` isn't configured. Overlapping calls share one
|
|
241
|
+
in-flight save, so awaiting it always means "the current document is saved".
|
|
155
242
|
|
|
156
243
|
`getDocument()` throws and `save()` rejects while the editor is unlicensed, because there is no
|
|
157
244
|
document behind them that anyone has edited — the editor never mounted. That matters if you autosave
|
|
158
245
|
on a timer: without it, a licence outage would write a blank document over the stored one.
|
|
159
246
|
|
|
160
247
|
Two ways to save: **pull** (`getDocument()`, persist it yourself, then `markSaved()`)
|
|
161
|
-
or **push** (`save()
|
|
162
|
-
echoes every `change` payload back into the `document` property is declaring each
|
|
163
|
-
keystroke saved and will never see the editor dirty.
|
|
248
|
+
or **push** (`save()`, which also gives you the compiled HTML).
|
|
164
249
|
|
|
165
250
|
### The `Document`
|
|
166
251
|
Opaque by design: persist it (it's plain JSON — `JSON.stringify` it) and hand it
|
|
@@ -168,37 +253,453 @@ back, but don't reach into it, since the shape is the editor's to evolve. Two wa
|
|
|
168
253
|
get one:
|
|
169
254
|
|
|
170
255
|
- **`createEmptyDocument(kind?)`** — a fresh blank document; `'email'` (the default) or
|
|
171
|
-
`'page'
|
|
256
|
+
`'page'`.
|
|
172
257
|
- **`parseDocument(json)`** — the supported path from stored JSON back to a
|
|
173
258
|
`Document`. Throws if the editor can't open it, so a corrupt row fails at your load
|
|
174
259
|
boundary rather than deep inside the editor. Use what it returns, not what you
|
|
175
260
|
passed in.
|
|
176
261
|
|
|
177
|
-
|
|
262
|
+
## Document kinds: email and page
|
|
263
|
+
|
|
264
|
+
The kind is a property of the *document*, not a mode of the editor — an editor opening
|
|
265
|
+
a page document is the website builder for it. One licence, one embed, one save flow
|
|
266
|
+
covers both, and both kinds can live in one store (the JSON carries its own kind).
|
|
267
|
+
|
|
268
|
+
```js
|
|
269
|
+
editor.document = createEmptyDocument('page')
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
What differs for a page: no email-size figure or spam warning (nothing clips a web
|
|
273
|
+
page), Saved blocks are kept apart by kind, the HTML block accepts `<iframe>` embeds,
|
|
274
|
+
and per-recipient conditions are unavailable (see
|
|
275
|
+
[Dynamic content](#dynamic-content-per-recipient-blocks)). Everything else — blocks,
|
|
276
|
+
columns, backgrounds, fonts, merge tags — behaves identically.
|
|
277
|
+
|
|
278
|
+
## Saved blocks
|
|
279
|
+
|
|
280
|
+
A Saved block is one block, with its whole subtree, lifted out of a document and kept
|
|
281
|
+
by *you* under a name, for inserting into any document later. The editor stores
|
|
282
|
+
nothing: you own the library.
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
editor.config = {
|
|
286
|
+
...config,
|
|
287
|
+
async saveBlock(saved) { // the editor mints the whole record, id included
|
|
288
|
+
await api.saveBlock(saved) // store it as given; `content` is opaque JSON
|
|
289
|
+
library = [...library, saved]
|
|
290
|
+
editor.savedBlocks = library // reassigning is what makes it appear
|
|
291
|
+
},
|
|
292
|
+
async deleteSavedBlock(id) {
|
|
293
|
+
await api.deleteBlock(id)
|
|
294
|
+
library = library.filter((entry) => entry.id !== id)
|
|
295
|
+
editor.savedBlocks = library
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// On load, validate stored rows at your boundary:
|
|
300
|
+
editor.savedBlocks = rows.map(parseSavedBlock)
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
`id` and `name` are ordinary strings you can key rows by and show in your own admin;
|
|
304
|
+
`content` is an opaque blob to write verbatim and hand back untouched. Resolving
|
|
305
|
+
`saveBlock` does **not** put the block in the Saved tab — `savedBlocks` is the only
|
|
306
|
+
truth the editor renders, so reassign it. A malformed or duplicate-id entry is dropped
|
|
307
|
+
with a warning rather than taking down the editor. Entries are offered only in
|
|
308
|
+
documents of the kind they were saved from.
|
|
309
|
+
|
|
310
|
+
## Block plugins
|
|
311
|
+
|
|
312
|
+
Custom block types — a video embed, a product card — registered per embed through
|
|
313
|
+
`config.plugins`. A plugin owns a JSON `data` value per block, draws its own settings
|
|
314
|
+
UI, and *bakes* that data into email HTML at edit time; the baked HTML is stored in the
|
|
315
|
+
document and rendered verbatim, so our servers never run your code.
|
|
316
|
+
|
|
317
|
+
```js
|
|
318
|
+
const artifact = await import('https://static.example.com/plugins/acme.js')
|
|
319
|
+
editor.config = { ...config, plugins: [artifact.createProductCardPlugin(apiBase)] }
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Plugins are **loaded objects, not URLs** — you do the import, which keeps plugin
|
|
323
|
+
deployment a static-file update that rebuilds nothing. The contract is `BlockPlugin`
|
|
324
|
+
(`name`, `label`, `icon?`, `create`, `parse`, `email`, `settings`, `preview?`,
|
|
325
|
+
`globalSettings?`); `name` is stored in every document that uses the block, so treat it
|
|
326
|
+
as permanent and namespace it (`acme.product-card`). `email()` must be deterministic
|
|
327
|
+
and `data` must be plain JSON — the editor re-bakes on load and on every change.
|
|
328
|
+
|
|
329
|
+
## AI assistance
|
|
330
|
+
|
|
331
|
+
The editor can offer authors AI help on the text they're writing — but it ships **the UI
|
|
332
|
+
and none of the intelligence**. Every request goes to a callback you implement, against
|
|
333
|
+
whatever model and account you choose. We hold no AI credential, meter no usage, and
|
|
334
|
+
never see the content: the same division as `uploadImage`, where you own the storage and
|
|
335
|
+
we own the experience.
|
|
336
|
+
|
|
337
|
+
Supply no `ai` and there are no AI controls at all — the default for every embed.
|
|
338
|
+
|
|
339
|
+
```js
|
|
340
|
+
editor.config = {
|
|
341
|
+
...config,
|
|
342
|
+
ai: {
|
|
343
|
+
// WHICH features this embed offers. The editor renders exactly this list, so turning
|
|
344
|
+
// one off means removing it here — there is no separate flag to keep in step.
|
|
345
|
+
actions: ['proofread', 'rewrite', 'shorten'],
|
|
346
|
+
|
|
347
|
+
// Called with the author's selection. Answer with the replacement text.
|
|
348
|
+
async run({ action, text, locale }) {
|
|
349
|
+
const res = await fetch('/api/ai', { // YOUR endpoint, your model, your key
|
|
350
|
+
method: 'POST',
|
|
351
|
+
headers: { 'content-type': 'application/json' },
|
|
352
|
+
body: JSON.stringify({ action, text, locale }),
|
|
353
|
+
})
|
|
354
|
+
if (!res.ok) throw new Error(`AI backend said ${res.status}`)
|
|
355
|
+
return (await res.json()).text
|
|
356
|
+
},
|
|
357
|
+
},
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
**The four actions**, each text-in/text-out: `proofread` (fix spelling and grammar,
|
|
362
|
+
keep the wording), `rewrite` (say it differently), `shorten`, `expand`. An action the
|
|
363
|
+
editor doesn't know is dropped with a console warning, and an empty `actions` array
|
|
364
|
+
means no controls — same as no `ai` at all.
|
|
365
|
+
|
|
366
|
+
**What the author sees:** an AI button in the text toolbar listing exactly your declared
|
|
367
|
+
actions. It runs on their selection, or on the whole block when nothing is selected. The
|
|
368
|
+
button is disabled while a request is in flight.
|
|
369
|
+
|
|
370
|
+
### Implementing the endpoint
|
|
371
|
+
|
|
372
|
+
The callback is the easy half; the endpoint behind it is where the work is. Two rules
|
|
373
|
+
shape everything below:
|
|
374
|
+
|
|
375
|
+
> **Whatever you return is spliced into the author's email verbatim.** Not shown for
|
|
376
|
+
> approval, not parsed for an answer — inserted. A model that helpfully replies *"Sure!
|
|
377
|
+
> Here's the corrected text:"* puts that sentence in the email.
|
|
378
|
+
|
|
379
|
+
> **Merge tags travel through as `{{tagId}}`.** The editor sends them as tokens and
|
|
380
|
+
> rebuilds them as chips from whatever you return, so your prompt must instruct the model
|
|
381
|
+
> to keep them **exactly** as-is. A model that "corrects" `{{firstName}}` to `{{ firstName }}`
|
|
382
|
+
> or translates it breaks the personalization for that block.
|
|
383
|
+
|
|
384
|
+
A worked Node endpoint, using the Anthropic SDK and structured outputs so the response
|
|
385
|
+
*cannot* carry a preamble:
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
import Anthropic from '@anthropic-ai/sdk'
|
|
389
|
+
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'
|
|
390
|
+
import { z } from 'zod'
|
|
391
|
+
|
|
392
|
+
const client = new Anthropic() // reads ANTHROPIC_API_KEY — server-side only, never the browser
|
|
393
|
+
|
|
394
|
+
// Structured output is the robust fix for the preamble problem: the model must fill this
|
|
395
|
+
// schema, so there is nowhere for "Here's your text:" to live.
|
|
396
|
+
const Result = z.object({ text: z.string() })
|
|
397
|
+
|
|
398
|
+
const INSTRUCTIONS: Record<string, string> = {
|
|
399
|
+
proofread:
|
|
400
|
+
'Correct spelling, grammar and punctuation. Preserve the wording, tone and meaning — do not rephrase.',
|
|
401
|
+
rewrite:
|
|
402
|
+
'Rephrase to read more clearly and naturally. Preserve the meaning, tone and approximate length.',
|
|
403
|
+
shorten:
|
|
404
|
+
'Make it shorter while keeping every essential point. Aim for roughly half the length.',
|
|
405
|
+
expand:
|
|
406
|
+
'Add relevant detail in the same voice. Aim for roughly 1.5x the length.',
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function handleAi(req, res) {
|
|
410
|
+
const { action, text, locale } = req.body
|
|
411
|
+
const instruction = INSTRUCTIONS[action]
|
|
412
|
+
if (!instruction) return res.status(400).json({ error: 'unknown action' })
|
|
413
|
+
if (text.length > 5000) return res.status(413).json({ error: 'selection too long' })
|
|
414
|
+
|
|
415
|
+
const message = await client.messages.parse({
|
|
416
|
+
model: 'claude-opus-5',
|
|
417
|
+
// These are short transforms on an email selection; a low cap bounds both latency
|
|
418
|
+
// and a runaway bill. Raise it if you allow long selections.
|
|
419
|
+
max_tokens: 4000,
|
|
420
|
+
system: [
|
|
421
|
+
`You edit marketing email copy. ${instruction}`,
|
|
422
|
+
`Reply in the same language as the input (the author is writing in "${locale}").`,
|
|
423
|
+
'Return ONLY the edited text — no commentary, no explanation, no quotes around it.',
|
|
424
|
+
'Preserve any {{merge_tag}} placeholders EXACTLY as they appear, including spelling and position.',
|
|
425
|
+
'Return plain text: no Markdown, no HTML.',
|
|
426
|
+
].join(' '),
|
|
427
|
+
messages: [{ role: 'user', content: text }],
|
|
428
|
+
output_config: { format: zodOutputFormat(Result) },
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
res.json({ text: message.parsed_output?.text ?? text })
|
|
432
|
+
}
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
**Clean what you return anyway.** Even with structured output, trim whitespace and strip
|
|
436
|
+
matched surrounding quotes — models add them. If the result is empty, return the original
|
|
437
|
+
text rather than an empty string; the editor treats an empty answer as nothing to do, but
|
|
438
|
+
returning the input makes that explicit.
|
|
439
|
+
|
|
440
|
+
### Security, cost and abuse
|
|
441
|
+
|
|
442
|
+
- **Never put a model key in the browser.** The callback runs in your page — anything it
|
|
443
|
+
holds is public. Call your own authenticated endpoint, as above.
|
|
444
|
+
- **Authenticate and authorise the endpoint** with the same session your app already
|
|
445
|
+
uses, and check the user may edit that document. Without it you've published an open
|
|
446
|
+
proxy to your model account.
|
|
447
|
+
- **Rate limit per user**, not per IP — this is a button an author can hold down. A short
|
|
448
|
+
per-user quota (say N requests/minute) plus a max input length is enough.
|
|
449
|
+
- **Cap the input**: reject selections beyond a size you're willing to pay for (the
|
|
450
|
+
editor sends the whole block when nothing is selected, so this is reachable by
|
|
451
|
+
accident).
|
|
452
|
+
- **Log for support, not surveillance**: the action, size and outcome are enough to debug
|
|
453
|
+
"the AI button doesn't work"; the copy itself is your customer's content.
|
|
454
|
+
|
|
455
|
+
### Failure and timeouts
|
|
456
|
+
|
|
457
|
+
**Rejecting is a first-class outcome, not a bug.** The editor shows the author an error,
|
|
458
|
+
logs your reason to the console, and leaves their text exactly as it was. Throw with a
|
|
459
|
+
message worth reading — a quota refusal and a network blip shouldn't look the same to
|
|
460
|
+
whoever supports the author:
|
|
461
|
+
|
|
462
|
+
```js
|
|
463
|
+
async run({ action, text, locale }) {
|
|
464
|
+
const res = await fetch('/api/ai', {
|
|
465
|
+
method: 'POST',
|
|
466
|
+
headers: { 'content-type': 'application/json' },
|
|
467
|
+
body: JSON.stringify({ action, text, locale }),
|
|
468
|
+
signal: AbortSignal.timeout(20_000), // authors abandon a spinner long before a gateway does
|
|
469
|
+
})
|
|
470
|
+
if (res.status === 429) throw new Error('AI quota reached — try again in a minute')
|
|
471
|
+
if (!res.ok) throw new Error(`AI backend said ${res.status}`)
|
|
472
|
+
return (await res.json()).text
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
### Testing without a model
|
|
477
|
+
|
|
478
|
+
Wire a fake first — the editor can't tell the difference, and it's the fastest way to see
|
|
479
|
+
the whole flow (including the failure path):
|
|
480
|
+
|
|
481
|
+
```js
|
|
482
|
+
ai: {
|
|
483
|
+
actions: ['proofread', 'rewrite', 'shorten', 'expand'],
|
|
484
|
+
run: async ({ action, text }) => {
|
|
485
|
+
await new Promise((r) => setTimeout(r, 400)) // see the pending state
|
|
486
|
+
if (text.includes('boom')) throw new Error('mock failure')
|
|
487
|
+
return `[${action}] ${text}`
|
|
488
|
+
},
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
### Reference
|
|
493
|
+
|
|
494
|
+
| | |
|
|
495
|
+
| --- | --- |
|
|
496
|
+
| `run` receives | `{ action, text, locale }` — `text` is plain text with merge tags as `{{tagId}}`; `locale` is the editor's current locale |
|
|
497
|
+
| `run` returns | `Promise<string>` — the replacement text |
|
|
498
|
+
| Applied as | plain text plus rebuilt merge-tag chips. **Formatting marks inside the replaced range are lost**; merge tags survive |
|
|
499
|
+
| On rejection | author sees an error, console gets your reason, text is unchanged |
|
|
500
|
+
| TypeScript | `AiAssistant`, `AiAction`, `AiRequest` are exported |
|
|
501
|
+
|
|
502
|
+
## Dynamic content: per-recipient blocks
|
|
503
|
+
|
|
504
|
+
A columns block in an **email** can carry a condition — a test on one merge tag that
|
|
505
|
+
decides, per recipient, whether that block appears. Authors set it in the block's
|
|
506
|
+
"Show to" controls; operators are `is` / `is not` (against one value) and
|
|
507
|
+
`has any of` / `has none of` (against a list, matched as a comma-separated set).
|
|
508
|
+
Pages have no conditions: a page is served, not sent, so there is no recipient to
|
|
509
|
+
resolve against.
|
|
510
|
+
|
|
511
|
+
**This affects your send pipeline.** A document using dynamic content compiles to HTML
|
|
512
|
+
containing `{{!region_N}}` placeholders that must be resolved per recipient — the editor
|
|
513
|
+
cannot resolve them, because it has no recipient. Resolving them needs the region list,
|
|
514
|
+
which the editor's `compiled` string does not carry, so a pipeline that sends dynamic
|
|
515
|
+
content compiles from the backend and substitutes per recipient:
|
|
516
|
+
[the compile API](#the-compile-api).
|
|
517
|
+
|
|
518
|
+
## Sending and publishing
|
|
519
|
+
|
|
520
|
+
The editor only edits and previews. Turning a document into sendable HTML happens on
|
|
521
|
+
our compile servers. There are two ways to reach them, and which you need depends on
|
|
522
|
+
whether your documents use [dynamic content](#dynamic-content-per-recipient-blocks):
|
|
523
|
+
|
|
524
|
+
- **The editor compiles while saving** — the `compiled` argument to
|
|
525
|
+
`config.saveDocument`. One complete HTML string. Sufficient on its own for documents
|
|
526
|
+
with **no** conditional blocks.
|
|
527
|
+
- **Your backend compiles** — `POST /v1/compile`, below. Returns the same HTML **plus
|
|
528
|
+
the region list** that conditional blocks need. Required if you use dynamic content,
|
|
529
|
+
and the right call for a sendout pipeline generally: compile once per campaign, then
|
|
530
|
+
substitute per recipient.
|
|
531
|
+
|
|
532
|
+
Store `document` and `compiled` **together, atomically**, and handle `compiled: null`
|
|
533
|
+
(compile unavailable) by re-compiling later rather than pairing new JSON with stale
|
|
534
|
+
HTML — that mismatch is how a send goes out with content the author no longer sees.
|
|
535
|
+
|
|
536
|
+
### The compile API
|
|
537
|
+
|
|
538
|
+
```http
|
|
539
|
+
POST https://api.bygga.dev/v1/compile
|
|
540
|
+
Authorization: Bearer <your licence key>
|
|
541
|
+
Content-Type: application/json
|
|
542
|
+
|
|
543
|
+
{ "document": { …the stored Document JSON, verbatim… } }
|
|
544
|
+
```
|
|
545
|
+
|
|
546
|
+
Your backend has no browser origin to attest, so it presents the **licence key** itself
|
|
547
|
+
(the editor, in a browser, presents the token its activation already earned). The
|
|
548
|
+
response:
|
|
549
|
+
|
|
550
|
+
```jsonc
|
|
551
|
+
{
|
|
552
|
+
// The whole standalone email or page. Merge tags appear as {{tagId}}; each
|
|
553
|
+
// conditional block appears as its own {{!region_N}}.
|
|
554
|
+
"html": "<!DOCTYPE html …>",
|
|
555
|
+
|
|
556
|
+
// One entry per conditional block, flat — a region's html never contains another
|
|
557
|
+
// region's placeholder, so substitution is a single unordered pass.
|
|
558
|
+
"regions": [
|
|
559
|
+
{
|
|
560
|
+
"placeholder": "{{!region_0}}", // the exact string to find; never rebuild it yourself
|
|
561
|
+
"html": "<table>…</table>", // the block's own HTML, may contain {{tagId}} of its own
|
|
562
|
+
"condition": {
|
|
563
|
+
"tagId": "company_id",
|
|
564
|
+
"operator": "equals", // or notEquals | anyOf | noneOf
|
|
565
|
+
"value": "abc123" // anyOf/noneOf carry "values": [...] instead
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
]
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
Failures answer `{ "error": "…" }` with the status carrying the retry semantics:
|
|
573
|
+
**400** the document is invalid (permanent — never retry), **401** bad or missing
|
|
574
|
+
credential, **413** body over the 4 MB cap, **429** rate limited, **503** busy, **500**
|
|
575
|
+
our fault. Retry `5xx` and `429` (both send `retry-after`); never retry `4xx`.
|
|
576
|
+
|
|
577
|
+
There is deliberately no list of merge-tag ids in the response — the string is the
|
|
578
|
+
contract. See the tag-set rule below.
|
|
579
|
+
|
|
580
|
+
### Substituting, per recipient
|
|
581
|
+
|
|
582
|
+
Two passes, **regions first** (a region's HTML can contain merge tags of its own), then
|
|
583
|
+
merge tags:
|
|
584
|
+
|
|
585
|
+
```js
|
|
586
|
+
function personalize({ html, regions }, values) {
|
|
587
|
+
let out = html
|
|
588
|
+
for (const region of regions) {
|
|
589
|
+
out = out.replaceAll(
|
|
590
|
+
region.placeholder,
|
|
591
|
+
matches(region.condition, values) ? region.html : '',
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
for (const [id, value] of Object.entries(values)) {
|
|
595
|
+
out = out.replaceAll(`{{${id}}}`, escapeHtml(value))
|
|
596
|
+
}
|
|
597
|
+
return out
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Equality: trim and lower-case BOTH sides; notEquals is exactly !equals, with no
|
|
601
|
+
// special case for the empty string. Set membership: read the recipient's value as a
|
|
602
|
+
// comma-separated set (trim + lower-case each element, drop empties); anyOf matches on
|
|
603
|
+
// a non-empty intersection, noneOf is exactly !anyOf. Each pair therefore partitions
|
|
604
|
+
// your audience — nobody receives a message with a hole in it.
|
|
605
|
+
function matches(condition, values) {
|
|
606
|
+
const raw = values[condition.tagId] ?? ''
|
|
607
|
+
if (condition.operator === 'anyOf' || condition.operator === 'noneOf') {
|
|
608
|
+
const assigned = new Set(
|
|
609
|
+
raw.split(',').map((v) => v.trim().toLowerCase()).filter(Boolean),
|
|
610
|
+
)
|
|
611
|
+
const any = (condition.values ?? []).some((v) =>
|
|
612
|
+
assigned.has(v.trim().toLowerCase()),
|
|
613
|
+
)
|
|
614
|
+
return condition.operator === 'anyOf' ? any : !any
|
|
615
|
+
}
|
|
616
|
+
const equal =
|
|
617
|
+
raw.trim().toLowerCase() === (condition.value ?? '').trim().toLowerCase()
|
|
618
|
+
return condition.operator === 'equals' ? equal : !equal
|
|
619
|
+
}
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
Three rules that have no error signal behind them, so build them in deliberately:
|
|
623
|
+
|
|
624
|
+
1. **Which values to load.** Every `{{id}}` in `html` **and** in each `regions[].html`
|
|
625
|
+
— scan with `/\{\{([A-Za-z0-9._-]+)\}\}/g`, which skips region placeholders because
|
|
626
|
+
`!` is outside the id character class — **plus every `regions[].condition.tagId`**.
|
|
627
|
+
Condition tags are compared, never rendered: they appear in no HTML, so a scan alone
|
|
628
|
+
misses them, the comparison then runs against an empty string, and the block silently
|
|
629
|
+
shows for nobody.
|
|
630
|
+
2. **Escape every value you splice.** Values land in raw markup and inside attributes;
|
|
631
|
+
an unescaped `<`, `&` or `"` breaks the markup or injects into it.
|
|
632
|
+
3. **Lint before sending.** Anything still matching `/\{\{/` after both passes is a tag
|
|
633
|
+
you had no value for or a region you missed — it reaches the recipient as literal
|
|
634
|
+
text. Fail the send instead.
|
|
635
|
+
|
|
636
|
+
### Pages
|
|
637
|
+
|
|
638
|
+
A page compiles through the same endpoint and returns the same shape, substituted at
|
|
639
|
+
serve time rather than send time; its `regions` array is always empty, since conditions
|
|
640
|
+
are email-only. Its HTML ships with an empty `<title>` and no meta description — inject
|
|
641
|
+
your own before serving — and it must never be sent as an email: it deliberately omits
|
|
642
|
+
the email-client armour, and the document's kind is there so your send queue can refuse.
|
|
643
|
+
|
|
644
|
+
## Theming
|
|
645
|
+
|
|
178
646
|
Design tokens are CSS custom properties (`--bygga-*`) read through the shadow boundary,
|
|
179
647
|
so a host retheme is just setting them on the element:
|
|
180
648
|
|
|
181
649
|
```css
|
|
182
|
-
bygga-editor {
|
|
650
|
+
bygga-editor {
|
|
651
|
+
--bygga-color-accent: #b00;
|
|
652
|
+
--bygga-color-border: #333;
|
|
653
|
+
--bygga-radius-sm: 4px;
|
|
654
|
+
--bygga-font-family: 'Acme Grotesk', sans-serif; /* your page loads the face */
|
|
655
|
+
}
|
|
183
656
|
```
|
|
184
657
|
|
|
658
|
+
Tokens cover surfaces, text, accent, borders, warning/danger/success, radii, shadows,
|
|
659
|
+
the tool-column width and the UI font. They restyle the editor's **chrome** only —
|
|
660
|
+
what goes *into the document* is bounded by `brand`, not by the theme. High-contrast
|
|
661
|
+
and forced-colors modes are handled behind the tokens and keep working under a retheme.
|
|
662
|
+
|
|
185
663
|
## TypeScript
|
|
186
664
|
|
|
187
665
|
The package augments `HTMLElementTagNameMap`, so
|
|
188
666
|
`document.querySelector('bygga-editor')` is typed as `ByggaEditorElement` — its
|
|
189
|
-
properties, methods, and a typed `addEventListener` for `change` / `dirtychange`
|
|
190
|
-
included.
|
|
191
|
-
|
|
192
|
-
- **Types:** `Document`, `EditorConfig`, `
|
|
193
|
-
`
|
|
194
|
-
|
|
195
|
-
`
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
667
|
+
properties, methods, and a typed `addEventListener` for `change` / `dirtychange` /
|
|
668
|
+
`licenseerror` included.
|
|
669
|
+
|
|
670
|
+
- **Types:** `Document`, `EditorConfig`, `ByggaEditorElement`, `ByggaEditorEventMap`,
|
|
671
|
+
`Brand`, `BrandFont`, `BrandCustomFont`, `MergeTags`, `MergeTagEntry`,
|
|
672
|
+
`MergeTagLabels`, `MergeTagOption`, `SavedBlock`, `SavedBlockContent`, `BlockPlugin`,
|
|
673
|
+
`BlockPluginSettingsContext`, `BlockPluginGlobalsContext`,
|
|
674
|
+
`BlockPluginGlobalSettings`, `AiAssistant`, `AiAction`, `AiRequest`, `Locale`,
|
|
675
|
+
`LicenseError`, `LicenseErrorReason`.
|
|
676
|
+
- **Values:** `createEmptyDocument`, `parseDocument`, `parseSavedBlock`,
|
|
677
|
+
`SUPPORTED_LOCALES`, `DEFAULT_LOCALE`, `EDITOR_TAG`, and `ByggaEditor` (the element
|
|
678
|
+
constructor, already registered on import).
|
|
679
|
+
|
|
680
|
+
## Framework integration
|
|
681
|
+
|
|
682
|
+
`config`, `document`, `mergeTags`, `brand` and `savedBlocks` are **JS properties**, not
|
|
683
|
+
attributes — objects and arrays can't pass through HTML attributes. Frameworks that set
|
|
684
|
+
attributes by default need a ref.
|
|
685
|
+
|
|
686
|
+
```jsx
|
|
687
|
+
// React
|
|
688
|
+
function Editor({ doc, onChange }) {
|
|
689
|
+
const ref = useRef(null)
|
|
690
|
+
useEffect(() => {
|
|
691
|
+
const el = ref.current
|
|
692
|
+
el.config = { uploadImage } // set before it matters; el is already connected
|
|
693
|
+
el.document = doc
|
|
694
|
+
const handler = (e) => onChange(e.detail[0])
|
|
695
|
+
el.addEventListener('change', handler)
|
|
696
|
+
return () => el.removeEventListener('change', handler)
|
|
697
|
+
}, [doc, onChange])
|
|
698
|
+
return <bygga-editor ref={ref} license-key="bk_live_…" style={{ height: '100vh' }} />
|
|
699
|
+
}
|
|
700
|
+
```
|
|
701
|
+
|
|
702
|
+
In **Vue**, `:config`, `:document` and `@change` work directly on the element (mark it
|
|
703
|
+
as a custom element in your build config). In **Angular**, use property binding
|
|
704
|
+
(`[config]`) with `CUSTOM_ELEMENTS_SCHEMA`. In plain HTML, create the element, assign
|
|
705
|
+
the properties, then append it — the order in [Quick start](#quick-start).
|