@reddoorla/maintenance 0.47.0 → 0.50.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/dist/index.js CHANGED
@@ -939,13 +939,348 @@ async function a11yAudit(ctx) {
939
939
  }
940
940
  }
941
941
 
942
+ // src/audits/domain.ts
943
+ import { promises as dnsPromises } from "dns";
944
+ import tls from "tls";
945
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
946
+ async function checkDomain(url, deps) {
947
+ let host;
948
+ try {
949
+ host = new URL(url).hostname;
950
+ } catch {
951
+ return { resolved: false, certDaysRemaining: null };
952
+ }
953
+ try {
954
+ await deps.lookup(host);
955
+ } catch {
956
+ return { resolved: false, certDaysRemaining: null };
957
+ }
958
+ let validTo;
959
+ try {
960
+ validTo = await deps.certValidTo(host);
961
+ } catch {
962
+ validTo = null;
963
+ }
964
+ if (!validTo || Number.isNaN(validTo.getTime()))
965
+ return { resolved: true, certDaysRemaining: null };
966
+ return {
967
+ resolved: true,
968
+ certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY)
969
+ };
970
+ }
971
+ function defaultDomainDeps(now) {
972
+ return {
973
+ lookup: async (host) => {
974
+ await dnsPromises.lookup(host);
975
+ },
976
+ certValidTo: (host) => new Promise((resolvePromise) => {
977
+ const socket = tls.connect(
978
+ { host, port: 443, servername: host, timeout: 1e4, rejectUnauthorized: true },
979
+ () => {
980
+ const cert = socket.authorized ? socket.getPeerCertificate() : null;
981
+ socket.end();
982
+ const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;
983
+ resolvePromise(validTo);
984
+ }
985
+ );
986
+ socket.on("error", () => resolvePromise(null));
987
+ socket.on("timeout", () => {
988
+ socket.destroy();
989
+ resolvePromise(null);
990
+ });
991
+ }),
992
+ now
993
+ };
994
+ }
995
+ async function domainAudit(ctx) {
996
+ const { site } = ctx;
997
+ const label = siteLabel(site);
998
+ if (!site.deployedUrl) {
999
+ return { audit: "domain", site: label, status: "skip", summary: "no deployed URL" };
1000
+ }
1001
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
1002
+ const deps = ctx.domainDeps ?? defaultDomainDeps(now);
1003
+ const check = await checkDomain(site.deployedUrl, deps);
1004
+ const checkedAt = now.toISOString();
1005
+ const status = check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14 ? "pass" : "warn";
1006
+ const summary = !check.resolved ? "did not resolve" : check.certDaysRemaining === null ? "resolved, no usable TLS cert" : `resolved, cert ${check.certDaysRemaining}d remaining`;
1007
+ return {
1008
+ audit: "domain",
1009
+ site: label,
1010
+ status,
1011
+ summary,
1012
+ details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt }
1013
+ };
1014
+ }
1015
+
1016
+ // src/audits/route-discovery.ts
1017
+ var DEFAULT_CAP = 15;
1018
+ function parseSitemapUrls(xml) {
1019
+ const out = [];
1020
+ const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
1021
+ let m;
1022
+ while ((m = re.exec(xml)) !== null) {
1023
+ const url = m[1];
1024
+ if (url) out.push(url.trim());
1025
+ }
1026
+ return out;
1027
+ }
1028
+ function parseHtmlLinks(html, baseUrl) {
1029
+ const out = /* @__PURE__ */ new Set();
1030
+ const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
1031
+ let m;
1032
+ while ((m = re.exec(html)) !== null) {
1033
+ const href = m[1];
1034
+ if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
1035
+ try {
1036
+ const u = new URL(href, baseUrl);
1037
+ if (u.origin !== new URL(baseUrl).origin) continue;
1038
+ out.add(u.pathname);
1039
+ } catch {
1040
+ }
1041
+ }
1042
+ return [...out];
1043
+ }
1044
+ function family(pathname) {
1045
+ return pathname.split("/").filter(Boolean)[0] ?? "";
1046
+ }
1047
+ function sampleRoutePaths(urlsOrPaths, cap = DEFAULT_CAP) {
1048
+ const seen = /* @__PURE__ */ new Set(["/"]);
1049
+ const buckets = /* @__PURE__ */ new Map();
1050
+ for (const raw of urlsOrPaths) {
1051
+ let pathname;
1052
+ try {
1053
+ pathname = raw.startsWith("/") ? new URL(raw, "https://x.invalid").pathname : new URL(raw).pathname;
1054
+ } catch {
1055
+ continue;
1056
+ }
1057
+ if (pathname === "/") continue;
1058
+ if (seen.has(pathname)) continue;
1059
+ seen.add(pathname);
1060
+ const fam = family(pathname);
1061
+ const arr = buckets.get(fam) ?? [];
1062
+ arr.push(pathname);
1063
+ buckets.set(fam, arr);
1064
+ }
1065
+ const result = ["/"];
1066
+ const families = [...buckets.values()];
1067
+ let guard = 0;
1068
+ while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 1e4) {
1069
+ for (const fam of families) {
1070
+ if (result.length >= cap) break;
1071
+ const next = fam.shift();
1072
+ if (next) result.push(next);
1073
+ }
1074
+ }
1075
+ return result;
1076
+ }
1077
+ function familyCountsOf(paths) {
1078
+ const counts = {};
1079
+ for (const p of paths) {
1080
+ const key = p === "/" ? "/" : `/${family(p)}`;
1081
+ counts[key] = (counts[key] ?? 0) + 1;
1082
+ }
1083
+ return counts;
1084
+ }
1085
+ async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
1086
+ const origin = new URL(deployedUrl).origin;
1087
+ const abs = (paths) => paths.map((p) => new URL(p, origin).href);
1088
+ const sitemapXml = await deps.fetchText(new URL("/sitemap.xml", origin).href);
1089
+ if (sitemapXml) {
1090
+ const urls = parseSitemapUrls(sitemapXml);
1091
+ if (urls.length > 0) {
1092
+ const paths = sampleRoutePaths(urls, cap);
1093
+ return { routes: abs(paths), source: "sitemap", familyCounts: familyCountsOf(paths) };
1094
+ }
1095
+ }
1096
+ const homeHtml = await deps.fetchText(origin);
1097
+ if (homeHtml) {
1098
+ const links = parseHtmlLinks(homeHtml, origin);
1099
+ if (links.length > 0) {
1100
+ const paths = sampleRoutePaths(links, cap);
1101
+ return { routes: abs(paths), source: "homepage-links", familyCounts: familyCountsOf(paths) };
1102
+ }
1103
+ }
1104
+ return { routes: [new URL("/", origin).href], source: "root-only", familyCounts: { "/": 1 } };
1105
+ }
1106
+
1107
+ // src/audits/browser.ts
1108
+ function isBroken(status) {
1109
+ return status === null || status >= 400;
1110
+ }
1111
+ function summarizeBrowser(routes, links, familyCounts) {
1112
+ const desktopChecks = routes.flatMap((r) => r.desktop);
1113
+ const mobileChecks = routes.flatMap((r) => r.mobile);
1114
+ const desktopOk = routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));
1115
+ const mobileOk = routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));
1116
+ const brokenLinks = links.filter((l) => isBroken(l.status)).length;
1117
+ const linksOk = links.length > 0 && brokenLinks === 0;
1118
+ const engines = [...new Set(desktopChecks.map((d) => d.engine))];
1119
+ const devices2 = [...new Set(mobileChecks.map((m) => m.device))];
1120
+ const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
1121
+ const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices2.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken`;
1122
+ return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };
1123
+ }
1124
+ async function browserAudit(ctx) {
1125
+ const { site } = ctx;
1126
+ const label = siteLabel(site);
1127
+ if (!site.deployedUrl) {
1128
+ return { audit: "browser", site: label, status: "skip", summary: "no deployed URL" };
1129
+ }
1130
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
1131
+ const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();
1132
+ const runner = ctx.browserRunner ?? await defaultBrowserRunner();
1133
+ try {
1134
+ const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
1135
+ const routeResults = await runner.probe(discovered.routes);
1136
+ const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];
1137
+ const linkResults = await runner.checkLinks(internalLinks);
1138
+ const summary = summarizeBrowser(
1139
+ routeResults,
1140
+ linkResults,
1141
+ discovered.familyCounts ?? familyCountsOf(discovered.routes)
1142
+ );
1143
+ const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
1144
+ return {
1145
+ audit: "browser",
1146
+ site: label,
1147
+ status,
1148
+ summary: summary.note,
1149
+ details: { ...summary, checkedAt: now.toISOString() }
1150
+ };
1151
+ } finally {
1152
+ await runner.close?.();
1153
+ }
1154
+ }
1155
+ function defaultDiscoverDeps() {
1156
+ return {
1157
+ fetchText: async (url) => {
1158
+ try {
1159
+ const res = await fetch(url, { redirect: "follow" });
1160
+ if (!res.ok) return null;
1161
+ return await res.text();
1162
+ } catch {
1163
+ return null;
1164
+ }
1165
+ }
1166
+ };
1167
+ }
1168
+ var DESKTOP_VIEWPORT = { width: 1366, height: 900 };
1169
+ var PAGE_TIMEOUT_MS = 3e4;
1170
+ async function defaultBrowserRunner() {
1171
+ const { chromium, firefox, webkit, devices: devices2 } = await import("@playwright/test");
1172
+ const desktopEngines = [
1173
+ { engine: "chromium", type: chromium },
1174
+ { engine: "firefox", type: firefox },
1175
+ { engine: "webkit", type: webkit }
1176
+ ];
1177
+ const mobileTargets = [
1178
+ { device: "Pixel 7", descriptor: devices2["Pixel 7"] },
1179
+ { device: "iPhone 14", descriptor: devices2["iPhone 14"] }
1180
+ ];
1181
+ return {
1182
+ async probe(urls) {
1183
+ const results = [];
1184
+ const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));
1185
+ const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));
1186
+ try {
1187
+ for (const url of urls) {
1188
+ const desktop = [];
1189
+ const linkSet = /* @__PURE__ */ new Set();
1190
+ for (let i = 0; i < desktopEngines.length; i++) {
1191
+ const engine = desktopEngines[i].engine;
1192
+ const browser = browsers[i];
1193
+ const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
1194
+ const page = await ctx.newPage();
1195
+ const errors = [];
1196
+ page.on("pageerror", (e) => errors.push(String(e)));
1197
+ let ok = false;
1198
+ try {
1199
+ const resp = await page.goto(url, {
1200
+ waitUntil: "domcontentloaded",
1201
+ timeout: PAGE_TIMEOUT_MS
1202
+ });
1203
+ const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
1204
+ ok = !!resp && resp.ok() && errors.length === 0 && hasMain;
1205
+ if (engine === "chromium") {
1206
+ const hrefs = await page.evaluate("Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)").catch(() => []);
1207
+ const origin = new URL(url).origin;
1208
+ for (const h of hrefs) {
1209
+ try {
1210
+ if (new URL(h).origin === origin) linkSet.add(new URL(h).href);
1211
+ } catch {
1212
+ }
1213
+ }
1214
+ }
1215
+ } catch {
1216
+ ok = false;
1217
+ } finally {
1218
+ await ctx.close().catch(() => {
1219
+ });
1220
+ }
1221
+ desktop.push({ engine, ok });
1222
+ }
1223
+ const mobile = [];
1224
+ for (let i = 0; i < mobileTargets.length; i++) {
1225
+ const { device, descriptor } = mobileTargets[i];
1226
+ const browser = mobileBrowsers[i];
1227
+ const ctx = await browser.newContext({ ...descriptor });
1228
+ const page = await ctx.newPage();
1229
+ const errors = [];
1230
+ page.on("pageerror", (e) => errors.push(String(e)));
1231
+ let ok = false;
1232
+ try {
1233
+ const resp = await page.goto(url, {
1234
+ waitUntil: "domcontentloaded",
1235
+ timeout: PAGE_TIMEOUT_MS
1236
+ });
1237
+ const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
1238
+ ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
1239
+ } catch {
1240
+ ok = false;
1241
+ } finally {
1242
+ await ctx.close().catch(() => {
1243
+ });
1244
+ }
1245
+ mobile.push({ device, ok });
1246
+ }
1247
+ results.push({ url, desktop, mobile, links: [...linkSet] });
1248
+ }
1249
+ } finally {
1250
+ await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {
1251
+ })));
1252
+ }
1253
+ return results;
1254
+ },
1255
+ async checkLinks(urls) {
1256
+ const out = [];
1257
+ for (const url of urls) {
1258
+ let status;
1259
+ try {
1260
+ let res = await fetch(url, { method: "HEAD", redirect: "follow" });
1261
+ if (res.status === 405 || res.status === 501) {
1262
+ res = await fetch(url, { method: "GET", redirect: "follow" });
1263
+ }
1264
+ status = res.status;
1265
+ } catch {
1266
+ status = null;
1267
+ }
1268
+ out.push({ url, status });
1269
+ }
1270
+ return out;
1271
+ }
1272
+ };
1273
+ }
1274
+
942
1275
  // src/audits/index.ts
943
1276
  var REGISTRY = {
944
1277
  deps: depsAudit,
945
1278
  lint: lintAudit,
946
1279
  security: securityAudit,
947
1280
  lighthouse: lighthouseAudit,
948
- a11y: a11yAudit
1281
+ a11y: a11yAudit,
1282
+ domain: domainAudit,
1283
+ browser: browserAudit
949
1284
  };
950
1285
  var ALL_AUDIT_NAMES = Object.keys(REGISTRY);
951
1286
  var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
@@ -2557,6 +2892,16 @@ function isHttpUrl(s) {
2557
2892
  }
2558
2893
  return parsed.protocol === "http:" || parsed.protocol === "https:";
2559
2894
  }
2895
+ function isNetlifyAppUrl(s) {
2896
+ let parsed;
2897
+ try {
2898
+ parsed = new URL(s);
2899
+ } catch {
2900
+ return false;
2901
+ }
2902
+ const host = parsed.hostname.toLowerCase();
2903
+ return host === "netlify.app" || host.endsWith(".netlify.app");
2904
+ }
2560
2905
 
2561
2906
  // src/inventory/json.ts
2562
2907
  function validate(raw) {
@@ -2646,6 +2991,10 @@ var ACTIVE_STATUSES = /* @__PURE__ */ new Set([
2646
2991
  "maintenance",
2647
2992
  "launch period"
2648
2993
  ]);
2994
+ var FREQUENCIES = ["None", "Monthly", "Quarterly", "Yearly"];
2995
+ function toFrequency(raw) {
2996
+ return typeof raw === "string" && FREQUENCIES.includes(raw) ? raw : "None";
2997
+ }
2649
2998
  function mapRow(rec) {
2650
2999
  const f = rec.fields;
2651
3000
  const attachments = f["Header image"] ?? [];
@@ -2656,8 +3005,8 @@ function mapRow(rec) {
2656
3005
  url: String(f["url"] ?? ""),
2657
3006
  status: f["Status"] ?? null,
2658
3007
  pointOfContact: f["point of contact"] ?? null,
2659
- maintenanceFreq: f["maintenence freq"] ?? "None",
2660
- testingFreq: f["testing freq"] ?? "None",
3008
+ maintenanceFreq: toFrequency(f["maintenence freq"]),
3009
+ testingFreq: toFrequency(f["testing freq"]),
2661
3010
  maintenanceDay: f["maintenance day"] ?? null,
2662
3011
  testingDay: f["testing day"] ?? null,
2663
3012
  ga4PropertyId: f["GA4 property ID"] ?? null,
@@ -2680,6 +3029,14 @@ function mapRow(rec) {
2680
3029
  securityVulnsHigh: f["Security Vulns High"] ?? null,
2681
3030
  securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
2682
3031
  securityVulnsLow: f["Security Vulns Low"] ?? null,
3032
+ lastSecurityAuditAt: f["Last security audit at"] ?? null,
3033
+ certDaysRemaining: f["Cert days remaining"] ?? null,
3034
+ domainCheckedAt: f["Domain checked at"] ?? null,
3035
+ crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
3036
+ mobileOk: typeof f["Mobile OK"] === "boolean" ? f["Mobile OK"] : null,
3037
+ linksOk: typeof f["Links OK"] === "boolean" ? f["Links OK"] : null,
3038
+ brokenLinks: typeof f["Broken links"] === "number" ? f["Broken links"] : null,
3039
+ browserCheckedAt: f["Browser checked at"] ?? null,
2683
3040
  copyIntro: trimToNull(f["Copy \u2014 Intro"]),
2684
3041
  copyContact: trimToNull(f["Copy \u2014 Contact"]),
2685
3042
  copyFooter: trimToNull(f["Copy \u2014 Footer"]),
@@ -2785,15 +3142,10 @@ var DEFAULT_COPY = {
2785
3142
  ],
2786
3143
  announceHeading: "YOUR ONGOING SITE CARE",
2787
3144
  announceBody: "We've completed a full test of your site and set it up for ongoing care to keep it fast, secure, and healthy. Here's what you can expect from us going forward:",
2788
- announceCadenceHeading: "WHAT TO EXPECT",
2789
- announceTestingLabel: "Full site testing",
2790
- announceMaintenanceLabel: "Routine maintenance",
2791
- announcePreviewLabel: "From your latest full site test:",
2792
- announceScoreNote: "These are independent Google Lighthouse scores, each out of 100 \u2014 higher is better.",
2793
3145
  announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
2794
3146
  announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
2795
3147
  announceCadence: "After each one we'll send you a short report like this \u2014 there's nothing you need to do.",
2796
- announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just reply \u2014 we'd love to help."
3148
+ announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know."
2797
3149
  };
2798
3150
  function override(v) {
2799
3151
  if (typeof v !== "string") return null;
@@ -2883,70 +3235,118 @@ function safeUrl(raw) {
2883
3235
  return "#";
2884
3236
  }
2885
3237
 
2886
- // src/reports/maintenance-email/template.ts
3238
+ // src/reports/email-sections.ts
2887
3239
  var escapeXml = escapeHtml;
3240
+ var RED = "#C00";
3241
+ var GREY = "#757575";
3242
+ var BORDER = "#CCCCCC";
3243
+ var TREND_UP = "#2E7D32";
3244
+ var TREND_NEUTRAL = GREY;
2888
3245
  var CHECK_PNG = `cid:${CHECK_CID}`;
2889
- var BLURRED_TESTS = `cid:${BLURRED_CID}`;
2890
- function fmtDate(d) {
2891
- if (!d || Number.isNaN(d.getTime())) return "";
2892
- const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
2893
- const dd = String(d.getUTCDate()).padStart(2, "0");
2894
- const yyyy = d.getUTCFullYear();
2895
- return `${mm}.${dd}.${yyyy}`;
2896
- }
2897
3246
  function fmtUsers(n) {
2898
3247
  return n.toLocaleString("en-US");
2899
3248
  }
2900
- var TREND_UP = "#2E7D32";
2901
- var TREND_NEUTRAL = "#757575";
2902
- function trendText(color, text) {
2903
- return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
3249
+ function checklistRowsSection(rows, opts) {
3250
+ return rows.map((label, i) => {
3251
+ const isLast = i === rows.length - 1;
3252
+ const border = isLast ? "" : ` border-bottom="solid ${BORDER} 1px"`;
3253
+ const lastPad = isLast ? ` padding-bottom="${opts.lastPaddingBottom}"` : "";
3254
+ return `
3255
+ <mj-section background-color="${opts.background}" padding="0px"${lastPad}>
3256
+ <mj-group>
3257
+ <mj-column padding-left="0px" width="90%"${border}>
3258
+ <mj-text height="25px" padding-left="0px" color="${GREY}" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
3259
+ </mj-column>
3260
+ <mj-column width="10%"${border} padding-top="15px">
3261
+ <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
3262
+ </mj-column>
3263
+ </mj-group>
3264
+ </mj-section>`;
3265
+ }).join("");
3266
+ }
3267
+ var LIGHTHOUSE_ROWS = [
3268
+ { label: "Performance", key: "performance", range: "Acceptable 50\u201389 // Ideal 90\u2013100" },
3269
+ { label: "Readability (A11y)", key: "accessibility", range: "Acceptable 80\u201399 // Ideal 100" },
3270
+ { label: "Best Practices", key: "bestPractices", range: "Acceptable 60\u201379 // Ideal 80\u2013100" },
3271
+ { label: "Site Structure", key: "seo", range: "Acceptable 50\u201389 // Ideal 90\u2013100" }
3272
+ ];
3273
+ function lighthouseScoresSection(lighthouse2, opts = {}) {
3274
+ const background = opts.background ?? "#F4F4F4";
3275
+ const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
3276
+ const labelTop = opts.pad ?? "55px";
3277
+ const footnoteBottom = opts.pad ? "0px" : "36px";
3278
+ const rows = LIGHTHOUSE_ROWS.map(
3279
+ ({ label, key, range }, i) => `
3280
+ <mj-text color="${RED}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
3281
+ <mj-text color="${RED}" font-size="44px" font-weight="400" padding-top="0px">${lighthouse2[key]}</mj-text>
3282
+ <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">${range}</mj-text>${i < LIGHTHOUSE_ROWS.length - 1 ? `
3283
+ <mj-divider border-width="1px" border-style="solid" border-color="${BORDER}" padding="0" />` : ""}`
3284
+ ).join("");
3285
+ return `
3286
+ <mj-section background-color="${background}"${sectionPad}>
3287
+ <mj-column>
3288
+ <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">LIGHTHOUSE SCORES*</mj-text>${rows}
3289
+ <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="${footnoteBottom}" line-height="20px">*A Lighthouse score is a numerical measure provided by Google's Lighthouse tool, which evaluates various aspects of a web page's quality.</mj-text>
3290
+ </mj-column>
3291
+ </mj-section>`;
2904
3292
  }
