@one-million-lines/email-builder 0.2.2 → 0.4.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/CHANGELOG.md CHANGED
@@ -4,6 +4,57 @@ All notable changes to this package are documented here.
4
4
  This project adheres to [Semantic Versioning](https://semver.org/) and the
5
5
  [Keep a Changelog](https://keepachangelog.com/) format.
6
6
 
7
+ ## [0.4.0] — 2026-09-03
8
+
9
+ ### Added
10
+
11
+ - **Special link types** — links can now be tagged with a semantic `linkType`
12
+ (`"unsubscribe"`, `"view_in_browser"`, `"manage_preferences"`, `"user_profile"`).
13
+ The rendered HTML carries both a `data-link-type` attribute on the anchor element
14
+ **and** a placeholder href value (e.g. `{{unsubscribe_url}}`), giving backend
15
+ processors two independent ways to locate and replace per-recipient URLs.
16
+ - New `SpecialLinkType` union type and `SPECIAL_LINK_PLACEHOLDERS` map exported
17
+ from `core/types`.
18
+ - `linkType` field added to `ButtonElement` and `ImageElement` (top-level), and
19
+ to `TextElement.style` (alongside the existing `link`).
20
+ - `text()`, `button()`, and `muted()` helpers accept a `linkType` option.
21
+ - New `footerLinks(links, opts?)` helper creates a text element with multiple
22
+ inline `<a>` tags, each carrying `data-link-type` — ideal for footer lines
23
+ that combine "Unsubscribe · View in browser".
24
+ - All built-in footer modules updated to use `footerLinks()` and proper
25
+ `linkType` values instead of plain text.
26
+ - Right sidebar: **Link role** dropdown added below the Link URL field for
27
+ Text, Image, and Button elements. Selecting a role auto-fills the placeholder
28
+ URL.
29
+ - `safeUrl()` updated to pass through `{{...}}` placeholder URLs without
30
+ stripping them.
31
+ - Validation schema updated to accept `linkType` on image and button elements.
32
+
33
+ - **Merge tags / personalisation tokens** — dynamic content placeholders can
34
+ now be configured and inserted directly from the editor sidebar.
35
+ - New `MergeTag` interface: `{ attribute: string; title: string }`.
36
+ - New `mergeTags` prop on `<EmailBuilder>` (React) and `createEmailBuilder`
37
+ (vanilla) accepts an array of merge tag definitions.
38
+ - New `builder.registerMergeTags(tags)` method on `BuilderHandle` for plugin
39
+ authors.
40
+ - New `useMergeTagsStore` reactive store (`src/plugins/mergeTags/state.ts`).
41
+ - Right sidebar: **Insert merge tag** dropdown appears in the Text element
42
+ panel when merge tags are configured. Selecting one appends
43
+ `{attribute}` to the text content.
44
+ - `MergeTag` exported from the public API.
45
+
46
+ - **Template & block authoring guide** (`src/templates/TEMPLATE_GUIDE.md`) —
47
+ comprehensive, AI-agent-ready reference documenting:
48
+ - The full `EmailDocument` JSON schema
49
+ - All element types and their style options
50
+ - Module structure, categories, and naming conventions
51
+ - Theme tokens and how to reference them
52
+ - All helper functions with options tables
53
+ - Special link types and merge tags
54
+ - Step-by-step instructions for creating new templates and module definitions
55
+ - A complete template code example
56
+ - A commit checklist and AI agent prompt template
57
+
7
58
  ## [Unreleased]
8
59
 
9
60
  ### Added
package/README.md CHANGED
@@ -155,6 +155,49 @@ response is validated with Zod before it is applied. See
155
155
  [`src/ai/README.md`](./src/ai/README.md) and
156
156
  [`backend/README.md`](./backend/README.md).
157
157
 
158
+ ### Product search
159
+
160
+ Connect product cards to your catalog. When a product provider is configured, a
161
+ **Find** button appears on any product grid (and a search icon on each card).
162
+ Searching opens a modal, previews the returned product, and — on **Save** —
163
+ populates the card's fields (which stay fully editable afterwards).
164
+
165
+ ```tsx
166
+ <EmailBuilder productEndpoint="http://localhost:3001/products/search" />
167
+ ```
168
+
169
+ ```ts
170
+ import { registerPlugin, productSearchPlugin } from "@one-million-lines/email-builder";
171
+ registerPlugin(productSearchPlugin({ endpoint: "http://localhost:3001/products/search" }));
172
+ ```
173
+
174
+ The demo backend in [`backend/`](./backend) ships a `/products/search` endpoint
175
+ over a small in-memory catalog. Product fields are `title`, `final_price`,
176
+ `old_price` (optional), `description`, `link`, `image` and `stars` (optional).
177
+ See [`src/plugins/productSearch/README.md`](./src/plugins/productSearch/README.md)
178
+ for the wire protocol and response mapping.
179
+
180
+ ### Voucher select
181
+
182
+ Let users pick a discount code from your backend instead of typing it. When a
183
+ voucher provider is configured, selecting a **voucher block** (the built-in
184
+ **Voucher Code** module, or any module with a `voucherCode` text element) shows a
185
+ **Select voucher** dropdown that fills the code. The list is fetched once and
186
+ cached (lazily, or eagerly with `preload`).
187
+
188
+ ```tsx
189
+ <EmailBuilder voucherEndpoint="http://localhost:3001/vouchers" />
190
+ ```
191
+
192
+ ```ts
193
+ import { registerPlugin, voucherPlugin } from "@one-million-lines/email-builder";
194
+ registerPlugin(voucherPlugin({ endpoint: "http://localhost:3001/vouchers", preload: true }));
195
+ ```
196
+
197
+ The demo backend ships a `GET /vouchers` endpoint. See
198
+ [`src/plugins/voucherSelect/README.md`](./src/plugins/voucherSelect/README.md)
199
+ for the wire protocol and response mapping.
200
+
158
201
  ### Styling & isolation
159
202
 
160
203
  The stylesheet at `@one-million-lines/email-builder/styles.css` is designed to
@@ -1,5 +1,5 @@
1
1
  import { ModuleDefinition } from '../modules/registry';
2
- import { Theme } from './types';
2
+ import { Theme, MergeTag } from './types';
3
3
  import { AIProvider } from './aiActions';
4
4
  export interface AssetProvider {
5
5
  upload: (file: File) => Promise<{
@@ -7,13 +7,52 @@ export interface AssetProvider {
7
7
  alt?: string;
8
8
  }>;
9
9
  }
10
+ /**
11
+ * A single product returned by a {@link ProductProvider} search. Field names
12
+ * match the builder's `Product` model so results drop straight into a card.
13
+ */
14
+ export interface ProductSearchResult {
15
+ name: string;
16
+ finalPrice: string;
17
+ oldPrice?: string;
18
+ description?: string;
19
+ link?: string;
20
+ image?: string;
21
+ imageAlt?: string;
22
+ stars?: number;
23
+ /** Optional external identifier echoed back from the backend. */
24
+ sku?: string;
25
+ }
26
+ export interface ProductProvider {
27
+ /** Look up a single product for a free-text query. Resolves null if none. */
28
+ search: (query: string) => Promise<ProductSearchResult | null>;
29
+ }
30
+ /** A discount/voucher entry returned by a {@link VoucherProvider}. */
31
+ export interface Voucher {
32
+ /** Stable identifier (used to remember the selection). */
33
+ id: string;
34
+ /** Human label shown in the select dropdown. */
35
+ title: string;
36
+ /** The code (or merge tag) inserted into the voucher block. */
37
+ code: string;
38
+ /** Optional longer description. */
39
+ description?: string;
40
+ }
41
+ export interface VoucherProvider {
42
+ /** Load the list of vouchers to choose from. */
43
+ list: () => Promise<Voucher[]>;
44
+ }
10
45
  export interface BuilderHandle {
11
46
  registerModule: (def: ModuleDefinition) => void;
12
47
  registerTheme: (theme: Theme) => void;
13
48
  registerAssetProvider: (provider: AssetProvider) => void;
49
+ registerProductProvider: (provider: ProductProvider) => void;
50
+ registerVoucherProvider: (provider: VoucherProvider) => void;
14
51
  setAIProvider: (provider: AIProvider) => void;
52
+ /** Configure the list of merge tags available in the text element sidebar. */
53
+ registerMergeTags: (tags: MergeTag[]) => void;
15
54
  }
16
- export type PluginType = "modules" | "themes" | "asset-provider" | "ai-provider";
55
+ export type PluginType = "modules" | "themes" | "asset-provider" | "product-provider" | "voucher-provider" | "ai-provider";
17
56
  export interface Plugin {
18
57
  name: string;
19
58
  type: PluginType;
@@ -23,4 +62,6 @@ export declare const builder: BuilderHandle;
23
62
  export declare function registerPlugin(plugin: Plugin): void;
24
63
  export declare function getRegisteredThemes(): Theme[];
25
64
  export declare function getAssetProvider(): AssetProvider | null;
65
+ export declare function getProductProvider(): ProductProvider | null;
66
+ export declare function getVoucherProvider(): VoucherProvider | null;
26
67
  export declare function getAIProvider(): AIProvider | null;
@@ -1,4 +1,24 @@
1
1
  export type ElementType = "text" | "image" | "button" | "spacer" | "divider" | "productGrid";
2
+ /**
3
+ * Marks a link as a well-known system link. Backend processors replace the
4
+ * placeholder href (e.g. `{{unsubscribe_url}}`) with a real per-recipient URL.
5
+ * The rendered HTML also carries a `data-link-type` attribute so backends that
6
+ * parse HTML can find and replace these links without inspecting the JSON.
7
+ */
8
+ export type SpecialLinkType = "unsubscribe" | "view_in_browser" | "manage_preferences" | "user_profile";
9
+ /** Placeholder href values automatically set when a SpecialLinkType is chosen. */
10
+ export declare const SPECIAL_LINK_PLACEHOLDERS: Record<SpecialLinkType, string>;
11
+ /**
12
+ * A merge tag / personalisation token. Configured via the `mergeTags` prop on
13
+ * `<EmailBuilder>` or via `builder.registerMergeTags(tags)`.
14
+ * When the user picks a tag, `{attribute}` is inserted into the text content.
15
+ */
16
+ export interface MergeTag {
17
+ /** Dot-notation path used as the placeholder, e.g. `"user.firstname"`. */
18
+ attribute: string;
19
+ /** Human-readable label shown in the sidebar dropdown, e.g. `"First name"`. */
20
+ title: string;
21
+ }
2
22
  export interface BaseStyle {
3
23
  paddingTop?: number;
4
24
  paddingBottom?: number;
@@ -18,7 +38,7 @@ export interface BaseStyle {
18
38
  export interface TextElement {
19
39
  id: string;
20
40
  type: "text";
21
- role?: "headline" | "subheadline" | "body" | "caption";
41
+ role?: "headline" | "subheadline" | "body" | "caption" | "voucherCode";
22
42
  content: string;
23
43
  style?: BaseStyle & {
24
44
  fontFamily?: string;
@@ -28,6 +48,8 @@ export interface TextElement {
28
48
  fontWeight?: number | string;
29
49
  color?: string;
30
50
  link?: string;
51
+ /** Marks the element link as a well-known system link (e.g. unsubscribe). */
52
+ linkType?: SpecialLinkType;
31
53
  };
32
54
  }
33
55
  export interface ImageElement {
@@ -36,6 +58,8 @@ export interface ImageElement {
36
58
  src: string;
37
59
  alt?: string;
38
60
  link?: string;
61
+ /** Marks the image link as a well-known system link. */
62
+ linkType?: SpecialLinkType;
39
63
  style?: BaseStyle & {
40
64
  width?: number;
41
65
  height?: number;
@@ -46,6 +70,8 @@ export interface ButtonElement {
46
70
  type: "button";
47
71
  label: string;
48
72
  link: string;
73
+ /** Marks the button link as a well-known system link. */
74
+ linkType?: SpecialLinkType;
49
75
  style?: BaseStyle & {
50
76
  backgroundColor?: string;
51
77
  color?: string;
@@ -79,6 +105,8 @@ export interface Product {
79
105
  description?: string;
80
106
  link?: string;
81
107
  buttonLabel?: string;
108
+ /** Optional 0–5 rating. Rendered as star glyphs when the grid shows stars. */
109
+ stars?: number;
82
110
  }
83
111
  export interface ProductGridElement {
84
112
  id: string;
@@ -88,6 +116,8 @@ export interface ProductGridElement {
88
116
  showOldPrice: boolean;
89
117
  showButton: boolean;
90
118
  showDescription: boolean;
119
+ /** Show the star rating on each product card. Optional for back-compat. */
120
+ showStars?: boolean;
91
121
  buttonLabel?: string;
92
122
  style?: BaseStyle & {
93
123
  nameColor?: string;
@@ -1,3 +1,10 @@
1
1
  export declare function uid(prefix?: string): string;
2
2
  export declare function escapeHtml(s: string): string;
3
3
  export declare function safeUrl(url: string | undefined): string;
4
+ /** Default gold used for product star ratings. */
5
+ export declare const STAR_COLOR = "#F5A623";
6
+ /**
7
+ * Build a 5-glyph star string for a 0–5 rating (rounded to the nearest whole
8
+ * star). Uses ★ (full) and ☆ (empty) so it renders in any email client.
9
+ */
10
+ export declare function starGlyphs(rating: number): string;
@@ -16,12 +16,14 @@ export declare const moduleSchema: z.ZodObject<{
16
16
  src: z.ZodString;
17
17
  alt: z.ZodOptional<z.ZodString>;
18
18
  link: z.ZodOptional<z.ZodString>;
19
+ linkType: z.ZodOptional<z.ZodString>;
19
20
  style: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodRecord<z.ZodString, z.ZodAny>]>>>;
20
21
  }, z.core.$strip>, z.ZodObject<{
21
22
  id: z.ZodString;
22
23
  type: z.ZodLiteral<"button">;
23
24
  label: z.ZodString;
24
25
  link: z.ZodString;
26
+ linkType: z.ZodOptional<z.ZodString>;
25
27
  style: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodRecord<z.ZodString, z.ZodAny>]>>>;
26
28
  }, z.core.$strip>, z.ZodObject<{
27
29
  id: z.ZodString;
@@ -44,11 +46,13 @@ export declare const moduleSchema: z.ZodObject<{
44
46
  description: z.ZodOptional<z.ZodString>;
45
47
  link: z.ZodOptional<z.ZodString>;
46
48
  buttonLabel: z.ZodOptional<z.ZodString>;
49
+ stars: z.ZodOptional<z.ZodNumber>;
47
50
  }, z.core.$strip>>;
48
51
  columns: z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<3>]>;
49
52
  showOldPrice: z.ZodBoolean;
50
53
  showButton: z.ZodBoolean;
51
54
  showDescription: z.ZodBoolean;
55
+ showStars: z.ZodOptional<z.ZodBoolean>;
52
56
  buttonLabel: z.ZodOptional<z.ZodString>;
53
57
  style: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodRecord<z.ZodString, z.ZodAny>]>>>;
54
58
  }, z.core.$strip>], "type">>;
@@ -102,12 +106,14 @@ export declare const documentSchema: z.ZodObject<{
102
106
  src: z.ZodString;
103
107
  alt: z.ZodOptional<z.ZodString>;
104
108
  link: z.ZodOptional<z.ZodString>;
109
+ linkType: z.ZodOptional<z.ZodString>;
105
110
  style: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodRecord<z.ZodString, z.ZodAny>]>>>;
106
111
  }, z.core.$strip>, z.ZodObject<{
107
112
  id: z.ZodString;
108
113
  type: z.ZodLiteral<"button">;
109
114
  label: z.ZodString;
110
115
  link: z.ZodString;
116
+ linkType: z.ZodOptional<z.ZodString>;
111
117
  style: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodRecord<z.ZodString, z.ZodAny>]>>>;
112
118
  }, z.core.$strip>, z.ZodObject<{
113
119
  id: z.ZodString;
@@ -130,11 +136,13 @@ export declare const documentSchema: z.ZodObject<{
130
136
  description: z.ZodOptional<z.ZodString>;
131
137
  link: z.ZodOptional<z.ZodString>;
132
138
  buttonLabel: z.ZodOptional<z.ZodString>;
139
+ stars: z.ZodOptional<z.ZodNumber>;
133
140
  }, z.core.$strip>>;
134
141
  columns: z.ZodUnion<readonly [z.ZodLiteral<1>, z.ZodLiteral<2>, z.ZodLiteral<3>]>;
135
142
  showOldPrice: z.ZodBoolean;
136
143
  showButton: z.ZodBoolean;
137
144
  showDescription: z.ZodBoolean;
145
+ showStars: z.ZodOptional<z.ZodBoolean>;
138
146
  buttonLabel: z.ZodOptional<z.ZodString>;
139
147
  style: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodRecord<z.ZodString, z.ZodAny>]>>>;
140
148
  }, z.core.$strip>], "type">>;