@emailens/cli 0.6.0 → 0.7.1

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/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { EMAIL_CLIENTS } from "@emailens/engine";
12
12
  // package.json
13
13
  var package_default = {
14
14
  name: "@emailens/cli",
15
- version: "0.6.0",
15
+ version: "0.7.1",
16
16
  description: "CLI tool for email compatibility analysis: lint, preview and export HTML, MJML, Maizzle and React Email rendering across 21 email clients.",
17
17
  type: "module",
18
18
  bin: {
@@ -39,7 +39,7 @@ var package_default = {
39
39
  prepublishOnly: "bun run build"
40
40
  },
41
41
  dependencies: {
42
- "@emailens/engine": "^0.12.2",
42
+ "@emailens/engine": "^0.14.2",
43
43
  citty: "^0.1.6",
44
44
  "cli-table3": "^0.6.5",
45
45
  ora: "^8.2.0",
@@ -99,7 +99,6 @@ import ora from "ora";
99
99
  import {
100
100
  analyzeEmail,
101
101
  generateCompatibilityScore,
102
- warningsForClient,
103
102
  CompileError
104
103
  } from "@emailens/engine";
105
104
 
@@ -153,7 +152,10 @@ Valid IDs: ${Array.from(validIds).join(", ")}`
153
152
  }
154
153
  return requested;
155
154
  }
156
- function resolveFormat(formatFlag, filePath) {
155
+ function isVueSfc(source) {
156
+ return /<template[\s>]/i.test(source) && /<\/template>/i.test(source);
157
+ }
158
+ function resolveFormat(formatFlag, filePath, source) {
157
159
  if (formatFlag) {
158
160
  if (!VALID_FORMATS.has(formatFlag)) {
159
161
  throw new Error(
@@ -165,6 +167,7 @@ function resolveFormat(formatFlag, filePath) {
165
167
  if (filePath && filePath !== "-") {
166
168
  return detectFormat(filePath);
167
169
  }
170
+ if (source && isVueSfc(source)) return "maizzle";
168
171
  return "html";
169
172
  }
170
173
  function toFramework(format) {
@@ -174,6 +177,15 @@ function toFramework(format) {
174
177
  function positionsApply(format) {
175
178
  return format === "html";
176
179
  }
180
+ var TARGETING_POLICY_ARG = {
181
+ type: "string",
182
+ description: "Client-targeting policy: progressive (default), strict, or lenient"
183
+ };
184
+ function parseTargetingPolicy(raw) {
185
+ if (raw == null || raw === "") return void 0;
186
+ if (raw === "progressive" || raw === "strict" || raw === "lenient") return raw;
187
+ throw new Error(`Unknown targeting policy "${raw}". Valid: progressive, strict, lenient`);
188
+ }
177
189
 
178
190
  // src/commands/analyze.ts
179
191
  import { compile } from "@emailens/engine/compile";
@@ -184,6 +196,57 @@ import Table from "cli-table3";
184
196
  import {
185
197
  EMAIL_CLIENTS as EMAIL_CLIENTS3
186
198
  } from "@emailens/engine";
199
+
200
+ // src/output/json.ts
201
+ function worstClientScore(scores) {
202
+ const values = Object.values(scores);
203
+ if (values.length === 0) return 0;
204
+ return Math.min(...values.map((s) => s.score));
205
+ }
206
+ function formatJsonOutput(opts) {
207
+ const overallScore = worstClientScore(opts.scores);
208
+ const output = {
209
+ overallScore,
210
+ scores: opts.scores,
211
+ warnings: opts.warnings.map((w) => ({
212
+ client: w.client,
213
+ property: w.property,
214
+ severity: w.severity,
215
+ message: w.message,
216
+ suggestion: w.suggestion,
217
+ fix: w.fix ? {
218
+ before: w.fix.before,
219
+ after: w.fix.after,
220
+ language: w.fix.language,
221
+ description: w.fix.description
222
+ } : void 0,
223
+ loc: w.loc,
224
+ locs: w.locs,
225
+ locsTruncated: w.locsTruncated
226
+ }))
227
+ };
228
+ if (opts.transforms) {
229
+ output.transforms = opts.transforms.map((t) => ({
230
+ clientId: t.clientId,
231
+ html: t.html
232
+ }));
233
+ }
234
+ if (opts.darkMode) {
235
+ output.darkMode = {};
236
+ for (const [clientId, dm] of Object.entries(opts.darkMode)) {
237
+ output.darkMode[clientId] = {
238
+ html: dm.html,
239
+ warningCount: dm.warnings.length
240
+ };
241
+ }
242
+ }
243
+ return output;
244
+ }
245
+ function printJson(opts) {
246
+ console.log(JSON.stringify(formatJsonOutput(opts), null, 2));
247
+ }
248
+
249
+ // src/output/terminal.ts
187
250
  function colorScore(score) {
188
251
  const str = String(score);
189
252
  if (score >= 90) return pc.green(str);
@@ -242,8 +305,7 @@ function printScoreTable(scores, warnings, options) {
242
305
  ]);
243
306
  }
244
307
  console.log(table.toString());
245
- const scoreValues = Object.values(scores);
246
- const overall = scoreValues.length > 0 ? Math.round(scoreValues.reduce((a, b) => a + b.score, 0) / scoreValues.length) : 0;
308
+ const overall = worstClientScore(scores);
247
309
  console.log();
248
310
  console.log(` Overall: ${colorScore(overall)}/100`);
249
311
  console.log();
@@ -284,51 +346,6 @@ function printWarnings(warnings, options) {
284
346
  }
285
347
  }
286
348
 
287
- // src/output/json.ts
288
- function formatJsonOutput(opts) {
289
- const scoreValues = Object.values(opts.scores);
290
- const overallScore = scoreValues.length > 0 ? Math.round(scoreValues.reduce((a, b) => a + b.score, 0) / scoreValues.length) : 0;
291
- const output = {
292
- overallScore,
293
- scores: opts.scores,
294
- warnings: opts.warnings.map((w) => ({
295
- client: w.client,
296
- property: w.property,
297
- severity: w.severity,
298
- message: w.message,
299
- suggestion: w.suggestion,
300
- fix: w.fix ? {
301
- before: w.fix.before,
302
- after: w.fix.after,
303
- language: w.fix.language,
304
- description: w.fix.description
305
- } : void 0,
306
- loc: w.loc,
307
- locs: w.locs,
308
- locsTruncated: w.locsTruncated
309
- }))
310
- };
311
- if (opts.transforms) {
312
- output.transforms = opts.transforms.map((t) => ({
313
- clientId: t.clientId,
314
- html: t.html
315
- }));
316
- }
317
- if (opts.darkMode) {
318
- output.darkMode = {};
319
- for (const [clientId, dm] of Object.entries(opts.darkMode)) {
320
- output.darkMode[clientId] = {
321
- html: dm.html,
322
- warningCount: dm.warnings.length
323
- };
324
- }
325
- }
326
- return output;
327
- }
328
- function printJson(opts) {
329
- console.log(JSON.stringify(formatJsonOutput(opts), null, 2));
330
- }
331
-
332
349
  // src/commands/analyze.ts
333
350
  var analyze_default = defineCommand({
334
351
  meta: {
@@ -359,20 +376,25 @@ var analyze_default = defineCommand({
359
376
  type: "boolean",
360
377
  alias: "q",
361
378
  description: "Suppress spinners and decorations"
362
- }
379
+ },
380
+ targetingPolicy: TARGETING_POLICY_ARG
363
381
  },
364
382
  async run({ args }) {
365
383
  const spinner = args.quiet || args.json ? null : ora();
366
384
  try {
367
- const format = resolveFormat(args.format, args.input);
385
+ if (args.format) resolveFormat(args.format);
368
386
  spinner?.start("Reading input...");
369
387
  const source = await readInput(args.input);
370
388
  spinner?.succeed("Input read");
389
+ const format = resolveFormat(args.format, args.input, source);
371
390
  const html = await compile(source, format);
372
391
  const clientIds = resolveClients(args.clients);
373
392
  spinner?.start("Analyzing compatibility...");
374
393
  const framework = toFramework(format);
375
- const warnings = analyzeEmail(html, framework, { positions: positionsApply(format) });
394
+ const warnings = analyzeEmail(html, framework, {
395
+ positions: positionsApply(format),
396
+ targetingPolicy: parseTargetingPolicy(args.targetingPolicy)
397
+ });
376
398
  const allScores = generateCompatibilityScore(warnings);
377
399
  const clientSet = new Set(clientIds);
378
400
  const scores = {};
@@ -381,7 +403,7 @@ var analyze_default = defineCommand({
381
403
  scores[id] = data;
382
404
  }
383
405
  }
384
- const filteredWarnings = clientIds.flatMap((id) => warningsForClient(warnings, id));
406
+ const filteredWarnings = clientIds.flatMap((id) => warnings.filter((w) => w.client === id));
385
407
  spinner?.succeed("Analysis complete");
386
408
  if (args.json) {
387
409
  printJson({ scores, warnings: filteredWarnings });
@@ -408,7 +430,6 @@ import {
408
430
  analyzeEmail as analyzeEmail2,
409
431
  generateCompatibilityScore as generateCompatibilityScore2,
410
432
  simulateDarkMode,
411
- warningsForClient as warningsForClient2,
412
433
  CompileError as CompileError2
413
434
  } from "@emailens/engine";
414
435
  import { compile as compile2 } from "@emailens/engine/compile";
@@ -455,15 +476,17 @@ var preview_default = defineCommand2({
455
476
  type: "boolean",
456
477
  alias: "q",
457
478
  description: "Suppress spinners and decorations"
458
- }
479
+ },
480
+ targetingPolicy: TARGETING_POLICY_ARG
459
481
  },
460
482
  async run({ args }) {
461
483
  const spinner = args.quiet || args.json ? null : ora2();
462
484
  try {
463
- const format = resolveFormat(args.format, args.input);
485
+ if (args.format) resolveFormat(args.format);
464
486
  spinner?.start("Reading input...");
465
487
  const source = await readInput(args.input);
466
488
  spinner?.succeed("Input read");
489
+ const format = resolveFormat(args.format, args.input, source);
467
490
  const html = await compile2(source, format);
468
491
  const clientIds = resolveClients(args.clients);
469
492
  const clientSet = new Set(clientIds);
@@ -473,7 +496,9 @@ var preview_default = defineCommand2({
473
496
  spinner?.succeed(`Transformed for ${transforms.length} clients`);
474
497
  spinner?.start("Analyzing compatibility...");
475
498
  const framework = toFramework(format);
476
- const warnings = analyzeEmail2(html, framework);
499
+ const warnings = analyzeEmail2(html, framework, {
500
+ targetingPolicy: parseTargetingPolicy(args.targetingPolicy)
501
+ });
477
502
  const allScores = generateCompatibilityScore2(warnings);
478
503
  const scores = {};
479
504
  for (const [id, data] of Object.entries(allScores)) {
@@ -481,7 +506,7 @@ var preview_default = defineCommand2({
481
506
  scores[id] = data;
482
507
  }
483
508
  }
484
- const filteredWarnings = clientIds.flatMap((id) => warningsForClient2(warnings, id));
509
+ const filteredWarnings = clientIds.flatMap((id) => warnings.filter((w) => w.client === id));
485
510
  spinner?.succeed("Analysis complete");
486
511
  let darkMode;
487
512
  if (args["dark-mode"]) {
@@ -593,7 +618,6 @@ import {
593
618
  analyzeEmail as analyzeEmail3,
594
619
  generateCompatibilityScore as generateCompatibilityScore3,
595
620
  simulateDarkMode as simulateDarkMode2,
596
- warningsForClient as warningsForClient3,
597
621
  CompileError as CompileError3
598
622
  } from "@emailens/engine";
599
623
  import { compile as compile3 } from "@emailens/engine/compile";
@@ -602,8 +626,7 @@ import { compile as compile3 } from "@emailens/engine/compile";
602
626
  import { EMAIL_CLIENTS as EMAIL_CLIENTS5 } from "@emailens/engine";
603
627
  function generateHtmlReport(opts) {
604
628
  const clientMap = new Map(EMAIL_CLIENTS5.map((c) => [c.id, c]));
605
- const scoreValues = Object.values(opts.scores);
606
- const overallScore = scoreValues.length > 0 ? Math.round(scoreValues.reduce((a, b) => a + b.score, 0) / scoreValues.length) : 0;
629
+ const overallScore = worstClientScore(opts.scores);
607
630
  const scoreColor = (s) => s >= 90 ? "#22c55e" : s >= 70 ? "#eab308" : "#ef4444";
608
631
  const clientCards = Object.entries(opts.scores).sort(([, a], [, b]) => a.score - b.score).map(([clientId, { score, errors, warnings: warns, info }]) => {
609
632
  const client = clientMap.get(clientId);
@@ -720,16 +743,18 @@ var export_default = defineCommand4({
720
743
  type: "boolean",
721
744
  alias: "q",
722
745
  description: "Suppress spinners and decorations"
723
- }
746
+ },
747
+ targetingPolicy: TARGETING_POLICY_ARG
724
748
  },
725
749
  async run({ args }) {
726
750
  const spinner = args.quiet ? null : ora3();
727
751
  const outDir = resolve(args.out ?? "./emailens-report");
728
752
  try {
729
- const format = resolveFormat(args.format, args.input);
753
+ if (args.format) resolveFormat(args.format);
730
754
  spinner?.start("Reading input...");
731
755
  const source = await readInput(args.input);
732
756
  spinner?.succeed("Input read");
757
+ const format = resolveFormat(args.format, args.input, source);
733
758
  const html = await compile3(source, format);
734
759
  const clientIds = resolveClients(args.clients);
735
760
  const clientSet = new Set(clientIds);
@@ -739,7 +764,9 @@ var export_default = defineCommand4({
739
764
  spinner?.succeed(`Transformed for ${transforms.length} clients`);
740
765
  spinner?.start("Analyzing compatibility...");
741
766
  const framework = toFramework(format);
742
- const warnings = analyzeEmail3(html, framework);
767
+ const warnings = analyzeEmail3(html, framework, {
768
+ targetingPolicy: parseTargetingPolicy(args.targetingPolicy)
769
+ });
743
770
  const allScores = generateCompatibilityScore3(warnings);
744
771
  const scores = {};
745
772
  for (const [id, data] of Object.entries(allScores)) {
@@ -747,7 +774,7 @@ var export_default = defineCommand4({
747
774
  scores[id] = data;
748
775
  }
749
776
  }
750
- const filteredWarnings = clientIds.flatMap((id) => warningsForClient3(warnings, id));
777
+ const filteredWarnings = clientIds.flatMap((id) => warnings.filter((w) => w.client === id));
751
778
  spinner?.succeed("Analysis complete");
752
779
  let darkMode;
753
780
  if (args["dark-mode"]) {
@@ -875,19 +902,23 @@ var fix_default = defineCommand5({
875
902
  "max-tokens": {
876
903
  type: "string",
877
904
  description: "Maximum input tokens for the prompt (default: 16000)"
878
- }
905
+ },
906
+ targetingPolicy: TARGETING_POLICY_ARG
879
907
  },
880
908
  async run({ args }) {
881
909
  const spinner = args.quiet || args.json ? null : ora4();
882
910
  try {
883
- const format = resolveFormat(args.format, args.input);
884
- const framework = toFramework(format);
911
+ if (args.format) resolveFormat(args.format);
885
912
  spinner?.start("Reading input...");
886
913
  const source = await readInput(args.input);
914
+ const format = resolveFormat(args.format, args.input, source);
915
+ const framework = toFramework(format);
887
916
  const html = await compile4(source, format);
888
917
  spinner?.succeed("Input read");
889
918
  spinner?.start("Analyzing compatibility...");
890
- const warnings = analyzeEmail4(html, framework);
919
+ const warnings = analyzeEmail4(html, framework, {
920
+ targetingPolicy: parseTargetingPolicy(args.targetingPolicy)
921
+ });
891
922
  const scores = generateCompatibilityScore4(warnings);
892
923
  spinner?.succeed(`Found ${warnings.length} warnings`);
893
924
  if (warnings.length === 0) {
@@ -1027,11 +1058,11 @@ import {
1027
1058
  CompileError as CompileError5
1028
1059
  } from "@emailens/engine";
1029
1060
  import { compile as compile5 } from "@emailens/engine/compile";
1030
- var VALID_SKIPS = /* @__PURE__ */ new Set(["spam", "links", "accessibility", "images", "compatibility", "overflow", "visual", "darkContrast", "mobileContrast", "design", "vml", "styleSurvival"]);
1061
+ var VALID_SKIPS = /* @__PURE__ */ new Set(["spam", "links", "accessibility", "images", "compatibility", "overflow", "visual", "darkContrast", "mobileContrast", "design", "vml", "styleSurvival", "targeting"]);
1031
1062
  var audit_default = defineCommand6({
1032
1063
  meta: {
1033
1064
  name: "audit",
1034
- description: "Full email audit: compatibility + spam + links + accessibility + images + overflow + visual + contrast + design + Outlook VML"
1065
+ description: "Full email audit: compatibility + spam + links + accessibility + images + overflow + visual + contrast + design + Outlook VML + client targeting"
1035
1066
  },
1036
1067
  args: {
1037
1068
  input: {
@@ -1055,16 +1086,18 @@ var audit_default = defineCommand6({
1055
1086
  },
1056
1087
  skip: {
1057
1088
  type: "string",
1058
- description: "Comma-separated checks to skip: spam,links,accessibility,images,compatibility,overflow,visual,darkContrast,mobileContrast,design,vml"
1059
- }
1089
+ description: "Comma-separated checks to skip: spam,links,accessibility,images,compatibility,overflow,visual,darkContrast,mobileContrast,design,vml,styleSurvival,targeting"
1090
+ },
1091
+ targetingPolicy: TARGETING_POLICY_ARG
1060
1092
  },
1061
1093
  async run({ args }) {
1062
1094
  const spinner = args.quiet || args.json ? null : ora5();
1063
1095
  try {
1064
- const format = resolveFormat(args.format, args.input);
1096
+ if (args.format) resolveFormat(args.format);
1065
1097
  spinner?.start("Reading input...");
1066
1098
  const source = await readInput(args.input);
1067
1099
  spinner?.succeed("Input read");
1100
+ const format = resolveFormat(args.format, args.input, source);
1068
1101
  const html = await compile5(source, format);
1069
1102
  const skip = [];
1070
1103
  if (args.skip) {
@@ -1077,7 +1110,8 @@ var audit_default = defineCommand6({
1077
1110
  }
1078
1111
  spinner?.start("Running full audit...");
1079
1112
  const framework = toFramework(format);
1080
- const report = auditEmail(html, { framework, skip });
1113
+ const targetingPolicy = parseTargetingPolicy(args.targetingPolicy);
1114
+ const report = auditEmail(html, { framework, skip, targetingPolicy });
1081
1115
  spinner?.succeed("Audit complete");
1082
1116
  if (args.json) {
1083
1117
  console.log(JSON.stringify(report, null, 2));
@@ -1162,6 +1196,14 @@ function printQualitySummary(report, skip, options) {
1162
1196
  const color = issues.length === 0 ? pc4.green : pc4.red;
1163
1197
  table.push(["Style Survival", color(`${issues.length} issue${issues.length === 1 ? "" : "s"}`)]);
1164
1198
  }
1199
+ if (!skip.includes("targeting")) {
1200
+ const { detectedHacks, warnings } = report.targeting;
1201
+ const color = warnings.length === 0 ? pc4.green : pc4.yellow;
1202
+ table.push([
1203
+ "Client Targeting",
1204
+ `${detectedHacks.length} detected, ${color(`${warnings.length} warning${warnings.length === 1 ? "" : "s"}`)}`
1205
+ ]);
1206
+ }
1165
1207
  console.log(table.toString());
1166
1208
  console.log();
1167
1209
  }
@@ -1261,7 +1303,8 @@ var VALID_SKIPS2 = /* @__PURE__ */ new Set([
1261
1303
  "mobileContrast",
1262
1304
  "design",
1263
1305
  "vml",
1264
- "styleSurvival"
1306
+ "styleSurvival",
1307
+ "targeting"
1265
1308
  ]);
1266
1309
  var lint_default = defineCommand7({
1267
1310
  meta: {
@@ -1289,8 +1332,9 @@ var lint_default = defineCommand7({
1289
1332
  },
1290
1333
  skip: {
1291
1334
  type: "string",
1292
- description: "Comma-separated checks to skip: spam,links,accessibility,images,compatibility,inboxPreview,size,templateVariables,overflow,visual,vml"
1335
+ description: "Comma-separated checks to skip: spam,links,accessibility,images,compatibility,inboxPreview,size,templateVariables,overflow,visual,vml,styleSurvival,targeting"
1293
1336
  },
1337
+ targetingPolicy: TARGETING_POLICY_ARG,
1294
1338
  maxWarnings: {
1295
1339
  type: "string",
1296
1340
  description: "Fail if more than n warnings"
@@ -1298,6 +1342,7 @@ var lint_default = defineCommand7({
1298
1342
  },
1299
1343
  async run({ args }) {
1300
1344
  try {
1345
+ if (args.format) resolveFormat(args.format);
1301
1346
  const project = loadProjectConfig();
1302
1347
  if (project?.invalid.length && !args.json) {
1303
1348
  console.error(
@@ -1341,15 +1386,17 @@ var lint_default = defineCommand7({
1341
1386
  let totalErrors = 0;
1342
1387
  let totalWarnings = 0;
1343
1388
  for (const file of files) {
1344
- const format = resolveFormat(args.format, file);
1345
1389
  const source = await readInput(file);
1390
+ const format = resolveFormat(args.format, file, source);
1346
1391
  const html = await compile6(source, format);
1347
1392
  const framework = toFramework(format);
1348
1393
  const positions = positionsApply(format);
1394
+ const targetingPolicy = parseTargetingPolicy(args.targetingPolicy);
1349
1395
  const report = auditEmail2(html, {
1350
1396
  framework,
1351
1397
  skip,
1352
- positions
1398
+ positions,
1399
+ targetingPolicy
1353
1400
  });
1354
1401
  const issues = applySeverities(flattenToLintIssues(report, skip), project?.rules);
1355
1402
  const errors = issues.filter((i) => i.severity === "error").length;
@@ -1624,6 +1671,20 @@ function flattenToLintIssues(report, skip) {
1624
1671
  });
1625
1672
  }
1626
1673
  }
1674
+ if (!skip.includes("targeting")) {
1675
+ for (const issue of report.targeting.warnings) {
1676
+ issues.push({
1677
+ severity: issue.severity,
1678
+ category: "targeting",
1679
+ rule: issue.property,
1680
+ message: issue.message,
1681
+ detail: issue.suggestion,
1682
+ ...issue.loc ? { loc: issue.loc } : {},
1683
+ ...issue.locs?.length ? { locs: issue.locs } : {},
1684
+ ...issue.locsTruncated ? { locsTruncated: true } : {}
1685
+ });
1686
+ }
1687
+ }
1627
1688
  if (!skip.includes("design")) {
1628
1689
  for (const issue of report.design.issues) {
1629
1690
  issues.push({