@emailens/cli 0.3.2 → 0.3.3

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/README.md CHANGED
@@ -74,6 +74,45 @@ cat email.html | emailens fix - --format jsx # Pipe from stdin
74
74
  | `--estimate` | | Only show token estimate without calling the AI |
75
75
  | `--max-tokens` | | Maximum input tokens for the prompt (default: 16000) |
76
76
 
77
+ ### `emailens lint <file|glob>`
78
+
79
+ CI/CD-friendly linting with structured exit codes. Flattens all audit checks (compatibility, content hygiene, links, accessibility, images, inbox preview, size, template variables) into a unified issue list.
80
+
81
+ ```bash
82
+ emailens lint email.html
83
+ emailens lint src/*.html
84
+ emailens lint email.html --json
85
+ emailens lint email.html --fail-on-warning
86
+ emailens lint email.html --skip spam,links
87
+ emailens lint email.html --max-warnings 5
88
+ ```
89
+
90
+ | Flag | Alias | Description |
91
+ |------|-------|-------------|
92
+ | `--format` | `-f` | Input format: `html`, `jsx`, `mjml`, `maizzle` |
93
+ | `--json` | | Output as JSON |
94
+ | `--fail-on-warning` | | Exit 2 if warnings found |
95
+ | `--skip` | | Comma-separated checks to skip: `spam,links,accessibility,images,compatibility,inboxPreview,size,templateVariables` |
96
+ | `--max-warnings` | | Fail if more than n warnings |
97
+
98
+ **Exit codes:**
99
+ - `0` — clean
100
+ - `1` — errors found
101
+ - `2` — warnings only (with `--fail-on-warning` or `--max-warnings` exceeded)
102
+
103
+ **Output format:**
104
+
105
+ ```
106
+ src/emails/welcome.html
107
+ error outlook-windows border-radius Not supported in Outlook Windows
108
+ warn spam caps-ratio 20%+ of words are ALL CAPS
109
+
110
+ src/emails/newsletter.html
111
+ pass No issues found
112
+
113
+ 2 files | 1 error | 1 warning
114
+ ```
115
+
77
116
  ### `emailens clients`
78
117
 
79
118
  List all 12 supported email clients.
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ export {
10
+ __require
11
+ };
12
+ //# sourceMappingURL=chunk-7OCVIDC7.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import {
3
+ __require
4
+ } from "./chunk-7OCVIDC7.js";
2
5
 
3
6
  // src/index.ts
4
- import { defineCommand as defineCommand7, runMain } from "citty";
7
+ import { defineCommand as defineCommand8, runMain } from "citty";
5
8
 
6
9
  // src/commands/analyze.ts
7
10
  import { defineCommand } from "citty";
@@ -24,7 +27,7 @@ async function readInput(pathOrStdin) {
24
27
  return readFileSync("/dev/stdin", "utf-8");
25
28
  } catch {
26
29
  }