2905
3293
  function analyticsTrendLine(cur, prev) {
2906
3294
  if (cur === void 0 || prev === void 0) {
2907
- return trendText(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
3295
+ return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
2908
3296
  }
2909
3297
  if (prev === 0) {
2910
- return cur > 0 ? trendText(TREND_UP, "\u25B2 New this period (0 last period)") : trendText(TREND_NEUTRAL, "Last Period: 0");
3298
+ return cur > 0 ? trendLine(TREND_UP, "\u25B2 New this period (0 last period)") : trendLine(TREND_NEUTRAL, "Last Period: 0");
2911
3299
  }
2912
3300
  const pct = Math.round((cur - prev) / prev * 100);
2913
3301
  const range = `(${fmtUsers(prev)} \u2192 ${fmtUsers(cur)})`;
2914
- if (pct > 0) return trendText(TREND_UP, `\u25B2 ${pct}% vs last period ${range}`);
2915
- if (pct < 0) return trendText(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
2916
- return trendText(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
3302
+ if (pct > 0) return trendLine(TREND_UP, `\u25B2 ${pct}% vs last period ${range}`);
3303
+ if (pct < 0) return trendLine(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
3304
+ return trendLine(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
3305
+ }
3306
+ function trendLine(color, text) {
3307
+ return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
3308
+ }
3309
+ function footnoteLine(text) {
3310
+ return `<mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">${text}</mj-text>`;
3311
+ }
3312
+ function analyticsSection(opts) {
3313
+ const users = opts.current !== void 0 ? fmtUsers(opts.current) : "\u2014";
3314
+ const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join("\n ");
3315
+ const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join("\n ");
3316
+ const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
3317
+ const labelTop = opts.pad ?? "75px";
3318
+ return `
3319
+ <mj-section background-color="${opts.background}"${sectionPad}>
3320
+ <mj-column>
3321
+ <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">ANALYTICS</mj-text>
3322
+ <mj-text color="${RED}" font-size="44px" font-weight="400">${users} Users</mj-text>
3323
+ ${analyticsTrendLine(opts.current, opts.previous)}
3324
+ ${body}
3325
+ ${footnotes}
3326
+ </mj-column>
3327
+ </mj-section>`;
3328
+ }
3329
+
3330
+ // src/reports/maintenance-email/template.ts
3331
+ var escapeXml2 = escapeHtml;
3332
+ var BLURRED_TESTS = `cid:${BLURRED_CID}`;
3333
+ function fmtDate(d) {
3334
+ if (!d || Number.isNaN(d.getTime())) return "";
3335
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
3336
+ const dd = String(d.getUTCDate()).padStart(2, "0");
3337
+ const yyyy = d.getUTCFullYear();
3338
+ return `${mm}.${dd}.${yyyy}`;
2917
3339
  }
2918
3340
  function maintenanceChecksSection(copy, searchPosition) {
2919
3341
  const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` : copy.maintenanceChecks[3] ?? "";
2920
3342
  const rows = copy.maintenanceChecks.map((label, i) => i === 3 ? googleLabel : label);
2921
- return rows.map(
2922
- (label, i) => `
2923
- <mj-section background-color="white" padding="0px"${i === rows.length - 1 ? ' padding-bottom="36px"' : ""}>
2924
- <mj-group>
2925
- <mj-column padding-left="0px" width="90%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
2926
- <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
2927
- </mj-column>
2928
- <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
2929
- <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
2930
- </mj-column>
2931
- </mj-group>
2932
- </mj-section>`
2933
- ).join("");
3343
+ return checklistRowsSection(rows, { background: "white", lastPaddingBottom: "36px" });
2934
3344
  }
2935
3345
  function testingChecklistSection(copy) {
2936
- const rows = copy.testingChecklist;
2937
- return rows.map(
2938
- (label, i) => `
2939
- <mj-section background-color="#F4F4F4" padding="0px"${i === rows.length - 1 ? ' padding-bottom="60px"' : ""}>
2940
- <mj-group>
2941
- <mj-column width="90%" padding-left="0px"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
2942
- <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
2943
- </mj-column>
2944
- <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
2945
- <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
2946
- </mj-column>
2947
- </mj-group>
2948
- </mj-section>`
2949
- ).join("");
3346
+ return checklistRowsSection(copy.testingChecklist, {
3347
+ background: "#F4F4F4",
3348
+ lastPaddingBottom: "60px"
3349
+ });
2950
3350
  }
2951
3351
  function maintenanceTestingPlaceholder(lastTested) {
2952
3352
  return `
@@ -2966,7 +3366,7 @@ function testingIntroSection(copy) {
2966
3366
  <mj-section background-color="#F4F4F4">
2967
3367
  <mj-column>
2968
3368
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
2969
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.testingIntro)}</mj-text>
3369
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.testingIntro)}</mj-text>
2970
3370
  </mj-column>
2971
3371
  </mj-section>`;
2972
3372
  }
@@ -2974,8 +3374,8 @@ function commentarySection(text, copy) {
2974
3374
  return `
2975
3375
  <mj-section background-color="white">
2976
3376
  <mj-column>
2977
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.notesHeader)}</mj-text>
2978
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(text).replace(/\r\n?|\n/g, "<br/>")}</mj-text>
3377
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml2(copy.notesHeader)}</mj-text>
3378
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(text).replace(/\r\n?|\n/g, "<br/>")}</mj-text>
2979
3379
  </mj-column>
