@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/cli/bin.js +958 -283
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +3 -5
- package/dist/cli/commands/audit.js +441 -17
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/index.d.ts +91 -9
- package/dist/index.js +786 -240
- package/dist/index.js.map +1 -1
- package/dist/recipes/sync-configs.d.ts +1 -1
- package/dist/{types-DeKpgkG-.d.ts → types-QG-QhCYh.d.ts} +1 -1
- package/package.json +1 -1
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"]
|
|
2660
|
-
testingFreq: f["testing freq"]
|
|
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
|
|
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/
|
|
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
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
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
|
|
3295
|
+
return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
|
|
2908
3296
|
}
|
|
2909
3297
|
if (prev === 0) {
|
|
2910
|
-
return cur > 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
|
|
2915
|
-
if (pct < 0) return
|
|
2916
|
-
return
|
|
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
|
|
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
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
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">${
|
|
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">${
|
|
2978
|
-
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${
|
|
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 = `${
|
|
2988
|
-
const href = isHttpUrl(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 ${
|
|
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">${
|
|
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
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
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">${
|
|
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()} ${
|
|
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">${
|
|
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
|
|
3078
|
-
var
|
|
3455
|
+
var RED2 = "#C00";
|
|
3456
|
+
var GREY2 = "#757575";
|
|
3079
3457
|
function buildLaunchMjml(data) {
|
|
3080
3458
|
const copy = data.copy ?? DEFAULT_COPY;
|
|
3081
|
-
const previewText = `${
|
|
3459
|
+
const previewText = `${escapeXml2(data.siteName)} is live`;
|
|
3082
3460
|
const setupRows = copy.launchSetupItems.map(
|
|
3083
3461
|
(item) => `
|
|
3084
|
-
<mj-text color="${
|
|
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">${
|
|
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="${
|
|
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="${
|
|
3111
|
-
<mj-text color="${
|
|
3112
|
-
<mj-text color="${
|
|
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="${
|
|
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="${
|
|
3122
|
-
<mj-text color="${
|
|
3123
|
-
<mj-text color="${
|
|
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
|
|
3138
|
-
var
|
|
3139
|
-
var
|
|
3140
|
-
function
|
|
3141
|
-
return
|
|
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
|
|
3144
|
-
|
|
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
|
|
3169
|
-
|
|
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
|
-
|
|
3172
|
-
${
|
|
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
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
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
|
-
|
|
3194
|
-
${
|
|
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
|
-
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
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
|
-
|
|
3223
|
-
${
|
|
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">${
|
|
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="${
|
|
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>${
|
|
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="
|
|
3607
|
+
<mj-section background-color="${introBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
|
|
3249
3608
|
<mj-column>
|
|
3250
|
-
|
|
3251
|
-
<mj-text color="${
|
|
3252
|
-
|
|
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
|
-
${
|
|
3614
|
+
${maintenanceSection}
|
|
3615
|
+
${testingSection}
|
|
3616
|
+
${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}
|
|
3617
|
+
${analytics}
|
|
3256
3618
|
${improvementsSection}
|
|
3257
|
-
<mj-section background-color="
|
|
3619
|
+
<mj-section background-color="${contactBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
|
|
3258
3620
|
<mj-column>
|
|
3259
|
-
<mj-text color="${
|
|
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="${
|
|
3275
|
-
<mj-text color="${
|
|
3276
|
-
<mj-text color="${
|
|
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
|
|
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()) /
|
|
3531
|
-
const prevEnd = new Date(periodStart.getTime() -
|
|
3532
|
-
const prevStart = new Date(prevEnd.getTime() - lengthDays *
|
|
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.
|
|
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
|
|
3689
|
-
|
|
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
|
-
|
|
3707
|
-
|
|
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
|
|
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
|
|
3977
|
-
//
|
|
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
|
-
|
|
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; }
|