27
- return new Promise((resolve2, reject) => {
30
+ return new Promise((resolve3, reject) => {
28
31
  const chunks = [];
29
32
  process.stdin.resume();
30
33
  process.stdin.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
@@ -33,7 +36,7 @@ async function readInput(pathOrStdin) {
33
36
  if (!data.trim()) {
34
37
  reject(new Error("No input received from stdin. Pipe HTML or provide a file path."));
35
38
  } else {
36
- resolve2(data);
39
+ resolve3(data);
37
40
  }
38
41
  });
39
42
  process.stdin.on("error", () => {
@@ -394,7 +397,7 @@ var preview_default = defineCommand2({
394
397
  const outDir = args.out ?? "./emailens-screenshots";
395
398
  spinner?.start("Capturing screenshots...");
396
399
  try {
397
- const { captureScreenshots } = await import("./screenshots-PMZQU5V7.js");
400
+ const { captureScreenshots } = await import("./screenshots-UOLRXFAJ.js");
398
401
  const results = await captureScreenshots(
399
402
  transforms,
400
403
  outDir,
@@ -566,6 +569,11 @@ function escapeHtml(str) {
566
569
  }
567
570
 
568
571
  // src/commands/export.ts
572
+ var toPlainText = null;
573
+ try {
574
+ ({ toPlainText } = __require("@emailens/engine"));
575
+ } catch {
576
+ }
569
577
  var export_default = defineCommand4({
570
578
  meta: {
571
579
  name: "export",
@@ -605,6 +613,10 @@ var export_default = defineCommand4({
605
613
  type: "boolean",
606
614
  description: "Export as JSON instead of HTML"
607
615
  },
616
+ "plain-text": {
617
+ type: "boolean",
618
+ description: "Also export a plain text version (.txt) for multipart emails"
619
+ },
608
620
  quiet: {
609
621
  type: "boolean",
610
622
  alias: "q",
@@ -650,7 +662,7 @@ var export_default = defineCommand4({
650
662
  if (args.screenshots) {
651
663
  spinner?.start("Capturing screenshots...");
652
664
  try {
653
- const { captureScreenshots } = await import("./screenshots-PMZQU5V7.js");
665
+ const { captureScreenshots } = await import("./screenshots-UOLRXFAJ.js");
654
666
  const screenshotDir = join(outDir, "screenshots");
655
667
  const results = await captureScreenshots(
656
668
  transforms,
@@ -684,6 +696,15 @@ var export_default = defineCommand4({
684
696
  writeFileSync(htmlPath, htmlReport);
685
697
  spinner?.succeed(`HTML report saved to ${htmlPath}`);
686
698
  }
699
+ if (args["plain-text"] && toPlainText) {
700
+ spinner?.start("Generating plain text version...");
701
+ const plainText = toPlainText(html);
702
+ const txtPath = join(outDir, "email.txt");
703
+ writeFileSync(txtPath, plainText);
704
+ spinner?.succeed(`Plain text saved to ${txtPath}`);
705
+ } else if (args["plain-text"] && !toPlainText) {
706
+ spinner?.warn("Plain text export requires @emailens/engine >=0.8.6");
707
+ }
687
708
  if (!args.quiet) {
688
709
  console.log();
689
710
  console.log(` Output: ${outDir}`);
@@ -840,7 +861,7 @@ var fix_default = defineCommand5({
840
861
  spinner?.start("Generating AI fix...");
841
862
  let Anthropic;
842
863
  try {
843
- Anthropic = (await import("./sdk-HQZAL655.js")).default;
864
+ Anthropic = (await import("./sdk-IYJIYBYM.js")).default;
844
865
  } catch {
845
866
  spinner?.fail("@anthropic-ai/sdk is required for the fix command");
846
867
  console.error(pc3.dim("\n Install it with: npm install @anthropic-ai/sdk\n"));
@@ -990,7 +1011,7 @@ function printQualitySummary(report, skip, options) {
990
1011
  if (!skip.includes("spam")) {
991
1012
  const { score, level, issues } = report.spam;
992
1013
  const color = score >= 90 ? pc4.green : score >= 70 ? pc4.yellow : pc4.red;
993
- table.push(["Spam Score", `${color(String(score))}/100 (${level}) \u2014 ${issues.length} issue${issues.length === 1 ? "" : "s"}`]);
1014
+ table.push(["Content Hygiene", `${color(String(score))}/100 (${level}) \u2014 ${issues.length} issue${issues.length === 1 ? "" : "s"}`]);
994
1015
  }
995
1016
  if (!skip.includes("links")) {
996
1017
  const { totalLinks, issues } = report.links;
@@ -1011,11 +1032,275 @@ function printQualitySummary(report, skip, options) {
1011
1032
  console.log();
1012
1033
  }
1013
1034
 
1035
+ // src/commands/lint.ts
1036
+ import { defineCommand as defineCommand7 } from "citty";
1037
+ import pc5 from "picocolors";
1038
+ import { compile as compile6 } from "@emailens/engine/compile";
1039
+ import {
1040
+ auditEmail as auditEmail2,
1041
+ CompileError as CompileError6
1042
+ } from "@emailens/engine";
1043
+ import { readdir } from "fs/promises";
1044
+ import { resolve as resolve2, relative } from "path";
1045
+ var VALID_SKIPS2 = /* @__PURE__ */ new Set([
1046
+ "spam",
1047
+ "links",
1048
+ "accessibility",
1049
+ "images",
1050
+ "compatibility",
1051
+ "inboxPreview",
1052
+ "size",
1053
+ "templateVariables"
1054
+ ]);
1055
+ var lint_default = defineCommand7({
1056
+ meta: {
1057
+ name: "lint",
1058
+ description: "Lint HTML emails for compatibility, spam, accessibility, and more. CI/CD-friendly with structured exit codes."
1059
+ },
1060
+ args: {
1061
+ input: {
1062
+ type: "positional",
1063
+ description: "HTML file path or glob pattern",
1064
+ required: true
1065
+ },
1066
+ format: {
1067
+ type: "string",
1068
+ alias: "f",
1069
+ description: "Input format: html, jsx, mjml, maizzle"
1070
+ },
1071
+ json: {
1072
+ type: "boolean",
1073
+ description: "Output as JSON"
1074
+ },
1075
+ failOnWarning: {
1076
+ type: "boolean",
1077
+ description: "Exit 2 if warnings found"
1078
+ },
1079
+ skip: {
1080
+ type: "string",
1081
+ description: "Comma-separated checks to skip: spam,links,accessibility,images,compatibility,inboxPreview,size,templateVariables"
1082
+ },
1083
+ maxWarnings: {
1084
+ type: "string",
1085
+ description: "Fail if more than n warnings"
1086
+ }
1087
+ },
1088
+ async run({ args }) {
1089
+ try {
1090
+ const skip = [];
1091
+ if (args.skip) {
1092
+ for (const s of args.skip.split(",").map((s2) => s2.trim()).filter(Boolean)) {
1093
+ if (!VALID_SKIPS2.has(s)) {
1094
+ throw new Error(`Unknown skip value "${s}". Valid: ${Array.from(VALID_SKIPS2).join(", ")}`);
1095
+ }
1096
+ skip.push(s);
1097
+ }
1098
+ }
1099
+ const maxWarningsRaw = args.maxWarnings !== void 0 ? parseInt(args.maxWarnings, 10) : void 0;
1100
+ if (maxWarningsRaw !== void 0 && isNaN(maxWarningsRaw)) {
1101
+ throw new Error(`--maxWarnings must be a number, got: ${args.maxWarnings}`);
1102
+ }
1103
+ const maxWarnings = maxWarningsRaw ?? Infinity;
1104
+ const files = await resolveGlob(args.input);
1105
+ if (files.length === 0) {
1106
+ if (args.json) {
1107
+ console.log(JSON.stringify({ files: [], totalErrors: 0, totalWarnings: 0 }));
1108
+ } else {
1109
+ console.error(pc5.red(`No files found matching: ${args.input}`));
1110
+ }
1111
+ process.exit(1);
1112
+ }
1113
+ const results = [];
1114
+ let totalErrors = 0;
1115
+ let totalWarnings = 0;
1116
+ for (const file of files) {
1117
+ const format = resolveFormat(args.format, file);
1118
+ const source = await readInput(file);
1119
+ const html = await compile6(source, format, file);
1120
+ const framework = toFramework(format);
1121
+ const report = auditEmail2(html, {
1122
+ framework,
1123
+ skip
1124
+ });
1125
+ const issues = flattenToLintIssues(report, skip);
1126
+ const errors = issues.filter((i) => i.severity === "error").length;
1127
+ const warnings = issues.filter((i) => i.severity === "warning").length;
1128
+ results.push({ file: relative(process.cwd(), file), issues, errors, warnings });
1129
+ totalErrors += errors;
1130
+ totalWarnings += warnings;
1131
+ }
1132
+ if (args.json) {
1133
+ console.log(JSON.stringify({ files: results, totalErrors, totalWarnings }, null, 2));
1134
+ } else {
1135
+ for (const result of results) {
1136
+ console.log(pc5.underline(result.file));
1137
+ if (result.issues.length === 0) {
1138
+ console.log(` ${pc5.green("pass")} No issues found`);
1139
+ } else {
1140
+ for (const issue of result.issues) {
1141
+ const sev = issue.severity === "error" ? pc5.red("error") : issue.severity === "warning" ? pc5.yellow("warn ") : pc5.blue("info ");
1142
+ console.log(` ${sev} ${pc5.dim(issue.category.padEnd(18))} ${pc5.bold(issue.rule.padEnd(22))} ${issue.message}`);
1143
+ }
1144
+ }
1145
+ console.log();
1146
+ }
1147
+ const summary = [
1148
+ `${files.length} file${files.length === 1 ? "" : "s"}`,
1149
+ totalErrors > 0 ? pc5.red(`${totalErrors} error${totalErrors === 1 ? "" : "s"}`) : null,
1150
+ totalWarnings > 0 ? pc5.yellow(`${totalWarnings} warning${totalWarnings === 1 ? "" : "s"}`) : null,
1151
+ totalErrors === 0 && totalWarnings === 0 ? pc5.green("all clean") : null
1152
+ ].filter(Boolean).join(" | ");
1153
+ console.log(summary);
1154
+ }
1155
+ if (totalErrors > 0) {
1156
+ process.exit(1);
1157
+ }
1158
+ if (args.failOnWarning && totalWarnings > 0) {
1159
+ if (!args.json) {
1160
+ console.error(pc5.yellow(`
1161
+ Failed: ${totalWarnings} warning${totalWarnings === 1 ? "" : "s"} found (--failOnWarning)`));
1162
+ }
1163
+ process.exit(2);
1164
+ }
1165
+ if (Number.isFinite(maxWarnings) && totalWarnings > maxWarnings) {
1166
+ if (!args.json) {
1167
+ console.error(pc5.red(`
1168
+ Exceeded max warnings: ${totalWarnings} > ${maxWarnings}`));
1169
+ }
1170
+ process.exit(2);
1171
+ }
1172
+ } catch (err) {
1173
+ if (err instanceof CompileError6) {
1174
+ console.error(pc5.red(`Compilation failed (${err.phase}): ${err.message}`));
1175
+ } else {
1176
+ console.error(pc5.red(err.message));
1177
+ }
1178
+ process.exit(1);
1179
+ }
1180
+ }
1181
+ });
1182
+ async function resolveGlob(pattern) {
1183
+ if (!pattern.includes("*")) {
1184
+ return [resolve2(pattern)];
1185
+ }
1186
+ if (pattern.includes("**")) {
1187
+ throw new Error("Recursive glob (**) is not supported. Use a single-level wildcard (e.g., src/*.html) or list files explicitly.");
1188
+ }
1189
+ const lastSep = Math.max(pattern.lastIndexOf("/"), pattern.lastIndexOf("\\"));
1190
+ const dir = lastSep >= 0 ? pattern.slice(0, lastSep) : ".";
1191
+ const glob = lastSep >= 0 ? pattern.slice(lastSep + 1) : pattern;
1192
+ const regex = new RegExp("^" + glob.replace(/\*/g, ".*") + "$");
1193
+ const entries = await readdir(resolve2(dir), { withFileTypes: true });
1194
+ return entries.filter((e) => e.isFile() && regex.test(e.name)).map((e) => resolve2(dir, e.name)).sort();
1195
+ }
1196
+ function flattenToLintIssues(report, skip) {
1197
+ const issues = [];
1198
+ if (!skip.includes("compatibility")) {
1199
+ const seenProps = /* @__PURE__ */ new Map();
1200
+ for (const w of report.compatibility.warnings) {
1201
+ const key = `${w.property}\0${w.severity}`;
1202
+ const existing = seenProps.get(key);
1203
+ if (existing) {
1204
+ existing.clients.push(w.client);
1205
+ } else {
1206
+ seenProps.set(key, {
1207
+ severity: w.severity,
1208
+ clients: [w.client],
1209
+ message: w.message
1210
+ });
1211
+ }
1212
+ }
1213
+ for (const [key, val] of seenProps) {
1214
+ const property = key.split("\0")[0];
1215
+ issues.push({
1216
+ severity: val.severity,
1217
+ category: val.clients.slice(0, 3).join(",") + (val.clients.length > 3 ? `+${val.clients.length - 3}` : ""),
1218
+ rule: property,
1219
+ message: val.message
1220
+ });
1221
+ }
1222
+ }
1223
+ if (!skip.includes("spam")) {
1224
+ for (const issue of report.spam.issues) {
1225
+ issues.push({
1226
+ severity: issue.severity,
1227
+ category: "spam",
1228
+ rule: issue.rule,
1229
+ message: issue.message,
1230
+ detail: issue.detail
1231
+ });
1232
+ }
1233
+ }
1234
+ if (!skip.includes("links")) {
1235
+ for (const issue of report.links.issues) {
1236
+ issues.push({
1237
+ severity: issue.severity,
1238
+ category: "links",
1239
+ rule: issue.rule,
1240
+ message: issue.message
1241
+ });
1242
+ }
1243
+ }
1244
+ if (!skip.includes("accessibility")) {
1245
+ for (const issue of report.accessibility.issues) {
1246
+ issues.push({
1247
+ severity: issue.severity,
1248
+ category: "accessibility",
1249
+ rule: issue.rule,
1250
+ message: issue.message
1251
+ });
1252
+ }
1253
+ }
1254
+ if (!skip.includes("images")) {
1255
+ for (const issue of report.images.issues) {
1256
+ issues.push({
1257
+ severity: issue.severity,
1258
+ category: "images",
1259
+ rule: issue.rule,
1260
+ message: issue.message
1261
+ });
1262
+ }
1263
+ }
1264
+ if (!skip.includes("inboxPreview")) {
1265
+ for (const issue of report.inboxPreview.issues) {
1266
+ issues.push({
1267
+ severity: issue.severity,
1268
+ category: "inboxPreview",
1269
+ rule: issue.rule,
1270
+ message: issue.message
1271
+ });
1272
+ }
1273
+ }
1274
+ if (!skip.includes("size")) {
1275
+ for (const issue of report.size.issues) {
1276
+ issues.push({
1277
+ severity: issue.severity,
1278
+ category: "size",
1279
+ rule: issue.rule,
1280
+ message: issue.message
1281
+ });
1282
+ }
1283
+ }
1284
+ if (!skip.includes("templateVariables")) {
1285
+ for (const issue of report.templateVariables.issues) {
1286
+ issues.push({
1287
+ severity: issue.severity,
1288
+ category: "templateVars",
1289
+ rule: issue.rule,
1290
+ message: issue.message
1291
+ });
1292
+ }
1293
+ }
1294
+ const order = { error: 0, warning: 1, info: 2 };
1295
+ issues.sort((a, b) => order[a.severity] - order[b.severity]);
1296
+ return issues;
1297
+ }
1298
+
1014
1299
  // src/index.ts
1015
- var main = defineCommand7({
1300
+ var main = defineCommand8({
1016
1301
  meta: {
1017
1302
  name: "emailens",
1018
- version: "0.3.0",
1303
+ version: "0.4.0",
1019
1304
  description: "Email compatibility analysis CLI \u2014 preview how HTML emails render across 12 email clients."
1020
1305
  },
1021
1306
  subCommands: {
@@ -1024,7 +1309,8 @@ var main = defineCommand7({
1024
1309
  clients: clients_default,
1025
1310
  export: export_default,
1026
1311
  fix: fix_default,
1027
- audit: audit_default
1312
+ audit: audit_default,
1313
+ lint: lint_default
1028
1314
  }
1029
1315
  });
1030
1316
  runMain(main);