2980
3380
  </mj-section>`;
2981
3381
  }
@@ -2984,8 +3384,8 @@ function hasHeaderDims(data) {
2984
3384
  }
2985
3385
  function headerImageTag(data) {
2986
3386
  const src = `cid:${data.headerImageCid}`;
2987
- const alt = `${escapeXml(data.siteName)} maintenance report`;
2988
- const href = isHttpUrl(data.siteUrl) ? escapeXml(data.siteUrl) : "#";
3387
+ const alt = `${escapeXml2(data.siteName)} maintenance report`;
3388
+ const href = isHttpUrl(data.siteUrl) ? escapeXml2(data.siteUrl) : "#";
2989
3389
  if (hasHeaderDims(data)) {
2990
3390
  return `<mj-image href="${href}" src="${src}" alt="${alt}" width="${data.headerWidth}px" css-class="rd-header" container-background-color="${data.headerBgColor}" />`;
2991
3391
  }
@@ -2998,7 +3398,7 @@ function headerStyleBlock(data) {
2998
3398
  function buildMjml(data) {
2999
3399
  const copy = data.copy ?? DEFAULT_COPY;
3000
3400
  const isTesting = data.reportType === "Testing";
3001
- const previewText = `Checked up on ${escapeXml(data.siteName)}`;
3401
+ const previewText = `Checked up on ${escapeXml2(data.siteName)}`;
3002
3402
  return `<mjml>
