@samva/markup 0.13.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,13 @@
1
1
  import { n as isObject } from "./guards-eV1qLbf2.js";
2
2
  import { n as inputSchema, r as jsonSchema } from "./input-schema-zjkgrXn2.js";
3
3
  import { n as TEMPLATE_NAME_PATTERN, t as TEMPLATE_ID_PATTERN } from "./template-zLUjcwfF.js";
4
- import { D as samvaBrandPlugin, E as reservedBrandAttributes, O as BRAND_FOOTER_MARKER, S as emailElement, T as parseBrandSpecifier, b as EMAIL_ELEMENTS, s as serializeStyle, t as checkAttribute } from "./html-eOZfDwVf.js";
4
+ import { D as samvaBrandPlugin, E as reservedBrandAttributes, O as BRAND_FOOTER_MARKER, S as emailElement, T as parseBrandSpecifier, b as EMAIL_ELEMENTS, s as serializeStyle, t as checkAttribute } from "./html-BvRFepeO.js";
5
5
  import { FORMATTER_NAMES } from "./fmt.js";
6
6
  import { i as drainEmailDiagnostics, t as EmailNode } from "./jsx-runtime-CBlexoeR.js";
7
- import { a as Email, d as equalColumnWidth, i as Divider, l as Section, n as Column, o as Image, r as Columns, s as Link, t as Button, u as Spacer } from "./components-DycSEKva.js";
8
- import { n as applyStylesheet } from "./ir-C-ubz6N3.js";
9
- import { i as buildLineMap, s as lineColumn, t as DIAGNOSTIC_CODES } from "./diagnostic-codes-DdZ4tka5.js";
10
- import { l as isAssetPath, n as brandComponents } from "./brand-C45QBKX9.js";
7
+ import { a as Email, d as equalColumnWidth, i as Divider, l as Section, n as Column, o as Image, p as PRIMITIVE_CLASSES, r as Columns, s as Link, t as Button, u as Spacer } from "./components-3XHjYD3i.js";
8
+ import { i as renderDarkRules, n as DARK_CLASS_PREFIX, r as applyStylesheet } from "./ir-DNL6H7jA.js";
9
+ import { i as buildLineMap, s as lineColumn, t as DIAGNOSTIC_CODES } from "./diagnostic-codes-UXwOO3A-.js";
10
+ import { l as isAssetPath, n as brandComponents } from "./brand-nreeKWxF.js";
11
11
  import { parse } from "css-tree";
12
12
  import { parse as parse$1 } from "@babel/parser";
13
13
  //#region src/email/theme-imports.ts
@@ -156,11 +156,13 @@ const mergeStylesheets = (sheets) => {
156
156
  }));
157
157
  });
158
158
  const variables = {};
159
+ const darkVariables = {};
159
160
  const headAtRules = [];
160
161
  const fontFaces = /* @__PURE__ */ new Map();
161
162
  const diagnostics = sheets.flatMap((sheet) => sheet.diagnostics);
162
163
  for (const sheet of sheets) {
163
164
  Object.assign(variables, sheet.variables);
165
+ Object.assign(darkVariables, sheet.darkVariables);
164
166
  headAtRules.push(...sheet.headAtRules);
165
167
  for (const face of sheet.fontFaces) fontFaces.set(face.css, face);
166
168
  }
@@ -169,6 +171,7 @@ const mergeStylesheets = (sheets) => {
169
171
  headAtRules,
170
172
  fontFaces: [...fontFaces.values()],
171
173
  variables,
174
+ darkVariables,
172
175
  diagnostics
173
176
  };
174
177
  };
