@standhigher/react-email-editor 0.4.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -142,7 +142,7 @@ var DEFAULT_EMAIL_BODY = {
142
142
  textColor: "#000000",
143
143
  backgroundColor: "#ffffff",
144
144
  contentWidth: CANVAS_DEVICE_WIDTH.desktop,
145
- fontFamily: "Arial, Helvetica, sans-serif",
145
+ fontFamily: "",
146
146
  fontWeight: FONT_WEIGHTS.normal,
147
147
  linkColor: "#005bd3",
148
148
  linkDecoration: LINK_DECORATIONS.underline,
@@ -379,7 +379,7 @@ var DEFAULT_TYPOGRAPHY = Object.freeze({
379
379
  function typographyWithBody(body) {
380
380
  return __spreadValues(__spreadValues({}, DEFAULT_TYPOGRAPHY), body ? {
381
381
  color: body.textColor,
382
- fontFamily: body.fontFamily,
382
+ fontFamily: cssFont(body.fontFamily, DEFAULT_TYPOGRAPHY.fontFamily),
383
383
  fontWeight: body.fontWeight,
384
384
  linkColor: body.linkColor,
385
385
  linkDecoration: body.linkDecoration
@@ -2661,9 +2661,34 @@ function createEmailDocument(blocks = []) {
2661
2661
  function createEmptyDocument() {
2662
2662
  return { version: DOCUMENT_VERSION, subject: "", body: resolveEmailBody(), blocks: [] };
2663
2663
  }
2664
+ function isRecord(value) {
2665
+ return value !== null && typeof value === "object" && !Array.isArray(value);
2666
+ }
2664
2667
  function ensureEmailDocument(document) {
2665
- if (document.version !== DOCUMENT_VERSION) {
2666
- throw new Error(`Unsupported email document version: ${document.version}. Expected ${DOCUMENT_VERSION}.`);
2668
+ if (!isRecord(document)) {
2669
+ throw new TypeError("Invalid email document: expected an object.");
2670
+ }
2671
+ if (!Array.isArray(document.blocks)) {
2672
+ throw new TypeError("Invalid email document: blocks must be an array.");
2673
+ }
2674
+ if (document.body != null && !isRecord(document.body)) {
2675
+ throw new TypeError("Invalid email document: body must be an object when provided.");
2676
+ }
2677
+ if (document.subject !== void 0 && typeof document.subject !== "string") {
2678
+ throw new TypeError("Invalid email document: subject must be a string when provided.");
2679
+ }
2680
+ for (const [index, block] of document.blocks.entries()) {
2681
+ const path = `Invalid email document: blocks[${index}]`;
2682
+ if (!isRecord(block)) throw new TypeError(`${path} must be an object.`);
2683
+ if (typeof block.id !== "string" || !block.id.trim()) {
2684
+ throw new TypeError(`${path}.id must be a non-empty string.`);
2685
+ }
2686
+ if (typeof block.type !== "string" || !block.type.trim()) {
2687
+ throw new TypeError(`${path}.type must be a non-empty string.`);
2688
+ }
2689
+ if (!isRecord(block.controls)) {
2690
+ throw new TypeError(`${path}.controls must be an object.`);
2691
+ }
2667
2692
  }
2668
2693
  return __spreadProps(__spreadValues({}, document), { body: resolveEmailBody(document.body) });
2669
2694
  }
@@ -2807,7 +2832,7 @@ var EmailControls = {
2807
2832
 
2808
2833
  // src/plugins/define-block.ts
2809
2834
  function defineBlock(input) {
2810
- var _a, _b, _c;
2835
+ var _a, _b, _c, _d;
2811
2836
  const options = snapshotValue(input);
2812
2837
  if (!options.type.trim() || !options.title.trim()) throw new Error("Block type and title are required.");
2813
2838
  const schema = (_a = options.controls) != null ? _a : {};
@@ -2825,7 +2850,7 @@ function defineBlock(input) {
2825
2850
  type: options.type,
2826
2851
  title: options.title,
2827
2852
  description: options.description,
2828
- icon: options.icon,
2853
+ icon: (_c = options.icon) != null ? _c : esc(Array.from(options.title.trim())[0]),
2829
2854
  override: options.override,
2830
2855
  schema,
2831
2856
  controls: Object.entries(schema).map(([name, spec]) => __spreadProps(__spreadValues({}, spec), { name })),
@@ -2834,7 +2859,7 @@ function defineBlock(input) {
2834
2859
  inspectorSections: options.inspectorSections,
2835
2860
  defaultStyle: options.defaultStyle,
2836
2861
  defaultData: options.defaultData,
2837
- inlineControl: options.Canvas ? void 0 : (_c = inline[0]) == null ? void 0 : _c[0],
2862
+ inlineControl: options.Canvas ? void 0 : (_d = inline[0]) == null ? void 0 : _d[0],
2838
2863
  canvas: bindPluginComponent(options.Canvas),
2839
2864
  inspector: bindPluginComponent(options.Inspector),
2840
2865
  renderEmail: (ctx) => {
@@ -3471,7 +3496,7 @@ var BODY_CONTROL_SECTIONS = [
3471
3496
  { title: "General", controls: [
3472
3497
  { id: "textColor", group: "typography", label: "Text color", control: "color" },
3473
3498
  { id: "linkColor", group: "typography", label: "Link color", control: "color" },
3474
- { id: "fontFamily", group: "typography", label: "Font family", control: "select", options: copyFontFamilies() },
3499
+ { id: "fontFamily", group: "typography", label: "Font family", control: "select", options: [{ label: "Default", value: "" }, ...copyFontFamilies()] },
3475
3500
  __spreadProps(__spreadValues({}, FONT_WEIGHT), { id: "fontWeight", label: "Font weight" })
3476
3501
  ] },
3477
3502
  { title: "Content", controls: [
@@ -4589,6 +4614,9 @@ function Inspector({
4589
4614
  ] });
4590
4615
  }
4591
4616
 
4617
+ // src/stage/Stage.tsx
4618
+ import { useMemo as useMemo5 } from "react";
4619
+
4592
4620
  // src/stage/canvas/Canvas.tsx
4593
4621
  import { useDroppable } from "@dnd-kit/core";
4594
4622
  import {
@@ -4619,19 +4647,88 @@ import { CSS } from "@dnd-kit/utilities";
4619
4647
  // src/stage/canvas/components/PluginCanvasContent.tsx
4620
4648
  import { useMemo as useMemo2 } from "react";
4621
4649
 
4622
- // src/render/template-preview.ts
4623
- var TOKEN_PATTERN = /\{\{-?([\s\S]*?)-?\}\}/g;
4650
+ // src/render/default-template-engine.ts
4651
+ var TOKEN_PATTERN = /\{\{(-[ \t\r\n])?([\s\S]*?)([ \t\r\n]-)?\}\}/g;
4652
+ var UNSUPPORTED_IDENTIFIERS = /* @__PURE__ */ new Set([
4653
+ "and",
4654
+ "or",
4655
+ "not",
4656
+ "len",
4657
+ "index",
4658
+ "slice",
4659
+ "call",
4660
+ "print",
4661
+ "printf",
4662
+ "println",
4663
+ "html",
4664
+ "js",
4665
+ "urlquery",
4666
+ "eq",
4667
+ "ne",
4668
+ "lt",
4669
+ "le",
4670
+ "gt",
4671
+ "ge",
4672
+ "break",
4673
+ "continue",
4674
+ "else",
4675
+ "end"
4676
+ ]);
4677
+ var PATH_PATTERN = /^(?:\.|\$|(?:\$\.|\.)?[\p{L}_][\p{L}\p{Nd}_]*(?:\.[\p{L}_][\p{L}\p{Nd}_]*)*)$/u;
4678
+ function supportsExpression(expression) {
4679
+ if (PATH_PATTERN.test(expression) && !UNSUPPORTED_IDENTIFIERS.has(expression)) return true;
4680
+ if (/^-?\d+(?:\.\d+)?$/.test(expression)) return true;
4681
+ if (/^"(?:[^"\\]|\\.)*"$/.test(expression)) {
4682
+ try {
4683
+ JSON.parse(expression);
4684
+ return true;
4685
+ } catch (e) {
4686
+ return false;
4687
+ }
4688
+ }
4689
+ return false;
4690
+ }
4691
+ function supportsTokens(tokens) {
4692
+ var _a;
4693
+ const branches = [];
4694
+ for (const token of tokens) {
4695
+ if (token.text !== void 0) {
4696
+ if (token.text.includes("{{")) return false;
4697
+ continue;
4698
+ }
4699
+ const tag = (_a = token.tag) != null ? _a : "";
4700
+ const branch = /^(if|range|with)\s+([\s\S]+)$/.exec(tag);
4701
+ if (branch) {
4702
+ if (!supportsExpression(branch[2].trim())) return false;
4703
+ branches.push({ kind: branch[1], hasElse: false });
4704
+ } else if (tag === "end") {
4705
+ if (!branches.pop()) return false;
4706
+ } else if (tag === "else" || /^else\s+if\s+/.test(tag)) {
4707
+ const current = branches.at(-1);
4708
+ if (!current || current.hasElse) return false;
4709
+ if (tag === "else") current.hasElse = true;
4710
+ else if (current.kind !== "if" || !supportsExpression(tag.replace(/^else\s+if\s+/, ""))) return false;
4711
+ } else if (!supportsExpression(tag)) return false;
4712
+ }
4713
+ return branches.length === 0;
4714
+ }
4624
4715
  function tokenize(template) {
4625
4716
  var _a;
4626
4717
  const tokens = [];
4627
4718
  let lastIndex = 0;
4719
+ let trimNext = false;
4628
4720
  for (const match of template.matchAll(TOKEN_PATTERN)) {
4629
4721
  const index = (_a = match.index) != null ? _a : 0;
4630
- if (index > lastIndex) tokens.push({ text: template.slice(lastIndex, index) });
4631
- tokens.push({ tag: match[1].trim() });
4722
+ let text = template.slice(lastIndex, index);
4723
+ if (trimNext) text = text.replace(/^[ \t\r\n]+/, "");
4724
+ if (match[1]) text = text.replace(/[ \t\r\n]+$/, "");
4725
+ if (text) tokens.push({ text });
4726
+ tokens.push({ tag: match[2].trim() });
4727
+ trimNext = Boolean(match[3]);
4632
4728
  lastIndex = index + match[0].length;
4633
4729
  }
4634
- if (lastIndex < template.length) tokens.push({ text: template.slice(lastIndex) });
4730
+ const tail = template.slice(lastIndex);
4731
+ if (tail) tokens.push({ text: trimNext ? tail.replace(/^[ \t\r\n]+/, "") : tail });
4635
4732
  return tokens;
4636
4733
  }
4637
4734
  function parseNodes(tokens, startIndex) {
@@ -4646,11 +4743,11 @@ function parseNodes(tokens, startIndex) {
4646
4743
  continue;
4647
4744
  }
4648
4745
  const tag = (_a = token.tag) != null ? _a : "";
4649
- if (tag === "else" || tag.startsWith("else if ")) {
4746
+ if (tag === "else" || /^else\s+if\s+/.test(tag)) {
4650
4747
  return { nodes, nextIndex: index, stopTag: "else" };
4651
4748
  }
4652
4749
  if (tag === "end") return { nodes, nextIndex: index, stopTag: "end" };
4653
- const branchMatch = /^(if|range|with)\s+(.+)$/.exec(tag);
4750
+ const branchMatch = /^(if|range|with)\s+([\s\S]+)$/.exec(tag);
4654
4751
  if (branchMatch) {
4655
4752
  const branch = parseBranch(
4656
4753
  tokens,
@@ -4674,7 +4771,7 @@ function parseBranch(tokens, startIndex, kind, expression) {
4674
4771
  let nextIndex = body.nextIndex;
4675
4772
  if (body.stopTag === "else") {
4676
4773
  const elseTag = (_b = (_a = tokens[body.nextIndex]) == null ? void 0 : _a.tag) != null ? _b : "";
4677
- const elseIfMatch = /^else\s+if\s+(.+)$/.exec(elseTag);
4774
+ const elseIfMatch = /^else\s+if\s+([\s\S]+)$/.exec(elseTag);
4678
4775
  if (elseIfMatch) {
4679
4776
  const nested = parseBranch(
4680
4777
  tokens,
@@ -4698,42 +4795,44 @@ function parseBranch(tokens, startIndex, kind, expression) {
4698
4795
  function readPath(value, path) {
4699
4796
  if (path === "") return value;
4700
4797
  return path.split(".").reduce((current, key) => {
4701
- if (current && typeof current === "object") return current[key];
4798
+ if (current && typeof current === "object" && Object.hasOwn(current, key)) return current[key];
4702
4799
  return void 0;
4703
4800
  }, value);
4704
4801
  }
4705
4802
  function resolveExpression(expression, root, current) {
4706
4803
  const trimmed = expression.trim();
4707
4804
  if (trimmed === ".") return current;
4805
+ if (trimmed === "$") return root;
4708
4806
  if (trimmed === "true") return true;
4709
4807
  if (trimmed === "false") return false;
4710
4808
  if (trimmed === "nil") return null;
4711
- if (/^\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
4712
- if (trimmed.startsWith('"') && trimmed.endsWith('"') || trimmed.startsWith("'") && trimmed.endsWith("'")) {
4713
- return trimmed.slice(1, -1);
4714
- }
4809
+ if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
4810
+ if (trimmed.startsWith('"')) return JSON.parse(trimmed);
4715
4811
  if (trimmed.startsWith("$.")) return readPath(root, trimmed.slice(2));
4716
4812
  if (trimmed.startsWith(".")) return readPath(current, trimmed.slice(1));
4717
4813
  return readPath(current, trimmed);
4718
4814
  }
4719
4815
  function isTruthy(value) {
4720
4816
  if (value == null || value === false || value === "" || value === 0) return false;
4721
- return !Array.isArray(value) || value.length > 0;
4722
- }
4723
- function escapeHtml(value) {
4724
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
4817
+ if (Array.isArray(value)) return value.length > 0;
4818
+ if (value instanceof Map || value instanceof Set) return value.size > 0;
4819
+ if (typeof value === "object") {
4820
+ const prototype = Object.getPrototypeOf(value);
4821
+ if (prototype === Object.prototype || prototype === null) return Object.keys(value).length > 0;
4822
+ }
4823
+ return true;
4725
4824
  }
4726
4825
  function renderNodes(nodes, root, current) {
4727
4826
  return nodes.map((node) => {
4728
4827
  if (node.kind === "text") return node.value;
4729
4828
  if (node.kind === "expression") {
4730
4829
  const value2 = resolveExpression(node.expression, root, current);
4731
- return value2 == null ? "" : escapeHtml(String(value2));
4830
+ return value2 == null ? "" : esc(String(value2)).replaceAll("'", "&#39;");
4732
4831
  }
4733
4832
  const value = resolveExpression(node.expression, root, current);
4734
4833
  if (node.kind === "range") {
4735
- const items = Array.isArray(value) ? value : value && typeof value === "object" ? Object.values(value) : [];
4736
- return items.map((item) => renderNodes(node.children, root, item)).join("");
4834
+ const items = Array.isArray(value) ? value : value && typeof value === "object" ? Object.keys(value).sort().map((key) => value[key]) : [];
4835
+ return items.length > 0 ? items.map((item) => renderNodes(node.children, root, item)).join("") : renderNodes(node.alternate, root, current);
4737
4836
  }
4738
4837
  if (!isTruthy(value)) {
4739
4838
  return renderNodes(node.alternate, root, current);
@@ -4742,9 +4841,40 @@ function renderNodes(nodes, root, current) {
4742
4841
  return renderNodes(node.children, root, childContext);
4743
4842
  }).join("");
4744
4843
  }
4745
- function renderTemplatePreview(html, defaultData) {
4746
- if (defaultData === void 0) return html;
4747
- return renderNodes(parseNodes(tokenize(html), 0).nodes, defaultData, defaultData);
4844
+ var defaultTemplateEngine = Object.freeze({
4845
+ render(template, data) {
4846
+ const tokens = tokenize(template);
4847
+ if (!supportsTokens(tokens)) return template;
4848
+ return renderNodes(parseNodes(tokens, 0).nodes, data, data);
4849
+ }
4850
+ });
4851
+
4852
+ // src/render/template-preview.ts
4853
+ function normalizeEngineError(error) {
4854
+ return error instanceof Error ? error : new Error(String(error) || "Template engine failed.", { cause: error });
4855
+ }
4856
+ function renderTemplatePreview(template, data, options = {}) {
4857
+ var _a;
4858
+ const engine = (_a = options.templateEngine) != null ? _a : defaultTemplateEngine;
4859
+ if (data === void 0 && engine === defaultTemplateEngine) return template;
4860
+ try {
4861
+ const html = engine.render(template, structuredClone(data != null ? data : {}));
4862
+ if (typeof html !== "string") throw new TypeError("TemplateEngine.render must return a string synchronously.");
4863
+ return html;
4864
+ } catch (error) {
4865
+ throw normalizeEngineError(error);
4866
+ }
4867
+ }
4868
+ function validateTemplate(template, options = {}) {
4869
+ var _a, _b;
4870
+ const engine = (_a = options.templateEngine) != null ? _a : defaultTemplateEngine;
4871
+ try {
4872
+ const issues = (_b = engine.validate) == null ? void 0 : _b.call(engine, template);
4873
+ if (issues !== void 0 && !Array.isArray(issues)) throw new TypeError("TemplateEngine.validate must return an array synchronously.");
4874
+ return issues;
4875
+ } catch (error) {
4876
+ throw normalizeEngineError(error);
4877
+ }
4748
4878
  }
4749
4879
 
4750
4880
  // src/render/block-html.ts
@@ -4769,7 +4899,7 @@ function variableControlName(name) {
4769
4899
  const token = /^\{\{-?\s*([\s\S]*?)\s*-?\}\}$/.exec(name.trim());
4770
4900
  const inner = (token ? token[1] : name).trim();
4771
4901
  const control = inner.startsWith(".") ? inner.slice(1) : inner;
4772
- if (!control || !/^[A-Za-z_][\w.]*$/.test(control)) return null;
4902
+ if (!control || !/^[\p{L}_][\p{L}\p{Nd}_]*(?:\.[\p{L}_][\p{L}\p{Nd}_]*)*$/u.test(control)) return null;
4773
4903
  return control;
4774
4904
  }
4775
4905
  function dottedListedToken(name) {
@@ -4835,12 +4965,12 @@ function renderBlockContent(node, definitions = DEFAULT_BLOCKS, mode = "email",
4835
4965
  }
4836
4966
  function resolveBlockContent(rendered, node, options) {
4837
4967
  var _a, _b;
4838
- if (!options.resolveHtmlTemplate) {
4839
- return applyListedVariableDots(rendered, (_a = options.variables) != null ? _a : []);
4840
- }
4968
+ const normalized = applyListedVariableDots(rendered, (_a = options.variables) != null ? _a : []);
4969
+ if (!options.resolveHtmlTemplate) return normalized;
4841
4970
  return renderTemplatePreview(
4842
- rendered,
4843
- (_b = options.templateData) != null ? _b : node.defaultData
4971
+ normalized,
4972
+ (_b = options.templateData) != null ? _b : node.defaultData,
4973
+ options
4844
4974
  );
4845
4975
  }
4846
4976
  function renderBlock(node, definitions = DEFAULT_BLOCKS, mode = "email", body = DEFAULT_EMAIL_BODY) {
@@ -4982,15 +5112,17 @@ function HtmlCanvas({ html, baseCss = "", onSelect }) {
4982
5112
 
4983
5113
  // src/stage/canvas/components/PluginCanvasContent.tsx
4984
5114
  import { jsx as jsx49, jsxs as jsxs38 } from "react/jsx-runtime";
4985
- function PluginCanvasContent({ block, body, definition, variables, selected, toolbarId, toolbarCollapsed, select, onCommitControl, onCommitControls, onCommitStyle }) {
5115
+ function PluginCanvasContent({ block, body, definition, variables, templateEngine, selected, toolbarId, toolbarCollapsed, select, onCommitControl, onCommitControls, onCommitStyle }) {
4986
5116
  var _a, _b;
4987
5117
  const variableSnapshot = useMemo2(() => structuredClone(variables), [variables]);
4988
5118
  const context = useMemo2(() => definition ? buildRenderContext(block, "canvas", body, definition) : null, [block, body, definition]);
4989
5119
  const renderedHtml = useMemo2(() => definition && context ? renderBlockContentFromContext(context, definition, {
4990
5120
  resolveHtmlTemplate: true,
5121
+ templateEngine,
5122
+ variables,
4991
5123
  templateData: mergeTemplateData(variables, block.defaultData),
4992
5124
  body
4993
- }) : "", [block.defaultData, body, context, definition, variables]);
5125
+ }) : "", [block.defaultData, body, context, definition, variables, templateEngine]);
4994
5126
  if (!definition || !context) return /* @__PURE__ */ jsxs38("div", { className: "ree-plugin-error", role: "alert", children: [
4995
5127
  "Missing block plugin: ",
4996
5128
  block.type,
@@ -5107,6 +5239,7 @@ function CanvasBlock({
5107
5239
  selected,
5108
5240
  dropEdge,
5109
5241
  variables,
5242
+ templateEngine,
5110
5243
  onSelect,
5111
5244
  onCommitControl,
5112
5245
  onCommitControls,
@@ -5116,6 +5249,7 @@ function CanvasBlock({
5116
5249
  }) {
5117
5250
  var _a, _b, _c;
5118
5251
  const block = useMemo3(() => __spreadProps(__spreadValues({}, sourceBlock), { style: resolveBlockStyle(sourceBlock.style, definition) }), [sourceBlock, definition]);
5252
+ const renderKey = useMemo3(() => ({ definition, templateEngine, variables }), [definition, templateEngine, variables]);
5119
5253
  const toolbarId = useId13();
5120
5254
  const [toolbarCollapsed, setToolbarCollapsed] = useState16(false);
5121
5255
  const dragData = { kind: "canvas" };
@@ -5177,7 +5311,7 @@ function CanvasBlock({
5177
5311
  {
5178
5312
  className: `ree-block-content${(definition == null ? void 0 : definition.inlineControl) ? " is-inline" : ""}`,
5179
5313
  style: canvasBlockStyle(block, body),
5180
- children: /* @__PURE__ */ jsx51(BlockErrorBoundary, { identity: sourceBlock, resetKey: definition, children: /* @__PURE__ */ jsx51(PluginCanvasContent, { block, body, definition, variables, selected, toolbarId, toolbarCollapsed, select: onSelect, onCommitControl, onCommitControls, onCommitStyle }) })
5314
+ children: /* @__PURE__ */ jsx51(BlockErrorBoundary, { identity: sourceBlock, resetKey: renderKey, children: /* @__PURE__ */ jsx51(PluginCanvasContent, { block, body, definition, variables, templateEngine, selected, toolbarId, toolbarCollapsed, select: onSelect, onCommitControl, onCommitControls, onCommitStyle }) })
5181
5315
  }
5182
5316
  ),
5183
5317
  definition ? /* @__PURE__ */ jsx51("span", { className: "ree-block-badge", children: definition.title }) : null
@@ -5196,6 +5330,7 @@ function Canvas({
5196
5330
  dropTargetId,
5197
5331
  dropEdge,
5198
5332
  variables,
5333
+ templateEngine,
5199
5334
  device,
5200
5335
  onSelect,
5201
5336
  onCommitControl,
@@ -5262,6 +5397,7 @@ function Canvas({
5262
5397
  selected: block.id === selectedId,
5263
5398
  dropEdge: dropTargetId === block.id ? dropEdge : null,
5264
5399
  variables,
5400
+ templateEngine,
5265
5401
  onSelect: () => onSelect(block.id),
5266
5402
  onCommitControl,
5267
5403
  onCommitControls,
@@ -5285,67 +5421,16 @@ function Canvas({
5285
5421
 
5286
5422
  // src/stage/preview/Preview.tsx
5287
5423
  import { useCallback as useCallback2, useEffect as useEffect7, useMemo as useMemo4, useRef as useRef14 } from "react";
5288
-
5289
- // src/render/document-html.ts
5290
- function documentToHtml(document, definitions = DEFAULT_BLOCKS, options = {}) {
5291
- const source = ensureEmailDocument(document);
5292
- const body = source.body;
5293
- const baseCss = emailBaseCss(body);
5294
- const rendered = source.blocks.map((node, index) => {
5295
- var _a;
5296
- const blockData = __spreadValues(__spreadValues({}, options.templateData), node.defaultData);
5297
- const block = blockToHtml(node, definitions, "email", {
5298
- resolveHtmlTemplate: options.resolveHtmlTemplates,
5299
- body,
5300
- variables: options.variables,
5301
- templateData: mergeTemplateData(
5302
- (_a = options.variables) != null ? _a : [],
5303
- Object.keys(blockData).length > 0 ? blockData : void 0
5304
- )
5305
- });
5306
- const gap = index < source.blocks.length - 1 ? spacerHtml(body.blockGap) : "";
5307
- return block + gap;
5308
- }).join("\n");
5309
- const { html: content, styles } = extractHeadStyles(rendered);
5310
- return [
5311
- "<!DOCTYPE html>",
5312
- '<html lang="en">',
5313
- "<head>",
5314
- '<meta charset="utf-8" />',
5315
- '<meta name="viewport" content="width=device-width, initial-scale=1" />',
5316
- "<title>Email</title>",
5317
- styles,
5318
- "</head>",
5319
- `<body style="margin:0;padding:0;${baseCss};background-color:#ffffff;word-spacing:normal;">`,
5320
- `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="${baseCss};background-color:#ffffff;table-layout:fixed;">`,
5321
- "<tr>",
5322
- `<td align="center" style="padding:0;${baseCss};">`,
5323
- `<table role="presentation" align="center" width="${body.contentWidth}" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:${body.contentWidth}px;margin-left:auto;margin-right:auto;table-layout:fixed;">`,
5324
- "<tr>",
5325
- `<td style="box-sizing:border-box;${styleToCss(surfaceStyle(body))};">`,
5326
- content,
5327
- "</td>",
5328
- "</tr>",
5329
- "</table>",
5330
- "</td>",
5331
- "</tr>",
5332
- "</table>",
5333
- "</body>",
5334
- "</html>"
5335
- ].join("\n");
5336
- }
5337
-
5338
- // src/stage/preview/Preview.tsx
5339
5424
  import { jsx as jsx53 } from "react/jsx-runtime";
5340
- function Preview({ document, definitions, variables, device }) {
5425
+ function Preview({ document, config, variables, device }) {
5341
5426
  const frameRef = useRef14(null);
5342
5427
  const cleanupRef = useRef14();
5343
5428
  const html = useMemo4(
5344
- () => documentToHtml(document, definitions, {
5429
+ () => config.toHtml(document, {
5345
5430
  resolveHtmlTemplates: true,
5346
5431
  variables
5347
5432
  }),
5348
- [document, definitions, variables]
5433
+ [document, config, variables]
5349
5434
  );
5350
5435
  const observeContent = useCallback2(() => {
5351
5436
  var _a;
@@ -5491,6 +5576,7 @@ function Stage({
5491
5576
  onDeviceChange,
5492
5577
  onSelectBlock
5493
5578
  }) {
5579
+ const previewKey = useMemo5(() => ({ config: session.config, variables: session.variables }), [session.config, session.variables]);
5494
5580
  const { targetId, dropEdge } = useEditorDragState();
5495
5581
  return /* @__PURE__ */ jsxs42("div", { className: "ree-col ree-col-center", children: [
5496
5582
  /* @__PURE__ */ jsx55(
@@ -5512,6 +5598,7 @@ function Stage({
5512
5598
  dropTargetId: targetId,
5513
5599
  dropEdge,
5514
5600
  variables: session.variables,
5601
+ templateEngine: session.config.templateEngine,
5515
5602
  device,
5516
5603
  onSelect: onSelectBlock,
5517
5604
  onCommitControl: session.updateControl,
@@ -5520,11 +5607,11 @@ function Stage({
5520
5607
  onDuplicate: session.duplicateBlock,
5521
5608
  onRemove: session.removeBlock
5522
5609
  }
5523
- ) }) : /* @__PURE__ */ jsx55("div", { className: "ree-stage-view", children: /* @__PURE__ */ jsx55(BlockErrorBoundary, { identity: session.document, resetKey: session.definitions, children: /* @__PURE__ */ jsx55(
5610
+ ) }) : /* @__PURE__ */ jsx55("div", { className: "ree-stage-view", children: /* @__PURE__ */ jsx55(BlockErrorBoundary, { identity: session.document, resetKey: previewKey, children: /* @__PURE__ */ jsx55(
5524
5611
  Preview,
5525
5612
  {
5526
5613
  document: session.document,
5527
- definitions: session.definitions,
5614
+ config: session.config,
5528
5615
  variables: session.variables,
5529
5616
  device
5530
5617
  }
@@ -5694,9 +5781,59 @@ function createBlockNode(type, definitions) {
5694
5781
  };
5695
5782
  }
5696
5783
 
5784
+ // src/render/document-html.ts
5785
+ function documentToHtml(document, definitions = DEFAULT_BLOCKS, options = {}) {
5786
+ const source = ensureEmailDocument(document);
5787
+ const body = source.body;
5788
+ const baseCss = emailBaseCss(body);
5789
+ const rendered = source.blocks.map((node, index) => {
5790
+ var _a;
5791
+ const blockData = __spreadValues(__spreadValues({}, options.templateData), node.defaultData);
5792
+ const block = blockToHtml(node, definitions, "email", {
5793
+ resolveHtmlTemplate: options.resolveHtmlTemplates,
5794
+ templateEngine: options.templateEngine,
5795
+ body,
5796
+ variables: options.variables,
5797
+ templateData: mergeTemplateData(
5798
+ (_a = options.variables) != null ? _a : [],
5799
+ Object.keys(blockData).length > 0 ? blockData : void 0
5800
+ )
5801
+ });
5802
+ const gap = index < source.blocks.length - 1 ? spacerHtml(body.blockGap) : "";
5803
+ return block + gap;
5804
+ }).join("\n");
5805
+ const { html: content, styles } = extractHeadStyles(rendered);
5806
+ return [
5807
+ "<!DOCTYPE html>",
5808
+ '<html lang="en">',
5809
+ "<head>",
5810
+ '<meta charset="utf-8" />',
5811
+ '<meta name="viewport" content="width=device-width, initial-scale=1" />',
5812
+ "<title>Email</title>",
5813
+ styles,
5814
+ "</head>",
5815
+ `<body style="margin:0;padding:0;${baseCss};background-color:#ffffff;word-spacing:normal;">`,
5816
+ `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="${baseCss};background-color:#ffffff;table-layout:fixed;">`,
5817
+ "<tr>",
5818
+ `<td align="center" style="padding:0;${baseCss};">`,
5819
+ `<table role="presentation" align="center" width="${body.contentWidth}" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:${body.contentWidth}px;margin-left:auto;margin-right:auto;table-layout:fixed;">`,
5820
+ "<tr>",
5821
+ `<td style="box-sizing:border-box;${styleToCss(surfaceStyle(body))};">`,
5822
+ content,
5823
+ "</td>",
5824
+ "</tr>",
5825
+ "</table>",
5826
+ "</td>",
5827
+ "</tr>",
5828
+ "</table>",
5829
+ "</body>",
5830
+ "</html>"
5831
+ ].join("\n");
5832
+ }
5833
+
5697
5834
  // src/plugins/config.ts
5698
5835
  function buildEmailEditorConfig(options = {}) {
5699
- var _a, _b, _c;
5836
+ var _a, _b, _c, _d;
5700
5837
  const definitions = new Map(DEFAULT_BLOCKS.map((block) => [block.type, block]));
5701
5838
  const customTypes = /* @__PURE__ */ new Set();
5702
5839
  for (const plugin of (_a = options.plugins) != null ? _a : []) {
@@ -5711,16 +5848,21 @@ function buildEmailEditorConfig(options = {}) {
5711
5848
  for (const type of visibleBuiltins) if (!builtinTypes.has(type)) throw new Error(`Unknown builtin block: ${type}`);
5712
5849
  const all = Object.freeze([...definitions.values()].map((plugin) => snapshotValue(plugin)));
5713
5850
  const registered = new Map(all.map((plugin) => [plugin.type, plugin]));
5851
+ const templateEngine = (_c = options.templateEngine) != null ? _c : defaultTemplateEngine;
5714
5852
  return Object.freeze({
5853
+ templateEngine,
5715
5854
  definitions: all,
5716
- library: Object.freeze([...visibleBuiltins.map((type) => registered.get(type)), ...((_c = options.plugins) != null ? _c : []).filter((plugin) => !builtinTypes.has(plugin.type)).map((plugin) => registered.get(plugin.type))]),
5855
+ library: Object.freeze([...visibleBuiltins.map((type) => registered.get(type)), ...((_d = options.plugins) != null ? _d : []).filter((plugin) => !builtinTypes.has(plugin.type)).map((plugin) => registered.get(plugin.type))]),
5717
5856
  pickImage: options.pickImage,
5718
5857
  createBlock: (type) => {
5719
5858
  const block = createBlockNode(type, all);
5720
5859
  if (!block) throw new Error(`Unknown block plugin: ${type}`);
5721
5860
  return block;
5722
5861
  },
5723
- toHtml: (document, htmlOptions) => documentToHtml(document, all, htmlOptions),
5862
+ toHtml: (document, htmlOptions) => {
5863
+ var _a2;
5864
+ return documentToHtml(document, all, __spreadProps(__spreadValues({}, htmlOptions), { templateEngine: (_a2 = htmlOptions == null ? void 0 : htmlOptions.templateEngine) != null ? _a2 : templateEngine }));
5865
+ },
5724
5866
  validate: (document) => validateDocument(document, all)
5725
5867
  });
5726
5868
  }
@@ -6067,10 +6209,10 @@ function EmailEditor(props) {
6067
6209
  }
6068
6210
 
6069
6211
  // src/plugins/useEmailEditorConfig.ts
6070
- import { useMemo as useMemo5 } from "react";
6212
+ import { useMemo as useMemo6 } from "react";
6071
6213
  function useEmailEditorConfig(options = {}) {
6072
- const { plugins, builtins, pickImage } = options;
6073
- return useMemo5(() => buildEmailEditorConfig({ plugins, builtins, pickImage }), [plugins, builtins, pickImage]);
6214
+ const { plugins, builtins, pickImage, templateEngine } = options;
6215
+ return useMemo6(() => buildEmailEditorConfig({ plugins, builtins, pickImage, templateEngine }), [plugins, builtins, pickImage, templateEngine]);
6074
6216
  }
6075
6217
 
6076
6218
  // src/shell/useEmailEditor.ts
@@ -6130,6 +6272,7 @@ export {
6130
6272
  createDimensionControls,
6131
6273
  createEmailDocument,
6132
6274
  createTypographyControls,
6275
+ defaultTemplateEngine,
6133
6276
  defineBlock,
6134
6277
  documentToHtml,
6135
6278
  generateId,
@@ -6142,5 +6285,6 @@ export {
6142
6285
  useEmailEditor,
6143
6286
  useEmailEditorConfig,
6144
6287
  useEmailEditorState,
6288
+ validateTemplate,
6145
6289
  variablesToTemplateData
6146
6290
  };
@@ -262,4 +262,8 @@ export const fragment = blockToHtml(block, undefined, "email", {
262
262
  });
263
263
  ```
264
264
 
265
- Complete documents shallow-merge variables → templateData → block.defaultData, with later data winning. Single-block rendering does not merge variables automatically; this example merges explicitly and uses the singular resolveHtmlTemplate option. Your application chooses the actual sending engine; preview only supports a subset of Go templates.
265
+ Complete documents shallow-merge variables → templateData → block.defaultData, with later data winning. Preview and sending apply listed-variable dot normalization while preserving the stored template source. Single-block rendering does not merge variables automatically; this example merges explicitly and uses the singular resolveHtmlTemplate option. Your application chooses the actual sending engine. The default preview supports basic syntax; inject templateEngine for complete Go semantics.
266
+
267
+ ## Custom template engine and standalone preview
268
+
269
+ See [Template preview and custom engines](https://github.com/standhigher/react-email-editor/blob/main/docs/template-engines.md) for supported syntax, adapter behavior and integration examples.
@@ -262,4 +262,8 @@ export const fragment = blockToHtml(block, undefined, "email", {
262
262
  });
263
263
  ```
264
264
 
265
- 完整文档自动按顶层浅合并 `variables → templateData → block.defaultData`,后者优先。单区块不会自动合并变量;示例显式合并,并使用单数 `resolveHtmlTemplate`。真实发送端的模板引擎由应用决定,预览仅支持部分 Go template 语法。
265
+ 完整文档自动按顶层浅合并 `variables → templateData → block.defaultData`,后者优先。预览和发送会先按变量清单补点,存储中的模板原文保持不变;单区块不会自动合并变量,示例显式合并,并使用单数 `resolveHtmlTemplate`。真实发送端的模板引擎由应用决定;默认预览仅支持基础语法,完整 Go 语义通过 `templateEngine` 注入。
266
+
267
+ ## 自定义模板引擎与独立预览
268
+
269
+ 完整说明与接入示例见 [模板预览与自定义引擎](https://github.com/standhigher/react-email-editor/blob/main/docs/template-engines.zh-CN.md)。