3003
3403
  <mj-head>
3004
3404
  <mj-attributes>
@@ -3020,52 +3420,30 @@ function buildMjml(data) {
3020
3420
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
3021
3421
  <mj-text color="#C00" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
3022
3422
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
3023
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.maintenanceIntro)}</mj-text>
3423
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.maintenanceIntro)}</mj-text>
3024
3424
  </mj-column>
3025
3425
  </mj-section>
3026
3426
  ${maintenanceChecksSection(copy, data.searchPosition)}
3027
- <mj-section background-color="#F4F4F4">
3028
- <mj-column>
3029
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">LIGHTHOUSE SCORES*</mj-text>
3030
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Performance</mj-text>
3031
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.performance}</mj-text>
3032
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 50\u201389 // Ideal 90\u2013100</mj-text>
3033
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3034
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Readability (A11y)</mj-text>
3035
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.accessibility}</mj-text>
3036
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 80\u201399 // Ideal 100</mj-text>
3037
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3038
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Best Practices</mj-text>
3039
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.bestPractices}</mj-text>
3040
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 60\u201379 // Ideal 80\u201392</mj-text>
3041
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3042
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Site Structure</mj-text>
3043
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.seo}</mj-text>
3044
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="0px" padding-bottom="36px">Acceptable 50\u201389 // Ideal 90\u2013100</mj-text>
3045
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">*A Lighthouse score is a numerical measure provided by Google's Lighthouse tool, which evaluates various aspects of a web page's quality.</mj-text>
3046
- </mj-column>
3047
- </mj-section>
3048
- <mj-section background-color="white">
3049
- <mj-column>
3050
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">ANALYTICS</mj-text>
3051
- <mj-text color="#C00" font-size="44px" font-weight="400">${data.gaUsersCurrent !== void 0 ? fmtUsers(data.gaUsersCurrent) : "\u2014"} Users</mj-text>
3052
- ${analyticsTrendLine(data.gaUsersCurrent, data.gaUsersPrevious)}
3053
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" padding-bottom="36px" line-height="20px">${escapeXml(copy.seoCta)}</mj-text>
3054
- </mj-column>
3055
- </mj-section>
3427
+ ${lighthouseScoresSection(data.lighthouse)}
3428
+ ${analyticsSection({
3429
+ current: data.gaUsersCurrent,
3430
+ previous: data.gaUsersPrevious,
3431
+ background: "white",
3432
+ footnoteLines: [escapeXml2(copy.seoCta)]
3433
+ })}
3056
3434
  ${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder(data.lastTestedDate)}
3057
3435
  ${data.commentary ? commentarySection(data.commentary, copy) : ""}
3058
3436
  <mj-section background-color="white">
3059
3437
  <mj-column padding-top="36px">
3060
3438
  <mj-text color="#C00" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
3061
3439
  ${copy.contact.map(
3062
- (line, i) => i === copy.contact.length - 1 ? `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" padding-top="0px" line-height="30px" padding-bottom="36px">${escapeXml(line)}</mj-text>` : `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
3440
+ (line, i) => i === copy.contact.length - 1 ? `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" padding-top="0px" line-height="30px" padding-bottom="36px">${escapeXml2(line)}</mj-text>` : `<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
3063
3441
  ).join("\n ")}
3064
3442
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3065
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>
3443
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml2(copy.footerOrg)}. All rights reserved.</mj-text>
3066
3444
  <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
3067
3445
  ${[copy.footerOrg, ...copy.footerAddress].map(
3068
- (line) => `<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
3446
+ (line) => `<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(line)}</mj-text>`
3069
3447
  ).join("\n ")}
3070
3448
  </mj-column>
3071
3449
  </mj-section>
@@ -3074,22 +3452,22 @@ function buildMjml(data) {
3074
3452
  }
3075
3453
 
3076
3454
  // src/reports/launch-email/template.ts
3077
- var RED = "#C00";
3078
- var GREY = "#757575";
3455
+ var RED2 = "#C00";
3456
+ var GREY2 = "#757575";
3079
3457
  function buildLaunchMjml(data) {
3080
3458
  const copy = data.copy ?? DEFAULT_COPY;
3081
- const previewText = `${escapeXml(data.siteName)} is live`;
3459
+ const previewText = `${escapeXml2(data.siteName)} is live`;
3082
3460
  const setupRows = copy.launchSetupItems.map(
3083
3461
  (item) => `
3084
- <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml(item)}</mj-text>`
3462
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml2(item)}</mj-text>`
3085
3463
  ).join("");
