@one-million-lines/email-builder 0.3.0 → 0.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/CHANGELOG.md CHANGED
@@ -4,6 +4,96 @@ 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.5.0] — 2026-09-03
8
+
9
+ ### Added
10
+
11
+ - **Inline rich-text toolbar** — a floating, selection-aware formatting bar
12
+ appears whenever the cursor is inside a text element in the canvas.
13
+ - **Formatting**: Bold, Italic, Underline — toggle buttons that reflect the
14
+ current selection state via `queryCommandState`.
15
+ - **Font family** — email-safe font stacks: Arial, Georgia, Tahoma, Trebuchet
16
+ MS, Verdana, Courier New, Times New Roman. Wraps the selection in
17
+ `<span style="font-family:...">`.
18
+ - **Font size** — 10–48 px presets. Wraps the selection in
19
+ `<span style="font-size:...px">`.
20
+ - **Text colour** — 21 preset swatches plus a custom hex/name input. Uses
21
+ `styleWithCSS` + `execCommand('foreColor')` to produce
22
+ `<span style="color:...">`.
23
+ - **Insert link** — inline popover with a URL input and a special-link role
24
+ dropdown (Unsubscribe, View in browser, Manage preferences, User profile).
25
+ Wraps the selection in `<a href="..." data-link-type="...">` or inserts a
26
+ standalone linked anchor when nothing is selected.
27
+ - **Emoji picker** — 44 curated emojis in a compact grid; inserts at cursor.
28
+ - **Merge-tag inserter** — when `mergeTags` are configured, a tag dropdown
29
+ appears in the toolbar; selecting a tag inserts `{attribute}` at the cursor.
30
+ - The toolbar uses `onMouseDown + preventDefault` on all buttons so the
31
+ contenteditable never loses focus during formatting.
32
+ - New files: `src/editor/RichTextToolbar.tsx`, `src/editor/richTextState.ts`.
33
+
34
+ ### Changed
35
+
36
+ - **Right sidebar — Text element panel** simplified. Removed per-selection
37
+ properties (font family, font size, font weight, colour, link URL, link role,
38
+ merge-tag inserter) which are now handled by the inline toolbar. The sidebar
39
+ now only shows block-level settings: default alignment, line height, letter
40
+ spacing, padding, and visibility (hide on mobile / desktop).
41
+ - `TextRender` in `Canvas.tsx` now registers/unregisters itself with
42
+ `useRichTextStore` on focus/blur, supplies a `data-email-text` attribute for
43
+ toolbar targeting, and prevents click events from bubbling when the element is
44
+ actively being edited (so caret repositioning clicks don't deselect the element).
45
+
46
+ ## [0.4.0] — 2026-09-03
47
+
48
+ ### Added
49
+
50
+ - **Special link types** — links can now be tagged with a semantic `linkType`
51
+ (`"unsubscribe"`, `"view_in_browser"`, `"manage_preferences"`, `"user_profile"`).
52
+ The rendered HTML carries both a `data-link-type` attribute on the anchor element
53
+ **and** a placeholder href value (e.g. `{{unsubscribe_url}}`), giving backend
54
+ processors two independent ways to locate and replace per-recipient URLs.
55
+ - New `SpecialLinkType` union type and `SPECIAL_LINK_PLACEHOLDERS` map exported
56
+ from `core/types`.
57
+ - `linkType` field added to `ButtonElement` and `ImageElement` (top-level), and
58
+ to `TextElement.style` (alongside the existing `link`).
59
+ - `text()`, `button()`, and `muted()` helpers accept a `linkType` option.
60
+ - New `footerLinks(links, opts?)` helper creates a text element with multiple
61
+ inline `<a>` tags, each carrying `data-link-type` — ideal for footer lines
62
+ that combine "Unsubscribe · View in browser".
63
+ - All built-in footer modules updated to use `footerLinks()` and proper
64
+ `linkType` values instead of plain text.
65
+ - Right sidebar: **Link role** dropdown added below the Link URL field for
66
+ Text, Image, and Button elements. Selecting a role auto-fills the placeholder
67
+ URL.
68
+ - `safeUrl()` updated to pass through `{{...}}` placeholder URLs without
69
+ stripping them.
70
+ - Validation schema updated to accept `linkType` on image and button elements.
71
+
72
+ - **Merge tags / personalisation tokens** — dynamic content placeholders can
73
+ now be configured and inserted directly from the editor sidebar.
74
+ - New `MergeTag` interface: `{ attribute: string; title: string }`.
75
+ - New `mergeTags` prop on `<EmailBuilder>` (React) and `createEmailBuilder`
76
+ (vanilla) accepts an array of merge tag definitions.
77
+ - New `builder.registerMergeTags(tags)` method on `BuilderHandle` for plugin
78
+ authors.
79
+ - New `useMergeTagsStore` reactive store (`src/plugins/mergeTags/state.ts`).
80
+ - Right sidebar: **Insert merge tag** dropdown appears in the Text element
81
+ panel when merge tags are configured. Selecting one appends
82
+ `{attribute}` to the text content.
83
+ - `MergeTag` exported from the public API.
84
+
85
+ - **Template & block authoring guide** (`src/templates/TEMPLATE_GUIDE.md`) —
86
+ comprehensive, AI-agent-ready reference documenting:
87
+ - The full `EmailDocument` JSON schema
88
+ - All element types and their style options
89
+ - Module structure, categories, and naming conventions
90
+ - Theme tokens and how to reference them
91
+ - All helper functions with options tables
92
+ - Special link types and merge tags
93
+ - Step-by-step instructions for creating new templates and module definitions
94
+ - A complete template code example
95
+ - A commit checklist and AI agent prompt template
96
+
7
97
  ## [Unreleased]
8
98
 
9
99
  ### Added
@@ -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<{
@@ -49,6 +49,8 @@ export interface BuilderHandle {
49
49
  registerProductProvider: (provider: ProductProvider) => void;
50
50
  registerVoucherProvider: (provider: VoucherProvider) => void;
51
51
  setAIProvider: (provider: AIProvider) => void;
52
+ /** Configure the list of merge tags available in the text element sidebar. */
53
+ registerMergeTags: (tags: MergeTag[]) => void;
52
54
  }
53
55
  export type PluginType = "modules" | "themes" | "asset-provider" | "product-provider" | "voucher-provider" | "ai-provider";
54
56
  export interface Plugin {
@@ -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;
@@ -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;
@@ -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;
@@ -104,12 +106,14 @@ export declare const documentSchema: z.ZodObject<{
104
106
  src: z.ZodString;
105
107
  alt: z.ZodOptional<z.ZodString>;
106
108
  link: z.ZodOptional<z.ZodString>;
109
+ linkType: z.ZodOptional<z.ZodString>;
107
110
  style: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodRecord<z.ZodString, z.ZodAny>]>>>;
108
111
  }, z.core.$strip>, z.ZodObject<{
109
112
  id: z.ZodString;
110
113
  type: z.ZodLiteral<"button">;
111
114
  label: z.ZodString;
112
115
  link: z.ZodString;
116
+ linkType: z.ZodOptional<z.ZodString>;
113
117
  style: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber, z.ZodBoolean, z.ZodRecord<z.ZodString, z.ZodAny>]>>>;
114
118
  }, z.core.$strip>, z.ZodObject<{
115
119
  id: z.ZodString;
@@ -0,0 +1 @@
1
+ export declare function RichTextToolbar(): import("react").JSX.Element | null;
@@ -0,0 +1,7 @@
1
+ interface RichTextState {
2
+ /** The currently focused contenteditable element, or null. */
3
+ activeEl: HTMLElement | null;
4
+ setActiveEl: (el: HTMLElement | null) => void;
5
+ }
6
+ export declare const useRichTextStore: import('zustand').UseBoundStore<import('zustand').StoreApi<RichTextState>>;
7
+ export {};