@samva/markup 0.13.0 → 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.
- package/AGENTS.md +11 -1
- package/CHANGELOG.md +50 -0
- package/dist/{assets-DCULM6QD.d.ts → assets-tISHaQNF.d.ts} +1 -1
- package/dist/{brand-C45QBKX9.js → brand-B-t62NJB.js} +4 -4
- package/dist/brand.d.ts +48 -25
- package/dist/brand.js +3 -3
- package/dist/compiler.d.ts +23 -6
- package/dist/compiler.js +657 -199
- package/dist/components-CwWSoG5N.js +401 -0
- package/dist/dark-scheme-CbCwGDWH.js +12 -0
- package/dist/{diagnostic-codes-DdZ4tka5.js → diagnostic-codes-UXwOO3A-.js} +22 -0
- package/dist/diagnostics.d.ts +17 -0
- package/dist/diagnostics.js +1 -1
- package/dist/edit.js +2 -2
- package/dist/email.d.ts +52 -9
- package/dist/email.js +1 -1
- package/dist/{ir-C-ubz6N3.js → ir-D-gTGquU.js} +202 -78
- package/dist/{profile-CXnkfEMi.js → profile-Cc8VxOKA.js} +72 -11
- package/dist/{render-Cja448i0.js → render-6gLei61p.js} +7 -5
- package/dist/{render-C6OTH8BQ.d.ts → render-C1X3S5or.d.ts} +5 -0
- package/dist/render.d.ts +1 -1
- package/dist/render.js +2 -2
- package/dist/{theme-css-CEUrDpqq.js → theme-css-s9QwD5Ko.js} +108 -46
- package/generated/contract.json +176 -2
- package/generated/reference.md +165 -118
- package/package.json +1 -1
- package/skills/sml/SKILL.md +63 -5
- package/dist/components-DycSEKva.js +0 -273
package/dist/compiler.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { n as isObject } from "./guards-eV1qLbf2.js";
|
|
2
2
|
import { n as TEMPLATE_NAME_PATTERN } from "./template-zLUjcwfF.js";
|
|
3
3
|
import { D as samvaBrandPlugin, T as parseBrandSpecifier, _ as renderIr, w as brandSpecifierPattern } from "./html-eOZfDwVf.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { a as
|
|
8
|
-
import {
|
|
4
|
+
import { m as PRIMITIVE_CSS } from "./components-CwWSoG5N.js";
|
|
5
|
+
import { c as foregroundDefaults, d as downlevelColors, f as formatColor, l as parseBrandCss, n as DEFAULT_THEME_CSS, p as parseColor, u as printBrandCss } from "./theme-css-s9QwD5Ko.js";
|
|
6
|
+
import { n as isDarkSchemeCondition, r as isDarkSchemeQuery, t as DARK_SCHEME_MEDIA } from "./dark-scheme-CbCwGDWH.js";
|
|
7
|
+
import { a as downlevelMediaQuery, c as renderHeadCss, d as matchesSelector, f as unquote, i as renderDarkRules, l as resolveVariables, o as finalizeValue, r as applyStylesheet, s as foldCalc, u as classListOf } from "./ir-D-gTGquU.js";
|
|
8
|
+
import { i as buildLineMap, s as lineColumn } from "./diagnostic-codes-UXwOO3A-.js";
|
|
9
|
+
import { a as lowerProject, d as resolveFromDirectory, f as resolveThemeImports, i as completed, l as Findings, n as introducedProfileErrors, r as loadProject, t as checkStaticProfile, u as projectDirectory } from "./profile-Cc8VxOKA.js";
|
|
10
|
+
import { c as checkAssetBase, i as withoutUnsubscribeRow, l as isAssetPath, n as brandComponents, o as assetEntry, s as assetUrl, t as BRAND_COMPONENT_CLASSES } from "./brand-B-t62NJB.js";
|
|
9
11
|
import { t as EmailCompileError } from "./diagnostics-C0Tr8-eQ.js";
|
|
10
|
-
import { n as compileEmail, r as renderEmailContent } from "./render-
|
|
12
|
+
import { n as compileEmail, r as renderEmailContent } from "./render-6gLei61p.js";
|
|
11
13
|
import { caniemail, parseHtml, rawData } from "caniemail";
|
|
12
14
|
import { generate, ident, parse, walk } from "css-tree";
|
|
13
15
|
import { parse as parse$1 } from "@babel/parser";
|
|
@@ -224,173 +226,6 @@ const compatibilityContext = (html, css) => {
|
|
|
224
226
|
};
|
|
225
227
|
};
|
|
226
228
|
//#endregion
|
|
227
|
-
//#region src/email/compatibility.ts
|
|
228
|
-
/**
|
|
229
|
-
* The pinned matrix. Every finding is reported per client, and a client absent
|
|
230
|
-
* from this list is a client Samva makes no claim about.
|
|
231
|
-
*/
|
|
232
|
-
const EMAIL_CLIENT_MATRIX = [
|
|
233
|
-
{
|
|
234
|
-
id: "gmail.desktop-webmail",
|
|
235
|
-
label: "Gmail web"
|
|
236
|
-
},
|
|
237
|
-
{
|
|
238
|
-
id: "gmail.ios",
|
|
239
|
-
label: "Gmail iOS"
|
|
240
|
-
},
|
|
241
|
-
{
|
|
242
|
-
id: "gmail.android",
|
|
243
|
-
label: "Gmail Android"
|
|
244
|
-
},
|
|
245
|
-
{
|
|
246
|
-
id: "apple-mail.macos",
|
|
247
|
-
label: "Apple Mail macOS"
|
|
248
|
-
},
|
|
249
|
-
{
|
|
250
|
-
id: "apple-mail.ios",
|
|
251
|
-
label: "Apple Mail iOS"
|
|
252
|
-
},
|
|
253
|
-
{
|
|
254
|
-
id: "yahoo.desktop-webmail",
|
|
255
|
-
label: "Yahoo web"
|
|
256
|
-
},
|
|
257
|
-
{
|
|
258
|
-
id: "outlook.outlook-com",
|
|
259
|
-
label: "Outlook web and new Windows"
|
|
260
|
-
},
|
|
261
|
-
{
|
|
262
|
-
id: "outlook.windows",
|
|
263
|
-
label: "Outlook classic Windows"
|
|
264
|
-
}
|
|
265
|
-
];
|
|
266
|
-
const LABELS = new Map(EMAIL_CLIENT_MATRIX.map((target) => [target.id, target.label]));
|
|
267
|
-
const COMPATIBILITY_PROVENANCE = {
|
|
268
|
-
rules: "caniemail@2.0.2",
|
|
269
|
-
apiVersion: rawData.api_version,
|
|
270
|
-
lastUpdate: rawData.last_update_date
|
|
271
|
-
};
|
|
272
|
-
/**
|
|
273
|
-
* The constructs whose loss actually breaks a message: the recipient reads
|
|
274
|
-
* something different, in a different order, follows a link that does not work,
|
|
275
|
-
* or sees a layout that has collapsed. A target client dropping one of these
|
|
276
|
-
* blocks publication.
|
|
277
|
-
*
|
|
278
|
-
* The list is deliberately short and enumerated, because the contract is to
|
|
279
|
-
* block *known* failures. Nonessential differences require an assessment of
|
|
280
|
-
* emitted values: a concrete risk warns, expected differences and unverified
|
|
281
|
-
* behavior remain compatibility observations.
|
|
282
|
-
*/
|
|
283
|
-
const ESSENTIAL_CONSTRUCTS = /* @__PURE__ */ new Set([
|
|
284
|
-
"<table> element",
|
|
285
|
-
"<div> element",
|
|
286
|
-
"<p> element",
|
|
287
|
-
"<span> element",
|
|
288
|
-
"<ul>, <ol> and <dl>",
|
|
289
|
-
"<h1> to <h6> elements",
|
|
290
|
-
"HTML5 doctype",
|
|
291
|
-
"width attribute",
|
|
292
|
-
"height attribute",
|
|
293
|
-
"align attribute",
|
|
294
|
-
"valign attribute",
|
|
295
|
-
"<img> element",
|
|
296
|
-
"display",
|
|
297
|
-
"mailto: links",
|
|
298
|
-
"Local anchors"
|
|
299
|
-
]);
|
|
300
|
-
const KNOWN_TITLES = new Set(rawData.data.map((feature) => feature.title));
|
|
301
|
-
/** The innermost recorded node containing an offset, so a finding names its element. */
|
|
302
|
-
const originsAt = (positions, offset) => {
|
|
303
|
-
let best;
|
|
304
|
-
for (const position of positions) {
|
|
305
|
-
if (offset < position.start || offset >= position.end) continue;
|
|
306
|
-
if (best === void 0 || position.end - position.start < best.end - best.start) best = position;
|
|
307
|
-
}
|
|
308
|
-
return best?.origins ?? [];
|
|
309
|
-
};
|
|
310
|
-
const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
311
|
-
/**
|
|
312
|
-
* Check the emitted document against the pinned matrix.
|
|
313
|
-
*
|
|
314
|
-
* Findings are grouped by construct rather than repeated per client, so one
|
|
315
|
-
* unsupported property is one diagnostic naming every client that drops it.
|
|
316
|
-
*/
|
|
317
|
-
const checkEmailCompatibility = (input) => {
|
|
318
|
-
const targets = input.clients ?? EMAIL_CLIENT_MATRIX;
|
|
319
|
-
const context = compatibilityContext(input.html, input.css);
|
|
320
|
-
const targetClients = targets.map((target) => target.id);
|
|
321
|
-
const reports = [caniemail({
|
|
322
|
-
clients: targetClients,
|
|
323
|
-
html: input.html
|
|
324
|
-
}), ...input.css === void 0 ? [] : [caniemail({
|
|
325
|
-
clients: targetClients,
|
|
326
|
-
css: input.css
|
|
327
|
-
})]];
|
|
328
|
-
const grouped = /* @__PURE__ */ new Map();
|
|
329
|
-
for (const bucket of reports.flatMap((report) => [report.issues.errors, report.issues.warnings])) for (const [client, issues] of bucket) {
|
|
330
|
-
const label = LABELS.get(client) ?? client;
|
|
331
|
-
for (const raw of issues) {
|
|
332
|
-
const hiddenContentFailure = client === "outlook.windows" && raw.support === "partial" && (raw.title === "display" || raw.title === "display:none") && context.hiddenContentOffsets.length > 0;
|
|
333
|
-
const blocking = hiddenContentFailure || raw.support !== "partial" && ESSENTIAL_CONSTRUCTS.has(raw.title);
|
|
334
|
-
const { explanation, ...compatibility } = assessCompatibility(raw.title, client, raw.support, context, blocking);
|
|
335
|
-
const key = JSON.stringify([
|
|
336
|
-
raw.title,
|
|
337
|
-
raw.support,
|
|
338
|
-
compatibility,
|
|
339
|
-
explanation
|
|
340
|
-
]);
|
|
341
|
-
const offsets = hiddenContentFailure ? context.hiddenContentOffsets : context.offsets(raw.title);
|
|
342
|
-
const entry = grouped.get(key);
|
|
343
|
-
if (entry === void 0) grouped.set(key, {
|
|
344
|
-
title: raw.title,
|
|
345
|
-
support: raw.support,
|
|
346
|
-
clients: /* @__PURE__ */ new Set([label]),
|
|
347
|
-
notes: new Set(raw.notes),
|
|
348
|
-
explanation,
|
|
349
|
-
compatibility,
|
|
350
|
-
blocking,
|
|
351
|
-
offsets
|
|
352
|
-
});
|
|
353
|
-
else {
|
|
354
|
-
entry.clients.add(label);
|
|
355
|
-
for (const note of raw.notes) entry.notes.add(note);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
const findings = [];
|
|
360
|
-
for (const entry of grouped.values()) {
|
|
361
|
-
const severity = entry.blocking ? "error" : entry.compatibility.assessment === "risk" ? "warning" : "info";
|
|
362
|
-
const occurrences = entry.offsets.map((offset) => originsAt(input.positions ?? [], offset)).filter((origins) => origins.length > 0);
|
|
363
|
-
const clients = [...entry.clients].sort();
|
|
364
|
-
const dropped = entry.support === "partial" ? clients.length === 1 ? "partially supports" : "partially support" : clients.length === 1 ? "does not support" : "do not support";
|
|
365
|
-
findings.push({
|
|
366
|
-
code: `caniemail/${slug(entry.title)}`,
|
|
367
|
-
severity,
|
|
368
|
-
message: `${clients.join(", ")} ${dropped} ${entry.title}. ${entry.explanation}`,
|
|
369
|
-
compatibility: entry.compatibility,
|
|
370
|
-
origins: occurrences[0] ?? [],
|
|
371
|
-
...occurrences.length === 0 ? {} : { occurrences },
|
|
372
|
-
...input.fixture === void 0 ? {} : { fixtures: [input.fixture] },
|
|
373
|
-
clients,
|
|
374
|
-
...entry.notes.size === 0 ? {} : { notes: [...entry.notes].join(" ") },
|
|
375
|
-
provenance: `${COMPATIBILITY_PROVENANCE.rules} (caniemail data ${COMPATIBILITY_PROVENANCE.apiVersion}, updated ${COMPATIBILITY_PROVENANCE.lastUpdate})`
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
return findings.sort((left, right) => left.code.localeCompare(right.code));
|
|
379
|
-
};
|
|
380
|
-
/**
|
|
381
|
-
* Constructs the pinned data says nothing about for a target client. These are
|
|
382
|
-
* reported so "we did not check that" is never mistaken for "that works".
|
|
383
|
-
*/
|
|
384
|
-
const unknownCoverage = (titles) => [...titles].filter((title) => !KNOWN_TITLES.has(title)).sort().map((title) => ({
|
|
385
|
-
code: "caniemail/unknown-coverage",
|
|
386
|
-
severity: "info",
|
|
387
|
-
message: `The pinned rules say nothing about ${title}, so no client claim is made about it. Verify it in a real client before relying on it.`,
|
|
388
|
-
origins: [],
|
|
389
|
-
provenance: `${COMPATIBILITY_PROVENANCE.rules} (caniemail data ${COMPATIBILITY_PROVENANCE.apiVersion}, updated ${COMPATIBILITY_PROVENANCE.lastUpdate})`
|
|
390
|
-
}));
|
|
391
|
-
/** A publication carrying one of these is refused; the message would arrive broken. */
|
|
392
|
-
const hasBlockingCompatibility = (findings) => findings.some((finding) => finding.severity === "error");
|
|
393
|
-
//#endregion
|
|
394
229
|
//#region src/email/fonts.ts
|
|
395
230
|
const FONT_FILE = /\.woff2$/i;
|
|
396
231
|
const WOFF2_FORMAT = /^woff2(?:-variations)?$/i;
|
|
@@ -611,6 +446,7 @@ const parseStylesheet = (source, options = {}) => {
|
|
|
611
446
|
const headAtRules = [];
|
|
612
447
|
const fontFaces = [];
|
|
613
448
|
const variables = {};
|
|
449
|
+
const darkVariables = {};
|
|
614
450
|
const conditionalVariables = /* @__PURE__ */ new Set();
|
|
615
451
|
const diagnostics = [];
|
|
616
452
|
const layers = /* @__PURE__ */ new Map();
|
|
@@ -667,6 +503,7 @@ const parseStylesheet = (source, options = {}) => {
|
|
|
667
503
|
headAtRules: [],
|
|
668
504
|
fontFaces: [],
|
|
669
505
|
variables: {},
|
|
506
|
+
darkVariables: {},
|
|
670
507
|
diagnostics: [{
|
|
671
508
|
code: "css-parse-error",
|
|
672
509
|
severity: "error",
|
|
@@ -694,6 +531,7 @@ const parseStylesheet = (source, options = {}) => {
|
|
|
694
531
|
if (ROOT_SELECTORS.has(selector.trim())) for (const declaration of declarations) {
|
|
695
532
|
if (!declaration.property.startsWith("--")) continue;
|
|
696
533
|
if (context.conditions.length === 0) variables[declaration.property] = declaration.value;
|
|
534
|
+
else if (isDarkSchemeCondition(context.conditions)) darkVariables[declaration.property] = declaration.value;
|
|
697
535
|
else conditionalVariables.add(declaration.property);
|
|
698
536
|
}
|
|
699
537
|
rules.push({
|
|
@@ -750,7 +588,11 @@ const parseStylesheet = (source, options = {}) => {
|
|
|
750
588
|
return;
|
|
751
589
|
}
|
|
752
590
|
if (name === "theme" || name === "scope") {
|
|
753
|
-
|
|
591
|
+
const into = context.conditions.length === 0 ? variables : isDarkSchemeCondition(context.conditions) ? darkVariables : void 0;
|
|
592
|
+
for (const declaration of declarationsOf(node.block)) if (declaration.property.startsWith("--")) {
|
|
593
|
+
if (into === void 0) conditionalVariables.add(declaration.property);
|
|
594
|
+
else into[declaration.property] = declaration.value;
|
|
595
|
+
}
|
|
754
596
|
for (const child of node.block?.children ?? []) if (child.type !== "Declaration") visitNode(child, context);
|
|
755
597
|
return;
|
|
756
598
|
}
|
|
@@ -810,10 +652,561 @@ const parseStylesheet = (source, options = {}) => {
|
|
|
810
652
|
headAtRules,
|
|
811
653
|
fontFaces,
|
|
812
654
|
variables,
|
|
655
|
+
darkVariables,
|
|
813
656
|
diagnostics
|
|
814
657
|
};
|
|
815
658
|
};
|
|
816
659
|
//#endregion
|
|
660
|
+
//#region src/email/contrast.ts
|
|
661
|
+
const CANVAS = {
|
|
662
|
+
light: {
|
|
663
|
+
r: 1,
|
|
664
|
+
g: 1,
|
|
665
|
+
b: 1,
|
|
666
|
+
a: 1
|
|
667
|
+
},
|
|
668
|
+
dark: {
|
|
669
|
+
r: 18 / 255,
|
|
670
|
+
g: 18 / 255,
|
|
671
|
+
b: 18 / 255,
|
|
672
|
+
a: 1
|
|
673
|
+
}
|
|
674
|
+
};
|
|
675
|
+
/** The ratios `email-contrast` holds a message to, and what counts as large text. */
|
|
676
|
+
const CONTRAST_THRESHOLDS = {
|
|
677
|
+
/** Text below this is an error at any size. */
|
|
678
|
+
textError: 3,
|
|
679
|
+
/** Normal-size text below this is a warning (WCAG AA). */
|
|
680
|
+
textWarning: 4.5,
|
|
681
|
+
/** Text at least this many pixels is large, as is bold text at `largeBoldPx`. */
|
|
682
|
+
largePx: 24,
|
|
683
|
+
largeBoldPx: 18.5,
|
|
684
|
+
/** A filled button below this against its surroundings is an error. */
|
|
685
|
+
shapeError: 1.5,
|
|
686
|
+
/** A filled button below this is a warning (WCAG 1.4.11). */
|
|
687
|
+
shapeWarning: 3,
|
|
688
|
+
/** A button border at least this against the surroundings keeps the shape. */
|
|
689
|
+
border: 3
|
|
690
|
+
};
|
|
691
|
+
const TEXT_ERROR = CONTRAST_THRESHOLDS.textError;
|
|
692
|
+
const TEXT_WARNING = CONTRAST_THRESHOLDS.textWarning;
|
|
693
|
+
const LARGE_PX = CONTRAST_THRESHOLDS.largePx;
|
|
694
|
+
const LARGE_BOLD_PX = CONTRAST_THRESHOLDS.largeBoldPx;
|
|
695
|
+
const SHAPE_ERROR = CONTRAST_THRESHOLDS.shapeError;
|
|
696
|
+
const SHAPE_WARNING = CONTRAST_THRESHOLDS.shapeWarning;
|
|
697
|
+
const INSET_BORDER = CONTRAST_THRESHOLDS.border;
|
|
698
|
+
const SKIPPED_TAGS = /* @__PURE__ */ new Set([
|
|
699
|
+
"head",
|
|
700
|
+
"style",
|
|
701
|
+
"title",
|
|
702
|
+
"script"
|
|
703
|
+
]);
|
|
704
|
+
const INVISIBLE = /[\s\u200b-\u200f\ufeff\u00a0\u00ad]|\u034f/g;
|
|
705
|
+
const DARK_QUERY = /^@media\b[^{]*\(\s*prefers-color-scheme\s*:\s*dark\s*\)/i;
|
|
706
|
+
const clamp = (value) => value < 0 ? 0 : value > 1 ? 1 : value;
|
|
707
|
+
const channel = (value) => {
|
|
708
|
+
const v = clamp(value);
|
|
709
|
+
return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
710
|
+
};
|
|
711
|
+
const luminance = (color) => .2126 * channel(color.r) + .7152 * channel(color.g) + .0722 * channel(color.b);
|
|
712
|
+
const ratioOf = (left, right) => {
|
|
713
|
+
const a = luminance(left);
|
|
714
|
+
const b = luminance(right);
|
|
715
|
+
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
|
|
716
|
+
};
|
|
717
|
+
const hex = (color) => `#${[
|
|
718
|
+
color.r,
|
|
719
|
+
color.g,
|
|
720
|
+
color.b
|
|
721
|
+
].map((value) => Math.round(clamp(value) * 255).toString(16).padStart(2, "0")).join("")}`;
|
|
722
|
+
const over = (top, below) => ({
|
|
723
|
+
r: top.r * top.a + below.r * (1 - top.a),
|
|
724
|
+
g: top.g * top.a + below.g * (1 - top.a),
|
|
725
|
+
b: top.b * top.a + below.b * (1 - top.a),
|
|
726
|
+
a: 1
|
|
727
|
+
});
|
|
728
|
+
const declarationsOf = (source) => {
|
|
729
|
+
const found = [];
|
|
730
|
+
try {
|
|
731
|
+
walk(parse(source, {
|
|
732
|
+
context: "declarationList",
|
|
733
|
+
positions: false
|
|
734
|
+
}), (node) => {
|
|
735
|
+
if (node.type === "Declaration") found.push({
|
|
736
|
+
property: node.property.toLowerCase(),
|
|
737
|
+
value: generate(node.value).trim()
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
} catch {
|
|
741
|
+
return [];
|
|
742
|
+
}
|
|
743
|
+
return found;
|
|
744
|
+
};
|
|
745
|
+
/** Split on whitespace that is not inside parentheses. */
|
|
746
|
+
const tokens = (value) => {
|
|
747
|
+
const found = [];
|
|
748
|
+
let depth = 0;
|
|
749
|
+
let current = "";
|
|
750
|
+
for (const char of value) {
|
|
751
|
+
if (char === "(") depth += 1;
|
|
752
|
+
if (char === ")") depth -= 1;
|
|
753
|
+
if (/\s/.test(char) && depth === 0) {
|
|
754
|
+
if (current !== "") found.push(current);
|
|
755
|
+
current = "";
|
|
756
|
+
} else current += char;
|
|
757
|
+
}
|
|
758
|
+
if (current !== "") found.push(current);
|
|
759
|
+
return found;
|
|
760
|
+
};
|
|
761
|
+
const declaredColor = (value) => {
|
|
762
|
+
const color = parseColor(value);
|
|
763
|
+
return color === void 0 ? "unknown" : color;
|
|
764
|
+
};
|
|
765
|
+
const pixels = (value) => {
|
|
766
|
+
const trimmed = value.trim();
|
|
767
|
+
if (/^[+-]?0+(?:\.0+)?$/.test(trimmed)) return 0;
|
|
768
|
+
const match = /^(\d+(?:\.\d+)?|\.\d+)px$/i.exec(trimmed);
|
|
769
|
+
return match === null ? void 0 : Number(match[1]);
|
|
770
|
+
};
|
|
771
|
+
const BORDER_STYLES = /^(?:solid|dashed|dotted|double|groove|ridge|inset|outset|none|hidden)$/i;
|
|
772
|
+
const WIDTH_KEYWORDS = {
|
|
773
|
+
thin: 1,
|
|
774
|
+
medium: 3,
|
|
775
|
+
thick: 5
|
|
776
|
+
};
|
|
777
|
+
const resolve = (declarations) => {
|
|
778
|
+
let background;
|
|
779
|
+
let image = false;
|
|
780
|
+
let color;
|
|
781
|
+
let display;
|
|
782
|
+
let fontSize;
|
|
783
|
+
let bold;
|
|
784
|
+
const border = {
|
|
785
|
+
width: void 0,
|
|
786
|
+
visible: void 0,
|
|
787
|
+
color: void 0
|
|
788
|
+
};
|
|
789
|
+
for (const { property, value } of declarations) {
|
|
790
|
+
const lower = value.toLowerCase();
|
|
791
|
+
switch (property) {
|
|
792
|
+
case "background":
|
|
793
|
+
image = /url\(|gradient\(/.test(lower);
|
|
794
|
+
background = tokens(value).filter((part) => !/^(?:url|[\w-]*gradient)\(/i.test(part)).map(parseColor).find((entry) => entry !== void 0) ?? (/var\(|currentcolor|inherit|initial|unset/.test(lower) ? "unknown" : void 0);
|
|
795
|
+
break;
|
|
796
|
+
case "background-color":
|
|
797
|
+
background = declaredColor(value);
|
|
798
|
+
break;
|
|
799
|
+
case "background-image":
|
|
800
|
+
image = lower !== "none";
|
|
801
|
+
break;
|
|
802
|
+
case "color":
|
|
803
|
+
color = declaredColor(value);
|
|
804
|
+
break;
|
|
805
|
+
case "display":
|
|
806
|
+
display = lower.replace(/\s*!important$/, "");
|
|
807
|
+
break;
|
|
808
|
+
case "font-size":
|
|
809
|
+
fontSize = pixels(value);
|
|
810
|
+
break;
|
|
811
|
+
case "font-weight": {
|
|
812
|
+
const weight = Number(lower);
|
|
813
|
+
if (lower === "bold" || lower === "bolder") bold = true;
|
|
814
|
+
else if (lower === "normal" || lower === "lighter") bold = false;
|
|
815
|
+
else if (!Number.isNaN(weight)) bold = weight >= 700;
|
|
816
|
+
break;
|
|
817
|
+
}
|
|
818
|
+
case "border":
|
|
819
|
+
case "border-width":
|
|
820
|
+
case "border-color":
|
|
821
|
+
case "border-style": {
|
|
822
|
+
const parts = tokens(value);
|
|
823
|
+
if (property === "border") {
|
|
824
|
+
border.width = WIDTH_KEYWORDS.medium;
|
|
825
|
+
border.visible = false;
|
|
826
|
+
border.color = "unknown";
|
|
827
|
+
}
|
|
828
|
+
const styles = parts.filter((part) => BORDER_STYLES.test(part));
|
|
829
|
+
if (styles.length > 0) border.visible = styles.every((part) => !/^(?:none|hidden)$/i.test(part));
|
|
830
|
+
const sizes = parts.map((part) => pixels(part) ?? WIDTH_KEYWORDS[part.toLowerCase()]).filter((entry) => entry !== void 0);
|
|
831
|
+
if (sizes.length > 0) border.width = Math.min(...sizes);
|
|
832
|
+
else if (property === "border-width") border.width = void 0;
|
|
833
|
+
const named = parts.filter((part) => pixels(part) === void 0 && WIDTH_KEYWORDS[part.toLowerCase()] === void 0 && !BORDER_STYLES.test(part));
|
|
834
|
+
if (property !== "border-width" && property !== "border-style" && named.length > 0) {
|
|
835
|
+
const colors = named.map(parseColor);
|
|
836
|
+
border.color = colors.every((entry) => entry !== void 0) ? colors[0] : "unknown";
|
|
837
|
+
}
|
|
838
|
+
break;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return {
|
|
843
|
+
background,
|
|
844
|
+
image,
|
|
845
|
+
color,
|
|
846
|
+
display,
|
|
847
|
+
fontSize,
|
|
848
|
+
bold,
|
|
849
|
+
border
|
|
850
|
+
};
|
|
851
|
+
};
|
|
852
|
+
/** Properties a declaration list sets, for deciding whether an unimportant dark rule can apply. */
|
|
853
|
+
const has = (declarations, property) => declarations.some((entry) => entry.property === property);
|
|
854
|
+
const advance = (parent, style, tag) => {
|
|
855
|
+
let background = parent.background;
|
|
856
|
+
let ownBackground;
|
|
857
|
+
if (style.image || style.background === "unknown") background = "unknown";
|
|
858
|
+
else if (style.background !== void 0 && style.background.a > 0) {
|
|
859
|
+
if (style.background.a >= 1) background = style.background;
|
|
860
|
+
else if (parent.background !== "unknown") background = over(style.background, parent.background);
|
|
861
|
+
else background = "unknown";
|
|
862
|
+
if (background !== "unknown") ownBackground = background;
|
|
863
|
+
}
|
|
864
|
+
let color = tag === "a" ? void 0 : parent.color;
|
|
865
|
+
if (style.color === "unknown") color = "unknown";
|
|
866
|
+
else if (style.color !== void 0) color = style.color;
|
|
867
|
+
return {
|
|
868
|
+
ownBackground,
|
|
869
|
+
state: {
|
|
870
|
+
background,
|
|
871
|
+
color,
|
|
872
|
+
fontSize: style.fontSize ?? parent.fontSize,
|
|
873
|
+
bold: style.bold ?? parent.bold,
|
|
874
|
+
hidden: parent.hidden || style.display === "none"
|
|
875
|
+
}
|
|
876
|
+
};
|
|
877
|
+
};
|
|
878
|
+
const originsAt$1 = (positions, offset) => {
|
|
879
|
+
if (offset === void 0) return [];
|
|
880
|
+
let best;
|
|
881
|
+
for (const position of positions) {
|
|
882
|
+
if (offset < position.start || offset >= position.end) continue;
|
|
883
|
+
if (best === void 0 || position.end - position.start < best.end - best.start) best = position;
|
|
884
|
+
}
|
|
885
|
+
return best?.origins ?? [];
|
|
886
|
+
};
|
|
887
|
+
const compareRules = (left, right) => {
|
|
888
|
+
const important = (rule) => rule.declarations.some((d) => d.important) ? 1 : 0;
|
|
889
|
+
return important(left) - important(right) || left.specificity[0] - right.specificity[0] || left.specificity[1] - right.specificity[1] || left.specificity[2] - right.specificity[2] || left.order - right.order;
|
|
890
|
+
};
|
|
891
|
+
const textOf = (node) => node.children.map((child) => "data" in child ? String(child.data) : "").join("");
|
|
892
|
+
const MODE_LABEL = {
|
|
893
|
+
light: "Light mode",
|
|
894
|
+
dark: "Dark mode"
|
|
895
|
+
};
|
|
896
|
+
const FIX = "Pair background and text tokens (bg-primary with text-primary-foreground) and give the theme's dark-mode @theme a value for the token, or choose colors that reach";
|
|
897
|
+
/**
|
|
898
|
+
* Check text and filled-control contrast in the emitted document, in light and
|
|
899
|
+
* dark presentation.
|
|
900
|
+
*/
|
|
901
|
+
const checkEmailContrast = (input) => {
|
|
902
|
+
const { document } = parseHtml(input.html);
|
|
903
|
+
const sheets = input.css === void 0 ? [] : [input.css];
|
|
904
|
+
const collect = (nodes) => {
|
|
905
|
+
for (const node of nodes) {
|
|
906
|
+
if (!("attribs" in node)) continue;
|
|
907
|
+
if (node.name === "style") sheets.push(textOf(node));
|
|
908
|
+
else collect(node.children);
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
collect(document.children);
|
|
912
|
+
const darkRules = sheets.flatMap((sheet) => parseStylesheet(sheet).rules).filter((rule) => rule.conditions.length === 1 && DARK_QUERY.test(rule.conditions[0])).toSorted(compareRules);
|
|
913
|
+
const findings = /* @__PURE__ */ new Map();
|
|
914
|
+
const report = (mode, kind, foreground, background, ratio, required, severity, offset) => {
|
|
915
|
+
const origins = originsAt$1(input.positions ?? [], offset);
|
|
916
|
+
const measured = ratio.toFixed(2);
|
|
917
|
+
const colors = `${hex(foreground)} on ${hex(background)}`;
|
|
918
|
+
const message = kind === "text" ? `${MODE_LABEL[mode]}: text ${colors} has a contrast ratio of ${measured}:1; ${required}:1 is required. ${FIX} ${required}:1.` : `${MODE_LABEL[mode]}: the button background ${hex(foreground)} against its surroundings ${hex(background)} has a contrast ratio of ${measured}:1; ${SHAPE_WARNING}:1 is required for the shape to stay visible (below ${SHAPE_ERROR}:1 it disappears). ${FIX} ${SHAPE_WARNING}:1, or give the button a border that does.`;
|
|
919
|
+
const key = JSON.stringify([
|
|
920
|
+
mode,
|
|
921
|
+
kind,
|
|
922
|
+
hex(foreground),
|
|
923
|
+
hex(background),
|
|
924
|
+
measured,
|
|
925
|
+
origins
|
|
926
|
+
]);
|
|
927
|
+
const existing = findings.get(key);
|
|
928
|
+
if (existing !== void 0) {
|
|
929
|
+
if (origins.length > 0) existing.occurrences.push(origins);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
findings.set(key, {
|
|
933
|
+
key,
|
|
934
|
+
occurrences: origins.length > 0 ? [origins] : [],
|
|
935
|
+
diagnostic: {
|
|
936
|
+
code: "email-contrast",
|
|
937
|
+
severity,
|
|
938
|
+
message,
|
|
939
|
+
origins,
|
|
940
|
+
...input.fixture === void 0 ? {} : { fixtures: [input.fixture] }
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
};
|
|
944
|
+
const visit = (node, parents, parentAncestry, previous) => {
|
|
945
|
+
const inline = declarationsOf(node.attribs.style ?? "");
|
|
946
|
+
const attribute = node.attribs.bgcolor === void 0 ? [] : [{
|
|
947
|
+
property: "background-color",
|
|
948
|
+
value: node.attribs.bgcolor
|
|
949
|
+
}];
|
|
950
|
+
const shape = {
|
|
951
|
+
type: "Element",
|
|
952
|
+
tag: node.name,
|
|
953
|
+
props: node.attribs,
|
|
954
|
+
children: [],
|
|
955
|
+
origins: [],
|
|
956
|
+
authored: false
|
|
957
|
+
};
|
|
958
|
+
const ancestry = {
|
|
959
|
+
element: shape,
|
|
960
|
+
parent: parentAncestry,
|
|
961
|
+
previous
|
|
962
|
+
};
|
|
963
|
+
const base = [...attribute, ...inline];
|
|
964
|
+
const dark = [...base];
|
|
965
|
+
for (const rule of darkRules) {
|
|
966
|
+
if (!matchesSelector(rule.matcher, ancestry)) continue;
|
|
967
|
+
for (const declaration of rule.declarations) {
|
|
968
|
+
const property = declaration.property.toLowerCase();
|
|
969
|
+
if (declaration.important || !has(inline, property)) dark.push({
|
|
970
|
+
property,
|
|
971
|
+
value: declaration.value.replace(/\s*!important$/i, "")
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
const styles = {
|
|
976
|
+
light: resolve(base),
|
|
977
|
+
dark: resolve(dark)
|
|
978
|
+
};
|
|
979
|
+
const direct = node.children.some((child) => child.type === "text" && String(child.data).replace(INVISIBLE, "") !== "");
|
|
980
|
+
const states = {
|
|
981
|
+
light: parents.light,
|
|
982
|
+
dark: parents.dark
|
|
983
|
+
};
|
|
984
|
+
for (const mode of ["light", "dark"]) {
|
|
985
|
+
const parent = parents[mode];
|
|
986
|
+
const style = styles[mode];
|
|
987
|
+
const { state, ownBackground } = advance(parent, style, node.name);
|
|
988
|
+
states[mode] = state;
|
|
989
|
+
if (state.hidden) continue;
|
|
990
|
+
if (node.name === "a" && ownBackground !== void 0 && parent.background !== "unknown" && !style.image) {
|
|
991
|
+
const ratio = ratioOf(ownBackground, parent.background);
|
|
992
|
+
if (ratio < SHAPE_WARNING) {
|
|
993
|
+
const { width, visible, color } = style.border;
|
|
994
|
+
const bordered = visible === true && width !== void 0 && width > 0 && color !== void 0;
|
|
995
|
+
if (!(bordered && color !== "unknown" && ratioOf(over(color, parent.background), parent.background) >= INSET_BORDER) && !(bordered && color === "unknown")) report(mode, "button", ownBackground, parent.background, ratio, SHAPE_WARNING, ratio < SHAPE_ERROR ? "error" : "warning", node.startIndex ?? void 0);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
if (direct && state.color !== void 0 && state.color !== "unknown" && state.background !== "unknown") {
|
|
999
|
+
const ink = over(state.color, state.background);
|
|
1000
|
+
const ratio = ratioOf(ink, state.background);
|
|
1001
|
+
const required = state.fontSize !== void 0 && (state.fontSize >= LARGE_PX || state.bold && state.fontSize >= LARGE_BOLD_PX) ? TEXT_ERROR : TEXT_WARNING;
|
|
1002
|
+
if (ratio < required) report(mode, "text", ink, state.background, ratio, required, ratio < TEXT_ERROR ? "error" : "warning", node.startIndex ?? void 0);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
if (!SKIPPED_TAGS.has(node.name) && (!states.light.hidden || !states.dark.hidden)) {
|
|
1006
|
+
let siblings = [];
|
|
1007
|
+
for (const child of node.children) {
|
|
1008
|
+
if (!("attribs" in child) || SKIPPED_TAGS.has(child.name)) continue;
|
|
1009
|
+
const done = visit(child, states, ancestry, siblings);
|
|
1010
|
+
siblings = [...siblings, done];
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return shape;
|
|
1014
|
+
};
|
|
1015
|
+
const root = {
|
|
1016
|
+
light: {
|
|
1017
|
+
background: CANVAS.light,
|
|
1018
|
+
color: void 0,
|
|
1019
|
+
fontSize: void 0,
|
|
1020
|
+
bold: false,
|
|
1021
|
+
hidden: false
|
|
1022
|
+
},
|
|
1023
|
+
dark: {
|
|
1024
|
+
background: CANVAS.dark,
|
|
1025
|
+
color: void 0,
|
|
1026
|
+
fontSize: void 0,
|
|
1027
|
+
bold: false,
|
|
1028
|
+
hidden: false
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
let siblings = [];
|
|
1032
|
+
for (const node of document.children) {
|
|
1033
|
+
if (!("attribs" in node) || SKIPPED_TAGS.has(node.name)) continue;
|
|
1034
|
+
siblings = [...siblings, visit(node, root, void 0, siblings)];
|
|
1035
|
+
}
|
|
1036
|
+
return [...findings.values()].map(({ diagnostic, occurrences }) => ({
|
|
1037
|
+
...diagnostic,
|
|
1038
|
+
...occurrences.length > 1 ? { occurrences } : {}
|
|
1039
|
+
}));
|
|
1040
|
+
};
|
|
1041
|
+
//#endregion
|
|
1042
|
+
//#region src/email/compatibility.ts
|
|
1043
|
+
/**
|
|
1044
|
+
* The pinned matrix. Every finding is reported per client, and a client absent
|
|
1045
|
+
* from this list is a client Samva makes no claim about.
|
|
1046
|
+
*/
|
|
1047
|
+
const EMAIL_CLIENT_MATRIX = [
|
|
1048
|
+
{
|
|
1049
|
+
id: "gmail.desktop-webmail",
|
|
1050
|
+
label: "Gmail web"
|
|
1051
|
+
},
|
|
1052
|
+
{
|
|
1053
|
+
id: "gmail.ios",
|
|
1054
|
+
label: "Gmail iOS"
|
|
1055
|
+
},
|
|
1056
|
+
{
|
|
1057
|
+
id: "gmail.android",
|
|
1058
|
+
label: "Gmail Android"
|
|
1059
|
+
},
|
|
1060
|
+
{
|
|
1061
|
+
id: "apple-mail.macos",
|
|
1062
|
+
label: "Apple Mail macOS"
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
id: "apple-mail.ios",
|
|
1066
|
+
label: "Apple Mail iOS"
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
id: "yahoo.desktop-webmail",
|
|
1070
|
+
label: "Yahoo web"
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
id: "outlook.outlook-com",
|
|
1074
|
+
label: "Outlook web and new Windows"
|
|
1075
|
+
},
|
|
1076
|
+
{
|
|
1077
|
+
id: "outlook.windows",
|
|
1078
|
+
label: "Outlook classic Windows"
|
|
1079
|
+
}
|
|
1080
|
+
];
|
|
1081
|
+
const LABELS = new Map(EMAIL_CLIENT_MATRIX.map((target) => [target.id, target.label]));
|
|
1082
|
+
const COMPATIBILITY_PROVENANCE = {
|
|
1083
|
+
rules: "caniemail@2.0.2",
|
|
1084
|
+
apiVersion: rawData.api_version,
|
|
1085
|
+
lastUpdate: rawData.last_update_date
|
|
1086
|
+
};
|
|
1087
|
+
/**
|
|
1088
|
+
* The constructs whose loss actually breaks a message: the recipient reads
|
|
1089
|
+
* something different, in a different order, follows a link that does not work,
|
|
1090
|
+
* or sees a layout that has collapsed. A target client dropping one of these
|
|
1091
|
+
* blocks publication.
|
|
1092
|
+
*
|
|
1093
|
+
* The list is deliberately short and enumerated, because the contract is to
|
|
1094
|
+
* block *known* failures. Nonessential differences require an assessment of
|
|
1095
|
+
* emitted values: a concrete risk warns, expected differences and unverified
|
|
1096
|
+
* behavior remain compatibility observations.
|
|
1097
|
+
*/
|
|
1098
|
+
const ESSENTIAL_CONSTRUCTS = /* @__PURE__ */ new Set([
|
|
1099
|
+
"<table> element",
|
|
1100
|
+
"<div> element",
|
|
1101
|
+
"<p> element",
|
|
1102
|
+
"<span> element",
|
|
1103
|
+
"<ul>, <ol> and <dl>",
|
|
1104
|
+
"<h1> to <h6> elements",
|
|
1105
|
+
"HTML5 doctype",
|
|
1106
|
+
"width attribute",
|
|
1107
|
+
"height attribute",
|
|
1108
|
+
"align attribute",
|
|
1109
|
+
"valign attribute",
|
|
1110
|
+
"<img> element",
|
|
1111
|
+
"display",
|
|
1112
|
+
"mailto: links",
|
|
1113
|
+
"Local anchors"
|
|
1114
|
+
]);
|
|
1115
|
+
const KNOWN_TITLES = new Set(rawData.data.map((feature) => feature.title));
|
|
1116
|
+
/** The innermost recorded node containing an offset, so a finding names its element. */
|
|
1117
|
+
const originsAt = (positions, offset) => {
|
|
1118
|
+
let best;
|
|
1119
|
+
for (const position of positions) {
|
|
1120
|
+
if (offset < position.start || offset >= position.end) continue;
|
|
1121
|
+
if (best === void 0 || position.end - position.start < best.end - best.start) best = position;
|
|
1122
|
+
}
|
|
1123
|
+
return best?.origins ?? [];
|
|
1124
|
+
};
|
|
1125
|
+
const slug = (title) => title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1126
|
+
/**
|
|
1127
|
+
* Check the emitted document against the pinned matrix.
|
|
1128
|
+
*
|
|
1129
|
+
* Findings are grouped by construct rather than repeated per client, so one
|
|
1130
|
+
* unsupported property is one diagnostic naming every client that drops it.
|
|
1131
|
+
*/
|
|
1132
|
+
const checkEmailCompatibility = (input) => {
|
|
1133
|
+
const targets = input.clients ?? EMAIL_CLIENT_MATRIX;
|
|
1134
|
+
const context = compatibilityContext(input.html, input.css);
|
|
1135
|
+
const targetClients = targets.map((target) => target.id);
|
|
1136
|
+
const reports = [caniemail({
|
|
1137
|
+
clients: targetClients,
|
|
1138
|
+
html: input.html
|
|
1139
|
+
}), ...input.css === void 0 ? [] : [caniemail({
|
|
1140
|
+
clients: targetClients,
|
|
1141
|
+
css: input.css
|
|
1142
|
+
})]];
|
|
1143
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1144
|
+
for (const bucket of reports.flatMap((report) => [report.issues.errors, report.issues.warnings])) for (const [client, issues] of bucket) {
|
|
1145
|
+
const label = LABELS.get(client) ?? client;
|
|
1146
|
+
for (const raw of issues) {
|
|
1147
|
+
const hiddenContentFailure = client === "outlook.windows" && raw.support === "partial" && (raw.title === "display" || raw.title === "display:none") && context.hiddenContentOffsets.length > 0;
|
|
1148
|
+
const blocking = hiddenContentFailure || raw.support !== "partial" && ESSENTIAL_CONSTRUCTS.has(raw.title);
|
|
1149
|
+
const { explanation, ...compatibility } = assessCompatibility(raw.title, client, raw.support, context, blocking);
|
|
1150
|
+
const key = JSON.stringify([
|
|
1151
|
+
raw.title,
|
|
1152
|
+
raw.support,
|
|
1153
|
+
compatibility,
|
|
1154
|
+
explanation
|
|
1155
|
+
]);
|
|
1156
|
+
const offsets = hiddenContentFailure ? context.hiddenContentOffsets : context.offsets(raw.title);
|
|
1157
|
+
const entry = grouped.get(key);
|
|
1158
|
+
if (entry === void 0) grouped.set(key, {
|
|
1159
|
+
title: raw.title,
|
|
1160
|
+
support: raw.support,
|
|
1161
|
+
clients: /* @__PURE__ */ new Set([label]),
|
|
1162
|
+
notes: new Set(raw.notes),
|
|
1163
|
+
explanation,
|
|
1164
|
+
compatibility,
|
|
1165
|
+
blocking,
|
|
1166
|
+
offsets
|
|
1167
|
+
});
|
|
1168
|
+
else {
|
|
1169
|
+
entry.clients.add(label);
|
|
1170
|
+
for (const note of raw.notes) entry.notes.add(note);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
const findings = [];
|
|
1175
|
+
for (const entry of grouped.values()) {
|
|
1176
|
+
const severity = entry.blocking ? "error" : entry.compatibility.assessment === "risk" ? "warning" : "info";
|
|
1177
|
+
const occurrences = entry.offsets.map((offset) => originsAt(input.positions ?? [], offset)).filter((origins) => origins.length > 0);
|
|
1178
|
+
const clients = [...entry.clients].sort();
|
|
1179
|
+
const dropped = entry.support === "partial" ? clients.length === 1 ? "partially supports" : "partially support" : clients.length === 1 ? "does not support" : "do not support";
|
|
1180
|
+
findings.push({
|
|
1181
|
+
code: `caniemail/${slug(entry.title)}`,
|
|
1182
|
+
severity,
|
|
1183
|
+
message: `${clients.join(", ")} ${dropped} ${entry.title}. ${entry.explanation}`,
|
|
1184
|
+
compatibility: entry.compatibility,
|
|
1185
|
+
origins: occurrences[0] ?? [],
|
|
1186
|
+
...occurrences.length === 0 ? {} : { occurrences },
|
|
1187
|
+
...input.fixture === void 0 ? {} : { fixtures: [input.fixture] },
|
|
1188
|
+
clients,
|
|
1189
|
+
...entry.notes.size === 0 ? {} : { notes: [...entry.notes].join(" ") },
|
|
1190
|
+
provenance: `${COMPATIBILITY_PROVENANCE.rules} (caniemail data ${COMPATIBILITY_PROVENANCE.apiVersion}, updated ${COMPATIBILITY_PROVENANCE.lastUpdate})`
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
findings.push(...checkEmailContrast(input));
|
|
1194
|
+
return findings.sort((left, right) => left.code.localeCompare(right.code));
|
|
1195
|
+
};
|
|
1196
|
+
/**
|
|
1197
|
+
* Constructs the pinned data says nothing about for a target client. These are
|
|
1198
|
+
* reported so "we did not check that" is never mistaken for "that works".
|
|
1199
|
+
*/
|
|
1200
|
+
const unknownCoverage = (titles) => [...titles].filter((title) => !KNOWN_TITLES.has(title)).sort().map((title) => ({
|
|
1201
|
+
code: "caniemail/unknown-coverage",
|
|
1202
|
+
severity: "info",
|
|
1203
|
+
message: `The pinned rules say nothing about ${title}, so no client claim is made about it. Verify it in a real client before relying on it.`,
|
|
1204
|
+
origins: [],
|
|
1205
|
+
provenance: `${COMPATIBILITY_PROVENANCE.rules} (caniemail data ${COMPATIBILITY_PROVENANCE.apiVersion}, updated ${COMPATIBILITY_PROVENANCE.lastUpdate})`
|
|
1206
|
+
}));
|
|
1207
|
+
/** A publication carrying one of these is refused; the message would arrive broken. */
|
|
1208
|
+
const hasBlockingCompatibility = (findings) => findings.some((finding) => finding.severity === "error");
|
|
1209
|
+
//#endregion
|
|
817
1210
|
//#region src/email/tailwind-stylesheets.gen.ts
|
|
818
1211
|
/** The exact Tailwind release the compiler runs, recorded in every build manifest. */
|
|
819
1212
|
const TAILWIND_VERSION = "4.3.3";
|
|
@@ -826,31 +1219,91 @@ const STYLESHEETS = {
|
|
|
826
1219
|
"tailwindcss/preflight.css": "/*\n 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)\n 2. Remove default margins and padding\n 3. Reset all borders.\n*/\n\n*,\n::after,\n::before,\n::backdrop,\n::file-selector-button {\n box-sizing: border-box; /* 1 */\n margin: 0; /* 2 */\n padding: 0; /* 2 */\n border: 0 solid; /* 3 */\n}\n\n/*\n 1. Use a consistent sensible line-height in all browsers.\n 2. Prevent adjustments of font size after orientation changes in iOS.\n 3. Use a more readable tab size.\n 4. Use the user's configured `sans` font-family by default.\n 5. Use the user's configured `sans` font-feature-settings by default.\n 6. Use the user's configured `sans` font-variation-settings by default.\n 7. Disable tap highlights on iOS.\n*/\n\nhtml,\n:host {\n line-height: 1.5; /* 1 */\n -webkit-text-size-adjust: 100%; /* 2 */\n tab-size: 4; /* 3 */\n font-family: --theme(\n --default-font-family,\n -apple-system,\n BlinkMacSystemFont,\n 'Segoe UI',\n Roboto,\n 'Helvetica Neue',\n 'Noto Sans',\n Arial,\n sans-serif,\n 'Apple Color Emoji',\n 'Segoe UI Emoji',\n 'Segoe UI Symbol',\n 'Noto Color Emoji'\n ); /* 4 */\n font-feature-settings: --theme(--default-font-feature-settings, normal); /* 5 */\n font-variation-settings: --theme(--default-font-variation-settings, normal); /* 6 */\n -webkit-tap-highlight-color: transparent; /* 7 */\n}\n\n/*\n 1. Add the correct height in Firefox.\n 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)\n 3. Reset the default border style to a 1px solid border.\n*/\n\nhr {\n height: 0; /* 1 */\n color: inherit; /* 2 */\n border-top-width: 1px; /* 3 */\n}\n\n/*\n Add the correct text decoration in Chrome, Edge, and Safari.\n*/\n\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\n\n/*\n Remove the default font size and weight for headings.\n*/\n\nh1,\nh2,\nh3,\nh4,\nh5,\nh6 {\n font-size: inherit;\n font-weight: inherit;\n}\n\n/*\n Reset links to optimize for opt-in styling instead of opt-out.\n*/\n\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\n\n/*\n Add the correct font weight in Edge and Safari.\n*/\n\nb,\nstrong {\n font-weight: bolder;\n}\n\n/*\n 1. Use the user's configured `mono` font-family by default.\n 2. Use the user's configured `mono` font-feature-settings by default.\n 3. Use the user's configured `mono` font-variation-settings by default.\n 4. Correct the odd `em` font sizing in all browsers.\n*/\n\ncode,\nkbd,\nsamp,\npre {\n font-family: --theme(\n --default-mono-font-family,\n ui-monospace,\n SFMono-Regular,\n Menlo,\n Monaco,\n Consolas,\n 'Liberation Mono',\n 'Courier New',\n monospace\n ); /* 1 */\n font-feature-settings: --theme(--default-mono-font-feature-settings, normal); /* 2 */\n font-variation-settings: --theme(--default-mono-font-variation-settings, normal); /* 3 */\n font-size: 1em; /* 4 */\n}\n\n/*\n Add the correct font size in all browsers.\n*/\n\nsmall {\n font-size: 80%;\n}\n\n/*\n Prevent `sub` and `sup` elements from affecting the line height in all browsers.\n*/\n\nsub,\nsup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\n\nsub {\n bottom: -0.25em;\n}\n\nsup {\n top: -0.5em;\n}\n\n/*\n 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)\n 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)\n 3. Remove gaps between table borders by default.\n*/\n\ntable {\n text-indent: 0; /* 1 */\n border-color: inherit; /* 2 */\n border-collapse: collapse; /* 3 */\n}\n\n/*\n Use the modern Firefox focus style for all focusable elements.\n*/\n\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\n\n/*\n Add the correct vertical alignment in Chrome and Firefox.\n*/\n\nprogress {\n vertical-align: baseline;\n}\n\n/*\n Add the correct display in Chrome and Safari.\n*/\n\nsummary {\n display: list-item;\n}\n\n/*\n Make lists unstyled by default.\n*/\n\nol,\nul,\nmenu {\n list-style: none;\n}\n\n/*\n 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)\n 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)\n This can trigger a poorly considered lint error in some tools but is included by design.\n*/\n\nimg,\nsvg,\nvideo,\ncanvas,\naudio,\niframe,\nembed,\nobject {\n display: block; /* 1 */\n vertical-align: middle; /* 2 */\n}\n\n/*\n Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)\n*/\n\nimg,\nvideo {\n max-width: 100%;\n height: auto;\n}\n\n/*\n 1. Inherit font styles in all browsers.\n 2. Remove border radius in all browsers.\n 3. Remove background color in all browsers.\n 4. Ensure consistent opacity for disabled states in all browsers.\n*/\n\nbutton,\ninput,\nselect,\noptgroup,\ntextarea,\n::file-selector-button {\n font: inherit; /* 1 */\n font-feature-settings: inherit; /* 1 */\n font-variation-settings: inherit; /* 1 */\n letter-spacing: inherit; /* 1 */\n color: inherit; /* 1 */\n border-radius: 0; /* 2 */\n background-color: transparent; /* 3 */\n opacity: 1; /* 4 */\n}\n\n/*\n Restore default font weight.\n*/\n\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n\n/*\n Restore indentation.\n*/\n\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n\n/*\n Restore space after button.\n*/\n\n::file-selector-button {\n margin-inline-end: 4px;\n}\n\n/*\n Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)\n*/\n\n::placeholder {\n opacity: 1;\n}\n\n/*\n Set the default placeholder color to a semi-transparent version of the current text color in browsers that do not\n crash when using `color-mix(…)` with `currentcolor`. (https://github.com/tailwindlabs/tailwindcss/issues/17194)\n*/\n\n@supports (not (-webkit-appearance: -apple-pay-button)) /* Not Safari */ or\n (contain-intrinsic-size: 1px) /* Safari 17+ */ {\n ::placeholder {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n}\n\n/*\n Prevent resizing textareas horizontally by default.\n*/\n\ntextarea {\n resize: vertical;\n}\n\n/*\n Remove the inner padding in Chrome and Safari on macOS.\n*/\n\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n\n/*\n 1. Ensure date/time inputs have the same height when empty in iOS Safari.\n 2. Ensure text alignment can be changed on date/time inputs in iOS Safari.\n*/\n\n::-webkit-date-and-time-value {\n min-height: 1lh; /* 1 */\n text-align: inherit; /* 2 */\n}\n\n/*\n Prevent height from changing on date/time inputs in macOS Safari when the input is set to `display: block`.\n*/\n\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n\n/*\n Remove excess padding from pseudo-elements in date/time inputs to ensure consistent height across browsers.\n*/\n\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n\n::-webkit-datetime-edit,\n::-webkit-datetime-edit-year-field,\n::-webkit-datetime-edit-month-field,\n::-webkit-datetime-edit-day-field,\n::-webkit-datetime-edit-hour-field,\n::-webkit-datetime-edit-minute-field,\n::-webkit-datetime-edit-second-field,\n::-webkit-datetime-edit-millisecond-field,\n::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n\n/*\n Center dropdown marker shown on inputs with paired `<datalist>`s in Chrome. (https://github.com/tailwindlabs/tailwindcss/issues/18499)\n*/\n\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n\n/*\n Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)\n*/\n\n:-moz-ui-invalid {\n box-shadow: none;\n}\n\n/*\n Correct the inability to style the border radius in iOS Safari.\n*/\n\nbutton,\ninput:where([type='button'], [type='reset'], [type='submit']),\n::file-selector-button {\n appearance: button;\n}\n\n/*\n Correct the cursor style of increment and decrement buttons in Safari.\n*/\n\n::-webkit-inner-spin-button,\n::-webkit-outer-spin-button {\n height: auto;\n}\n\n/*\n Make elements with the HTML hidden attribute stay hidden by default.\n*/\n\n[hidden]:where(:not([hidden='until-found'])) {\n display: none !important;\n}\n"
|
|
827
1220
|
};
|
|
828
1221
|
/**
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
834
|
-
*
|
|
1222
|
+
* Lower one theme stylesheet for Tailwind and the email cascade.
|
|
1223
|
+
*
|
|
1224
|
+
* Every `@theme` becomes `static`. Tailwind emits a theme variable only when a
|
|
1225
|
+
* utility it generated reads it, but a project's own stylesheets read theme
|
|
1226
|
+
* variables too (`color: var(--color-primary)`), and Tailwind never sees those
|
|
1227
|
+
* rules. A later layer that redefines a variable replaces its options, so every
|
|
1228
|
+
* layer has to carry `static` for the value the cascade settles on to reach the
|
|
1229
|
+
* inliner.
|
|
1230
|
+
*
|
|
1231
|
+
* A dark-mode block, `@media (prefers-color-scheme: dark) { @theme { … } }`,
|
|
1232
|
+
* becomes a `:root` rule in that query, which the cascade reads as the dark
|
|
1233
|
+
* values of those variables (Tailwind would hoist a nested `@theme` into the
|
|
1234
|
+
* light theme). A stylesheet owns each color it sets in both modes: a color its
|
|
1235
|
+
* light `@theme` sets and its dark-mode block does not keeps that value in dark
|
|
1236
|
+
* mode too, rather than a dark value from a layer beneath it. A fill it sets
|
|
1237
|
+
* without a text color gets `foregroundDefaults`.
|
|
835
1238
|
*/
|
|
836
|
-
const
|
|
1239
|
+
const lowerThemeCss = (css) => {
|
|
837
1240
|
const ast = parse(css, {
|
|
838
1241
|
positions: true,
|
|
839
1242
|
parseValue: false,
|
|
840
1243
|
parseRulePrelude: false,
|
|
841
1244
|
parseCustomProperty: false
|
|
842
1245
|
});
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
if (
|
|
848
|
-
|
|
849
|
-
}
|
|
1246
|
+
const edits = [];
|
|
1247
|
+
const light = {};
|
|
1248
|
+
const dark = {};
|
|
1249
|
+
const declarations = (node, visit) => {
|
|
1250
|
+
if (node.type !== "Atrule" || node.block === null) return;
|
|
1251
|
+
for (const child of node.block.children) if (child.type === "Declaration" && child.loc != null && child.value.loc != null) visit(child.property, css.slice(child.value.loc.start.offset, child.value.loc.end.offset));
|
|
1252
|
+
};
|
|
1253
|
+
const preludeOf = (node) => node.type !== "Atrule" || node.prelude === null || node.prelude.loc == null ? "" : css.slice(node.prelude.loc.start.offset, node.prelude.loc.end.offset);
|
|
1254
|
+
const isTheme = (node) => node.type === "Atrule" && node.name.toLowerCase() === "theme" && node.loc != null;
|
|
1255
|
+
if (ast.type === "StyleSheet") for (const node of ast.children) {
|
|
1256
|
+
if (isTheme(node)) {
|
|
1257
|
+
if (!/(?:^|\s)static(?:\s|$)/i.test(preludeOf(node))) edits.push({
|
|
1258
|
+
start: node.loc.start.offset + 6,
|
|
1259
|
+
end: node.loc.start.offset + 6,
|
|
1260
|
+
text: " static"
|
|
1261
|
+
});
|
|
1262
|
+
declarations(node, (property, value) => {
|
|
1263
|
+
if (property.startsWith("--color-")) light[property] = value.trim();
|
|
1264
|
+
});
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
if (node.type !== "Atrule" || node.name.toLowerCase() !== "media" || node.block === null || !isDarkSchemeQuery(preludeOf(node))) continue;
|
|
1268
|
+
for (const child of node.block.children) {
|
|
1269
|
+
if (!isTheme(child) || child.type !== "Atrule" || child.block?.loc == null) continue;
|
|
1270
|
+
edits.push({
|
|
1271
|
+
start: child.loc.start.offset,
|
|
1272
|
+
end: child.block.loc.start.offset,
|
|
1273
|
+
text: ":root"
|
|
1274
|
+
});
|
|
1275
|
+
declarations(child, (property, value) => {
|
|
1276
|
+
dark[property] = value.trim();
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
const defaults = foregroundDefaults(light, dark);
|
|
1281
|
+
const lightAdded = Object.entries(defaults.light);
|
|
1282
|
+
const owned = {
|
|
1283
|
+
...light,
|
|
1284
|
+
...defaults.light
|
|
1285
|
+
};
|
|
1286
|
+
const darkAdded = [...Object.entries(defaults.dark), ...Object.entries(owned).filter(([property]) => !(property in dark) && !(property in defaults.dark))];
|
|
850
1287
|
let output = css;
|
|
851
|
-
for (const
|
|
852
|
-
|
|
1288
|
+
for (const edit of edits.sort((left, right) => right.start - left.start)) output = `${output.slice(0, edit.start)}${edit.text}${output.slice(edit.end)}`;
|
|
1289
|
+
const block = (entries) => entries.map(([property, value]) => ` ${property}: ${value};`).join("\n");
|
|
1290
|
+
return [
|
|
1291
|
+
output,
|
|
1292
|
+
...lightAdded.length === 0 ? [] : [`@theme static {\n${block(lightAdded)}\n}`],
|
|
1293
|
+
...darkAdded.length === 0 ? [] : [`${DARK_SCHEME_MEDIA} {\n :root {\n${block(darkAdded)}\n }\n}`]
|
|
1294
|
+
].join("\n");
|
|
853
1295
|
};
|
|
1296
|
+
/** The default theme, lowered once: the first layer of every project's theme. */
|
|
1297
|
+
const DEFAULT_THEME_TAILWIND_CSS = lowerThemeCss(DEFAULT_THEME_CSS);
|
|
1298
|
+
/**
|
|
1299
|
+
* What every project compiles beneath its own CSS, for a build that compiles no
|
|
1300
|
+
* Tailwind: the default theme and the primitives' rules, in Tailwind's layers.
|
|
1301
|
+
*/
|
|
1302
|
+
const BASE_STYLESHEET_CSS = [
|
|
1303
|
+
"@layer theme, base, components, utilities;",
|
|
1304
|
+
DEFAULT_THEME_TAILWIND_CSS,
|
|
1305
|
+
PRIMITIVE_CSS
|
|
1306
|
+
].join("\n");
|
|
854
1307
|
/** Compile the discovered class set into CSS with the pinned Tailwind release. */
|
|
855
1308
|
const compileTailwind = async (candidates, options = {}, imports = { stylesheets: /* @__PURE__ */ new Map() }) => {
|
|
856
1309
|
const entry = [
|
|
@@ -858,7 +1311,9 @@ const compileTailwind = async (candidates, options = {}, imports = { stylesheets
|
|
|
858
1311
|
"@import \"tailwindcss/theme.css\" layer(theme);",
|
|
859
1312
|
...options.preflight === true ? ["@import \"tailwindcss/preflight.css\" layer(base);"] : [],
|
|
860
1313
|
"@import \"tailwindcss/utilities.css\" layer(utilities);",
|
|
861
|
-
|
|
1314
|
+
DEFAULT_THEME_TAILWIND_CSS,
|
|
1315
|
+
PRIMITIVE_CSS,
|
|
1316
|
+
lowerThemeCss(options.css ?? "")
|
|
862
1317
|
].join("\n");
|
|
863
1318
|
const compiler = await compile(entry, {
|
|
864
1319
|
base: projectDirectory(options.cssPath ?? "theme.css"),
|
|
@@ -874,7 +1329,7 @@ const compileTailwind = async (candidates, options = {}, imports = { stylesheets
|
|
|
874
1329
|
return Promise.resolve({
|
|
875
1330
|
path: id,
|
|
876
1331
|
base,
|
|
877
|
-
content:
|
|
1332
|
+
content: lowerThemeCss(imports.brandCss)
|
|
878
1333
|
});
|
|
879
1334
|
}
|
|
880
1335
|
const path = resolveFromDirectory(base, id.replace(/[?#].*$/, ""));
|
|
@@ -883,7 +1338,7 @@ const compileTailwind = async (candidates, options = {}, imports = { stylesheets
|
|
|
883
1338
|
return Promise.resolve({
|
|
884
1339
|
path,
|
|
885
1340
|
base: projectDirectory(path),
|
|
886
|
-
content:
|
|
1341
|
+
content: lowerThemeCss(content)
|
|
887
1342
|
});
|
|
888
1343
|
}
|
|
889
1344
|
});
|
|
@@ -1018,6 +1473,7 @@ const readStylesheet = (id, source, options, assets = /* @__PURE__ */ new Map(),
|
|
|
1018
1473
|
headAtRules: sheet.headAtRules,
|
|
1019
1474
|
fontFaces: sheet.fontFaces,
|
|
1020
1475
|
variables: sheet.variables,
|
|
1476
|
+
darkVariables: sheet.darkVariables,
|
|
1021
1477
|
diagnostics: []
|
|
1022
1478
|
},
|
|
1023
1479
|
diagnostics: sheet.diagnostics
|
|
@@ -1127,7 +1583,7 @@ const resolveTheme = (files, tailwind, brand, brandPlugin) => {
|
|
|
1127
1583
|
let brandCss;
|
|
1128
1584
|
if (refusals.length === 0 && brand !== void 0 && cssRequests.length > 0) {
|
|
1129
1585
|
const parsed = parseBrandCss(brand.css);
|
|
1130
|
-
if (parsed.isOk()) brandCss =
|
|
1586
|
+
if (parsed.isOk()) brandCss = printBrandCss(parsed.value);
|
|
1131
1587
|
else {
|
|
1132
1588
|
const lines = buildLineMap(brand.css);
|
|
1133
1589
|
const specifier = cssRequests[0].specifier;
|
|
@@ -1174,9 +1630,9 @@ const transformProject = async (files, options) => {
|
|
|
1174
1630
|
for (const name of reading.classes ?? []) classes.add(name);
|
|
1175
1631
|
diagnostics.push(...reading.diagnostics);
|
|
1176
1632
|
}
|
|
1177
|
-
if (wanted === void 0
|
|
1633
|
+
if (wanted === void 0) return {
|
|
1178
1634
|
assets,
|
|
1179
|
-
tailwindSheet:
|
|
1635
|
+
tailwindSheet: readStylesheet(TAILWIND_ORIGIN, BASE_STYLESHEET_CSS, options).sheet,
|
|
1180
1636
|
stylesheets,
|
|
1181
1637
|
diagnostics
|
|
1182
1638
|
};
|
|
@@ -1203,7 +1659,7 @@ const brandTheme = (brand, plugin) => {
|
|
|
1203
1659
|
message: `${plugin.specifier}: ${finding.message}`,
|
|
1204
1660
|
origins: []
|
|
1205
1661
|
})) });
|
|
1206
|
-
return
|
|
1662
|
+
return printBrandCss(parsed.value);
|
|
1207
1663
|
};
|
|
1208
1664
|
/**
|
|
1209
1665
|
* Render `BrandFooter` against a brand on its own, for the send path to
|
|
@@ -1218,7 +1674,8 @@ const renderBrandFooter = async (brand, options) => {
|
|
|
1218
1674
|
const node = brandComponents(brand, plugin).BrandFooter({});
|
|
1219
1675
|
if (node === null) return {
|
|
1220
1676
|
html: "",
|
|
1221
|
-
text: ""
|
|
1677
|
+
text: "",
|
|
1678
|
+
headCss: ""
|
|
1222
1679
|
};
|
|
1223
1680
|
const styled = applyStylesheet(node, parseStylesheet(tailwind.css, { origin: plugin.specifier }));
|
|
1224
1681
|
const compiled = compileEmail(styled.tree, { brandPlugin: plugin });
|
|
@@ -1229,7 +1686,8 @@ const renderBrandFooter = async (brand, options) => {
|
|
|
1229
1686
|
const text = (rendered.text ?? "").trim();
|
|
1230
1687
|
return {
|
|
1231
1688
|
html: rendered.html ?? "",
|
|
1232
|
-
text: text === "" ? "" : `\n\n---\n${text}
|
|
1689
|
+
text: text === "" ? "" : `\n\n---\n${text}`,
|
|
1690
|
+
headCss: `${renderDarkRules(styled.darkRules)}${styled.headCss}`
|
|
1233
1691
|
};
|
|
1234
1692
|
};
|
|
1235
1693
|
/**
|