3086
3464
  const contactRows = copy.contact.map(
3087
3465
  (line) => `
3088
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
3466
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
3089
3467
  ).join("");
3090
3468
  const footerAddressRows = copy.footerAddress.map(
3091
3469
  (line) => `
3092
- <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
3470
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(line)}</mj-text>`
3093
3471
  ).join("");
3094
3472
  return `<mjml>
3095
3473
  <mj-head>
@@ -3107,20 +3485,20 @@ function buildLaunchMjml(data) {
3107
3485
  </mj-section>
3108
3486
  <mj-section background-color="white">
3109
3487
  <mj-column>
3110
- <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.launchHeading)}</mj-text>
3111
- <mj-text color="${RED}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
3112
- <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">${escapeXml(copy.launchBody)}</mj-text>
3488
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml2(copy.launchHeading)}</mj-text>
3489
+ <mj-text color="${RED2}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
3490
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">${escapeXml2(copy.launchBody)}</mj-text>
3113
3491
  ${setupRows}
3114
3492
  </mj-column>
3115
3493
  </mj-section>
3116
3494
  <mj-section background-color="white">
3117
3495
  <mj-column padding-top="36px">
3118
- <mj-text color="${RED}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
3496
+ <mj-text color="${RED2}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
3119
3497
  ${contactRows}
3120
3498
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3121
- <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>
3122
- <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
3123
- <mj-text color="${GREY}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(copy.footerOrg)}</mj-text>
3499
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml2(copy.footerOrg)}. All rights reserved.</mj-text>
3500
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
3501
+ <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(copy.footerOrg)}</mj-text>
3124
3502
  ${footerAddressRows}
3125
3503
  </mj-column>
3126
3504
  </mj-section>
@@ -3134,18 +3512,14 @@ var FREQ_PHRASE = {
3134
3512
  Quarterly: "every quarter",
3135
3513
  Yearly: "every year"
3136
3514
  };
