//#region src/blocks.d.ts interface SpacingValue { top: number; right: number; bottom: number; left: number; } interface BlockStyles { padding: SpacingValue; backgroundColor?: string; } interface BlockVisibility { desktop: boolean; mobile: boolean; } interface BaseBlock { id: string; type: string; styles: BlockStyles; visibility?: BlockVisibility; displayCondition?: { label: string; before: string; after: string; group?: string; description?: string; }; } type ColumnLayout = "1" | "2" | "3" | "2-1" | "1-2"; /** * Optional outer frame for a section. When present, the section is rendered * inside an `mj-wrapper` — a full-width band (its own background + padding) * that frames the section, e.g. a white card sitting on a colored band. */ interface SectionWrapper { backgroundColor?: string; padding?: SpacingValue; /** Corner radius in px for the outer frame. Omitted/0 = square corners. */ borderRadius?: number; } interface SectionBlock extends BaseBlock { type: "section"; columns: ColumnLayout; children: Block[][]; /** * Whether columns stack vertically on mobile. Absent or `true` keeps MJML's * default responsive behavior (columns stack below 480px). `false` renders * the columns inside an `` so they stay side-by-side on mobile, * proportionally shrunk to fit. */ stackOnMobile?: boolean; /** Corner radius in px. Omitted/0 = square corners. */ borderRadius?: number; /** Optional outer frame (rendered as an `mj-wrapper` around the section). */ wrapper?: SectionWrapper; } type HeadingLevel = 1 | 2 | 3 | 4; declare const HEADING_LEVEL_FONT_SIZE: Record; interface TitleBlock extends BaseBlock { type: "title"; content: string; level: HeadingLevel; /** Text color. Unset = inherit the document-level `textColor`. */ color?: string; textAlign: "left" | "center" | "right"; fontFamily?: string; } interface ParagraphBlock extends BaseBlock { type: "paragraph"; content: string; } interface ImageBlock extends BaseBlock { type: "image"; src: string; alt: string; width: number | "full"; /** * Height in pixels. Absent means the height is derived from the width, so the * image keeps its aspect ratio — setting both stretches it, since email * clients don't support `object-fit`. */ height?: number; align: "left" | "center" | "right"; linkUrl?: string; linkOpenInNewTab?: boolean; placeholderUrl?: string; decorative?: boolean; } interface ButtonBlock extends BaseBlock { type: "button"; text: string; url: string; openInNewTab?: boolean; backgroundColor: string; textColor: string; borderRadius: number; fontSize: number; buttonPadding: SpacingValue; fontFamily?: string; width?: number | "full"; /** * Placement of the button within its column. No visible effect when `width` * is `"full"`, since the button then spans the column. */ align: "left" | "center" | "right"; } interface DividerBlock extends BaseBlock { type: "divider"; lineStyle: "solid" | "dashed" | "dotted"; color: string; thickness: number; width: number | "full"; } interface VideoBlock extends BaseBlock { type: "video"; url: string; openInNewTab?: boolean; thumbnailUrl: string; alt: string; width: number | "full"; /** * Height in pixels for the thumbnail. Absent means the height is derived from * the width, so the thumbnail keeps its aspect ratio — setting both stretches * it, since email clients don't support `object-fit`. */ height?: number; align: "left" | "center" | "right"; placeholderUrl?: string; } type SocialPlatform = "facebook" | "twitter" | "instagram" | "linkedin" | "youtube" | "tiktok" | "pinterest" | "email" | "whatsapp" | "telegram" | "discord" | "snapchat" | "reddit" | "github" | "dribbble" | "behance" | "website"; type SocialIconStyle = "solid" | "outlined" | "rounded" | "square" | "circle"; type SocialIconSize = "small" | "medium" | "large"; interface SocialIcon { id: string; platform: SocialPlatform; url: string; } interface SocialIconsBlock extends BaseBlock { type: "social"; icons: SocialIcon[]; iconStyle: SocialIconStyle; iconSize: SocialIconSize; spacing: number; align: "left" | "center" | "right"; } interface SpacerBlock extends BaseBlock { type: "spacer"; height: number; } interface HtmlBlock extends BaseBlock { type: "html"; content: string; } interface MenuItemData { id: string; text: string; url: string; openInNewTab: boolean; bold: boolean; underline: boolean; color?: string; } interface MenuBlock extends BaseBlock { type: "menu"; items: MenuItemData[]; fontSize: number; fontFamily?: string; /** Base text/link color. Unset = inherit the document-level `textColor`. */ color?: string; linkColor?: string; textAlign: "left" | "center" | "right"; separator: string; separatorColor: string; spacing: number; } interface TableCellData { id: string; content: string; } interface TableRowData { id: string; cells: TableCellData[]; } interface TableBlock extends BaseBlock { type: "table"; rows: TableRowData[]; hasHeaderRow: boolean; headerBackgroundColor?: string; borderColor: string; borderWidth: number; cellPadding: number; fontSize: number; fontFamily?: string; /** Text color. Unset = inherit the document-level `textColor`. */ color?: string; textAlign: "left" | "center" | "right"; } interface CountdownBlock extends BaseBlock { type: "countdown"; targetDate: string; timezone: string; showDays: boolean; showHours: boolean; showMinutes: boolean; showSeconds: boolean; separator: ":" | "-" | " "; digitFontSize: number; digitColor: string; labelColor: string; labelFontSize: number; backgroundColor: string; fontFamily?: string; labelDays: string; labelHours: string; labelMinutes: string; labelSeconds: string; expiredMessage: string; expiredImageUrl: string; hideOnExpiry: boolean; } interface CustomBlock extends BaseBlock { type: "custom"; customType: string; fieldValues: Record; renderedHtml?: string; dataSourceFetched?: boolean; } type Block = SectionBlock | TitleBlock | ParagraphBlock | ImageBlock | ButtonBlock | DividerBlock | VideoBlock | SocialIconsBlock | SpacerBlock | HtmlBlock | MenuBlock | TableBlock | CountdownBlock | CustomBlock; type BlockType = Block["type"]; //#endregion //#region src/social.d.ts /** * SVG glyph (24×24 path) + brand color for each social platform. * * Single source of truth shared by two consumers that render icons in * different formats: the editor draws them as inline SVG (`SocialIconSvg.vue`) * and `@templatical/renderer` rasterizes them to PNGs * (`scripts/rasterize-social.mjs`) because Outlook's Word engine has no SVG * support. Typing as `Record` makes adding a platform to * the union a compile error until its glyph is supplied here, keeping both * consumers in lockstep. */ interface SocialIconGlyph { /** SVG path data on a 24×24 viewBox. */ path: string; /** Brand color as a 6-digit hex string. */ color: string; } declare const SOCIAL_ICON_GLYPHS: Record; //#endregion //#region src/saved-blocks.d.ts /** * A reusable, user-authored group of blocks — saved from the canvas and * re-insertable into any template. * * Distinct from a *custom block* (`CustomBlockDefinition`), which is a * developer-defined block **type** with its own template and field schema. * A saved block is an instance-level snapshot of ordinary blocks. */ interface SavedBlock { /** * Store-assigned identifier. Returned by {@link SavedBlocksProvider.create} * — the editor never generates it, so the store stays the authority on * identity (database primary key, storage slug, etc.). */ id: string; name: string; /** * Top-level blocks captured in this saved block. A `section` carries its own * `children`, so a whole section-with-columns round-trips as one entry. * * Blocks are re-identified on insert (via `cloneBlock`), so the IDs stored * here never collide with the blocks already on a canvas. */ content: Block[]; /** * Optional free-text grouping, surfaced in the browser as a filter. * * Flat and free-text by design — there is no category registry and no * nesting. The editor derives the set of available categories from the * entries it has loaded, so a category exists exactly as long as something * carries it; an entry without one is simply uncategorised. */ category?: string; /** * Per-entry permission carve-outs. **Absent means allowed** — the provider's * `update` / `delete` already say whether the capability exists at all, so * these exist only to forbid it on *particular* entries (a shared block a * viewer may insert but not edit, someone else's block, a locked entry). * * Return them from your API alongside the row, where the answer is already * known — the editor never second-guesses them and never computes its own. * When one is `false` the corresponding control is not rendered for that * entry. */ canUpdate?: boolean; canDelete?: boolean; /** * Store-assigned timestamps, used for display only: the browser shows a * relative "5m ago" label per entry (preferring `updatedAt`, falling back to * `createdAt`) with the absolute date on hover. * * They do **not** affect ordering — the editor renders whatever order * `list()` returns and never re-sorts. Both are optional; omit them and the * label is simply not shown. */ createdAt?: string; updatedAt?: string; } /** * Parameters for {@link SavedBlocksProvider.list}. An object (rather than * positional arguments) so future filters can be added without breaking * existing provider implementations. * * **These are only sent by headless callers.** The editor's own browser calls * `list()` with no parameters and filters the loaded entries in memory — that * way a provider stays a dumb store and still gets a working search box and * category filter. They arrive only when you drive `useSavedBlocks` * yourself (see the guide's "Headless use"), so implement them if you want * server-side filtering for your own UI and ignore them otherwise. */ interface SavedBlocksListParams { /** Free-text filter over the saved block's `name`. */ search?: string; /** Exact-match filter over {@link SavedBlock.category}. */ category?: string; } /** Payload for {@link SavedBlocksProvider.create}. */ interface SavedBlockInput { name: string; content: Block[]; category?: string; } /** * Partial patch for {@link SavedBlocksProvider.update}. Only the keys present * are being changed — `category: ""` clears the category, whereas omitting the * key leaves it alone. */ type SavedBlockPatch = Partial<{ name: string; content: Block[]; category: string; }>; /** * Storage contract for saved blocks. Implement it to back the editor's saved * blocks UI with your own persistence — the editor owns the save dialog, the * browser, and insertion; you own the transport. * * Pass an implementation as `savedBlocks` to `init()`. When omitted, the * feature stays off entirely and none of its UI renders. * * Every method may reject; the editor surfaces the failure through the * editor's `onError` callback and leaves its in-memory list untouched. * * **Each mutation can be turned off by passing `false` instead of a function.** * The editor then hides the affordance rather than letting the user try and * fail. They are required rather than optional precisely so that disabling is a * decision you state, never something you get by forgetting a method. Setting * all three yields a **read-only library**: users still browse, preview and * insert, because insertion only touches the canvas and never your store. * * ```ts * const provider: SavedBlocksProvider = { * list: ({ search } = {}) => * fetch(`/api/saved-blocks?search=${search ?? ""}`).then((r) => r.json()), * create: (input) => * fetch("/api/saved-blocks", { * method: "POST", * headers: { "Content-Type": "application/json" }, * body: JSON.stringify(input), * }).then((r) => r.json()), * update: (id, patch) => * fetch(`/api/saved-blocks/${id}`, { * method: "PUT", * headers: { "Content-Type": "application/json" }, * body: JSON.stringify(patch), * }).then((r) => r.json()), * delete: (id) => * fetch(`/api/saved-blocks/${id}`, { method: "DELETE" }).then(() => undefined), * }; * ``` */ interface SavedBlocksProvider { /** * Fetch saved blocks. The editor calls this with no arguments and expects * everything the current user may see — scoping the result per user, tenant * or permission is yours to do here. {@link SavedBlocksListParams} is only * populated by headless callers; honouring it is optional. * * The one method that cannot be disabled: without it the feature has nothing * to show. */ list(params?: SavedBlocksListParams): Promise; /** * Persist a new saved block and return it with its store-assigned `id`, or * `false` to disable saving entirely — the block chrome's bookmark action * disappears and no pick session can be started. */ create: false | ((input: SavedBlockInput) => Promise); /** * Apply a partial update and return the stored result, or `false` to disable * editing entirely. Renaming is `update(id, { name })` and recategorising is * `update(id, { category })` — there are no separate methods for either. * * To allow editing in general but forbid it on particular entries, keep the * function and set {@link SavedBlock.canUpdate} to `false` on those. */ update: false | ((id: string, patch: SavedBlockPatch) => Promise); /** * Remove a saved block, resolving once the store has applied the delete, or * `false` to disable deletion entirely. Per-entry exceptions go through * {@link SavedBlock.canDelete}. */ delete: false | ((id: string) => Promise); } //#endregion //#region src/defaults.d.ts type BlockDefaultsFor = Partial>; interface BlockDefaults { title?: BlockDefaultsFor; paragraph?: BlockDefaultsFor; image?: BlockDefaultsFor; button?: BlockDefaultsFor; divider?: BlockDefaultsFor; section?: BlockDefaultsFor; video?: BlockDefaultsFor; social?: BlockDefaultsFor; spacer?: BlockDefaultsFor; html?: BlockDefaultsFor; menu?: BlockDefaultsFor; table?: BlockDefaultsFor; countdown?: BlockDefaultsFor; } type TemplateDefaults = Partial; declare const TITLE_BLOCK_DEFAULTS: BlockDefaultsFor; declare const PARAGRAPH_BLOCK_DEFAULTS: BlockDefaultsFor; declare const IMAGE_BLOCK_DEFAULTS: BlockDefaultsFor; declare const BUTTON_BLOCK_DEFAULTS: BlockDefaultsFor; declare const DIVIDER_BLOCK_DEFAULTS: BlockDefaultsFor; declare const SECTION_BLOCK_DEFAULTS: BlockDefaultsFor; declare const VIDEO_BLOCK_DEFAULTS: BlockDefaultsFor; declare const SOCIAL_ICONS_BLOCK_DEFAULTS: BlockDefaultsFor; declare const SPACER_BLOCK_DEFAULTS: BlockDefaultsFor; declare const HTML_BLOCK_DEFAULTS: BlockDefaultsFor; declare const MENU_BLOCK_DEFAULTS: BlockDefaultsFor; declare const TABLE_BLOCK_DEFAULTS: BlockDefaultsFor; declare const COUNTDOWN_BLOCK_DEFAULTS: BlockDefaultsFor; declare const DEFAULT_BLOCK_DEFAULTS: Required; declare const DEFAULT_TEMPLATE_DEFAULTS: TemplateDefaults; declare function deepMergeDefaults(base: T, overrides: Partial): T; //#endregion //#region src/template.d.ts interface TemplateSettings { width: number; backgroundColor: string; /** * Document-level default text color: the `` default in the rendered * MJML, inherited by every text block (Title, Paragraph, Menu, Table) that * doesn't set its own `color`. Required, defaulting to `#1a1a1a` (see * `DEFAULT_TEMPLATE_DEFAULTS`); customize the default per-consumer via * `templateDefaults`. A block's explicit `color` or an inline text-color * mark overrides it. */ textColor: string; /** * Document-level link color: emitted as the global `a { color }` rule in the * rendered MJML, so it cascades to every link — rich-text and menu alike. * Optional: when unset, links inherit the surrounding text color * (`color: inherit`), preserving the pre-#352 default. A per-block or * per-item color (a Menu item's `color`, `MenuBlock.linkColor`) still * overrides it. */ linkColor?: string; /** * Whether links are underlined document-wide: drives the global * `a { text-decoration }` rule (`underline` when true, `none` when false). * Required, defaulting to `true` (see `DEFAULT_TEMPLATE_DEFAULTS`) — the * common, more accessible email default. Applies to body (rich-text) links; * buttons and menu items carry their own inline `text-decoration` and are * unaffected. Set `false` to render links without an underline. */ linkUnderline: boolean; fontFamily: string; preheaderText?: string; /** * BCP-47 language code for the rendered email's ``. Drives * screen-reader pronunciation. Default `'en'` via `DEFAULT_TEMPLATE_DEFAULTS`. */ locale: string; } interface TemplateContent { blocks: Block[]; settings: TemplateSettings; } declare function createDefaultTemplateContent(defaultFontFamily?: string, templateDefaults?: TemplateDefaults): TemplateContent; //#endregion //#region src/test-email.d.ts /** * What a {@link TestEmailProvider} receives when the user asks to send a test. */ interface TestEmailPayload { recipient: string; /** * The editor's current content, exactly as `getContent()` would return it. * Always present. */ content: TemplateContent; /** * The template rendered to MJML. * * Present only when {@link TestEmailProvider.includeMjml} is set **and** * `@templatical/renderer` resolved. Always guard for absence: opting in * without the renderer installed still sends, just without this field (the * editor logs one warning naming the package). * * You still have to compile MJML to HTML — the editor never does, and * deliberately doesn't bundle a compiler. */ mjml?: string; /** * Echo of {@link TestEmailProvider.allowedRecipients}, present only when one * was configured. * * **Untrusted.** It is read out of the browser and carries no signature, so it * is not authoritative about anything. Two things it is genuinely useful for: * keeping one `send` implementation portable between your own backend and * Templatical Cloud, and comparing it against `recipient` server-side — a * mismatch means the client was tampered with or is buggy, which is worth * logging. Never treat it *as* the allowlist; that list belongs on your * server. */ allowedRecipients?: string[]; } /** * Sending contract for test emails. Implement it to let users send a test of the * template they're editing through your own infrastructure — the editor owns the * trigger, the dialog, recipient validation and the sending/success/error * states; you own delivery. * * Pass an implementation as `testEmail` to `init()`. When omitted the feature * stays off entirely and no trigger renders. * * ```ts * const provider: TestEmailProvider = { * send: ({ recipient, content }) => * fetch("/api/test-email", { * method: "POST", * headers: { "Content-Type": "application/json" }, * body: JSON.stringify({ recipient, content }), * }).then((r) => { * if (!r.ok) throw new Error("Could not send the test email"); * }), * }; * ``` */ interface TestEmailProvider { /** * Deliver a test of the current template. * * Resolve on success — the dialog confirms, then closes. Reject with a * **user-presentable** message on failure: the dialog renders `error.message` * inline and stays open so the user can retry. */ send(payload: TestEmailPayload): Promise; /** * Also render the template to MJML and pass it as * {@link TestEmailPayload.mjml}, saving you a `renderToMjml()` call. * * **Requires `@templatical/renderer`**, an optional peer dependency. If it * isn't installed the send still happens with JSON only and one warning is * logged — opting in never breaks sending. A *rendering* failure is different: * that fails the send, because it means the template itself is broken and * silently sending without the MJML would hide it. */ includeMjml?: boolean; /** * Restrict who may be sent to: * * - **omitted** — the dialog accepts free text, validated for shape only; * - **one entry** — a read-only field, pre-filled with it; * - **several** — a picker of exactly those addresses; * - **empty array** — nobody may be sent to, so the feature reports itself * unavailable and no trigger renders. `[]` is read as a decision, not as * "unset". * * A picker constraint, **not a security boundary**: this array lives in the * user's browser and is trivially edited there. Validate the recipient on your * server regardless of what the dialog offered. */ allowedRecipients?: string[]; /** * Pre-fills the recipient field. Ignored when it isn't in * {@link allowedRecipients}. */ defaultRecipient?: string; } //#endregion //#region src/templates.d.ts /** * A stored template: its identity, an optional human-readable name, and the * content the editor edits. * * The store owns `id` — it is returned by {@link TemplatesProvider.create} and * never generated by the editor, so identity stays a property of your storage * (database primary key, slug, document id). */ interface Template { id: string; /** * Optional display name, shown in the editor header and editable inline. * * Optional because a store may not have a name column, in which case the * header renders nothing to edit. A rename is persisted as an ordinary * `save(id, { name })` patch — there is no separate rename method. */ name?: string; /** * When your store created the template, ISO 8601. * * A fallback for {@link Template.updatedAt}: the header prefers `updatedAt` * and reads this only in its absence, so a store that records creation but not * modification still shows something. */ createdAt?: string; /** * When your store last wrote the template, ISO 8601. * * Display-only — the header renders it as a relative label ("Updated 5m ago") * with the absolute date on hover. Both timestamps are absent from * {@link TemplatePatch}, so the editor never writes them: they are yours to * set, and the value the editor shows is whatever `load` or `save` returned. * Absent, or a value that does not parse, renders nothing. */ updatedAt?: string; content: TemplateContent; } /** * Partial patch for {@link TemplatesProvider.save}. Only the keys present are * being changed, so a rename can travel without content and vice versa. * * A patch (rather than bare content) because retrofitting `save(id, content)` * into `save(id, patch)` later would break every implementation — the same * reasoning, and the same shape, as `SavedBlockPatch`. */ type TemplatePatch = Partial<{ name: string; content: TemplateContent; }>; /** * Storage contract for the template itself — the save/load lifecycle around * whatever the editor is editing. * * Deliberately **not** CRUD: there is no `list` and no `delete`, and the editor * has no template browser. Choosing *which* template to open belongs to your * own application, which then hands the id to `editor.load(id)`. * * Pass an implementation as `templates` to `init()`. When omitted, the header's * name field, save button and save-status indicator do not render, and * `create()` / `load()` / `save()` reject with an explanatory error. * * Every method may reject; the editor reports the failure through `onError`, * surfaces it in the header, and leaves its state untouched — nothing is marked * saved that wasn't. * * **Each mutation can be turned off by passing `false` instead of a function**, * mirroring `SavedBlocksProvider`. They are required rather than optional * precisely so that disabling is a decision you state, never something you get * by forgetting a method. `save: false` yields a genuine read-only mode (a * template still loads and can be edited locally, there is simply nothing to * persist); `create: false` yields a no-new-templates mode. * * ```ts * const provider: TemplatesProvider = { * load: (id) => fetch(`/api/templates/${id}`).then((r) => r.json()), * create: (input) => * fetch("/api/templates", { * method: "POST", * headers: { "Content-Type": "application/json" }, * body: JSON.stringify(input), * }).then((r) => r.json()), * save: (id, patch) => * fetch(`/api/templates/${id}`, { * method: "PATCH", * headers: { "Content-Type": "application/json" }, * body: JSON.stringify(patch), * }).then((r) => r.json()), * }; * ``` */ interface TemplatesProvider { /** * Fetch a template by id. The one method that cannot be disabled: without it * there is nothing to open. */ load(id: string): Promise