@samva/markup 0.13.1 → 0.14.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,6 +1,7 @@
1
1
  import { n as isObject } from "./guards-eV1qLbf2.js";
2
2
  import { a as kebab, n as declarationValue, r as escapeAttribute } from "./html-eOZfDwVf.js";
3
3
  import { t as EmailNode } from "./jsx-runtime-CBlexoeR.js";
4
+ import { i as withinDarkScheme, t as DARK_SCHEME_MEDIA } from "./dark-scheme-CbCwGDWH.js";
4
5
  //#region src/email/font-stacks.ts
5
6
  /** Generic families every email client understands as the last resort in a stack. */
6
7
  const GENERIC_FAMILIES = /* @__PURE__ */ new Set([
@@ -90,6 +91,73 @@ const fontFallbackDiagnostic = (value) => ({
90
91
  message: `font-family ${JSON.stringify(value)} uses a project font without a generic fallback. Clients that drop webfonts (Gmail, classic Outlook) render this stack as written, so end it with sans-serif, serif or monospace.`
91
92
  });
92
93
  //#endregion
94
+ //#region src/email/selectors.ts
95
+ const classListOf = (element) => {
96
+ const value = element.props.class ?? element.props.className;
97
+ return typeof value === "string" ? value.split(/\s+/).filter((name) => name !== "") : [];
98
+ };
99
+ const attributeOf = (element, name) => {
100
+ const value = element.props[name];
101
+ if (value === void 0 || value === null || value === false) return void 0;
102
+ if (value === true) return "";
103
+ return typeof value === "string" || typeof value === "number" ? String(value) : void 0;
104
+ };
105
+ const matchesAttribute = (element, test) => {
106
+ const actual = test.name === "class" ? classListOf(element).join(" ") : attributeOf(element, test.name);
107
+ if (actual === void 0) return false;
108
+ if (test.operator === void 0 || test.value === void 0) return true;
109
+ switch (test.operator) {
110
+ case "=": return actual === test.value;
111
+ case "~=": return actual.split(/\s+/).includes(test.value);
112
+ case "^=": return actual.startsWith(test.value);
113
+ case "$=": return actual.endsWith(test.value);
114
+ case "*=": return actual.includes(test.value);
115
+ case "|=": return actual === test.value || actual.startsWith(`${test.value}-`);
116
+ default: return false;
117
+ }
118
+ };
119
+ const matchesCompound = (element, compound) => {
120
+ if (compound.opaque) return false;
121
+ if (compound.tag !== void 0 && compound.tag !== element.tag) return false;
122
+ if (compound.id !== void 0 && attributeOf(element, "id") !== compound.id) return false;
123
+ if (compound.classes.length > 0) {
124
+ const classes = classListOf(element);
125
+ if (!compound.classes.every((name) => classes.includes(name))) return false;
126
+ }
127
+ return compound.attributes.every((test) => matchesAttribute(element, test));
128
+ };
129
+ const matchesFrom = (selector, index, ancestry) => {
130
+ if (ancestry === void 0) return false;
131
+ const part = selector.parts[index];
132
+ if (part === void 0) return false;
133
+ if (!matchesCompound(ancestry.element, part.compound)) return false;
134
+ const left = selector.parts[index - 1];
135
+ if (left === void 0) return true;
136
+ switch (left.after ?? "descendant") {
137
+ case "child": return matchesFrom(selector, index - 1, ancestry.parent);
138
+ case "descendant":
139
+ for (let parent = ancestry.parent; parent !== void 0; parent = parent.parent) if (matchesFrom(selector, index - 1, parent)) return true;
140
+ return false;
141
+ case "adjacent": {
142
+ const sibling = ancestry.previous.at(-1);
143
+ return sibling === void 0 ? false : matchesFrom(selector, index - 1, {
144
+ element: sibling,
145
+ parent: ancestry.parent,
146
+ previous: ancestry.previous.slice(0, -1)
147
+ });
148
+ }
149
+ case "sibling":
150
+ for (let position = ancestry.previous.length - 1; position >= 0; position--) if (matchesFrom(selector, index - 1, {
151
+ element: ancestry.previous[position],
152
+ parent: ancestry.parent,
153
+ previous: ancestry.previous.slice(0, position)
154
+ })) return true;
155
+ return false;
156
+ }
157
+ };
158
+ /** Match a compiled selector against one element in its tree position. */
159
+ const matchesSelector = (selector, ancestry) => selector === void 0 || selector.parts.length === 0 ? false : matchesFrom(selector, selector.parts.length - 1, ancestry);
160
+ //#endregion
93
161
  //#region src/email/css-values.ts
94
162
  const MAX_RESOLVED_VALUE_LENGTH = 65536;
95
163
  const MAX_RESOLUTION_WORK = 262144;
@@ -253,8 +321,12 @@ const downlevelMediaQuery = (condition) => condition.replace(RANGE_FEATURE, (who
253
321
  return `(${comparison.startsWith(">") ? "min-" : "max-"}${feature}:${bound})`;
254
322
  });
255
323
  /** Render the rules that could not be inlined as one `<style>` body. */
256
- const renderHeadCss = (rules, headAtRules, variables) => {
324
+ const renderHeadCss = (rules, headAtRules, variables, darkVariables = {}) => {
257
325
  const blocks = [...headAtRules];
326
+ const darkScope = {
327
+ ...variables,
328
+ ...darkVariables
329
+ };
258
330
  const grouped = /* @__PURE__ */ new Map();
259
331
  for (const rule of rules) {
260
332
  const conditions = rule.conditions.map(downlevelMediaQuery);
@@ -267,8 +339,9 @@ const renderHeadCss = (rules, headAtRules, variables) => {
267
339
  else bucket.rules.push(rule);
268
340
  }
269
341
  for (const { conditions, rules: bucket } of grouped.values()) {
342
+ const scope = withinDarkScheme(conditions) ? darkScope : variables;
270
343
  const body = bucket.map((rule) => {
271
- const declarations = rule.declarations.filter((declaration) => !declaration.property.startsWith("--")).map((declaration) => `${declaration.property}:${finalizeValue(declaration.value, variables)}!important`).join(";");
344
+ const declarations = rule.declarations.filter((declaration) => !declaration.property.startsWith("--")).map((declaration) => `${declaration.property}:${finalizeValue(declaration.value, scope)}!important`).join(";");
272
345
  return declarations === "" ? "" : `${rule.selector}{${declarations}}`;
273
346
  }).filter((block) => block !== "").join("");
274
347
  if (body === "") continue;
@@ -277,73 +350,6 @@ const renderHeadCss = (rules, headAtRules, variables) => {
277
350
  return blocks.join("");
278
351
  };
279
352
  //#endregion
280
- //#region src/email/selectors.ts
281
- const classListOf = (element) => {
282
- const value = element.props.class ?? element.props.className;
283
- return typeof value === "string" ? value.split(/\s+/).filter((name) => name !== "") : [];
284
- };
285
- const attributeOf = (element, name) => {
286
- const value = element.props[name];
287
- if (value === void 0 || value === null || value === false) return void 0;
288
- if (value === true) return "";
289
- return typeof value === "string" || typeof value === "number" ? String(value) : void 0;
290
- };
291
- const matchesAttribute = (element, test) => {
292
- const actual = test.name === "class" ? classListOf(element).join(" ") : attributeOf(element, test.name);
293
- if (actual === void 0) return false;
294
- if (test.operator === void 0 || test.value === void 0) return true;
295
- switch (test.operator) {
296
- case "=": return actual === test.value;
297
- case "~=": return actual.split(/\s+/).includes(test.value);
298
- case "^=": return actual.startsWith(test.value);
299
- case "$=": return actual.endsWith(test.value);
300
- case "*=": return actual.includes(test.value);
301
- case "|=": return actual === test.value || actual.startsWith(`${test.value}-`);
302
- default: return false;
303
- }
304
- };
305
- const matchesCompound = (element, compound) => {
306
- if (compound.opaque) return false;
307
- if (compound.tag !== void 0 && compound.tag !== element.tag) return false;
308
- if (compound.id !== void 0 && attributeOf(element, "id") !== compound.id) return false;
309
- if (compound.classes.length > 0) {
310
- const classes = classListOf(element);
311
- if (!compound.classes.every((name) => classes.includes(name))) return false;
312
- }
313
- return compound.attributes.every((test) => matchesAttribute(element, test));
314
- };
315
- const matchesFrom = (selector, index, ancestry) => {
316
- if (ancestry === void 0) return false;
317
- const part = selector.parts[index];
318
- if (part === void 0) return false;
319
- if (!matchesCompound(ancestry.element, part.compound)) return false;
320
- const left = selector.parts[index - 1];
321
- if (left === void 0) return true;
322
- switch (left.after ?? "descendant") {
323
- case "child": return matchesFrom(selector, index - 1, ancestry.parent);
324
- case "descendant":
325
- for (let parent = ancestry.parent; parent !== void 0; parent = parent.parent) if (matchesFrom(selector, index - 1, parent)) return true;
326
- return false;
327
- case "adjacent": {
328
- const sibling = ancestry.previous.at(-1);
329
- return sibling === void 0 ? false : matchesFrom(selector, index - 1, {
330
- element: sibling,
331
- parent: ancestry.parent,
332
- previous: ancestry.previous.slice(0, -1)
333
- });
334
- }
335
- case "sibling":
336
- for (let position = ancestry.previous.length - 1; position >= 0; position--) if (matchesFrom(selector, index - 1, {
337
- element: ancestry.previous[position],
338
- parent: ancestry.parent,
339
- previous: ancestry.previous.slice(0, position)
340
- })) return true;
341
- return false;
342
- }
343
- };
344
- /** Match a compiled selector against one element in its tree position. */
345
- const matchesSelector = (selector, ancestry) => selector === void 0 || selector.parts.length === 0 ? false : matchesFrom(selector, selector.parts.length - 1, ancestry);
346
- //#endregion
347
353
  //#region src/email/cascade.ts
348
354
  const VARIABLE_REFERENCE = /var\(\s*--[\w-]+\s*(?:,[^()]*)?\)/g;
349
355
  /**
@@ -434,12 +440,98 @@ const physicalPadding = (property, value, context) => {
434
440
  if (match[2] !== void 0) return [[`padding-${sides[match[2] === "start" ? 0 : 1]}`, values[0]]];
435
441
  return [[`padding-${sides[0]}`, values[0]], [`padding-${sides[1]}`, values[1] ?? values[0]]];
436
442
  };
443
+ /** Generated dark-mode classes share this prefix; it is reserved for the compiler. */
444
+ const DARK_CLASS_PREFIX = "sml-dark-";
445
+ /** A class named for its declarations, so equal sets share one rule and builds are stable. */
446
+ const darkClassName = (body) => {
447
+ let hash = 2166136261;
448
+ for (let index = 0; index < body.length; index++) {
449
+ hash ^= body.charCodeAt(index);
450
+ hash = Math.imul(hash, 16777619) >>> 0;
451
+ }
452
+ return `${DARK_CLASS_PREFIX}${hash.toString(36)}`;
453
+ };
454
+ const SIDES = [
455
+ "top",
456
+ "right",
457
+ "bottom",
458
+ "left"
459
+ ];
460
+ const BORDER_PARTS = [
461
+ "width",
462
+ "style",
463
+ "color"
464
+ ];
465
+ const CORNERS = [
466
+ "top-left",
467
+ "top-right",
468
+ "bottom-right",
469
+ "bottom-left"
470
+ ];
471
+ const BORDER_IMAGE = [
472
+ "source",
473
+ "slice",
474
+ "width",
475
+ "outset",
476
+ "repeat"
477
+ ].map((part) => `border-image-${part}`);
478
+ /**
479
+ * The longhands a border property writes: `border` writes the twelve per-side longhands and
480
+ * resets the border image, `border-top` the top three, `border-color` the four colors,
481
+ * `border-radius` the four corners.
482
+ * `undefined` for a property outside the border box.
483
+ */
484
+ const borderLonghands = (property) => {
485
+ if (property === "border") return [...SIDES.flatMap((side) => BORDER_PARTS.map((part) => `border-${side}-${part}`)), ...BORDER_IMAGE];
486
+ if (property === "border-image") return BORDER_IMAGE;
487
+ if (property === "border-radius") return CORNERS.map((corner) => `border-${corner}-radius`);
488
+ const side = /^border-(top|right|bottom|left)$/.exec(property)?.[1];
489
+ if (side !== void 0) return BORDER_PARTS.map((part) => `border-${side}-${part}`);
490
+ const part = /^border-(width|style|color)$/.exec(property)?.[1];
491
+ if (part !== void 0) return SIDES.map((each) => `border-${each}-${part}`);
492
+ return property.startsWith("border-") ? [property] : void 0;
493
+ };
494
+ /**
495
+ * Whether two declarations can override one another because they write a property in common:
496
+ * `background` and `background-color`, `border-color` and `border-top`, `font` and `line-height`.
497
+ * `border-radius` shares nothing with `border-color`.
498
+ */
499
+ const overlaps = (left, right) => {
500
+ if (left === right) return true;
501
+ const leftBorder = borderLonghands(left);
502
+ const rightBorder = borderLonghands(right);
503
+ if (leftBorder !== void 0 || rightBorder !== void 0) return leftBorder !== void 0 && rightBorder !== void 0 && leftBorder.some((longhand) => rightBorder.includes(longhand));
504
+ const sets = (shorthand, longhand) => longhand.startsWith(`${shorthand}-`) || shorthand === "font" && longhand === "line-height";
505
+ return sets(left, right) || sets(right, left);
506
+ };
507
+ /** Add a class to an element's props, whichever of `class` and `className` it uses. */
508
+ const withClass = (props, name) => {
509
+ const key = typeof props.class === "string" || props.className === void 0 ? "class" : "className";
510
+ const current = props[key];
511
+ return {
512
+ ...props,
513
+ [key]: typeof current === "string" && current.trim() !== "" ? `${current} ${name}` : name
514
+ };
515
+ };
516
+ /** The generated dark-mode classes as one `@media (prefers-color-scheme:dark)` block, written as the head CSS writes its media queries. */
517
+ const renderDarkRules = (rules) => rules.size === 0 ? "" : `${DARK_SCHEME_MEDIA.replace(": ", ":")}{${[...rules].sort(([left], [right]) => left.localeCompare(right)).map(([name, body]) => `.${name}{${body}}`).join("")}}`;
437
518
  /**
438
519
  * Resolve every stylesheet against the tree: inline what a client will honor on
439
- * the element, and keep the rest for `<head>`.
520
+ * the element, and keep the rest for `<head>`. Where the sheet has a dark-mode
521
+ * theme, an element whose inlined styles read a color it redefines also gets a
522
+ * generated class whose dark-mode rule restates those styles at their dark
523
+ * values.
440
524
  */
441
525
  const applyStylesheet = (root, sheet, options = {}) => {
442
526
  const diagnostics = [...sheet.diagnostics];
527
+ const hasDarkTheme = Object.keys(sheet.darkVariables).length > 0;
528
+ const darkRules = /* @__PURE__ */ new Map();
529
+ const darkClassFor = (declarations) => {
530
+ const body = declarations.map(([property, value]) => `${property}:${value}!important`).join(";");
531
+ const name = darkClassName(body);
532
+ darkRules.set(name, body);
533
+ return name;
534
+ };
443
535
  const inlinable = [];
444
536
  const retained = [];
445
537
  const styledClasses = /* @__PURE__ */ new Set();
@@ -462,7 +554,7 @@ const applyStylesheet = (root, sheet, options = {}) => {
462
554
  * A fragment is not an element in the serialized document, so its children
463
555
  * join that same list rather than starting a new one.
464
556
  */
465
- const visitChildren = (children, ancestry, variables, writing) => {
557
+ const visitChildren = (children, ancestry, variables, darkScope, writing) => {
466
558
  const previous = [];
467
559
  const visit = (nodes) => nodes.map((child) => {
468
560
  if (child.type === "Element") {
@@ -470,7 +562,7 @@ const applyStylesheet = (root, sheet, options = {}) => {
470
562
  element: child,
471
563
  parent: ancestry,
472
564
  previous: [...previous]
473
- }, variables, writing);
565
+ }, variables, darkScope, writing);
474
566
  previous.push(child);
475
567
  return result;
476
568
  }
@@ -489,7 +581,7 @@ const applyStylesheet = (root, sheet, options = {}) => {
489
581
  * cascade like any other declaration and then inherit, so a `--brand` declared
490
582
  * on `.card` has to reach `var(--brand)` on `.card` and everything under it.
491
583
  */
492
- const applyTo = (element, ancestry, inherited, inheritedWriting) => {
584
+ const applyTo = (element, ancestry, inherited, inheritedDark, inheritedWriting) => {
493
585
  const conditionalWriting = conditionalWritingRules.some((rule) => {
494
586
  if (rule.matcher === void 0 || rule.matcher.parts.length === 0 || rule.matcher.parts.some((part) => part.compound.opaque)) return true;
495
587
  for (let context = ancestry; context !== void 0; context = context.parent) if (matchesSelector(rule.matcher, context)) return true;
@@ -519,6 +611,10 @@ const applyStylesheet = (root, sheet, options = {}) => {
519
611
  ...inherited,
520
612
  ...declared
521
613
  };
614
+ const darkScope = Object.keys(declared).length === 0 ? inheritedDark : {
615
+ ...inheritedDark,
616
+ ...declared
617
+ };
522
618
  const inheritedValue = (property, parent, initial, fallback = parent) => {
523
619
  const value = winners.get(property)?.value;
524
620
  if (value === void 0) return fallback;
@@ -537,24 +633,51 @@ const applyStylesheet = (root, sheet, options = {}) => {
537
633
  physical.set(name, lowered);
538
634
  }
539
635
  const bgcolor = element.props.bgcolor;
540
- const props = typeof bgcolor === "string" && bgcolor.includes("var(") ? {
636
+ const lightProps = typeof bgcolor === "string" && bgcolor.includes("var(") ? {
541
637
  ...element.props,
542
638
  bgcolor: finalizeValue(bgcolor, variables)
543
639
  } : element.props;
640
+ const dark = [];
641
+ if (hasDarkTheme) {
642
+ const changed = [];
643
+ for (const [property, entry] of winners) {
644
+ if (property.startsWith("--") || !entry.value.includes("var(")) continue;
645
+ if (finalizeValue(entry.value, darkScope) !== finalizeValue(entry.value, variables)) changed.push(property);
646
+ }
647
+ const restated = new Set(changed);
648
+ for (let grew = changed.length > 0; grew;) {
649
+ grew = false;
650
+ for (const property of winners.keys()) if (!property.startsWith("--") && !restated.has(property) && [...restated].some((candidate) => overlaps(candidate, property))) {
651
+ restated.add(property);
652
+ grew = true;
653
+ }
654
+ }
655
+ for (const [property, entry] of [...winners].sort(([, left], [, right]) => compareRank(left.key, right.key))) if (restated.has(property)) dark.push([property, finalizeValue(entry.value, darkScope)]);
656
+ if (typeof bgcolor === "string" && bgcolor.includes("var(") && !winners.has("background-color") && !winners.has("background")) {
657
+ const value = finalizeValue(bgcolor, darkScope);
658
+ if (value !== finalizeValue(bgcolor, variables)) dark.push(["background-color", value]);
659
+ }
660
+ }
661
+ const darkClass = dark.length === 0 ? void 0 : darkClassFor(dark);
662
+ const props = darkClass === void 0 ? lightProps : withClass(lightProps, darkClass);
544
663
  return EmailNode.Element({
545
664
  ...element,
546
665
  props: emitted.length === 0 ? props : {
547
666
  ...props,
548
667
  style: Object.fromEntries(physical)
549
668
  },
550
- children: visitChildren(element.children, ancestry, variables, writing)
669
+ children: visitChildren(element.children, ancestry, variables, darkScope, writing)
551
670
  });
552
671
  };
672
+ const rootDark = {
673
+ ...sheet.variables,
674
+ ...sheet.darkVariables
675
+ };
553
676
  const tree = root.type === "Element" ? applyTo(root, {
554
677
  element: root,
555
678
  parent: void 0,
556
679
  previous: []
557
- }, sheet.variables, horizontalWriting) : root.type === "Fragment" ? EmailNode.Fragment({ children: visitChildren(root.children, void 0, sheet.variables, horizontalWriting) }) : root;
680
+ }, sheet.variables, rootDark, horizontalWriting) : root.type === "Fragment" ? EmailNode.Fragment({ children: visitChildren(root.children, void 0, sheet.variables, rootDark, horizontalWriting) }) : root;
558
681
  for (const rule of retained) for (const declaration of rule.declarations) checkFallback(declaration.property, finalizeValue(declaration.value, sheet.variables), []);
559
682
  for (const [value, origins] of fallbackFindings) diagnostics.push({
560
683
  ...fontFallbackDiagnostic(value),
@@ -568,7 +691,8 @@ const applyStylesheet = (root, sheet, options = {}) => {
568
691
  });
569
692
  return {
570
693
  tree,
571
- headCss: renderHeadCss(retained, sheet.headAtRules, sheet.variables),
694
+ headCss: renderHeadCss(retained, sheet.headAtRules, sheet.variables, sheet.darkVariables),
695
+ darkRules,
572
696
  fontFaceCss: sheet.fontFaces.map((face) => face.css).join(""),
573
697
  diagnostics
574
698
  };
@@ -578,4 +702,4 @@ const applyStylesheet = (root, sheet, options = {}) => {
578
702
  /** The IR version this package writes. A publication pins the version it was built with. */
579
703
  const SML_IR_VERSION = 1;
580
704
  //#endregion
581
- export { downlevelMediaQuery as a, renderHeadCss as c, matchesSelector as i, resolveVariables as l, applyStylesheet as n, finalizeValue as o, classListOf as r, foldCalc as s, SML_IR_VERSION as t, unquote as u };
705
+ export { downlevelMediaQuery as a, renderHeadCss as c, matchesSelector as d, unquote as f, renderDarkRules as i, resolveVariables as l, DARK_CLASS_PREFIX as n, finalizeValue as o, applyStylesheet as r, foldCalc as s, SML_IR_VERSION as t, classListOf as u };
@@ -4,10 +4,10 @@ import { n as TEMPLATE_NAME_PATTERN, t as TEMPLATE_ID_PATTERN } from "./template
4
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";
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-CwWSoG5N.js";
8
+ import { i as renderDarkRules, n as DARK_CLASS_PREFIX, r as applyStylesheet } from "./ir-D-gTGquU.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-B-t62NJB.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} …>.");
@@ -2951,6 +2973,35 @@ const mapTree = (tree, leafStyle, counter = { next: 0 }) => {
2951
2973
  else: otherwise
2952
2974
  };
2953
2975
  };
2976
+ /** A class tree as the IR value it was read from: a literal, or a choice between literals. */
2977
+ const treeValue = (tree) => "leaf" in tree ? tree.leaf : {
2978
+ if: tree.pred,
2979
+ then: treeValue(tree.then),
2980
+ else: treeValue(tree.else)
2981
+ };
2982
+ const appendClass = (classes, name) => name === void 0 ? classes : classes.trim() === "" ? name : `${classes} ${name}`;
2983
+ /**
2984
+ * An element's class with the dark-mode class each pass gave it. A choice between literal class
2985
+ * lists resolved one list per pass, so each list gains its own pass's class; any other class
2986
+ * value gains the first pass's. `undefined` when no pass gave the element one.
2987
+ */
2988
+ const withDarkClass = (value, tree, perPass) => {
2989
+ if (perPass.every((name) => name === void 0)) return void 0;
2990
+ if (tree !== void 0 && leavesOf(tree).length > 1) {
2991
+ let leaf = 0;
2992
+ const extend = (node) => "leaf" in node ? { leaf: appendClass(node.leaf, perPass[Math.min(leaf++, perPass.length - 1)]) } : {
2993
+ pred: node.pred,
2994
+ then: extend(node.then),
2995
+ else: extend(node.else)
2996
+ };
2997
+ return treeValue(extend(tree));
2998
+ }
2999
+ const name = perPass[0];
3000
+ if (name === void 0) return value;
3001
+ if (value === void 0) return name;
3002
+ if (typeof value === "string") return appendClass(value, name);
3003
+ return { concat: [value, ` ${name}`] };
3004
+ };
2954
3005
  const collect = (nodes, into) => {
2955
3006
  for (const node of nodes) {
2956
3007
  if (isValueNode(node)) continue;
@@ -3057,6 +3108,8 @@ const applyStyles = (root, sheet, options) => {
3057
3108
  for (const node of nodes) if (node.type === "Fragment") read(node.children, into);
3058
3109
  else if (node.type === "Raw") into.raws.push(node.html);
3059
3110
  else if (node.type === "Element") {
3111
+ const classes = typeof node.props.class === "string" ? node.props.class.split(" ") : [];
3112
+ into.darkClasses.push(classes.find((name) => name.startsWith(DARK_CLASS_PREFIX)));
3060
3113
  into.styles.push(node.props.style === void 0 ? void 0 : node.props.style);
3061
3114
  into.bgcolors.push(typeof node.props.bgcolor === "string" ? node.props.bgcolor : void 0);
3062
3115
  read(node.children, into);
@@ -3065,6 +3118,7 @@ const applyStyles = (root, sheet, options) => {
3065
3118
  const results = [];
3066
3119
  let headCss = "";
3067
3120
  let fontFaceCss = "";
3121
+ const darkRules = /* @__PURE__ */ new Map();
3068
3122
  const seen = /* @__PURE__ */ new Set();
3069
3123
  for (let pass = 0; pass < passes; pass++) {
3070
3124
  const tree = shadow([root], pass, { next: 0 });
@@ -3073,6 +3127,7 @@ const applyStyles = (root, sheet, options) => {
3073
3127
  headCss = applied.headCss;
3074
3128
  fontFaceCss = applied.fontFaceCss;
3075
3129
  }
3130
+ for (const [name, body] of applied.darkRules) darkRules.set(name, body);
3076
3131
  for (const diagnostic of applied.diagnostics) {
3077
3132
  const key = `${diagnostic.code}\0${diagnostic.message}`;
3078
3133
  if (seen.has(key)) continue;
@@ -3082,6 +3137,7 @@ const applyStyles = (root, sheet, options) => {
3082
3137
  const result = {
3083
3138
  styles: [],
3084
3139
  bgcolors: [],
3140
+ darkClasses: [],
3085
3141
  raws: []
3086
3142
  };
3087
3143
  read([applied.tree], result);
@@ -3114,10 +3170,15 @@ const applyStyles = (root, sheet, options) => {
3114
3170
  const baseStyle = first?.styles[ordinal];
3115
3171
  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
3172
  const bgcolor = first?.bgcolors[ordinal];
3117
- const attrs = typeof node.attrs?.bgcolor === "string" && bgcolor !== void 0 && bgcolor !== node.attrs.bgcolor ? {
3173
+ const painted = typeof node.attrs?.bgcolor === "string" && bgcolor !== void 0 && bgcolor !== node.attrs.bgcolor ? {
3118
3174
  ...node.attrs,
3119
3175
  bgcolor: tokens.toValue(bgcolor) ?? bgcolor
3120
3176
  } : node.attrs;
3177
+ const darkClass = withDarkClass(node.attrs?.class, tree, results.map((result) => result.darkClasses[ordinal]));
3178
+ const attrs = darkClass === void 0 ? painted : {
3179
+ ...painted,
3180
+ class: darkClass
3181
+ };
3121
3182
  const kids = rebuild(node.children ?? []);
3122
3183
  const { style: _authored, children: _children, attrs: _attrs, ...rest } = node;
3123
3184
  return {
@@ -3152,7 +3213,7 @@ const applyStyles = (root, sheet, options) => {
3152
3213
  });
3153
3214
  return {
3154
3215
  root: rebuild([root])[0] ?? root,
3155
- headCss,
3216
+ headCss: `${renderDarkRules(darkRules)}${headCss}`,
3156
3217
  fontFaceCss
3157
3218
  };
3158
3219
  };
@@ -1,7 +1,7 @@
1
1
  import { S as emailElement, o as serializeEmailHtml } from "./html-eOZfDwVf.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-CwWSoG5N.js";
4
+ import { i as renderDarkRules, r as applyStylesheet } from "./ir-D-gTGquU.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",
@@ -441,6 +441,11 @@ interface Stylesheet {
441
441
  readonly fontFaces: readonly FontFaceRule[];
442
442
  /** Unconditional custom properties, for resolving `var()` before inlining. */
443
443
  readonly variables: Readonly<Record<string, string>>;
444
+ /**
445
+ * The custom properties the dark-mode query redefines (a theme's dark-mode
446
+ * block). A declaration that reads one switches in dark mode.
447
+ */
448
+ readonly darkVariables: Readonly<Record<string, string>>;
444
449
  readonly diagnostics: readonly EmailDiagnostic[];
445
450
  }
446
451
  /** Compile a selector into the plain-data matcher the render lane uses. */
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-C1X3S5or.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
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";
2
+ import { t as SML_IR_VERSION } from "./ir-D-gTGquU.js";
3
+ import { i as deriveEmailText, n as compileEmail, r as renderEmailContent, t as EMAIL_DOCTYPE } from "./render-6gLei61p.js";
4
4
  //#region src/sms-segments.ts
5
5
  const GSM7_EXTENDED = /* @__PURE__ */ new Set([
6
6
  "[",