@vitrina/email-builder 0.1.4
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/LICENSE +21 -0
- package/README.md +150 -0
- package/dist/index.d.ts +243 -0
- package/dist/index.js +1475 -0
- package/dist/index.js.map +1 -0
- package/package.json +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vitrina (VitrinaDev)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# @vitrina/email-builder
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@vitrina/email-builder)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
An **Unlayer-style drag-and-drop email builder** for React. Rows → weighted
|
|
7
|
+
Columns → Blocks (text, heading, button, image, divider, spacer, social, menu,
|
|
8
|
+
HTML). Inline rich-text, undo/redo, desktop/mobile preview. It edits a plain
|
|
9
|
+
`EmailDesign` JSON object and compiles it to **email-safe table HTML** — inline
|
|
10
|
+
styles, forced-light rendering, fluid columns that stack on phones, and Outlook
|
|
11
|
+
fallbacks.
|
|
12
|
+
|
|
13
|
+
- **Framework-agnostic** — one `<EmailBuilder value onChange />` component.
|
|
14
|
+
- **Tailwind + shadcn friendly** — styled entirely with utility classes + your CSS-var theme tokens. No stylesheet to import.
|
|
15
|
+
- **i18n-free** — every visible string is injected via a `labels` prop.
|
|
16
|
+
- **Merge-tag aware** — `{{name}}`-style tags pass through untouched, for your own per-recipient rendering.
|
|
17
|
+
- **Tiny** — no editor engine bundled; only `highlight.js` + `lucide-react` as deps, React as a peer.
|
|
18
|
+
|
|
19
|
+
> Built by [Vitrina](https://github.com/VitrinaDev) for its campaigns product and extracted as a standalone package.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm i @vitrina/email-builder
|
|
25
|
+
# or: pnpm add @vitrina/email-builder / yarn add @vitrina/email-builder
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Peer deps: **React ≥ 19** and `react-dom`.
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
```tsx
|
|
33
|
+
import { useState } from 'react';
|
|
34
|
+
import { EmailBuilder, compileDesign, defaultDesign, type EmailDesign } from '@vitrina/email-builder';
|
|
35
|
+
|
|
36
|
+
export function MyEditor() {
|
|
37
|
+
const [design, setDesign] = useState<EmailDesign>(() => defaultDesign());
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div style={{ height: 640 }}>
|
|
41
|
+
<EmailBuilder
|
|
42
|
+
value={design}
|
|
43
|
+
onChange={setDesign}
|
|
44
|
+
uploadImage={async (file) => uploadAndReturnPublicUrl(file)} // optional
|
|
45
|
+
socialIconBase="https://cdn.example.com/social-icons" // optional
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// When you save/send — compileDesign returns the <body> inner HTML. Wrap it in
|
|
52
|
+
// your own shell (doctype/head + compliance footer) and render merge tags
|
|
53
|
+
// server-side per recipient.
|
|
54
|
+
const bodyHtml = compileDesign(design, { socialIconBase: 'https://cdn.example.com/social-icons' });
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Styling requirements
|
|
58
|
+
|
|
59
|
+
The builder renders **Tailwind utility classes** and reads **shadcn/ui-style CSS
|
|
60
|
+
variables** (`--primary`, `--muted-foreground`, `--border`, `--popover`,
|
|
61
|
+
`--ring`, `--destructive`, …). Any shadcn base provides them.
|
|
62
|
+
|
|
63
|
+
Because the classes live inside the compiled package, tell Tailwind to scan it.
|
|
64
|
+
|
|
65
|
+
**Tailwind v4** (`globals.css`):
|
|
66
|
+
|
|
67
|
+
```css
|
|
68
|
+
@import "tailwindcss";
|
|
69
|
+
@source "../node_modules/@vitrina/email-builder/dist";
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
**Tailwind v3** (`tailwind.config`):
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
content: ['./src/**/*.{ts,tsx}', './node_modules/@vitrina/email-builder/dist/**/*.js'],
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The HTML block's code editor picks up syntax colors from any highlight.js theme
|
|
79
|
+
you happen to load — optional; it degrades to monochrome without one.
|
|
80
|
+
|
|
81
|
+
## API
|
|
82
|
+
|
|
83
|
+
| Export | What |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `EmailBuilder` | The editor. Props below. |
|
|
86
|
+
| `compileDesign(design, opts?)` | `EmailDesign` → email-safe body HTML string. `opts.socialIconBase` for hosted social icons. |
|
|
87
|
+
| `defaultDesign()` | A pleasant starter design. |
|
|
88
|
+
| `blockCount(design)` | Total blocks (e.g. to gate "next"). |
|
|
89
|
+
| `MOBILE_STACK_CSS` | Optional `<style>` for your `<head>` (extra mobile polish). |
|
|
90
|
+
| `DEFAULT_LABELS`, `BuilderLabels` | The English label bag + its type. |
|
|
91
|
+
| types | `EmailDesign`, `EmailSettings`, `Block`, `BlockType`, `Row` |
|
|
92
|
+
|
|
93
|
+
### `<EmailBuilder>` props
|
|
94
|
+
|
|
95
|
+
| Prop | Type | |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `value` | `EmailDesign` | required — the design (controlled) |
|
|
98
|
+
| `onChange` | `(d: EmailDesign) => void` | required |
|
|
99
|
+
| `labels?` | `Partial<BuilderLabels>` | override any string (defaults are English) |
|
|
100
|
+
| `uploadImage?` | `(file: File) => Promise<string>` | host-provided uploader; without it images are URL-only |
|
|
101
|
+
| `socialIconBase?` | `string` | absolute base for hosted icons — `<base>/<slug>.png`; omit for a colored-chip fallback |
|
|
102
|
+
| `height?` | `number \| string` | editor height (default `640`) |
|
|
103
|
+
|
|
104
|
+
## The compile contract
|
|
105
|
+
|
|
106
|
+
`compileDesign` emits the `<body>` **inner** HTML only. **You** own the outer
|
|
107
|
+
shell — doctype/`<head>`, and any compliance footer (unsubscribe, physical
|
|
108
|
+
address) your emails need. Merge tags like `{{name}}` are left verbatim so you
|
|
109
|
+
can render them per recipient at send time.
|
|
110
|
+
|
|
111
|
+
Rules the output follows so it renders across clients:
|
|
112
|
+
|
|
113
|
+
- Table-based layout, 100% inline styles (no `<style>`/media-queries in the body).
|
|
114
|
+
- **Forced light** friendliness — pair with `<meta name="color-scheme" content="light only">` in your head to avoid dark-mode inversion breaking your colors.
|
|
115
|
+
- **Fluid columns** — multi-column rows use the inline-block "hybrid" pattern, so they **stack on phones with no media query** (Gmail included), with an `[if mso]` ghost table keeping Outlook side-by-side.
|
|
116
|
+
- **Images** carry a `width` attribute (Outlook sizes off it) + `max-width:100%`.
|
|
117
|
+
|
|
118
|
+
### Email-client notes
|
|
119
|
+
|
|
120
|
+
| | Gmail | Apple Mail | Outlook (desktop) |
|
|
121
|
+
| --- | --- | --- | --- |
|
|
122
|
+
| Core layout, buttons, images | ✅ | ✅ | ✅ |
|
|
123
|
+
| Column stacking on mobile | ✅ | ✅ | n/a |
|
|
124
|
+
| `border-radius` (rounded buttons/icons) | ✅ | ✅ | ⬜ renders square |
|
|
125
|
+
| Row **background images** | ✅ | ✅ | ⬜ needs VML |
|
|
126
|
+
|
|
127
|
+
Social icons need publicly-hosted PNGs at `socialIconBase` (Gmail/Outlook strip
|
|
128
|
+
SVG). Slugs: `instagram facebook whatsapp x twitter linkedin youtube tiktok
|
|
129
|
+
pinterest snapchat telegram messenger reddit spotify threads github vimeo tumblr
|
|
130
|
+
discord email website`.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pnpm install
|
|
136
|
+
pnpm test # vitest — compiler + model unit tests
|
|
137
|
+
pnpm typecheck
|
|
138
|
+
pnpm lint
|
|
139
|
+
pnpm build # tsup → dist (ESM + d.ts)
|
|
140
|
+
|
|
141
|
+
# runnable playground (see examples/basic):
|
|
142
|
+
cd examples/basic && pnpm install && pnpm dev
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full workflow and
|
|
146
|
+
[CHANGELOG.md](./CHANGELOG.md) for release history.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
[MIT](./LICENSE) © Vitrina
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import * as react from 'react';
|
|
2
|
+
|
|
3
|
+
type BlockAlign = 'left' | 'center' | 'right';
|
|
4
|
+
interface BlockBase {
|
|
5
|
+
id: string;
|
|
6
|
+
padding: number;
|
|
7
|
+
}
|
|
8
|
+
interface TextBlock extends BlockBase {
|
|
9
|
+
type: 'text';
|
|
10
|
+
html: string;
|
|
11
|
+
align: BlockAlign;
|
|
12
|
+
fontSize: number;
|
|
13
|
+
color: string;
|
|
14
|
+
lineHeight: number;
|
|
15
|
+
}
|
|
16
|
+
interface HeadingBlock extends BlockBase {
|
|
17
|
+
type: 'heading';
|
|
18
|
+
html: string;
|
|
19
|
+
level: 1 | 2 | 3;
|
|
20
|
+
align: BlockAlign;
|
|
21
|
+
color: string;
|
|
22
|
+
}
|
|
23
|
+
interface ButtonBlock extends BlockBase {
|
|
24
|
+
type: 'button';
|
|
25
|
+
label: string;
|
|
26
|
+
href: string;
|
|
27
|
+
align: BlockAlign;
|
|
28
|
+
background: string;
|
|
29
|
+
color: string;
|
|
30
|
+
radius: number;
|
|
31
|
+
fullWidth: boolean;
|
|
32
|
+
}
|
|
33
|
+
type LinkAction = 'url' | 'email' | 'phone';
|
|
34
|
+
interface ImageBlock extends BlockBase {
|
|
35
|
+
type: 'image';
|
|
36
|
+
src: string;
|
|
37
|
+
alt: string;
|
|
38
|
+
href: string;
|
|
39
|
+
linkAction: LinkAction;
|
|
40
|
+
align: BlockAlign;
|
|
41
|
+
width: number;
|
|
42
|
+
}
|
|
43
|
+
interface DividerBlock extends BlockBase {
|
|
44
|
+
type: 'divider';
|
|
45
|
+
color: string;
|
|
46
|
+
thickness: number;
|
|
47
|
+
}
|
|
48
|
+
interface SpacerBlock extends BlockBase {
|
|
49
|
+
type: 'spacer';
|
|
50
|
+
height: number;
|
|
51
|
+
}
|
|
52
|
+
interface SocialItem {
|
|
53
|
+
network: string;
|
|
54
|
+
url: string;
|
|
55
|
+
}
|
|
56
|
+
type IconStyle = 'circle' | 'rounded' | 'square';
|
|
57
|
+
interface SocialBlock extends BlockBase {
|
|
58
|
+
type: 'social';
|
|
59
|
+
align: BlockAlign;
|
|
60
|
+
size: number;
|
|
61
|
+
spacing: number;
|
|
62
|
+
iconStyle: IconStyle;
|
|
63
|
+
items: SocialItem[];
|
|
64
|
+
}
|
|
65
|
+
interface HtmlBlock extends BlockBase {
|
|
66
|
+
type: 'html';
|
|
67
|
+
html: string;
|
|
68
|
+
}
|
|
69
|
+
interface MenuItem {
|
|
70
|
+
label: string;
|
|
71
|
+
url: string;
|
|
72
|
+
}
|
|
73
|
+
interface MenuBlock extends BlockBase {
|
|
74
|
+
type: 'menu';
|
|
75
|
+
items: MenuItem[];
|
|
76
|
+
layout: 'horizontal' | 'vertical';
|
|
77
|
+
align: BlockAlign;
|
|
78
|
+
fontSize: number;
|
|
79
|
+
color: string;
|
|
80
|
+
gap: number;
|
|
81
|
+
}
|
|
82
|
+
type Block = TextBlock | HeadingBlock | ButtonBlock | ImageBlock | DividerBlock | SpacerBlock | SocialBlock | HtmlBlock | MenuBlock;
|
|
83
|
+
type BlockType = Block['type'];
|
|
84
|
+
interface Column {
|
|
85
|
+
id: string;
|
|
86
|
+
weight: number;
|
|
87
|
+
background: string;
|
|
88
|
+
paddingX: number;
|
|
89
|
+
paddingY: number;
|
|
90
|
+
blocks: Block[];
|
|
91
|
+
}
|
|
92
|
+
interface Row {
|
|
93
|
+
id: string;
|
|
94
|
+
background: string;
|
|
95
|
+
contentBackground: string;
|
|
96
|
+
backgroundImage: string;
|
|
97
|
+
paddingY: number;
|
|
98
|
+
gap: number;
|
|
99
|
+
columns: Column[];
|
|
100
|
+
}
|
|
101
|
+
interface EmailSettings {
|
|
102
|
+
backgroundColor: string;
|
|
103
|
+
contentWidth: number;
|
|
104
|
+
fontFamily: string;
|
|
105
|
+
textColor: string;
|
|
106
|
+
linkColor: string;
|
|
107
|
+
preheader: string;
|
|
108
|
+
}
|
|
109
|
+
interface EmailDesign {
|
|
110
|
+
version: 1;
|
|
111
|
+
settings: EmailSettings;
|
|
112
|
+
rows: Row[];
|
|
113
|
+
}
|
|
114
|
+
/** A pleasant starter so a fresh campaign is never a blank canvas. */
|
|
115
|
+
declare function defaultDesign(): EmailDesign;
|
|
116
|
+
/** Count blocks across every row/column — the wizard gates "next" on ≥1. */
|
|
117
|
+
declare function blockCount(design: EmailDesign): number;
|
|
118
|
+
|
|
119
|
+
interface BuilderLabels {
|
|
120
|
+
tabContent: string;
|
|
121
|
+
tabRows: string;
|
|
122
|
+
tabSettings: string;
|
|
123
|
+
blockNames: Record<BlockType, string>;
|
|
124
|
+
emailSettings: string;
|
|
125
|
+
rowSettings: string;
|
|
126
|
+
columnSettings: string;
|
|
127
|
+
columns: string;
|
|
128
|
+
back: string;
|
|
129
|
+
duplicate: string;
|
|
130
|
+
remove: string;
|
|
131
|
+
moveUp: string;
|
|
132
|
+
moveDown: string;
|
|
133
|
+
addRow: string;
|
|
134
|
+
empty: string;
|
|
135
|
+
dropRowHere: string;
|
|
136
|
+
undo: string;
|
|
137
|
+
redo: string;
|
|
138
|
+
preview: string;
|
|
139
|
+
desktop: string;
|
|
140
|
+
mobile: string;
|
|
141
|
+
edit: string;
|
|
142
|
+
bold: string;
|
|
143
|
+
italic: string;
|
|
144
|
+
underline: string;
|
|
145
|
+
strike: string;
|
|
146
|
+
link: string;
|
|
147
|
+
linkUrl: string;
|
|
148
|
+
bulletList: string;
|
|
149
|
+
numberList: string;
|
|
150
|
+
clearFormat: string;
|
|
151
|
+
insertField: string;
|
|
152
|
+
align: string;
|
|
153
|
+
fontSize: string;
|
|
154
|
+
textColor: string;
|
|
155
|
+
lineHeight: string;
|
|
156
|
+
level: string;
|
|
157
|
+
buttonLabel: string;
|
|
158
|
+
buttonHref: string;
|
|
159
|
+
buttonBg: string;
|
|
160
|
+
radius: string;
|
|
161
|
+
fullWidth: string;
|
|
162
|
+
imageUrl: string;
|
|
163
|
+
imageAlt: string;
|
|
164
|
+
imageHref: string;
|
|
165
|
+
imageWidth: string;
|
|
166
|
+
imageWidthHint: string;
|
|
167
|
+
uploadImage: string;
|
|
168
|
+
uploading: string;
|
|
169
|
+
dropImage: string;
|
|
170
|
+
color: string;
|
|
171
|
+
thickness: string;
|
|
172
|
+
height: string;
|
|
173
|
+
size: string;
|
|
174
|
+
textEditHint: string;
|
|
175
|
+
socialLinks: string;
|
|
176
|
+
socialAdd: string;
|
|
177
|
+
socialNetwork: string;
|
|
178
|
+
socialUrl: string;
|
|
179
|
+
iconSpacing: string;
|
|
180
|
+
htmlContent: string;
|
|
181
|
+
htmlHint: string;
|
|
182
|
+
menuItems: string;
|
|
183
|
+
menuAdd: string;
|
|
184
|
+
layout: string;
|
|
185
|
+
horizontal: string;
|
|
186
|
+
vertical: string;
|
|
187
|
+
itemGap: string;
|
|
188
|
+
action: string;
|
|
189
|
+
linkType: string;
|
|
190
|
+
openWebsite: string;
|
|
191
|
+
sendEmail: string;
|
|
192
|
+
callPhone: string;
|
|
193
|
+
linkValue: string;
|
|
194
|
+
rowBg: string;
|
|
195
|
+
contentBg: string;
|
|
196
|
+
backgroundImage: string;
|
|
197
|
+
paddingY: string;
|
|
198
|
+
paddingX: string;
|
|
199
|
+
blockGap: string;
|
|
200
|
+
columnBg: string;
|
|
201
|
+
containerPadding: string;
|
|
202
|
+
iconStyle: string;
|
|
203
|
+
styleCircle: string;
|
|
204
|
+
styleRounded: string;
|
|
205
|
+
styleSquare: string;
|
|
206
|
+
pageBg: string;
|
|
207
|
+
contentWidth: string;
|
|
208
|
+
font: string;
|
|
209
|
+
linkColor: string;
|
|
210
|
+
preheader: string;
|
|
211
|
+
preheaderHint: string;
|
|
212
|
+
}
|
|
213
|
+
declare const DEFAULT_LABELS: BuilderLabels;
|
|
214
|
+
|
|
215
|
+
interface EmailBuilderProps {
|
|
216
|
+
value: EmailDesign;
|
|
217
|
+
onChange: (design: EmailDesign) => void;
|
|
218
|
+
labels?: Partial<BuilderLabels>;
|
|
219
|
+
/** Host-provided uploader; without it the image block is URL-only. */
|
|
220
|
+
uploadImage?: (file: File) => Promise<string>;
|
|
221
|
+
/** Absolute base URL for hosted social icons (<base>/<slug>.png). Omit to
|
|
222
|
+
* use the built-in colored-chip fallback. */
|
|
223
|
+
socialIconBase?: string;
|
|
224
|
+
height?: number | string;
|
|
225
|
+
}
|
|
226
|
+
declare function EmailBuilder({ value, onChange, labels: labelOverrides, uploadImage, socialIconBase, height }: EmailBuilderProps): react.JSX.Element;
|
|
227
|
+
|
|
228
|
+
interface CompileOpts {
|
|
229
|
+
/** Absolute base URL for hosted social icons (<base>/<slug>.png). When
|
|
230
|
+
* omitted, social renders the self-contained colored-chip fallback. */
|
|
231
|
+
socialIconBase?: string;
|
|
232
|
+
}
|
|
233
|
+
/** Optional. Multi-column rows already stack on narrow screens via the fluid
|
|
234
|
+
* hybrid layout (no media query needed — Gmail included). This just widens
|
|
235
|
+
* the stacked columns to the full width on phones for a touch more polish;
|
|
236
|
+
* drop it into your email's <head> if you like (compileDesign emits body-only
|
|
237
|
+
* HTML, so the head belongs to the consumer's shell). */
|
|
238
|
+
declare const MOBILE_STACK_CSS = "<style>@media only screen and (max-width:600px){.eb-col{max-width:100%!important}}</style>";
|
|
239
|
+
/** Design → the body HTML stored in campaign.content.html. Full-bleed rows on
|
|
240
|
+
* the page background; the server appends the compliant footer at send time. */
|
|
241
|
+
declare function compileDesign(design: EmailDesign, opts?: CompileOpts): string;
|
|
242
|
+
|
|
243
|
+
export { type Block, type BlockType, type BuilderLabels, DEFAULT_LABELS, EmailBuilder, type EmailBuilderProps, type EmailDesign, type EmailSettings, MOBILE_STACK_CSS, type Row, blockCount, compileDesign, defaultDesign };
|