@@ -974,6 +977,7 @@ const emailNodeToIr = (node, tokens, source) => {
974
977
  //#endregion
975
978
  //#region src/sml/components.ts
976
979
  const NUMERIC = {
980
+ Email: ["gutter"],
977
981
  Button: [
978
982
  "height",
979
983
  "paddingX",
@@ -983,6 +987,7 @@ const NUMERIC = {
983
987
  "width"
984
988
  ],
985
989
  Spacer: ["height"],
990
+ Columns: ["gap"],
986
991
  Divider: ["thickness"],
987
992
  BrandLogo: ["width"]
988
993
  };
@@ -1043,7 +1048,7 @@ const equalWidth = (per, list) => {
1043
1048
  * items (all in one row without `per`), each cell from the callback. Returns the nodes to place
1044
1049
  * inside the Columns table, and a wrapper that repeats that table per chunk.
1045
1050
  */
1046
- const columnsEach = (call, bindings) => {
1051
+ const columnsEach = (call, bindings, stack) => {
1047
1052
  const { lowerer, ctx, node } = call;
1048
1053
  const listValue = bindings.get("each");
1049
1054
  const list = listValue === void 0 ? void 0 : bindingValue(listValue);
@@ -1070,7 +1075,8 @@ const columnsEach = (call, bindings) => {
1070
1075
  const cellCtx = {
1071
1076
  ...ctx,
1072
1077
  loops: row === void 0 ? ctx.loops : [...ctx.loops, row],
1073
- columnWidth: equalWidth(per, list)
1078
+ columnWidth: equalWidth(per, list),
1079
+ columnStack: stack
1074
1080
  };
1075
1081
  const cells = lowerer.eachOver(cellCtx, node, row === void 0 ? list : { bind: `$${row}` }, fn);
1076
1082
  if (cells === void 0) return void 0;
@@ -1124,17 +1130,32 @@ const runBuiltinComponent = (call) => {
1124
1130
  let bindings = declared;
1125
1131
  let wrap = (table) => table;
1126
1132
  let kids;
1133
+ const stackBinding = declared.get("stack");
1134
+ const stack = stackBinding === void 0 ? true : bindingValue(stackBinding);
1135
+ if (exported === "Columns" && typeof stack !== "boolean") {
1136
+ lowerer.error(ctx, node, "invalid-columns-stack", "<Columns stack> decides the layout at compile time, so it must be true or false.", "Write stack={false}, or leave it out to stack on narrow screens.");
1137
+ return [];
1138
+ }
1127
1139
  const childCtx = {
1128
1140
  ...ctx,
1129
- columnWidth: void 0
1141
+ columnWidth: void 0,
1142
+ columnStack: exported === "Columns" ? stack === true : void 0
1130
1143
  };
1144
+ if (exported === "Columns" && declared.has("each") && declared.has("gap")) {
1145
+ lowerer.error(ctx, node, "invalid-columns-gap", "<Columns gap> spaces Column children written out in the row; cells repeated with each cannot take it.", "Write the Columns out, or pad each repeated cell (className=\"px-2\").");
1146
+ return [];
1147
+ }
1131
1148
  if (exported === "Columns" && declared.has("each")) {
1132
- const chunked = columnsEach(call, declared);
1149
+ const chunked = columnsEach(call, declared, stack === true);
1133
1150
  if (chunked === void 0) return [];
1134
- bindings = new Map([...declared].filter(([name]) => name !== "each" && name !== "per"));
1151
+ bindings = new Map([...declared].filter(([name]) => name !== "each" && name !== "per" && name !== "stack"));
1135
1152
  kids = [chunked.cells];
1136
1153
  wrap = chunked.wrap;
1137
1154
  } else kids = lowerer.jsxChildren(childCtx, node);
1155
+ if (exported === "Columns" && declared.has("gap") && kids.some((kid) => !isValueNode(kid) && !("el" in kid))) {
1156
+ lowerer.error(ctx, node, "invalid-columns-gap", "<Columns gap> spaces Column children that always render; a conditional or repeated Column would leave a stray gap.", "Pad the cells instead (className=\"pr-6\"), or write each Column unconditionally.");
1157
+ return [];
1158
+ }
1138
1159
  const tokens = new Tokens();
1139
1160
  const props = {};
1140
1161
  for (const [name, bound] of bindings) props[name] = bindingToProp(call, tokens, name, bound);
@@ -1146,6 +1167,7 @@ const runBuiltinComponent = (call) => {
1146
1167
  }
1147
1168
  }
1148
1169
  if (exported === "Column" && props.width === void 0 && ctx.columnWidth !== void 0) props.width = propFor(tokens, ctx.columnWidth);
1170
+ if (exported === "Column" && ctx.columnStack === true) props.className = typeof props.className === "string" && props.className !== "" ? `${PRIMITIVE_CLASSES.stack} ${props.className}` : PRIMITIVE_CLASSES.stack;
1149
1171
  if (kids.length > 0) props.children = childrenProp(tokens, kids);
1150
1172
  if (exported === "Button" && typeof props.children === "string" && props.children !== "" && props.width === void 0 && props.height !== void 0 && tokens.toValue(props.children) !== props.children) {
1151
1173
  lowerer.error(ctx, node, "button-dynamic-label", "Classic Outlook draws the button from a fixed box, and a label read at render time cannot size it.", "Give the button a width: <Button width={200} …>.");
@@ -2508,7 +2530,12 @@ const BUTTON_TEXT_LIMIT = 25;
2508
2530
  const URL_BUTTON_LIMIT = 2;
2509
2531
  const PHONE_BUTTON_LIMIT = 1;
2510
2532
  const QUICK_REPLY_LIMIT = 10;
2533
+ const FLOW_BUTTON_LIMIT = 1;
2511
2534
  const BUTTON_LIMIT = 10;
2535
+ const FLOW_HANDLE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2536
+ const FLOW_HANDLE_LIMIT = 64;
2537
+ const FLOW_SCREEN_PATTERN = /^[A-Za-z_]{1,100}$/;
2538
+ const FLOW_ACTIONS = ["navigate", "data_exchange"];
2512
2539
  const jsxName = (name) => {
2513
2540
  if (name.type === "JSXIdentifier") return text(name, "name") ?? "";
2514
2541
  if (name.type === "JSXMemberExpression") return `${jsxName(child(name, "object") ?? name)}.${text(child(name, "property") ?? name, "name") ?? ""}`;
@@ -2614,6 +2641,7 @@ const ZERO = {
2614
2641
  url: 0,
2615
2642
  phone: 0,
2616
2643
  quickReply: 0,
2644
+ flow: 0,
2617
2645
  total: 0
2618
2646
  };
2619
2647
  /** The most buttons of each kind any combination of the conditions can produce. */
@@ -2628,18 +2656,21 @@ const maximalCounts = (nodes) => {
2628
2656
  url: Math.max(then.url, otherwise.url),
2629
2657
  phone: Math.max(then.phone, otherwise.phone),
2630
2658
  quickReply: Math.max(then.quickReply, otherwise.quickReply),
2659
+ flow: Math.max(then.flow, otherwise.flow),
2631
2660
  total: Math.max(then.total, otherwise.total)
2632
2661
  };
2633
2662
  } else add = {
2634
2663
  url: node.type === "url" ? 1 : 0,
2635
2664
  phone: node.type === "phone" ? 1 : 0,
2636
2665
  quickReply: node.type === "quick-reply" ? 1 : 0,
2666
+ flow: node.type === "flow" ? 1 : 0,
2637
2667
  total: 1
2638
2668
  };
2639
2669
  counts = {
2640
2670
  url: counts.url + add.url,
2641
2671
  phone: counts.phone + add.phone,
2642
2672
  quickReply: counts.quickReply + add.quickReply,
2673
+ flow: counts.flow + add.flow,
2643
2674
  total: counts.total + add.total
2644
2675
  };
2645
2676
  }
@@ -2657,6 +2688,7 @@ const buttonNodes = (buttons) => buttons.flatMap((button) => {
2657
2688
  case "url": return [button.text, button.url];
2658
2689
  case "phone": return [button.text, button.phone];
2659
2690
  case "copy-code": return [button.code];
2691
+ case "flow": return [button.text];
2660
2692
  }
2661
2693
  });
2662
2694
  const whatsappChannel = (input, field) => {
@@ -2750,6 +2782,7 @@ const whatsappChannel = (input, field) => {
2750
2782
  over("URL buttons", counts.url, URL_BUTTON_LIMIT);
2751
2783
  over("phone buttons", counts.phone, PHONE_BUTTON_LIMIT);
2752
2784
  over("quick-reply buttons", counts.quickReply, QUICK_REPLY_LIMIT);
2785
+ over("flow buttons", counts.flow, FLOW_BUTTON_LIMIT);
2753
2786
  over("buttons in all", counts.total, BUTTON_LIMIT);
2754
2787
  roots.push(...buttonNodes(buttons));
2755
2788
  }
@@ -2768,7 +2801,8 @@ const BUTTON_TAGS = [
2768
2801
  "UrlButton",
2769
2802
  "QuickReplyButton",
2770
2803
  "PhoneButton",
2771
- "CopyCodeButton"
2804
+ "CopyCodeButton",
2805
+ "FlowButton"
2772
2806
  ];
2773
2807
  const buttonList = (input, ctx, local, parent) => {
2774
2808
  const { lowerer } = input;
@@ -2793,6 +2827,54 @@ const buttonList = (input, ctx, local, parent) => {
2793
2827
  if (value === void 0) lowerer.error(ctx, element, "missing-attribute", `This button needs \`${name}\`.`, `Write ${name}="…" or ${name}={input.field}.`);
2794
2828
  return value;
2795
2829
  };
2830
+ const flowButton = (element) => {
2831
+ const label_ = label(element, "label");
2832
+ const values = /* @__PURE__ */ new Map();
2833
+ let valid = true;
2834
+ const refuse = (node, code, message, fix) => {
2835
+ valid = false;
2836
+ lowerer.error(ctx, node, code, message, fix);
2837
+ };
2838
+ for (const item of attributesOf(element)) {
2839
+ if (item.type === "JSXSpreadAttribute") {
2840
+ refuse(item, "invalid-attribute", "A spread attribute runs code, and templates render without running code.", "Write flow, action and screen as string literals.");
2841
+ continue;
2842
+ }
2843
+ const nameNode = child(item, "name");
2844
+ const name = nameNode === void 0 ? "" : jsxName(nameNode);
2845
+ if (name === "key") continue;
2846
+ if (name !== "flow" && name !== "action" && name !== "screen") {
2847
+ refuse(item, "invalid-attribute", `\`${name}\` is not an attribute of this button.`, "The attributes are flow, action and screen.");
2848
+ continue;
2849
+ }
2850
+ const raw = child(item, "value");
2851
+ const literal = raw?.type === "JSXExpressionContainer" ? child(raw, "expression") : raw;
2852
+ if (literal?.type !== "StringLiteral") {
2853
+ refuse(item, "invalid-attribute", `\`${name}\` is fixed when the template is registered, so it is a string literal, not a binding.`, `Write ${name}="…".`);
2854
+ continue;
2855
+ }
2856
+ values.set(name, text(literal, "value") ?? "");
2857
+ }
2858
+ const flow = values.get("flow");
2859
+ if (flow === void 0) {
2860
+ if (valid) refuse(element, "missing-attribute", "This button needs `flow`.", "Write flow=\"handle\".");
2861
+ } else if (flow.length > FLOW_HANDLE_LIMIT || !FLOW_HANDLE_PATTERN.test(flow)) refuse(element, "invalid-attribute", `\`${flow}\` is not a flow handle.`, `Use the lowercase kebab-case handle of flows/<handle>.json, at most ${FLOW_HANDLE_LIMIT} characters.`);
2862
+ const action = values.get("action") ?? "navigate";
2863
+ if (!FLOW_ACTIONS.includes(action)) refuse(element, "invalid-attribute", `\`${action}\` is not a flow action.`, `Use one of ${FLOW_ACTIONS.join(", ")}.`);
2864
+ const screen = values.get("screen");
2865
+ if (screen !== void 0) {
2866
+ if (action === "data_exchange") refuse(element, "invalid-attribute", "A data_exchange flow has its endpoint choose the first screen, so it takes no `screen`.", "Remove screen, or use action=\"navigate\".");
2867
+ else if (!FLOW_SCREEN_PATTERN.test(screen) || screen === "SUCCESS") refuse(element, "invalid-attribute", `\`${screen}\` is not a flow screen id.`, "Use the screen's id: letters and underscores, at most 100 characters, and not SUCCESS.");
2868
+ }
2869
+ if (!valid || flow === void 0 || label_ === void 0) return void 0;
2870
+ return {
2871
+ type: "flow",
2872
+ text: label_,
2873
+ flow,
2874
+ action,
2875
+ ...screen === void 0 ? {} : { screen }
2876
+ };
2877
+ };
2796
2878
  const single = (element) => {
2797
2879
  const tag = tagOf(element);
2798
2880
  switch (tag.startsWith(`${local}.`) ? tag.slice(local.length + 1) : "") {
@@ -2829,6 +2911,7 @@ const buttonList = (input, ctx, local, parent) => {
2829
2911
  code
2830
2912
  };
2831
2913
  }
2914
+ case "FlowButton": return flowButton(element);
2832
2915
  default:
2833
2916
  lowerer.error(ctx, element, "whatsapp-structure", `<WhatsApp.Buttons> holds ${BUTTON_TAGS.map((name) => `<WhatsApp.${name}>`).join(", ")}.`, "Use one of the button components.");
2834
2917
  return;
@@ -2951,6 +3034,35 @@ const mapTree = (tree, leafStyle, counter = { next: 0 }) => {
2951
3034
  else: otherwise
2952
3035
  };
2953
3036
  };
3037
+ /** A class tree as the IR value it was read from: a literal, or a choice between literals. */
3038
+ const treeValue = (tree) => "leaf" in tree ? tree.leaf : {
3039
+ if: tree.pred,
3040
+ then: treeValue(tree.then),
3041
+ else: treeValue(tree.else)
3042
+ };
3043
+ const appendClass = (classes, name) => name === void 0 ? classes : classes.trim() === "" ? name : `${classes} ${name}`;
3044
+ /**
3045
+ * An element's class with the dark-mode class each pass gave it. A choice between literal class
3046
+ * lists resolved one list per pass, so each list gains its own pass's class; any other class
3047
+ * value gains the first pass's. `undefined` when no pass gave the element one.
3048
+ */
3049
+ const withDarkClass = (value, tree, perPass) => {
3050
+ if (perPass.every((name) => name === void 0)) return void 0;
3051
+ if (tree !== void 0 && leavesOf(tree).length > 1) {
3052
+ let leaf = 0;
3053
+ const extend = (node) => "leaf" in node ? { leaf: appendClass(node.leaf, perPass[Math.min(leaf++, perPass.length - 1)]) } : {
3054
+ pred: node.pred,
3055
+ then: extend(node.then),
3056
+ else: extend(node.else)
3057
+ };
3058
+ return treeValue(extend(tree));
3059
+ }
3060
+ const name = perPass[0];
3061
+ if (name === void 0) return value;
3062
+ if (value === void 0) return name;
3063
+ if (typeof value === "string") return appendClass(value, name);
3064
+ return { concat: [value, ` ${name}`] };
3065
+ };
2954
3066
  const collect = (nodes, into) => {
2955
3067
  for (const node of nodes) {
2956
3068
  if (isValueNode(node)) continue;
@@ -3057,6 +3169,8 @@ const applyStyles = (root, sheet, options) => {
3057
3169
  for (const node of nodes) if (node.type === "Fragment") read(node.children, into);
3058
3170
  else if (node.type === "Raw") into.raws.push(node.html);
3059
3171
  else if (node.type === "Element") {
3172
+ const classes = typeof node.props.class === "string" ? node.props.class.split(" ") : [];
3173
+ into.darkClasses.push(classes.find((name) => name.startsWith(DARK_CLASS_PREFIX)));
3060
3174
  into.styles.push(node.props.style === void 0 ? void 0 : node.props.style);
3061
3175
  into.bgcolors.push(typeof node.props.bgcolor === "string" ? node.props.bgcolor : void 0);
3062
3176
  read(node.children, into);
@@ -3065,6 +3179,7 @@ const applyStyles = (root, sheet, options) => {
3065
3179
  const results = [];
3066
3180
  let headCss = "";
3067
3181
  let fontFaceCss = "";
3182
+ const darkRules = /* @__PURE__ */ new Map();
3068
3183
  const seen = /* @__PURE__ */ new Set();
3069
3184
  for (let pass = 0; pass < passes; pass++) {
3070
3185
  const tree = shadow([root], pass, { next: 0 });
@@ -3073,6 +3188,7 @@ const applyStyles = (root, sheet, options) => {
3073
3188
  headCss = applied.headCss;
3074
3189
  fontFaceCss = applied.fontFaceCss;
3075
3190
  }
3191
+ for (const [name, body] of applied.darkRules) darkRules.set(name, body);
3076
3192
  for (const diagnostic of applied.diagnostics) {
3077
3193
  const key = `${diagnostic.code}\0${diagnostic.message}`;
3078
3194
  if (seen.has(key)) continue;
@@ -3082,6 +3198,7 @@ const applyStyles = (root, sheet, options) => {
3082
3198
  const result = {
3083
3199
  styles: [],
3084
3200
  bgcolors: [],
3201
+ darkClasses: [],
3085
3202
  raws: []
3086
3203
  };
3087
3204
  read([applied.tree], result);
@@ -3114,10 +3231,15 @@ const applyStyles = (root, sheet, options) => {
3114
3231
  const baseStyle = first?.styles[ordinal];
3115
3232
  const style = tree !== void 0 && leavesOf(tree).length > 1 ? mapTree(tree, (leaf) => styleOf(results[Math.min(leaf, results.length - 1)]?.styles[ordinal], node)) : styleOf(baseStyle, node);
3116
3233
  const bgcolor = first?.bgcolors[ordinal];
3117
- const attrs = typeof node.attrs?.bgcolor === "string" && bgcolor !== void 0 && bgcolor !== node.attrs.bgcolor ? {
3234
+ const painted = typeof node.attrs?.bgcolor === "string" && bgcolor !== void 0 && bgcolor !== node.attrs.bgcolor ? {
3118
3235
  ...node.attrs,
3119
3236
  bgcolor: tokens.toValue(bgcolor) ?? bgcolor
3120
3237
  } : node.attrs;
3238
+ const darkClass = withDarkClass(node.attrs?.class, tree, results.map((result) => result.darkClasses[ordinal]));
3239
+ const attrs = darkClass === void 0 ? painted : {
3240
+ ...painted,
3241
+ class: darkClass
3242
+ };
3121
3243
  const kids = rebuild(node.children ?? []);
3122
3244
  const { style: _authored, children: _children, attrs: _attrs, ...rest } = node;
3123
3245
  return {
@@ -3152,7 +3274,7 @@ const applyStyles = (root, sheet, options) => {
3152
3274
  });
3153
3275
  return {
3154
3276
  root: rebuild([root])[0] ?? root,
3155
- headCss,
3277
+ headCss: `${renderDarkRules(darkRules)}${headCss}`,
3156
3278
  fontFaceCss
3157
3279
  };
3158
3280
  };
@@ -167,6 +167,14 @@ type IrWhatsAppButton = {
167
167
  } | {
168
168
  readonly type: "copy-code";
169
169
  readonly code: IrValue;
170
+ } | {
171
+ readonly type: "flow";
172
+ readonly text: IrValue;
173
+ /** The Samva flow handle, resolved to Meta's flow id when the template is registered. */
174
+ readonly flow: string;
175
+ readonly action: "navigate" | "data_exchange";
176
+ /** Meta's `navigate_screen`; absent opens the flow's first entry screen. */
177
+ readonly screen?: string;
170
178
  };
171
179
  /** A button, or buttons that appear when a condition over the input holds. */
172
180
  type IrWhatsAppButtonNode = IrWhatsAppButton | {
@@ -243,6 +251,12 @@ type RenderedWhatsAppButton = {
243
251
  } | {
244
252
  readonly type: "copy-code";
245
253
  readonly code: string;
254
+ } | {
255
+ readonly type: "flow";
256
+ readonly text: string;
257
+ readonly flow: string;
258
+ readonly action: "navigate" | "data_exchange";
259
+ readonly screen?: string;
246
260
  };
247
261
  /** A WhatsApp message with every text rendered and every conditional button resolved. */
248
262
  interface RenderedIrWhatsApp {
@@ -441,6 +455,11 @@ interface Stylesheet {
441
455
  readonly fontFaces: readonly FontFaceRule[];
442
456
  /** Unconditional custom properties, for resolving `var()` before inlining. */
443
457
  readonly variables: Readonly<Record<string, string>>;
458
+ /**
459
+ * The custom properties the dark-mode query redefines (a theme's dark-mode
460
+ * block). A declaration that reads one switches in dark mode.
461
+ */
462
+ readonly darkVariables: Readonly<Record<string, string>>;
444
463
  readonly diagnostics: readonly EmailDiagnostic[];
445
464
  }
446
465
  /** Compile a selector into the plain-data matcher the render lane uses. */
@@ -1,7 +1,7 @@
1
- import { S as emailElement, o as serializeEmailHtml } from "./html-eOZfDwVf.js";
1
+ import { S as emailElement, o as serializeEmailHtml } from "./html-BvRFepeO.js";
2
2
  import { i as drainEmailDiagnostics, t as EmailNode } from "./jsx-runtime-CBlexoeR.js";
3
- import { f as preheaderNode } from "./components-DycSEKva.js";
4
- import { n as applyStylesheet } from "./ir-C-ubz6N3.js";
3
+ import { f as preheaderNode } from "./components-3XHjYD3i.js";
4
+ import { i as renderDarkRules, r as applyStylesheet } from "./ir-DNL6H7jA.js";
5
5
  import { r as hasBlockingDiagnostic, t as EmailCompileError } from "./diagnostics-C0Tr8-eQ.js";
6
6
  //#region src/email/text.ts
7
7
  const PARAGRAPH = "\n\n";
@@ -219,12 +219,14 @@ const renderEmailContent = (stylesheet, render, options = {}) => {
219
219
  tree: base,
220
220
  headCss: "",
221
221
  fontFaceCss: "",
222
- diagnostics: []
222
+ diagnostics: [],
223
+ darkRules: /* @__PURE__ */ new Map()
223
224
  } : applyStylesheet(base, stylesheet);
225
+ const headCss = `${renderDarkRules(styled.darkRules)}${styled.headCss}`;
224
226
  const style = [...styled.diagnostics];
225
227
  let tree = styled.tree;
226
- if (styled.headCss !== "" || styled.fontFaceCss !== "") {
227
- const placed = withHeadCss(tree, styled.headCss, styled.fontFaceCss);
228
+ if (headCss !== "" || styled.fontFaceCss !== "") {
229
+ const placed = withHeadCss(tree, headCss, styled.fontFaceCss);
228
230
  if (placed === void 0) style.push({
229
231
  code: "missing-head",
230
232
  severity: "error",
package/dist/render.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { C as isUrlAttribute, S as emailElement, _ as EmailGlobalProps, b as EmailStyle, g as EmailElementSpec, h as EmailAttributeSpec, m as EMAIL_GLOBAL_ATTRIBUTES, p as EMAIL_ELEMENTS, r as EmailNode, v as EmailIntrinsicElements, x as EmailTag, y as EmailLayout } from "./jsx-runtime-DC7xG0aA.js";
2
- import { $ as IrWhatsApp, A as emailAuthoringTarget, B as IrElement, D as EmittedPosition, E as EmailElementSelection, F as emailSelections, G as IrPredicate, H as IrFormatter, I as renderIrPreview, J as IrRawPart, K as IrPreheader, L as IrArithmetic, M as emailSelectionAncestry, N as emailSelectionIndex, O as INSTANCE_PATH_ATTRIBUTE, P as emailSelectionSiblings, Q as IrValue, R as IrComparison, U as IrIf, V as IrEmail, W as IrNode, X as IrSource, Y as IrSms, Z as IrStyle, a as renderEmailContent, at as RenderedPosition, c as checkAttribute, ct as TemplateIr, d as escapeText, et as IrWhatsAppButton, f as kebab, i as compileEmail, it as RenderedIrWhatsApp, j as emailDefinitionInstances, k as IrPreview, l as declarationValue, m as serializeStyle, n as EmailCompileOutput, nt as RenderOptions, o as EmailHtmlOutput, ot as RenderedWhatsAppButton, p as serializeEmailHtml, q as IrRaw, r as RenderedEmail, rt as RenderedIrEmail, s as SerializeEmailOptions, st as SML_IR_VERSION, t as EMAIL_DOCTYPE, tt as IrWhatsAppButtonNode, u as escapeAttribute, z as IrEach } from "./render-C6OTH8BQ.js";
2
+ import { $ as IrWhatsApp, A as emailAuthoringTarget, B as IrElement, D as EmittedPosition, E as EmailElementSelection, F as emailSelections, G as IrPredicate, H as IrFormatter, I as renderIrPreview, J as IrRawPart, K as IrPreheader, L as IrArithmetic, M as emailSelectionAncestry, N as emailSelectionIndex, O as INSTANCE_PATH_ATTRIBUTE, P as emailSelectionSiblings, Q as IrValue, R as IrComparison, U as IrIf, V as IrEmail, W as IrNode, X as IrSource, Y as IrSms, Z as IrStyle, a as renderEmailContent, at as RenderedPosition, c as checkAttribute, ct as TemplateIr, d as escapeText, et as IrWhatsAppButton, f as kebab, i as compileEmail, it as RenderedIrWhatsApp, j as emailDefinitionInstances, k as IrPreview, l as declarationValue, m as serializeStyle, n as EmailCompileOutput, nt as RenderOptions, o as EmailHtmlOutput, ot as RenderedWhatsAppButton, p as serializeEmailHtml, q as IrRaw, r as RenderedEmail, rt as RenderedIrEmail, s as SerializeEmailOptions, st as SML_IR_VERSION, t as EMAIL_DOCTYPE, tt as IrWhatsAppButtonNode, u as escapeAttribute, z as IrEach } from "./render-BdvewoQt.js";
3
3
  //#region src/render-ir.d.ts
4
4
  interface IrRenderDiagnostic {
5
5
  readonly code: string;
package/dist/render.js CHANGED
@@ -1,6 +1,6 @@
1
- import { C as isUrlAttribute, S as emailElement, _ as renderIr, a as kebab, b as EMAIL_ELEMENTS, c as INSTANCE_PATH_ATTRIBUTE, d as emailSelectionAncestry, f as emailSelectionIndex, g as IrRenderError, h as renderIrPreview, i as escapeText, l as emailAuthoringTarget, m as emailSelections, n as declarationValue, o as serializeEmailHtml, p as emailSelectionSiblings, r as escapeAttribute, s as serializeStyle, t as checkAttribute, u as emailDefinitionInstances, v as renderIrSms, x as EMAIL_GLOBAL_ATTRIBUTES, y as renderIrWhatsApp } from "./html-eOZfDwVf.js";
2
- import { t as SML_IR_VERSION } from "./ir-C-ubz6N3.js";
3
- import { i as deriveEmailText, n as compileEmail, r as renderEmailContent, t as EMAIL_DOCTYPE } from "./render-Cja448i0.js";
1
+ import { C as isUrlAttribute, S as emailElement, _ as renderIr, a as kebab, b as EMAIL_ELEMENTS, c as INSTANCE_PATH_ATTRIBUTE, d as emailSelectionAncestry, f as emailSelectionIndex, g as IrRenderError, h as renderIrPreview, i as escapeText, l as emailAuthoringTarget, m as emailSelections, n as declarationValue, o as serializeEmailHtml, p as emailSelectionSiblings, r as escapeAttribute, s as serializeStyle, t as checkAttribute, u as emailDefinitionInstances, v as renderIrSms, x as EMAIL_GLOBAL_ATTRIBUTES, y as renderIrWhatsApp } from "./html-BvRFepeO.js";
2
+ import { t as SML_IR_VERSION } from "./ir-DNL6H7jA.js";
3
+ import { i as deriveEmailText, n as compileEmail, r as renderEmailContent, t as EMAIL_DOCTYPE } from "./render-DpYouYYG.js";
4
4
  //#region src/sms-segments.ts
5
5
  const GSM7_EXTENDED = /* @__PURE__ */ new Set([
6
6
  "[",
@@ -1,3 +1,4 @@
1
+ import { r as isDarkSchemeQuery } from "./dark-scheme-CbCwGDWH.js";
1
2
  import { generate, parse, walk } from "css-tree";
2
3
  import { Result } from "better-result";
3
4
  //#region src/email/css-color.ts
@@ -322,14 +323,15 @@ const BRAND_NAMESPACES = [
322
323
  "--text-",
323
324
  "--leading-",
324
325
  "--tracking-",
325
- "--radius-",
326
+ "--radius",
326
327
  "--spacing",
327
328
  "--container-",
328
329
  "--shadow-"
329
330
  ];
331
+ /** `--color-` names a prefix; `--radius` is the variable itself or a `--radius-*` prefix. */
332
+ const inNamespace = (property, namespace) => namespace.endsWith("-") ? property.startsWith(namespace) : property === namespace || property.startsWith(`${namespace}-`);
330
333
  const BRAND_PROPERTY = /^--[a-z][a-z0-9-]*$/;
331
334
  const BRAND_UNSAFE_VALUE = /[{};<>\\@]|url\s*\(|expression\s*\(|\/\*/i;
332
- const BRAND_DARK_MEDIA = /^\(\s*prefers-color-scheme\s*:\s*dark\s*\)$/i;
333
335
  const BRAND_FONT_DESCRIPTORS = /* @__PURE__ */ new Set([
334
336
  "font-family",
335
337
  "src",
@@ -338,8 +340,6 @@ const BRAND_FONT_DESCRIPTORS = /* @__PURE__ */ new Set([
338
340
  "font-display",
339
341
  "unicode-range"
340
342
  ]);
341
- /** The theme variable a dark-mode color lowers to: `--color-brand` → `--color-brand-dark`. */
342
- const darkVariableName = (property) => `${property}-dark`;
343
343
  const spanOfNode = (node) => ({
344
344
  start: node.loc?.start.offset ?? 0,
345
345
  end: node.loc?.end.offset ?? 0
@@ -390,7 +390,7 @@ const parseBrandCss = (css) => {
390
390
  report("brand-css-property", `${property} is not a theme variable; name theme variables --namespace-name.`, span);
391
391
  continue;
392
392
  }
393
- if (!BRAND_NAMESPACES.some((namespace) => property.startsWith(namespace))) {
393
+ if (!BRAND_NAMESPACES.some((namespace) => inNamespace(property, namespace))) {
394
394
  report("brand-css-namespace", `${property} is outside the brand namespaces (${BRAND_NAMESPACES.map((namespace) => namespace.endsWith("-") ? `${namespace}*` : namespace).join(", ")}).`, span);
395
395
  continue;
396
396
  }
@@ -476,7 +476,7 @@ const parseBrandCss = (css) => {
476
476
  readFontFace(node);
477
477
  continue;
478
478
  }
479
- if (name === "media" && node.prelude !== null && BRAND_DARK_MEDIA.test(generate(node.prelude).trim())) {
479
+ if (name === "media" && node.prelude !== null && isDarkSchemeQuery(generate(node.prelude))) {
480
480
  for (const child of node.block.children) if (child.type === "Atrule" && child.name.toLowerCase() === "theme") readTheme(child, dark, "dark");
481
481
  else report("brand-css-unsupported", "The dark-mode block may contain only @theme.", spanOfNode(child));
482
482
  continue;
@@ -484,10 +484,6 @@ const parseBrandCss = (css) => {
484
484
  }
485
485
  report("brand-css-unsupported", "A brand theme contains only @theme, a dark-mode @theme (@media (prefers-color-scheme: dark)), and @font-face.", spanOfNode(node));
486
486
  }
487
- for (const property of Object.keys(dark)) if (darkVariableName(property) in variables) report("brand-css-dark-conflict", `${darkVariableName(property)} and the dark-mode ${property} name the same theme variable; keep one.`, {
488
- start: 0,
489
- end: 0
490
- });
491
487
  return diagnostics.length > 0 ? Result.err(diagnostics) : Result.ok({
492
488
  variables,
493
489
  dark,
@@ -518,9 +514,92 @@ const printBrandCss = (brand) => {
518
514
  ];
519
515
  return blocks.length === 0 ? "" : `${blocks.join("\n\n")}\n`;
520
516
  };
521
- /** The brand color, and the text color that sits on it. */
522
- const BRAND_COLOR_VARIABLE = "--color-brand";
523
- const BRAND_FOREGROUND_VARIABLE = "--color-brand-foreground";
517
+ /**
518
+ * The semantic color tokens, as Tailwind color names (`bg-card`,
519
+ * `text-primary-foreground`, `border-border`), each with the role a block uses
520
+ * it for. A block reads only these, so swapping the theme reskins it.
521
+ */
522
+ const THEME_COLOR_ROLES = {
523
+ background: "The page, behind everything.",
524
+ foreground: "Text on the page.",
525
+ card: "A surface that groups the message's content.",
526
+ "card-foreground": "Text on a card.",
527
+ primary: "The main action's fill (a button), and links.",
528
+ "primary-foreground": "Text on a primary fill.",
529
+ secondary: "A quieter action's fill.",
530
+ "secondary-foreground": "Text on a secondary fill.",
531
+ muted: "A subdued surface: a code block, a table header.",
532
+ "muted-foreground": "Secondary text on the page, a card or a muted surface: labels, captions, the footer.",
533
+ accent: "A highlighted surface: a callout, a selected row.",
534
+ "accent-foreground": "Text on an accent surface.",
535
+ border: "Rules, dividers and outlines."
536
+ };
537
+ const THEME_COLORS = Object.keys(THEME_COLOR_ROLES);
538
+ /**
539
+ * The non-color tokens: the corner radius (`rounded`; `rounded-sm` to `rounded-xl` follow it) and
540
+ * the body and heading families (`font-sans`, `font-heading`).
541
+ */
542
+ const THEME_TOKENS = [
543
+ "--radius",
544
+ "--font-sans",
545
+ "--font-heading"
546
+ ];
547
+ const colorVariables = (colors) => Object.fromEntries(THEME_COLORS.map((name) => [`--color-${name}`, colors[name]]));
548
+ /**
549
+ * The theme every project compiles with, beneath its own: a neutral light and
550
+ * dark palette, an 8px radius and the system sans-serif stack. A project's
551
+ * `theme.css`, its imported stylesheets and its brand layer over it.
552
+ */
553
+ const DEFAULT_THEME = {
554
+ variables: {
555
+ ...colorVariables({
556
+ background: "#ffffff",
557
+ foreground: "#0a0a0a",
558
+ card: "#ffffff",
559
+ "card-foreground": "#0a0a0a",
560
+ primary: "#171717",
561
+ "primary-foreground": "#fafafa",
562
+ secondary: "#f5f5f5",
563
+ "secondary-foreground": "#171717",
564
+ muted: "#f5f5f5",
565
+ "muted-foreground": "#737373",
566
+ accent: "#f5f5f5",
567
+ "accent-foreground": "#171717",
568
+ border: "#e5e5e5"
569
+ }),
570
+ "--radius": "8px",
571
+ "--radius-sm": "calc(var(--radius) - 4px)",
572
+ "--radius-md": "calc(var(--radius) - 2px)",
573
+ "--radius-lg": "var(--radius)",
574
+ "--radius-xl": "calc(var(--radius) + 4px)",
575
+ "--font-sans": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif",
576
+ "--font-heading": "var(--font-sans)"
577
+ },
578
+ dark: colorVariables({
579
+ background: "#0a0a0a",
580
+ foreground: "#fafafa",
581
+ card: "#171717",
582
+ "card-foreground": "#fafafa",
583
+ primary: "#fafafa",
584
+ "primary-foreground": "#171717",
585
+ secondary: "#262626",
586
+ "secondary-foreground": "#fafafa",
587
+ muted: "#262626",
588
+ "muted-foreground": "#a3a3a3",
589
+ accent: "#262626",
590
+ "accent-foreground": "#fafafa",
591
+ border: "#2e2e2e"
592
+ }),
593
+ fontFaces: []
594
+ };
595
+ /** `DEFAULT_THEME` as theme CSS. */
596
+ const DEFAULT_THEME_CSS = printBrandCss(DEFAULT_THEME);
597
+ /** The fills whose text color a brand may leave out: `--color-primary` and `--color-primary-foreground`, and so on. */
598
+ const FILL_COLORS = [
599
+ "primary",
600
+ "secondary",
601
+ "accent"
602
+ ];
524
603
  /** WCAG relative luminance of an sRGB channel triple in 0–1. */
525
604
  const relativeLuminance = (r, g, b) => {
526
605
  const linear = (channel) => {
@@ -545,41 +624,24 @@ const contrastForeground = (color) => {
545
624
  return 1.05 / (luminance + .05) > (luminance + .05) / .05 ? "#ffffff" : "#000000";
546
625
  };
547
626
  /**
548
- * The text-on-brand colors a brand gets when it leaves them out: for the light
549
- * theme and, when the brand sets a dark-mode brand color, for dark mode. Each
550
- * is the `contrastForeground` of the brand color it sits on. A brand that sets
551
- * the foreground, or whose brand color does not evaluate, gets no default.
552
- */
553
- const brandForegroundDefaults = (brand) => {
554
- const light = brand.variables["--color-brand-foreground"] === void 0 && brand.variables["--color-brand"] !== void 0 ? contrastForeground(brand.variables[BRAND_COLOR_VARIABLE]) : void 0;
555
- const darkBrand = brand.dark[BRAND_COLOR_VARIABLE];
556
- return {
557
- light,
558
- dark: darkBrand !== void 0 && brand.dark["--color-brand-foreground"] === void 0 && brand.variables[darkVariableName("--color-brand-foreground")] === void 0 ? contrastForeground(darkBrand) : void 0
559
- };
560
- };
561
- /**
562
- * A brand theme as flat theme variables, the way a build compiles it. Email
563
- * inlining resolves `var()` once, so a dark-mode `@theme` cannot switch a
564
- * value at run time; each dark color lowers to its `-dark` theme variable,
565
- * which `dark:` utilities read (`dark:bg-brand-dark`). A text-on-brand color
566
- * the brand leaves out is filled in by `brandForegroundDefaults`, so every
567
- * build compiles the same default and the stored CSS never carries it.
627
+ * The text colors a theme layer gets for the fills it sets without one: for
628
+ * each of `FILL_COLORS`, the `contrastForeground` of the fill, in light mode
629
+ * and, where the layer's dark-mode block sets the fill, in dark mode. A layer
630
+ * that sets the foreground, or whose fill does not evaluate, gets no default.
631
+ * Every build lowers each theme layer with these, so stored theme CSS never
632
+ * carries them.
568
633
  */
569
- const brandThemeVariables = (brand) => {
570
- const defaults = brandForegroundDefaults(brand);
634
+ const foregroundDefaults = (light, dark) => {
635
+ const defaults = (colors) => Object.fromEntries(FILL_COLORS.flatMap((fill) => {
636
+ const color = colors[`--color-${fill}`];
637
+ const foreground = `--color-${fill}-foreground`;
638
+ const picked = color === void 0 || colors[foreground] !== void 0 ? void 0 : contrastForeground(color);
639
+ return picked === void 0 ? [] : [[foreground, picked]];
640
+ }));
571
641
  return {
572
- ...brand.variables,
573
- ...defaults.light === void 0 ? {} : { [BRAND_FOREGROUND_VARIABLE]: defaults.light },
574
- ...Object.fromEntries(Object.entries(brand.dark).map(([property, value]) => [darkVariableName(property), value])),
575
- ...defaults.dark === void 0 ? {} : { [darkVariableName(BRAND_FOREGROUND_VARIABLE)]: defaults.dark }
642
+ light: defaults(light),
643
+ dark: defaults(dark)
576
644
  };
577
645
  };
578
- /** The stylesheet Tailwind compiles for the theme's brand import: `brandThemeVariables` and the font faces. */
579
- const brandTailwindCss = (brand) => printBrandCss({
580
- fontFaces: brand.fontFaces,
581
- variables: brandThemeVariables(brand),
582
- dark: {}
583
- });
584
646
  //#endregion
585
- export { brandThemeVariables as a, parseBrandCss as c, formatColor as d, parseColor as f, brandTailwindCss as i, printBrandCss as l, BRAND_FOREGROUND_VARIABLE as n, contrastForeground as o, brandForegroundDefaults as r, darkVariableName as s, BRAND_COLOR_VARIABLE as t, downlevelColors as u };
647
+ export { THEME_COLOR_ROLES as a, foregroundDefaults as c, downlevelColors as d, formatColor as f, THEME_COLORS as i, parseBrandCss as l, DEFAULT_THEME_CSS as n, THEME_TOKENS as o, parseColor as p, FILL_COLORS as r, contrastForeground as s, DEFAULT_THEME as t, printBrandCss as u };