@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,6 +1,7 @@
1
1
  import { n as isObject } from "./guards-eV1qLbf2.js";
2
- import { a as kebab, n as declarationValue, r as escapeAttribute } from "./html-eOZfDwVf.js";
2
+ import { a as kebab, n as declarationValue, r as escapeAttribute } from "./html-BvRFepeO.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 };