@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 CHANGED
@@ -71,6 +71,16 @@ function isHttpUrl(s) {
71
71
  }
72
72
  return parsed.protocol === "http:" || parsed.protocol === "https:";
73
73
  }
74
+ function isNetlifyAppUrl(s) {
75
+ let parsed;
76
+ try {
77
+ parsed = new URL(s);
78
+ } catch {
79
+ return false;
80
+ }
81
+ const host = parsed.hostname.toLowerCase();
82
+ return host === "netlify.app" || host.endsWith(".netlify.app");
83
+ }
74
84
  var init_url = __esm({
75
85
  "src/util/url.ts"() {
76
86
  "use strict";
@@ -161,6 +171,9 @@ function parseNotifyRouting(raw) {
161
171
  function isDashboardVisible(site) {
162
172
  return site.status !== null && ACTIVE_STATUSES.has(site.status);
163
173
  }
174
+ function toFrequency(raw) {
175
+ return typeof raw === "string" && FREQUENCIES.includes(raw) ? raw : "None";
176
+ }
164
177
  function mapRow(rec) {
165
178
  const f = rec.fields;
166
179
  const attachments = f["Header image"] ?? [];
@@ -171,8 +184,8 @@ function mapRow(rec) {
171
184
  url: String(f["url"] ?? ""),
172
185
  status: f["Status"] ?? null,
173
186
  pointOfContact: f["point of contact"] ?? null,
174
- maintenanceFreq: f["maintenence freq"] ?? "None",
175
- testingFreq: f["testing freq"] ?? "None",
187
+ maintenanceFreq: toFrequency(f["maintenence freq"]),
188
+ testingFreq: toFrequency(f["testing freq"]),
176
189
  maintenanceDay: f["maintenance day"] ?? null,
177
190
  testingDay: f["testing day"] ?? null,
178
191
  ga4PropertyId: f["GA4 property ID"] ?? null,
@@ -195,6 +208,14 @@ function mapRow(rec) {
195
208
  securityVulnsHigh: f["Security Vulns High"] ?? null,
196
209
  securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
197
210
  securityVulnsLow: f["Security Vulns Low"] ?? null,
211
+ lastSecurityAuditAt: f["Last security audit at"] ?? null,
212
+ certDaysRemaining: f["Cert days remaining"] ?? null,
213
+ domainCheckedAt: f["Domain checked at"] ?? null,
214
+ crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
215
+ mobileOk: typeof f["Mobile OK"] === "boolean" ? f["Mobile OK"] : null,
216
+ linksOk: typeof f["Links OK"] === "boolean" ? f["Links OK"] : null,
217
+ brokenLinks: typeof f["Broken links"] === "number" ? f["Broken links"] : null,
218
+ browserCheckedAt: f["Browser checked at"] ?? null,
198
219
  copyIntro: trimToNull(f["Copy \u2014 Intro"]),
199
220
  copyContact: trimToNull(f["Copy \u2014 Contact"]),
200
221
  copyFooter: trimToNull(f["Copy \u2014 Footer"]),
@@ -256,7 +277,24 @@ function securityFields(counts) {
256
277
  "Security Vulns Critical": counts.critical,
257
278
  "Security Vulns High": counts.high,
258
279
  "Security Vulns Moderate": counts.moderate,
259
- "Security Vulns Low": counts.low
280
+ "Security Vulns Low": counts.low,
281
+ // Stamp freshness alongside the counts so the Security Updates auto-tick can require a recent
282
+ // audit (a clean count from months ago must not silently keep ticking the box).
283
+ "Last security audit at": (/* @__PURE__ */ new Date()).toISOString()
284
+ };
285
+ }
286
+ function domainFields(result) {
287
+ const fields = { "Domain checked at": result.checkedAt };
288
+ if (result.certDaysRemaining !== null) fields["Cert days remaining"] = result.certDaysRemaining;
289
+ return fields;
290
+ }
291
+ function browserFields(r) {
292
+ return {
293
+ "Crossbrowser OK": r.desktopOk,
294
+ "Mobile OK": r.mobileOk,
295
+ "Links OK": r.linksOk,
296
+ "Broken links": r.brokenLinks,
297
+ "Browser checked at": r.checkedAt
260
298
  };
261
299
  }
262
300
  async function updateScores(base, recordId, scores) {
@@ -277,6 +315,8 @@ async function updateAuditFields(base, recordId, audits) {
277
315
  if (audits.a11y) Object.assign(fields, a11yFields(audits.a11y));
278
316
  if (audits.deps) Object.assign(fields, depsFields(audits.deps));
279
317
  if (audits.security) Object.assign(fields, securityFields(audits.security));
318
+ if (audits.domain) Object.assign(fields, domainFields(audits.domain));
319
+ if (audits.browser) Object.assign(fields, browserFields(audits.browser));
280
320
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
281
321
  return fields;
282
322
  }
@@ -295,7 +335,7 @@ async function updateLaunched(base, recordId, at) {
295
335
  const fields = { Status: "maintenance", "Launched at": at };
296
336
  await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
297
337
  }
298
- var WEBSITES_TABLE, ACTIVE_STATUSES;
338
+ var WEBSITES_TABLE, ACTIVE_STATUSES, FREQUENCIES;
299
339
  var init_websites = __esm({
300
340
  "src/reports/airtable/websites.ts"() {
301
341
  "use strict";
@@ -304,6 +344,7 @@ var init_websites = __esm({
304
344
  "maintenance",
305
345
  "launch period"
306
346
  ]);
347
+ FREQUENCIES = ["None", "Monthly", "Quarterly", "Yearly"];
307
348
  }
308
349
  });
309
350
 
@@ -470,6 +511,53 @@ var init_security_airtable = __esm({
470
511
  }
471
512
  });
472
513
 
514
+ // src/audits/domain-airtable.ts
515
+ function hasDomainResult(result) {
516
+ if (result.audit !== "domain") return false;
517
+ const d = result.details;
518
+ return !!d && typeof d.checkedAt === "string";
519
+ }
520
+ function domainResultFromAudit(result) {
521
+ if (result.audit !== "domain") {
522
+ throw new Error(`Expected a 'domain' AuditResult, got '${result.audit}'`);
523
+ }
524
+ const d = result.details;
525
+ return {
526
+ certDaysRemaining: typeof d?.certDaysRemaining === "number" ? d.certDaysRemaining : null,
527
+ checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
528
+ };
529
+ }
530
+ var init_domain_airtable = __esm({
531
+ "src/audits/domain-airtable.ts"() {
532
+ "use strict";
533
+ }
534
+ });
535
+
536
+ // src/audits/browser-airtable.ts
537
+ function hasBrowserResult(result) {
538
+ if (result.audit !== "browser") return false;
539
+ const d = result.details;
540
+ return !!d && typeof d.checkedAt === "string";
541
+ }
542
+ function browserFieldsFromAudit(result) {
543
+ if (result.audit !== "browser") {
544
+ throw new Error(`Expected a 'browser' AuditResult, got '${result.audit}'`);
545
+ }
546
+ const d = result.details;
547
+ return {
548
+ desktopOk: d?.desktopOk === true,
549
+ mobileOk: d?.mobileOk === true,
550
+ linksOk: d?.linksOk === true,
551
+ brokenLinks: typeof d?.brokenLinks === "number" ? d.brokenLinks : 0,
552
+ checkedAt: typeof d?.checkedAt === "string" ? d.checkedAt : (/* @__PURE__ */ new Date()).toISOString()
553
+ };
554
+ }
555
+ var init_browser_airtable = __esm({
556
+ "src/audits/browser-airtable.ts"() {
557
+ "use strict";
558
+ }
559
+ });
560
+
473
561
  // src/audits/write-audits-to-airtable.ts
474
562
  var write_audits_to_airtable_exports = {};
475
563
  __export(write_audits_to_airtable_exports, {
@@ -480,22 +568,14 @@ __export(write_audits_to_airtable_exports, {
480
568
  async function writeAuditsToAirtable(args) {
481
569
  const { base, websites, slug, results } = args;
482
570
  const lhResult = results.find((r) => r.audit === "lighthouse");
483
- if (!lhResult) {
484
- throw Object.assign(
485
- new Error(
486
- "--write-airtable requires a lighthouse result; did you pass --only without lighthouse?"
487
- ),
488
- { exitCode: 2 }
489
- );
490
- }
491
571
  const target = websites.find((w) => siteSlug(w.name) === slug);
492
572
  if (!target) {
493
573
  throw Object.assign(new Error(`No Websites row matched slug "${slug}"`), { exitCode: 2 });
494
574
  }
495
575
  const writes = [];
496
576
  const audits = {};
497
- const lhHasScores = hasRealScores(lhResult);
498
- if (lhHasScores) {
577
+ const lhHasScores = lhResult ? hasRealScores(lhResult) : false;
578
+ if (lhResult && lhHasScores) {
499
579
  const scores = lighthouseScoresFromResult(lhResult);
500
580
  audits.scores = scores;
501
581
  writes.push({ audit: "lighthouse", counts: scores });
@@ -518,10 +598,22 @@ async function writeAuditsToAirtable(args) {
518
598
  audits.security = counts;
519
599
  writes.push({ audit: "security", counts });
520
600
  }
601
+ const dom = results.find((r) => r.audit === "domain");
602
+ if (dom && hasDomainResult(dom)) {
603
+ const result = domainResultFromAudit(dom);
604
+ audits.domain = result;
605
+ writes.push({ audit: "domain", counts: result });
606
+ }
607
+ const browser = results.find((r) => r.audit === "browser");
608
+ if (browser && hasBrowserResult(browser)) {
609
+ const fields = browserFieldsFromAudit(browser);
610
+ audits.browser = fields;
611
+ writes.push({ audit: "browser", counts: fields });
612
+ }
521
613
  if (Object.keys(audits).length > 0) {
522
614
  await updateAuditFields(base, target.id, audits);
523
615
  }
524
- if (!lhHasScores) {
616
+ if (lhResult && !lhHasScores) {
525
617
  const persisted = writes.map((w) => w.audit);
526
618
  throw Object.assign(
527
619
  new Error(
@@ -572,6 +664,8 @@ var init_write_audits_to_airtable = __esm({
572
664
  init_a11y_airtable();
573
665
  init_deps_airtable();
574
666
  init_security_airtable();
667
+ init_domain_airtable();
668
+ init_browser_airtable();
575
669
  }
576
670
  });
577
671
 
@@ -657,9 +751,32 @@ function mapRow2(rec) {
657
751
  deliveryStatus: f["Delivery status"] ?? "pending",
658
752
  renderedHtmlAttachment: html,
659
753
  resendMessageId: f["Resend message ID"] ?? null,
660
- checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])]))
754
+ checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
755
+ autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"])
661
756
  };
662
757
  }
758
+ function parseAutoEvidence(raw) {
759
+ if (typeof raw !== "string" || !raw.trim()) return null;
760
+ let parsed;
761
+ try {
762
+ parsed = JSON.parse(raw);
763
+ } catch {
764
+ return null;
765
+ }
766
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
767
+ const out = {};
768
+ for (const [field, v] of Object.entries(parsed)) {
769
+ if (!v || typeof v !== "object") continue;
770
+ const o = v;
771
+ if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown") continue;
772
+ out[field] = {
773
+ result: o.result,
774
+ checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
775
+ note: typeof o.note === "string" ? o.note : ""
776
+ };
777
+ }
778
+ return Object.keys(out).length > 0 ? out : null;
779
+ }
663
780
  function lighthouseFromFields(f) {
664
781
  const p = f["Lighthouse \u2014 Performance"];
665
782
  const a = f["Lighthouse \u2014 Accessibility"];
@@ -696,6 +813,10 @@ async function createDraft(base, input) {
696
813
  if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
697
814
  if (input.period !== void 0) fields["Period"] = input.period;
698
815
  if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
816
+ for (const field of input.checklistTicks ?? []) fields[field] = true;
817
+ if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
818
+ fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
819
+ }
699
820
  const created = await base(REPORTS_TABLE).create([{ fields }]);
700
821
  const rec = created[0];
701
822
  if (!rec) throw new Error("Airtable create returned no records");
@@ -835,15 +956,10 @@ var init_copy = __esm({
835
956
  ],
836
957
  announceHeading: "YOUR ONGOING SITE CARE",
837
958
  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:",
838
- announceCadenceHeading: "WHAT TO EXPECT",
839
- announceTestingLabel: "Full site testing",
840
- announceMaintenanceLabel: "Routine maintenance",
841
- announcePreviewLabel: "From your latest full site test:",
842
- announceScoreNote: "These are independent Google Lighthouse scores, each out of 100 \u2014 higher is better.",
843
959
  announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
844
960
  announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
845
961
  announceCadence: "After each one we'll send you a short report like this \u2014 there's nothing you need to do.",
846
- 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."
962
+ announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know."
847
963
  };
848
964
  }
849
965
  });
@@ -917,65 +1033,124 @@ var init_html = __esm({
917
1033
  }
918
1034
  });
919
1035
 
920
- // src/reports/maintenance-email/template.ts
921
- function fmtDate(d) {
922
- if (!d || Number.isNaN(d.getTime())) return "";
923
- const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
924
- const dd = String(d.getUTCDate()).padStart(2, "0");
925
- const yyyy = d.getUTCFullYear();
926
- return `${mm}.${dd}.${yyyy}`;
927
- }
1036
+ // src/reports/email-sections.ts
928
1037
  function fmtUsers(n) {
929
1038
  return n.toLocaleString("en-US");
930
1039
  }
931
- function trendText(color, text) {
932
- return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
1040
+ function checklistRowsSection(rows, opts) {
1041
+ return rows.map((label, i) => {
1042
+ const isLast = i === rows.length - 1;
1043
+ const border = isLast ? "" : ` border-bottom="solid ${BORDER} 1px"`;
1044
+ const lastPad = isLast ? ` padding-bottom="${opts.lastPaddingBottom}"` : "";
1045
+ return `
1046
+ <mj-section background-color="${opts.background}" padding="0px"${lastPad}>
1047
+ <mj-group>
1048
+ <mj-column padding-left="0px" width="90%"${border}>
1049
+ <mj-text height="25px" padding-left="0px" color="${GREY}" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
1050
+ </mj-column>
1051
+ <mj-column width="10%"${border} padding-top="15px">
1052
+ <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
1053
+ </mj-column>
1054
+ </mj-group>
1055
+ </mj-section>`;
1056
+ }).join("");
1057
+ }
1058
+ function lighthouseScoresSection(lighthouse2, opts = {}) {
1059
+ const background = opts.background ?? "#F4F4F4";
1060
+ const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
1061
+ const labelTop = opts.pad ?? "55px";
1062
+ const footnoteBottom = opts.pad ? "0px" : "36px";
1063
+ const rows = LIGHTHOUSE_ROWS.map(
1064
+ ({ label, key, range }, i) => `
1065
+ <mj-text color="${RED}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
1066
+ <mj-text color="${RED}" font-size="44px" font-weight="400" padding-top="0px">${lighthouse2[key]}</mj-text>
1067
+ <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 ? `
1068
+ <mj-divider border-width="1px" border-style="solid" border-color="${BORDER}" padding="0" />` : ""}`
1069
+ ).join("");
1070
+ return `
1071
+ <mj-section background-color="${background}"${sectionPad}>
1072
+ <mj-column>
1073
+ <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">LIGHTHOUSE SCORES*</mj-text>${rows}
1074
+ <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>
1075
+ </mj-column>
1076
+ </mj-section>`;
933
1077
  }
934
1078
  function analyticsTrendLine(cur, prev) {
935
1079
  if (cur === void 0 || prev === void 0) {
936
- return trendText(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
1080
+ return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
937
1081
  }
938
1082
  if (prev === 0) {
939
- return cur > 0 ? trendText(TREND_UP, "\u25B2 New this period (0 last period)") : trendText(TREND_NEUTRAL, "Last Period: 0");
1083
+ return cur > 0 ? trendLine(TREND_UP, "\u25B2 New this period (0 last period)") : trendLine(TREND_NEUTRAL, "Last Period: 0");
940
1084
  }
941
1085
  const pct = Math.round((cur - prev) / prev * 100);
942
1086
  const range = `(${fmtUsers(prev)} \u2192 ${fmtUsers(cur)})`;
943
- if (pct > 0) return trendText(TREND_UP, `\u25B2 ${pct}% vs last period ${range}`);
944
- if (pct < 0) return trendText(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
945
- return trendText(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
1087
+ if (pct > 0) return trendLine(TREND_UP, `\u25B2 ${pct}% vs last period ${range}`);
1088
+ if (pct < 0) return trendLine(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
1089
+ return trendLine(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
1090
+ }
1091
+ function trendLine(color, text) {
1092
+ return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
1093
+ }
1094
+ function footnoteLine(text) {
1095
+ 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>`;
1096
+ }
1097
+ function analyticsSection(opts) {
1098
+ const users = opts.current !== void 0 ? fmtUsers(opts.current) : "\u2014";
1099
+ const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join("\n ");
1100
+ const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join("\n ");
1101
+ const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
1102
+ const labelTop = opts.pad ?? "75px";
1103
+ return `
1104
+ <mj-section background-color="${opts.background}"${sectionPad}>
1105
+ <mj-column>
1106
+ <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">ANALYTICS</mj-text>
1107
+ <mj-text color="${RED}" font-size="44px" font-weight="400">${users} Users</mj-text>
1108
+ ${analyticsTrendLine(opts.current, opts.previous)}
1109
+ ${body}
1110
+ ${footnotes}
1111
+ </mj-column>
1112
+ </mj-section>`;
1113
+ }
1114
+ var escapeXml, RED, GREY, BORDER, TREND_UP, TREND_NEUTRAL, CHECK_PNG, LIGHTHOUSE_ROWS;
1115
+ var init_email_sections = __esm({
1116
+ "src/reports/email-sections.ts"() {
1117
+ "use strict";
1118
+ init_html();
1119
+ init_assets();
1120
+ escapeXml = escapeHtml;
1121
+ RED = "#C00";
1122
+ GREY = "#757575";
1123
+ BORDER = "#CCCCCC";
1124
+ TREND_UP = "#2E7D32";
1125
+ TREND_NEUTRAL = GREY;
1126
+ CHECK_PNG = `cid:${CHECK_CID}`;
1127
+ LIGHTHOUSE_ROWS = [
1128
+ { label: "Performance", key: "performance", range: "Acceptable 50\u201389 // Ideal 90\u2013100" },
1129
+ { label: "Readability (A11y)", key: "accessibility", range: "Acceptable 80\u201399 // Ideal 100" },
1130
+ { label: "Best Practices", key: "bestPractices", range: "Acceptable 60\u201379 // Ideal 80\u2013100" },
1131
+ { label: "Site Structure", key: "seo", range: "Acceptable 50\u201389 // Ideal 90\u2013100" }
1132
+ ];
1133
+ }
1134
+ });
1135
+
1136
+ // src/reports/maintenance-email/template.ts
1137
+ function fmtDate(d) {
1138
+ if (!d || Number.isNaN(d.getTime())) return "";
1139
+ const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
1140
+ const dd = String(d.getUTCDate()).padStart(2, "0");
1141
+ const yyyy = d.getUTCFullYear();
1142
+ return `${mm}.${dd}.${yyyy}`;
946
1143
  }
947
1144
  function maintenanceChecksSection(copy, searchPosition) {
948
1145
  const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` : copy.maintenanceChecks[3] ?? "";
949
1146
  const rows = copy.maintenanceChecks.map((label, i) => i === 3 ? googleLabel : label);
950
- return rows.map(
951
- (label, i) => `
952
- <mj-section background-color="white" padding="0px"${i === rows.length - 1 ? ' padding-bottom="36px"' : ""}>
953
- <mj-group>
954
- <mj-column padding-left="0px" width="90%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
955
- <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
956
- </mj-column>
957
- <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
958
- <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
959
- </mj-column>
960
- </mj-group>
961
- </mj-section>`
962
- ).join("");
1147
+ return checklistRowsSection(rows, { background: "white", lastPaddingBottom: "36px" });
963
1148
  }
964
1149
  function testingChecklistSection(copy) {
965
- const rows = copy.testingChecklist;
966
- return rows.map(
967
- (label, i) => `
968
- <mj-section background-color="#F4F4F4" padding="0px"${i === rows.length - 1 ? ' padding-bottom="60px"' : ""}>
969
- <mj-group>
970
- <mj-column width="90%" padding-left="0px"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""}>
971
- <mj-text height="25px" padding-left="0px" color="#757575" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
972
- </mj-column>
973
- <mj-column width="10%"${i < rows.length - 1 ? ' border-bottom="solid #CCCCCC 1px"' : ""} padding-top="15px">
974
- <mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
975
- </mj-column>
976
- </mj-group>
977
- </mj-section>`
978
- ).join("");
1150
+ return checklistRowsSection(copy.testingChecklist, {
1151
+ background: "#F4F4F4",
1152
+ lastPaddingBottom: "60px"
1153
+ });
979
1154
  }
980
1155
  function maintenanceTestingPlaceholder(lastTested) {
981
1156
  return `
@@ -995,7 +1170,7 @@ function testingIntroSection(copy) {
995
1170
  <mj-section background-color="#F4F4F4">
996
1171
  <mj-column>
997
1172
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
998
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.testingIntro)}</mj-text>
1173
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.testingIntro)}</mj-text>
999
1174
  </mj-column>
1000
1175
  </mj-section>`;
1001
1176
  }
@@ -1003,8 +1178,8 @@ function commentarySection(text, copy) {
1003
1178
  return `
1004
1179
  <mj-section background-color="white">
1005
1180
  <mj-column>
1006
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.notesHeader)}</mj-text>
1007
- <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>
1181
+ <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml2(copy.notesHeader)}</mj-text>
1182
+ <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>
1008
1183
  </mj-column>
1009
1184
  </mj-section>`;
1010
1185
  }
@@ -1013,8 +1188,8 @@ function hasHeaderDims(data) {
1013
1188
  }
1014
1189
  function headerImageTag(data) {
1015
1190
  const src = `cid:${data.headerImageCid}`;
1016
- const alt = `${escapeXml(data.siteName)} maintenance report`;
1017
- const href = isHttpUrl(data.siteUrl) ? escapeXml(data.siteUrl) : "#";
1191
+ const alt = `${escapeXml2(data.siteName)} maintenance report`;
1192
+ const href = isHttpUrl(data.siteUrl) ? escapeXml2(data.siteUrl) : "#";
1018
1193
  if (hasHeaderDims(data)) {
1019
1194
  return `<mj-image href="${href}" src="${src}" alt="${alt}" width="${data.headerWidth}px" css-class="rd-header" container-background-color="${data.headerBgColor}" />`;
1020
1195
  }
@@ -1027,7 +1202,7 @@ function headerStyleBlock(data) {
1027
1202
  function buildMjml(data) {
1028
1203
  const copy = data.copy ?? DEFAULT_COPY;
1029
1204
  const isTesting = data.reportType === "Testing";
1030
- const previewText = `Checked up on ${escapeXml(data.siteName)}`;
1205
+ const previewText = `Checked up on ${escapeXml2(data.siteName)}`;
1031
1206
  return `<mjml>
1032
1207
  <mj-head>
1033
1208
  <mj-attributes>
@@ -1049,89 +1224,65 @@ function buildMjml(data) {
1049
1224
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
1050
1225
  <mj-text color="#C00" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
1051
1226
  <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
1052
- <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml(copy.maintenanceIntro)}</mj-text>
1227
+ <mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.maintenanceIntro)}</mj-text>
1053
1228
  </mj-column>
1054
1229
  </mj-section>
1055
1230
  ${maintenanceChecksSection(copy, data.searchPosition)}
1056
- <mj-section background-color="#F4F4F4">
1057
- <mj-column>
1058
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">LIGHTHOUSE SCORES*</mj-text>
1059
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Performance</mj-text>
1060
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.performance}</mj-text>
1061
- <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>
1062
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1063
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Readability (A11y)</mj-text>
1064
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.accessibility}</mj-text>
1065
- <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>
1066
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1067
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Best Practices</mj-text>
1068
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.bestPractices}</mj-text>
1069
- <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>
1070
- <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1071
- <mj-text color="#C00" font-size="20px" font-weight="300" padding-top="25px">Site Structure</mj-text>
1072
- <mj-text color="#C00" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse.seo}</mj-text>
1073
- <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>
1074
- <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>
1075
- </mj-column>
1076
- </mj-section>
1077
- <mj-section background-color="white">
1078
- <mj-column>
1079
- <mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">ANALYTICS</mj-text>
1080
- <mj-text color="#C00" font-size="44px" font-weight="400">${data.gaUsersCurrent !== void 0 ? fmtUsers(data.gaUsersCurrent) : "\u2014"} Users</mj-text>
1081
- ${analyticsTrendLine(data.gaUsersCurrent, data.gaUsersPrevious)}
1082
- <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>
1083
- </mj-column>
1084
- </mj-section>
1231
+ ${lighthouseScoresSection(data.lighthouse)}
1232
+ ${analyticsSection({
1233
+ current: data.gaUsersCurrent,
1234
+ previous: data.gaUsersPrevious,
1235
+ background: "white",
1236
+ footnoteLines: [escapeXml2(copy.seoCta)]
1237
+ })}
1085
1238
  ${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder(data.lastTestedDate)}
1086
1239
  ${data.commentary ? commentarySection(data.commentary, copy) : ""}
1087
1240
  <mj-section background-color="white">
1088
1241
  <mj-column padding-top="36px">
1089
1242
  <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>
1090
1243
  ${copy.contact.map(
1091
- (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>`
1244
+ (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>`
1092
1245
  ).join("\n ")}
1093
1246
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1094
- <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>
1247
+ <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>
1095
1248
  <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>
1096
1249
  ${[copy.footerOrg, ...copy.footerAddress].map(
1097
- (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>`
1250
+ (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>`
1098
1251
  ).join("\n ")}
1099
1252
  </mj-column>
1100
1253
  </mj-section>
1101
1254
  </mj-body>
1102
1255
  </mjml>`;
1103
1256
  }
1104
- var escapeXml, CHECK_PNG, BLURRED_TESTS, TREND_UP, TREND_NEUTRAL;
1257
+ var escapeXml2, BLURRED_TESTS;
1105
1258
  var init_template = __esm({
1106
1259
  "src/reports/maintenance-email/template.ts"() {
1107
1260
  "use strict";
1108
1261
  init_copy();
1109
1262
  init_assets();
1263
+ init_email_sections();
1110
1264
  init_html();
1111
1265
  init_url();
1112
- escapeXml = escapeHtml;
1113
- CHECK_PNG = `cid:${CHECK_CID}`;
1266
+ escapeXml2 = escapeHtml;
1114
1267
  BLURRED_TESTS = `cid:${BLURRED_CID}`;
1115
- TREND_UP = "#2E7D32";
1116
- TREND_NEUTRAL = "#757575";
1117
1268
  }
1118
1269
  });
1119
1270
 
1120
1271
  // src/reports/launch-email/template.ts
1121
1272
  function buildLaunchMjml(data) {
1122
1273
  const copy = data.copy ?? DEFAULT_COPY;
1123
- const previewText = `${escapeXml(data.siteName)} is live`;
1274
+ const previewText = `${escapeXml2(data.siteName)} is live`;
1124
1275
  const setupRows = copy.launchSetupItems.map(
1125
1276
  (item) => `
1126
- <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>`
1277
+ <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>`
1127
1278
  ).join("");
1128
1279
  const contactRows = copy.contact.map(
1129
1280
  (line) => `
1130
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1281
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
1131
1282
  ).join("");
1132
1283
  const footerAddressRows = copy.footerAddress.map(
1133
1284
  (line) => `
1134
- <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>`
1285
+ <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>`
1135
1286
  ).join("");
1136
1287
  return `<mjml>
1137
1288
  <mj-head>
@@ -1149,47 +1300,43 @@ function buildLaunchMjml(data) {
1149
1300
  </mj-section>
1150
1301
  <mj-section background-color="white">
1151
1302
  <mj-column>
1152
- <mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.launchHeading)}</mj-text>
1153
- <mj-text color="${RED}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
1154
- <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>
1303
+ <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml2(copy.launchHeading)}</mj-text>
1304
+ <mj-text color="${RED2}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
1305
+ <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>
1155
1306
  ${setupRows}
1156
1307
  </mj-column>
1157
1308
  </mj-section>
1158
1309
  <mj-section background-color="white">
1159
1310
  <mj-column padding-top="36px">
1160
- <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>
1311
+ <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>
1161
1312
  ${contactRows}
1162
1313
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1163
- <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>
1164
- <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>
1165
- <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>
1314
+ <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>
1315
+ <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>
1316
+ <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>
1166
1317
  ${footerAddressRows}
1167
1318
  </mj-column>
1168
1319
  </mj-section>
1169
1320
  </mj-body>
1170
1321
  </mjml>`;
1171
1322
  }
1172
- var RED, GREY;
1323
+ var RED2, GREY2;
1173
1324
  var init_template2 = __esm({
1174
1325
  "src/reports/launch-email/template.ts"() {
1175
1326
  "use strict";
1176
1327
  init_copy();
1177
1328
  init_template();
1178
- RED = "#C00";
1179
- GREY = "#757575";
1329
+ RED2 = "#C00";
1330
+ GREY2 = "#757575";
1180
1331
  }
1181
1332
  });
1182
1333
 
1183
1334
  // src/reports/announcement-email/template.ts
1184
- function fmtVisitors(n) {
1185
- return n.toLocaleString("en-US");
1335
+ function sectionLabel(text) {
1336
+ return `<mj-text color="${RED3}" font-size="20px" font-weight="700" padding-top="0px">${escapeXml2(text)}</mj-text>`;
1186
1337
  }
1187
- function visitorTrend(cur, prev) {
1188
- if (cur === void 0 || prev === void 0 || prev === 0) return null;
1189
- const pct = Math.round((cur - prev) / prev * 100);
1190
- if (pct > 0) return `\u25B2 ${pct}% vs the previous month`;
1191
- if (pct < 0) return `\u25BC ${Math.abs(pct)}% vs the previous month`;
1192
- return "No change vs the previous month";
1338
+ function bodyLine(text, paddingTop = "8px") {
1339
+ 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>`;
1193
1340
  }
1194
1341
  function announcementSiteExtras(site) {
1195
1342
  return {
@@ -1200,74 +1347,65 @@ function announcementSiteExtras(site) {
1200
1347
  function buildAnnouncementMjml(data) {
1201
1348
  const copy = data.copy ?? DEFAULT_COPY;
1202
1349
  const previewText = "Your monthly report from Reddoor";
1350
+ const cad = data.cadence;
1351
+ const hasMaint = Boolean(cad && cad.maintenance !== "None");
1352
+ const hasTesting = Boolean(cad && cad.testing !== "None");
1203
1353
  const improvementItems = [];
1204
1354
  if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
1205
1355
  if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
1206
- const improvementsSection = improvementItems.length > 0 ? `
1207
- <mj-section background-color="white">
1356
+ const hasImpr = improvementItems.length > 0;
1357
+ const BANDS = ["white", "#F4F4F4"];
1358
+ let bandN = 0;
1359
+ const nextBg = () => BANDS[bandN++ % 2];
1360
+ const introBg = nextBg();
1361
+ const maintBg = hasMaint ? nextBg() : "";
1362
+ const testBg = hasTesting ? nextBg() : "";
1363
+ const lighthouseBg = nextBg();
1364
+ const analyticsBg = nextBg();
1365
+ const improvementsBg = hasImpr ? nextBg() : "";
1366
+ const contactBg = nextBg();
1367
+ const maintenanceSection = cad && cad.maintenance !== "None" ? `
1368
+ <mj-section background-color="${maintBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
1208
1369
  <mj-column>
1209
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">RECENT IMPROVEMENTS</mj-text>
1210
- ${improvementItems.map(
1211
- (item) => `
1212
- <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>`
1213
- ).join("")}
1370
+ ${sectionLabel("MAINTENANCE CHECKS")}
1371
+ ${bodyLine(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.${cad.testing === "None" ? ` ${copy.announceCadence}` : ""}`)}
1214
1372
  </mj-column>
1215
- </mj-section>` : "";
1216
- const cad = data.cadence;
1217
- const cadenceBlocks = [];
1218
- if (cad && cad.testing !== "None")
1219
- cadenceBlocks.push({
1220
- line: `${copy.announceTestingLabel} \u2014 ${FREQ_PHRASE[cad.testing]}`,
1221
- checks: copy.testingChecklist
1222
- });
1223
- if (cad && cad.maintenance !== "None")
1224
- cadenceBlocks.push({
1225
- line: `${copy.announceMaintenanceLabel} \u2014 ${FREQ_PHRASE[cad.maintenance]}`,
1226
- checks: copy.maintenanceChecks
1227
- });
1228
- const cadenceSection = cadenceBlocks.length > 0 ? `
1229
- <mj-section background-color="white">
1373
+ </mj-section>${checklistRowsSection(copy.maintenanceChecks, {
1374
+ background: maintBg,
1375
+ lastPaddingBottom: SECTION_PAD
1376
+ })}` : "";
1377
+ const testingSection = cad && cad.testing !== "None" ? `
1378
+ <mj-section background-color="${testBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
1230
1379
  <mj-column>
1231
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">${escapeXml(copy.announceCadenceHeading)}</mj-text>
1232
- ${cadenceBlocks.map(
1233
- (b) => `
1234
- <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(
1235
- (c) => `
1236
- <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>`
1237
- ).join("")}`
1238
- ).join("")}
1239
- <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>
1380
+ ${sectionLabel("TESTING")}
1381
+ ${bodyLine(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}. ${copy.announceCadence}`)}
1240
1382
  </mj-column>
1241
- </mj-section>` : "";
1242
- const scoreRows = SCORE_PREVIEW.map(
1243
- ({ label, key }) => `
1244
- <mj-text color="${RED2}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
1245
- <mj-text color="${RED2}" font-size="44px" font-weight="400" padding-top="0px">${data.lighthouse[key]}</mj-text>`
1246
- ).join("");
1247
- const scoreNote = copy.announceScoreNote ? `
1248
- <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>` : "";
1249
- const trend = visitorTrend(data.gaUsersCurrent, data.gaUsersPrevious);
1250
- const trafficRows = [];
1251
- if (data.gaUsersCurrent !== void 0)
1252
- trafficRows.push(`
1253
- <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>`);
1254
- if (data.searchPosition !== void 0)
1255
- trafficRows.push(`
1256
- <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>`);
1257
- const trafficSection = trafficRows.length > 0 ? `
1258
- <mj-section background-color="white">
1383
+ </mj-section>${checklistRowsSection(copy.testingChecklist, {
1384
+ background: testBg,
1385
+ lastPaddingBottom: SECTION_PAD
1386
+ })}` : "";
1387
+ const analytics = analyticsSection({
1388
+ current: data.gaUsersCurrent,
1389
+ previous: data.gaUsersPrevious,
1390
+ background: analyticsBg,
1391
+ pad: SECTION_PAD,
1392
+ bodyLines: data.searchPosition !== void 0 ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`] : []
1393
+ });
1394
+ const improvementsSection = hasImpr ? `
1395
+ <mj-section background-color="${improvementsBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
1259
1396
  <mj-column>
1260
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="36px">TRAFFIC &amp; SEARCH</mj-text>
1261
- ${trafficRows.join("")}
1397
+ ${sectionLabel("RECENT IMPROVEMENTS")}
1398
+ ${improvementItems.map((item) => bodyLine(item)).join("\n ")}
1399
+ ${bodyLine(copy.announceOpenDoor, "16px")}
1262
1400
  </mj-column>
1263
1401
  </mj-section>` : "";
1264
1402
  const contactRows = copy.contact.map(
1265
1403
  (line) => `
1266
- <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml(line)}</mj-text>`
1404
+ <mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
1267
1405
  ).join("");
1268
1406
  const footerAddressRows = copy.footerAddress.map(
1269
1407
  (line) => `
1270
- <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>`
1408
+ <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>`
1271
1409
  ).join("");
1272
1410
  return `<mjml>
1273
1411
  <mj-head>
@@ -1276,69 +1414,54 @@ function buildAnnouncementMjml(data) {
1276
1414
  <mj-section padding-left="11%" padding-right="11%"/>
1277
1415
  <mj-image padding="0px" />
1278
1416
  </mj-attributes>
1279
- <mj-preview>${escapeXml(previewText)}</mj-preview>
1417
+ <mj-preview>${escapeXml2(previewText)}</mj-preview>
1280
1418
  ${headerStyleBlock(data)}
1281
1419
  </mj-head>
1282
1420
  <mj-body background-color="white">
1283
1421
  <mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
1284
1422
  <mj-column>${headerImageTag(data)}</mj-column>
1285
1423
  </mj-section>
1286
- <mj-section background-color="white">
1424
+ <mj-section background-color="${introBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
1287
1425
  <mj-column>
1288
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml(copy.announceHeading)}</mj-text>
1289
- <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>
1290
- <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>
1426
+ ${sectionLabel(copy.announceHeading)}
1427
+ <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>
1428
+ ${bodyLine(copy.announceBody)}
1291
1429
  </mj-column>
1292
1430
  </mj-section>
1293
- ${cadenceSection}
1431
+ ${maintenanceSection}
1432
+ ${testingSection}
1433
+ ${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}
1434
+ ${analytics}
1294
1435
  ${improvementsSection}
1295
- <mj-section background-color="#F4F4F4">
1296
- <mj-column>
1297
- <mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="55px">${escapeXml(copy.announcePreviewLabel)}</mj-text>
1298
- ${scoreRows}${scoreNote}
1299
- </mj-column>
1300
- </mj-section>
1301
- ${trafficSection}
1302
- <mj-section background-color="white">
1436
+ <mj-section background-color="${contactBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
1303
1437
  <mj-column>
1304
- <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>
1305
- </mj-column>
1306
- </mj-section>
1307
- <mj-section background-color="white">
1308
- <mj-column padding-top="36px">
1309
- <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>
1438
+ <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>
1310
1439
  ${contactRows}
1311
1440
  <mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
1312
- <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>
1313
- <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>
1314
- <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>
1441
+ <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>
1442
+ <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>
1443
+ <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>
1315
1444
  ${footerAddressRows}
1316
1445
  </mj-column>
1317
1446
  </mj-section>
1318
1447
  </mj-body>
1319
1448
  </mjml>`;
1320
1449
  }
1321
- var FREQ_PHRASE, RED2, GREY2, CHECK_PNG2, SCORE_PREVIEW;
1450
+ var FREQ_PHRASE, RED3, GREY3, SECTION_PAD;
1322
1451
  var init_template3 = __esm({
1323
1452
  "src/reports/announcement-email/template.ts"() {
1324
1453
  "use strict";
1325
1454
  init_copy();
1326
1455
  init_template();
1327
- init_assets();
1456
+ init_email_sections();
1328
1457
  FREQ_PHRASE = {
1329
1458
  Monthly: "every month",
1330
1459
  Quarterly: "every quarter",
1331
1460
  Yearly: "every year"
1332
1461
  };
1333
- RED2 = "#C00";
1334
- GREY2 = "#757575";
1335
- CHECK_PNG2 = `cid:${CHECK_CID}`;
1336
- SCORE_PREVIEW = [
1337
- { label: "Performance", key: "performance" },
1338
- { label: "Readability (A11y)", key: "accessibility" },
1339
- { label: "Best Practices", key: "bestPractices" },
1340
- { label: "Site Structure", key: "seo" }
1341
- ];
1462
+ RED3 = "#C00";
1463
+ GREY3 = "#757575";
1464
+ SECTION_PAD = "40px";
1342
1465
  }
1343
1466
  });
1344
1467
 
@@ -1464,7 +1587,7 @@ function gitHubSignalsStale(swept, now) {
1464
1587
  if (swept === null) return true;
1465
1588
  const ageMs = now.getTime() - Date.parse(swept);
1466
1589
  if (!Number.isFinite(ageMs)) return true;
1467
- return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY2;
1590
+ return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY4;
1468
1591
  }
1469
1592
  function collectVulnAlerts(sites, baseUrl) {
1470
1593
  const items = [];
@@ -1558,13 +1681,13 @@ function collectCiAlerts(sites, baseUrl, now = /* @__PURE__ */ new Date()) {
1558
1681
  }
1559
1682
  return items;
1560
1683
  }
1561
- var GITHUB_SIGNALS_STALE_DAYS, MS_PER_DAY2, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
1684
+ var GITHUB_SIGNALS_STALE_DAYS, MS_PER_DAY4, LIGHTHOUSE_FLOOR, LIGHTHOUSE_CATEGORIES2;
1562
1685
  var init_digest_collectors = __esm({
1563
1686
  "src/alerts/digest-collectors.ts"() {
1564
1687
  "use strict";
1565
1688
  init_websites();
1566
1689
  GITHUB_SIGNALS_STALE_DAYS = 3;
1567
- MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
1690
+ MS_PER_DAY4 = 24 * 60 * 60 * 1e3;
1568
1691
  LIGHTHOUSE_FLOOR = 75;
1569
1692
  LIGHTHOUSE_CATEGORIES2 = [
1570
1693
  { field: "pScore", slug: "performance", label: "Performance" },
@@ -1648,16 +1771,16 @@ __export(digest_exports, {
1648
1771
  runDigest: () => runDigest
1649
1772
  });
1650
1773
  function readySection(items) {
1651
- const heading = `<h2 style="color:${RED3};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
1774
+ const heading = `<h2 style="color:${RED4};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Ready for your yes</h2>`;
1652
1775
  if (items.length === 0) {
1653
- return `${heading}<p style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1776
+ return `${heading}<p style="color:${GREY4};font-family:helvetica,sans-serif;font-size:16px;margin:0">Nothing waiting on you.</p>`;
1654
1777
  }
1655
1778
  const rows = items.map((it) => {
1656
1779
  const safeUrl = it.dashboardUrl.startsWith("https://") ? it.dashboardUrl : void 0;
1657
1780
  const link = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">review &amp; approve</a>` : `review &amp; approve`;
1658
1781
  return `
1659
1782
  <tr>
1660
- <td style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1783
+ <td style="color:${GREY4};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">
1661
1784
  <strong style="color:#222">${escapeHtml(it.siteName)}</strong> \u2014 ${escapeHtml(it.reportType)} (${escapeHtml(it.period)})
1662
1785
  \u2014 ${link}
1663
1786
  </td>
@@ -1667,15 +1790,15 @@ function readySection(items) {
1667
1790
  }
1668
1791
  function attentionBadge(status) {
1669
1792
  if (status === "new")
1670
- return `<strong style="color:${RED3};font-family:helvetica,sans-serif">NEW</strong> `;
1793
+ return `<strong style="color:${RED4};font-family:helvetica,sans-serif">NEW</strong> `;
1671
1794
  if (status === "worse")
1672
- return `<strong style="color:${RED3};font-family:helvetica,sans-serif">WORSE</strong> `;
1795
+ return `<strong style="color:${RED4};font-family:helvetica,sans-serif">WORSE</strong> `;
1673
1796
  return "";
1674
1797
  }
1675
1798
  function attentionSection(items) {
1676
- const heading = `<h2 style="color:${RED3};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1799
+ const heading = `<h2 style="color:${RED4};font-family:helvetica,sans-serif;font-size:20px;font-weight:700;margin:32px 0 8px">Needs attention</h2>`;
1677
1800
  if (items.length === 0) {
1678
- return `${heading}<p style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1801
+ return `${heading}<p style="color:${GREY4};font-family:helvetica,sans-serif;font-size:16px;margin:0">All clear \u2014 nothing needs attention.</p>`;
1679
1802
  }
1680
1803
  const bySite = /* @__PURE__ */ new Map();
1681
1804
  for (const it of items) {
@@ -1692,7 +1815,7 @@ function attentionSection(items) {
1692
1815
  const titleHtml = safeUrl ? `<a href="${escapeHtml(safeUrl)}" style="${ANCHOR_STYLE}">${escapeHtml(it.title)}</a>` : escapeHtml(it.title);
1693
1816
  return `
1694
1817
  <tr>
1695
- <td style="color:${GREY3};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1818
+ <td style="color:${GREY4};font-family:helvetica,sans-serif;font-size:16px;line-height:24px;padding-bottom:8px">${attentionBadge(it.status)}${titleHtml}</td>
1696
1819
  </tr>`;
1697
1820
  }).join("");
1698
1821
  return `
@@ -1817,7 +1940,7 @@ function renderDigestHtml(sections) {
1817
1940
  <table width="600" style="border-collapse:collapse">
1818
1941
  <tr>
1819
1942
  <td>
1820
- <h1 style="color:${RED3};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1943
+ <h1 style="color:${RED4};font-family:helvetica,sans-serif;font-size:24px;font-weight:700;margin:0 0 8px">Your fleet today</h1>
1821
1944
  ${readySection(sections.readyForYourYes)}
1822
1945
  ${attentionSection(sections.needsAttention)}
1823
1946
  </td>
@@ -1829,7 +1952,7 @@ function renderDigestHtml(sections) {
1829
1952
  </body>
1830
1953
  </html>`;
1831
1954
  }
1832
- var GREY3, RED3, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1955
+ var GREY4, RED4, ANCHOR_STYLE, SEVERITY_ORDER, FROM_ADDRESS, DIGEST_OPERATOR_FALLBACK;
1833
1956
  var init_digest = __esm({
1834
1957
  "src/reports/digest.ts"() {
1835
1958
  "use strict";
@@ -1841,9 +1964,9 @@ var init_digest = __esm({
1841
1964
  init_digest_collectors();
1842
1965
  init_digest_state();
1843
1966
  init_html();
1844
- GREY3 = "#757575";
1845
- RED3 = "#C00";
1846
- ANCHOR_STYLE = `color:${RED3};font-family:helvetica,sans-serif`;
1967
+ GREY4 = "#757575";
1968
+ RED4 = "#C00";
1969
+ ANCHOR_STYLE = `color:${RED4};font-family:helvetica,sans-serif`;
1847
1970
  SEVERITY_ORDER = { critical: 0, warning: 1 };
1848
1971
  FROM_ADDRESS = "Reddoor Reports <reports@reddoorla.com>";
1849
1972
  DIGEST_OPERATOR_FALLBACK = "info@reddoorla.com";
@@ -2003,42 +2126,39 @@ async function sendOne(client, base, site, report) {
2003
2126
  headerHeight: header.displayHeight,
2004
2127
  headerBgColor: header.placeholderColor,
2005
2128
  // Announcement-only: re-derive cadence + improvements from the site row so the SENT email
2006
- // keeps its WHAT TO EXPECT section + improvement callouts. Without this the send-time
2007
- // re-render drops them entirely (they're not stored on the Reports row). Ignored by the
2008
- // other report templates.
2129
+ // keeps its cadence copy + improvement callouts. Without this the send-time re-render drops
2130
+ // them entirely (they're not stored on the Reports row). Ignored by the other templates.
2009
2131
  ...report.reportType === "Announcement" ? announcementSiteExtras(site) : {}
2010
2132
  });
2011
2133
  const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
2012
2134
  const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
2135
+ const attachments = [
2136
+ toInlineAttachment({
2137
+ bytes: header.bytes,
2138
+ filename: `${cidName}.jpg`,
2139
+ contentType: header.contentType,
2140
+ cid: cidName
2141
+ })
2142
+ ];
2143
+ for (const img of [bundled.check, bundled.blurred]) {
2144
+ if (html.includes(`cid:${img.cid}`)) {
2145
+ attachments.push(
2146
+ toInlineAttachment({
2147
+ bytes: img.bytes,
2148
+ filename: img.filename,
2149
+ contentType: img.contentType,
2150
+ cid: img.cid
2151
+ })
2152
+ );
2153
+ }
2154
+ }
2013
2155
  const payload = {
2014
2156
  from: FROM_ADDRESS2,
2015
2157
  to,
2016
2158
  replyTo: REPLY_TO,
2017
2159
  subject,
2018
2160
  html,
2019
- attachments: [
2020
- toInlineAttachment({
2021
- bytes: header.bytes,
2022
- filename: `${cidName}.jpg`,
2023
- contentType: header.contentType,
2024
- cid: cidName
2025
- }),
2026
- // Bundled images referenced via cid:rd-check-png / cid:rd-blurred-tests-jpg
2027
- // in the template. Attached inline so the email is self-contained — no
2028
- // external CDN dependency, no image-blocked broken icons in webmail.
2029
- toInlineAttachment({
2030
- bytes: bundled.check.bytes,
2031
- filename: bundled.check.filename,
2032
- contentType: bundled.check.contentType,
2033
- cid: bundled.check.cid
2034
- }),
2035
- toInlineAttachment({
2036
- bytes: bundled.blurred.bytes,
2037
- filename: bundled.blurred.filename,
2038
- contentType: bundled.blurred.contentType,
2039
- cid: bundled.blurred.cid
2040
- })
2041
- ],
2161
+ attachments,
2042
2162
  // Stable across retries of the same row — if Airtable stamping fails after a
2043
2163
  // successful Resend, the next --send-ready replays with the same key and
2044
2164
  // Resend returns the original message id rather than sending a duplicate.
@@ -3068,13 +3188,348 @@ async function a11yAudit(ctx) {
3068
3188
  }
3069
3189
  }
3070
3190
 
3191
+ // src/audits/domain.ts
3192
+ import { promises as dnsPromises } from "dns";
3193
+ import tls from "tls";
3194
+ var MS_PER_DAY = 24 * 60 * 60 * 1e3;
3195
+ async function checkDomain(url, deps) {
3196
+ let host;
3197
+ try {
3198
+ host = new URL(url).hostname;
3199
+ } catch {
3200
+ return { resolved: false, certDaysRemaining: null };
3201
+ }
3202
+ try {
3203
+ await deps.lookup(host);
3204
+ } catch {
3205
+ return { resolved: false, certDaysRemaining: null };
3206
+ }
3207
+ let validTo;
3208
+ try {
3209
+ validTo = await deps.certValidTo(host);
3210
+ } catch {
3211
+ validTo = null;
3212
+ }
3213
+ if (!validTo || Number.isNaN(validTo.getTime()))
3214
+ return { resolved: true, certDaysRemaining: null };
3215
+ return {
3216
+ resolved: true,
3217
+ certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY)
3218
+ };
3219
+ }
3220
+ function defaultDomainDeps(now) {
3221
+ return {
3222
+ lookup: async (host) => {
3223
+ await dnsPromises.lookup(host);
3224
+ },
3225
+ certValidTo: (host) => new Promise((resolvePromise) => {
3226
+ const socket = tls.connect(
3227
+ { host, port: 443, servername: host, timeout: 1e4, rejectUnauthorized: true },
3228
+ () => {
3229
+ const cert = socket.authorized ? socket.getPeerCertificate() : null;
3230
+ socket.end();
3231
+ const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;
3232
+ resolvePromise(validTo);
3233
+ }
3234
+ );
3235
+ socket.on("error", () => resolvePromise(null));
3236
+ socket.on("timeout", () => {
3237
+ socket.destroy();
3238
+ resolvePromise(null);
3239
+ });
3240
+ }),
3241
+ now
3242
+ };
3243
+ }
3244
+ async function domainAudit(ctx) {
3245
+ const { site } = ctx;
3246
+ const label = siteLabel(site);
3247
+ if (!site.deployedUrl) {
3248
+ return { audit: "domain", site: label, status: "skip", summary: "no deployed URL" };
3249
+ }
3250
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
3251
+ const deps = ctx.domainDeps ?? defaultDomainDeps(now);
3252
+ const check = await checkDomain(site.deployedUrl, deps);
3253
+ const checkedAt = now.toISOString();
3254
+ const status = check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14 ? "pass" : "warn";
3255
+ const summary = !check.resolved ? "did not resolve" : check.certDaysRemaining === null ? "resolved, no usable TLS cert" : `resolved, cert ${check.certDaysRemaining}d remaining`;
3256
+ return {
3257
+ audit: "domain",
3258
+ site: label,
3259
+ status,
3260
+ summary,
3261
+ details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt }
3262
+ };
3263
+ }
3264
+
3265
+ // src/audits/route-discovery.ts
3266
+ var DEFAULT_CAP = 15;
3267
+ function parseSitemapUrls(xml) {
3268
+ const out = [];
3269
+ const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
3270
+ let m;
3271
+ while ((m = re.exec(xml)) !== null) {
3272
+ const url = m[1];
3273
+ if (url) out.push(url.trim());
3274
+ }
3275
+ return out;
3276
+ }
3277
+ function parseHtmlLinks(html, baseUrl) {
3278
+ const out = /* @__PURE__ */ new Set();
3279
+ const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
3280
+ let m;
3281
+ while ((m = re.exec(html)) !== null) {
3282
+ const href = m[1];
3283
+ if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
3284
+ try {
3285
+ const u = new URL(href, baseUrl);
3286
+ if (u.origin !== new URL(baseUrl).origin) continue;
3287
+ out.add(u.pathname);
3288
+ } catch {
3289
+ }
3290
+ }
3291
+ return [...out];
3292
+ }
3293
+ function family(pathname) {
3294
+ return pathname.split("/").filter(Boolean)[0] ?? "";
3295
+ }
3296
+ function sampleRoutePaths(urlsOrPaths, cap = DEFAULT_CAP) {
3297
+ const seen = /* @__PURE__ */ new Set(["/"]);
3298
+ const buckets = /* @__PURE__ */ new Map();
3299
+ for (const raw of urlsOrPaths) {
3300
+ let pathname;
3301
+ try {
3302
+ pathname = raw.startsWith("/") ? new URL(raw, "https://x.invalid").pathname : new URL(raw).pathname;
3303
+ } catch {
3304
+ continue;
3305
+ }
3306
+ if (pathname === "/") continue;
3307
+ if (seen.has(pathname)) continue;
3308
+ seen.add(pathname);
3309
+ const fam = family(pathname);
3310
+ const arr = buckets.get(fam) ?? [];
3311
+ arr.push(pathname);
3312
+ buckets.set(fam, arr);
3313
+ }
3314
+ const result = ["/"];
3315
+ const families = [...buckets.values()];
3316
+ let guard = 0;
3317
+ while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 1e4) {
3318
+ for (const fam of families) {
3319
+ if (result.length >= cap) break;
3320
+ const next = fam.shift();
3321
+ if (next) result.push(next);
3322
+ }
3323
+ }
3324
+ return result;
3325
+ }
3326
+ function familyCountsOf(paths) {
3327
+ const counts = {};
3328
+ for (const p of paths) {
3329
+ const key = p === "/" ? "/" : `/${family(p)}`;
3330
+ counts[key] = (counts[key] ?? 0) + 1;
3331
+ }
3332
+ return counts;
3333
+ }
3334
+ async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
3335
+ const origin = new URL(deployedUrl).origin;
3336
+ const abs = (paths) => paths.map((p) => new URL(p, origin).href);
3337
+ const sitemapXml = await deps.fetchText(new URL("/sitemap.xml", origin).href);
3338
+ if (sitemapXml) {
3339
+ const urls = parseSitemapUrls(sitemapXml);
3340
+ if (urls.length > 0) {
3341
+ const paths = sampleRoutePaths(urls, cap);
3342
+ return { routes: abs(paths), source: "sitemap", familyCounts: familyCountsOf(paths) };
3343
+ }
3344
+ }
3345
+ const homeHtml = await deps.fetchText(origin);
3346
+ if (homeHtml) {
3347
+ const links = parseHtmlLinks(homeHtml, origin);
3348
+ if (links.length > 0) {
3349
+ const paths = sampleRoutePaths(links, cap);
3350
+ return { routes: abs(paths), source: "homepage-links", familyCounts: familyCountsOf(paths) };
3351
+ }
3352
+ }
3353
+ return { routes: [new URL("/", origin).href], source: "root-only", familyCounts: { "/": 1 } };
3354
+ }
3355
+
3356
+ // src/audits/browser.ts
3357
+ function isBroken(status) {
3358
+ return status === null || status >= 400;
3359
+ }
3360
+ function summarizeBrowser(routes, links, familyCounts) {
3361
+ const desktopChecks = routes.flatMap((r) => r.desktop);
3362
+ const mobileChecks = routes.flatMap((r) => r.mobile);
3363
+ const desktopOk = routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));
3364
+ const mobileOk = routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));
3365
+ const brokenLinks = links.filter((l) => isBroken(l.status)).length;
3366
+ const linksOk = links.length > 0 && brokenLinks === 0;
3367
+ const engines = [...new Set(desktopChecks.map((d) => d.engine))];
3368
+ const devices2 = [...new Set(mobileChecks.map((m) => m.device))];
3369
+ const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
3370
+ const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices2.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken`;
3371
+ return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };
3372
+ }
3373
+ async function browserAudit(ctx) {
3374
+ const { site } = ctx;
3375
+ const label = siteLabel(site);
3376
+ if (!site.deployedUrl) {
3377
+ return { audit: "browser", site: label, status: "skip", summary: "no deployed URL" };
3378
+ }
3379
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
3380
+ const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();
3381
+ const runner = ctx.browserRunner ?? await defaultBrowserRunner();
3382
+ try {
3383
+ const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
3384
+ const routeResults = await runner.probe(discovered.routes);
3385
+ const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];
3386
+ const linkResults = await runner.checkLinks(internalLinks);
3387
+ const summary = summarizeBrowser(
3388
+ routeResults,
3389
+ linkResults,
3390
+ discovered.familyCounts ?? familyCountsOf(discovered.routes)
3391
+ );
3392
+ const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
3393
+ return {
3394
+ audit: "browser",
3395
+ site: label,
3396
+ status,
3397
+ summary: summary.note,
3398
+ details: { ...summary, checkedAt: now.toISOString() }
3399
+ };
3400
+ } finally {
3401
+ await runner.close?.();
3402
+ }
3403
+ }
3404
+ function defaultDiscoverDeps() {
3405
+ return {
3406
+ fetchText: async (url) => {
3407
+ try {
3408
+ const res = await fetch(url, { redirect: "follow" });
3409
+ if (!res.ok) return null;
3410
+ return await res.text();
3411
+ } catch {
3412
+ return null;
3413
+ }
3414
+ }
3415
+ };
3416
+ }
3417
+ var DESKTOP_VIEWPORT = { width: 1366, height: 900 };
3418
+ var PAGE_TIMEOUT_MS = 3e4;
3419
+ async function defaultBrowserRunner() {
3420
+ const { chromium, firefox, webkit, devices: devices2 } = await import("@playwright/test");
3421
+ const desktopEngines = [
3422
+ { engine: "chromium", type: chromium },
3423
+ { engine: "firefox", type: firefox },
3424
+ { engine: "webkit", type: webkit }
3425
+ ];
3426
+ const mobileTargets = [
3427
+ { device: "Pixel 7", descriptor: devices2["Pixel 7"] },
3428
+ { device: "iPhone 14", descriptor: devices2["iPhone 14"] }
3429
+ ];
3430
+ return {
3431
+ async probe(urls) {
3432
+ const results = [];
3433
+ const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));
3434
+ const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));
3435
+ try {
3436
+ for (const url of urls) {
3437
+ const desktop = [];
3438
+ const linkSet = /* @__PURE__ */ new Set();
3439
+ for (let i = 0; i < desktopEngines.length; i++) {
3440
+ const engine = desktopEngines[i].engine;
3441
+ const browser = browsers[i];
3442
+ const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
3443
+ const page = await ctx.newPage();
3444
+ const errors = [];
3445
+ page.on("pageerror", (e) => errors.push(String(e)));
3446
+ let ok = false;
3447
+ try {
3448
+ const resp = await page.goto(url, {
3449
+ waitUntil: "domcontentloaded",
3450
+ timeout: PAGE_TIMEOUT_MS
3451
+ });
3452
+ const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
3453
+ ok = !!resp && resp.ok() && errors.length === 0 && hasMain;
3454
+ if (engine === "chromium") {
3455
+ const hrefs = await page.evaluate("Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)").catch(() => []);
3456
+ const origin = new URL(url).origin;
3457
+ for (const h of hrefs) {
3458
+ try {
3459
+ if (new URL(h).origin === origin) linkSet.add(new URL(h).href);
3460
+ } catch {
3461
+ }
3462
+ }
3463
+ }
3464
+ } catch {
3465
+ ok = false;
3466
+ } finally {
3467
+ await ctx.close().catch(() => {
3468
+ });
3469
+ }
3470
+ desktop.push({ engine, ok });
3471
+ }
3472
+ const mobile = [];
3473
+ for (let i = 0; i < mobileTargets.length; i++) {
3474
+ const { device, descriptor } = mobileTargets[i];
3475
+ const browser = mobileBrowsers[i];
3476
+ const ctx = await browser.newContext({ ...descriptor });
3477
+ const page = await ctx.newPage();
3478
+ const errors = [];
3479
+ page.on("pageerror", (e) => errors.push(String(e)));
3480
+ let ok = false;
3481
+ try {
3482
+ const resp = await page.goto(url, {
3483
+ waitUntil: "domcontentloaded",
3484
+ timeout: PAGE_TIMEOUT_MS
3485
+ });
3486
+ const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
3487
+ ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
3488
+ } catch {
3489
+ ok = false;
3490
+ } finally {
3491
+ await ctx.close().catch(() => {
3492
+ });
3493
+ }
3494
+ mobile.push({ device, ok });
3495
+ }
3496
+ results.push({ url, desktop, mobile, links: [...linkSet] });
3497
+ }
3498
+ } finally {
3499
+ await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {
3500
+ })));
3501
+ }
3502
+ return results;
3503
+ },
3504
+ async checkLinks(urls) {
3505
+ const out = [];
3506
+ for (const url of urls) {
3507
+ let status;
3508
+ try {
3509
+ let res = await fetch(url, { method: "HEAD", redirect: "follow" });
3510
+ if (res.status === 405 || res.status === 501) {
3511
+ res = await fetch(url, { method: "GET", redirect: "follow" });
3512
+ }
3513
+ status = res.status;
3514
+ } catch {
3515
+ status = null;
3516
+ }
3517
+ out.push({ url, status });
3518
+ }
3519
+ return out;
3520
+ }
3521
+ };
3522
+ }
3523
+
3071
3524
  // src/audits/index.ts
3072
3525
  var REGISTRY = {
3073
3526
  deps: depsAudit,
3074
3527
  lint: lintAudit,
3075
3528
  security: securityAudit,
3076
3529
  lighthouse: lighthouseAudit,
3077
- a11y: a11yAudit
3530
+ a11y: a11yAudit,
3531
+ domain: domainAudit,
3532
+ browser: browserAudit
3078
3533
  };
3079
3534
  var ALL_AUDIT_NAMES = Object.keys(REGISTRY);
3080
3535
  var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
@@ -3551,8 +4006,13 @@ function deployedUrlNotice(which, url, cwd) {
3551
4006
  if (others.length === 0) return null;
3552
4007
  return `note: --url only affects lighthouse; ${others.join(", ")} ran against the local checkout at ${cwd}`;
3553
4008
  }
4009
+ var CHECKOUT_FREE_AUDITS = /* @__PURE__ */ new Set([
4010
+ "lighthouse",
4011
+ "domain",
4012
+ "browser"
4013
+ ]);
3554
4014
  function auditNeedsCheckout(site, which) {
3555
- const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => n === "lighthouse");
4015
+ const deployedCapable = site.deployedUrl !== void 0 && which.every((n) => CHECKOUT_FREE_AUDITS.has(n));
3556
4016
  return !deployedCapable;
3557
4017
  }
3558
4018
  function applyDeployedUrl(sites, url) {
@@ -5775,10 +6235,154 @@ init_render();
5775
6235
  init_websites();
5776
6236
  init_copy();
5777
6237
  init_reports();
5778
- init_attachments();
5779
6238
  import { mkdir as mkdir4, writeFile as writeFile10 } from "fs/promises";
5780
6239
  import { dirname as dirname6 } from "path";
5781
6240
 
6241
+ // src/reports/queue.ts
6242
+ init_reports();
6243
+ var REPORT_TIER = {
6244
+ Maintenance: 1,
6245
+ Testing: 2,
6246
+ Announcement: 3,
6247
+ Launch: 3
6248
+ };
6249
+ function reportTier(type) {
6250
+ return REPORT_TIER[type];
6251
+ }
6252
+ async function queueDraft(base, report) {
6253
+ const newTier = reportTier(report.reportType);
6254
+ const others = (await listReportsForSite(base, report.siteId)).filter(isPendingApproval).filter((r) => r.id !== report.id);
6255
+ const blocker = others.find((r) => reportTier(r.reportType) >= newTier);
6256
+ if (blocker) {
6257
+ await setDraftReady(base, report.id, false);
6258
+ return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };
6259
+ }
6260
+ const supersededIds = [];
6261
+ for (const r of others) {
6262
+ await setDraftReady(base, r.id, false);
6263
+ supersededIds.push(r.id);
6264
+ }
6265
+ await setDraftReady(base, report.id, true);
6266
+ return { queued: true, supersededIds };
6267
+ }
6268
+
6269
+ // src/reports/auto-tick.ts
6270
+ init_checklist();
6271
+ init_url();
6272
+ var STALE_DAYS = 3;
6273
+ var MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
6274
+ function isFresh(checkedAt, now) {
6275
+ if (!checkedAt) return false;
6276
+ const t = new Date(checkedAt).getTime();
6277
+ if (Number.isNaN(t)) return false;
6278
+ return now.getTime() - t <= STALE_DAYS * MS_PER_DAY2;
6279
+ }
6280
+ var CERT_MIN_DAYS = 14;
6281
+ function autoTickChecklist(site, reportType, now, signals) {
6282
+ const out = /* @__PURE__ */ new Map();
6283
+ const fields = new Set(checklistFor(reportType).map((i) => i.field));
6284
+ if (fields.has("Maint: Google Indexed")) {
6285
+ const g = googleEvidence(now, signals.search);
6286
+ if (g) out.set("Maint: Google Indexed", g);
6287
+ }
6288
+ if (fields.has("Maint: Security Updates")) {
6289
+ const s = securityEvidence(site, now);
6290
+ if (s) out.set("Maint: Security Updates", s);
6291
+ }
6292
+ if (fields.has("Maint: Domain, DNS & SSL")) {
6293
+ const d = domainEvidence(site, now);
6294
+ if (d) out.set("Maint: Domain, DNS & SSL", d);
6295
+ }
6296
+ if (fields.has("Test: Desktop Browsers")) {
6297
+ const e = browserEvidence(
6298
+ site.crossbrowserOk,
6299
+ site,
6300
+ now,
6301
+ "Desktop renders cleanly",
6302
+ "render errors"
6303
+ );
6304
+ if (e) out.set("Test: Desktop Browsers", e);
6305
+ }
6306
+ if (fields.has("Test: Mobile Browsers")) {
6307
+ const e = browserEvidence(
6308
+ site.mobileOk,
6309
+ site,
6310
+ now,
6311
+ "Mobile renders cleanly",
6312
+ "overflow/errors"
6313
+ );
6314
+ if (e) out.set("Test: Mobile Browsers", e);
6315
+ }
6316
+ if (fields.has("Test: Links & Navigation")) {
6317
+ const broken = site.brokenLinks;
6318
+ const failNote = broken && broken > 0 ? `${broken} broken link(s)` : "broken links / nav";
6319
+ const e = browserEvidence(site.linksOk, site, now, "All internal links resolve", failNote);
6320
+ if (e) out.set("Test: Links & Navigation", e);
6321
+ }
6322
+ return out;
6323
+ }
6324
+ function browserEvidence(ok, site, now, passNote, failNote) {
6325
+ if (ok === null || !site.browserCheckedAt) return null;
6326
+ const at = site.browserCheckedAt;
6327
+ if (!isFresh(at, now)) {
6328
+ return { result: "unknown", checkedAt: at, note: "Browser check is stale (>3d)" };
6329
+ }
6330
+ return ok ? { result: "pass", checkedAt: at, note: passNote } : { result: "fail", checkedAt: at, note: failNote };
6331
+ }
6332
+ function securityEvidence(site, now) {
6333
+ const crit = site.securityVulnsCritical;
6334
+ const high = site.securityVulnsHigh;
6335
+ if (crit === null || high === null || !site.lastSecurityAuditAt) return null;
6336
+ const at = site.lastSecurityAuditAt;
6337
+ if (!isFresh(at, now)) {
6338
+ return { result: "unknown", checkedAt: at, note: "Security audit is stale (>3d)" };
6339
+ }
6340
+ if (crit === 0 && high === 0) {
6341
+ return { result: "pass", checkedAt: at, note: "No known critical/high vulnerabilities" };
6342
+ }
6343
+ return { result: "fail", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };
6344
+ }
6345
+ function googleEvidence(now, search) {
6346
+ const at = now.toISOString();
6347
+ if (search.softFailed) {
6348
+ return { result: "unknown", checkedAt: at, note: "Search Console unavailable this run" };
6349
+ }
6350
+ if (search.value === null) return null;
6351
+ if (search.value.foundOnPage1) {
6352
+ const pos2 = search.value.position;
6353
+ return {
6354
+ result: "pass",
6355
+ checkedAt: at,
6356
+ note: `Page 1 on Google${pos2 !== null ? ` (#${pos2})` : ""}`
6357
+ };
6358
+ }
6359
+ const pos = search.value.position;
6360
+ return {
6361
+ result: "fail",
6362
+ checkedAt: at,
6363
+ note: `Not on page 1${pos !== null ? ` (avg #${pos})` : ""}`
6364
+ };
6365
+ }
6366
+ function domainEvidence(site, now) {
6367
+ if (!site.url || isNetlifyAppUrl(site.url)) return null;
6368
+ if (!site.domainCheckedAt) return null;
6369
+ const at = site.domainCheckedAt;
6370
+ if (!isFresh(site.domainCheckedAt, now)) {
6371
+ return { result: "unknown", checkedAt: at, note: "Domain check is stale (>3d)" };
6372
+ }
6373
+ const days = site.certDaysRemaining;
6374
+ if (days === null) {
6375
+ return { result: "fail", checkedAt: at, note: "Did not resolve, or no valid TLS cert" };
6376
+ }
6377
+ if (days <= CERT_MIN_DAYS) {
6378
+ return { result: "fail", checkedAt: at, note: `TLS cert expires in ${days}d` };
6379
+ }
6380
+ return { result: "pass", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };
6381
+ }
6382
+
6383
+ // src/reports/draft.ts
6384
+ init_attachments();
6385
+
5782
6386
  // src/reports/ga/config.ts
5783
6387
  init_credentials();
5784
6388
  import { dirname as dirname5, join as join26 } from "path";
@@ -5794,7 +6398,7 @@ import { readFileSync as readFileSync3 } from "fs";
5794
6398
  import { JWT } from "google-auth-library";
5795
6399
  import { BetaAnalyticsDataClient } from "@google-analytics/data";
5796
6400
  var ANALYTICS_READONLY = "https://www.googleapis.com/auth/analytics.readonly";
5797
- var MS_PER_DAY = 864e5;
6401
+ var MS_PER_DAY3 = 864e5;
5798
6402
  function ymd2(d) {
5799
6403
  return d.toISOString().slice(0, 10);
5800
6404
  }
@@ -5807,9 +6411,9 @@ async function fetchPeriodUsers(query, periodStart, periodEnd) {
5807
6411
  subject: query.subject
5808
6412
  });
5809
6413
  const client = new BetaAnalyticsDataClient({ authClient });
5810
- const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY);
5811
- const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY);
5812
- const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY);
6414
+ const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY3);
6415
+ const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY3);
6416
+ const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY3);
5813
6417
  const property = `properties/${query.propertyId}`;
5814
6418
  const run = async (start, end) => {
5815
6419
  const [resp] = await client.runReport({
@@ -5933,7 +6537,7 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
5933
6537
  const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
5934
6538
  const periodEnd = today;
5935
6539
  const completedOn = today;
5936
- const lastTestedDate = reportType === "Maintenance" && siteRow.testingDay ? new Date(siteRow.testingDay) : null;
6540
+ const lastTestedDate = reportType === "Maintenance" && siteRow.lastLighthouseAuditAt ? new Date(siteRow.lastLighthouseAuditAt) : null;
5937
6541
  const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
5938
6542
  const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
5939
6543
  const gaUsers = gaResult.value;
@@ -5961,13 +6565,28 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
5961
6565
  const path = options.previewPath ?? `reports/${slug}/draft.html`;
5962
6566
  await mkdir4(dirname6(path), { recursive: true });
5963
6567
  await writeFile10(path, html, "utf-8");
5964
- return { reportRow: null, htmlPath: path, html, softFailures };
6568
+ return { reportRow: null, htmlPath: path, html, softFailures, queued: null, supersededIds: [] };
5965
6569
  }
5966
6570
  if (base === null) throw new Error("base required when previewOnly=false");
5967
6571
  if (options.completeRowId) {
5968
- await finishDraftRow(base, options.completeRowId, slug, periodEnd, html);
5969
- return { reportRow: options.existingRow ?? null, htmlPath: null, html, softFailures };
6572
+ await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);
6573
+ const outcome2 = await queueDraft(base, {
6574
+ id: options.completeRowId,
6575
+ siteId: siteRow.id,
6576
+ reportType
6577
+ });
6578
+ return {
6579
+ reportRow: options.existingRow ?? null,
6580
+ htmlPath: null,
6581
+ html,
6582
+ softFailures,
6583
+ queued: outcome2.queued,
6584
+ supersededIds: outcome2.supersededIds
6585
+ };
5970
6586
  }
6587
+ const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });
6588
+ const checklistTicks = [...evidence.entries()].filter(([, e]) => e.result === "pass").map(([field]) => field);
6589
+ const autoEvidence = Object.fromEntries(evidence);
5971
6590
  const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
5972
6591
  const created = await createDraft(base, {
5973
6592
  reportId,
@@ -5981,15 +6600,28 @@ async function draftReportForSite(base, siteRow, reportType, options = {}) {
5981
6600
  lastTestedDate,
5982
6601
  ...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
5983
6602
  ...search ? { searchFoundPage1: search.foundOnPage1 } : {},
5984
- ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {}
6603
+ ...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {},
6604
+ checklistTicks,
6605
+ autoEvidence
6606
+ });
6607
+ await uploadDraftHtml(created.id, slug, periodEnd, html);
6608
+ const outcome = await queueDraft(base, {
6609
+ id: created.id,
6610
+ siteId: siteRow.id,
6611
+ reportType
5985
6612
  });
5986
- await finishDraftRow(base, created.id, slug, periodEnd, html);
5987
- return { reportRow: created, htmlPath: null, html, softFailures };
6613
+ return {
6614
+ reportRow: created,
6615
+ htmlPath: null,
6616
+ html,
6617
+ softFailures,
6618
+ queued: outcome.queued,
6619
+ supersededIds: outcome.supersededIds
6620
+ };
5988
6621
  }
5989
- async function finishDraftRow(base, rowId, slug, periodEnd, html) {
6622
+ async function uploadDraftHtml(rowId, slug, periodEnd, html) {
5990
6623
  const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
5991
6624
  await uploadAttachment(rowId, "Rendered HTML", html, htmlFilename, "text/html");
5992
- await setDraftReady(base, rowId, true);
5993
6625
  }
5994
6626
  var NO_ENRICHMENT = { value: null, softFailed: false };
5995
6627
  async function fetchGaUsers(siteRow, periodStart, periodEnd) {
@@ -6039,6 +6671,14 @@ async function derivePeriodStart(base, siteRow, reportType, today) {
6039
6671
  }
6040
6672
 
6041
6673
  // src/cli/commands/report.ts
6674
+ function draftLine(reportId, queued, supersededIds, verb = "drafted") {
6675
+ const id = reportId ?? "(unknown)";
6676
+ if (queued === false) {
6677
+ return `\u2022 ${verb} but NOT queued: ${id} \u2014 a higher-or-equal-tier report is already pending approval`;
6678
+ }
6679
+ const sup = supersededIds.length > 0 ? ` (superseded ${supersededIds.length} lower-tier draft${supersededIds.length > 1 ? "s" : ""})` : "";
6680
+ return `\u2713 ${verb}: ${id}${sup}`;
6681
+ }
6042
6682
  function parseSingleSiteReportType(raw) {
6043
6683
  if (raw === void 0 || raw.trim() === "") return "Maintenance";
6044
6684
  const norm = raw.trim().toLowerCase();
@@ -6101,15 +6741,30 @@ async function draftDueReports(base, today) {
6101
6741
  lines.push(`\u2022 skipped (already drafted ${period}): ${item.site.name} ${item.reportType}`);
6102
6742
  continue;
6103
6743
  }
6744
+ const blockedByPending = reports.some(
6745
+ (r) => r.siteId === item.site.id && r.id !== existing.id && r.sentAt === null && r.draftReady && reportTier(r.reportType) >= reportTier(item.reportType)
6746
+ );
6747
+ if (blockedByPending) {
6748
+ skipped++;
6749
+ lines.push(
6750
+ `\u2022 skipped (superseded \u2014 a higher-or-equal-tier report is pending): ${item.site.name} ${item.reportType}`
6751
+ );
6752
+ continue;
6753
+ }
6104
6754
  try {
6105
6755
  const result = await draftReportForSite(base, item.site, item.reportType, {
6106
6756
  period,
6107
6757
  completeRowId: existing.id,
6108
6758
  existingRow: existing
6109
6759
  });
6110
- existing.draftReady = true;
6760
+ existing.draftReady = result.queued === true;
6111
6761
  lines.push(
6112
- `\u2713 completed half-made draft: ${result.reportRow?.reportId ?? existing.reportId}`
6762
+ draftLine(
6763
+ result.reportRow?.reportId ?? existing.reportId,
6764
+ result.queued,
6765
+ result.supersededIds,
6766
+ "completed half-made draft"
6767
+ )
6113
6768
  );
6114
6769
  if (result.softFailures.length > 0) softFailedSites++;
6115
6770
  } catch (e) {
@@ -6129,7 +6784,7 @@ async function draftDueReports(base, today) {
6129
6784
  }
6130
6785
  try {
6131
6786
  const result = await draftReportForSite(base, item.site, item.reportType, { period });
6132
- lines.push(`\u2713 drafted: ${result.reportRow?.reportId}`);
6787
+ lines.push(draftLine(result.reportRow?.reportId, result.queued, result.supersededIds));
6133
6788
  if (result.reportRow) reports.push(result.reportRow);
6134
6789
  if (result.softFailures.length > 0) softFailedSites++;
6135
6790
  } catch (e) {
@@ -6159,7 +6814,15 @@ async function runSingleSiteDraft(slug, opts) {
6159
6814
  if (opts.previewOnly) {
6160
6815
  return { output: `Preview written to ${result.htmlPath}`, code: 0 };
6161
6816
  }
6162
- return { output: `Draft created: ${result.reportRow?.reportId}`, code: 0 };
6817
+ return {
6818
+ output: draftLine(
6819
+ result.reportRow?.reportId,
6820
+ result.queued,
6821
+ result.supersededIds,
6822
+ "Draft created"
6823
+ ),
6824
+ code: 0
6825
+ };
6163
6826
  }
6164
6827
 
6165
6828
  // src/cli/commands/init.ts
@@ -6428,7 +7091,7 @@ async function launch(site, deps = {}) {
6428
7091
  `\u26A0 Launch preview upload skipped for ${target.name}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
6429
7092
  );
6430
7093
  }
6431
- await setDraftReady(base, report.id, true);
7094
+ await queueDraft(base, { id: report.id, siteId: target.id, reportType: "Launch" });
6432
7095
  } catch (err) {
6433
7096
  steps.push({ name: "draft", result: errorOf(err) });
6434
7097
  return stop();
@@ -6566,9 +7229,19 @@ async function announce(deps) {
6566
7229
  `\u26A0 Announcement preview upload skipped for ${w.name}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
6567
7230
  );
6568
7231
  }
6569
- await setDraftReady(base, report.id, true);
7232
+ const queue = await queueDraft(base, {
7233
+ id: report.id,
7234
+ siteId: w.id,
7235
+ reportType: "Announcement"
7236
+ });
6570
7237
  const recipientMissing = !(w.reportRecipientsTo && w.reportRecipientsTo.trim());
6571
- results.push({ site: w.name, status: statusKind, reportId: report.id, recipientMissing });
7238
+ results.push({
7239
+ site: w.name,
7240
+ status: statusKind,
7241
+ reportId: report.id,
7242
+ recipientMissing,
7243
+ queued: queue.queued
7244
+ });
6572
7245
  } catch (err) {
6573
7246
  results.push({
6574
7247
  site: w.name,
@@ -6760,7 +7433,9 @@ var AUDIT_DESCRIPTIONS = {
6760
7433
  lighthouse: "Run @lhci/cli autorun using the canonical lighthouserc.",
6761
7434
  a11y: "Playwright + axe against the canonical a11y routes.",
6762
7435
  security: "pnpm audit (falls back to npm audit), prod-deps by default.",
6763
- lint: "ESLint + Prettier using the canonical configs."
7436
+ lint: "ESLint + Prettier using the canonical configs.",
7437
+ domain: "DNS resolve + TLS cert expiry against the deployed URL (checkout-free).",
7438
+ browser: "Playwright across desktop engines + mobile devices + link-check against the deployed URL (checkout-free)."
6764
7439
  };
6765
7440
  var RECIPE_DESCRIPTIONS = {
6766
7441
  "sync-configs": "Overwrite a site's canonical configs to match @reddoorla/maintenance.",