3137
- var RED2 = "#C00";
3138
- var GREY2 = "#757575";
3139
- var CHECK_PNG2 = `cid:${CHECK_CID}`;
3140
- function fmtVisitors(n) {
3141
- return n.toLocaleString("en-US");
3515
+ var RED3 = "#C00";
3516
+ var GREY3 = "#757575";
3517
+ var SECTION_PAD = "40px";
3518
+ function sectionLabel(text) {
3519
+ return `<mj-text color="${RED3}" font-size="20px" font-weight="700" padding-top="0px">${escapeXml2(text)}</mj-text>`;
3142
3520
  }
3143
- function visitorTrend(cur, prev) {
3144
- if (cur === void 0 || prev === void 0 || prev === 0) return null;
3145
- const pct = Math.round((cur - prev) / prev * 100);
3146
- if (pct > 0) return `\u25B2 ${pct}% vs the previous month`;
3147
- if (pct < 0) return `\u25BC ${Math.abs(pct)}% vs the previous month`;
3148
- return "No change vs the previous month";
3521
+ function bodyLine(text, paddingTop = "8px") {
3522
+ return `<mj-text color="${GREY3}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="${paddingTop}">${escapeXml2(text)}</mj-text>`;
3149
3523
  }
3150
3524
  function announcementSiteExtras(site) {
3151
3525
  return {
@@ -3153,83 +3527,68 @@ function announcementSiteExtras(site) {
3153
3527
  improvements: { resendForms: true, svelte5: true }
3154
3528
  };
3155
3529
  }
3156
- var SCORE_PREVIEW = [
3157
- { label: "Performance", key: "performance" },
3158
- { label: "Readability (A11y)", key: "accessibility" },
3159
- { label: "Best Practices", key: "bestPractices" },
3160
- { label: "Site Structure", key: "seo" }
3161
- ];
3162
3530
  function buildAnnouncementMjml(data) {
3163
3531
  const copy = data.copy ?? DEFAULT_COPY;
3164
3532
  const previewText = "Your monthly report from Reddoor";
3533
+ const cad = data.cadence;
3534
+ const hasMaint = Boolean(cad && cad.maintenance !== "None");
3535
+ const hasTesting = Boolean(cad && cad.testing !== "None");
3165
3536
  const improvementItems = [];
3166
3537
  if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
3167
3538
  if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
3168
- const improvementsSection = improvementItems.length > 0 ? `
3169
- <mj-section background-color="white">
3539
+ const hasImpr = improvementItems.length > 0;
3540
+ const BANDS = ["white", "#F4F4F4"];
3541
+ let bandN = 0;
3542
+ const nextBg = () => BANDS[bandN++ % 2];
3543
+ const introBg = nextBg();
3544
+ const maintBg = hasMaint ? nextBg() : "";
3545
+ const testBg = hasTesting ? nextBg() : "";
3546
+ const lighthouseBg = nextBg();
3547
+ const analyticsBg = nextBg();
3548
+ const improvementsBg = hasImpr ? nextBg() : "";
3549
+ const contactBg = nextBg();
3550
+ const maintenanceSection = cad && cad.maintenance !== "None" ? `
3551
+ <mj-section background-color="${maintBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
3170
3552
  <mj-column>
3171
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">RECENT IMPROVEMENTS</mj-text>
3172
- ${improvementItems.map(
3173
- (item) => `
3174
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px" padding-bottom="4px">\u2022 ${escapeXml(item)}</mj-text>`
3175
- ).join("")}
3553
+ ${sectionLabel("MAINTENANCE CHECKS")}
3554
+ ${bodyLine(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.${cad.testing === "None" ? ` ${copy.announceCadence}` : ""}`)}
3176
3555
  </mj-column>
3177
- </mj-section>` : "";
3178
- const cad = data.cadence;
3179
- const cadenceBlocks = [];
3180
- if (cad && cad.testing !== "None")
3181
- cadenceBlocks.push({
3182
- line: `${copy.announceTestingLabel} \u2014 ${FREQ_PHRASE[cad.testing]}`,
3183
- checks: copy.testingChecklist
3184
- });
3185
- if (cad && cad.maintenance !== "None")
3186
- cadenceBlocks.push({
3187
- line: `${copy.announceMaintenanceLabel} \u2014 ${FREQ_PHRASE[cad.maintenance]}`,
3188
- checks: copy.maintenanceChecks
3189
- });
3190
- const cadenceSection = cadenceBlocks.length > 0 ? `
3191
- <mj-section background-color="white">
3556
+ </mj-section>${checklistRowsSection(copy.maintenanceChecks, {
3557
+ background: maintBg,
3558
+ lastPaddingBottom: SECTION_PAD
3559
+ })}` : "";
3560
+ const testingSection = cad && cad.testing !== "None" ? `
3561
+ <mj-section background-color="${testBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
3192
3562
  <mj-column>
3193
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">${escapeXml(copy.announceCadenceHeading)}</mj-text>
3194
- ${cadenceBlocks.map(
3195
- (b) => `
3196
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="400" line-height="24px" padding-top="12px" padding-bottom="2px">\u2022 ${escapeXml(b.line)}</mj-text>${b.checks.map(
3197
- (c) => `
3198
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="14px" font-weight="300" line-height="22px" padding-top="1px" padding-bottom="0px" padding-left="16px">${escapeXml(c)} <img src="${CHECK_PNG2}" alt="\u2713" width="14" height="14" style="vertical-align:middle;display:inline-block;margin-left:2px;" /></mj-text>`
3199
- ).join("")}`
3200
- ).join("")}
3201
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="14px">${escapeXml(copy.announceCadence)}</mj-text>
3563
+ ${sectionLabel("TESTING")}
3564
+ ${bodyLine(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}. ${copy.announceCadence}`)}
3202
3565
  </mj-column>
3203
- </mj-section>` : "";
3204
- const scoreRows = SCORE_PREVIEW.map(
3205
- ({ label, key }) => `
3206
- <mj-text color="${RED2}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
3207
- <mj-text color="${RED2}" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse[key]}</mj-text>`
3208
- ).join("");
3209
- const scoreNote = copy.announceScoreNote ? `
3210
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" font-style="italic" line-height="18px" padding-top="16px">${escapeXml(copy.announceScoreNote)}</mj-text>` : "";
3211
- const trend = visitorTrend(data.gaUsersCurrent, data.gaUsersPrevious);
3212
- const trafficRows = [];
3213
- if (data.gaUsersCurrent !== void 0)
3214
- trafficRows.push(`
3215
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="8px"><span style="color:${RED2};font-size:22px;font-weight:400;">${escapeXml(fmtVisitors(data.gaUsersCurrent))}</span> visitors in the last month${trend ? ` \u2014 ${escapeXml(trend)}` : ""}</mj-text>`);
3216
- if (data.searchPosition !== void 0)
3217
- trafficRows.push(`
3218
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="4px">Page 1 Google result (#${data.searchPosition}) for your brand search</mj-text>`);
3219
- const trafficSection = trafficRows.length > 0 ? `
3220
- <mj-section background-color="white">
3566
+ </mj-section>${checklistRowsSection(copy.testingChecklist, {
3567
+ background: testBg,
3568
+ lastPaddingBottom: SECTION_PAD
3569
+ })}` : "";
3570
+ const analytics = analyticsSection({
3571
+ current: data.gaUsersCurrent,
3572
+ previous: data.gaUsersPrevious,
3573
+ background: analyticsBg,
3574
+ pad: SECTION_PAD,
3575
+ bodyLines: data.searchPosition !== void 0 ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`] : []
3576
+ });
3577
+ const improvementsSection = hasImpr ? `
3578
+ <mj-section background-color="${improvementsBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
3221
3579
  <mj-column>
3222
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">TRAFFIC &amp; SEARCH</mj-text>
3223
- ${trafficRows.join("")}
3580
+ ${sectionLabel("RECENT IMPROVEMENTS")}
3581
+ ${improvementItems.map((item) => bodyLine(item)).join("\n ")}
3582
+ ${bodyLine(copy.announceOpenDoor, "16px")}
3224
3583
  </mj-column>
3225
3584
  </mj-section>` : "";
3226
3585
  const contactRows = copy.contact.map(
3227
3586
  (line) => `
3228
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
3587
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
3229
3588
  ).join("");
3230
3589
  const footerAddressRows = copy.footerAddress.map(
3231
3590
  (line) => `
3232
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(line)}</mj-text>`
3591
+ <mj-text color="${GREY3}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(line)}</mj-text>`
3233
3592
  ).join("");
3234
3593
  return `<mjml>
3235
3594
  <mj-head>
@@ -3238,42 +3597,33 @@ function buildAnnouncementMjml(data) {
3238
3597
  <mj-section padding-left="11%" padding-right="11%"/>
3239
3598
  <mj-image padding="0px" />
3240
3599
  </mj-attributes>
3241
- <mj-preview>${escapeXml(previewText)}</mj-preview>
3600
+ <mj-preview>${escapeXml2(previewText)}</mj-preview>
3242
3601
  ${headerStyleBlock(data)}
3243
3602
  </mj-head>
3244
3603
  <mj-body background-color="white">
3245
3604
  <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
3246
3605
  <mj-column>${headerImageTag(data)}</mj-column>
3247
3606
  </mj-section>
3248
- <mj-section background-color="white">
3607
+ <mj-section background-color="${introBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
3249
3608
  <mj-column>
3250
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.announceHeading)}</mj-text>
3251
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">Prepared for ${escapeXml(data.siteName)}</mj-text>
3252
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="8px">${escapeXml(copy.announceBody)}</mj-text>
3609
+ ${sectionLabel(copy.announceHeading)}
3610
+ <mj-text color="${GREY3}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="20px">Prepared for ${escapeXml2(data.siteName)}</mj-text>
3611
+ ${bodyLine(copy.announceBody)}
3253
3612
  </mj-column>
3254
3613
  </mj-section>
3255
- ${cadenceSection}
3614
+ ${maintenanceSection}
3615
+ ${testingSection}
3616
+ ${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}
3617
+ ${analytics}
3256
3618
  ${improvementsSection}
3257
- <mj-section background-color="#F4F4F4">
3619
+ <mj-section background-color="${contactBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
3258
3620
  <mj-column>
3259
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.announcePreviewLabel)}</mj-text>
3260
- ${scoreRows}${scoreNote}
3261
- </mj-column>
3262
- </mj-section>
3263
- ${trafficSection}
3264
- <mj-section background-color="white">
3265
- <mj-column>
3266
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="36px">${escapeXml(copy.announceOpenDoor)}</mj-text>
3267
- </mj-column>
3268
- </mj-section>
3269
- <mj-section background-color="white">
3270
- <mj-column padding-top="36px">
3271
- <mj-text color="${RED2}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="36px" line-height="36px">Any questions, concerns or requests?</mj-text>
3621
+ <mj-text color="${RED3}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="0px" line-height="36px">Any questions, concerns or requests?</mj-text>
3272
3622
  ${contactRows}
3273
3623
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
3274
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>
3275
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
3276
- <mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml(copy.footerOrg)}</mj-text>
3624
+ <mj-text color="${GREY3}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" padding-top="24px" line-height="20px" font-style="italic">Copyright ${(/* @__PURE__ */ new Date()).getUTCFullYear()} ${escapeXml2(copy.footerOrg)}. All rights reserved.</mj-text>
3625
+ <mj-text color="${GREY3}" font-family="helvetica, sans-serif" font-size="12px" font-weight="700" line-height="16px" padding-top="0" padding-bottom="0px">Our mailing address is:</mj-text>
3626
+ <mj-text color="${GREY3}" font-family="helvetica, sans-serif" font-size="12px" font-weight="300" line-height="16px" padding-top="0" padding-bottom="0px">${escapeXml2(copy.footerOrg)}</mj-text>
3277
3627
  ${footerAddressRows}
3278
3628
  </mj-column>
3279
3629
  </mj-section>
@@ -3366,9 +3716,32 @@ function mapRow2(rec) {
3366
3716
  deliveryStatus: f["Delivery status"] ?? "pending",
3367
3717
  renderedHtmlAttachment: html,
3368
3718
  resendMessageId: f["Resend message ID"] ?? null,
3369
- checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])]))
3719
+ checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
3720
+ autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"])
3370
3721
  };
3371
3722
  }
3723
+ function parseAutoEvidence(raw) {
3724
+ if (typeof raw !== "string" || !raw.trim()) return null;
3725
+ let parsed;
3726
+ try {
3727
+ parsed = JSON.parse(raw);
3728
+ } catch {
3729
+ return null;
3730
+ }
3731
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
3732
+ const out = {};
3733
+ for (const [field, v] of Object.entries(parsed)) {
3734
+ if (!v || typeof v !== "object") continue;
3735
+ const o = v;
3736
+ if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown") continue;
3737
+ out[field] = {
3738
+ result: o.result,
3739
+ checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
3740
+ note: typeof o.note === "string" ? o.note : ""
3741
+ };
3742
+ }
3743
+ return Object.keys(out).length > 0 ? out : null;
3744
+ }
3372
3745
  function lighthouseFromFields(f) {
3373
3746
  const p = f["Lighthouse \u2014 Performance"];
3374
3747
  const a = f["Lighthouse \u2014 Accessibility"];
@@ -3402,6 +3775,10 @@ async function createDraft(base, input) {
3402
3775
  if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
3403
3776
  if (input.period !== void 0) fields["Period"] = input.period;
3404
3777
  if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
3778
+ for (const field of input.checklistTicks ?? []) fields[field] = true;
3779
+ if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
3780
+ fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
3781
+ }
3405
3782
  const created = await base(REPORTS_TABLE).create([{ fields }]);
3406
3783
  const rec = created[0];
3407
3784
  if (!rec) throw new Error("Airtable create returned no records");
@@ -3443,6 +3820,145 @@ async function stampSent(base, recordId, sentAt, messageId) {
3443
3820
  ]);
3444
3821
  }
3445
3822
 
3823
+ // src/reports/queue.ts
3824
+ var REPORT_TIER = {
3825
+ Maintenance: 1,
3826
+ Testing: 2,
3827
+ Announcement: 3,
3828
+ Launch: 3
3829
+ };
3830
+ function reportTier(type) {
3831
+ return REPORT_TIER[type];
3832
+ }
3833
+ async function queueDraft(base, report) {
3834
+ const newTier = reportTier(report.reportType);
3835
+ const others = (await listReportsForSite(base, report.siteId)).filter(isPendingApproval).filter((r) => r.id !== report.id);
3836
+ const blocker = others.find((r) => reportTier(r.reportType) >= newTier);
3837
+ if (blocker) {
3838
+ await setDraftReady(base, report.id, false);
3839
+ return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };
3840
+ }
3841
+ const supersededIds = [];
3842
+ for (const r of others) {
3843
+ await setDraftReady(base, r.id, false);
3844
+ supersededIds.push(r.id);
3845
+ }
3846
+ await setDraftReady(base, report.id, true);
3847
+ return { queued: true, supersededIds };
3848
+ }
3849
+
3850
+ // src/reports/auto-tick.ts
3851
+ var STALE_DAYS = 3;
3852
+ var MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
3853
+ function isFresh(checkedAt, now) {
3854
+ if (!checkedAt) return false;
3855
+ const t = new Date(checkedAt).getTime();
3856
+ if (Number.isNaN(t)) return false;
3857
+ return now.getTime() - t <= STALE_DAYS * MS_PER_DAY2;
3858
+ }
3859
+ var CERT_MIN_DAYS = 14;
3860
+ function autoTickChecklist(site, reportType, now, signals) {
3861
+ const out = /* @__PURE__ */ new Map();
3862
+ const fields = new Set(checklistFor(reportType).map((i) => i.field));
3863
+ if (fields.has("Maint: Google Indexed")) {
3864
+ const g = googleEvidence(now, signals.search);
3865
+ if (g) out.set("Maint: Google Indexed", g);
3866
+ }
3867
+ if (fields.has("Maint: Security Updates")) {
3868
+ const s = securityEvidence(site, now);
3869
+ if (s) out.set("Maint: Security Updates", s);
3870
+ }
3871
+ if (fields.has("Maint: Domain, DNS & SSL")) {
3872
+ const d = domainEvidence(site, now);
3873
+ if (d) out.set("Maint: Domain, DNS & SSL", d);
3874
+ }
3875
+ if (fields.has("Test: Desktop Browsers")) {
3876
+ const e = browserEvidence(
3877
+ site.crossbrowserOk,
3878
+ site,
3879
+ now,
3880
+ "Desktop renders cleanly",
3881
+ "render errors"
3882
+ );
3883
+ if (e) out.set("Test: Desktop Browsers", e);
3884
+ }
3885
+ if (fields.has("Test: Mobile Browsers")) {
3886
+ const e = browserEvidence(
3887
+ site.mobileOk,
3888
+ site,
3889
+ now,
3890
+ "Mobile renders cleanly",
3891
+ "overflow/errors"
3892
+ );
3893
+ if (e) out.set("Test: Mobile Browsers", e);
3894
+ }
3895
+ if (fields.has("Test: Links & Navigation")) {
3896
+ const broken = site.brokenLinks;
3897
+ const failNote = broken && broken > 0 ? `${broken} broken link(s)` : "broken links / nav";
3898
+ const e = browserEvidence(site.linksOk, site, now, "All internal links resolve", failNote);
3899
+ if (e) out.set("Test: Links & Navigation", e);
3900
+ }
3901
+ return out;
3902
+ }
3903
+ function browserEvidence(ok, site, now, passNote, failNote) {
3904
+ if (ok === null || !site.browserCheckedAt) return null;
3905
+ const at = site.browserCheckedAt;
3906
+ if (!isFresh(at, now)) {
3907
+ return { result: "unknown", checkedAt: at, note: "Browser check is stale (>3d)" };
3908
+ }
3909
+ return ok ? { result: "pass", checkedAt: at, note: passNote } : { result: "fail", checkedAt: at, note: failNote };
3910
+ }
3911
+ function securityEvidence(site, now) {
3912
+ const crit = site.securityVulnsCritical;
3913
+ const high = site.securityVulnsHigh;
3914
+ if (crit === null || high === null || !site.lastSecurityAuditAt) return null;
3915
+ const at = site.lastSecurityAuditAt;
3916
+ if (!isFresh(at, now)) {
3917
+ return { result: "unknown", checkedAt: at, note: "Security audit is stale (>3d)" };
3918
+ }
3919
+ if (crit === 0 && high === 0) {
3920
+ return { result: "pass", checkedAt: at, note: "No known critical/high vulnerabilities" };
3921
+ }
3922
+ return { result: "fail", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };
3923
+ }
3924
+ function googleEvidence(now, search) {
3925
+ const at = now.toISOString();
3926
+ if (search.softFailed) {
3927
+ return { result: "unknown", checkedAt: at, note: "Search Console unavailable this run" };
3928
+ }
3929
+ if (search.value === null) return null;
3930
+ if (search.value.foundOnPage1) {
3931
+ const pos2 = search.value.position;
3932
+ return {
3933
+ result: "pass",
3934
+ checkedAt: at,
3935
+ note: `Page 1 on Google${pos2 !== null ? ` (#${pos2})` : ""}`
3936
+ };
3937
+ }
3938
+ const pos = search.value.position;
3939
+ return {
3940
+ result: "fail",
3941
+ checkedAt: at,
3942
+ note: `Not on page 1${pos !== null ? ` (avg #${pos})` : ""}`
3943
+ };
3944
+ }
3945
+ function domainEvidence(site, now) {
3946
+ if (!site.url || isNetlifyAppUrl(site.url)) return null;
3947
+ if (!site.domainCheckedAt) return null;
3948
+ const at = site.domainCheckedAt;
3949
+ if (!isFresh(site.domainCheckedAt, now)) {
3950
+ return { result: "unknown", checkedAt: at, note: "Domain check is stale (>3d)" };
3951
+ }
3952
+ const days = site.certDaysRemaining;
3953
+ if (days === null) {
3954
+ return { result: "fail", checkedAt: at, note: "Did not resolve, or no valid TLS cert" };
3955
+ }
3956
+ if (days <= CERT_MIN_DAYS) {
3957
+ return { result: "fail", checkedAt: at, note: `TLS cert expires in ${days}d` };
3958
+ }
3959
+ return { result: "pass", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };
3960
+ }
3961
+
3446
3962
  // src/reports/airtable/attachments.ts
3447
3963
  function looksLikeHtml(bytes) {
3448
3964
  const start = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
@@ -3514,7 +4030,7 @@ import { readFileSync as readFileSync3 } from "fs";
3514
4030
  import { JWT } from "google-auth-library";
3515
4031
  import { BetaAnalyticsDataClient } from "@google-analytics/data";
3516
4032
  var ANALYTICS_READONLY = "https://www.googleapis.com/auth/analytics.readonly";
3517
- var MS_PER_DAY = 864e5;
4033
+ var MS_PER_DAY3 = 864e5;
3518
4034
  function ymd2(d) {
3519
4035
  return d.toISOString().slice(0, 10);
3520
4036
  }
@@ -3527,9 +4043,9 @@ async function fetchPeriodUsers(query, periodStart, periodEnd) {
3527
4043
  subject: query.subject
3528
4044
  });
3529
4045
  const client = new BetaAnalyticsDataClient({ authClient });
3530
- const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY);
3531
- const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY);
3532
- const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY);
4046
+ const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY3);
4047
+ const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY3);
4048
+ const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY3);
3533
4049
  const property = `properties/${query.propertyId}`;
3534
4050
  const run = async (start, end) => {
3535
4051
  const [resp] = await client.runReport({
@@ -3653,7 +4169,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3653
4169
  const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
3654
4170
  const periodEnd = today;
3655
4171
  const completedOn = today;
3656
- const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
4172
+ const lastTestedDate = reportType === "Maintenance" && siteRow.lastLighthouseAuditAt ? new Date(siteRow.lastLighthouseAuditAt) : null;
3657
4173
  const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
3658
4174
  const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
3659
4175
  const gaUsers = gaResult.value;
@@ -3681,13 +4197,28 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3681
4197
  const path = options.previewPath ?? `reports/${slug}/draft.html`;
3682
4198
  await mkdir3(dirname6(path), { recursive: true });
3683
4199
  await writeFile10(path, html, "utf-8");
3684
- return { reportRow: null, htmlPath: path, html, softFailures };
4200
+ return { reportRow: null, htmlPath: path, html, softFailures, queued: null, supersededIds: [] };
3685
4201
  }
3686
4202
  if (base === null) throw new Error("base required when previewOnly=false");
3687
4203
  if (options.completeRowId) {
3688
- await finishDraftRow(base, options.completeRowId, slug, periodEnd, html);
3689
- return { reportRow: options.existingRow ?? null, htmlPath: null, html, softFailures };
4204
+ await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);
4205
+ const outcome2 = await queueDraft(base, {
4206
+ id: options.completeRowId,
4207
+ siteId: siteRow.id,
4208
+ reportType
4209
+ });
4210
+ return {
4211
+ reportRow: options.existingRow ?? null,
4212
+ htmlPath: null,
4213
+ html,
4214
+ softFailures,
4215
+ queued: outcome2.queued,
4216
+ supersededIds: outcome2.supersededIds
4217
+ };
3690
4218
  }
4219
+ const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });
4220
+ const checklistTicks = [...evidence.entries()].filter(([, e]) => e.result === "pass").map(([field]) => field);
4221
+ const autoEvidence = Object.fromEntries(evidence);
3691
4222
  const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
3692
4223
  const created = await createDraft(base, {
3693
4224
  reportId,
@@ -3701,15 +4232,28 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
3701
4232
  lastTestedDate,
3702
4233
  ...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
3703
4234
  ...search ? { searchFoundPage1: search.foundOnPage1 } : {},
3704
- ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
4235
+ ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {},
4236
+ checklistTicks,
4237
+ autoEvidence
4238
+ });
4239
+ await uploadDraftHtml(created.id, slug, periodEnd, html);
4240
+ const outcome = await queueDraft(base, {
4241
+ id: created.id,
4242
+ siteId: siteRow.id,
4243
+ reportType
3705
4244
  });
3706
- await finishDraftRow(base, created.id, slug, periodEnd, html);
3707
- return { reportRow: created, htmlPath: null, html, softFailures };
4245
+ return {
4246
+ reportRow: created,
4247
+ htmlPath: null,
4248
+ html,
4249
+ softFailures,
4250
+ queued: outcome.queued,
4251
+ supersededIds: outcome.supersededIds
4252
+ };
3708
4253
  }
3709
- async function finishDraftRow(base, rowId, slug, periodEnd, html) {
4254
+ async function uploadDraftHtml(rowId, slug, periodEnd, html) {
3710
4255
  const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
3711
4256
  await uploadAttachment(rowId, "Rendered HTML", html, htmlFilename, "text/html");
3712
- await setDraftReady(base, rowId, true);
3713
4257
  }
3714
4258
  var NO_ENRICHMENT = { value: null, softFailed: false };
3715
4259
  async function fetchGaUsers(siteRow, periodStart, periodEnd) {
@@ -3973,42 +4517,39 @@ async function sendOne(client, base, site, report) {
3973
4517
  headerHeight: header.displayHeight,
3974
4518
  headerBgColor: header.placeholderColor,
3975
4519
  // Announcement-only: re-derive cadence + improvements from the site row so the SENT email
3976
- // keeps its WHAT TO EXPECT section + improvement callouts. Without this the send-time
3977
- // re-render drops them entirely (they're not stored on the Reports row). Ignored by the
3978
- // other report templates.
4520
+ // keeps its cadence copy + improvement callouts. Without this the send-time re-render drops
4521
+ // them entirely (they're not stored on the Reports row). Ignored by the other templates.
3979
4522
  ...report.reportType === "Announcement" ? announcementSiteExtras(site) : {}
3980
4523
  });
3981
4524
  const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
3982
4525
  const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
4526
+ const attachments = [
4527
+ toInlineAttachment({
4528
+ bytes: header.bytes,
4529
+ filename: `${cidName}.jpg`,
4530
+ contentType: header.contentType,
4531
+ cid: cidName
4532
+ })
4533
+ ];
4534
+ for (const img of [bundled.check, bundled.blurred]) {
4535
+ if (html.includes(`cid:${img.cid}`)) {
4536
+ attachments.push(
4537
+ toInlineAttachment({
4538
+ bytes: img.bytes,
4539
+ filename: img.filename,
4540
+ contentType: img.contentType,
4541
+ cid: img.cid
4542
+ })
4543
+ );
4544
+ }
4545
+ }
3983
4546
  const payload = {
3984
4547
  from: FROM_ADDRESS,
3985
4548
  to,
3986
4549
  replyTo: REPLY_TO,
3987
4550
  subject,
3988
4551
  html,
3989
- attachments: [
3990
- toInlineAttachment({
3991
- bytes: header.bytes,
3992
- filename: `${cidName}.jpg`,
3993
- contentType: header.contentType,
3994
- cid: cidName
3995
- }),
3996
- // Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
3997
- // in the template. Attached inline so the email is self-contained — no
3998
- // external CDN dependency, no image-blocked broken icons in webmail.
3999
- toInlineAttachment({
4000
- bytes: bundled.check.bytes,
4001
- filename: bundled.check.filename,
4002
- contentType: bundled.check.contentType,
4003
- cid: bundled.check.cid
4004
- }),
4005
- toInlineAttachment({
4006
- bytes: bundled.blurred.bytes,
4007
- filename: bundled.blurred.filename,
4008
- contentType: bundled.blurred.contentType,
4009
- cid: bundled.blurred.cid
4010
- })
4011
- ],
4552
+ attachments,
4012
4553
  // Stable across retries of the same row — if Airtable stamping fails after a
4013
4554
  // successful Resend, the next --send-ready replays with the same key and
4014
4555
  // Resend returns the original message id rather than sending a duplicate.
@@ -4205,7 +4746,9 @@ function checklistBlock(r) {
4205
4746
  const url = `/api/reports/${encodeURIComponent(r.id)}/checklist`;
4206
4747
  const boxes = items.map((item) => {
4207
4748
  const checked = r.checklist[item.field] === true ? " checked" : "";
4208
- return `<label class="check-item"><input type="checkbox" class="checklist-checkbox" data-checklist-report-id="${rid}" data-field="${escapeHtml(item.field)}" data-checklist-url="${escapeHtml(url)}"${checked} /> ${escapeHtml(item.label)}</label>`;
4749
+ const ev = r.autoEvidence?.[item.field];
4750
+ const badge = ev ? ev.result === "pass" ? ` <span class="auto-badge auto-pass" title="${escapeHtml(ev.note)}">auto \u2713</span>` : ` <span class="auto-badge auto-amber" title="${escapeHtml(ev.note)}">auto: ${escapeHtml(ev.note)}</span>` : "";
4751
+ return `<label class="check-item"><input type="checkbox" class="checklist-checkbox" data-checklist-report-id="${rid}" data-field="${escapeHtml(item.field)}" data-checklist-url="${escapeHtml(url)}"${checked} /> ${escapeHtml(item.label)}${badge}</label>`;
4209
4752
  }).join("");
4210
4753
  return `<div class="checklist" data-checklist-for="${rid}">${boxes}</div>`;
4211
4754
  }
@@ -4332,6 +4875,9 @@ button.approve:disabled { opacity: 0.6; cursor: default; }
4332
4875
  .checklist { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0.5rem 0 0.25rem 0.25rem; }
4333
4876
  .check-item { display: flex; align-items: center; gap: 0.4rem; font-size: 0.9rem; }
4334
4877
  .check-item input { margin: 0; }
4878
+ .auto-badge { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; }
4879
+ .auto-pass { background: #e6f4ea; color: #137333; }
4880
+ .auto-amber { background: #fef7e0; color: #b06000; }
4335
4881
  .pill { font-size: 0.75rem; padding: 0.1rem 0.5rem; border-radius: 999px; font-weight: 700; }
4336
4882
  .subm-list { list-style: none; padding: 0; margin: 0; }
4337
4883
  .subm-item { padding: 0.6rem 0; border-bottom: 1px solid #eee; }