@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/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"]
|
|
175
|
-
testingFreq: f["testing freq"]
|
|
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
|
|
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/
|
|
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
|
|
932
|
-
return
|
|
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
|
|
1080
|
+
return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
|
|
937
1081
|
}
|
|
938
1082
|
if (prev === 0) {
|
|
939
|
-
return cur > 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
|
|
944
|
-
if (pct < 0) return
|
|
945
|
-
return
|
|
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
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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">${
|
|
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">${
|
|
1007
|
-
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${
|
|
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 = `${
|
|
1017
|
-
const href = isHttpUrl(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 ${
|
|
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">${
|
|
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
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
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">${
|
|
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()} ${
|
|
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">${
|
|
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
|
|
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
|
-
|
|
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 = `${
|
|
1274
|
+
const previewText = `${escapeXml2(data.siteName)} is live`;
|
|
1124
1275
|
const setupRows = copy.launchSetupItems.map(
|
|
1125
1276
|
(item) => `
|
|
1126
|
-
<mj-text color="${
|
|
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">${
|
|
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="${
|
|
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="${
|
|
1153
|
-
<mj-text color="${
|
|
1154
|
-
<mj-text color="${
|
|
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="${
|
|
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="${
|
|
1164
|
-
<mj-text color="${
|
|
1165
|
-
<mj-text color="${
|
|
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
|
|
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
|
-
|
|
1179
|
-
|
|
1329
|
+
RED2 = "#C00";
|
|
1330
|
+
GREY2 = "#757575";
|
|
1180
1331
|
}
|
|
1181
1332
|
});
|
|
1182
1333
|
|
|
1183
1334
|
// src/reports/announcement-email/template.ts
|
|
1184
|
-
function
|
|
1185
|
-
return
|
|
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
|
|
1188
|
-
|
|
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
|
|
1207
|
-
|
|
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
|
-
|
|
1210
|
-
${
|
|
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
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
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
|
-
|
|
1232
|
-
${
|
|
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
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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
|
-
|
|
1261
|
-
${
|
|
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">${
|
|
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="${
|
|
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>${
|
|
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="
|
|
1424
|
+
<mj-section background-color="${introBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
|
|
1287
1425
|
<mj-column>
|
|
1288
|
-
|
|
1289
|
-
<mj-text color="${
|
|
1290
|
-
|
|
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
|
-
${
|
|
1431
|
+
${maintenanceSection}
|
|
1432
|
+
${testingSection}
|
|
1433
|
+
${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}
|
|
1434
|
+
${analytics}
|
|
1294
1435
|
${improvementsSection}
|
|
1295
|
-
<mj-section background-color="
|
|
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="${
|
|
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="${
|
|
1313
|
-
<mj-text color="${
|
|
1314
|
-
<mj-text color="${
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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 *
|
|
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,
|
|
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
|
-
|
|
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:${
|
|
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:${
|
|
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 & approve</a>` : `review & approve`;
|
|
1658
1781
|
return `
|
|
1659
1782
|
<tr>
|
|
1660
|
-
<td style="color:${
|
|
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:${
|
|
1793
|
+
return `<strong style="color:${RED4};font-family:helvetica,sans-serif">NEW</strong> `;
|
|
1671
1794
|
if (status === "worse")
|
|
1672
|
-
return `<strong style="color:${
|
|
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:${
|
|
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:${
|
|
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:${
|
|
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:${
|
|
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
|
|
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
|
-
|
|
1845
|
-
|
|
1846
|
-
ANCHOR_STYLE = `color:${
|
|
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
|
|
2007
|
-
//
|
|
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
|
|
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
|
|
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()) /
|
|
5811
|
-
const prevEnd = new Date(periodStart.getTime() -
|
|
5812
|
-
const prevStart = new Date(prevEnd.getTime() - lengthDays *
|
|
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.
|
|
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
|
|
5969
|
-
|
|
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
|
-
|
|
5987
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
|
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
|
|
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({
|
|
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.",
|