Changelog
Every @templatical/* package shares one version number, so this is the whole suite's release history in one place. Each entry lists the packages it changed.
Installing or upgrading is covered in Installation.
0.21.0
Features
Open-source Saved Blocks — reusable groups of blocks users save and re-insert — backed by a consumer-supplied storage provider. Previously Cloud-only ("Saved Modules").
@templatical/core · @templatical/editor · @templatical/types
The editor owns the UI; you own persistence. Templatical Cloud now consumes the same interface as one adapter rather than a separate implementation.
Saving starts from a block's bookmark action and opens a pick session: plain clicks add or remove blocks on the canvas, a bar shows the count with Save/Cancel (Escape cancels, Enter confirms), and Save opens a dialog that asks for a name and previews the picked blocks. The preview lists them in pick order and each row can be dragged (or moved with the arrow keys from its grip handle) to reorder before saving; blocks are stored in whatever order the list ends in. Picking never touches the editor's block selection. Browsing gives search, an optional free-text category filter, live preview, insert-at-position, rename and delete. A category is set in the save dialog (suggesting the ones already in use) and editable inline afterwards; it is flat and optional — there are no folders. Both filters run in the editor over whatever list() returned, so a provider that simply returns its entries gets search and categories for free.
Permissions are the implementer's to set. Each mutation on the provider is false | fn: pass false and the editor hides that affordance rather than letting the user try and fail. For exceptions on individual entries, return canUpdate / canDelete on them — absent means allowed. Setting all three to false gives a read-only library users still browse, preview and insert from, since insertion never touches your store. list cannot be disabled.
Nothing is fetched until the user opens the browser or the save dialog — list() is never called on editor load. The rail entry is present from the first paint whenever a provider is configured, so a slow or empty list() can neither delay the editor nor shift the sidebar; the browser shows skeleton rows on a first open, and reopens render the previous entries while refreshing underneath.
import {
init,
createLocalStorageSavedBlocksProvider,
} from "@templatical/editor";
// Zero-backend option, for demos and prototypes:
await init({
container: "#editor",
savedBlocks: createLocalStorageSavedBlocksProvider(),
});
// Or implement `SavedBlocksProvider` against your own API:
await init({ container: "#editor", savedBlocks: myProvider });Off by default. With no savedBlocks provider the feature is entirely absent and none of its UI code is downloaded — the pick bar and both dialogs are lazily loaded chunks fetched only when actually used.
Ordering belongs to the provider: the browser renders list()'s order verbatim and never re-sorts, so you control it server-side. createdAt / updatedAt are display only — each entry shows a relative timestamp (hover for the absolute date) and both fields are optional.
New exports:
@templatical/types—SavedBlock,SavedBlocksListParams,SavedBlocksProvider@templatical/core—useSavedBlocks,createLocalStorageSavedBlocksProvider@templatical/core/cloud—createCloudSavedBlocksProvider@templatical/editor—savedBlocksconfig option, plus re-exports of the provider factory and types
Breaking changes
useSavedModulesis removed from@templatical/core/cloud. UseuseSavedBlocksfrom@templatical/corewith a provider —createCloudSavedBlocksProvider(authManager)for Cloud. The return shape changed:modules/loadModules/createModule/updateModule/deleteModule→savedBlocks/load/create/update/remove.SavedModuleis removed from@templatical/types. UseSavedBlock, whosecreatedAt/updatedAtare now optional (a browser-local or in-memory store may not track them).initCloud()'smodulesoption is renamed tosavedBlocks.modules: falsebecomessavedBlocks: false.- Editor translation keys renamed.
blockActions.saveAsModule→blockActions.saveAsBlock,sidebarNav.browseModules→sidebarNav.browseSavedBlocks, and the cloud chunk'smodules.*namespace moved into the OSS chunk assavedBlocks.*. Only affects consumers overriding translations directly.
The Cloud REST contract is unchanged: ApiClient.listModules/createModule/updateModule/deleteModule and the saved-modules routes keep their names and paths.
Fixes
- Cloud no longer renders a dead "save as block" button on plans without the saved-blocks entitlement. Availability is now a reactive signal on the capability, so the control appears only when the feature actually works.
0.20.0
Features
Add per-field color presets to custom-block color fields.
@templatical/editor · @templatical/types
A color field in a CustomBlockDefinition now accepts the same presets / allowCustom pair as the editor-wide colors config, applied to that one field — so a field can be scoped to a color role (an accent/ink pair, say) while every other picker keeps the global palette. Entries are validated as #rgb / #rrggbb hex, exactly like editor-level presets.
A field's presets replace the editor-wide palette for that field rather than intersecting it, so a locked field can carry colors the global grid doesn't list; what a field can never do is unlock a locked editor. Setting neither inherits the editor's palette and its allowCustom; allowCustom: false locks one field while the rest of the editor stays free-form; allowCustom: true cannot unlock a field when colors.allowCustom is false. An empty presets: [] — or one whose entries are all invalid — narrows nothing, so the field inherits the editor's palette.
Field configs that can't be honoured are reported once per block definition, naming both the block type and the field key: invalid preset entries, an ignored empty list, an ignored allowCustom: true, and a locked field whose default its own palette can't reselect. Non-breaking — color fields that set neither option render exactly as before.
0.19.0
Features
Add a fonts.builtIns option to restrict which of the seven built-in fonts the font picker offers.
@templatical/editor · @templatical/types
builtIns: true (or omitting it) keeps all seven built-ins — the current behaviour. builtIns: false drops them all so the picker lists only customFonts. A builtIns: string[] allowlist keeps just the named families, matched case-insensitively; a name that isn't a built-in is logged with a warning and skipped, the same way paletteBlocks treats an unknown entry.
Filtering only affects the picker: excluding a built-in never removes a custom font, a custom font stays usable as defaultFont when every built-in is excluded, and content already using an excluded family still resolves to its proper fallback stack. When the family new templates seed (fonts.defaultFont, or Arial by default) isn't in the offered list, the editor warns once at init so the mismatch is caught. Non-breaking — the default is unchanged.
Add a colors editor option for a preset color-picker palette.
@templatical/editor · @templatical/types
colors.presets renders a clickable grid inside every color picker popover (block toolbars, template settings, rich text, custom-block color fields); clicking a preset applies it and the preset matching the current value is marked selected. Presets must be #rgb / #rrggbb hex — invalid entries are skipped with a console warning. The grid is an ARIA radio group: arrow keys rove focus between chips (roving tabindex) and Enter/Space activate.
colors.allowCustom: false (with presets) hides the wheel and hex input so authors can only pick from the palette — a white-label / brand-kit constraint. In this locked mode the palette leads with a "no colour" chip that restores the unset (inherit) state, and the editor warns when any blockDefaults / templateDefaults colour falls outside the palette. It is ignored with a warning when no presets are configured. Non-breaking — pickers render exactly as before when colors is unset.
0.18.0
Features
Add a resolveImageUrl config option — a display-only resolver for image src values (#415).
@templatical/editor
The canvas calls it to obtain a preview URL for a src the user entered (resolveImageUrl?: (src: string) => string | null | Promise<string | null>); the content model and toMjml() output always keep the canonical value. Returning null (or the input value) means "use the src as-is". This lets hosts whose templates reference images by non-displayable values — e.g. plain file names resolved to ephemeral blob: URLs from local storage — show real previews without rewriting content via setContent() or reverse-substituting URLs before export.
The resolver is called once per committed src value (typing in the src input is debounced, so partial values never reach it) and results are cached per src for the editor instance's lifetime, including failures — a src whose lookup failed stays unresolved until the editor is re-initialized (a re-resolve hook may follow in a later release). Merge-tag srcs are never passed to the resolver; their placeholderUrl preview is resolved instead.
Covers every image the canvas paints from content: image block srcs, design-time placeholder previews, and explicit video thumbnailUrls. Thumbnails auto-derived from a YouTube/Vimeo URL are already real URLs and are never passed to the resolver.
0.17.1
Fixes and improvements
Escape table cell content when rendering, and strip HTML from imported BeeFree table cells.
@templatical/import-beefree · @templatical/renderer
@templatical/renderer now HTML-escapes table cell content in renderCell, matching the menu and button renderers and the editor, which treats cells as a plain-text field. Previously a cell like <b>x</b> was emitted raw — it showed as literal characters in the editor but rendered as bold HTML in the sent email, and arbitrary user HTML was injected into the output. Merge tags are still resolved before escaping, so {{tag}} placeholders survive intact.
@templatical/import-beefree now reduces HTML inside imported table cells to plain text (via the same stripTagsPlain used for button labels), since Templatical table cells are plain text. When a strip actually removes markup — for example a cell link, whose URL is lost — the module is reported as approximated with a warning, so the lossy conversion surfaces in the import report instead of happening silently. Plain-text tables are unaffected and still report as converted.
0.17.0
Features
Add an opt-in htmlBlockPreview config option that renders HTML blocks as a live preview in the editor canvas.
@templatical/editor
When enabled (htmlBlockPreview: true or { enabled: true } — off by default), each HTML block's content is rendered verbatim inside a sandboxed <iframe> (sandbox="allow-same-origin", no allow-scripts) instead of the static placeholder card. Scripts and inline event handlers never execute and the fragment's styles can't leak into the editor. This is preview-only — the MJML/HTML export path renders HTML blocks regardless.
Also corrects the HTML block's editing-panel hint, which previously claimed scripts and unsafe elements were stripped on export; the OSS renderer does not sanitize HTML block content, so the hint now states that content is exported as-is.
0.16.5
Fixes and improvements
Fix two shadow-DOM rendering bugs surfaced when the editor is embedded under a transformed ancestor
@templatical/editor
Popovers mispositioned under a transformed ancestor. The color picker, the rich-text floating toolbars (Title + Paragraph), and the merge-tag autocomplete positioned their teleported popovers with
position: fixedusing viewport coordinates. When any ancestor of the editor has atransform/filter/will-change(a host's scroll-parallax wrapper, route transition, or reveal animation — even while its computedtransformreadsnone, since a running/animated transform still promotes the element), that ancestor becomes the containing block for thefixedpopover and offset it far from its trigger. They now anchorposition: absoluteinside the (positioned).tpl-popover-root, converting the viewport target to root-local coordinates via the newusePopoverPositionhelper — immune to the ancestor transform.ToggleSwitchknob off-center / overflowing its track. Tailwind Preflight is intentionally omitted, and the hand-rolled form reset never zeroed native<button>padding — so a button with no padding utility kept the UA default (1px 6pxin Chromium). In shadow-DOM mode (no host reset to mask it) that shrank the fixed-size toggle track and pushed the knob off-center. The:where(.tpl) buttonreset now zeroespadding/margin(specificity stays 0, so per-buttontpl:p-*utilities still win).Block palette rail stayed expanded after a drag-drop. Dropping a block leaves the cursor out in the canvas, so no
mouseleavefired to collapse the hover-expanded sidebar rail (and the mid-dragmouseleavewas intentionally suppressed) — it stayed open until the next hover-in/out. It now collapses on drag-end.
Also documents the containing-block caveat (a transform/filter/will-change on an ancestor of the mount point offsets the editor's fixed-positioned overlays and drag ghost) on the init() container option and in the installation docs.
0.16.4
Fixes and improvements
Add a per-section "Stack on mobile" control and make the mobile preview stack columns
@templatical/editor · @templatical/renderer · @templatical/types
- Fix (#395): the editor's canvas mobile preview now stacks multi-column sections (each column full-width) on the mobile viewport, matching the exported email. Previously columns stayed side-by-side in the preview while the sent email stacked them.
- Feature (#396): new optional
SectionBlock.stackOnMobile. A "Stack on mobile" toggle in the section settings (shown for multi-column sections, on by default) lets you opt out of stacking — the columns then render inside an<mj-group>and stay side-by-side on mobile, reflected in both the canvas preview and the MJML output. Existing templates are unaffected: an absent value keeps MJML's default stacking behavior.
0.16.3
Fixes and improvements
The swatch-only color pickers (paragraph text color and highlight, plus the menu color control) now carry a manual hex field with an inline × clear inside the picker popover — the same control as the sidebar color pickers. You can type or paste an exact hex (applied on Enter/blur) and clear back to the inherited color with the ×. This restores the unset affordance that the move to the shared color picker had dropped, and adds precise hex entry that swatch-only mode previously lacked.
@templatical/editor
The color picker also normalizes colors to hex for display and for seeding the wheel, so editing an already-applied color shows #rrggbb (not the browser's rgb(...) read-back) and the wheel opens on the correct color.
0.16.2
Fixes and improvements
Point the default socialIconsBaseUrl at jsDelivr instead of unpkg (https://cdn.jsdelivr.net/npm/@templatical/renderer@<version>/assets/social). jsDelivr's multi-CDN backbone with automatic failover is a better fit for the social-icon PNGs embedded in archived emails, which recipients may open years after delivery. The URL stays version-pinned, so already-sent emails keep their existing unpkg URLs (still resolvable) and only new renders use jsDelivr. Consumers overriding socialIconsBaseUrl are unaffected.
@templatical/renderer
Rich-text toolbar (text color + highlight) now uses the SDK's shared color picker — the same hex-wheel ColorPicker used everywhere else in the editor — instead of the native OS color input. The controls are unset-aware (an inherited-color selection shows the "not set" swatch, with the wheel seeded on the color the text actually renders in) and sized to match the toolbar. Adds size ("sm" | "md") and ariaLabel props to the internal ColorPicker.
@templatical/editor
0.16.1
Features
Add per-link color for rich-text links
@templatical/editor
A rich-text link (in Paragraph and Title blocks) can now carry its own color — set it in the link dialog, or select the link and use the text-color control. The color is applied to the <a> itself, so the link's text and its underline stay the same color (the underline follows currentColor) in the editor canvas and the exported MJML/HTML alike. A per-link color takes absolute priority: it overrides the document link color, and setting one also strips any inline text color already on the link's text, so the whole link (glyphs and underline) follows the chosen color rather than showing a recolored underline over differently-colored text. Conversely, applying a text color across a selection that includes a link updates the link's own color to match — so a link stays internally consistent (its text, underline, and the color shown in the link dialog and toolbar always agree) in both directions.
Previously a link's color could only be applied as an inner text-color span, which colored the text but left the underline painted by the ancestor <a> in the document link color — a visible mismatch that also shipped in the exported email. Putting the color on the link resolves it, and completes the per-link styling deferred from the document-level link color work. (#373)
0.16.0
Features
Add Spanish translations for the editor and media library.
@templatical/editor · @templatical/media-library
Add Catalan translations for the editor and media library.
@templatical/editor · @templatical/media-library
Add a document-level default text color with a full per-block cascade
@templatical/editor · @templatical/renderer · @templatical/types
TemplateSettings gains a required textColor (default #1a1a1a, customizable via templateDefaults). Every text block — Title, Paragraph, Menu, Table — inherits it unless it sets its own color, so a document text color now flows through the whole template. To enable that, the per-block color on Title, Menu and Table is now optional: unset means "inherit the document color", and new blocks default to unset. An explicit per-block color (or an inline text-color mark) still overrides, and links inherit via color: inherit.
It's exposed as a color picker in the editor's Appearance settings (next to Background color) and reflected live on the canvas; each text block's own color picker gains an unset/inherit state.
Breaking (types): TemplateSettings.textColor is now required — add it when hand-constructing settings (including content passed to init()), or use createDefaultTemplateContent() / init({ templateDefaults: { textColor } }), which supply it. TitleBlock, MenuBlock, and TableBlock now have an optional color (string | undefined) — handle the unset case if you read it (unset means the block inherits the document color).
Runtime stays backward-compatible: content lacking textColor still renders (falling back to the previous default), and existing templates with explicit block colors are byte-for-byte unchanged. Only newly created content shifts — paragraph body text resolves to #1a1a1a instead of MJML's default #000000, a negligible and more consistent shade. (#355)
Add document-level link color and underline controls
@templatical/editor · @templatical/renderer · @templatical/types
TemplateSettings gains an optional linkColor and a required linkUnderline (default true). The renderer emits them as a single global a { color; text-decoration } rule. linkColor cascades to every link — rich-text and menu alike; unset keeps color: inherit (links follow the surrounding text color). linkUnderline underlines body (rich-text) links; buttons and menu items carry their own inline text-decoration and are unaffected. An inline per-link/per-item color (a Menu item's color, MenuBlock.linkColor) still overrides the color.
Both are exposed in the editor's Appearance settings — a link-color picker and an underline toggle next to the text color — and reflected live on the canvas, fixing the previous preview/export mismatch (the canvas hardcoded a blue underlined link that never shipped).
Newly created content (via createDefaultTemplateContent() / init() defaults) now underlines body links by default — the common, more accessible email default. Set linkUnderline: false for no underline.
Breaking (types): TemplateSettings.linkUnderline is now required — add it when hand-constructing settings, or use createDefaultTemplateContent() / init({ templateDefaults }), which supply it. linkColor is optional; omit it to keep links inheriting the text color.
Runtime stays backward-compatible for stored content: content lacking linkUnderline still renders without an underline (the renderer treats an absent value as off), so already-saved templates are unchanged. (#352)
Fixes and improvements
Fix es (Spanish) and ca (Catalan) translations not loading in the media library. The locale loader now auto-discovers every locale file via import.meta.glob (matching the editor) instead of a hardcoded supported-locales list that omitted them, so both languages resolve correctly instead of falling back to English.
@templatical/media-library
0.15.1
Fixes and improvements
Fix: clicking a link inside a rich-text block no longer opens it
@templatical/editor
Clicking a link inside a Paragraph or Title block used to navigate to its href (typically opening a new tab) instead of letting you work with the block. Two paths were affected:
- On the canvas (not editing), the link rendered as a plain
<a>with no click guard — unlike Button/Menu/Image/Video/SocialIcons blocks, whose anchors carry a@click.prevent. - While editing, StarterKit bundles its own Link extension (registered with
openOnClick: true), which was registered alongside — and overriding — the editor'sLinkExt(openOnClick: false). Disabling StarterKit's bundled Link (link: false) — plus its bundled Underline in the paragraph editor, which already adds its own — removes those duplicate extensions and the "duplicate extension names" console warnings TipTap logged for them.
A click on a rich-text link now selects the block on the canvas (double-click still opens the inline editor) and does nothing while editing; preview mode leaves links clickable. (#351)
Fix: editor primary buttons no longer render with a transparent background in bundled/CDN builds (#357)
@templatical/editor · @templatical/media-library
@templatical/media-library's shared .tpl form-element reset authored its button reset as a bare .tpl button { background: none; border: none } (specificity 0,1,1). Because @templatical/editor bundles these styles and shares the .tpl scope class, that reset out-specified the editor's single-class button utilities such as .tpl:bg-[var(--tpl-primary)] (0,1,0) — rendering the Insert/Update Link dialog's primary action button with a transparent background (invisible on light backgrounds) and stripping button borders. It surfaced in the CDN bundle and in any app that bundles the editor from source (e.g. the deployed playground); the npm dist was unaffected because it externalizes media-library. The reset is now :where(.tpl) button (specificity 0,0,1), matching the editor's own reset, so per-button utilities always win.
0.15.0
Features
Add type-ahead merge tag autocomplete to input and textarea fields
@templatical/editor · @templatical/types
Typing the syntax opener (e.g. {{) in any merge-tag-enabled input or textarea — button/image/video/menu links, image alt text, template settings, and custom-block text fields — now surfaces the same autocomplete popup as the rich-text editor. The popup, filtering, keyboard navigation (Arrow / Enter / Tab / Escape), and caret positioning are shared with the TipTap path, so behavior is identical across both surfaces. Controlled by the existing mergeTags.autocomplete flag (default on; auto-disabled when tags is empty or a custom syntax is used).
@templatical/types gains getSyntaxClosingChar() alongside getSyntaxTriggerChar().
Fixes and improvements
Fix: show merge tag labels in Button, Menu, Video and Image block canvas display
@templatical/editor
Merge-tag-enabled fields rendered directly on the canvas now show a tag's human-readable label (e.g. "Shipping Method") instead of the raw {{shipping_method}} token, matching the rich-text editor: button labels, menu item labels, and the Video URL / Image src placeholders shown when those fields are merge tags. Resolved tags carry a subtle dotted underline (in the current text color) so a dynamic value stays distinguishable from user-typed text on any background. Display-only — the raw token is unchanged in the stored value and MJML output. (#348)
0.14.0
Features
Add standalone logic tags — a control-flow feature separate from merge tags. Configure logicTags.tags (standalone tokens like {% else %}) and logicTags.pairs (open/close constructs like {% if %} … {% endif %}), or supply logicTags.onRequest to plug in your own picker (mirrors mergeTags.onRequest; precedence: onRequest → built-in picker). A dedicated "Insert logic" affordance appears in rich-text blocks and in merge-tag-enabled plain fields (button text, URLs, alt text). Standalone tags insert at the cursor; pairs wrap the current selection (or drop with the caret between them). The built-in picker is a single searchable list grouped by group — each group holds both its standalone tags and its open/close pairs, with keyword badges (one per tag, two per pair). Typed and pasted logic tags are still highlighted automatically, independent of this config. New exported types: LogicTagsConfig, LogicTag, LogicPair.
@templatical/editor
Add an optional outer frame to section blocks (section.wrapper) — a full-width band with its own background, padding, and corner radius that frames the section, rendered as an mj-wrapper around the section's mj-section. This makes the common "white card on a colored band" layout possible without nesting sections (which MJML forbids). Enable it from the section toolbar's Wrapper panel, or set createSectionBlock({ wrapper: { backgroundColor, padding, borderRadius } }); omit it and existing templates are unchanged. (#312)
@templatical/editor · @templatical/renderer · @templatical/types
Fixes and improvements
Add an optional borderRadius (px) to section blocks. Set it from the section toolbar or via createSectionBlock({ borderRadius }); the renderer emits it as border-radius on the mj-section, so a section with a background color reads as a rounded card on a contrasting background. Omitted or 0 keeps square corners, so existing templates are unchanged. First step toward the framed "card on colored background" pattern. (#312)
@templatical/editor · @templatical/renderer · @templatical/types
Fix the editor allowing a section to be dropped into another section's column (dragged from the sidebar palette) and then silently losing it on export. MJML cannot nest mj-section inside mj-column, so renderToMjml() / editor.toMjml() dropped the nested section and all of its content. Dragging a section into a column is now rejected up front, and the core addBlock / moveBlock APIs refuse to nest a section into a column, so the invalid state can no longer be created. (#292)
@templatical/core · @templatical/editor
0.13.0
Features
Add drag-and-drop image upload (#229). Drag an image file from your computer onto an image block (empty or filled to replace), the sidebar image field, or a custom block's image field to set it — the editor forwards the dropped File to your onRequestMedia handler via the new optional MediaRequestContext.files, exactly like the Browse Media path (upload it and return the URL). In Cloud editors the dropped file is uploaded to your media library automatically. A file dropped anywhere else on the editor is ignored instead of navigating the browser away.
@templatical/editor · @templatical/media-library
0.12.1
Fixes and improvements
Fix block/section background color incorrectly showing #ffffff when no color is set. An unset color now reads as "Not set" (a slashed swatch) instead of a fake white, and picking a color equal to the default — e.g. white on a transparent background — now persists and renders correctly instead of being silently dropped. A clear (×) button resets a color back to unset. (#282)
@templatical/editor
0.12.0
Features
Add a width option to button blocks: buttons can be set to a fixed pixel width or stretched to full width ('full'), independently of their label, instead of always shrinking to fit their content. Omitting width keeps the previous content-sized behavior, so existing templates are unaffected (#260).
@templatical/editor · @templatical/renderer · @templatical/types
Add website option to social icons
@templatical/editor · @templatical/types
Allow entering a custom image width. The image width control now has a "Custom" option alongside the existing presets (Full width / 300 / 400 / 500) that reveals a pixel input, so images can be sized to any width instead of only the nearest preset (#259).
@templatical/editor
Fixes and improvements
Fix the button settings panel laying out the Background and Text Color pickers side by side in a two-column grid too narrow to hold each picker's swatch + hex input, clipping the hex field. The two color fields now stack vertically (full width), matching the rest of the panel.
@templatical/editor
Centralize social-icon glyph data (SVG path + brand color) into a single SOCIAL_ICON_GLYPHS map in @templatical/types, shared by the editor's inline-SVG renderer and the renderer's PNG rasterizer (which previously each kept their own copy). Adding a platform to the SocialPlatform union is now a compile error until its glyph exists, so the editor and renderer can no longer drift out of sync. Social platform dropdown labels now resolve through i18n (social.platforms) instead of a hardcoded English name.
@templatical/editor · @templatical/renderer · @templatical/types
Prevent palette block from disappearing during drag and drop
@templatical/editor
0.11.1
Fixes and improvements
Add a smallScreenNotice option (default true): on viewports narrower than ~768px the editor now shows a "use a larger screen" notice instead of a cramped, unusable drag-and-drop layout. The palette, canvas, and properties panel can't lay out on a phone and touch dragging is impractical, so this is the honest fallback. Opt out with smallScreenNotice: false to render the editor at any width if you handle small screens yourself. Applies to both the OSS and cloud editors (#235).
@templatical/editor
0.11.0
Features
Add a paletteBlocks config option to reorder and filter the block palette (#232)
@templatical/editor
init({ paletteBlocks: [...] }) now accepts an allowlist that controls which block types appear in the sidebar palette and in what order. Only the listed types are shown, in the given order — unlisted built-ins (e.g. video, table) are hidden. Reference built-ins by their bare type ("image") and custom blocks by their custom:-prefixed type ("custom:qrcode"), so the two can be interleaved freely. Unknown entries (a typo, an unregistered custom block) are logged with a warning and skipped. Filtering the palette never affects rendering — existing content that uses a hidden block type still renders correctly. Omit paletteBlocks for the full default palette.
Fixes and improvements
Fix the editor clipping its own content on short viewports. The block-types palette is now a scroll region, and the editor's min-height floor was lowered so it fills short containers instead of overflowing them — restoring access to the bottom of the palette, the footer, and the config panel's lower controls (#231).
@templatical/editor
Fix the global email background being hidden in the editor when a section has its own background. The background now renders in the gutters around the centered content, matching how it appears when the email is sent (#230).
@templatical/editor
0.10.4
Fixes and improvements
Fix the inline "Browse media" button not inserting an image when the image is nested inside a section (#219)
@templatical/editor
SectionBlock rendered each nested child block with only a @fetch-data listener, whereas Canvas (top-level blocks) also forwards @update. ImageBlock signals a media pick by emitting update and holds no editor reference of its own, so an image inside a section emitted into the void and the picked media never landed. The content-sidebar path was unaffected because it updates the selected block by id, independent of nesting. SectionBlock now forwards the child's @update to editor.updateBlock, matching Canvas.
0.10.3
Fixes and improvements
Make the editor and standalone media library independent of the host page's html { font-size } (#209).
@templatical/editor · @templatical/media-library
The UI's length scale (spacing, font sizes, border radii) is now anchored to a new --tpl-user-base-size token that defaults to a fixed 16px, instead of rem. A rem always resolves against the document root — even inside the editor's shadow root — so a consumer design system that set e.g. html { font-size: 8px } previously shrank the entire editor. It no longer does.
Consumers on a normal 16px root see identical sizing. To scale the whole UI, set --tpl-user-base-size on the editor container (or any ancestor): a px value to enlarge/compact (18px, 14px), or a rem value such as 2rem to deliberately track a custom root font-size. Email content on the canvas is unaffected — it uses the pixel sizes stored on each block.
0.10.2
Fixes and improvements
Fix Converting circular structure to JSON when exporting after a drag inside a section (#203)
@templatical/core · @templatical/editor · @templatical/types
Dragging a block within a section column could leave a Sortable expando back-ref (HTMLDivElement.SortableXXX → instance → el → div) reachable from the editor's live content. The public getContent() serialized with a naked JSON.stringify, so it threw on that cycle and broke export until the section was removed.
@templatical/types: add the cycle-safesafeClone()helper (WeakSet-replacer JSON round-trip that drops self-referencing back-refs instead of throwing).@templatical/editor:init().getContent()andinitCloud().getContent()now clone viasafeClone(); the pre-ready fallback also defaults to an empty template instead of throwing when no content was supplied.@templatical/core:history.cloneContent()now reusessafeClone()(same behavior, deduplicated).
0.10.1
Fixes and improvements
Migrate the framework-agnostic packages from tsup to tsdown (Rolldown + Oxc)
@templatical/core · @templatical/import-beefree · @templatical/import-html · @templatical/import-unlayer · @templatical/renderer · @templatical/types
The six framework-agnostic library packages — types, core, renderer, import-beefree, import-unlayer, import-html — now build with tsdown instead of tsup. This drops rollup / rollup-plugin-dts from the build path and aligns these packages with Rolldown (which Vite already uses). Published output is functionally equivalent: same ESM exports, same externals, equivalent .d.ts.
The Vue/CSS packages (editor, media-library) and quality deliberately remain on Vite + vue-tsc/tsc + @microsoft/api-extractor — rolldown-plugin-dts inlines the editor's bundled-but-type-external third-party surface (~950 kB vs ~11 kB), and Vite's batteries-included handling (env replacement, CSS/Tailwind, glob, dts externalization) isn't worth reconstructing manually there.
Close meaningful test-coverage gaps and fix a BeeFree import bug
@templatical/editor · @templatical/import-beefree
@templatical/import-beefree: stop emitting a redundantfont-weight: 400span (now matches@templatical/import-unlayer).@templatical/editor: export the merge-tag suggestionrenderfactory so the popup lifecycle is unit-testable (behavior unchanged).- Added regression tests across editor, import, and media-library packages, and started measuring Vue SFCs in coverage.
Fix a batch of correctness and data-loss bugs found during an audit
@templatical/core · @templatical/editor · @templatical/import-beefree · @templatical/import-html · @templatical/media-library · @templatical/renderer
Each fix ships with a regression test that fails without the change.
@templatical/editor— rich-text URL sanitizer XSS bypass.isSafeUrlonly.trim()-ed the value before scheme matching, so payloads with embedded tab/newline/CR or leading control characters (e.g.java\tscript:…,\x01javascript:…) matched no scheme and were treated as safe, yet re-formed a livejavascript:URL once rendered. The value is now normalized the way the WHATWG URL parser does (strip ASCII tab/LF/CR anywhere, strip leading C0-control/space) before the scheme check.@templatical/core(cloud) —moveBlockdata loss. The cloud editor spliced a block out of its parent before resolving the destination, so an invalid/staletargetSectionId, a non-section target, or an out-of-rangecolumnIndex(all reachable via remote MCP/collaborationmove_blockpayloads) dropped the block irrecoverably. It now resolves and validates the target before mutating the source, mirroring the OSS editor.@templatical/core(cloud) — collaboration broadcast positioning. TheaddBlockbroadcast wrapper dropped the 4thindexargument, so duplicating a block or inserting a saved module at a position appended it to the end and desynced collaborators. The wrapper now forwardsindexand includes it in the broadcast payload.@templatical/editor— table cell edits clobbered in shadow DOM. Thev-cell-contentguard comparedel.ownerDocument.activeElement, which returns the shadow host (never the inner<td>) in the default shadow-DOM mount, so a concurrent externalupdate_blockoverwrote in-progress keystrokes. It now resolves the focused element viael.getRootNode().activeElement.@templatical/renderer— display conditions dropped on nested blocks. Blocks inside a section column never received their{% if %}/{% endif %}display-condition guards, so conditional content in a multi-column layout rendered unconditionally for every recipient. Display-condition wrapping is now applied to nested blocks too.@templatical/editor— snapshot restore failure left wrong content. When a snapshot restore failed, the editor was left showing the previewed snapshot as the live document with the banner gone and the backup discarded. The content is now rolled back to the pre-preview state on failure, and the restore is no longer an unhandled promise rejection.@templatical/media-library— crop resize aspect-ratio distortion.resizeCanvasinjected a spurious factor whenmaxWidthwas set but onlymaxHeightclamped, squishing the image horizontally and disagreeing with the on-screen preview. It now scales width bymaxHeight / targetHeight.@templatical/import-html— wrapper-div content reordering. Loose content appearing before a table inside a wrapping<div>/<center>/<main>was emitted after the table-derived sections, reordering the document. Pending loose content is now flushed before each nested table.@templatical/import-html— paragraph alignment dropped. A container'stext-alignwas lost when the inner<p>carried a non-style attribute (class/id/dir/…). Alignment is now applied with an attribute-tolerant matcher that merges into any existingstyle.@templatical/import-beefree— single-column row background dropped. A single-column row's background color was discarded because only multi-column rows were wrapped in a section. Single-column rows with a non-transparent background are now wrapped in a one-column section carrying the background.
0.10.0
Features
Custom blocks can now declare default block styles in their definition, and the renderer honors block.styles.padding on custom and HTML blocks.
@templatical/renderer · @templatical/types
New defaultStyles on CustomBlockDefinition. Custom block authors can now declare default padding and backgroundColor alongside template and fields. The value is a Partial<BlockStyles> deep-merged over the base defaults — specify only the fields you want to override. Controls both the editor canvas wrapper and the rendered MJML/email output.
customBlocks: [
{
type: 'pricing-table',
template: '<table>…</table>',
fields: [...],
defaultStyles: {
padding: { top: 0, right: 0, bottom: 0, left: 0 },
},
},
]renderCustom and renderHtml now honor block.styles.padding. Previously the renderer emitted <mj-text> without an explicit padding attribute for custom and HTML blocks, so MJML's built-in 10px 25px default applied regardless of block.styles.padding. Both renderers now pass padding="..." from the block's styles, matching how every other built-in renderer (paragraph, title, menu, table) already worked.
Behavior change for existing custom and HTML blocks. Previously, rendered email output used MJML's mj-text default of 10px 25px. Now it uses block.styles.padding, which defaults to 10px all around from createDefaultStyles(). To restore the old visual, set defaultStyles: { padding: { top: 10, right: 25, bottom: 10, left: 25 } } on the custom block definition, or override padding on the individual block instance via the editor.
Collapse the responsive model to Desktop + Mobile, dropping the tablet tier.
@templatical/editor · @templatical/renderer · @templatical/types
ViewportSize is now "desktop" | "mobile" and BlockVisibility drops its tablet field. The editor's viewport toggle no longer offers a Tablet preview, and the renderer emits a single 480px breakpoint (tpl-hide-mobile ≤480px, tpl-hide-desktop ≥481px) instead of three bands. A "tablet" breakpoint isn't a meaningful concept for email (bodies are ~600px wide; a tablet viewport renders at full desktop width), and the useful responsive split is binary — mobile vs. not-mobile, matching MJML's model.
Migration: saved templates carrying visibility.tablet keep parsing — the extra key is ignored at runtime. A block previously hidden only on tablet (tablet: false with desktop/mobile true) will now show on 481–768px devices, because there's no longer a tpl-hide-tablet class. No data migration is required; re-saving a block normalizes its visibility object to the new shape.
Remove the unimplemented BaseBlock.customCss per-block CSS surface.
@templatical/editor · @templatical/types
BaseBlock.customCss?: string was a typed field with a "Custom CSS" textarea in the block settings panel, but no renderer ever read it — the field was dead data (same shape as the styles.responsive removal in #154). The editor textarea, the type field, and the three locale strings (customCss / css / cssPlaceholder) plus the docs section are removed.
Per-block free-form CSS is the wrong shape for an email editor: it targets end-users (who typically aren't email-CSS fluent), it doesn't dedupe across instances, and there is no reliable rendering surface for it that survives email-client variance. Custom-block-scoped CSS belongs at the definition level (developer-authored, deduped, emitted to <mj-head><mj-style>…</mj-style></mj-head>) — tracked separately in #155.
Migration: saved templates carrying a customCss string keep parsing — the extra key is ignored at runtime. No data migration is required; nothing read the field before this change, so no rendered output changes.
Remove the unimplemented BlockStyles.responsive / ResponsiveStyles surface and make preview mode honor block visibility.
@templatical/editor · @templatical/types
styles.responsive (tablet/mobile padding overrides) was typed and documented but read by neither the renderer nor the editor preview, so the values were dead data (#146). The ResponsiveStyles type, the responsive field on BlockStyles, and their docs are removed. Per-breakpoint padding is intentionally not implemented: email clients vary in media-query support (Outlook desktop ignores them entirely) and MJML already stacks columns and scales fluidly on mobile. Use visibility for per-viewport show/hide.
The editor preview now actually hides blocks that are set hidden on the current viewport (previously they were only dimmed with a badge), so the preview matches the exported MJML.
Add CustomBlockDefinition.stylesheet — definition-level CSS that emits once into <mj-head><mj-style> in the rendered MJML and is mirrored in the editor canvas.
@templatical/editor · @templatical/renderer · @templatical/types
Custom blocks render as raw HTML inside an mj-text cell, which means MJML's automatic responsive behavior (column stacking, fluid images) only applies to the outer layout — not to the internals of a custom block. Previously a developer had no clean place to put per-definition media queries, hover states, or block-specific font declarations; ad-hoc <style> blocks inside the template ended up in the email body rather than <mj-head>, with no dedupe across instances.
The new stylesheet?: string field on CustomBlockDefinition solves this:
- The renderer collects every definition's
stylesheetfrom the content tree, dedupes bycustomTypeand by trimmed content, and emits each unique stylesheet once as an additional<mj-style>block alongside the built-in visibility media queries. - The editor canvas mirrors the same CSS via a reactive
<style>element rendered inside the editor root — in shadow-DOM mode it scopes to the shadow root; in light-DOM mode it shares the global stylesheet surface already established bydist/style.css. - The renderer adds an optional
getCustomBlockStylesheet?: (customType: string) => string | undefined | nullresolver toRenderOptions. The editor wires this from its block registry automatically; headless callers provide their own resolver from whatever definitions map they manage. TemplaticalEditor(the OSS init return) gainsgetCustomBlockStylesheet(customType)for parity withrenderCustomBlock.
Class names in stylesheet are not scoped by the SDK — namespace them per definition (e.g. .tplc-<type>-<element>) to avoid collisions. Email-client caveats apply (Outlook desktop ignores @media queries, matching every other media-query-based feature in the SDK such as block visibility).
Fully backward compatible: existing definitions and renderer callers that omit the new field/option produce the same MJML and editor behavior as before.
Addresses #155 (raised as the follow-up to #146).
Remove margin from BlockStyles.
@templatical/editor · @templatical/import-beefree · @templatical/import-html · @templatical/import-unlayer · @templatical/types
margin was a canvas-only style: it surfaced in the block settings panel and applied to the editor wrapper, but the renderer never read it, so it was dropped from the exported email — a WYSIWYG mismatch. Email spacing is expressed via padding (the renderer honors it on every block), so margin added a second, unreliable spacing control with no email output.
BlockStyles.marginis removed from the type and fromcreateDefaultStyles().- The Margin inputs are removed from the block settings panel, and the editor canvas no longer applies a wrapper margin.
- The BeeFree, Unlayer, and HTML importers no longer emit a
marginfield on converted blocks.
Use padding for block spacing. Persisted templates that still carry a margin key load fine — the extra field is ignored.
Add a single lintTemplate(content, options?) entry point that runs every linter — accessibility, structure, and links — and returns the merged issue list. Prefer it over calling lintAccessibility / lintStructure / lintLinks individually: new linter categories are picked up automatically by every consumer that funnels through it.
@templatical/editor · @templatical/quality
The editor's live linter (useTemplateLint) now calls lintTemplate internally; behavior is unchanged. The individual linter exports remain available.
0.9.1
Fixes and improvements
Fix theming of the built-in merge tag picker modal. The panel carries the tpl token class, which re-declares every design token with light-mode defaults, so without re-establishing the theme locally the picker ignored dark mode and the consumer theme config overrides. The panel now sets data-tpl-theme and applies the resolved theme styles — matching the pattern used by the other OSS panels (rich-text toolbar, link dialog) — so its surfaces, text, borders, and primary-accent highlight follow the editor theme correctly.
@templatical/editor
0.9.0
Features
Add a built-in merge tag picker modal. When mergeTags.tags is configured without mergeTags.onRequest, clicking "Insert merge tag" now opens a searchable, keyboard-navigable picker that lists every tag. The picker supports optional grouping (via a new group field on MergeTag) and per-tag helper text (via a new description field). onRequest continues to take precedence when set.
@templatical/editor · @templatical/types
0.8.5
Fixes and improvements
Harden HTML/regex hot paths against polynomial-ReDoS and incomplete-sanitization classes flagged by GitHub code scanning. All changes preserve existing public APIs.
@templatical/core · @templatical/editor · @templatical/import-beefree · @templatical/import-html · @templatical/import-unlayer · @templatical/media-library · @templatical/quality · @templatical/renderer · @templatical/types
@templatical/types: rewriteresolveHtmlMergeTagLabels/resolveHtmlLogicMergeTagLabelsfrom a<span[^>]*…[^>]*>regex to a single-pass linear scanner. Adversarial inputs that used to take O(n²) now complete in O(n).@templatical/renderer: same linear-scanner rewrite forconvertMergeTagsToValues. Paragraph stripper changed[^>]*→[^<>]*so it fails fast on<p<p<p…-style inputs.@templatical/quality: linear-time HTML-comment stripper inhasNestedAnchors. An unterminated<!--now drops the rest of the input rather than leaving the literal<!--behind (closes the incomplete-sanitization gap). Thelink.javascript-protocolrule now also flagsdata:andvbscript:URLs — both can encode executable script and were previously only flagged as the lower-severitylink.unsupported-protocol. Rule ID unchanged; message gained a{protocol}placeholder. Severity overrides set againstlink.javascript-protocolcontinue to apply.@templatical/import-unlayer/@templatical/import-beefree: replace<p[^>]*>([\s\S]*?)</p>paragraph-wrap regex with a linear scanner. Button-label sanitizer now drops unterminated<scriptfragments instead of leaving them in the imported JSON.parsePxValuecollapses two whitespace quantifiers around an optionalpxso trailing whitespace can't trigger backtracking.- CI: every job in
.github/workflows/ci.ymlnow runs under a least-privilegepermissions: contents: readtoken. Closes the missing-workflow-permissions alerts. - Playground Cloudflare Worker:
generateIdswitched frombytes[i] % 62(biased — indices 0..7 were ~25% more likely than 8..61) to rejection sampling for a uniform distribution over the alphabet.
Regression coverage added: 13 new tests assert linear-time behavior on 10k–50k-char adversarial inputs (bounded at 500ms), plus correctness tests for the new dangerous-protocol coverage, nested-span rewriting, and button-label sanitization edge cases.
0.8.4
Fixes and improvements
Fix input fields overflowing their container in the template settings panel and other sidebars (issue #115).
@templatical/editor
Root cause: Tailwind preflight is disabled (intentional — see CLAUDE.md), so box-sizing: border-box was never applied to form elements. The hand-rolled .tpl reset block reset font-family and button chrome but not box-sizing. With tpl:w-full (width: 100%) plus a horizontal padding utility like tpl:px-3.5 (28px total), inputs resolved to content-box and extended their padding beyond the parent — most visible in the 320px right sidebar.
Fix: add box-sizing: border-box to the form-element reset in packages/editor/src/styles/index.css. Affects every <input>, <select>, <textarea>, and <button> under .tpl. Also resolves the social-toolbar slider/number-input misalignment reported in the same issue.
Regression locked by packages/editor/tests/formResetStyles.test.ts.
0.8.3
Fixes and improvements
Fix center alignment in Video and Image blocks (issue #111). When a fixed pixel width was set and alignment was set to "center", the editor preview rendered the block flush-left instead of centered.
@templatical/editor
Root cause: the alignment styles mixed the CSS margin shorthand with the marginLeft longhand in the same Vue style object. Vue patched margin: "0 auto" first (expanding to all four longhands including margin-left: auto), then patched marginLeft: undefined which cleared margin-left back to 0. Result: margin-right: auto only, which is left-alignment with extra space on the right.
Fix: use only longhand marginLeft / marginRight properties — no shorthand. MJML export was unaffected (the renderer emits align="center" on <mj-image> directly); only the editor preview was broken.
0.8.2
Fixes and improvements
Add a11y.link-nested-anchor rule and embrace HTML5-spec anchor parsing.
@templatical/quality
New rule
a11y.link-nested-anchor(default severityerror) flags templates that contain an<a>opened inside another open<a>. Nested anchors are invalid HTML and email clients render them inconsistently — some flatten, some strip the inner anchor, some break the click target.- Detection inspects the raw HTML on
ParagraphandTitleblocks via a new exported helperhasNestedAnchors(html), which tokenizes anchor open/close tags and ignores anchor-like text inside HTML comments. - Opt out per-template via
lintAccessibility(content, { accessibility: { rules: { "a11y.link-nested-anchor": "off" } } }), or downgrade with"warning"/"info".
Behavior change in extractAnchors
htmlparser2 12 (bumped from 9) emits an implicit </a> when a second <a> opens, matching the HTML5 spec. extractAnchors now returns nested anchors as flat siblings rather than merging the outer anchor's surrounding text. For input '<a href="/outer">prefix <a href="/inner">inner</a> suffix</a>':
- outer.text === "prefix inner suffix"
+ outer.text === "prefix"
+ inner.text === "inner" // unchangedText that appears after the inner anchor's close and before the outer's stray </a> is outside any open anchor in the spec parse and is not attributed to either. Consumers calling extractAnchors directly should check whether their callers relied on the old merged-text behavior; the structural concern (nested-anchor markup) is now captured by a11y.link-nested-anchor.
New exports
hasNestedAnchors(html: string): boolean
0.8.1
Fixes and improvements
Reshape LintOptions around per-tool config namespaces. Each linter's severity overrides and tool-specific knobs now live under its own key, and each linter can be disabled individually.
@templatical/editor · @templatical/quality
@templatical/quality
LintOptions.rulesandLintOptions.thresholdsmoved into their owning linter namespace:accessibility.rules,accessibility.thresholds,structure.rules,links.rules.LintOptions.linkskeepsnonProductionHostsbut now also acceptslinks.rules.- Each tool key (
accessibility,structure,links) acceptsfalseto disable that linter entirely without enumerating its rules — e.g.lintLinks(content, { links: false })returns[]. - New
isLintFullyDisabled(options)helper returnstruewhen no linter would run — eitherdisabled: trueor all three tool keys set tofalse. The editor uses this gate to skip lazy-loading the package, hide the Issues sidebar tab, and suppress canvas badges. Headless consumers can use it to short-circuit before any linter call. - New exported option types:
AccessibilityLintOptions,StructureLintOptions,LinksLintOptions,RuleOverrides. The oldLintLinksOptionstype is removed (replaced byLinksLintOptions). - Severity override keys still use the full prefixed rule ID (
a11y.*,structure.*,link.*) — the same ID emitted onLintIssue.ruleId, so values copied from an issue paste straight into config.
- lintAccessibility(content, {
- rules: { "a11y.img-missing-alt": "warning" },
- thresholds: { minFontSize: 16 },
- });
+ lintAccessibility(content, {
+ accessibility: {
+ rules: { "a11y.img-missing-alt": "warning" },
+ thresholds: { minFontSize: 16 },
+ },
+ });
- lintLinks(content, {
- rules: { "link.localhost-or-staging": "error" },
- links: { nonProductionHosts: ["*.preview.*"] },
- });
+ lintLinks(content, {
+ links: {
+ rules: { "link.localhost-or-staging": "error" },
+ nonProductionHosts: ["*.preview.*"],
+ },
+ });@templatical/editor
init({ lint })andinitCloud({ lint })consume the new shape verbatim. When every per-tool key is set tofalsethe editor behaves as iflint.disabled === true: no chunk download for@templatical/quality, no Issues sidebar tab, no inline canvas badges.useTemplateLintre-exports the newisLintFullyDisabledhelper.
0.8.0
Features
Add lintStructure + lintLinks and reshape the quality package around a shared linting surface.
@templatical/editor · @templatical/quality
@templatical/quality
- New
lintStructure(content, options?)linter — 5 rules:structure.duplicate-block-id,structure.section-column-mismatch,structure.nested-section,structure.empty-section(auto-fix removes the section),structure.empty-column. - New
lintLinks(content, options?)linter — 5 rules covering URL hygiene across every URL-bearing field in the template (anchors in rich text +button.url,image.linkUrl,video.url,menu.items[].url,social.icons[].url):link.javascript-protocol(error) — flagsjavascript:hrefs that the render-time sanitizer would silently strip.link.unsupported-protocol(warning) — flags explicit schemes outsidehttp,https,mailto,tel,sms.link.malformed-mailto(warning) — sanity-checksmailto:recipient + domain shape.link.malformed-tel(warning) — rejects letters intel:URIs.link.localhost-or-staging(warning) — flags URL hosts matchingoptions.links.nonProductionHosts(default catcheslocalhost,127.0.0.1,0.0.0.0,*.local,*.staging.*,*.dev.*).
- New
walkUrls(content) → UrlOccurrence[]helper for headless callers building URL-scoped rules. LintOptionsgainslinks?: { nonProductionHosts?: string[] }forlink.localhost-or-stagingconfiguration.DEFAULT_NON_PRODUCTION_HOSTSis exported as the baseline.- Rule IDs are now namespaced. Every accessibility rule is prefixed with
a11y.(e.g.img-missing-alt→a11y.img-missing-alt); structure rules usestructure.; link rules uselink.. Severity overrides and message-map keys must use the prefixed form. - Type names renamed for cross-linter reuse:
A11yIssue→LintIssue,A11yOptions→LintOptions,A11yPatch→LintPatch,A11yPatchContext→LintPatchContext(now also exposesremoveBlock),A11yThresholds→LintThresholds,DEFAULT_THRESHOLDS→DEFAULT_A11Y_THRESHOLDS. TheRULESexport is nowACCESSIBILITY_RULES; newSTRUCTURE_RULESandLINK_RULESexports sit alongside it. - New exports:
lintStructure,STRUCTURE_RULES,formatStructureMessage,getStructureMessages,SUPPORTED_STRUCTURE_MESSAGE_LOCALES,StructureMessageMap,StructureRuleMessageId,lintLinks,LINK_RULES,walkUrls,UrlOccurrence,UrlSource,formatLinkMessage,getLinkMessages,SUPPORTED_LINK_MESSAGE_LOCALES,LinkMessageMap,LinkRuleMessageId,LintLinksOptions,ResolvedLinksOptions,DEFAULT_NON_PRODUCTION_HOSTS.
@templatical/editor
init({ accessibility })is renamed toinit({ lint })— the same option object now drives every linter exported by@templatical/quality(accessibility + structure + links).- Sidebar tab renamed from "Accessibility" to "Issues" and now shows all three linter families. The composable is
useTemplateLint(wasuseAccessibilityLint); the inject key isTEMPLATE_LINT_KEY; the components areIssuesPanel.vueandBlockIssueBadge.vue. - Section toolbar now rebalances
childrenwhen the columns layout changes (1↔2↔3 / 1-2 / 2-1) — grows pad with empty columns, shrinks merge trailing columns into the last kept column so blocks are never silently dropped. Eliminates thestructure.section-column-mismatcherror that previously fired on every layout change. - Editor button reset (
.tpl button { background: none }) now wrapped in:where()so its specificity drops to (0,0,1) and per-button utility classes (e.g.tpl:bg-[var(--tpl-primary)]) win. Fixes the Fix-button-renders-transparent bug introduced by the canvas reset; affects every primary-bg button in the editor.
0.7.3
Fixes and improvements
Batch of bug fixes hardening editor correctness and security:
@templatical/core · @templatical/editor
- Link dialog rejects dangerous URL schemes.
javascript:,data:,vbscript:,file:(plus case-bypasses likeJaVaScRiPt:and whitespace-padded variants) are now dropped at link-insert time. Safe schemes (http,https,mailto,tel,ftp,ftps,sms,xmpp,cid) and#anchors still pass through. v-htmlcontent sanitized before render.ParagraphBlockandTitleBlocknow scrub<script>/<style>/<iframe>/on*event handlers and unsafehref/srcschemes fromblock.contentbefore binding it tov-html. Closes the XSS path where a malicious or compromised template JSON could execute code on canvas load. TipTap-authored content (the common case) is unaffected.- Block duplication regenerates nested IDs. Cloning a
table,social, ormenublock previously reused identicalrows[].id/cells[].id/icons[].id/items[].idfrom the source, violating the unique-id invariant. - Removing a section clears descendant selection. Previously, deleting an ancestor with a child selected left
selectedBlockIddangling on the now-orphan id. The full subtree is walked on remove and selection is cleared if any descendant id matches. addBlock/moveBlockvalidatecolumnIndexagainst the section layout. PassingcolumnIndex: 5on a"2"-layout section no longer creates phantom columns persisted into JSON; out-of-range indices are rejected andmoveBlockleaves the source intact.- Media-picker callers guard against post-unmount writes.
ImageBlock,ImageToolbar,VideoToolbar, and the custom-blockImageFieldnow check an alive flag afterawait onRequestMedia(). Closing the editor mid-pick no longer triggers zombieemit("update")/ pulse-ref writes on a torn-down component. - Keyboard shortcuts scoped to the active editor when two are mounted. Each
useEditorCoreinstance previously installed its owndocumentkeydown listener, so a singleCmd+Zfired both editors' undo handlers. The newactiveEditorTrackerroutes shortcuts to the editor the user most recently interacted with (single-editor pages keep the original always-active behavior). MergeTagSuggestioncancels its pendingrequestAnimationFrameon exit. The reposition-after-paint frame previously ran after the popup tore down, pinning the Vue app and DOM nodes for one frame.useMergeTagField.insertMergeTagno longer emits after the host component unmounts. A scope-dispose flag now gates the post-await requestMergeTag()writes (emit +isEditing+nextTick).useFonts.loadCustomFontsno longer flipsisLoadedafter dispose. The post-Promise.allSettledwrite is gated by the same scope-dispose flag.
0.7.2
Fixes and improvements
Block clone now inserts directly after the source block (in the same section column when applicable) instead of appending to the end of the canvas. Action bar now follows the editor's UI theme — appears dark in editor dark mode instead of being forced light by the canvas-wrapper override. Canvas dark-mode preview refactored: filter moved from .tpl-canvas-wrapper onto a sibling bg layer + per-block .tpl-block-content wrapper, so block chrome (action bar, indicators) is never inside the filter region — no more counter-filter flicker when toggling dark preview. Fixes drag-inside-section in Chrome: all three <VueDraggable> instances (sidebar, canvas, section) now use force-fallback to bypass Chrome's silent failure to initiate native drag from a nested HTML5 Sortable AND to ensure consistent cross-list drag-over coordination (Sortable only binds native dragover in HTML5 mode, so mixing modes breaks cross-list drops). Fixes a cyclic object value error that broke clone/move after a within-section drag — history.cloneContent is now cycle-safe (drops back-refs instead of throwing) and SectionBlock.setColumnBlocks deep-clones each emitted block to strip any Sortable expando the drag handler might attach. Adds findBlockLocation(blockId) to useEditor (and the cloud variant) and an optional findBlockLocation option on useBlockActions to power the new "insert clone after source" behavior.
@templatical/core · @templatical/editor
0.7.1
Fixes and improvements
Render social icons as hosted PNGs instead of inline SVG data URIs so they display in Outlook desktop (the Word rendering engine has no SVG support and rejects base64 in <img src>). PNGs are shipped with the npm package and served via the version-pinned unpkg URL by default; override via the new RenderOptions.socialIconsBaseUrl to self-host. Replace the Style segmented control in the social icons sidebar with a native dropdown so the 5-option list no longer overflows the sidebar.
@templatical/editor · @templatical/renderer
0.7.0
Features
Mount the editor inside a Shadow DOM by default. init({ container }) now resolve shadowDom: true when the option is omitted — host page stylesheets no longer cascade into editor elements (p, h1, a, input, etc.) via tag selectors, closing issue #70.
@templatical/editor
Behavior changes consumers may notice:
- External
document.querySelector("#editor .tpl-…")queries no longer reach editor internals because the editor's DOM lives insidecontainer.shadowRoot. Walk the shadow root explicitly (container.shadowRoot.querySelector(...)) or opt out withshadowDom: false. - Host stylesheets that intentionally styled editor elements via element selectors stop applying. The supported theming protocol is now the
--tpl-user-*CSS custom property namespace — set--tpl-user-primary,--tpl-user-radius-md, etc. on the editor container (or any ancestor) and the override inherits across the shadow boundary. The existingthemeconfig option still takes precedence and works unchanged. - Browser minimums in default mode bump to Firefox 101+ and Safari 16.4+ (required by the
adoptedStyleSheetsAPI). Chrome / Edge 80+ is unchanged. PassshadowDom: falseto keep the previous light-DOM mount with broader browser support.
The shadowDom: false escape hatch remains supported.
0.6.7
Fixes and improvements
Simplify locale resolution in @templatical/editor and @templatical/media-library and align behavior between the two. Both packages now share the same canonicalization step (trim, treat _ as -, lowercase) so locales like pt_BR are accepted alongside pt-BR. The editor's exact-then-base fallback logic is deduplicated behind a single helper used by resolveLocale, isLocaleSupported, and isCloudLocaleSupported. @templatical/editor now re-exports getSupportedLocales, getSupportedCloudLocales, isLocaleSupported, isCloudLocaleSupported, and getBaseLocale from its public entry point so consumers can drive their own locale pickers without reaching into the i18n subpath. No behavior change for any existing locale input; this is purely cleanup and a small API surface addition.
@templatical/editor · @templatical/media-library
0.6.6
Fixes and improvements
Fix Title block rendering as <p> inside the editor canvas. The exported MJML/HTML already used the correct <h${level}> tag, but the canvas wrapped TipTap's stored content in a plain <div> and left the outer <p> from the editor's paragraph node in place, so the editor preview diverged from the final email and consumer CSS rules targeting p could unintentionally style titles in the canvas. The non-editing branch of TitleBlock now renders h1–h4 matching block.level and strips the single outer <p> wrapper using the same rule the renderer applies. No data migration is needed — existing templates already carry level and render correctly on reload. Consumers that previously overrode title styling via .tpl-text-content p selectors in the canvas should switch to heading selectors (h1–h4) to match the exported output.
@templatical/editor
0.6.5
Fixes and improvements
Replace vuedraggable with vue-draggable-plus. The previous draggable library shipped a UMD-only bundle whose define.amd wrapper got inlined into our published editor chunks, causing error TP1200 unsupported AMD define() dependency element form for any Next.js 15+ consumer using Turbopack (the default). The new library ships proper ESM, so the published bundle no longer contains UMD/AMD wrappers and Turbopack builds succeed. No public API change.
@templatical/editor
0.6.4
Fixes and improvements
Fix Webpack 5 production build failure on @templatical/media-library (issue #63). The dynamic import() for the cloud media browser was missing the try/catch wrapper that the other three optional peers (pusher-js, @templatical/quality, @templatical/renderer) already had. Without it, Webpack escalates "Module not found" from a warning to an error and breaks the consumer's production build. Wraps the import so OSS consumers (no cloud, no media-library installed) can build cleanly. Adds a regression test that builds the editor as a real Webpack 5 consumer would (pnpm run test:webpack-consumer, wired into CI). Vite, esbuild, and Rolldown were unaffected.
@templatical/editor
0.6.3
Fixes and improvements
Fix missing video block configuration panel. Selecting a video block in the canvas previously showed only the common spacing/background/display settings — there was no way to set the video URL, custom thumbnail, alt text, width, alignment, or open-in-new-tab from the sidebar. Adds a VideoToolbar matching the parity of ImageToolbar, including merge-tag-aware URL/thumbnail inputs and a media browser button when an onRequestMedia handler is configured.
@templatical/editor
0.6.2
Fixes and improvements
Polish and general bug fixes
@templatical/core · @templatical/editor · @templatical/import-html · @templatical/renderer
0.6.1
Fixes and improvements
Fix four bugs in @templatical/quality:
@templatical/quality
text-all-capsnow flags non-Latin scripts. The previous Latin-only character class silently passed all-caps Cyrillic, Greek, Vietnamese, and other non-Latin scripts. The rule now uses the Unicode\p{L}class with locale-aware case comparison, soСКИДКА ПЯТЬДЕСЯТ ПРОЦЕНТОВ…andΜΕΓΑΛΗ ΠΡΟΣΦΟΡΑ…are reported just like English shouty caps.img-linked-no-contextis now locale-aware. Action-verb hints were hardcoded English, which produced false positives on every German / French / Portuguese alt text. The hints have moved into the dictionary registry as a newlinkedImageActionHintsfield, with the same cross-locale union semantics asvagueLinkTextandvagueButtonLabels. Locale contributors should add their language's action verbs (single tokens, e.g."kaufen","compre") when adding a new dictionary file.link-target-blank-no-relauto-fix. The fix performed a raw substringString.replaceagainst the attribute string, so an earlier attribute whose value happened to containrel="…"(e.g.data-x='rel="external"') could be corrupted instead of the realrel. The fix now splices by the attribute's parsed start offset.extractAnchors(public utility). When a nested<a>opened (invalid but parsed permissively), the outer anchor's accumulated prefix text was wiped. Each open anchor now owns its own buffer; nested anchors no longer truncate ancestors.
The Dictionary type exported from @templatical/quality gains a required linkedImageActionHints: string[] field. Downstream contributors maintaining a custom dictionaries/<locale>.ts will see a typeof en typecheck error until they add the field — see the updated Contributing locales guide.
0.6.0
Features
Introduce @templatical/quality — an MIT-licensed accessibility linter for Templatical email templates — and wire it into the editor.
@templatical/editor · @templatical/quality · @templatical/renderer · @templatical/types
New package: @templatical/quality
lintAccessibility(content, options?)— synchronous, pure, no DOM. Walks the JSONTemplateContenttree and runs every enabled rule, returningA11yIssue[]withseverity,message,blockId, and an optionalfixpatch.- 19 deterministic rules across images, headings, links, text, buttons, and structure (missing alt, filename-style alt, low contrast, vague CTAs, heading-skip, multiple H1, target=_blank without rel=noopener, all-caps, undersized touch targets, missing preheader, …). Three rules ship one-click auto-fixes.
- Public utilities:
walkBlocks,getContrastRatio(WCAG sRGB),parseHex,isOpaqueHex,extractAnchors,extractText,getDictionary,formatMessage,getMessages. PlusRule,RuleHit,RuleMeta,A11yIssue,A11yOptions, etc. - Per-rule severity overrides (
'error' | 'warning' | 'info' | 'off') and configurable thresholds (altMaxLength,minFontSize,allCapsMinLength,minTouchTargetPx). - Locale-aware: rule messages and vague-text dictionaries auto-discover via
import.meta.glob(drop amessages/<lang>.tsordictionaries/<lang>.tsand it's bundled). The dictionary is a cross-locale union — a German-locale email with an English "Click here" button still flags. Shipsen+detoday.
Type changes (@templatical/types)
TemplateSettings.locale(optional, defaults to'en') — drives rendered<mjml lang="…">.ImageBlock.decorative(optional boolean) — when true, the renderer forcesalt=""and addsrole="presentation".PlanConfig.accessibility.blockOnError(cloud) — server-side policy hook.
Renderer changes (@templatical/renderer)
- Emits
<mjml lang="…">fromsettings.locale. - Honors
ImageBlock.decorative(empty alt + role="presentation").
Editor integration (@templatical/editor)
- New
accessibilityoption oninit()/initCloud()— fullA11yOptionsshape. Optional peer; the dynamic import is gated and tree-shakeable, so the linter chunk never downloads when not used. - New
useAccessibilityLintcomposable — debounced 500ms re-lint on content changes, applies auto-fixes through the editor's existingupdateBlock/updateSettings(history-tracked, undoable per fix). - New right-sidebar "Accessibility" tab (lazy-loaded). Errors / Warnings / Info groups with localized messages, "Jump to block" and "Fix" buttons, count badge.
- New inline canvas badge inside
BlockWrapper—CircleAlertfor errors,TriangleAlertfor warnings. - New "Decorative image" toggle on
ImageToolbarbound toblock.decorative. - Editor mode forces the linter
localeto matchinit({ locale })—accessibility.localeis overwritten on the way through. Headless callers keep full control. - Cloud save-gate: when
planConfig.accessibility.blockOnError === trueand the linter reports any errors, the save flow surfaces a confirmation modal. Both the toolbar Save button and theCmd/Ctrl+Skeyboard shortcut route through the gate. - New i18n keys (
accessibility.*inen/deOSS chunks;saveGate.*in cloud chunks). - CDN bundle ships
@templatical/qualityand@templatical/rendereras separate code-split chunks, so CDN consumers don't install the optional peer manually.
0.5.1
Fixes and improvements
Fix several merge-tag UX bugs:
@templatical/editor
- Insert button no longer renders without an
onRequestcallback. When only staticmergeTags.tagswere configured, the "Insert merge tag" button still showed in the rich-text toolbar,MergeTagInput, andMergeTagTextarea, but clicking it silently no-oped (requestMergeTagreturns null withoutonRequestMergeTag). Static-tags users discover tags via the autocomplete typing trigger; the button now only appears whenonRequestis wired up. Renamed the underlying flag fromisEnabled→canRequestMergeTagfor clarity. - Autocomplete popup positioning no longer breaks on consumer pages with transformed ancestors. The popup used to mount inside
[data-tpl-theme](the editor wrapper) and rely onposition: fixedresolving against the viewport. Anytransformon a consumer-page ancestor (route transitions, reveal animations) makes that ancestor the containing block for fixed descendants — the popup landed off-screen instead of pinning to the caret. Popup now mounts todocument.bodyand snapshots--tpl-*design tokens + typography from the editor's theme root inline so styling carries over without inheriting.tplbase rules. - Popup rounded corners restored.
MergeTagSuggestionListwas referencing the undefined--tpl-radius-mdtoken; switched to--tpl-radius.
Cleanup: leftover "placeholder" copy in editor and playground i18n strings (and corresponding docs in apps/docs) is updated to "merge tag" where it referred to the merge-tag concept rather than HTML input placeholder text.
0.5.0
Features
Add @templatical/import-html package. Converts table-based HTML email templates (MJML output, Mailchimp/SendGrid/Campaign Monitor exports, hand-coded marketing emails) to Templatical JSON via convertHtmlTemplate(html). Resolves <style> blocks onto inline styles, recognizes layout tables, button cells, spacer cells, and dividers. Unknown elements are preserved as HTML-fallback blocks.
@templatical/import-html
0.4.0
Features
Add new @templatical/import-unlayer package that converts Unlayer design JSON (the output of editor.saveDesign(...)) into Templatical's TemplateContent shape. Mirrors @templatical/import-beefree: maps text, heading, image, button, divider, html, menu, social, video; reports timer as html-fallback and form as skipped; flattens 4+ column rows; surfaces a per-content conversion report. MIT-licensed.
@templatical/core · @templatical/editor · @templatical/import-beefree · @templatical/import-unlayer · @templatical/media-library · @templatical/renderer · @templatical/types
The Unlayer migration guide (/guide/migration-from-unlayer and /de/guide/migration-from-unlayer) is rewritten around the importer. The playground replaces the BeeFree-only chooser button with a single "Import existing template" modal that exposes BeeFree and Unlayer as tabs. README, license FAQ, security policy, and contributing guide reflect the new package; cloud headless API reference adds the matching templates/import/from-unlayer route row.
0.3.2
Fixes and improvements
Fix a batch of bugs uncovered by a targeted audit:
@templatical/editor · @templatical/renderer
@templatical/editoruseFocusTrap: the focus-restorerequestAnimationFrameis now cancelled when the trap deactivates before the frame fires, so it no longer touches stale DOM. A second activation (e.g. container ref swapped while still active) now tears down the previous keydown listener before binding a new one, preventing duplicate listeners.@templatical/editoruseMergeTagField.insertMergeTag: a rejection fromrequestMergeTag()no longer leavesinsertingMergeTagstuck attrueand locking outstopEditing(). The flag is now reset in afinally.@templatical/editorbundle-stats Vite plugin: a failure inside the stats-generationcloseBundlehook (missing dist file, unexpected layout) no longer crashes the editor build. The plugin warns and skips instead.@templatical/editoruseRichTextLinkDialog:mailto:,tel:,ftp:, and#anchorURLs are no longer mangled by an unconditionalhttps://prefix. Only bare hostnames/paths get the scheme prepended.@templatical/editoruseKeyboardReorder: pressing Escape after a lifted block was moved across containers (e.g. concurrent drag) now correctly restores it. The cancel logic compares the full original location, not just the index.@templatical/editorformatRelativeTime: an invalid date string now returnsnullinstead of"NaN days ago".@templatical/editorSlidingPillSelect: the sliding pill is now hidden whenmodelValuematches no option, instead of silently parking on the first one and producing anaria-checkedmismatch.@templatical/editoruseCloudMediaLibrary.handleRequestMedia: a second call while the built-in media library is open no longer leaves the first call's promise hanging forever. The pending promise is now resolved withnullbefore opening a new picker.@templatical/renderertable & menu colors:borderColor,headerBackgroundColor,separatorColor, and menuitem.colorare now run through a CSS-value escaper that strips;,{,}, and newlines. Tampered or AI-generated color values can no longer break out of the inlinestyle="color: …"attribute to inject extra properties (e.g.background: url(…)for open-tracking).@templatical/renderertitle block: title content stored as TipTap's<p>...</p>no longer produces invalid<h2><p>...</p></h2>markup. A single outer<p>wrapper is stripped before the<h${level}>tag is emitted.@templatical/renderercolumns: the three-column layout widths now sum to exactly 100% (the last column rounds to 33.34% instead of 33.33%, eliminating a 0.01% gap that some clients distributed unpredictably).@templatical/rendererimage/video/button/menu links:target="_blank"links now also emitrel="noopener", closing awindow.openerleak when emails are opened in webmail clients.@templatical/renderercustom block background:CustomBlock.styles.backgroundColornow reaches the compiled HTML — the renderer was emitting<mj-text>withoutcontainer-background-color, so MJML silently dropped the bg (same class as #26).@templatical/rendererimage with emptysrc: the renderer no longer emits<mj-image src="">(which compiles to a broken-image<img src="">). Empty-src images are now skipped, mirroring thevideoblock's existing behavior.@templatical/renderertitle heading levels: out-of-rangelevelvalues no longer interpolatefont-size="undefinedpx"and break MJML compilation. Bothfont-sizeand the heading tag are clamped to a defined entry.@templatical/renderernested sections: aSectionBlockplaced inside a column (via tampered JSON or programmatic API) is now filtered out instead of emitting<mj-section>inside<mj-column>, which mjml@5 rejects with a hard error.@templatical/rendererbutton:backgroundColorandtextColorare nowescapeAttr'd like every other user-supplied attribute. A"in either value can no longer break the surrounding MJML attribute.@templatical/rendererbutton with emptyurl: an empty button URL no longer compiles to a clickable<a href="">(which navigates to the current page on click). Thehrefattribute is omitted entirely when the URL is empty.@templatical/rendererspacer: spacers now occupy exactlyheightpixels in the exported HTML, matching the editor canvas.block.styles.paddingno longer inflates a 30px spacer to 50px.@templatical/rendererempty paragraph: a paragraph with no content (or only<p></p>/ whitespace) now renders to an empty string instead of a styled<td>cell that silently consumes vertical and horizontal whitespace.@templatical/rendererparagraph default font-size: paragraphs without an explicit font-size now render at 14px to match the editor canvas (Tailwindtext-sm), not mjml@5's intrinsic 13px default. Per-section TipTap inlinestyle="font-size: …"overrides still apply.
0.3.1
Fixes and improvements
Emit dist/bundle-stats.json during the editor build with gzipped totals for the initial static bundle and the lazy chunks. Powers the bundle-size pill on templatical.com — the marketing site fetches the file from unpkg at its own build time so the published number reflects what real consumers see (Vite/webpack/Rollup preserve dynamic-import boundaries) instead of a bundler-server-side overcount.
@templatical/editor
File contract:
{
"version": "x.y.z",
"initialGzipBytes": 211686,
"initialRawBytes": 717015,
"initialFileCount": 30,
"lazyGzipBytes": 250763,
"lazyRawBytes": 876728,
"lazyFileCount": 43,
"generatedAt": "2026-05-02T12:48:23.883Z"
}Implemented as a small Vite closeBundle plugin in packages/editor/vite.config.ts — walks the static-import graph from templatical-editor.js, gzips each chunk individually, and treats every other .js in dist/ as lazy. dist/cdn/ (the separate script-tag distribution) is excluded.
0.3.0
Features
Merge tag autocomplete in rich text editors. Typing the syntax opener (e.g. {{ for Liquid/Handlebars, *| for Mailchimp, %%= for AMPscript) inside a paragraph or title block surfaces a popup of matching merge tags. Selecting an item (mouse click, Enter, or Tab) inserts the tag as a styled node — same form as the toolbar picker.
@templatical/core · @templatical/editor · @templatical/import-beefree · @templatical/media-library · @templatical/renderer · @templatical/types
@templatical/types
- New
getSyntaxTriggerChar(syntax)helper that maps aSyntaxPresetto its trigger string ("{{","*|","%%=") ornullfor custom regex syntaxes. MergeTagsConfiggains optionalautocomplete?: boolean(defaulttrue). Set tofalseto disable the popup while keeping the toolbar picker available.
@templatical/editor
- New
MergeTagSuggestionTipTap extension built on@tiptap/suggestion. Filters tags case-insensitively againstlabelandvalue, capped at 10 results. - New
MergeTagSuggestionList.vuepopup component — keyboard navigable (↑/↓/Enter/Tab/Esc), ARIA combobox-compliant (role="combobox"+aria-haspopup/aria-expanded/aria-controls/aria-activedescendanton the contenteditable;role="listbox"+role="option"+ stable per-option ids on the popup). - Wired into
ParagraphEditor.vueandTitleEditor.vue. Autocomplete activates only whentagsis non-empty ANDsyntaxmatches a built-in preset. - Popup mounts at the theme root (outside the Canvas's
filter-induced containing block) so dark-mode positioning stays correct. Viewport-flip logic places the popup above the caret when there's not enough room below; constrained tomax-h: 50vhwith internal scrolling. - New i18n key
mergeTag.suggestionEmpty(en + de).
Behavior
- Trigger fires regardless of preceding character (no whitespace requirement) —
.{{opens the popup just like{{. - Custom-regex syntaxes silently disable autocomplete since the trigger string can't be inferred.
Cloud editor
- Inherited transitively —
CloudEditor.vueuses the sameParagraphBlock/TitleBlockcomponents, so autocomplete works there as well with no extra wiring.
0.2.1
Fixes and improvements
Fix a batch of bugs uncovered by a targeted audit:
@templatical/core · @templatical/editor · @templatical/media-library · @templatical/types
@templatical/coreuseAutoSave: a save scheduled inside the debounce window no longer fires afterenabledflips tofalseorpause()is called. The setTimeout callback now re-checks both gates.@templatical/media-libraryinit(): two rapidinit()calls no longer orphan the first-mounted Vue app. The "unmount existing" guard moved after the awaits so the second call observes the first instance.@templatical/coreuseEditor.moveBlock: passing an invalidtargetSectionIdno longer deletes the block. The target section is resolved before the source is mutated, so an invalid target is now a clean no-op.@templatical/coreuseEditorlock checks:addBlockandmoveBlocknow respectisBlockLockedfor the target section / moved block, matching the existing checks onupdateBlockandremoveBlock.@templatical/editorkeyboard shortcuts:Cmd/Ctrl+Snow triggers save when Caps Lock is on. The handler matchese.key.toLowerCase() === "s"to mirror thez(undo/redo) handler.@templatical/editorinit()andinitCloud(): same race fix as the media-library one — concurrent calls no longer orphan the first-mounted editor app.@templatical/typesresolveSyntax: passing an unknown preset name now falls back toliquidinstead of returningundefinedand crashing downstream callers.@templatical/editoruseFonts: a custom font that fails to load is now registered for cleanup, so its<link>tag is removed on editor unmount instead of leaking in<head>.@templatical/coreuseHistoryInterceptor: history snapshots are no longer recorded for no-op mutations (e.g. updating a peer-locked block), preventing the undo button from becoming a silent no-op.@templatical/editoruseRichTextEditor: unmounting the host component while TipTap extensions are still loading no longer leaks a TipTap editor instance. Adestroyedguard short-circuits and disposes any editor created across the await boundary.@templatical/media-libraryuseMediaLibrary.loadItems/loadMore: a stalebrowseMediaresponse from a previous folder no longer overwrites the current view. Each request carries a monotonic token and only the latest response commits to state.@templatical/typesisMergeTagValue: handlebars logic tags such as{{#if x}}and{{/if}}are no longer misclassified as value merge tags by the liberal handlebars value regex.
0.2.0
Features
This release bundles three changes: an OSS/Cloud locale split, a fix for missing custom blocks in MJML/JSON exports, and a fix for incorrect background-color attributes on inner MJML elements.
@templatical/core · @templatical/editor · @templatical/import-beefree · @templatical/media-library · @templatical/renderer · @templatical/types
OSS/Cloud locale split
Split @templatical/editor translations into OSS and cloud chunks so external locale contributions only need to cover the open-source surface.
Editor i18n changes
- Added
packages/editor/src/i18n/locales/cloud/{en,de}.tscontaining strings used only byinitCloud()features: AI chat / rewrite / menu, comments, collaboration, scoring, snapshots, plan limits (header.*), test email, saved modules, design reference, cloud loading/error overlays. These groups were removed from the OSSlocales/{en,de}.ts. - New exports from
@templatical/editor:loadCloudTranslations(locale),getSupportedCloudLocales(),isCloudLocaleSupported(locale), typeCloudTranslations. - New injection key
CLOUD_TRANSLATIONS_KEYand composablesuseCloudI18n()(returnsCloudTranslations | nullfor shared components that conditionally render cloud UI) /useCloudI18nStrict()(throws if not provided, for cloud-only components). initCloud()now loads OSS + cloud translation chunks in parallel and provides both.init()(OSS) loads only the OSS chunk — the cloud strings are tree-shaken from the OSS bundle.- Supported-locale lists are auto-derived via
import.meta.glob. OSS and cloud locales are tracked separately, so an OSS-only contributor addinglocales/fr.tswithoutlocales/cloud/fr.tsships a French OSS UI while the cloud chunk gracefully falls back to English at runtime.
Locale parity enforcement
- Type-driven: every non-
enlocale file is now annotated: typeof enso missing/extra/mistyped keys failpnpm run typecheck. - Runtime:
tests/i18n.test.tsdiscovers locale files viaimport.meta.globand asserts nested-key parity plus per-key{placeholder}parity. OSS parity is hard-required; cloud parity is skip-if-absent (only enforced for cloud locales that exist on disk). Same pattern applied to@templatical/media-library.
Migration notes for embedders
- No public API removals.
Translations,useI18n(),loadTranslations(),getSupportedLocales(),isLocaleSupported(),TRANSLATIONS_KEYkeep their previous names and behavior — they just refer to the OSS surface now. - If you imported cloud-only string paths through
Translations(e.g.t.aiChat.title), switch touseCloudI18n()/useCloudI18nStrict(). WithininitCloud()the cloud strings are still available; they are no longer present on the OSSTranslationstype. - Existing locale overrides passed to
init()/initCloud()continue to work. Cloud overrides are not yet a supported public input — only locale strings are.
Custom blocks now appear in MJML/JSON exports
Custom blocks were missing from MJML/JSON exports because their rendered HTML was never persisted from the editor's UI ref into the export pipeline. The fix moves custom-block resolution into the renderer itself as an explicit contract.
Renderer
renderToMjml(content, options?)is now async (Promise<string>). Custom blocks may need async resolution.- New
RenderOptions.renderCustomBlock?: (block: CustomBlock) => Promise<string>option. The renderer walks the tree, awaits all custom-block resolutions in parallel, then runs the existing sync render pass. - If no callback is provided, the renderer falls back to
block.renderedHtml(if present) and otherwise omits the custom block from output.
Editor
editor.toMjml()is nowPromise<string>(was sync), always present (was optional). Wires the editor's internal block registry into the renderer'srenderCustomBlockcallback automatically.- If
@templatical/rendereris not installed,toMjml()throws a clear error — the renderer remains an optional peer dependency. - New method
editor.renderCustomBlock(block): Promise<string>for headless callers that want to drive the renderer directly while reusing the editor's registry. - The Cloud editor does not expose
toMjml()— the cloud backend handles MJML conversion server-side with additional processing (signed image URLs, asset rewriting). Use the OSSinit()if you want client-side export.
Migration
- Add
awaiteverywhere you calleditor.toMjml()orrenderToMjml(content). - Drop any optional-chain (
editor.toMjml?.()) — the method is always defined now. - Headless / Node.js consumers calling
renderToMjmldirectly with custom blocks should pass arenderCustomBlockresolver (e.g. a Liquid engine running againstblock.fieldValues) — see the renderer README for the full pattern.
MJML inner-element background colors now render correctly
Inner MJML elements (mj-text, mj-image, mj-table, mj-navbar, mj-video) only support container-background-color per the MJML spec; passing background-color was silently dropped by MJML compilers, leaving the rendered email's <td> wrapper without a background. The renderer now emits the correct attribute. mj-section and mj-button continue to use the native background-color attribute they natively support.
The rule is centralized in a new bgAttr(color, "container" | "native") helper so future renderers can't regress, and round-trip MJML→HTML compile tests (tests/mjml-bg-roundtrip.test.ts) catch the silent-drop class of bug.
0.1.2
Fixes and improvements
Fix editor interactivity broken in 0.1.1.
@templatical/editor
In 0.1.1 we bundled Vue inline but left @templatical/core and @templatical/types as external dependencies. Because @templatical/core imports reactivity primitives from @vue/reactivity (the standalone package), and the editor bundle shipped Vue's full runtime (which contains its own copy of the reactivity system), consumers ended up with two separate reactivity instances at runtime — each with its own dep-tracking WeakMap. Refs created by @templatical/core's useEditor were never tracked by the editor's render functions, so clicks, drags, and every state mutation silently no-op'd: the editor rendered initial chrome but ignored all user input.
This release bundles @templatical/core, @templatical/types, and all transitive Vue libraries (@vueuse/core, vuedraggable, @tiptap/*, @lucide/vue) inline into the editor's npm entry, with vue and @vue/reactivity deduped to a single instance via Vite's resolve.dedupe. The editor is now a truly self-contained drop-in:
- Consumer install drops from 149 → 71 packages (no
vue, no@vue/*, no@tiptap/*, no@templatical/core/typesinnode_modules). - Zero peer warnings on
npm install @templatical/editorfor any framework (React, Svelte, Angular, vanilla, Vue). - Interactivity works in every consumer setup verified — including a React app with
<div id="editor" />rendered as a JSX child inside the React tree.
The only externals that remain are the optional cloud/feature peers a consumer explicitly opts into: @templatical/media-library, @templatical/renderer, pusher-js.
Note for SDK contributors: when adding a new runtime dependency to the editor that uses Vue's reactivity (or imports from @vue/reactivity directly), it MUST be bundled inline (i.e. listed in devDependencies, not dependencies, and not in rolldownOptions.external). Adding such a dep as a runtime/peer dep reintroduces the duplicate-reactivity bug.
0.1.1
Fixes and improvements
Fix consumer install/bundle of @templatical/editor.
@templatical/core · @templatical/editor
@templatical/editor/style.css export — CSS now emits asdist/style.cssso the./style.csssubpath export resolves. Previously emitted asdist/templatical-editor.css, causing 404s forimport '@templatical/editor/style.css'and breaking sandbox bundlers (bundlephobia, bundlejs).@templatical/editorpeer deps —vueandtailwindcssremoved frompeerDependencies. Vue is now bundled into the npm entry; Tailwind is build-time only (CSS already compiled).npm install @templatical/editoris now a complete install for any consumer (React, Svelte, Angular, vanilla, Vue) with no peer warnings. Note for Vue app consumers: Vue is now isolated inside the editor (Stripe-Elements pattern). Your Vue tree is unaffected, but Vue is shipped twice (~80KB gz duplicated).@templatical/core/cloud pusher-js — clearer error when cloud features are used without thepusher-jsoptional peer installed.
0.1.0
Features
Initial production release
@templatical/editor
0.0.6
Fixes and improvements
Dependency update
@templatical/editor
0.0.5
Fixes and improvements
Polish and component extraction
@templatical/editor
0.0.4
Fixes and improvements
Fix CDN version of Editor + Style and animation fixes
@templatical/editor · @templatical/media-library
0.0.3
Fixes and improvements
Test coverage and Media Library CDN build
@templatical/editor · @templatical/media-library
0.0.2
Fixes and improvements
Include CDN build (ES module with code-split chunks) in the editor package at dist/cdn/. Drop IIFE build in favor of ES-only output for smaller initial load. Add pusher-js as a dependency in core for typecheck support.
@templatical/core · @templatical/editor · @templatical/media-library · @templatical/renderer