@momentumcms/server-analog 0.5.0 → 0.5.2

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/index.cjs CHANGED
@@ -317,7 +317,7 @@ function detectMimeType(buffer) {
317
317
  if (match) {
318
318
  if (sig.bytes[0] === 82 && sig.bytes[1] === 73) {
319
319
  if (buffer.length >= 12) {
320
- const formatId = buffer.slice(8, 12).toString("ascii");
320
+ const formatId = String.fromCharCode(...buffer.subarray(8, 12));
321
321
  if (formatId === "WEBP") {
322
322
  return "image/webp";
323
323
  }
@@ -330,7 +330,7 @@ function detectMimeType(buffer) {
330
330
  }
331
331
  }
332
332
  if (sig.mimeType === "video/mp4" && buffer.length >= 8) {
333
- const boxType = buffer.slice(4, 8).toString("ascii");
333
+ const boxType = String.fromCharCode(...buffer.subarray(4, 8));
334
334
  if (boxType === "ftyp") {
335
335
  return "video/mp4";
336
336
  }
@@ -339,7 +339,7 @@ function detectMimeType(buffer) {
339
339
  }
340
340
  }
341
341
  if (isTextContent(buffer)) {
342
- const text2 = buffer.toString("utf8", 0, Math.min(buffer.length, 1e3));
342
+ const text2 = new TextDecoder().decode(buffer.subarray(0, Math.min(buffer.length, 1e3)));
343
343
  if (text2.trim().startsWith("{") || text2.trim().startsWith("[")) {
344
344
  return "application/json";
345
345
  }
@@ -531,6 +531,383 @@ var init_src = __esm({
531
531
  }
532
532
  });
533
533
 
534
+ // libs/email/src/lib/utils/escape-html.ts
535
+ function escapeHtml2(unsafe) {
536
+ return unsafe.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
537
+ }
538
+ var init_escape_html = __esm({
539
+ "libs/email/src/lib/utils/escape-html.ts"() {
540
+ "use strict";
541
+ }
542
+ });
543
+
544
+ // libs/email/src/lib/utils/css-inliner.ts
545
+ function inlineCss(html) {
546
+ return (0, import_juice.default)(html, {
547
+ removeStyleTags: true,
548
+ preserveMediaQueries: true,
549
+ preserveFontFaces: true,
550
+ insertPreservedExtraCss: true
551
+ });
552
+ }
553
+ var import_juice;
554
+ var init_css_inliner = __esm({
555
+ "libs/email/src/lib/utils/css-inliner.ts"() {
556
+ "use strict";
557
+ import_juice = __toESM(require("juice"));
558
+ }
559
+ });
560
+
561
+ // libs/email/src/lib/utils/replace-variables.ts
562
+ function replaceVariables(text2, variables) {
563
+ return text2.replace(/\{\{(\w+)\}\}/g, (_, key) => variables[key] ?? "");
564
+ }
565
+ function replaceBlockVariables(blocks2, variables) {
566
+ return blocks2.map((block) => ({
567
+ ...block,
568
+ data: replaceDataVariables(block.data, variables)
569
+ }));
570
+ }
571
+ function replaceDataVariables(data, variables) {
572
+ const result = {};
573
+ for (const [key, value] of Object.entries(data)) {
574
+ if (typeof value === "string") {
575
+ result[key] = replaceVariables(value, variables);
576
+ } else if (Array.isArray(value)) {
577
+ result[key] = value.map((item) => {
578
+ if (typeof item === "object" && item !== null && "blocks" in item) {
579
+ const col = item;
580
+ const nestedBlocks = Array.isArray(col["blocks"]) ? col["blocks"] : [];
581
+ return { ...col, blocks: replaceBlockVariables(nestedBlocks, variables) };
582
+ }
583
+ return item;
584
+ });
585
+ } else {
586
+ result[key] = value;
587
+ }
588
+ }
589
+ return result;
590
+ }
591
+ var init_replace_variables = __esm({
592
+ "libs/email/src/lib/utils/replace-variables.ts"() {
593
+ "use strict";
594
+ }
595
+ });
596
+
597
+ // libs/email/src/lib/utils/sanitize.ts
598
+ function sanitizeAlignment(value) {
599
+ return VALID_ALIGNMENTS.has(value) ? value : "left";
600
+ }
601
+ function sanitizeCssValue(value) {
602
+ return value.replace(/[;{}()"'<>\\]/g, "");
603
+ }
604
+ function sanitizeFontFamily(value) {
605
+ return value.replace(/[;{}()"<>\\]/g, "");
606
+ }
607
+ function sanitizeCssNumber(value, fallback) {
608
+ if (value === null || value === void 0)
609
+ return String(fallback);
610
+ const num = Number(value);
611
+ return Number.isFinite(num) && num >= 0 ? String(num) : String(fallback);
612
+ }
613
+ function sanitizeUrl(url) {
614
+ const trimmed = url.trim();
615
+ if (!trimmed || trimmed === "#")
616
+ return trimmed || "#";
617
+ try {
618
+ const parsed = new URL(trimmed);
619
+ return SAFE_URL_PROTOCOLS.has(parsed.protocol) ? trimmed : "#";
620
+ } catch {
621
+ if (trimmed.startsWith("/") || trimmed.startsWith("#"))
622
+ return trimmed;
623
+ return "#";
624
+ }
625
+ }
626
+ var VALID_ALIGNMENTS, SAFE_URL_PROTOCOLS;
627
+ var init_sanitize = __esm({
628
+ "libs/email/src/lib/utils/sanitize.ts"() {
629
+ "use strict";
630
+ VALID_ALIGNMENTS = /* @__PURE__ */ new Set(["left", "center", "right"]);
631
+ SAFE_URL_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:", "mailto:"]);
632
+ }
633
+ });
634
+
635
+ // libs/email/src/types.ts
636
+ var DEFAULT_EMAIL_THEME;
637
+ var init_types = __esm({
638
+ "libs/email/src/types.ts"() {
639
+ "use strict";
640
+ DEFAULT_EMAIL_THEME = {
641
+ primaryColor: "#18181b",
642
+ backgroundColor: "#f4f4f5",
643
+ textColor: "#3f3f46",
644
+ mutedColor: "#71717a",
645
+ fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
646
+ borderRadius: "8px"
647
+ };
648
+ }
649
+ });
650
+
651
+ // libs/email/src/lib/render/render-blocks.ts
652
+ function renderEmailFromBlocks(template, options) {
653
+ const theme = { ...DEFAULT_EMAIL_THEME, ...template.theme };
654
+ const shouldInline = options?.inlineCss ?? true;
655
+ const blocks2 = options?.variables ? replaceBlockVariables(template.blocks, options.variables) : template.blocks;
656
+ const validBlocks = blocks2.filter((block) => {
657
+ if (!isValidBlock(block)) {
658
+ console.warn("[momentum:email] Skipping invalid email block:", block);
659
+ return false;
660
+ }
661
+ return true;
662
+ });
663
+ const blocksHtml = validBlocks.map((block) => renderBlock(block, theme, 0)).join("\n");
664
+ let html = wrapEmailDocument(blocksHtml, theme);
665
+ if (shouldInline) {
666
+ html = inlineCss(html);
667
+ }
668
+ return html;
669
+ }
670
+ function wrapEmailDocument(content, theme) {
671
+ const fontFamily = sanitizeFontFamily(theme.fontFamily);
672
+ const bgColor = sanitizeCssValue(theme.backgroundColor);
673
+ const borderRadius = sanitizeCssValue(theme.borderRadius);
674
+ return `<!DOCTYPE html>
675
+ <html lang="en">
676
+ <head>
677
+ <meta charset="UTF-8">
678
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
679
+ </head>
680
+ <body style="margin: 0; padding: 0; font-family: ${fontFamily}; background-color: ${bgColor}; line-height: 1.6;">
681
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background-color: ${bgColor};">
682
+ <tr>
683
+ <td style="padding: 40px 20px;">
684
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width: 480px; margin: 0 auto; background-color: #ffffff; border-radius: ${borderRadius}; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
685
+ <tr>
686
+ <td style="padding: 40px;">
687
+ ${content}
688
+ </td>
689
+ </tr>
690
+ </table>
691
+ </td>
692
+ </tr>
693
+ </table>
694
+ </body>
695
+ </html>`;
696
+ }
697
+ function isValidBlock(block) {
698
+ const rec = block;
699
+ return typeof block === "object" && block !== null && typeof rec["id"] === "string" && rec["id"].length > 0 && typeof rec["type"] === "string" && typeof rec["data"] === "object" && rec["data"] !== null;
700
+ }
701
+ function renderBlock(block, theme, depth) {
702
+ switch (block.type) {
703
+ case "header":
704
+ return renderHeaderBlock(block.data, theme);
705
+ case "text":
706
+ return renderTextBlock(block.data, theme);
707
+ case "button":
708
+ return renderButtonBlock(block.data, theme);
709
+ case "image":
710
+ return renderImageBlock(block.data);
711
+ case "divider":
712
+ return renderDividerBlock(block.data);
713
+ case "spacer":
714
+ return renderSpacerBlock(block.data);
715
+ case "columns":
716
+ if (depth >= MAX_BLOCK_DEPTH) {
717
+ console.warn("[momentum:email] Max nesting depth reached, skipping columns block");
718
+ return "";
719
+ }
720
+ return renderColumnsBlock(block.data, theme, depth);
721
+ case "footer":
722
+ return renderFooterBlock(block.data, theme);
723
+ default:
724
+ return `<!-- unknown block type: ${escapeHtml2(block.type)} -->`;
725
+ }
726
+ }
727
+ function renderHeaderBlock(data, theme) {
728
+ const title = escapeHtml2(String(data["title"] ?? ""));
729
+ const subtitle = data["subtitle"] ? escapeHtml2(String(data["subtitle"])) : "";
730
+ const alignment = sanitizeAlignment(String(data["alignment"] ?? "left"));
731
+ return `<h1 style="margin: 0 0 8px; font-size: 24px; font-weight: 600; color: ${sanitizeCssValue(theme.textColor)}; text-align: ${alignment};">${title}</h1>${subtitle ? `<p style="margin: 0 0 16px; font-size: 16px; color: ${sanitizeCssValue(theme.mutedColor)}; text-align: ${alignment};">${subtitle}</p>` : ""}`;
732
+ }
733
+ function renderTextBlock(data, theme) {
734
+ const content = escapeHtml2(String(data["content"] ?? ""));
735
+ const fontSize = sanitizeCssNumber(data["fontSize"], 16);
736
+ const color = sanitizeCssValue(String(data["color"] ?? theme.textColor));
737
+ const alignment = sanitizeAlignment(String(data["alignment"] ?? "left"));
738
+ return `<p style="margin: 0 0 16px; font-size: ${fontSize}px; color: ${color}; text-align: ${alignment}; line-height: 1.6;">${content}</p>`;
739
+ }
740
+ function renderButtonBlock(data, theme) {
741
+ const label = escapeHtml2(String(data["label"] ?? "Click here"));
742
+ const href = escapeHtml2(sanitizeUrl(String(data["href"] ?? "#")));
743
+ const bgColor = sanitizeCssValue(String(data["backgroundColor"] ?? theme.primaryColor));
744
+ const color = sanitizeCssValue(String(data["color"] ?? "#ffffff"));
745
+ const alignment = sanitizeAlignment(String(data["alignment"] ?? "left"));
746
+ return `<table role="presentation" width="100%" cellspacing="0" cellpadding="0">
747
+ <tr>
748
+ <td style="padding: 0 0 16px;" align="${alignment}">
749
+ <a href="${href}" style="display: inline-block; padding: 12px 24px; background-color: ${bgColor}; color: ${color}; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 16px;">${label}</a>
750
+ </td>
751
+ </tr>
752
+ </table>`;
753
+ }
754
+ function renderImageBlock(data) {
755
+ const rawSrc = String(data["src"] ?? "").trim();
756
+ if (!rawSrc)
757
+ return "<!-- image block: no src configured -->";
758
+ const src = escapeHtml2(sanitizeUrl(rawSrc));
759
+ const alt = escapeHtml2(String(data["alt"] ?? ""));
760
+ const width = sanitizeCssValue(String(data["width"] ?? "100%"));
761
+ const img = `<img src="${src}" alt="${alt}" width="${width}" style="display: block; max-width: 100%; height: auto; border: 0;">`;
762
+ if (data["href"]) {
763
+ const href = escapeHtml2(sanitizeUrl(String(data["href"])));
764
+ return `<a href="${href}" style="display: block;">${img}</a>`;
765
+ }
766
+ return img;
767
+ }
768
+ function renderDividerBlock(data) {
769
+ const color = sanitizeCssValue(String(data["color"] ?? "#e4e4e7"));
770
+ const margin = sanitizeCssValue(String(data["margin"] ?? "24px 0"));
771
+ return `<hr style="border: none; border-top: 1px solid ${color}; margin: ${margin};">`;
772
+ }
773
+ function renderSpacerBlock(data) {
774
+ const height = sanitizeCssNumber(data["height"], 24);
775
+ return `<div style="height: ${height}px; line-height: ${height}px; font-size: 1px;">&nbsp;</div>`;
776
+ }
777
+ function renderColumnsBlock(data, theme, depth) {
778
+ const rawColumns = data["columns"];
779
+ const columns = Array.isArray(rawColumns) ? rawColumns : [];
780
+ const width = Math.floor(100 / (columns.length || 1));
781
+ const tds = columns.map((col) => {
782
+ const colObj = (
783
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- narrowing unknown column objects
784
+ typeof col === "object" && col !== null ? col : {}
785
+ );
786
+ const rawBlocks = colObj["blocks"];
787
+ const colContent = (Array.isArray(rawBlocks) ? rawBlocks : []).filter(isValidBlock).map((b) => renderBlock(b, theme, depth + 1)).join("\n");
788
+ return `<td style="width: ${width}%; vertical-align: top; padding: 0 8px;">${colContent}</td>`;
789
+ }).join("\n");
790
+ return `<table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr>${tds}</tr></table>`;
791
+ }
792
+ function renderFooterBlock(data, theme) {
793
+ const text2 = escapeHtml2(String(data["text"] ?? ""));
794
+ const color = sanitizeCssValue(String(data["color"] ?? theme.mutedColor));
795
+ return `<p style="margin: 16px 0 0; font-size: 12px; color: ${color}; text-align: center;">${text2}</p>`;
796
+ }
797
+ var MAX_BLOCK_DEPTH;
798
+ var init_render_blocks = __esm({
799
+ "libs/email/src/lib/render/render-blocks.ts"() {
800
+ "use strict";
801
+ init_escape_html();
802
+ init_css_inliner();
803
+ init_replace_variables();
804
+ init_sanitize();
805
+ init_types();
806
+ MAX_BLOCK_DEPTH = 5;
807
+ }
808
+ });
809
+
810
+ // libs/email/src/lib/utils/blocks-to-plain-text.ts
811
+ function blocksToPlainText(blocks2, depth = 0) {
812
+ const lines = [];
813
+ for (const block of blocks2) {
814
+ const text2 = blockToText(block, depth);
815
+ if (text2) {
816
+ lines.push(text2);
817
+ }
818
+ }
819
+ return lines.join("\n\n");
820
+ }
821
+ function blockToText(block, depth) {
822
+ switch (block.type) {
823
+ case "header":
824
+ return headerToText(block.data);
825
+ case "text":
826
+ return String(block.data["content"] ?? "");
827
+ case "button":
828
+ return buttonToText(block.data);
829
+ case "footer":
830
+ return String(block.data["text"] ?? "");
831
+ case "columns":
832
+ if (depth >= MAX_BLOCK_DEPTH2) {
833
+ console.warn("[momentum:email] Max nesting depth reached, skipping columns block");
834
+ return "";
835
+ }
836
+ return columnsToText(block.data, depth);
837
+ case "divider":
838
+ case "spacer":
839
+ case "image":
840
+ return "";
841
+ default:
842
+ return "";
843
+ }
844
+ }
845
+ function headerToText(data) {
846
+ const title = String(data["title"] ?? "");
847
+ const subtitle = data["subtitle"] ? String(data["subtitle"]) : "";
848
+ if (!title && !subtitle)
849
+ return "";
850
+ if (!subtitle)
851
+ return title;
852
+ return `${title}
853
+ ${subtitle}`;
854
+ }
855
+ function buttonToText(data) {
856
+ const label = String(data["label"] ?? "");
857
+ const href = data["href"] ? String(data["href"]) : "";
858
+ if (!label)
859
+ return "";
860
+ if (!href)
861
+ return label;
862
+ return `${label}: ${href}`;
863
+ }
864
+ function columnsToText(data, depth) {
865
+ const columns = data["columns"];
866
+ if (!Array.isArray(columns))
867
+ return "";
868
+ const parts = [];
869
+ for (const col of columns) {
870
+ if (col && typeof col === "object" && Array.isArray(col.blocks)) {
871
+ const colText = blocksToPlainText(col.blocks, depth + 1);
872
+ if (colText) {
873
+ parts.push(colText);
874
+ }
875
+ }
876
+ }
877
+ return parts.join("\n\n");
878
+ }
879
+ var MAX_BLOCK_DEPTH2;
880
+ var init_blocks_to_plain_text = __esm({
881
+ "libs/email/src/lib/utils/blocks-to-plain-text.ts"() {
882
+ "use strict";
883
+ MAX_BLOCK_DEPTH2 = 5;
884
+ }
885
+ });
886
+
887
+ // libs/email/src/server.ts
888
+ var server_exports = {};
889
+ __export(server_exports, {
890
+ DEFAULT_EMAIL_THEME: () => DEFAULT_EMAIL_THEME,
891
+ blocksToPlainText: () => blocksToPlainText,
892
+ escapeHtml: () => escapeHtml2,
893
+ inlineCss: () => inlineCss,
894
+ isValidBlock: () => isValidBlock,
895
+ renderEmailFromBlocks: () => renderEmailFromBlocks,
896
+ replaceBlockVariables: () => replaceBlockVariables,
897
+ replaceVariables: () => replaceVariables
898
+ });
899
+ var init_server = __esm({
900
+ "libs/email/src/server.ts"() {
901
+ "use strict";
902
+ init_render_blocks();
903
+ init_replace_variables();
904
+ init_blocks_to_plain_text();
905
+ init_css_inliner();
906
+ init_escape_html();
907
+ init_types();
908
+ }
909
+ });
910
+
534
911
  // libs/server-analog/src/index.ts
535
912
  var src_exports2 = {};
536
913
  __export(src_exports2, {
@@ -973,6 +1350,31 @@ function validateRowCount(name, label, count, minRows, maxRows, errors) {
973
1350
  }
974
1351
 
975
1352
  // libs/core/src/lib/collections/media.collection.ts
1353
+ var validateFocalPoint = (value) => {
1354
+ if (value === null || value === void 0)
1355
+ return true;
1356
+ if (typeof value !== "object" || Array.isArray(value)) {
1357
+ return "Focal point must be an object with x and y coordinates";
1358
+ }
1359
+ const fp = Object.fromEntries(Object.entries(value));
1360
+ if (!("x" in fp) || !("y" in fp)) {
1361
+ return "Focal point must have both x and y properties";
1362
+ }
1363
+ const { x, y } = fp;
1364
+ if (typeof x !== "number" || !Number.isFinite(x)) {
1365
+ return "Focal point x must be a finite number";
1366
+ }
1367
+ if (typeof y !== "number" || !Number.isFinite(y)) {
1368
+ return "Focal point y must be a finite number";
1369
+ }
1370
+ if (x < 0 || x > 1) {
1371
+ return `Focal point x must be between 0 and 1 (received ${x})`;
1372
+ }
1373
+ if (y < 0 || y > 1) {
1374
+ return `Focal point y must be between 0 and 1 (received ${y})`;
1375
+ }
1376
+ return true;
1377
+ };
976
1378
  var MediaCollection = defineCollection({
977
1379
  slug: "media",
978
1380
  labels: {
@@ -1027,6 +1429,14 @@ var MediaCollection = defineCollection({
1027
1429
  json("focalPoint", {
1028
1430
  label: "Focal Point",
1029
1431
  description: "Focal point coordinates for image cropping",
1432
+ validate: validateFocalPoint,
1433
+ admin: {
1434
+ hidden: true
1435
+ }
1436
+ }),
1437
+ json("sizes", {
1438
+ label: "Image Sizes",
1439
+ description: "Generated image size variants",
1030
1440
  admin: {
1031
1441
  hidden: true
1032
1442
  }
@@ -1796,6 +2206,15 @@ function deepEqual(a, b) {
1796
2206
  (key) => Object.prototype.hasOwnProperty.call(bRec, key) && deepEqual(aRec[key], bRec[key])
1797
2207
  );
1798
2208
  }
2209
+ function stripTransientKeys(data) {
2210
+ const result = {};
2211
+ for (const [key, value] of Object.entries(data)) {
2212
+ if (!key.startsWith("_")) {
2213
+ result[key] = value;
2214
+ }
2215
+ }
2216
+ return result;
2217
+ }
1799
2218
  function flattenWhereClause(where) {
1800
2219
  if (!where)
1801
2220
  return {};
@@ -1959,6 +2378,7 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
1959
2378
  );
1960
2379
  }
1961
2380
  processedData = await this.runHooks("beforeChange", processedData, "create");
2381
+ processedData = stripTransientKeys(processedData);
1962
2382
  const doc = await this.adapter.create(this.slug, processedData);
1963
2383
  await this.runHooks("afterChange", doc, "create");
1964
2384
  if (hasFieldHooks(this.collectionConfig.fields)) {
@@ -2019,6 +2439,7 @@ var CollectionOperationsImpl = class _CollectionOperationsImpl {
2019
2439
  );
2020
2440
  }
2021
2441
  processedData = await this.runHooks("beforeChange", processedData, "update", originalDoc);
2442
+ processedData = stripTransientKeys(processedData);
2022
2443
  const doc = await this.adapter.update(this.slug, id, processedData);
2023
2444
  await this.runHooks("afterChange", doc, "update", originalDoc);
2024
2445
  if (hasFieldHooks(this.collectionConfig.fields)) {
@@ -3566,7 +3987,8 @@ async function handleUpload(config, request) {
3566
3987
  filesize: file.size,
3567
3988
  path: storedFile.path,
3568
3989
  url: storedFile.url,
3569
- alt: alt ?? ""
3990
+ alt: alt ?? "",
3991
+ _file: file
3570
3992
  };
3571
3993
  const api = getMomentumAPI().setContext({ user });
3572
3994
  const doc = await api.collection(collection).create(mediaData);
@@ -3633,7 +4055,8 @@ async function handleCollectionUpload(globalConfig, request) {
3633
4055
  mimeType: file.mimeType,
3634
4056
  filesize: file.size,
3635
4057
  path: storedFile.path,
3636
- url: storedFile.url
4058
+ url: storedFile.url,
4059
+ _file: file
3637
4060
  };
3638
4061
  const api = getMomentumAPI().setContext({ user });
3639
4062
  const doc = await api.collection(collectionSlug).create(docData);
@@ -3670,6 +4093,7 @@ function getMimeTypeFromPath(path) {
3670
4093
  png: "image/png",
3671
4094
  gif: "image/gif",
3672
4095
  webp: "image/webp",
4096
+ avif: "image/avif",
3673
4097
  svg: "image/svg+xml",
3674
4098
  pdf: "application/pdf",
3675
4099
  json: "application/json",
@@ -4335,11 +4759,11 @@ SwaggerUIBundle({
4335
4759
 
4336
4760
  // libs/server-core/src/lib/preview-renderer.ts
4337
4761
  function renderPreviewHTML(options) {
4338
- const { doc, collection } = options;
4762
+ const { doc, collection, customFieldRenderers } = options;
4339
4763
  const titleField = collection.admin?.useAsTitle ?? "id";
4340
4764
  const title = escapeHtml(String(doc[titleField] ?? doc["id"] ?? "Untitled"));
4341
4765
  const fields = collection.fields ?? [];
4342
- const fieldHtml = fields.filter((f) => !isHiddenField(f) && !isLayoutField(f) && f.name !== titleField).map((f) => renderField(f, doc)).filter(Boolean).join("\n");
4766
+ const fieldHtml = fields.filter((f) => !isHiddenField(f) && !isLayoutField(f) && f.name !== titleField).map((f) => renderField(f, doc, customFieldRenderers)).filter(Boolean).join("\n");
4343
4767
  const richTextFields = fields.filter((f) => f.type === "richText").map((f) => f.name);
4344
4768
  return `<!DOCTYPE html>
4345
4769
  <html lang="en">
@@ -4373,6 +4797,7 @@ ${fieldHtml}
4373
4797
  (function(){
4374
4798
  var richTextFields=${JSON.stringify(richTextFields)};
4375
4799
  window.addEventListener('message',function(e){
4800
+ if(e.origin!==window.location.origin)return;
4376
4801
  if(!e.data||e.data.type!=='momentum-preview-update')return;
4377
4802
  var d=e.data.data;if(!d)return;
4378
4803
  document.querySelectorAll('[data-field]').forEach(function(el){
@@ -4393,16 +4818,20 @@ if(titleEl){var tf=titleEl.getAttribute('data-field');if(d[tf]!==undefined)title
4393
4818
  </body>
4394
4819
  </html>`;
4395
4820
  }
4396
- function renderField(field, doc) {
4821
+ function renderField(field, doc, customRenderers) {
4397
4822
  const value = doc[field.name];
4398
4823
  if (value === void 0 || value === null) {
4399
4824
  return renderFieldWrapper(field, "");
4400
4825
  }
4826
+ const editorKey = field.admin?.editor;
4827
+ if (editorKey && customRenderers?.[editorKey]) {
4828
+ return renderFieldWrapper(field, customRenderers[editorKey](value, field));
4829
+ }
4401
4830
  switch (field.type) {
4402
4831
  case "richText":
4403
4832
  return renderFieldWrapper(
4404
4833
  field,
4405
- `<div class="field-value rich-text" data-field="${escapeHtml(field.name)}">${String(value)}</div>`
4834
+ `<div class="field-value rich-text" data-field="${escapeHtml(field.name)}">${escapeHtml(String(value))}</div>`
4406
4835
  );
4407
4836
  case "checkbox":
4408
4837
  return renderFieldWrapper(
@@ -4733,6 +5162,20 @@ function coerceCsvValue(value, fieldType) {
4733
5162
  }
4734
5163
 
4735
5164
  // libs/server-analog/src/lib/server-analog.ts
5165
+ function getEmailBuilderFieldName(collection) {
5166
+ const field = collection.fields.find(
5167
+ (f) => f.type === "json" && f.admin?.editor === "email-builder"
5168
+ );
5169
+ return field?.name;
5170
+ }
5171
+ async function renderEmailPreviewHTML(doc, blocksFieldName) {
5172
+ const { renderEmailFromBlocks: renderEmailFromBlocks2 } = await Promise.resolve().then(() => (init_server(), server_exports));
5173
+ const blocks2 = doc[blocksFieldName];
5174
+ if (!Array.isArray(blocks2) || blocks2.length === 0) {
5175
+ return '<html><body style="display:flex;align-items:center;justify-content:center;min-height:100vh;color:#666;font-family:sans-serif"><p>No email blocks yet.</p></body></html>';
5176
+ }
5177
+ return renderEmailFromBlocks2({ blocks: blocks2 });
5178
+ }
4736
5179
  function nestBracketParams(flat) {
4737
5180
  const result = {};
4738
5181
  for (const [key, value] of Object.entries(flat)) {
@@ -5320,6 +5763,63 @@ function createComprehensiveMomentumHandler(config) {
5320
5763
  }
5321
5764
  }
5322
5765
  }
5766
+ if (seg2 === "preview" && seg1 && (method === "GET" || method === "POST")) {
5767
+ if (!user) {
5768
+ utils.setResponseStatus(event, 401);
5769
+ return { error: "Authentication required to access preview" };
5770
+ }
5771
+ try {
5772
+ const collectionSlug2 = seg0;
5773
+ const docId = seg1;
5774
+ const collectionConfig = config.collections.find((c) => c.slug === collectionSlug2);
5775
+ if (!collectionConfig) {
5776
+ utils.setResponseStatus(event, 404);
5777
+ return { error: "Collection not found" };
5778
+ }
5779
+ const accessFn = collectionConfig.access?.read;
5780
+ if (accessFn) {
5781
+ const allowed = await Promise.resolve(accessFn({ req: { user } }));
5782
+ if (!allowed) {
5783
+ utils.setResponseStatus(event, 403);
5784
+ return { error: "Access denied" };
5785
+ }
5786
+ }
5787
+ let docRecord;
5788
+ if (method === "POST") {
5789
+ const body2 = await safeReadBody(event, utils, method);
5790
+ if (body2["data"] && typeof body2["data"] === "object") {
5791
+ docRecord = body2["data"];
5792
+ } else {
5793
+ utils.setResponseStatus(event, 400);
5794
+ return { error: "POST preview requires { data: ... } body" };
5795
+ }
5796
+ } else {
5797
+ const contextApi = getContextualAPI(user);
5798
+ const doc = await contextApi.collection(collectionSlug2).findById(docId);
5799
+ if (!doc) {
5800
+ utils.setResponseStatus(event, 404);
5801
+ return { error: "Document not found" };
5802
+ }
5803
+ docRecord = doc;
5804
+ }
5805
+ const emailField = getEmailBuilderFieldName(collectionConfig);
5806
+ const html = emailField ? await renderEmailPreviewHTML(docRecord, emailField) : renderPreviewHTML({ doc: docRecord, collection: collectionConfig });
5807
+ utils.setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
5808
+ return utils.send(event, html);
5809
+ } catch (error) {
5810
+ const message = sanitizeErrorMessage(error, "Unknown error");
5811
+ if (message.includes("Access denied")) {
5812
+ utils.setResponseStatus(event, 403);
5813
+ return { error: message };
5814
+ }
5815
+ if (message.includes("not found")) {
5816
+ utils.setResponseStatus(event, 404);
5817
+ return { error: message };
5818
+ }
5819
+ utils.setResponseStatus(event, 500);
5820
+ return { error: "Preview failed", message };
5821
+ }
5822
+ }
5323
5823
  if (seg1 && seg2 && method === "POST") {
5324
5824
  const collectionSlug2 = seg0;
5325
5825
  const docId = seg1;
@@ -5417,42 +5917,6 @@ function createComprehensiveMomentumHandler(config) {
5417
5917
  };
5418
5918
  }
5419
5919
  }
5420
- if (seg2 === "preview" && seg1 && method === "GET") {
5421
- try {
5422
- const collectionSlug2 = seg0;
5423
- const docId = seg1;
5424
- const contextApi = getContextualAPI(user);
5425
- const doc = await contextApi.collection(collectionSlug2).findById(docId);
5426
- if (!doc) {
5427
- utils.setResponseStatus(event, 404);
5428
- return { error: "Document not found" };
5429
- }
5430
- const collectionConfig = config.collections.find((c) => c.slug === collectionSlug2);
5431
- if (!collectionConfig) {
5432
- utils.setResponseStatus(event, 404);
5433
- return { error: "Collection not found" };
5434
- }
5435
- const html = renderPreviewHTML({
5436
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- doc type from API
5437
- doc,
5438
- collection: collectionConfig
5439
- });
5440
- utils.setResponseHeader(event, "Content-Type", "text/html; charset=utf-8");
5441
- return utils.send(event, html);
5442
- } catch (error) {
5443
- const message = sanitizeErrorMessage(error, "Unknown error");
5444
- if (message.includes("Access denied")) {
5445
- utils.setResponseStatus(event, 403);
5446
- return { error: message };
5447
- }
5448
- if (message.includes("not found")) {
5449
- utils.setResponseStatus(event, 404);
5450
- return { error: message };
5451
- }
5452
- utils.setResponseStatus(event, 500);
5453
- return { error: "Preview failed", message };
5454
- }
5455
- }
5456
5920
  if (seg0 && seg1 && !seg2) {
5457
5921
  const customKey = `${method}:${seg0}/${seg1}`;
5458
5922
  const customEntry = customEndpointMap.get(customKey);
@@ -5705,12 +6169,13 @@ function createComprehensiveMomentumHandler(config) {
5705
6169
  fields[field.name] = field.data.toString("utf-8");
5706
6170
  }
5707
6171
  }
6172
+ const collectionUpload = postUploadCol.upload ?? {};
5708
6173
  const uploadRequest = {
5709
6174
  file,
5710
6175
  user,
5711
6176
  fields,
5712
6177
  collectionSlug: seg0,
5713
- collectionUpload: postUploadCol.upload
6178
+ collectionUpload
5714
6179
  };
5715
6180
  const response2 = await handleCollectionUpload(uploadConfig, uploadRequest);
5716
6181
  utils.setResponseStatus(event, response2.status);