@bygga.dev/editor 1.4.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 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, so don't
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>`** *(optional)* — push-save hook
94
- invoked by `save()`; on success the editor marks the document saved. Rejecting
95
- leaves the document dirty.
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, with
105
- per-locale labels (`{ firstName: { en: 'First name', sv: 'Förnamn' } }`). Populates
106
- the merge-tag picker; your backend must later resolve every id it sees. A missing
107
- locale label falls back to the default locale, then to the bare id.
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 a
111
- document. All halves are optional; an absent one is unrestricted.
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
- colors: ['#2f2a72', '#a28c67'], // replaces the free spectrum with swatches
116
- fonts: ['georgia', 'arial'], // narrows the email-safe faces; first seeds new documents
117
- customFonts: [ // appends your own faces after the email-safe set
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 view-online page, Apple Mail); most email clients strip remote fonts
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 (`customFonts` in its JSON), so it keeps rendering even if you later
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
- ### `license-key` attribute
134
- Your licence key (see [You need a licence key](#you-need-a-licence-key)). Reactive: changing it
135
- re-activates, so a host that fetches its key can set it after the element is connected. Omit it on
136
- `localhost`, which is licensed without one.
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
- - **`change`** — a `CustomEvent` fired whenever the document changes; `event.detail[0]`
140
- is a detached JSON snapshot (persist it with `JSON.stringify`).
141
- - **`dirtychange`** — a `CustomEvent` fired when the unsaved-changes state flips;
142
- `event.detail[0]` is a boolean. Drives, e.g., a Save button's enabled state.
143
- - **`licenseerror`** — a `CustomEvent` fired once when the editor refuses to open;
144
- `event.detail[0]` is `{ reason }`, one of `key`, `domain`, `origin`, `malformed`,
145
- `unreachable`, `invalid`, `insecure-context`. The panel and a fuller `console.error`
146
- diagnostic are already handled — this is so you can route it into your own telemetry.
147
- Events do not bubble, so listen on the element itself.
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 in-flight save.
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()`). `change` is a *notification*, not a request — a host that
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'` decides what it compiles to.
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
- ### Theming
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 { --bygga-color-accent: #b00; }
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`, `Brand`, `BrandFont`, `BrandCustomFont`,
193
- `MergeTags`, `MergeTagLabels`, `Locale`, `ByggaEditorElement`, `ByggaEditorEventMap`.
194
- - **Values:** `createEmptyDocument`, `parseDocument`, `SUPPORTED_LOCALES`,
195
- `DEFAULT_LOCALE`, `EDITOR_TAG`, and `ByggaEditor` (the element constructor,
196
- already registered on import).
197
-
198
- ## Rendering to email
199
-
200
- The editor only edits and previews. Turning a saved document into sendable,
201
- email-client-safe HTML is done for you: the editor compiles while saving and hands it to
202
- `config.saveDocument(document, compiled)` as one standalone HTML string with `{{tag}}`
203
- placeholders. Substitute them with a find-and-replace at send time — escaping each value
204
- yourself, since they are spliced in raw.
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).