@reddoorla/maintenance 0.55.0 → 0.57.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/README.md +3 -1
- package/dist/airtable-2BJX4ATJ.js +9 -0
- package/dist/airtable-2BJX4ATJ.js.map +1 -0
- package/dist/announce-JEVCDDKL.js +171 -0
- package/dist/announce-JEVCDDKL.js.map +1 -0
- package/dist/bump-deps-O3SOLQYY.js +57 -0
- package/dist/bump-deps-O3SOLQYY.js.map +1 -0
- package/dist/chunk-2X322ZES.js +99 -0
- package/dist/chunk-2X322ZES.js.map +1 -0
- package/dist/chunk-4NAOR44V.js +153 -0
- package/dist/chunk-4NAOR44V.js.map +1 -0
- package/dist/chunk-4PY4I5OR.js +1167 -0
- package/dist/chunk-4PY4I5OR.js.map +1 -0
- package/dist/chunk-5L2QXUTU.js +279 -0
- package/dist/chunk-5L2QXUTU.js.map +1 -0
- package/dist/chunk-5WL3ET24.js +203 -0
- package/dist/chunk-5WL3ET24.js.map +1 -0
- package/dist/chunk-6LIWDIXU.js +38 -0
- package/dist/chunk-6LIWDIXU.js.map +1 -0
- package/dist/chunk-6VAL7XJT.js +52 -0
- package/dist/chunk-6VAL7XJT.js.map +1 -0
- package/dist/chunk-BWLUNY7L.js +585 -0
- package/dist/chunk-BWLUNY7L.js.map +1 -0
- package/dist/chunk-CASCWQQX.js +161 -0
- package/dist/chunk-CASCWQQX.js.map +1 -0
- package/dist/chunk-CLVTVDOU.js +274 -0
- package/dist/chunk-CLVTVDOU.js.map +1 -0
- package/dist/chunk-D3DQFC32.js +79 -0
- package/dist/chunk-D3DQFC32.js.map +1 -0
- package/dist/chunk-EYODPUHV.js +37 -0
- package/dist/chunk-EYODPUHV.js.map +1 -0
- package/dist/chunk-FDIFTIXC.js +70 -0
- package/dist/chunk-FDIFTIXC.js.map +1 -0
- package/dist/chunk-FUD4I2ZW.js +35 -0
- package/dist/chunk-FUD4I2ZW.js.map +1 -0
- package/dist/chunk-HG5DTFNF.js +62 -0
- package/dist/chunk-HG5DTFNF.js.map +1 -0
- package/dist/chunk-HN6I2XWI.js +65 -0
- package/dist/chunk-HN6I2XWI.js.map +1 -0
- package/dist/chunk-HVOOCK6L.js +84 -0
- package/dist/chunk-HVOOCK6L.js.map +1 -0
- package/dist/chunk-IMIR7SC2.js +51 -0
- package/dist/chunk-IMIR7SC2.js.map +1 -0
- package/dist/chunk-J3OPWFZE.js +64 -0
- package/dist/chunk-J3OPWFZE.js.map +1 -0
- package/dist/chunk-J4ECCFRT.js +221 -0
- package/dist/chunk-J4ECCFRT.js.map +1 -0
- package/dist/chunk-JNXCDUYB.js +211 -0
- package/dist/chunk-JNXCDUYB.js.map +1 -0
- package/dist/chunk-JQG222CI.js +49 -0
- package/dist/chunk-JQG222CI.js.map +1 -0
- package/dist/chunk-LKGVSM2O.js +43 -0
- package/dist/chunk-LKGVSM2O.js.map +1 -0
- package/dist/chunk-LYCKN7ZR.js +41 -0
- package/dist/chunk-LYCKN7ZR.js.map +1 -0
- package/dist/chunk-MKSZBODM.js +162 -0
- package/dist/chunk-MKSZBODM.js.map +1 -0
- package/dist/chunk-O65I7G7G.js +246 -0
- package/dist/chunk-O65I7G7G.js.map +1 -0
- package/dist/chunk-OCS7SZUZ.js +243 -0
- package/dist/chunk-OCS7SZUZ.js.map +1 -0
- package/dist/chunk-OGGRFBL4.js +14 -0
- package/dist/chunk-OGGRFBL4.js.map +1 -0
- package/dist/chunk-OQPMZ6BM.js +368 -0
- package/dist/chunk-OQPMZ6BM.js.map +1 -0
- package/dist/chunk-OTGB6YQO.js +52 -0
- package/dist/chunk-OTGB6YQO.js.map +1 -0
- package/dist/chunk-PZQ3WED2.js +14 -0
- package/dist/chunk-PZQ3WED2.js.map +1 -0
- package/dist/chunk-QTHKOIFJ.js +13 -0
- package/dist/chunk-QTHKOIFJ.js.map +1 -0
- package/dist/chunk-U5SZ4FOP.js +26 -0
- package/dist/chunk-U5SZ4FOP.js.map +1 -0
- package/dist/chunk-W3G7XFBZ.js +444 -0
- package/dist/chunk-W3G7XFBZ.js.map +1 -0
- package/dist/chunk-WEKL2HYJ.js +31 -0
- package/dist/chunk-WEKL2HYJ.js.map +1 -0
- package/dist/chunk-WVRPOXKH.js +74 -0
- package/dist/chunk-WVRPOXKH.js.map +1 -0
- package/dist/chunk-XKOZUVK2.js +121 -0
- package/dist/chunk-XKOZUVK2.js.map +1 -0
- package/dist/chunk-XO2RUYVM.js +138 -0
- package/dist/chunk-XO2RUYVM.js.map +1 -0
- package/dist/chunk-XTK5VIQB.js +97 -0
- package/dist/chunk-XTK5VIQB.js.map +1 -0
- package/dist/chunk-XXTZBPUY.js +9 -0
- package/dist/chunk-XXTZBPUY.js.map +1 -0
- package/dist/chunk-ZYBOT5PM.js +153 -0
- package/dist/chunk-ZYBOT5PM.js.map +1 -0
- package/dist/cli/bin.js +75 -7785
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +37 -2336
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-EPVO3FEH.js +10 -0
- package/dist/client-EPVO3FEH.js.map +1 -0
- package/dist/client-JA6QLEDW.js +48 -0
- package/dist/client-JA6QLEDW.js.map +1 -0
- package/dist/configs/lighthouse.js +4 -31
- package/dist/configs/lighthouse.js.map +1 -1
- package/dist/configs/playwright-a11y.js +6 -33
- package/dist/configs/playwright-a11y.js.map +1 -1
- package/dist/convert-to-pnpm-UR64X3OX.js +51 -0
- package/dist/convert-to-pnpm-UR64X3OX.js.map +1 -0
- package/dist/db-AXKMRTVA.js +23 -0
- package/dist/db-AXKMRTVA.js.map +1 -0
- package/dist/digest-YTO73BEO.js +408 -0
- package/dist/digest-YTO73BEO.js.map +1 -0
- package/dist/forms/index.js +3 -8
- package/dist/forms/index.js.map +1 -1
- package/dist/github-signals-XLQXXH6Q.js +116 -0
- package/dist/github-signals-XLQXXH6Q.js.map +1 -0
- package/dist/index.js +102 -4757
- package/dist/index.js.map +1 -1
- package/dist/init-JITQXFVL.js +85 -0
- package/dist/init-JITQXFVL.js.map +1 -0
- package/dist/launch-XW6PEKUL.js +207 -0
- package/dist/launch-XW6PEKUL.js.map +1 -0
- package/dist/lighthouse-airtable-4FKYKFH6.js +12 -0
- package/dist/lighthouse-airtable-4FKYKFH6.js.map +1 -0
- package/dist/migrate-P2LS4WXI.js +7 -0
- package/dist/migrate-P2LS4WXI.js.map +1 -0
- package/dist/onboard-TCQOP2W4.js +69 -0
- package/dist/onboard-TCQOP2W4.js.map +1 -0
- package/dist/orchestrate-LHH3LQNP.js +23 -0
- package/dist/orchestrate-LHH3LQNP.js.map +1 -0
- package/dist/recipes/sync-configs.js +9 -505
- package/dist/recipes/sync-configs.js.map +1 -1
- package/dist/renovate-dispatch-YVBTBJJY.js +100 -0
- package/dist/renovate-dispatch-YVBTBJJY.js.map +1 -0
- package/dist/report-WK3SKWJI.js +240 -0
- package/dist/report-WK3SKWJI.js.map +1 -0
- package/dist/resend-G2BJMAQV.js +7 -0
- package/dist/resend-G2BJMAQV.js.map +1 -0
- package/dist/self-updating-BYUBUNO4.js +61 -0
- package/dist/self-updating-BYUBUNO4.js.map +1 -0
- package/dist/svelte-codemods-BYRJAU7I.js +51 -0
- package/dist/svelte-codemods-BYRJAU7I.js.map +1 -0
- package/dist/sync-configs-JPRLAW5J.js +109 -0
- package/dist/sync-configs-JPRLAW5J.js.map +1 -0
- package/dist/upgrade-ERA45EVR.js +64 -0
- package/dist/upgrade-ERA45EVR.js.map +1 -0
- package/dist/util/git.js +19 -76
- package/dist/util/git.js.map +1 -1
- package/dist/util/pkg.js +5 -45
- package/dist/util/pkg.js.map +1 -1
- package/dist/websites-X5DLC2PC.js +43 -0
- package/dist/websites-X5DLC2PC.js.map +1 -0
- package/dist/write-audits-to-airtable-CE4POBAF.js +13 -0
- package/dist/write-audits-to-airtable-CE4POBAF.js.map +1 -0
- package/package.json +13 -12
package/dist/index.js
CHANGED
|
@@ -1,4758 +1,103 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
import
|
|
83
|
-
import
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
function siteLabel(site) {
|
|
87
|
-
return site.name || site.path;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// src/configs/baseline-versions.ts
|
|
91
|
-
var baselineVersions = {
|
|
92
|
-
// SvelteKit core
|
|
93
|
-
svelte: "^5.55.10",
|
|
94
|
-
"@sveltejs/kit": "^2.61.1",
|
|
95
|
-
"@sveltejs/adapter-netlify": "^6.0.4",
|
|
96
|
-
"@sveltejs/adapter-auto": "^7.0.1",
|
|
97
|
-
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
|
98
|
-
"svelte-check": "^4.4.8",
|
|
99
|
-
// Build tooling
|
|
100
|
-
vite: "^8.0.14",
|
|
101
|
-
vitest: "^4.1.7",
|
|
102
|
-
typescript: "^6.0.3",
|
|
103
|
-
// Tailwind 4
|
|
104
|
-
tailwindcss: "^4.3.0",
|
|
105
|
-
"@tailwindcss/vite": "^4.3.0",
|
|
106
|
-
// Prismic
|
|
107
|
-
"@prismicio/client": "^7.21.8",
|
|
108
|
-
"@prismicio/svelte": "^2.2.1",
|
|
109
|
-
"@slicemachine/adapter-sveltekit": "^0.3.96",
|
|
110
|
-
"slice-machine-ui": "^2.21.3",
|
|
111
|
-
// Test tooling
|
|
112
|
-
"@playwright/test": "^1.60.0",
|
|
113
|
-
"@axe-core/playwright": "^4.11.3",
|
|
114
|
-
"@lhci/cli": "^0.15.1",
|
|
115
|
-
// Lint
|
|
116
|
-
eslint: "^10.4.0",
|
|
117
|
-
"eslint-plugin-svelte": "^3.18.0",
|
|
118
|
-
"eslint-config-prettier": "^10.1.8",
|
|
119
|
-
prettier: "^3.8.3",
|
|
120
|
-
"prettier-plugin-svelte": "^4.0.1",
|
|
121
|
-
"typescript-eslint": "^8.60.0",
|
|
122
|
-
"@eslint/js": "^10.0.1",
|
|
123
|
-
globals: "^17.6.0",
|
|
124
|
-
// Misc
|
|
125
|
-
"@lucide/svelte": "^1.17.0",
|
|
126
|
-
"@zerodevx/svelte-img": "^2.1.2"
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
// src/audits/deps-outdated.ts
|
|
130
|
-
import { stat } from "fs/promises";
|
|
131
|
-
import { join } from "path";
|
|
132
|
-
async function exists(path) {
|
|
133
|
-
try {
|
|
134
|
-
await stat(path);
|
|
135
|
-
return true;
|
|
136
|
-
} catch {
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
function majorOf(version) {
|
|
141
|
-
const head = version.replace(/^[\^~]/, "").split(".")[0] ?? "0";
|
|
142
|
-
const n = Number.parseInt(head, 10);
|
|
143
|
-
return Number.isNaN(n) ? 0 : n;
|
|
144
|
-
}
|
|
145
|
-
async function scanOutdated(sitePath, spawn2) {
|
|
146
|
-
if (!await exists(join(sitePath, "pnpm-lock.yaml"))) return null;
|
|
147
|
-
try {
|
|
148
|
-
if (!await exists(join(sitePath, "node_modules"))) {
|
|
149
|
-
const install = await spawn2("pnpm", ["install", "--frozen-lockfile"], {
|
|
150
|
-
cwd: sitePath,
|
|
151
|
-
timeoutMs: 18e4
|
|
152
|
-
});
|
|
153
|
-
if (install.code !== 0) return null;
|
|
154
|
-
}
|
|
155
|
-
const res = await spawn2("pnpm", ["outdated", "--json"], {
|
|
156
|
-
cwd: sitePath,
|
|
157
|
-
timeoutMs: 6e4
|
|
158
|
-
});
|
|
159
|
-
const parsed = JSON.parse(res.stdout || "{}");
|
|
160
|
-
const entries = Object.values(parsed);
|
|
161
|
-
return {
|
|
162
|
-
outdated: entries.length,
|
|
163
|
-
major: entries.filter((e) => e.current && e.latest && majorOf(e.latest) > majorOf(e.current)).length
|
|
164
|
-
};
|
|
165
|
-
} catch {
|
|
166
|
-
return null;
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// src/audits/deps.ts
|
|
171
|
-
function stripCaret(range) {
|
|
172
|
-
return range.replace(/^[\^~]/, "");
|
|
173
|
-
}
|
|
174
|
-
function isComparableRange(spec) {
|
|
175
|
-
return /^[\^~]?\d/.test(spec.trim());
|
|
176
|
-
}
|
|
177
|
-
function parseSemver(v) {
|
|
178
|
-
const cleaned = stripCaret(v).split("-")[0] ?? "0.0.0";
|
|
179
|
-
const parts = cleaned.split(".").map((n) => Number.parseInt(n, 10));
|
|
180
|
-
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
181
|
-
}
|
|
182
|
-
function compareSemver(actual, baseline) {
|
|
183
|
-
const [aMajor, aMinor, aPatch] = parseSemver(actual);
|
|
184
|
-
const [bMajor, bMinor, bPatch] = parseSemver(baseline);
|
|
185
|
-
if (aMajor > bMajor) return "newer";
|
|
186
|
-
if (aMajor < bMajor) return "major";
|
|
187
|
-
if (aMinor > bMinor) return "newer";
|
|
188
|
-
if (aMinor < bMinor) return "minor";
|
|
189
|
-
if (aPatch > bPatch) return "newer";
|
|
190
|
-
if (aPatch < bPatch) return "patch";
|
|
191
|
-
return "same";
|
|
192
|
-
}
|
|
193
|
-
async function depsAudit(ctx) {
|
|
194
|
-
const pkgPath = join2(ctx.site.path, "package.json");
|
|
195
|
-
let pkgRaw;
|
|
196
|
-
try {
|
|
197
|
-
pkgRaw = await readFile(pkgPath, "utf-8");
|
|
198
|
-
} catch (err) {
|
|
199
|
-
return {
|
|
200
|
-
audit: "deps",
|
|
201
|
-
site: siteLabel(ctx.site),
|
|
202
|
-
status: "skip",
|
|
203
|
-
summary: `no package.json at ${pkgPath}`,
|
|
204
|
-
details: { error: String(err) }
|
|
205
|
-
};
|
|
206
|
-
}
|
|
207
|
-
let pkg;
|
|
208
|
-
try {
|
|
209
|
-
pkg = JSON.parse(pkgRaw);
|
|
210
|
-
} catch (err) {
|
|
211
|
-
return {
|
|
212
|
-
audit: "deps",
|
|
213
|
-
site: siteLabel(ctx.site),
|
|
214
|
-
status: "fail",
|
|
215
|
-
summary: `package.json is not valid JSON: ${err.message}`,
|
|
216
|
-
details: { error: String(err) }
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
const installed = {
|
|
220
|
-
...pkg.dependencies ?? {},
|
|
221
|
-
...pkg.devDependencies ?? {}
|
|
222
|
-
};
|
|
223
|
-
const entries = [];
|
|
224
|
-
for (const [name, baseline] of Object.entries(baselineVersions)) {
|
|
225
|
-
const actual = installed[name];
|
|
226
|
-
if (!actual) continue;
|
|
227
|
-
if (!isComparableRange(actual)) continue;
|
|
228
|
-
entries.push({
|
|
229
|
-
pkg: name,
|
|
230
|
-
baseline,
|
|
231
|
-
actual,
|
|
232
|
-
drift: compareSemver(actual, baseline)
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
const anyMajor = entries.some((d) => d.drift === "major");
|
|
236
|
-
const anyMinor = entries.some((d) => d.drift === "minor");
|
|
237
|
-
const anyNewer = entries.some((d) => d.drift === "newer");
|
|
238
|
-
const status = anyMajor ? "fail" : anyMinor || anyNewer ? "warn" : "pass";
|
|
239
|
-
const driftSummary = status === "pass" ? `all ${entries.length} tracked deps in line with baseline` : status === "warn" ? `${entries.filter((d) => d.drift !== "same").length} of ${entries.length} tracked deps drifted` : `${entries.filter((d) => d.drift === "major").length} deps lagging by a major version`;
|
|
240
|
-
const outdated = await scanOutdated(ctx.site.path, ctx.spawn ?? defaultSpawn);
|
|
241
|
-
const summary = outdated ? `${driftSummary}; ${outdated.outdated} outdated install(s) (${outdated.major} major)` : driftSummary;
|
|
242
|
-
return {
|
|
243
|
-
audit: "deps",
|
|
244
|
-
site: siteLabel(ctx.site),
|
|
245
|
-
status,
|
|
246
|
-
summary,
|
|
247
|
-
details: { entries, outdated }
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// src/audits/lint.ts
|
|
252
|
-
import { existsSync } from "fs";
|
|
253
|
-
import { readFile as readFile2 } from "fs/promises";
|
|
254
|
-
import { join as join3 } from "path";
|
|
255
|
-
import { ESLint } from "eslint";
|
|
256
|
-
import { check as prettierCheck, resolveConfig as prettierResolveConfig } from "prettier";
|
|
257
|
-
import { glob } from "tinyglobby";
|
|
258
|
-
var TARGET_GLOBS = ["**/*.{ts,js,svelte}"];
|
|
259
|
-
var IGNORE = ["node_modules/**", "dist/**", ".svelte-kit/**", "build/**", ".netlify/**"];
|
|
260
|
-
async function listFiles(cwd) {
|
|
261
|
-
return glob(TARGET_GLOBS, { cwd, ignore: IGNORE, absolute: false });
|
|
262
|
-
}
|
|
263
|
-
async function lintAudit(ctx) {
|
|
264
|
-
const { site } = ctx;
|
|
265
|
-
const configPath = join3(site.path, "eslint.config.js");
|
|
266
|
-
if (!existsSync(configPath)) {
|
|
267
|
-
return {
|
|
268
|
-
audit: "lint",
|
|
269
|
-
site: siteLabel(site),
|
|
270
|
-
status: "skip",
|
|
271
|
-
summary: "no eslint config at site root"
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
const eslint2 = new ESLint({
|
|
275
|
-
cwd: site.path,
|
|
276
|
-
overrideConfigFile: configPath,
|
|
277
|
-
errorOnUnmatchedPattern: false
|
|
278
|
-
});
|
|
279
|
-
const relFiles = await listFiles(site.path);
|
|
280
|
-
const eslintResults = await eslint2.lintFiles(relFiles);
|
|
281
|
-
const eslintErrors = eslintResults.reduce((n, r) => n + r.errorCount, 0);
|
|
282
|
-
const eslintWarnings = eslintResults.reduce((n, r) => n + r.warningCount, 0);
|
|
283
|
-
const prettierUnformatted = [];
|
|
284
|
-
for (const rel of relFiles) {
|
|
285
|
-
const absForResolve = join3(site.path, rel);
|
|
286
|
-
const source = await readFile2(absForResolve, "utf-8");
|
|
287
|
-
const options = await prettierResolveConfig(absForResolve) ?? {};
|
|
288
|
-
const ok = await prettierCheck(source, { ...options, filepath: absForResolve });
|
|
289
|
-
if (!ok) prettierUnformatted.push(rel);
|
|
290
|
-
}
|
|
291
|
-
const status = eslintErrors > 0 || prettierUnformatted.length > 0 ? "fail" : eslintWarnings > 0 ? "warn" : "pass";
|
|
292
|
-
const summary = status === "pass" ? `lint clean across ${relFiles.length} files` : `${eslintErrors} eslint errors, ${eslintWarnings} warnings, ${prettierUnformatted.length} unformatted`;
|
|
293
|
-
return {
|
|
294
|
-
audit: "lint",
|
|
295
|
-
site: siteLabel(site),
|
|
296
|
-
status,
|
|
297
|
-
summary,
|
|
298
|
-
details: {
|
|
299
|
-
eslintErrors,
|
|
300
|
-
eslintWarnings,
|
|
301
|
-
prettierUnformatted,
|
|
302
|
-
files: relFiles.length
|
|
303
|
-
}
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// src/audits/security.ts
|
|
308
|
-
function classify(v) {
|
|
309
|
-
if (v.critical > 0 || v.high > 0) return "fail";
|
|
310
|
-
if (v.moderate > 0 || v.low > 0) return "warn";
|
|
311
|
-
return "pass";
|
|
312
|
-
}
|
|
313
|
-
function normalizeSeverity(s) {
|
|
314
|
-
if (s === "low" || s === "moderate" || s === "high" || s === "critical") return s;
|
|
315
|
-
return "low";
|
|
316
|
-
}
|
|
317
|
-
function extractAdvisoriesFromPnpm(parsed) {
|
|
318
|
-
const out = [];
|
|
319
|
-
for (const a of Object.values(parsed.advisories ?? {})) {
|
|
320
|
-
if (!a) continue;
|
|
321
|
-
out.push({
|
|
322
|
-
module: a.module_name ?? "unknown",
|
|
323
|
-
severity: normalizeSeverity(a.severity),
|
|
324
|
-
title: a.title ?? "(no title)",
|
|
325
|
-
...a.cves ? { cves: a.cves } : {},
|
|
326
|
-
...a.url ? { url: a.url } : {}
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
return out;
|
|
330
|
-
}
|
|
331
|
-
function resolveNpmAdvisoryRoot(startName, vulnerabilities) {
|
|
332
|
-
const seen = /* @__PURE__ */ new Set();
|
|
333
|
-
let current = startName;
|
|
334
|
-
while (!seen.has(current)) {
|
|
335
|
-
seen.add(current);
|
|
336
|
-
const entry = vulnerabilities[current];
|
|
337
|
-
if (!entry || !Array.isArray(entry.via)) return { rootName: current };
|
|
338
|
-
const detailed = entry.via.find(
|
|
339
|
-
(e) => typeof e === "object" && e !== null
|
|
340
|
-
);
|
|
341
|
-
if (detailed) return { rootName: current, detail: detailed };
|
|
342
|
-
const next = entry.via.find((e) => typeof e === "string");
|
|
343
|
-
if (!next || next === current) return { rootName: current };
|
|
344
|
-
current = next;
|
|
345
|
-
}
|
|
346
|
-
return { rootName: current };
|
|
347
|
-
}
|
|
348
|
-
function extractAdvisoriesFromNpm(parsed) {
|
|
349
|
-
const vulnerabilities = parsed.vulnerabilities ?? {};
|
|
350
|
-
const roots = /* @__PURE__ */ new Map();
|
|
351
|
-
for (const [name, v] of Object.entries(vulnerabilities)) {
|
|
352
|
-
if (!v) continue;
|
|
353
|
-
const { rootName, detail } = resolveNpmAdvisoryRoot(name, vulnerabilities);
|
|
354
|
-
if (roots.has(rootName)) continue;
|
|
355
|
-
const rootEntry = vulnerabilities[rootName];
|
|
356
|
-
const severity = normalizeSeverity(rootEntry?.severity ?? v.severity);
|
|
357
|
-
const title = detail?.title ?? rootName;
|
|
358
|
-
const url = detail?.url;
|
|
359
|
-
roots.set(rootName, {
|
|
360
|
-
module: rootEntry?.name ?? rootName,
|
|
361
|
-
severity,
|
|
362
|
-
title,
|
|
363
|
-
...url ? { url } : {}
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
return [...roots.values()];
|
|
367
|
-
}
|
|
368
|
-
async function runAuditTool(spawn2, cmd, args, cwd) {
|
|
369
|
-
let raw;
|
|
370
|
-
try {
|
|
371
|
-
raw = await spawn2(cmd, args, { cwd });
|
|
372
|
-
} catch (err) {
|
|
373
|
-
const e = err;
|
|
374
|
-
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) return { kind: "missing" };
|
|
375
|
-
return { kind: "error", reason: `spawn failed: ${String(err).slice(0, 200)}` };
|
|
376
|
-
}
|
|
377
|
-
if (raw.code !== 0 && raw.code !== 1) {
|
|
378
|
-
return {
|
|
379
|
-
kind: "error",
|
|
380
|
-
reason: `exit ${raw.code}${raw.stderr ? `: ${raw.stderr.slice(0, 150)}` : ""}`
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
let parsed;
|
|
384
|
-
try {
|
|
385
|
-
parsed = JSON.parse(raw.stdout || "{}");
|
|
386
|
-
} catch (err) {
|
|
387
|
-
return { kind: "error", reason: `unparseable JSON: ${String(err).slice(0, 100)}` };
|
|
388
|
-
}
|
|
389
|
-
const errEnvelope = parsed.error;
|
|
390
|
-
if (errEnvelope && typeof errEnvelope === "object") {
|
|
391
|
-
return { kind: "error", reason: errEnvelope.code ?? "error envelope returned" };
|
|
392
|
-
}
|
|
393
|
-
const vulnsMeta = parsed.metadata?.vulnerabilities;
|
|
394
|
-
if (!vulnsMeta || Object.keys(vulnsMeta).length === 0) {
|
|
395
|
-
return { kind: "error", reason: "no metadata.vulnerabilities in output" };
|
|
396
|
-
}
|
|
397
|
-
return { kind: "ok", parsed };
|
|
398
|
-
}
|
|
399
|
-
async function securityAudit(ctx) {
|
|
400
|
-
const spawn2 = ctx.spawn ?? defaultSpawn;
|
|
401
|
-
const site = ctx.site;
|
|
402
|
-
const label = siteLabel(site);
|
|
403
|
-
let used = "pnpm audit";
|
|
404
|
-
let result = await runAuditTool(spawn2, "pnpm", ["audit", "--json", "--prod"], site.path);
|
|
405
|
-
if (result.kind !== "ok") {
|
|
406
|
-
const pnpmReason = result.kind === "missing" ? "not installed" : result.reason;
|
|
407
|
-
const npmResult = await runAuditTool(
|
|
408
|
-
spawn2,
|
|
409
|
-
"npm",
|
|
410
|
-
["audit", "--json", "--omit=dev"],
|
|
411
|
-
site.path
|
|
412
|
-
);
|
|
413
|
-
if (npmResult.kind === "ok") {
|
|
414
|
-
result = npmResult;
|
|
415
|
-
used = "npm audit";
|
|
416
|
-
} else {
|
|
417
|
-
const npmReason = npmResult.kind === "missing" ? "not installed" : npmResult.reason;
|
|
418
|
-
return {
|
|
419
|
-
audit: "security",
|
|
420
|
-
site: label,
|
|
421
|
-
status: "skip",
|
|
422
|
-
summary: `cannot run audit \u2014 pnpm: ${pnpmReason}; npm: ${npmReason}`
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
const parsed = result.parsed;
|
|
427
|
-
const counts = {
|
|
428
|
-
low: parsed.metadata?.vulnerabilities?.low ?? 0,
|
|
429
|
-
moderate: parsed.metadata?.vulnerabilities?.moderate ?? 0,
|
|
430
|
-
high: parsed.metadata?.vulnerabilities?.high ?? 0,
|
|
431
|
-
critical: parsed.metadata?.vulnerabilities?.critical ?? 0
|
|
432
|
-
};
|
|
433
|
-
const advisories = used === "pnpm audit" ? extractAdvisoriesFromPnpm(parsed) : extractAdvisoriesFromNpm(parsed);
|
|
434
|
-
const status = classify(counts);
|
|
435
|
-
const total = counts.low + counts.moderate + counts.high + counts.critical;
|
|
436
|
-
const summary = status === "pass" ? `${used}: 0 vulnerabilities` : `${used}: ${total} vulnerabilities (${counts.critical}C/${counts.high}H/${counts.moderate}M/${counts.low}L)`;
|
|
437
|
-
return {
|
|
438
|
-
audit: "security",
|
|
439
|
-
site: label,
|
|
440
|
-
status,
|
|
441
|
-
summary,
|
|
442
|
-
details: { counts, advisories }
|
|
443
|
-
};
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// src/audits/lighthouse.ts
|
|
447
|
-
import { readFile as readFile4, writeFile, mkdtemp, rm, readdir } from "fs/promises";
|
|
448
|
-
import { tmpdir } from "os";
|
|
449
|
-
import { join as join5 } from "path";
|
|
450
|
-
|
|
451
|
-
// src/configs/lighthouse.ts
|
|
452
|
-
var lighthouseConfig = {
|
|
453
|
-
ci: {
|
|
454
|
-
collect: {
|
|
455
|
-
url: ["http://localhost:5173/dev/a11y-fixtures"],
|
|
456
|
-
// `npm run vite:dev` works on both pnpm and npm sites — pnpm respects
|
|
457
|
-
// the `run` form too. Keeps this config portable across the fleet
|
|
458
|
-
// while sites transition to pnpm.
|
|
459
|
-
startServerCommand: "npm run vite:dev",
|
|
460
|
-
startServerReadyPattern: "ready in",
|
|
461
|
-
startServerReadyTimeout: 12e4,
|
|
462
|
-
numberOfRuns: 1,
|
|
463
|
-
settings: {
|
|
464
|
-
preset: "desktop",
|
|
465
|
-
skipAudits: ["uses-http2"]
|
|
466
|
-
}
|
|
467
|
-
},
|
|
468
|
-
assert: {
|
|
469
|
-
assertions: {
|
|
470
|
-
"categories:accessibility": ["error", { minScore: 0.95 }],
|
|
471
|
-
"categories:best-practices": ["error", { minScore: 0.9 }],
|
|
472
|
-
"categories:seo": ["error", { minScore: 0.9 }],
|
|
473
|
-
"categories:performance": ["warn", { minScore: 0.7 }]
|
|
474
|
-
}
|
|
475
|
-
},
|
|
476
|
-
upload: {
|
|
477
|
-
target: "temporary-public-storage"
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
};
|
|
481
|
-
|
|
482
|
-
// src/audits/util/site-config.ts
|
|
483
|
-
import { readFile as readFile3 } from "fs/promises";
|
|
484
|
-
import { join as join4 } from "path";
|
|
485
|
-
async function readSiteConfig(sitePath) {
|
|
486
|
-
let raw;
|
|
487
|
-
try {
|
|
488
|
-
raw = await readFile3(join4(sitePath, "package.json"), "utf-8");
|
|
489
|
-
} catch {
|
|
490
|
-
return {};
|
|
491
|
-
}
|
|
492
|
-
let pkg;
|
|
493
|
-
try {
|
|
494
|
-
pkg = JSON.parse(raw);
|
|
495
|
-
} catch {
|
|
496
|
-
return {};
|
|
497
|
-
}
|
|
498
|
-
if (!pkg || typeof pkg !== "object") return {};
|
|
499
|
-
const cfg = pkg.reddoor;
|
|
500
|
-
if (!cfg || typeof cfg !== "object") return {};
|
|
501
|
-
const out = {};
|
|
502
|
-
const url = cfg.lighthouseUrl;
|
|
503
|
-
if (typeof url === "string" && url.length > 0) {
|
|
504
|
-
out.lighthouseUrl = url;
|
|
505
|
-
}
|
|
506
|
-
return out;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// src/util/free-port.ts
|
|
510
|
-
import { createServer } from "net";
|
|
511
|
-
async function findFreePort() {
|
|
512
|
-
return new Promise((resolve, reject) => {
|
|
513
|
-
const server = createServer();
|
|
514
|
-
server.unref();
|
|
515
|
-
server.on("error", reject);
|
|
516
|
-
server.listen(0, "127.0.0.1", () => {
|
|
517
|
-
const addr = server.address();
|
|
518
|
-
if (typeof addr === "object" && addr) {
|
|
519
|
-
const port = addr.port;
|
|
520
|
-
server.close(() => resolve(port));
|
|
521
|
-
} else {
|
|
522
|
-
server.close();
|
|
523
|
-
reject(new Error("findFreePort: could not determine assigned port from socket"));
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
function withFreePort(url, port) {
|
|
529
|
-
const u = new URL(url);
|
|
530
|
-
u.hostname = "localhost";
|
|
531
|
-
u.port = String(port);
|
|
532
|
-
return u.toString();
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// src/audits/lighthouse.ts
|
|
536
|
-
async function readJsonMaybe(path) {
|
|
537
|
-
try {
|
|
538
|
-
const raw = await readFile4(path, "utf-8");
|
|
539
|
-
return JSON.parse(raw);
|
|
540
|
-
} catch {
|
|
541
|
-
return null;
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
async function readLhrEntries(resultsDir) {
|
|
545
|
-
const files = await readdir(resultsDir).catch(() => []);
|
|
546
|
-
const entries = [];
|
|
547
|
-
for (const f of files) {
|
|
548
|
-
if (!f.startsWith("lhr-") || !f.endsWith(".json")) continue;
|
|
549
|
-
const lhr = await readJsonMaybe(join5(resultsDir, f));
|
|
550
|
-
if (!lhr || !lhr.categories) continue;
|
|
551
|
-
const summary = {};
|
|
552
|
-
for (const [k, v] of Object.entries(lhr.categories)) {
|
|
553
|
-
if (typeof v?.score === "number") summary[k] = v.score;
|
|
554
|
-
}
|
|
555
|
-
entries.push({ url: lhr.requestedUrl, summary });
|
|
556
|
-
}
|
|
557
|
-
return entries;
|
|
558
|
-
}
|
|
559
|
-
function averageSummaries(entries) {
|
|
560
|
-
if (entries.length === 0) return {};
|
|
561
|
-
const sums = {};
|
|
562
|
-
const counts = {};
|
|
563
|
-
for (const e of entries) {
|
|
564
|
-
for (const [k, v] of Object.entries(e.summary ?? {})) {
|
|
565
|
-
if (typeof v !== "number") continue;
|
|
566
|
-
sums[k] = (sums[k] ?? 0) + v;
|
|
567
|
-
counts[k] = (counts[k] ?? 0) + 1;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
const out = {};
|
|
571
|
-
for (const k of Object.keys(sums)) {
|
|
572
|
-
const total = sums[k] ?? 0;
|
|
573
|
-
const count = counts[k] ?? 1;
|
|
574
|
-
out[k] = total / count;
|
|
575
|
-
}
|
|
576
|
-
return out;
|
|
577
|
-
}
|
|
578
|
-
function categoryFromAssertion(a) {
|
|
579
|
-
const colonIdx = a.name.indexOf(":");
|
|
580
|
-
return colonIdx >= 0 ? a.name.slice(colonIdx + 1) : a.name;
|
|
581
|
-
}
|
|
582
|
-
function messageForAssertion(a) {
|
|
583
|
-
const actual = typeof a.actual === "number" ? a.actual.toFixed(2) : "n/a";
|
|
584
|
-
return `${a.name} ${a.operator} ${a.expected} (actual: ${actual})`;
|
|
585
|
-
}
|
|
586
|
-
async function parseLhciResults(resultsDir, label, raw) {
|
|
587
|
-
const manifest = await readLhrEntries(resultsDir);
|
|
588
|
-
if (manifest.length === 0) {
|
|
589
|
-
return {
|
|
590
|
-
audit: "lighthouse",
|
|
591
|
-
site: label,
|
|
592
|
-
status: "fail",
|
|
593
|
-
summary: `lighthouse: no lhr-*.json written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
|
|
594
|
-
};
|
|
595
|
-
}
|
|
596
|
-
const assertionResults = await readJsonMaybe(join5(resultsDir, "assertion-results.json")) ?? [];
|
|
597
|
-
const failed = assertionResults.filter((a) => !a.passed);
|
|
598
|
-
const assertions = failed.map((a) => ({
|
|
599
|
-
category: categoryFromAssertion(a),
|
|
600
|
-
level: a.level,
|
|
601
|
-
message: messageForAssertion(a)
|
|
602
|
-
}));
|
|
603
|
-
const anyError = assertions.some((a) => a.level === "error");
|
|
604
|
-
const anyWarn = assertions.some((a) => a.level === "warn");
|
|
605
|
-
const status = anyError ? "fail" : anyWarn ? "warn" : "pass";
|
|
606
|
-
const normalized = {
|
|
607
|
-
summary: averageSummaries(manifest),
|
|
608
|
-
assertionsFailed: failed.length,
|
|
609
|
-
assertions
|
|
610
|
-
};
|
|
611
|
-
const summary = status === "pass" ? "lighthouse: all categories passing" : `lighthouse: ${failed.length} assertion(s) failed`;
|
|
612
|
-
return { audit: "lighthouse", site: label, status, summary, details: normalized };
|
|
613
|
-
}
|
|
614
|
-
async function checkoutLighthouse(spawn2, site, label) {
|
|
615
|
-
const siteCfg = await readSiteConfig(site.path);
|
|
616
|
-
const port = await findFreePort();
|
|
617
|
-
const baseUrl = siteCfg.lighthouseUrl ?? lighthouseConfig.ci.collect.url[0];
|
|
618
|
-
const resolvedConfig = {
|
|
619
|
-
...lighthouseConfig,
|
|
620
|
-
ci: {
|
|
621
|
-
...lighthouseConfig.ci,
|
|
622
|
-
collect: {
|
|
623
|
-
...lighthouseConfig.ci.collect,
|
|
624
|
-
url: [withFreePort(baseUrl, port)],
|
|
625
|
-
startServerCommand: `npm run vite:dev -- --port ${port} --strictPort`
|
|
626
|
-
}
|
|
627
|
-
}
|
|
628
|
-
};
|
|
629
|
-
const configDir = await mkdtemp(join5(tmpdir(), "reddoor-lhci-"));
|
|
630
|
-
const configPath = join5(configDir, "lighthouserc.json");
|
|
631
|
-
await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
|
|
632
|
-
const resultsDir = join5(site.path, ".lighthouseci");
|
|
633
|
-
await rm(resultsDir, { recursive: true, force: true });
|
|
634
|
-
let raw;
|
|
635
|
-
try {
|
|
636
|
-
raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
|
|
637
|
-
cwd: site.path,
|
|
638
|
-
timeoutMs: 5 * 6e4
|
|
639
|
-
});
|
|
640
|
-
} catch (err) {
|
|
641
|
-
await rm(configDir, { recursive: true, force: true });
|
|
642
|
-
const e = err;
|
|
643
|
-
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
|
|
644
|
-
return {
|
|
645
|
-
audit: "lighthouse",
|
|
646
|
-
site: label,
|
|
647
|
-
status: "skip",
|
|
648
|
-
summary: "npx/@lhci/cli not available"
|
|
649
|
-
};
|
|
650
|
-
}
|
|
651
|
-
throw err;
|
|
652
|
-
}
|
|
653
|
-
await rm(configDir, { recursive: true, force: true });
|
|
654
|
-
return parseLhciResults(resultsDir, label, raw);
|
|
655
|
-
}
|
|
656
|
-
async function deployedLighthouse(spawn2, deployedUrl, label) {
|
|
657
|
-
const workDir = await mkdtemp(join5(tmpdir(), "reddoor-lh-deployed-"));
|
|
658
|
-
const resolvedConfig = {
|
|
659
|
-
ci: {
|
|
660
|
-
// Deliberately NOT spread from lighthouseConfig.ci.collect: deployed mode
|
|
661
|
-
// must omit startServerCommand and the dev-server settings entirely.
|
|
662
|
-
collect: {
|
|
663
|
-
url: [deployedUrl],
|
|
664
|
-
// 3 runs to damp Lighthouse's run-to-run variance; parseLhciResults
|
|
665
|
-
// averages the lhr files. (Median is a tracked future refinement.)
|
|
666
|
-
numberOfRuns: 3,
|
|
667
|
-
settings: { preset: "desktop", skipAudits: ["uses-http2"] }
|
|
668
|
-
},
|
|
669
|
-
assert: lighthouseConfig.ci.assert,
|
|
670
|
-
upload: { target: "filesystem", outputDir: join5(workDir, "lhci-report") }
|
|
671
|
-
}
|
|
672
|
-
};
|
|
673
|
-
const configPath = join5(workDir, "lighthouserc.json");
|
|
674
|
-
await writeFile(configPath, JSON.stringify(resolvedConfig), "utf-8");
|
|
675
|
-
const resultsDir = join5(workDir, ".lighthouseci");
|
|
676
|
-
let raw;
|
|
677
|
-
try {
|
|
678
|
-
raw = await spawn2("npx", ["--yes", "@lhci/cli", "autorun", `--config=${configPath}`], {
|
|
679
|
-
cwd: workDir,
|
|
680
|
-
// 3 serial cold runs of a slow deployed site (lhci's own maxWaitForLoad
|
|
681
|
-
// ~45-60s each) + first-use Chrome download can plausibly exceed 3 min →
|
|
682
|
-
// SIGTERM → no lhr-*.json → spurious "no scores". Match the 5-min budget
|
|
683
|
-
// the checkout path already gives (erp-industrials nightly flake,
|
|
684
|
-
// morning-brief 2026-06-10 MEDIUM-F).
|
|
685
|
-
timeoutMs: 5 * 6e4
|
|
686
|
-
});
|
|
687
|
-
} catch (err) {
|
|
688
|
-
await rm(workDir, { recursive: true, force: true });
|
|
689
|
-
const e = err;
|
|
690
|
-
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
|
|
691
|
-
return {
|
|
692
|
-
audit: "lighthouse",
|
|
693
|
-
site: label,
|
|
694
|
-
status: "skip",
|
|
695
|
-
summary: "npx/@lhci/cli not available"
|
|
696
|
-
};
|
|
697
|
-
}
|
|
698
|
-
throw err;
|
|
699
|
-
}
|
|
700
|
-
try {
|
|
701
|
-
return await parseLhciResults(resultsDir, label, raw);
|
|
702
|
-
} finally {
|
|
703
|
-
await rm(workDir, { recursive: true, force: true });
|
|
704
|
-
}
|
|
705
|
-
}
|
|
706
|
-
async function lighthouseAudit(ctx) {
|
|
707
|
-
const spawn2 = ctx.spawn ?? defaultSpawn;
|
|
708
|
-
const site = ctx.site;
|
|
709
|
-
const label = siteLabel(site);
|
|
710
|
-
return site.deployedUrl ? deployedLighthouse(spawn2, site.deployedUrl, label) : checkoutLighthouse(spawn2, site, label);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// src/audits/a11y.ts
|
|
714
|
-
import { readFile as readFile5, writeFile as writeFile2, mkdtemp as mkdtemp2, rm as rm2 } from "fs/promises";
|
|
715
|
-
import { join as join6 } from "path";
|
|
716
|
-
|
|
717
|
-
// src/configs/playwright-a11y.ts
|
|
718
|
-
import { defineConfig, devices } from "@playwright/test";
|
|
719
|
-
var a11yRoutes = [
|
|
720
|
-
{ path: "/dev/a11y-fixtures", name: "a11y fixtures" },
|
|
721
|
-
{ path: "/dev/animate-in", name: "animate-in demo" }
|
|
722
|
-
];
|
|
723
|
-
var smokeRoutes = [{ path: "/", name: "home" }];
|
|
724
|
-
var playwrightA11yConfig = defineConfig({
|
|
725
|
-
testDir: "tests",
|
|
726
|
-
testMatch: /.*\.spec\.ts$/,
|
|
727
|
-
fullyParallel: true,
|
|
728
|
-
forbidOnly: !!process.env.CI,
|
|
729
|
-
retries: process.env.CI ? 2 : 0,
|
|
730
|
-
reporter: process.env.CI ? "github" : "list",
|
|
731
|
-
use: {
|
|
732
|
-
baseURL: "http://localhost:5173",
|
|
733
|
-
trace: "on-first-retry"
|
|
734
|
-
},
|
|
735
|
-
projects: [
|
|
736
|
-
{
|
|
737
|
-
name: "chromium",
|
|
738
|
-
use: { ...devices["Desktop Chrome"] }
|
|
739
|
-
}
|
|
740
|
-
],
|
|
741
|
-
webServer: {
|
|
742
|
-
// Portable across pnpm and npm sites — pnpm respects `npm run` too.
|
|
743
|
-
command: "npm run vite:dev",
|
|
744
|
-
url: "http://localhost:5173/dev/a11y-fixtures",
|
|
745
|
-
reuseExistingServer: !process.env.CI,
|
|
746
|
-
timeout: 12e4
|
|
747
|
-
}
|
|
748
|
-
});
|
|
749
|
-
|
|
750
|
-
// src/audits/a11y.ts
|
|
751
|
-
var RESULTS_REL = ".reddoor-a11y/results.json";
|
|
752
|
-
async function readJsonMaybe2(path) {
|
|
753
|
-
try {
|
|
754
|
-
const raw = await readFile5(path, "utf-8");
|
|
755
|
-
return JSON.parse(raw);
|
|
756
|
-
} catch {
|
|
757
|
-
return null;
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
function buildPlaywrightConfig(port, sitePath) {
|
|
761
|
-
return `import { defineConfig } from "@playwright/test";
|
|
762
|
-
|
|
763
|
-
export default defineConfig({
|
|
764
|
-
testDir: ".",
|
|
765
|
-
testMatch: /.*\\.spec\\.ts$/,
|
|
766
|
-
fullyParallel: true,
|
|
767
|
-
forbidOnly: !!process.env.CI,
|
|
768
|
-
retries: process.env.CI ? 2 : 0,
|
|
769
|
-
reporter: process.env.CI ? "github" : "list",
|
|
770
|
-
use: {
|
|
771
|
-
baseURL: "http://localhost:${port}",
|
|
772
|
-
trace: "on-first-retry",
|
|
773
|
-
},
|
|
774
|
-
webServer: {
|
|
775
|
-
// --strictPort: refuse to bump to a different port if ours is taken,
|
|
776
|
-
// so the audit fails loudly instead of probing a zombie.
|
|
777
|
-
// reuseExistingServer:false: never reuse \u2014 we control the lifecycle.
|
|
778
|
-
// cwd: playwright's default webServer.cwd is the config file's
|
|
779
|
-
// directory. Our config lives in /tmp so without this override,
|
|
780
|
-
// "npm run vite:dev" tries to read /tmp/.../package.json and
|
|
781
|
-
// ENOENTs before vite ever starts. Caltex 2026-05-28 (0.10.5).
|
|
782
|
-
command: "npm run vite:dev -- --port ${port} --strictPort",
|
|
783
|
-
url: "http://localhost:${port}/dev/a11y-fixtures",
|
|
784
|
-
cwd: ${JSON.stringify(sitePath)},
|
|
785
|
-
reuseExistingServer: false,
|
|
786
|
-
timeout: 120_000,
|
|
787
|
-
},
|
|
788
|
-
});
|
|
789
|
-
`;
|
|
790
|
-
}
|
|
791
|
-
function buildSpec() {
|
|
792
|
-
return `import { test, expect } from "@playwright/test";
|
|
793
|
-
import AxeBuilder from "@axe-core/playwright";
|
|
794
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
795
|
-
import { dirname } from "node:path";
|
|
796
|
-
|
|
797
|
-
const pages = ${JSON.stringify(a11yRoutes)};
|
|
798
|
-
const smokePages = ${JSON.stringify(smokeRoutes)};
|
|
799
|
-
const OUTPUT = process.env.REDDOOR_A11Y_OUTPUT;
|
|
800
|
-
|
|
801
|
-
// Playwright's default per-test timeout is 30s. We loop through every
|
|
802
|
-
// configured route in a single test, so the budget needs to scale.
|
|
803
|
-
test.setTimeout(5 * 60_000);
|
|
804
|
-
|
|
805
|
-
test("a11y + hydration across configured routes", async ({ page }) => {
|
|
806
|
-
const violations = [];
|
|
807
|
-
|
|
808
|
-
// Capture uncaught client-side exceptions across every route we visit. A page
|
|
809
|
-
// that builds + SSRs cleanly can still throw on hydrate and blank itself
|
|
810
|
-
// (data-dynamiq: a Svelte 4->5 run() referenced a $state declared after it) --
|
|
811
|
-
// axe never sees that, so we listen for it directly and tag the route in scope.
|
|
812
|
-
let currentRoute = "";
|
|
813
|
-
page.on("pageerror", (err) => {
|
|
814
|
-
violations.push({
|
|
815
|
-
id: "client-error",
|
|
816
|
-
impact: "critical",
|
|
817
|
-
route: currentRoute,
|
|
818
|
-
help: String(err && err.message ? err.message : err),
|
|
819
|
-
});
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
for (const { path, name } of pages) {
|
|
823
|
-
currentRoute = name;
|
|
824
|
-
await page.goto(path);
|
|
825
|
-
// Snap CSS transitions/animations to their resting state before axe runs.
|
|
826
|
-
// AnimateIn-style fixtures transition opacity 0->1; sampling mid-transition
|
|
827
|
-
// makes axe compute color-contrast against semi-transparent text, yielding a
|
|
828
|
-
// flaky "serious" color-contrast violation (~1/3 of runs on /dev/animate-in).
|
|
829
|
-
// Disabling transitions/animations forces the final, rendered state
|
|
830
|
-
// deterministically -- which is also what users (and prefers-reduced-motion
|
|
831
|
-
// users) actually see, so it's the correct thing to assert.
|
|
832
|
-
await page.addStyleTag({
|
|
833
|
-
content: "*,*::before,*::after{transition:none!important;animation:none!important;}",
|
|
834
|
-
});
|
|
835
|
-
const results = await new AxeBuilder({ page })
|
|
836
|
-
.withTags(["wcag2a","wcag2aa","wcag21a","wcag21aa","wcag22aa"])
|
|
837
|
-
.analyze();
|
|
838
|
-
for (const v of results.violations) {
|
|
839
|
-
violations.push({
|
|
840
|
-
id: v.id,
|
|
841
|
-
impact: v.impact ?? "moderate",
|
|
842
|
-
route: name,
|
|
843
|
-
help: v.help,
|
|
844
|
-
helpUrl: v.helpUrl,
|
|
845
|
-
nodes: v.nodes.map((n) => ({ html: n.html, target: n.target })),
|
|
846
|
-
});
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// Hydration smoke check: load real routes (the homepage) and fail on any
|
|
851
|
-
// uncaught client-side error. No axe here -- real routes carry pre-existing
|
|
852
|
-
// a11y debt we don't gate on; we only assert they don't crash on hydrate.
|
|
853
|
-
// HTTP/SSR errors don't fire 'pageerror', so a data-less CI homepage that
|
|
854
|
-
// renders empty-but-valid won't false-fail -- only a real client crash does.
|
|
855
|
-
for (const { path, name } of smokePages) {
|
|
856
|
-
currentRoute = name;
|
|
857
|
-
await page.goto(path);
|
|
858
|
-
// Let hydration + first effects run so a TDZ/ReferenceError surfaces.
|
|
859
|
-
await page.waitForTimeout(2000);
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
const byImpact = {};
|
|
863
|
-
for (const v of violations) {
|
|
864
|
-
byImpact[v.impact] = (byImpact[v.impact] ?? 0) + 1;
|
|
865
|
-
}
|
|
866
|
-
if (OUTPUT) {
|
|
867
|
-
await mkdir(dirname(OUTPUT), { recursive: true });
|
|
868
|
-
await writeFile(
|
|
869
|
-
OUTPUT,
|
|
870
|
-
JSON.stringify({ totalViolations: violations.length, byImpact, violations }, null, 2),
|
|
871
|
-
);
|
|
872
|
-
}
|
|
873
|
-
expect(violations).toEqual([]);
|
|
874
|
-
});
|
|
875
|
-
`;
|
|
876
|
-
}
|
|
877
|
-
async function a11yAudit(ctx) {
|
|
878
|
-
const spawn2 = ctx.spawn ?? defaultSpawn;
|
|
879
|
-
const site = ctx.site;
|
|
880
|
-
const label = siteLabel(site);
|
|
881
|
-
const specDir = await mkdtemp2(join6(site.path, ".reddoor-a11y-spec-"));
|
|
882
|
-
try {
|
|
883
|
-
const specPath = join6(specDir, "a11y.spec.ts");
|
|
884
|
-
await writeFile2(specPath, buildSpec(), "utf-8");
|
|
885
|
-
const port = await findFreePort();
|
|
886
|
-
const configPath = join6(specDir, "playwright.config.ts");
|
|
887
|
-
await writeFile2(configPath, buildPlaywrightConfig(port, site.path), "utf-8");
|
|
888
|
-
const resultsPath = join6(site.path, RESULTS_REL);
|
|
889
|
-
await rm2(join6(site.path, ".reddoor-a11y"), { recursive: true, force: true });
|
|
890
|
-
let raw;
|
|
891
|
-
try {
|
|
892
|
-
raw = await spawn2(
|
|
893
|
-
"npx",
|
|
894
|
-
["--yes", "playwright", "test", `--config=${configPath}`, "--reporter=line", specPath],
|
|
895
|
-
{
|
|
896
|
-
cwd: site.path,
|
|
897
|
-
env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath },
|
|
898
|
-
// playwright on a cold tree downloads Chrome, boots the site's dev
|
|
899
|
-
// server, and runs axe over every configured route. The shared 30 s
|
|
900
|
-
// default in runAudits is fine for deps/lint/security but starves
|
|
901
|
-
// playwright (mirrors the lighthouse fix shipped earlier).
|
|
902
|
-
timeoutMs: 5 * 6e4
|
|
903
|
-
}
|
|
904
|
-
);
|
|
905
|
-
} catch (err) {
|
|
906
|
-
const e = err;
|
|
907
|
-
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
|
|
908
|
-
return {
|
|
909
|
-
audit: "a11y",
|
|
910
|
-
site: label,
|
|
911
|
-
status: "skip",
|
|
912
|
-
summary: "npx/playwright not available"
|
|
913
|
-
};
|
|
914
|
-
}
|
|
915
|
-
throw err;
|
|
916
|
-
}
|
|
917
|
-
const artifact = await readJsonMaybe2(resultsPath);
|
|
918
|
-
if (!artifact) {
|
|
919
|
-
return {
|
|
920
|
-
audit: "a11y",
|
|
921
|
-
site: label,
|
|
922
|
-
status: "fail",
|
|
923
|
-
summary: `a11y: no results written (exit ${raw.code})${raw.stderr ? ` \u2014 ${raw.stderr.slice(0, 200)}` : ""}`
|
|
924
|
-
};
|
|
925
|
-
}
|
|
926
|
-
const hasSerious = (artifact.byImpact.serious ?? 0) > 0 || (artifact.byImpact.critical ?? 0) > 0;
|
|
927
|
-
const hasAny = artifact.totalViolations > 0;
|
|
928
|
-
const status = hasSerious ? "fail" : hasAny ? "warn" : "pass";
|
|
929
|
-
const summary = status === "pass" ? `a11y: 0 violations across ${a11yRoutes.length} routes (+${smokeRoutes.length} hydration smoke)` : `a11y: ${artifact.totalViolations} violations`;
|
|
930
|
-
return {
|
|
931
|
-
audit: "a11y",
|
|
932
|
-
site: label,
|
|
933
|
-
status,
|
|
934
|
-
summary,
|
|
935
|
-
details: artifact
|
|
936
|
-
};
|
|
937
|
-
} finally {
|
|
938
|
-
await rm2(specDir, { recursive: true, force: true });
|
|
939
|
-
}
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
// src/audits/domain.ts
|
|
943
|
-
import { promises as dnsPromises } from "dns";
|
|
944
|
-
import tls from "tls";
|
|
945
|
-
var MS_PER_DAY = 24 * 60 * 60 * 1e3;
|
|
946
|
-
async function checkDomain(url, deps) {
|
|
947
|
-
let host;
|
|
948
|
-
try {
|
|
949
|
-
host = new URL(url).hostname;
|
|
950
|
-
} catch {
|
|
951
|
-
return { resolved: false, certDaysRemaining: null };
|
|
952
|
-
}
|
|
953
|
-
try {
|
|
954
|
-
await deps.lookup(host);
|
|
955
|
-
} catch {
|
|
956
|
-
return { resolved: false, certDaysRemaining: null };
|
|
957
|
-
}
|
|
958
|
-
let validTo;
|
|
959
|
-
try {
|
|
960
|
-
validTo = await deps.certValidTo(host);
|
|
961
|
-
} catch {
|
|
962
|
-
validTo = null;
|
|
963
|
-
}
|
|
964
|
-
if (!validTo || Number.isNaN(validTo.getTime()))
|
|
965
|
-
return { resolved: true, certDaysRemaining: null };
|
|
966
|
-
return {
|
|
967
|
-
resolved: true,
|
|
968
|
-
certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY)
|
|
969
|
-
};
|
|
970
|
-
}
|
|
971
|
-
function defaultDomainDeps(now) {
|
|
972
|
-
return {
|
|
973
|
-
lookup: async (host) => {
|
|
974
|
-
await dnsPromises.lookup(host);
|
|
975
|
-
},
|
|
976
|
-
certValidTo: (host) => new Promise((resolvePromise) => {
|
|
977
|
-
const socket = tls.connect(
|
|
978
|
-
{ host, port: 443, servername: host, timeout: 1e4, rejectUnauthorized: true },
|
|
979
|
-
() => {
|
|
980
|
-
const cert = socket.authorized ? socket.getPeerCertificate() : null;
|
|
981
|
-
socket.end();
|
|
982
|
-
const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;
|
|
983
|
-
resolvePromise(validTo);
|
|
984
|
-
}
|
|
985
|
-
);
|
|
986
|
-
socket.on("error", () => resolvePromise(null));
|
|
987
|
-
socket.on("timeout", () => {
|
|
988
|
-
socket.destroy();
|
|
989
|
-
resolvePromise(null);
|
|
990
|
-
});
|
|
991
|
-
}),
|
|
992
|
-
now
|
|
993
|
-
};
|
|
994
|
-
}
|
|
995
|
-
async function domainAudit(ctx) {
|
|
996
|
-
const { site } = ctx;
|
|
997
|
-
const label = siteLabel(site);
|
|
998
|
-
if (!site.deployedUrl) {
|
|
999
|
-
return { audit: "domain", site: label, status: "skip", summary: "no deployed URL" };
|
|
1000
|
-
}
|
|
1001
|
-
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1002
|
-
const deps = ctx.domainDeps ?? defaultDomainDeps(now);
|
|
1003
|
-
const check = await checkDomain(site.deployedUrl, deps);
|
|
1004
|
-
const checkedAt = now.toISOString();
|
|
1005
|
-
const status = check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14 ? "pass" : "warn";
|
|
1006
|
-
const summary = !check.resolved ? "did not resolve" : check.certDaysRemaining === null ? "resolved, no usable TLS cert" : `resolved, cert ${check.certDaysRemaining}d remaining`;
|
|
1007
|
-
return {
|
|
1008
|
-
audit: "domain",
|
|
1009
|
-
site: label,
|
|
1010
|
-
status,
|
|
1011
|
-
summary,
|
|
1012
|
-
details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt }
|
|
1013
|
-
};
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
// src/audits/route-discovery.ts
|
|
1017
|
-
var DEFAULT_CAP = 15;
|
|
1018
|
-
function parseSitemapUrls(xml) {
|
|
1019
|
-
const out = [];
|
|
1020
|
-
const re = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
|
|
1021
|
-
let m;
|
|
1022
|
-
while ((m = re.exec(xml)) !== null) {
|
|
1023
|
-
const url = m[1];
|
|
1024
|
-
if (url) out.push(url.trim());
|
|
1025
|
-
}
|
|
1026
|
-
return out;
|
|
1027
|
-
}
|
|
1028
|
-
function parseHtmlLinks(html, baseUrl) {
|
|
1029
|
-
const out = /* @__PURE__ */ new Set();
|
|
1030
|
-
const re = /<a\b[^>]*\bhref\s*=\s*["']([^"']+)["']/gi;
|
|
1031
|
-
let m;
|
|
1032
|
-
while ((m = re.exec(html)) !== null) {
|
|
1033
|
-
const href = m[1];
|
|
1034
|
-
if (!href || href.startsWith("#") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;
|
|
1035
|
-
try {
|
|
1036
|
-
const u = new URL(href, baseUrl);
|
|
1037
|
-
if (u.origin !== new URL(baseUrl).origin) continue;
|
|
1038
|
-
out.add(u.pathname);
|
|
1039
|
-
} catch {
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
return [...out];
|
|
1043
|
-
}
|
|
1044
|
-
function family(pathname) {
|
|
1045
|
-
return pathname.split("/").filter(Boolean)[0] ?? "";
|
|
1046
|
-
}
|
|
1047
|
-
function sampleRoutePaths(urlsOrPaths, cap = DEFAULT_CAP) {
|
|
1048
|
-
const seen = /* @__PURE__ */ new Set(["/"]);
|
|
1049
|
-
const buckets = /* @__PURE__ */ new Map();
|
|
1050
|
-
for (const raw of urlsOrPaths) {
|
|
1051
|
-
let pathname;
|
|
1052
|
-
try {
|
|
1053
|
-
pathname = raw.startsWith("/") ? new URL(raw, "https://x.invalid").pathname : new URL(raw).pathname;
|
|
1054
|
-
} catch {
|
|
1055
|
-
continue;
|
|
1056
|
-
}
|
|
1057
|
-
if (pathname === "/") continue;
|
|
1058
|
-
if (seen.has(pathname)) continue;
|
|
1059
|
-
seen.add(pathname);
|
|
1060
|
-
const fam = family(pathname);
|
|
1061
|
-
const arr = buckets.get(fam) ?? [];
|
|
1062
|
-
arr.push(pathname);
|
|
1063
|
-
buckets.set(fam, arr);
|
|
1064
|
-
}
|
|
1065
|
-
const result = ["/"];
|
|
1066
|
-
const families = [...buckets.values()];
|
|
1067
|
-
let guard = 0;
|
|
1068
|
-
while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 1e4) {
|
|
1069
|
-
for (const fam of families) {
|
|
1070
|
-
if (result.length >= cap) break;
|
|
1071
|
-
const next = fam.shift();
|
|
1072
|
-
if (next) result.push(next);
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
return result;
|
|
1076
|
-
}
|
|
1077
|
-
function familyCountsOf(paths) {
|
|
1078
|
-
const counts = {};
|
|
1079
|
-
for (const p of paths) {
|
|
1080
|
-
const key = p === "/" ? "/" : `/${family(p)}`;
|
|
1081
|
-
counts[key] = (counts[key] ?? 0) + 1;
|
|
1082
|
-
}
|
|
1083
|
-
return counts;
|
|
1084
|
-
}
|
|
1085
|
-
async function discoverRoutes(deployedUrl, deps, cap = DEFAULT_CAP) {
|
|
1086
|
-
const origin = new URL(deployedUrl).origin;
|
|
1087
|
-
const abs = (paths) => paths.map((p) => new URL(p, origin).href);
|
|
1088
|
-
const sitemapXml = await deps.fetchText(new URL("/sitemap.xml", origin).href);
|
|
1089
|
-
if (sitemapXml) {
|
|
1090
|
-
const urls = parseSitemapUrls(sitemapXml);
|
|
1091
|
-
if (urls.length > 0) {
|
|
1092
|
-
const paths = sampleRoutePaths(urls, cap);
|
|
1093
|
-
return { routes: abs(paths), source: "sitemap", familyCounts: familyCountsOf(paths) };
|
|
1094
|
-
}
|
|
1095
|
-
}
|
|
1096
|
-
const homeHtml = await deps.fetchText(origin);
|
|
1097
|
-
if (homeHtml) {
|
|
1098
|
-
const links = parseHtmlLinks(homeHtml, origin);
|
|
1099
|
-
if (links.length > 0) {
|
|
1100
|
-
const paths = sampleRoutePaths(links, cap);
|
|
1101
|
-
return { routes: abs(paths), source: "homepage-links", familyCounts: familyCountsOf(paths) };
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
return { routes: [new URL("/", origin).href], source: "root-only", familyCounts: { "/": 1 } };
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
// src/audits/browser.ts
|
|
1108
|
-
function isBroken(status) {
|
|
1109
|
-
return status === null || status >= 400;
|
|
1110
|
-
}
|
|
1111
|
-
function summarizeBrowser(routes, links, familyCounts) {
|
|
1112
|
-
const desktopChecks = routes.flatMap((r) => r.desktop);
|
|
1113
|
-
const mobileChecks = routes.flatMap((r) => r.mobile);
|
|
1114
|
-
const desktopOk = routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));
|
|
1115
|
-
const mobileOk = routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));
|
|
1116
|
-
const brokenLinks = links.filter((l) => isBroken(l.status)).length;
|
|
1117
|
-
const linksOk = links.length > 0 && brokenLinks === 0;
|
|
1118
|
-
const engines = [...new Set(desktopChecks.map((d) => d.engine))];
|
|
1119
|
-
const devices2 = [...new Set(mobileChecks.map((m) => m.device))];
|
|
1120
|
-
const families = Object.entries(familyCounts).map(([f, n]) => f === "/" ? "/" : `${f} \xD7${n}`).join(", ");
|
|
1121
|
-
const note = `${routes.length} routes (${families}); desktop ${engines.join("/") || "\u2014"}; mobile ${devices2.join("/") || "\u2014"}; ${links.length} links, ${brokenLinks} broken`;
|
|
1122
|
-
return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };
|
|
1123
|
-
}
|
|
1124
|
-
async function browserAudit(ctx) {
|
|
1125
|
-
const { site } = ctx;
|
|
1126
|
-
const label = siteLabel(site);
|
|
1127
|
-
if (!site.deployedUrl) {
|
|
1128
|
-
return { audit: "browser", site: label, status: "skip", summary: "no deployed URL" };
|
|
1129
|
-
}
|
|
1130
|
-
const now = ctx.now ?? /* @__PURE__ */ new Date();
|
|
1131
|
-
const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();
|
|
1132
|
-
const runner = ctx.browserRunner ?? await defaultBrowserRunner();
|
|
1133
|
-
try {
|
|
1134
|
-
const discovered = await discoverRoutes(site.deployedUrl, discoverDeps);
|
|
1135
|
-
const routeResults = await runner.probe(discovered.routes);
|
|
1136
|
-
const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];
|
|
1137
|
-
const linkResults = await runner.checkLinks(internalLinks);
|
|
1138
|
-
const summary = summarizeBrowser(
|
|
1139
|
-
routeResults,
|
|
1140
|
-
linkResults,
|
|
1141
|
-
discovered.familyCounts ?? familyCountsOf(discovered.routes)
|
|
1142
|
-
);
|
|
1143
|
-
const status = summary.desktopOk && summary.mobileOk && summary.linksOk ? "pass" : "warn";
|
|
1144
|
-
return {
|
|
1145
|
-
audit: "browser",
|
|
1146
|
-
site: label,
|
|
1147
|
-
status,
|
|
1148
|
-
summary: summary.note,
|
|
1149
|
-
details: { ...summary, checkedAt: now.toISOString() }
|
|
1150
|
-
};
|
|
1151
|
-
} finally {
|
|
1152
|
-
await runner.close?.();
|
|
1153
|
-
}
|
|
1154
|
-
}
|
|
1155
|
-
var FETCH_TIMEOUT_MS = 1e4;
|
|
1156
|
-
function defaultDiscoverDeps() {
|
|
1157
|
-
return {
|
|
1158
|
-
fetchText: async (url) => {
|
|
1159
|
-
try {
|
|
1160
|
-
const res = await fetch(url, {
|
|
1161
|
-
redirect: "follow",
|
|
1162
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
1163
|
-
});
|
|
1164
|
-
if (!res.ok) return null;
|
|
1165
|
-
return await res.text();
|
|
1166
|
-
} catch {
|
|
1167
|
-
return null;
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
};
|
|
1171
|
-
}
|
|
1172
|
-
var DESKTOP_VIEWPORT = { width: 1366, height: 900 };
|
|
1173
|
-
var PAGE_TIMEOUT_MS = 3e4;
|
|
1174
|
-
async function defaultBrowserRunner() {
|
|
1175
|
-
const { chromium, firefox, webkit, devices: devices2 } = await import("@playwright/test");
|
|
1176
|
-
const desktopEngines = [
|
|
1177
|
-
{ engine: "chromium", type: chromium },
|
|
1178
|
-
{ engine: "firefox", type: firefox },
|
|
1179
|
-
{ engine: "webkit", type: webkit }
|
|
1180
|
-
];
|
|
1181
|
-
const mobileTargets = [
|
|
1182
|
-
{ device: "Pixel 7", descriptor: devices2["Pixel 7"] },
|
|
1183
|
-
{ device: "iPhone 14", descriptor: devices2["iPhone 14"] }
|
|
1184
|
-
];
|
|
1185
|
-
return {
|
|
1186
|
-
async probe(urls) {
|
|
1187
|
-
const results = [];
|
|
1188
|
-
const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));
|
|
1189
|
-
const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));
|
|
1190
|
-
try {
|
|
1191
|
-
for (const url of urls) {
|
|
1192
|
-
const desktop = [];
|
|
1193
|
-
const linkSet = /* @__PURE__ */ new Set();
|
|
1194
|
-
for (let i = 0; i < desktopEngines.length; i++) {
|
|
1195
|
-
const engine = desktopEngines[i].engine;
|
|
1196
|
-
const browser = browsers[i];
|
|
1197
|
-
const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });
|
|
1198
|
-
const page = await ctx.newPage();
|
|
1199
|
-
const errors = [];
|
|
1200
|
-
page.on("pageerror", (e) => errors.push(String(e)));
|
|
1201
|
-
let ok = false;
|
|
1202
|
-
try {
|
|
1203
|
-
const resp = await page.goto(url, {
|
|
1204
|
-
waitUntil: "domcontentloaded",
|
|
1205
|
-
timeout: PAGE_TIMEOUT_MS
|
|
1206
|
-
});
|
|
1207
|
-
const hasMain = await page.locator("main, [role=main]").first().isVisible().catch(() => false);
|
|
1208
|
-
ok = !!resp && resp.ok() && errors.length === 0 && hasMain;
|
|
1209
|
-
if (engine === "chromium") {
|
|
1210
|
-
const hrefs = await page.evaluate("Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)").catch(() => []);
|
|
1211
|
-
const origin = new URL(url).origin;
|
|
1212
|
-
for (const h of hrefs) {
|
|
1213
|
-
try {
|
|
1214
|
-
if (new URL(h).origin === origin) linkSet.add(new URL(h).href);
|
|
1215
|
-
} catch {
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
} catch {
|
|
1220
|
-
ok = false;
|
|
1221
|
-
} finally {
|
|
1222
|
-
await ctx.close().catch(() => {
|
|
1223
|
-
});
|
|
1224
|
-
}
|
|
1225
|
-
desktop.push({ engine, ok });
|
|
1226
|
-
}
|
|
1227
|
-
const mobile = [];
|
|
1228
|
-
for (let i = 0; i < mobileTargets.length; i++) {
|
|
1229
|
-
const { device, descriptor } = mobileTargets[i];
|
|
1230
|
-
const browser = mobileBrowsers[i];
|
|
1231
|
-
const ctx = await browser.newContext({ ...descriptor });
|
|
1232
|
-
const page = await ctx.newPage();
|
|
1233
|
-
const errors = [];
|
|
1234
|
-
page.on("pageerror", (e) => errors.push(String(e)));
|
|
1235
|
-
let ok = false;
|
|
1236
|
-
try {
|
|
1237
|
-
const resp = await page.goto(url, {
|
|
1238
|
-
waitUntil: "domcontentloaded",
|
|
1239
|
-
timeout: PAGE_TIMEOUT_MS
|
|
1240
|
-
});
|
|
1241
|
-
const overflow = await page.evaluate("document.documentElement.scrollWidth > window.innerWidth + 2").catch(() => true);
|
|
1242
|
-
ok = !!resp && resp.ok() && errors.length === 0 && !overflow;
|
|
1243
|
-
} catch {
|
|
1244
|
-
ok = false;
|
|
1245
|
-
} finally {
|
|
1246
|
-
await ctx.close().catch(() => {
|
|
1247
|
-
});
|
|
1248
|
-
}
|
|
1249
|
-
mobile.push({ device, ok });
|
|
1250
|
-
}
|
|
1251
|
-
results.push({ url, desktop, mobile, links: [...linkSet] });
|
|
1252
|
-
}
|
|
1253
|
-
} finally {
|
|
1254
|
-
await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {
|
|
1255
|
-
})));
|
|
1256
|
-
}
|
|
1257
|
-
return results;
|
|
1258
|
-
},
|
|
1259
|
-
async checkLinks(urls) {
|
|
1260
|
-
const out = [];
|
|
1261
|
-
for (const url of urls) {
|
|
1262
|
-
let status;
|
|
1263
|
-
try {
|
|
1264
|
-
let res = await fetch(url, {
|
|
1265
|
-
method: "HEAD",
|
|
1266
|
-
redirect: "follow",
|
|
1267
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
1268
|
-
});
|
|
1269
|
-
if (res.status === 405 || res.status === 501) {
|
|
1270
|
-
res = await fetch(url, {
|
|
1271
|
-
method: "GET",
|
|
1272
|
-
redirect: "follow",
|
|
1273
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
1274
|
-
});
|
|
1275
|
-
}
|
|
1276
|
-
status = res.status;
|
|
1277
|
-
} catch {
|
|
1278
|
-
status = null;
|
|
1279
|
-
}
|
|
1280
|
-
out.push({ url, status });
|
|
1281
|
-
}
|
|
1282
|
-
return out;
|
|
1283
|
-
}
|
|
1284
|
-
};
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// src/audits/index.ts
|
|
1288
|
-
var REGISTRY = {
|
|
1289
|
-
deps: depsAudit,
|
|
1290
|
-
lint: lintAudit,
|
|
1291
|
-
security: securityAudit,
|
|
1292
|
-
lighthouse: lighthouseAudit,
|
|
1293
|
-
a11y: a11yAudit,
|
|
1294
|
-
domain: domainAudit,
|
|
1295
|
-
browser: browserAudit
|
|
1296
|
-
};
|
|
1297
|
-
var ALL_AUDIT_NAMES = Object.keys(REGISTRY);
|
|
1298
|
-
var DEFAULT_AUDIT_TIMEOUT_MS = 3e4;
|
|
1299
|
-
function timedSpawn(timeoutMs) {
|
|
1300
|
-
return (cmd, args, opts = {}) => defaultSpawn(cmd, args, { ...opts, timeoutMs: opts.timeoutMs ?? timeoutMs });
|
|
1301
|
-
}
|
|
1302
|
-
async function runOneAudit(site, name) {
|
|
1303
|
-
if (!(name in REGISTRY)) throw new Error(`unknown audit: ${name}`);
|
|
1304
|
-
const spawn2 = timedSpawn(DEFAULT_AUDIT_TIMEOUT_MS);
|
|
1305
|
-
const label = site.name || site.path;
|
|
1306
|
-
try {
|
|
1307
|
-
return await REGISTRY[name]({ site, spawn: spawn2 });
|
|
1308
|
-
} catch (err) {
|
|
1309
|
-
return {
|
|
1310
|
-
audit: name,
|
|
1311
|
-
site: label,
|
|
1312
|
-
status: "fail",
|
|
1313
|
-
summary: `${name}: unexpected error \u2014 ${String(err)}`
|
|
1314
|
-
};
|
|
1315
|
-
}
|
|
1316
|
-
}
|
|
1317
|
-
async function runAudits(site, which) {
|
|
1318
|
-
const names = which ?? ALL_AUDIT_NAMES;
|
|
1319
|
-
for (const n of names) {
|
|
1320
|
-
if (!(n in REGISTRY)) throw new Error(`unknown audit: ${n}`);
|
|
1321
|
-
}
|
|
1322
|
-
return Promise.all(names.map((n) => runOneAudit(site, n)));
|
|
1323
|
-
}
|
|
1324
|
-
async function runAuditsAcross(sites, which) {
|
|
1325
|
-
const all = await Promise.all(sites.map((s) => runAudits(s, which)));
|
|
1326
|
-
return all.flat();
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
// src/recipes/sync-configs.ts
|
|
1330
|
-
import { readFile as readFile6, writeFile as writeFile3, mkdir } from "fs/promises";
|
|
1331
|
-
import { join as join7, dirname } from "path";
|
|
1332
|
-
|
|
1333
|
-
// src/recipes/sync-configs/templates.ts
|
|
1334
|
-
var eslint = {
|
|
1335
|
-
config: "eslint",
|
|
1336
|
-
path: "eslint.config.js",
|
|
1337
|
-
contents: `import { createEslintConfig } from "@reddoorla/maintenance/configs/eslint";
|
|
1338
|
-
import svelteConfig from "./svelte.config.js";
|
|
1339
|
-
|
|
1340
|
-
export default createEslintConfig({ svelteConfig });
|
|
1341
|
-
`
|
|
1342
|
-
};
|
|
1343
|
-
var prettier = {
|
|
1344
|
-
config: "prettier",
|
|
1345
|
-
path: ".prettierrc.json",
|
|
1346
|
-
contents: `{
|
|
1347
|
-
"trailingComma": "all",
|
|
1348
|
-
"singleQuote": false,
|
|
1349
|
-
"printWidth": 100,
|
|
1350
|
-
"plugins": ["prettier-plugin-svelte"]
|
|
1351
|
-
}
|
|
1352
|
-
`
|
|
1353
|
-
};
|
|
1354
|
-
var prettierIgnore = {
|
|
1355
|
-
config: "prettier-ignore",
|
|
1356
|
-
path: ".prettierignore",
|
|
1357
|
-
contents: `pnpm-lock.yaml
|
|
1358
|
-
.svelte-kit/
|
|
1359
|
-
build/
|
|
1360
|
-
.netlify/
|
|
1361
|
-
dist/
|
|
1362
|
-
`
|
|
1363
|
-
};
|
|
1364
|
-
var lighthouse = {
|
|
1365
|
-
config: "lighthouse",
|
|
1366
|
-
path: "lighthouserc.json",
|
|
1367
|
-
contents: `${JSON.stringify(
|
|
1368
|
-
{
|
|
1369
|
-
$note: "Generated by @reddoorla/maintenance sync-configs; edit src/configs/lighthouse.ts in the package instead.",
|
|
1370
|
-
extends: "@reddoorla/maintenance/configs/lighthouse"
|
|
1371
|
-
},
|
|
1372
|
-
null,
|
|
1373
|
-
2
|
|
1374
|
-
)}
|
|
1375
|
-
`
|
|
1376
|
-
};
|
|
1377
|
-
var playwrightA11y = {
|
|
1378
|
-
config: "playwright-a11y",
|
|
1379
|
-
path: "playwright.config.ts",
|
|
1380
|
-
contents: `export { default } from "@reddoorla/maintenance/configs/playwright-a11y";
|
|
1381
|
-
`
|
|
1382
|
-
};
|
|
1383
|
-
var svelte = {
|
|
1384
|
-
config: "svelte",
|
|
1385
|
-
path: "svelte.config.js",
|
|
1386
|
-
contents: `import { createSvelteConfig } from "@reddoorla/maintenance/configs/svelte";
|
|
1387
|
-
import adapter from "@sveltejs/adapter-netlify";
|
|
1388
|
-
|
|
1389
|
-
/** @type {import('@sveltejs/kit').Config} */
|
|
1390
|
-
export default createSvelteConfig({
|
|
1391
|
-
kit: { adapter: adapter({ edge: false, split: false }) },
|
|
1392
|
-
});
|
|
1393
|
-
`
|
|
1394
|
-
};
|
|
1395
|
-
var ci = {
|
|
1396
|
-
config: "ci",
|
|
1397
|
-
path: ".github/workflows/ci.yml",
|
|
1398
|
-
contents: `name: ci
|
|
1399
|
-
on:
|
|
1400
|
-
pull_request:
|
|
1401
|
-
push:
|
|
1402
|
-
branches: [main]
|
|
1403
|
-
jobs:
|
|
1404
|
-
ci:
|
|
1405
|
-
uses: reddoorla/.github/.github/workflows/ci.yml@78c4da64b675f0f474961f12715f2a4c09d46eb5 # v1.0.0
|
|
1406
|
-
`
|
|
1407
|
-
};
|
|
1408
|
-
var renovateAction = {
|
|
1409
|
-
config: "renovate-action",
|
|
1410
|
-
path: ".github/workflows/renovate.yml",
|
|
1411
|
-
contents: `name: renovate
|
|
1412
|
-
on:
|
|
1413
|
-
schedule:
|
|
1414
|
-
- cron: "0 7 * * 1"
|
|
1415
|
-
workflow_dispatch:
|
|
1416
|
-
jobs:
|
|
1417
|
-
renovate:
|
|
1418
|
-
runs-on: ubuntu-latest
|
|
1419
|
-
steps:
|
|
1420
|
-
- uses: actions/checkout@v4
|
|
1421
|
-
- uses: renovatebot/github-action@v46.1.14
|
|
1422
|
-
with:
|
|
1423
|
-
token: \${{ secrets.RENOVATE_TOKEN }}
|
|
1424
|
-
env:
|
|
1425
|
-
RENOVATE_REPOSITORIES: \${{ github.repository }}
|
|
1426
|
-
`
|
|
1427
|
-
};
|
|
1428
|
-
var renovateConfig = {
|
|
1429
|
-
config: "renovate-config",
|
|
1430
|
-
path: "renovate.json",
|
|
1431
|
-
contents: `{
|
|
1432
|
-
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
|
1433
|
-
"extends": ["github>reddoorla/.github:renovate-config"]
|
|
1434
|
-
}
|
|
1435
|
-
`
|
|
1436
|
-
};
|
|
1437
|
-
var netlify = {
|
|
1438
|
-
config: "netlify",
|
|
1439
|
-
path: "netlify.toml",
|
|
1440
|
-
contents: `[build]
|
|
1441
|
-
command = "pnpm build"
|
|
1442
|
-
publish = "build/"
|
|
1443
|
-
functions = "functions/"
|
|
1444
|
-
|
|
1445
|
-
[build.environment]
|
|
1446
|
-
NODE_VERSION = "22"
|
|
1447
|
-
COREPACK_INTEGRITY_KEYS = "0"
|
|
1448
|
-
|
|
1449
|
-
# Baseline security headers for all responses. CSP is emitted per-response by
|
|
1450
|
-
# SvelteKit (see \`kit.csp\` in svelte.config.js) so it is intentionally omitted
|
|
1451
|
-
# here to avoid conflicting duplicates.
|
|
1452
|
-
[[headers]]
|
|
1453
|
-
for = "/*"
|
|
1454
|
-
[headers.values]
|
|
1455
|
-
Strict-Transport-Security = "max-age=63072000; includeSubDomains; preload"
|
|
1456
|
-
X-Content-Type-Options = "nosniff"
|
|
1457
|
-
X-Frame-Options = "SAMEORIGIN"
|
|
1458
|
-
Referrer-Policy = "strict-origin-when-cross-origin"
|
|
1459
|
-
Permissions-Policy = "camera=(), microphone=(), geolocation=(), interest-cohort=()"
|
|
1460
|
-
Cross-Origin-Opener-Policy = "same-origin"
|
|
1461
|
-
|
|
1462
|
-
[[headers]]
|
|
1463
|
-
for = "/favicon.png"
|
|
1464
|
-
[headers.values]
|
|
1465
|
-
Cache-Control = "public, max-age=31536000, immutable"
|
|
1466
|
-
|
|
1467
|
-
[[headers]]
|
|
1468
|
-
for = "/_app/immutable/*"
|
|
1469
|
-
[headers.values]
|
|
1470
|
-
Cache-Control = "public, max-age=31536000, immutable"
|
|
1471
|
-
`
|
|
1472
|
-
};
|
|
1473
|
-
var ALL_TEMPLATES = [
|
|
1474
|
-
eslint,
|
|
1475
|
-
prettier,
|
|
1476
|
-
prettierIgnore,
|
|
1477
|
-
lighthouse,
|
|
1478
|
-
playwrightA11y,
|
|
1479
|
-
svelte,
|
|
1480
|
-
ci,
|
|
1481
|
-
renovateAction,
|
|
1482
|
-
renovateConfig,
|
|
1483
|
-
netlify
|
|
1484
|
-
];
|
|
1485
|
-
function templatesByName(which) {
|
|
1486
|
-
return ALL_TEMPLATES.filter((t) => which.includes(t.config));
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
// src/recipes/sync-configs/gitignore.ts
|
|
1490
|
-
var MANAGED_MARKER = "# canonical entries from @reddoorla/maintenance sync-configs";
|
|
1491
|
-
var CANONICAL_GITIGNORE_ENTRIES = [
|
|
1492
|
-
"node_modules/",
|
|
1493
|
-
"build/",
|
|
1494
|
-
"dist/",
|
|
1495
|
-
".svelte-kit/",
|
|
1496
|
-
"coverage/",
|
|
1497
|
-
".vitest-cache/",
|
|
1498
|
-
"playwright-report/",
|
|
1499
|
-
"test-results/",
|
|
1500
|
-
".lighthouseci/",
|
|
1501
|
-
".tsbuildinfo",
|
|
1502
|
-
".env",
|
|
1503
|
-
".env.*",
|
|
1504
|
-
"!.env.example",
|
|
1505
|
-
".DS_Store",
|
|
1506
|
-
"*.log",
|
|
1507
|
-
".vercel/",
|
|
1508
|
-
".netlify/",
|
|
1509
|
-
".reddoor-a11y/",
|
|
1510
|
-
// The a11y audit's transient spec dir, written inside the checkout and
|
|
1511
|
-
// normally cleaned, but a timeout-SIGKILL of the parent orphans it. Ignored
|
|
1512
|
-
// fleet-wide so it never dirties a self-updating repo's tree (2026-06-10 M-D).
|
|
1513
|
-
".reddoor-a11y-spec-*/"
|
|
1514
|
-
];
|
|
1515
|
-
function stripLeadingSlash(s) {
|
|
1516
|
-
return s.startsWith("/") ? s.slice(1) : s;
|
|
1517
|
-
}
|
|
1518
|
-
function stripTrailingSlash(s) {
|
|
1519
|
-
return s.endsWith("/") ? s.slice(0, -1) : s;
|
|
1520
|
-
}
|
|
1521
|
-
function normalizePresence(line) {
|
|
1522
|
-
return stripTrailingSlash(stripLeadingSlash(line.trim()));
|
|
1523
|
-
}
|
|
1524
|
-
function presentSet(existing) {
|
|
1525
|
-
const set = /* @__PURE__ */ new Set();
|
|
1526
|
-
for (const raw of existing.split(/\r?\n/)) {
|
|
1527
|
-
const trimmed = raw.trim();
|
|
1528
|
-
if (!trimmed) continue;
|
|
1529
|
-
if (trimmed.startsWith("#")) continue;
|
|
1530
|
-
set.add(normalizePresence(trimmed));
|
|
1531
|
-
}
|
|
1532
|
-
return set;
|
|
1533
|
-
}
|
|
1534
|
-
function mergeGitignore(existing, canonical) {
|
|
1535
|
-
if (existing === null) {
|
|
1536
|
-
const body = [MANAGED_MARKER, ...canonical].join("\n") + "\n";
|
|
1537
|
-
return { content: body, added: [...canonical] };
|
|
1538
|
-
}
|
|
1539
|
-
const present = presentSet(existing);
|
|
1540
|
-
const added = [];
|
|
1541
|
-
for (const entry of canonical) {
|
|
1542
|
-
const norm = normalizePresence(entry);
|
|
1543
|
-
if (!present.has(norm)) {
|
|
1544
|
-
added.push(entry);
|
|
1545
|
-
present.add(norm);
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
if (added.length === 0) {
|
|
1549
|
-
return { content: existing, added: [] };
|
|
1550
|
-
}
|
|
1551
|
-
let base = existing;
|
|
1552
|
-
if (!base.endsWith("\n")) base += "\n";
|
|
1553
|
-
const block = ["", MANAGED_MARKER, ...added].join("\n") + "\n";
|
|
1554
|
-
return { content: base + block, added };
|
|
1555
|
-
}
|
|
1556
|
-
function findTrackedArtifacts(tracked, canonical) {
|
|
1557
|
-
const dirEntries = [];
|
|
1558
|
-
for (const raw of canonical) {
|
|
1559
|
-
const t = raw.trim();
|
|
1560
|
-
if (!t) continue;
|
|
1561
|
-
if (t.startsWith("!")) continue;
|
|
1562
|
-
if (/[*?[]/.test(t)) continue;
|
|
1563
|
-
const noLead = stripLeadingSlash(t);
|
|
1564
|
-
if (!noLead.endsWith("/")) continue;
|
|
1565
|
-
const name = stripTrailingSlash(noLead);
|
|
1566
|
-
if (!name) continue;
|
|
1567
|
-
dirEntries.push(name);
|
|
1568
|
-
}
|
|
1569
|
-
const matched = [];
|
|
1570
|
-
for (const path of tracked) {
|
|
1571
|
-
for (const dir of dirEntries) {
|
|
1572
|
-
if (path === dir || path.startsWith(dir + "/")) {
|
|
1573
|
-
matched.push(path);
|
|
1574
|
-
break;
|
|
1575
|
-
}
|
|
1576
|
-
}
|
|
1577
|
-
}
|
|
1578
|
-
return matched;
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
// src/util/git.ts
|
|
1582
|
-
import { execFile } from "child_process";
|
|
1583
|
-
import { promisify } from "util";
|
|
1584
|
-
var exec = promisify(execFile);
|
|
1585
|
-
async function git(cwd, args) {
|
|
1586
|
-
return exec("git", args, { cwd, env: process.env });
|
|
1587
|
-
}
|
|
1588
|
-
function branchName(recipe, when = /* @__PURE__ */ new Date()) {
|
|
1589
|
-
const compact = when.toISOString().replace(/[-:.]/g, "");
|
|
1590
|
-
return `maint/${recipe}-${compact}`;
|
|
1591
|
-
}
|
|
1592
|
-
async function currentBranch(cwd) {
|
|
1593
|
-
const { stdout } = await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
1594
|
-
return stdout.trim();
|
|
1595
|
-
}
|
|
1596
|
-
async function isWorkingTreeClean(cwd) {
|
|
1597
|
-
const { stdout } = await git(cwd, ["status", "--porcelain"]);
|
|
1598
|
-
return stdout.trim().length === 0;
|
|
1599
|
-
}
|
|
1600
|
-
async function createBranch(cwd, name) {
|
|
1601
|
-
await git(cwd, ["checkout", "-b", name]);
|
|
1602
|
-
}
|
|
1603
|
-
async function checkoutBranch(cwd, name) {
|
|
1604
|
-
await git(cwd, ["checkout", name]);
|
|
1605
|
-
}
|
|
1606
|
-
async function forceCheckoutBranch(cwd, name) {
|
|
1607
|
-
await git(cwd, ["checkout", "-f", name]);
|
|
1608
|
-
}
|
|
1609
|
-
async function deleteBranch(cwd, name) {
|
|
1610
|
-
await git(cwd, ["branch", "-D", name]);
|
|
1611
|
-
}
|
|
1612
|
-
async function stageAll(cwd) {
|
|
1613
|
-
await git(cwd, ["add", "-A"]);
|
|
1614
|
-
}
|
|
1615
|
-
async function listTrackedFiles(cwd) {
|
|
1616
|
-
const { stdout } = await git(cwd, ["ls-files"]);
|
|
1617
|
-
return stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
1618
|
-
}
|
|
1619
|
-
async function removeFromIndex(cwd, paths) {
|
|
1620
|
-
if (paths.length === 0) return;
|
|
1621
|
-
await git(cwd, ["rm", "-r", "--cached", "--", ...paths]);
|
|
1622
|
-
}
|
|
1623
|
-
async function commit(cwd, message) {
|
|
1624
|
-
await stageAll(cwd);
|
|
1625
|
-
const { stdout: status } = await git(cwd, ["status", "--porcelain"]);
|
|
1626
|
-
if (status.trim().length === 0) return null;
|
|
1627
|
-
await git(cwd, ["commit", "-m", message]);
|
|
1628
|
-
const { stdout: sha } = await git(cwd, ["rev-parse", "HEAD"]);
|
|
1629
|
-
return sha.trim();
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
// src/recipes/_with-recipe.ts
|
|
1633
|
-
async function withRecipe(body) {
|
|
1634
|
-
const label = siteLabel(body.site);
|
|
1635
|
-
if (body.checkTreeFirst && !await isWorkingTreeClean(body.site.path)) {
|
|
1636
|
-
throw new Error(`refusing to run: working tree is not clean at ${body.site.path}`);
|
|
1637
|
-
}
|
|
1638
|
-
const planned = await body.plan();
|
|
1639
|
-
if (planned.kind === "noop") {
|
|
1640
|
-
return {
|
|
1641
|
-
recipe: body.name,
|
|
1642
|
-
site: label,
|
|
1643
|
-
status: "noop",
|
|
1644
|
-
commits: [],
|
|
1645
|
-
...planned.notes ? { notes: planned.notes } : {}
|
|
1646
|
-
};
|
|
1647
|
-
}
|
|
1648
|
-
if (planned.kind === "failed") {
|
|
1649
|
-
return {
|
|
1650
|
-
recipe: body.name,
|
|
1651
|
-
site: label,
|
|
1652
|
-
status: "failed",
|
|
1653
|
-
commits: [],
|
|
1654
|
-
notes: planned.notes
|
|
1655
|
-
};
|
|
1656
|
-
}
|
|
1657
|
-
if (!body.checkTreeFirst && !await isWorkingTreeClean(body.site.path)) {
|
|
1658
|
-
throw new Error(`refusing to run: working tree is not clean at ${body.site.path}`);
|
|
1659
|
-
}
|
|
1660
|
-
let original = null;
|
|
1661
|
-
try {
|
|
1662
|
-
original = await currentBranch(body.site.path);
|
|
1663
|
-
} catch {
|
|
1664
|
-
original = null;
|
|
1665
|
-
}
|
|
1666
|
-
const branch = branchName(body.name);
|
|
1667
|
-
await createBranch(body.site.path, branch);
|
|
1668
|
-
const restoreOriginal = async () => {
|
|
1669
|
-
if (original === null || original === branch) return;
|
|
1670
|
-
try {
|
|
1671
|
-
await checkoutBranch(body.site.path, original);
|
|
1672
|
-
} catch (err) {
|
|
1673
|
-
console.warn(
|
|
1674
|
-
`warning: could not restore branch ${original} after ${body.name}: ${err instanceof Error ? err.message : String(err)}`
|
|
1675
|
-
);
|
|
1676
|
-
}
|
|
1677
|
-
};
|
|
1678
|
-
const restoreAfterFailure = async () => {
|
|
1679
|
-
if (original === null || original === branch) return;
|
|
1680
|
-
try {
|
|
1681
|
-
await forceCheckoutBranch(body.site.path, original);
|
|
1682
|
-
} catch (err) {
|
|
1683
|
-
console.warn(
|
|
1684
|
-
`warning: could not force-restore branch ${original} after failed ${body.name}: ${err instanceof Error ? err.message : String(err)}`
|
|
1685
|
-
);
|
|
1686
|
-
return;
|
|
1687
|
-
}
|
|
1688
|
-
try {
|
|
1689
|
-
await deleteBranch(body.site.path, branch);
|
|
1690
|
-
} catch (err) {
|
|
1691
|
-
console.warn(
|
|
1692
|
-
`warning: could not delete recipe branch ${branch} after failed ${body.name}: ${err instanceof Error ? err.message : String(err)}`
|
|
1693
|
-
);
|
|
1694
|
-
}
|
|
1695
|
-
};
|
|
1696
|
-
const shas = [];
|
|
1697
|
-
let result;
|
|
1698
|
-
try {
|
|
1699
|
-
result = await body.apply(planned.plan, {
|
|
1700
|
-
cwd: body.site.path,
|
|
1701
|
-
branch,
|
|
1702
|
-
commit: async (msg) => {
|
|
1703
|
-
const sha = await commit(body.site.path, msg);
|
|
1704
|
-
if (sha) shas.push(sha);
|
|
1705
|
-
return sha;
|
|
1706
|
-
}
|
|
1707
|
-
});
|
|
1708
|
-
} catch (err) {
|
|
1709
|
-
await restoreAfterFailure();
|
|
1710
|
-
throw err;
|
|
1711
|
-
}
|
|
1712
|
-
if (result.kind === "failed") {
|
|
1713
|
-
await restoreAfterFailure();
|
|
1714
|
-
return {
|
|
1715
|
-
recipe: body.name,
|
|
1716
|
-
site: label,
|
|
1717
|
-
status: "failed",
|
|
1718
|
-
commits: shas,
|
|
1719
|
-
notes: result.notes
|
|
1720
|
-
};
|
|
1721
|
-
}
|
|
1722
|
-
if (shas.length === 0) {
|
|
1723
|
-
await restoreOriginal();
|
|
1724
|
-
}
|
|
1725
|
-
const notes = result.notes ? `${result.notes}; branch: ${branch}` : `branch: ${branch}`;
|
|
1726
|
-
return {
|
|
1727
|
-
recipe: body.name,
|
|
1728
|
-
site: label,
|
|
1729
|
-
status: shas.length > 0 ? "applied" : "noop",
|
|
1730
|
-
commits: shas,
|
|
1731
|
-
notes
|
|
1732
|
-
};
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
// src/recipes/sync-configs.ts
|
|
1736
|
-
var GITIGNORE_CONFIG = "gitignore";
|
|
1737
|
-
var SVELTE_CONFIG = "svelte";
|
|
1738
|
-
var NETLIFY_CONFIG = "netlify";
|
|
1739
|
-
function isSvelteConfigCompliant(contents) {
|
|
1740
|
-
return contents.includes("createSvelteConfig") && contents.includes("@sveltejs/adapter-netlify");
|
|
1741
|
-
}
|
|
1742
|
-
var SECURITY_HEADER_RE = /Strict-Transport-Security|Content-Security-Policy|X-Frame-Options|X-Content-Type-Options|Referrer-Policy|Permissions-Policy|Cross-Origin-Opener-Policy/i;
|
|
1743
|
-
function isNetlifyConfigCompliant(contents) {
|
|
1744
|
-
return contents.includes("[[headers]]") && SECURITY_HEADER_RE.test(contents);
|
|
1745
|
-
}
|
|
1746
|
-
async function readMaybe(path) {
|
|
1747
|
-
try {
|
|
1748
|
-
return await readFile6(path, "utf-8");
|
|
1749
|
-
} catch {
|
|
1750
|
-
return null;
|
|
1751
|
-
}
|
|
1752
|
-
}
|
|
1753
|
-
async function planTemplateDiffs(cwd, templates) {
|
|
1754
|
-
const diffs = [];
|
|
1755
|
-
for (const t of templates) {
|
|
1756
|
-
const existing = await readMaybe(join7(cwd, t.path));
|
|
1757
|
-
if (existing === t.contents) continue;
|
|
1758
|
-
if (t.config === SVELTE_CONFIG && existing !== null && isSvelteConfigCompliant(existing)) {
|
|
1759
|
-
continue;
|
|
1760
|
-
}
|
|
1761
|
-
if (t.config === NETLIFY_CONFIG && existing !== null && isNetlifyConfigCompliant(existing)) {
|
|
1762
|
-
continue;
|
|
1763
|
-
}
|
|
1764
|
-
diffs.push(t);
|
|
1765
|
-
}
|
|
1766
|
-
return diffs;
|
|
1767
|
-
}
|
|
1768
|
-
async function planGitignore(cwd) {
|
|
1769
|
-
const existing = await readMaybe(join7(cwd, ".gitignore"));
|
|
1770
|
-
const merge = mergeGitignore(existing, CANONICAL_GITIGNORE_ENTRIES);
|
|
1771
|
-
const tracked = await listTrackedFiles(cwd);
|
|
1772
|
-
const toUntrack = findTrackedArtifacts(tracked, CANONICAL_GITIGNORE_ENTRIES);
|
|
1773
|
-
if (merge.added.length === 0 && toUntrack.length === 0) return { kind: "noop" };
|
|
1774
|
-
return { kind: "apply", content: merge.content, toUntrack, added: merge.added };
|
|
1775
|
-
}
|
|
1776
|
-
async function applyGitignore(cwd, plan) {
|
|
1777
|
-
await writeFile3(join7(cwd, ".gitignore"), plan.content, "utf-8");
|
|
1778
|
-
if (plan.toUntrack.length > 0) {
|
|
1779
|
-
await removeFromIndex(cwd, plan.toUntrack);
|
|
1780
|
-
}
|
|
1781
|
-
}
|
|
1782
|
-
async function syncConfigs(site, opts = {}) {
|
|
1783
|
-
const requested = opts.which ?? ALL_TEMPLATES.map((t) => t.config).concat(GITIGNORE_CONFIG);
|
|
1784
|
-
const templateNames = requested.filter((c) => c !== GITIGNORE_CONFIG);
|
|
1785
|
-
const templates = templatesByName(templateNames);
|
|
1786
|
-
const includeGitignore = requested.includes(GITIGNORE_CONFIG);
|
|
1787
|
-
return withRecipe({
|
|
1788
|
-
name: "sync-configs",
|
|
1789
|
-
site,
|
|
1790
|
-
plan: async () => {
|
|
1791
|
-
const templateDiffs = await planTemplateDiffs(site.path, templates);
|
|
1792
|
-
const gitignorePlan = includeGitignore ? await planGitignore(site.path) : { kind: "noop" };
|
|
1793
|
-
if (templateDiffs.length === 0 && gitignorePlan.kind === "noop") {
|
|
1794
|
-
return { kind: "noop", notes: "all targeted configs already match" };
|
|
1795
|
-
}
|
|
1796
|
-
return { kind: "apply", plan: { templateDiffs, gitignorePlan } };
|
|
1797
|
-
},
|
|
1798
|
-
apply: async ({ templateDiffs, gitignorePlan }, { commit: commit2 }) => {
|
|
1799
|
-
for (const t of templateDiffs) {
|
|
1800
|
-
const dest = join7(site.path, t.path);
|
|
1801
|
-
await mkdir(dirname(dest), { recursive: true });
|
|
1802
|
-
await writeFile3(dest, t.contents, "utf-8");
|
|
1803
|
-
await commit2(`chore: sync ${t.config} config from @reddoorla/maintenance`);
|
|
1804
|
-
}
|
|
1805
|
-
if (gitignorePlan.kind === "apply") {
|
|
1806
|
-
await applyGitignore(site.path, gitignorePlan);
|
|
1807
|
-
await commit2(`chore: sync gitignore from @reddoorla/maintenance`);
|
|
1808
|
-
}
|
|
1809
|
-
return { kind: "ok" };
|
|
1810
|
-
}
|
|
1811
|
-
});
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
// src/recipes/bump-deps.ts
|
|
1815
|
-
import { stat as stat2 } from "fs/promises";
|
|
1816
|
-
import { join as join8 } from "path";
|
|
1817
|
-
async function exists2(path) {
|
|
1818
|
-
try {
|
|
1819
|
-
await stat2(path);
|
|
1820
|
-
return true;
|
|
1821
|
-
} catch {
|
|
1822
|
-
return false;
|
|
1823
|
-
}
|
|
1824
|
-
}
|
|
1825
|
-
function outdatedFlagsForGroup(group) {
|
|
1826
|
-
if (group === "major") return ["--latest"];
|
|
1827
|
-
if (group === "minor") return [];
|
|
1828
|
-
return ["--depth", "0"];
|
|
1829
|
-
}
|
|
1830
|
-
function upFlagsForGroup(group) {
|
|
1831
|
-
if (group === "major") return ["--latest"];
|
|
1832
|
-
return [];
|
|
1833
|
-
}
|
|
1834
|
-
async function bumpDeps(site, opts = {}) {
|
|
1835
|
-
const group = opts.group ?? "minor";
|
|
1836
|
-
const spawn2 = opts.spawn ?? defaultSpawn;
|
|
1837
|
-
return withRecipe({
|
|
1838
|
-
name: "bump-deps",
|
|
1839
|
-
site,
|
|
1840
|
-
// pnpm install (in plan) mutates the lockfile, so the clean-tree check
|
|
1841
|
-
// MUST happen first — otherwise a desynced-lockfile resync would silently
|
|
1842
|
-
// land on top of whatever else was in the tree.
|
|
1843
|
-
checkTreeFirst: true,
|
|
1844
|
-
plan: async () => {
|
|
1845
|
-
const hasPnpmLock = await exists2(join8(site.path, "pnpm-lock.yaml"));
|
|
1846
|
-
if (!hasPnpmLock) {
|
|
1847
|
-
const hasNpmLock = await exists2(join8(site.path, "package-lock.json"));
|
|
1848
|
-
const hasYarnLock = await exists2(join8(site.path, "yarn.lock"));
|
|
1849
|
-
if (hasNpmLock || hasYarnLock) {
|
|
1850
|
-
const competing = hasNpmLock ? "package-lock.json" : "yarn.lock";
|
|
1851
|
-
return {
|
|
1852
|
-
kind: "failed",
|
|
1853
|
-
notes: `site has ${competing} but no pnpm-lock.yaml \u2014 run convert-to-pnpm first`
|
|
1854
|
-
};
|
|
1855
|
-
}
|
|
1856
|
-
}
|
|
1857
|
-
await spawn2("pnpm", ["install"], { cwd: site.path, streaming: true });
|
|
1858
|
-
const outdated = await spawn2(
|
|
1859
|
-
"pnpm",
|
|
1860
|
-
["outdated", "--json", ...outdatedFlagsForGroup(group)],
|
|
1861
|
-
{ cwd: site.path }
|
|
1862
|
-
);
|
|
1863
|
-
let parsed;
|
|
1864
|
-
try {
|
|
1865
|
-
parsed = JSON.parse(outdated.stdout || "{}");
|
|
1866
|
-
} catch {
|
|
1867
|
-
parsed = {};
|
|
1868
|
-
}
|
|
1869
|
-
if (Object.keys(parsed).length === 0) {
|
|
1870
|
-
return { kind: "noop", notes: `pnpm outdated reported nothing for group=${group}` };
|
|
1871
|
-
}
|
|
1872
|
-
return { kind: "apply", plan: { group } };
|
|
1873
|
-
},
|
|
1874
|
-
apply: async ({ group: g }, { commit: commit2, cwd }) => {
|
|
1875
|
-
await spawn2("pnpm", ["up", ...upFlagsForGroup(g)], { cwd, streaming: true });
|
|
1876
|
-
await commit2(`chore(deps): bump dependencies (${g})`);
|
|
1877
|
-
return { kind: "ok" };
|
|
1878
|
-
}
|
|
1879
|
-
});
|
|
1880
|
-
}
|
|
1881
|
-
|
|
1882
|
-
// src/recipes/svelte-5/index.ts
|
|
1883
|
-
import { join as join14 } from "path";
|
|
1884
|
-
|
|
1885
|
-
// src/util/pkg.ts
|
|
1886
|
-
import { readFile as readFile7, writeFile as writeFile4 } from "fs/promises";
|
|
1887
|
-
async function readPackageJson(path) {
|
|
1888
|
-
const raw = await readFile7(path, "utf-8");
|
|
1889
|
-
return JSON.parse(raw);
|
|
1890
|
-
}
|
|
1891
|
-
function detectIndentFromContent(raw) {
|
|
1892
|
-
const match = raw.match(/\n([ \t]+)"/);
|
|
1893
|
-
return match ? match[1] ?? " " : " ";
|
|
1894
|
-
}
|
|
1895
|
-
async function writePackageJson(path, pkg) {
|
|
1896
|
-
let indent = " ";
|
|
1897
|
-
try {
|
|
1898
|
-
const existing = await readFile7(path, "utf-8");
|
|
1899
|
-
indent = detectIndentFromContent(existing);
|
|
1900
|
-
} catch {
|
|
1901
|
-
}
|
|
1902
|
-
const content = JSON.stringify(pkg, null, indent) + "\n";
|
|
1903
|
-
await writeFile4(path, content, "utf-8");
|
|
1904
|
-
}
|
|
1905
|
-
function bumpDep(pkg, name, version, opts = {}) {
|
|
1906
|
-
const mode = opts.mode ?? "ensure";
|
|
1907
|
-
const next = {
|
|
1908
|
-
...pkg
|
|
1909
|
-
};
|
|
1910
|
-
if (pkg.dependencies) {
|
|
1911
|
-
next.dependencies = { ...pkg.dependencies };
|
|
1912
|
-
}
|
|
1913
|
-
if (pkg.devDependencies) {
|
|
1914
|
-
next.devDependencies = { ...pkg.devDependencies };
|
|
1915
|
-
}
|
|
1916
|
-
if (next.dependencies && name in next.dependencies) {
|
|
1917
|
-
if (next.dependencies[name] === version) return pkg;
|
|
1918
|
-
next.dependencies[name] = version;
|
|
1919
|
-
return next;
|
|
1920
|
-
}
|
|
1921
|
-
if (next.devDependencies && name in next.devDependencies) {
|
|
1922
|
-
if (next.devDependencies[name] === version) return pkg;
|
|
1923
|
-
next.devDependencies[name] = version;
|
|
1924
|
-
return next;
|
|
1925
|
-
}
|
|
1926
|
-
if (mode === "bump-only") return pkg;
|
|
1927
|
-
next.devDependencies = { ...next.devDependencies ?? {}, [name]: version };
|
|
1928
|
-
return next;
|
|
1929
|
-
}
|
|
1930
|
-
|
|
1931
|
-
// src/recipes/svelte-5/step-bump-versions.ts
|
|
1932
|
-
import { join as join9 } from "path";
|
|
1933
|
-
var SVELTE_5_VERSIONS = {
|
|
1934
|
-
svelte: "^5.55.5",
|
|
1935
|
-
"@sveltejs/kit": "^2.59.0",
|
|
1936
|
-
"@sveltejs/vite-plugin-svelte": "^7.0.0",
|
|
1937
|
-
"@sveltejs/adapter-netlify": "^6.0.4",
|
|
1938
|
-
"@sveltejs/adapter-auto": "^7.0.0",
|
|
1939
|
-
vite: "^8.0.10",
|
|
1940
|
-
"svelte-check": "^4.4.7",
|
|
1941
|
-
typescript: "^6.0.3",
|
|
1942
|
-
"typescript-svelte-plugin": "^0.3.52"
|
|
1943
|
-
};
|
|
1944
|
-
async function bumpToSvelte5Versions(cwd) {
|
|
1945
|
-
const pkgPath = join9(cwd, "package.json");
|
|
1946
|
-
const pkg = await readPackageJson(pkgPath);
|
|
1947
|
-
let next = pkg;
|
|
1948
|
-
for (const [name, version] of Object.entries(SVELTE_5_VERSIONS)) {
|
|
1949
|
-
next = bumpDep(next, name, version, { mode: "bump-only" });
|
|
1950
|
-
}
|
|
1951
|
-
if (next === pkg) return false;
|
|
1952
|
-
await writePackageJson(pkgPath, next);
|
|
1953
|
-
return true;
|
|
1954
|
-
}
|
|
1955
|
-
|
|
1956
|
-
// src/recipes/svelte-5/step-svelte-config.ts
|
|
1957
|
-
import { readFile as readFile8, writeFile as writeFile5 } from "fs/promises";
|
|
1958
|
-
import { join as join10 } from "path";
|
|
1959
|
-
var VITE_PLUGIN_PKG = "@sveltejs/vite-plugin-svelte";
|
|
1960
|
-
var IMPORT_FROM_VITE_PLUGIN = new RegExp(
|
|
1961
|
-
String.raw`^import\s+\{\s*([^}]+?)\s*\}\s+from\s+["']` + VITE_PLUGIN_PKG.replace(/[/]/g, "\\/") + String.raw`["'];?[ \t]*\n`,
|
|
1962
|
-
"m"
|
|
1963
|
-
);
|
|
1964
|
-
function dropVitePreprocessImport(source) {
|
|
1965
|
-
return source.replace(IMPORT_FROM_VITE_PLUGIN, (full, names) => {
|
|
1966
|
-
const remaining = names.split(",").map((n) => n.trim()).filter((n) => n.length > 0 && n !== "vitePreprocess");
|
|
1967
|
-
if (remaining.length === 0) return "";
|
|
1968
|
-
return `import { ${remaining.join(", ")} } from "${VITE_PLUGIN_PKG}";
|
|
1969
|
-
`;
|
|
1970
|
-
});
|
|
1971
|
-
}
|
|
1972
|
-
function findMatchingParen(source, openIdx) {
|
|
1973
|
-
if (source[openIdx] !== "(") return -1;
|
|
1974
|
-
let depth = 0;
|
|
1975
|
-
for (let i = openIdx; i < source.length; i++) {
|
|
1976
|
-
const ch = source[i];
|
|
1977
|
-
if (ch === "(") depth++;
|
|
1978
|
-
else if (ch === ")") {
|
|
1979
|
-
depth--;
|
|
1980
|
-
if (depth === 0) return i;
|
|
1981
|
-
}
|
|
1982
|
-
}
|
|
1983
|
-
return -1;
|
|
1984
|
-
}
|
|
1985
|
-
function dropPreprocessKey(source) {
|
|
1986
|
-
const startRe = /^(\s*)preprocess:\s*vitePreprocess\(/m;
|
|
1987
|
-
const m = startRe.exec(source);
|
|
1988
|
-
if (!m) return source;
|
|
1989
|
-
const indent = m[1] ?? "";
|
|
1990
|
-
const parenOpenAbs = m.index + m[0].length - 1;
|
|
1991
|
-
const parenCloseAbs = findMatchingParen(source, parenOpenAbs);
|
|
1992
|
-
if (parenCloseAbs < 0) return source;
|
|
1993
|
-
let tailIdx = parenCloseAbs + 1;
|
|
1994
|
-
while (tailIdx < source.length && /[ \t,]/.test(source[tailIdx] ?? "")) tailIdx++;
|
|
1995
|
-
if (source[tailIdx] === "\n") tailIdx++;
|
|
1996
|
-
return source.slice(0, m.index) + source.slice(tailIdx).replace(new RegExp(`^${indent}\\n`), "");
|
|
1997
|
-
}
|
|
1998
|
-
async function migrateSvelteConfig(cwd) {
|
|
1999
|
-
const path = join10(cwd, "svelte.config.js");
|
|
2000
|
-
let src;
|
|
2001
|
-
try {
|
|
2002
|
-
src = await readFile8(path, "utf-8");
|
|
2003
|
-
} catch {
|
|
2004
|
-
return false;
|
|
2005
|
-
}
|
|
2006
|
-
let next = src;
|
|
2007
|
-
next = dropPreprocessKey(next);
|
|
2008
|
-
next = dropVitePreprocessImport(next);
|
|
2009
|
-
if (next === src) return false;
|
|
2010
|
-
await writeFile5(path, next, "utf-8");
|
|
2011
|
-
return true;
|
|
2012
|
-
}
|
|
2013
|
-
|
|
2014
|
-
// src/recipes/svelte-5/step-svelte-migrate.ts
|
|
2015
|
-
async function runSvelteMigrate(cwd, spawn2 = defaultSpawn) {
|
|
2016
|
-
try {
|
|
2017
|
-
const { code, stderr } = await spawn2(
|
|
2018
|
-
"npx",
|
|
2019
|
-
["--yes", "svelte-migrate", "svelte-5", "--no-install"],
|
|
2020
|
-
{ cwd, timeoutMs: 5 * 6e4 }
|
|
2021
|
-
);
|
|
2022
|
-
if (code !== 0) {
|
|
2023
|
-
return { ran: false, stderr };
|
|
2024
|
-
}
|
|
2025
|
-
return { ran: true, stderr };
|
|
2026
|
-
} catch (err) {
|
|
2027
|
-
const e = err;
|
|
2028
|
-
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
|
|
2029
|
-
return { ran: false, stderr: "npx unavailable" };
|
|
2030
|
-
}
|
|
2031
|
-
throw err;
|
|
2032
|
-
}
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2035
|
-
// src/recipes/svelte-5/step-tailwind-upgrade.ts
|
|
2036
|
-
import { join as join11 } from "path";
|
|
2037
|
-
async function upgradeTailwind(cwd, spawn2 = defaultSpawn) {
|
|
2038
|
-
const pkg = await readPackageJson(join11(cwd, "package.json"));
|
|
2039
|
-
const tailwindVersion = pkg.devDependencies?.tailwindcss ?? pkg.dependencies?.tailwindcss;
|
|
2040
|
-
if (!tailwindVersion) return { ran: false, reason: "tailwindcss not installed" };
|
|
2041
|
-
if (/^\^?4\./.test(tailwindVersion)) return { ran: false, reason: "already on tailwind 4.x" };
|
|
2042
|
-
try {
|
|
2043
|
-
const { code, stderr } = await spawn2("npx", ["--yes", "@tailwindcss/upgrade", "--force"], {
|
|
2044
|
-
cwd,
|
|
2045
|
-
timeoutMs: 5 * 6e4
|
|
2046
|
-
});
|
|
2047
|
-
if (code !== 0) return { ran: false, reason: stderr.slice(0, 200) };
|
|
2048
|
-
return { ran: true };
|
|
2049
|
-
} catch (err) {
|
|
2050
|
-
const e = err;
|
|
2051
|
-
if (e.code === "ENOENT" || /ENOENT/.test(String(err))) {
|
|
2052
|
-
return { ran: false, reason: "npx unavailable" };
|
|
2053
|
-
}
|
|
2054
|
-
throw err;
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2057
|
-
|
|
2058
|
-
// src/recipes/svelte-5/step-gotchas.ts
|
|
2059
|
-
import { readFile as readFile9, writeFile as writeFile6 } from "fs/promises";
|
|
2060
|
-
import { join as join12 } from "path";
|
|
2061
|
-
import { glob as glob2 } from "tinyglobby";
|
|
2062
|
-
|
|
2063
|
-
// src/recipes/svelte-5/codemods/on-event-to-handler.ts
|
|
2064
|
-
var SCRIPT_BLOCK = /<script\b[^>]*>[\s\S]*?<\/script>/g;
|
|
2065
|
-
var SIMPLE_ON_EVENT = /\bon:([a-z]+)(?=\s*=)/g;
|
|
2066
|
-
var MODIFIER_EVENT = /\bon:[a-z]+\|[a-zA-Z]+(?=\s*=)/g;
|
|
2067
|
-
function flagEventModifiers(source) {
|
|
2068
|
-
const insertions = [];
|
|
2069
|
-
let m;
|
|
2070
|
-
MODIFIER_EVENT.lastIndex = 0;
|
|
2071
|
-
while ((m = MODIFIER_EVENT.exec(source)) !== null) {
|
|
2072
|
-
const tagStart = source.lastIndexOf("<", m.index);
|
|
2073
|
-
if (tagStart === -1) continue;
|
|
2074
|
-
const prevLineEnd = tagStart - 1;
|
|
2075
|
-
if (prevLineEnd >= 0) {
|
|
2076
|
-
const prevLineStart = source.lastIndexOf("\n", prevLineEnd - 1) + 1;
|
|
2077
|
-
const prevLine = source.slice(prevLineStart, prevLineEnd + 1);
|
|
2078
|
-
if (/<!--\s*@migration-task/.test(prevLine)) continue;
|
|
2079
|
-
}
|
|
2080
|
-
const lineStart = source.lastIndexOf("\n", tagStart - 1) + 1;
|
|
2081
|
-
const indent = source.slice(lineStart, tagStart);
|
|
2082
|
-
const safeIndent = /^[ \t]*$/.test(indent) ? indent : "";
|
|
2083
|
-
insertions.push({ tagStart, indent: safeIndent, modifier: m[0] });
|
|
2084
|
-
}
|
|
2085
|
-
let out = source;
|
|
2086
|
-
for (let i = insertions.length - 1; i >= 0; i--) {
|
|
2087
|
-
const { tagStart, indent, modifier } = insertions[i];
|
|
2088
|
-
const comment = `<!-- @migration-task: Svelte 5 removed event modifier syntax (\`${modifier}\`). Rewrite inline, e.g. onclick={(e) => { e.preventDefault(); ... }}. -->
|
|
2089
|
-
${indent}`;
|
|
2090
|
-
out = out.slice(0, tagStart) + comment + out.slice(tagStart);
|
|
2091
|
-
}
|
|
2092
|
-
return out;
|
|
2093
|
-
}
|
|
2094
|
-
function onEventToHandler(source) {
|
|
2095
|
-
const masked = [];
|
|
2096
|
-
const placeholder = (i) => ` SCRIPT_${i} `;
|
|
2097
|
-
const intermediate = source.replace(SCRIPT_BLOCK, (match) => {
|
|
2098
|
-
masked.push(match);
|
|
2099
|
-
return placeholder(masked.length - 1);
|
|
2100
|
-
});
|
|
2101
|
-
let processed = intermediate.replace(SIMPLE_ON_EVENT, (_full, name) => `on${name}`);
|
|
2102
|
-
processed = flagEventModifiers(processed);
|
|
2103
|
-
let out = processed;
|
|
2104
|
-
masked.forEach((blk, i) => {
|
|
2105
|
-
out = out.replace(placeholder(i), blk);
|
|
2106
|
-
});
|
|
2107
|
-
return out;
|
|
2108
|
-
}
|
|
2109
|
-
|
|
2110
|
-
// src/recipes/svelte-5/codemods/dollar-props.ts
|
|
2111
|
-
var SCRIPT_BLOCK2 = /<script\b([^>]*)>([\s\S]*?)<\/script>/;
|
|
2112
|
-
var EXPORT_LET = /^\s*export\s+let\s+(\w+)\s*(?::\s*([^=;\n]+))?\s*(?:=\s*([^;\n]+))?;?\s*$/gm;
|
|
2113
|
-
function transformScript(scriptBody, isTs) {
|
|
2114
|
-
const props = [];
|
|
2115
|
-
const cleaned = scriptBody.replace(
|
|
2116
|
-
EXPORT_LET,
|
|
2117
|
-
(_full, name, type, defaultExpr) => {
|
|
2118
|
-
props.push({
|
|
2119
|
-
name,
|
|
2120
|
-
type: type?.trim(),
|
|
2121
|
-
defaultExpr: defaultExpr?.trim()
|
|
2122
|
-
});
|
|
2123
|
-
return "";
|
|
2124
|
-
}
|
|
2125
|
-
);
|
|
2126
|
-
if (props.length === 0) return { body: scriptBody, changed: false };
|
|
2127
|
-
const destructured = props.map((p) => p.defaultExpr ? `${p.name} = ${p.defaultExpr}` : p.name).join(", ");
|
|
2128
|
-
let decl;
|
|
2129
|
-
if (isTs) {
|
|
2130
|
-
const typeSig = props.map((p) => {
|
|
2131
|
-
const optional = p.defaultExpr ? "?" : "";
|
|
2132
|
-
return `${p.name}${optional}: ${p.type ?? "unknown"}`;
|
|
2133
|
-
}).join("; ");
|
|
2134
|
-
decl = ` let { ${destructured} }: { ${typeSig} } = $props();`;
|
|
2135
|
-
} else {
|
|
2136
|
-
decl = ` let { ${destructured} } = $props();`;
|
|
2137
|
-
}
|
|
2138
|
-
const next = cleaned.replace(/^(\s*)/, (m) => `${m}${decl}
|
|
2139
|
-
`);
|
|
2140
|
-
return { body: next, changed: true };
|
|
2141
|
-
}
|
|
2142
|
-
function exportLetToProps(source) {
|
|
2143
|
-
const match = source.match(SCRIPT_BLOCK2);
|
|
2144
|
-
if (!match) return source;
|
|
2145
|
-
const attrs = match[1] ?? "";
|
|
2146
|
-
const inner = match[2] ?? "";
|
|
2147
|
-
const isTs = /\blang=["']ts["']/.test(attrs);
|
|
2148
|
-
const { body, changed } = transformScript(inner, isTs);
|
|
2149
|
-
if (!changed) return source;
|
|
2150
|
-
return source.replace(SCRIPT_BLOCK2, (full) => full.replace(inner, body));
|
|
2151
|
-
}
|
|
2152
|
-
|
|
2153
|
-
// src/util/svelte-source.ts
|
|
2154
|
-
function findStringEnd(source, openIdx) {
|
|
2155
|
-
const quote = source[openIdx];
|
|
2156
|
-
let i = openIdx + 1;
|
|
2157
|
-
while (i < source.length) {
|
|
2158
|
-
const ch = source[i];
|
|
2159
|
-
if (ch === "\\") {
|
|
2160
|
-
i += 2;
|
|
2161
|
-
continue;
|
|
2162
|
-
}
|
|
2163
|
-
if (ch === quote) return i;
|
|
2164
|
-
i++;
|
|
2165
|
-
}
|
|
2166
|
-
return -1;
|
|
2167
|
-
}
|
|
2168
|
-
|
|
2169
|
-
// src/recipes/svelte-5/codemods/dollar-restprops.ts
|
|
2170
|
-
function removeInterfaceBlock(source) {
|
|
2171
|
-
const re = /^\s*interface\s+\$\$Props\s*\{/m;
|
|
2172
|
-
let out = source;
|
|
2173
|
-
while (true) {
|
|
2174
|
-
const match = re.exec(out);
|
|
2175
|
-
if (!match) return out;
|
|
2176
|
-
const openBraceIdx = match.index + match[0].length - 1;
|
|
2177
|
-
let depth = 1;
|
|
2178
|
-
let i = openBraceIdx + 1;
|
|
2179
|
-
while (i < out.length && depth > 0) {
|
|
2180
|
-
const ch = out[i];
|
|
2181
|
-
if (ch === "{") depth++;
|
|
2182
|
-
else if (ch === "}") depth--;
|
|
2183
|
-
i++;
|
|
2184
|
-
}
|
|
2185
|
-
if (depth !== 0) return out;
|
|
2186
|
-
let endIdx = i;
|
|
2187
|
-
while (endIdx < out.length && /[ \t]/.test(out[endIdx] ?? "")) endIdx++;
|
|
2188
|
-
if (out[endIdx] === "\n") endIdx++;
|
|
2189
|
-
out = out.slice(0, match.index) + out.slice(endIdx);
|
|
2190
|
-
}
|
|
2191
|
-
}
|
|
2192
|
-
function maskStringLiterals(source) {
|
|
2193
|
-
const strings = [];
|
|
2194
|
-
let out = "";
|
|
2195
|
-
let i = 0;
|
|
2196
|
-
while (i < source.length) {
|
|
2197
|
-
const ch = source[i];
|
|
2198
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
2199
|
-
const closeIdx = findStringEnd(source, i);
|
|
2200
|
-
if (closeIdx === -1) {
|
|
2201
|
-
out += source.slice(i);
|
|
2202
|
-
break;
|
|
2203
|
-
}
|
|
2204
|
-
const literal = source.slice(i, closeIdx + 1);
|
|
2205
|
-
out += `__RDMNT_STR_${strings.length}__`;
|
|
2206
|
-
strings.push(literal);
|
|
2207
|
-
i = closeIdx + 1;
|
|
2208
|
-
} else {
|
|
2209
|
-
out += ch;
|
|
2210
|
-
i++;
|
|
2211
|
-
}
|
|
2212
|
-
}
|
|
2213
|
-
return {
|
|
2214
|
-
masked: out,
|
|
2215
|
-
restore: (s) => s.replace(/__RDMNT_STR_(\d+)__/g, (_full, idx) => strings[Number(idx)] ?? "")
|
|
2216
|
-
};
|
|
2217
|
-
}
|
|
2218
|
-
var PROPS_DECL = /let\s*\{([^}]*)\}\s*(?::\s*\{([^}]*)\})?\s*=\s*\$props\(\)\s*;?/;
|
|
2219
|
-
function injectRestIntoProps(scriptBody) {
|
|
2220
|
-
const match = scriptBody.match(PROPS_DECL);
|
|
2221
|
-
if (!match) return scriptBody;
|
|
2222
|
-
const destructured = match[1] ?? "";
|
|
2223
|
-
if (/\.\.\.\s*\w+/.test(destructured)) return scriptBody;
|
|
2224
|
-
const trimmed = destructured.trim().replace(/,\s*$/, "");
|
|
2225
|
-
const newDestructured = trimmed === "" ? " ...rest " : ` ${trimmed}, ...rest `;
|
|
2226
|
-
let replacement;
|
|
2227
|
-
if (match[2] !== void 0) {
|
|
2228
|
-
const typeBody = match[2];
|
|
2229
|
-
const hasIndexSig = /\[\s*key\s*:\s*string\s*\]\s*:/.test(typeBody);
|
|
2230
|
-
const newTypeBody = hasIndexSig ? typeBody : `${typeBody.trimEnd().replace(/;?\s*$/, "")}; [key: string]: unknown `;
|
|
2231
|
-
replacement = `let {${newDestructured}}: {${newTypeBody}} = $props();`;
|
|
2232
|
-
} else {
|
|
2233
|
-
replacement = `let {${newDestructured}} = $props();`;
|
|
2234
|
-
}
|
|
2235
|
-
return scriptBody.replace(PROPS_DECL, replacement);
|
|
2236
|
-
}
|
|
2237
|
-
var SCRIPT_BLOCK3 = /<script\b([^>]*)>([\s\S]*?)<\/script>/;
|
|
2238
|
-
var HAS_PROPS_CALL = /\$props\(\s*\)/;
|
|
2239
|
-
function removeDollarRestProps(source) {
|
|
2240
|
-
const next = removeInterfaceBlock(source);
|
|
2241
|
-
const scriptMatch = next.match(SCRIPT_BLOCK3);
|
|
2242
|
-
if (!scriptMatch) return next;
|
|
2243
|
-
if (!HAS_PROPS_CALL.test(scriptMatch[2] ?? "")) {
|
|
2244
|
-
return next;
|
|
2245
|
-
}
|
|
2246
|
-
const scriptInner = scriptMatch[2] ?? "";
|
|
2247
|
-
const { masked, restore } = maskStringLiterals(scriptInner);
|
|
2248
|
-
let processed = injectRestIntoProps(masked);
|
|
2249
|
-
processed = processed.replace(/\$\$restProps/g, "rest");
|
|
2250
|
-
const restoredInner = restore(processed);
|
|
2251
|
-
const newScriptBlock = scriptMatch[0].replace(scriptInner, () => restoredInner);
|
|
2252
|
-
const before = next.slice(0, scriptMatch.index);
|
|
2253
|
-
const after = next.slice(scriptMatch.index + scriptMatch[0].length);
|
|
2254
|
-
return before.replace(/\$\$restProps/g, "rest") + newScriptBlock + after.replace(/\$\$restProps/g, "rest");
|
|
2255
|
-
}
|
|
2256
|
-
|
|
2257
|
-
// src/recipes/svelte-5/codemods/state-effect-sync.ts
|
|
2258
|
-
var PATTERN = /let\s+(\w+)\s*=\s*\$state\(\s*([^)]+?)\s*\)\s*;[ \t\r\n]*\$effect\(\s*\(\s*\)\s*=>\s*\{\s*\w+\s*;\s*\1\s*=\s*([^;}]+?)\s*;?\s*\}\s*\)\s*;?/g;
|
|
2259
|
-
function stateEffectSyncToDerived(source) {
|
|
2260
|
-
return source.replace(PATTERN, (full, name, initExpr, effectExpr) => {
|
|
2261
|
-
if (initExpr.trim() !== effectExpr.trim()) return full;
|
|
2262
|
-
return `let ${name} = $derived(${initExpr.trim()});`;
|
|
2263
|
-
});
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
// src/recipes/svelte-5/codemods/dollar-props-class.ts
|
|
2267
|
-
var PROPS_DESTRUCTURE = /let\s*\{([\s\S]*?)\}(\s*:\s*\{([\s\S]*?)\})?\s*=\s*\$props\(\)/;
|
|
2268
|
-
var HAS_DOLLAR_PROPS_CLASS = /\$\$props\.class\b/;
|
|
2269
|
-
var DOLLAR_PROPS_CLASS_GLOBAL = /\$\$props\.class\b/g;
|
|
2270
|
-
var DOLLAR_PROPS_ANY = /\$\$props\b/;
|
|
2271
|
-
var SCRIPT_BLOCK4 = /<script\b[^>]*>[\s\S]*?<\/script>/g;
|
|
2272
|
-
var MIGRATION_TASK = /^<!--\s*@migration-task[\s\S]*?-->\s*\n?/gm;
|
|
2273
|
-
var IDENT = "className";
|
|
2274
|
-
function maskScripts(source) {
|
|
2275
|
-
const blocks = [];
|
|
2276
|
-
const masked = source.replace(SCRIPT_BLOCK4, (m) => {
|
|
2277
|
-
blocks.push(m);
|
|
2278
|
-
return `__SCRIPT_${blocks.length - 1}__`;
|
|
2279
|
-
});
|
|
2280
|
-
return { masked, blocks };
|
|
2281
|
-
}
|
|
2282
|
-
function restoreScripts(masked, blocks) {
|
|
2283
|
-
let out = masked;
|
|
2284
|
-
blocks.forEach((blk, i) => {
|
|
2285
|
-
out = out.replace(`__SCRIPT_${i}__`, blk);
|
|
2286
|
-
});
|
|
2287
|
-
return out;
|
|
2288
|
-
}
|
|
2289
|
-
function dollarPropsClass(source) {
|
|
2290
|
-
const { masked } = maskScripts(source);
|
|
2291
|
-
if (!HAS_DOLLAR_PROPS_CLASS.test(masked)) return source;
|
|
2292
|
-
if (!PROPS_DESTRUCTURE.test(source)) return source;
|
|
2293
|
-
let updated = source.replace(PROPS_DESTRUCTURE, (full, body, typeAnno, typeBody) => {
|
|
2294
|
-
if (/\bclass\s*:/.test(body)) return full;
|
|
2295
|
-
const cleanBody = body.trim().replace(/,\s*$/, "").trim();
|
|
2296
|
-
const newBody = cleanBody ? `${cleanBody}, class: ${IDENT} = ""` : `class: ${IDENT} = ""`;
|
|
2297
|
-
if (typeAnno) {
|
|
2298
|
-
const cleanType = (typeBody ?? "").trim().replace(/;\s*$/, "").trim();
|
|
2299
|
-
const newType = cleanType ? `${cleanType}; class?: string` : `class?: string`;
|
|
2300
|
-
return `let { ${newBody} }: { ${newType} } = $props()`;
|
|
2301
|
-
}
|
|
2302
|
-
return `let { ${newBody} } = $props()`;
|
|
2303
|
-
});
|
|
2304
|
-
const reMasked = maskScripts(updated);
|
|
2305
|
-
const templateRewritten = reMasked.masked.replace(DOLLAR_PROPS_CLASS_GLOBAL, IDENT);
|
|
2306
|
-
updated = restoreScripts(templateRewritten, reMasked.blocks);
|
|
2307
|
-
const stripped = updated.replace(MIGRATION_TASK, "");
|
|
2308
|
-
if (!DOLLAR_PROPS_ANY.test(stripped)) {
|
|
2309
|
-
updated = stripped;
|
|
2310
|
-
}
|
|
2311
|
-
return updated;
|
|
2312
|
-
}
|
|
2313
|
-
|
|
2314
|
-
// src/recipes/svelte-5/codemods/legacy-reactive.ts
|
|
2315
|
-
var SCRIPT_BLOCK5 = /<script\b([^>]*)>([\s\S]*?)<\/script>/g;
|
|
2316
|
-
var SIMPLE_REACTIVE = /^([ \t]*)\$:\s*(\w+)\s*=\s*([^;\n]+);?[ \t]*$/gm;
|
|
2317
|
-
var BLOCK_REACTIVE_HEAD = /(^|\n)([ \t]*)\$:\s*\{/g;
|
|
2318
|
-
function findMatchingClose(source, openIdx) {
|
|
2319
|
-
let depth = 0;
|
|
2320
|
-
let i = openIdx;
|
|
2321
|
-
while (i < source.length) {
|
|
2322
|
-
const ch = source[i];
|
|
2323
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
2324
|
-
const closeStr = findStringEnd(source, i);
|
|
2325
|
-
if (closeStr === -1) return -1;
|
|
2326
|
-
i = closeStr + 1;
|
|
2327
|
-
continue;
|
|
2328
|
-
}
|
|
2329
|
-
if (ch === "/") {
|
|
2330
|
-
const next = source[i + 1];
|
|
2331
|
-
if (next === "/") {
|
|
2332
|
-
const eol = source.indexOf("\n", i + 2);
|
|
2333
|
-
i = eol === -1 ? source.length : eol;
|
|
2334
|
-
continue;
|
|
2335
|
-
}
|
|
2336
|
-
if (next === "*") {
|
|
2337
|
-
const end = source.indexOf("*/", i + 2);
|
|
2338
|
-
if (end === -1) return -1;
|
|
2339
|
-
i = end + 2;
|
|
2340
|
-
continue;
|
|
2341
|
-
}
|
|
2342
|
-
}
|
|
2343
|
-
if (ch === "{") depth++;
|
|
2344
|
-
else if (ch === "}") {
|
|
2345
|
-
depth--;
|
|
2346
|
-
if (depth === 0) return i;
|
|
2347
|
-
}
|
|
2348
|
-
i++;
|
|
2349
|
-
}
|
|
2350
|
-
return -1;
|
|
2351
|
-
}
|
|
2352
|
-
var MIGRATION_MARKER = "// @migration-task: $effect won't trigger UI updates on plain `let` bindings \u2014 refine mutated locals to $state or split into per-variable $derived.";
|
|
2353
|
-
function transformBlocks(body) {
|
|
2354
|
-
const out = [];
|
|
2355
|
-
let last = 0;
|
|
2356
|
-
BLOCK_REACTIVE_HEAD.lastIndex = 0;
|
|
2357
|
-
let m;
|
|
2358
|
-
while ((m = BLOCK_REACTIVE_HEAD.exec(body)) !== null) {
|
|
2359
|
-
const leadingNewline = m[1] ?? "";
|
|
2360
|
-
const indent = m[2] ?? "";
|
|
2361
|
-
const headEnd = m.index + m[0].length;
|
|
2362
|
-
const openBraceIdx = headEnd - 1;
|
|
2363
|
-
const closeBraceIdx = findMatchingClose(body, openBraceIdx);
|
|
2364
|
-
if (closeBraceIdx === -1) continue;
|
|
2365
|
-
out.push(body.slice(last, m.index));
|
|
2366
|
-
out.push(leadingNewline);
|
|
2367
|
-
const blockBody = body.slice(openBraceIdx + 1, closeBraceIdx);
|
|
2368
|
-
out.push(`${indent}${MIGRATION_MARKER}
|
|
2369
|
-
`);
|
|
2370
|
-
out.push(`${indent}$effect(() => {${blockBody}});`);
|
|
2371
|
-
last = closeBraceIdx + 1;
|
|
2372
|
-
BLOCK_REACTIVE_HEAD.lastIndex = last;
|
|
2373
|
-
}
|
|
2374
|
-
out.push(body.slice(last));
|
|
2375
|
-
return out.join("");
|
|
2376
|
-
}
|
|
2377
|
-
function transformSimple(body) {
|
|
2378
|
-
return body.replace(SIMPLE_REACTIVE, (_full, indent, name, expr) => {
|
|
2379
|
-
return `${indent}let ${name} = $derived(${expr.trim()});`;
|
|
2380
|
-
});
|
|
2381
|
-
}
|
|
2382
|
-
function legacyReactiveToRunes(source) {
|
|
2383
|
-
return source.replace(SCRIPT_BLOCK5, (full, _attrs, body) => {
|
|
2384
|
-
let next = transformBlocks(body);
|
|
2385
|
-
next = transformSimple(next);
|
|
2386
|
-
if (next === body) return full;
|
|
2387
|
-
return full.replace(body, next);
|
|
2388
|
-
});
|
|
2389
|
-
}
|
|
2390
|
-
|
|
2391
|
-
// src/recipes/svelte-5/step-gotchas.ts
|
|
2392
|
-
var SVELTE_GLOBS = ["src/**/*.svelte"];
|
|
2393
|
-
var IGNORE2 = ["node_modules/**", ".svelte-kit/**", "build/**"];
|
|
2394
|
-
var CODEMODS = [
|
|
2395
|
-
onEventToHandler,
|
|
2396
|
-
exportLetToProps,
|
|
2397
|
-
removeDollarRestProps,
|
|
2398
|
-
stateEffectSyncToDerived,
|
|
2399
|
-
dollarPropsClass,
|
|
2400
|
-
legacyReactiveToRunes
|
|
2401
|
-
];
|
|
2402
|
-
async function planGotchaCodemods(cwd) {
|
|
2403
|
-
const changes = [];
|
|
2404
|
-
const relPaths = await glob2(SVELTE_GLOBS, { cwd, ignore: IGNORE2, absolute: false });
|
|
2405
|
-
for (const rel of relPaths) {
|
|
2406
|
-
const path = join12(cwd, rel);
|
|
2407
|
-
const before = await readFile9(path, "utf-8");
|
|
2408
|
-
const after = CODEMODS.reduce((s, fn) => fn(s), before);
|
|
2409
|
-
if (after !== before) changes.push({ rel, after });
|
|
2410
|
-
}
|
|
2411
|
-
return changes;
|
|
2412
|
-
}
|
|
2413
|
-
async function applyGotchaCodemods(cwd) {
|
|
2414
|
-
const changes = await planGotchaCodemods(cwd);
|
|
2415
|
-
for (const c of changes) {
|
|
2416
|
-
await writeFile6(join12(cwd, c.rel), c.after, "utf-8");
|
|
2417
|
-
}
|
|
2418
|
-
return { filesChanged: changes.length };
|
|
2419
|
-
}
|
|
2420
|
-
|
|
2421
|
-
// src/recipes/svelte-5/step-verify.ts
|
|
2422
|
-
async function verifyMigration(cwd, spawn2 = defaultSpawn) {
|
|
2423
|
-
let install;
|
|
2424
|
-
try {
|
|
2425
|
-
install = await spawn2("pnpm", ["install"], { cwd, timeoutMs: 10 * 6e4 });
|
|
2426
|
-
} catch {
|
|
2427
|
-
install = { skipped: true };
|
|
2428
|
-
}
|
|
2429
|
-
let check;
|
|
2430
|
-
try {
|
|
2431
|
-
check = await spawn2("pnpm", ["run", "check"], { cwd, timeoutMs: 5 * 6e4 });
|
|
2432
|
-
} catch {
|
|
2433
|
-
check = { skipped: true };
|
|
2434
|
-
}
|
|
2435
|
-
return { install, check };
|
|
2436
|
-
}
|
|
2437
|
-
|
|
2438
|
-
// src/recipes/svelte-5/step-summary.ts
|
|
2439
|
-
import { writeFile as writeFile7 } from "fs/promises";
|
|
2440
|
-
import { join as join13 } from "path";
|
|
2441
|
-
async function writeMigrationSummary(input) {
|
|
2442
|
-
const lines = [
|
|
2443
|
-
`# Svelte 4 \u2192 5 migration summary`,
|
|
2444
|
-
``,
|
|
2445
|
-
`Generated by @reddoorla/maintenance.`,
|
|
2446
|
-
``,
|
|
2447
|
-
`- svelte-migrate run: ${input.svelteMigrateRan ? "yes" : "no"}`,
|
|
2448
|
-
`- @tailwindcss/upgrade run: ${input.tailwindUpgraded ? "yes" : "no"}`,
|
|
2449
|
-
`- .svelte files touched by gotcha codemods: ${input.filesChangedByCodemods}`,
|
|
2450
|
-
``,
|
|
2451
|
-
`Next steps:`,
|
|
2452
|
-
`- Run \`pnpm run check\` and resolve any remaining warnings.`,
|
|
2453
|
-
`- Spot-check rune migrations in components that use \`reactive\` statements.`,
|
|
2454
|
-
`- Verify Playwright a11y tests still pass.`
|
|
2455
|
-
];
|
|
2456
|
-
const content = lines.join("\n") + "\n";
|
|
2457
|
-
const path = join13(input.cwd, "MIGRATION_SVELTE_5.md");
|
|
2458
|
-
await writeFile7(path, content, "utf-8");
|
|
2459
|
-
return path;
|
|
2460
|
-
}
|
|
2461
|
-
|
|
2462
|
-
// src/recipes/svelte-5/index.ts
|
|
2463
|
-
async function alreadyOnSvelte5(cwd) {
|
|
2464
|
-
try {
|
|
2465
|
-
const pkg = await readPackageJson(join14(cwd, "package.json"));
|
|
2466
|
-
const v = pkg.devDependencies?.svelte ?? pkg.dependencies?.svelte;
|
|
2467
|
-
return !!v && /^\^?5\./.test(v);
|
|
2468
|
-
} catch {
|
|
2469
|
-
return false;
|
|
2470
|
-
}
|
|
2471
|
-
}
|
|
2472
|
-
async function upgradeSvelte4to5(site, opts = {}) {
|
|
2473
|
-
const spawn2 = opts.spawn ?? defaultSpawn;
|
|
2474
|
-
return withRecipe({
|
|
2475
|
-
name: "svelte-4-to-5",
|
|
2476
|
-
site,
|
|
2477
|
-
plan: async () => {
|
|
2478
|
-
if (await alreadyOnSvelte5(site.path)) {
|
|
2479
|
-
return { kind: "noop", notes: "site already declares svelte ^5.x" };
|
|
2480
|
-
}
|
|
2481
|
-
return { kind: "apply", plan: true };
|
|
2482
|
-
},
|
|
2483
|
-
apply: async (_plan, { commit: commit2, cwd }) => {
|
|
2484
|
-
const bumped = await bumpToSvelte5Versions(cwd);
|
|
2485
|
-
if (bumped) {
|
|
2486
|
-
await commit2("chore(svelte5): bump svelte/kit/vite/vite-plugin-svelte");
|
|
2487
|
-
}
|
|
2488
|
-
const configChanged = await migrateSvelteConfig(cwd);
|
|
2489
|
-
if (configChanged) {
|
|
2490
|
-
await commit2("refactor(svelte5): migrate svelte.config.js (drop vitePreprocess)");
|
|
2491
|
-
}
|
|
2492
|
-
const migrate = await runSvelteMigrate(cwd, spawn2);
|
|
2493
|
-
if (migrate.ran) {
|
|
2494
|
-
await commit2("refactor(svelte5): run official svelte-migrate codemod");
|
|
2495
|
-
}
|
|
2496
|
-
const tw = await upgradeTailwind(cwd, spawn2);
|
|
2497
|
-
if (tw.ran) {
|
|
2498
|
-
await commit2("chore(svelte5): tailwindcss 3 \u2192 4 upgrade");
|
|
2499
|
-
}
|
|
2500
|
-
const codemods = await applyGotchaCodemods(cwd);
|
|
2501
|
-
if (codemods.filesChanged > 0) {
|
|
2502
|
-
await commit2(`refactor(svelte5): apply gotcha codemods (${codemods.filesChanged} files)`);
|
|
2503
|
-
}
|
|
2504
|
-
await verifyMigration(cwd, spawn2);
|
|
2505
|
-
await commit2("chore(svelte5): pnpm install + check");
|
|
2506
|
-
await writeMigrationSummary({
|
|
2507
|
-
cwd,
|
|
2508
|
-
filesChangedByCodemods: codemods.filesChanged,
|
|
2509
|
-
svelteMigrateRan: migrate.ran,
|
|
2510
|
-
tailwindUpgraded: tw.ran
|
|
2511
|
-
});
|
|
2512
|
-
await commit2("docs(svelte5): add MIGRATION_SVELTE_5.md summary");
|
|
2513
|
-
return { kind: "ok" };
|
|
2514
|
-
}
|
|
2515
|
-
});
|
|
2516
|
-
}
|
|
2517
|
-
|
|
2518
|
-
// src/recipes/svelte-codemods.ts
|
|
2519
|
-
import { writeFile as writeFile8 } from "fs/promises";
|
|
2520
|
-
import { join as join15 } from "path";
|
|
2521
|
-
async function svelteCodemods(site) {
|
|
2522
|
-
return withRecipe({
|
|
2523
|
-
name: "svelte-codemods",
|
|
2524
|
-
site,
|
|
2525
|
-
plan: async () => {
|
|
2526
|
-
const changes = await planGotchaCodemods(site.path);
|
|
2527
|
-
if (changes.length === 0) {
|
|
2528
|
-
return { kind: "noop", notes: "no codemod targets matched" };
|
|
2529
|
-
}
|
|
2530
|
-
return { kind: "apply", plan: changes };
|
|
2531
|
-
},
|
|
2532
|
-
apply: async (changes, { commit: commit2, cwd }) => {
|
|
2533
|
-
for (const c of changes) {
|
|
2534
|
-
await writeFile8(join15(cwd, c.rel), c.after, "utf-8");
|
|
2535
|
-
}
|
|
2536
|
-
await commit2(`refactor(svelte5): apply codemods (${changes.length} files)`);
|
|
2537
|
-
return { kind: "ok" };
|
|
2538
|
-
}
|
|
2539
|
-
});
|
|
2540
|
-
}
|
|
2541
|
-
|
|
2542
|
-
// src/recipes/convert-to-pnpm.ts
|
|
2543
|
-
import { rm as rm3, stat as stat3 } from "fs/promises";
|
|
2544
|
-
import { join as join16 } from "path";
|
|
2545
|
-
|
|
2546
|
-
// src/recipes/convert-to-pnpm/script-rewrites.ts
|
|
2547
|
-
function rewriteScriptForPnpm(script) {
|
|
2548
|
-
let out = script;
|
|
2549
|
-
out = out.replace(/\bnpm run(?=\s)/g, "pnpm run");
|
|
2550
|
-
out = out.replace(/\bnpx(?=\s)/g, "pnpm dlx");
|
|
2551
|
-
return out;
|
|
2552
|
-
}
|
|
2553
|
-
function rewriteScriptsForPnpm(scripts) {
|
|
2554
|
-
const next = {};
|
|
2555
|
-
let changedCount = 0;
|
|
2556
|
-
for (const [name, value] of Object.entries(scripts)) {
|
|
2557
|
-
const rewritten = rewriteScriptForPnpm(value);
|
|
2558
|
-
next[name] = rewritten;
|
|
2559
|
-
if (rewritten !== value) changedCount++;
|
|
2560
|
-
}
|
|
2561
|
-
return { scripts: next, changedCount };
|
|
2562
|
-
}
|
|
2563
|
-
|
|
2564
|
-
// src/recipes/convert-to-pnpm.ts
|
|
2565
|
-
var DEFAULT_PNPM_VERSION = "10.33.1";
|
|
2566
|
-
async function exists3(path) {
|
|
2567
|
-
try {
|
|
2568
|
-
await stat3(path);
|
|
2569
|
-
return true;
|
|
2570
|
-
} catch {
|
|
2571
|
-
return false;
|
|
2572
|
-
}
|
|
2573
|
-
}
|
|
2574
|
-
async function convertToPnpm(site, opts = {}) {
|
|
2575
|
-
const spawn2 = opts.spawn ?? defaultSpawn;
|
|
2576
|
-
const pnpmVersion = opts.pnpmVersion ?? DEFAULT_PNPM_VERSION;
|
|
2577
|
-
const pnpmLockPath = join16(site.path, "pnpm-lock.yaml");
|
|
2578
|
-
const npmLockPath = join16(site.path, "package-lock.json");
|
|
2579
|
-
const yarnLockPath = join16(site.path, "yarn.lock");
|
|
2580
|
-
return withRecipe({
|
|
2581
|
-
name: "convert-to-pnpm",
|
|
2582
|
-
site,
|
|
2583
|
-
plan: async () => {
|
|
2584
|
-
if (await exists3(pnpmLockPath)) {
|
|
2585
|
-
return { kind: "noop", notes: "site already has pnpm-lock.yaml" };
|
|
2586
|
-
}
|
|
2587
|
-
const hasNpmLock = await exists3(npmLockPath);
|
|
2588
|
-
const hasYarnLock = await exists3(yarnLockPath);
|
|
2589
|
-
if (!hasNpmLock && !hasYarnLock) {
|
|
2590
|
-
return {
|
|
2591
|
-
kind: "noop",
|
|
2592
|
-
notes: "no convertible lockfile (package-lock.json or yarn.lock) at site root"
|
|
2593
|
-
};
|
|
2594
|
-
}
|
|
2595
|
-
return { kind: "apply", plan: { hasNpmLock, hasYarnLock } };
|
|
2596
|
-
},
|
|
2597
|
-
apply: async ({ hasNpmLock, hasYarnLock }, { commit: commit2, cwd }) => {
|
|
2598
|
-
if (hasNpmLock) await rm3(npmLockPath, { force: true });
|
|
2599
|
-
if (hasYarnLock) await rm3(yarnLockPath, { force: true });
|
|
2600
|
-
const sourceLock = hasNpmLock ? "package-lock.json" : "yarn.lock";
|
|
2601
|
-
await commit2(`chore(pnpm): remove ${sourceLock}`);
|
|
2602
|
-
const pkgPath = join16(cwd, "package.json");
|
|
2603
|
-
const pkg = await readPackageJson(pkgPath);
|
|
2604
|
-
const next = { ...pkg, packageManager: `pnpm@${pnpmVersion}` };
|
|
2605
|
-
if (pkg.scripts && typeof pkg.scripts === "object") {
|
|
2606
|
-
const { scripts: rewritten, changedCount } = rewriteScriptsForPnpm(
|
|
2607
|
-
pkg.scripts
|
|
2608
|
-
);
|
|
2609
|
-
if (changedCount > 0) {
|
|
2610
|
-
next.scripts = rewritten;
|
|
2611
|
-
}
|
|
2612
|
-
}
|
|
2613
|
-
await writePackageJson(pkgPath, next);
|
|
2614
|
-
await commit2("chore(pnpm): pin packageManager + rewrite npm scripts");
|
|
2615
|
-
await rm3(join16(cwd, "node_modules"), { recursive: true, force: true });
|
|
2616
|
-
const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
|
|
2617
|
-
if (installResult.code !== 0) {
|
|
2618
|
-
return { kind: "failed", notes: `pnpm install failed (exit ${installResult.code})` };
|
|
2619
|
-
}
|
|
2620
|
-
await commit2("chore(pnpm): add pnpm-lock.yaml");
|
|
2621
|
-
return { kind: "ok" };
|
|
2622
|
-
}
|
|
2623
|
-
});
|
|
2624
|
-
}
|
|
2625
|
-
|
|
2626
|
-
// src/recipes/onboard.ts
|
|
2627
|
-
import { stat as stat4 } from "fs/promises";
|
|
2628
|
-
import { join as join18 } from "path";
|
|
2629
|
-
|
|
2630
|
-
// src/util/self-version.ts
|
|
2631
|
-
import { readFileSync, existsSync as existsSync2 } from "fs";
|
|
2632
|
-
import { fileURLToPath } from "url";
|
|
2633
|
-
import { dirname as dirname2, join as join17 } from "path";
|
|
2634
|
-
function selfPackageVersion(callerImportMetaUrl) {
|
|
2635
|
-
try {
|
|
2636
|
-
let dir = dirname2(fileURLToPath(callerImportMetaUrl));
|
|
2637
|
-
while (true) {
|
|
2638
|
-
const candidate = join17(dir, "package.json");
|
|
2639
|
-
if (existsSync2(candidate)) {
|
|
2640
|
-
const raw = readFileSync(candidate, "utf-8");
|
|
2641
|
-
const pkg = JSON.parse(raw);
|
|
2642
|
-
if (pkg.name === "@reddoorla/maintenance") {
|
|
2643
|
-
return pkg.version ?? "0.0.0";
|
|
2644
|
-
}
|
|
2645
|
-
}
|
|
2646
|
-
const parent = dirname2(dir);
|
|
2647
|
-
if (parent === dir) return "0.0.0";
|
|
2648
|
-
dir = parent;
|
|
2649
|
-
}
|
|
2650
|
-
} catch {
|
|
2651
|
-
return "0.0.0";
|
|
2652
|
-
}
|
|
2653
|
-
}
|
|
2654
|
-
function selfCaretRange(callerImportMetaUrl) {
|
|
2655
|
-
return `^${selfPackageVersion(callerImportMetaUrl)}`;
|
|
2656
|
-
}
|
|
2657
|
-
|
|
2658
|
-
// src/recipes/onboard.ts
|
|
2659
|
-
var PACKAGE_NAME = "@reddoorla/maintenance";
|
|
2660
|
-
var AUDIT_DEP_NAMES = {
|
|
2661
|
-
lighthouse: ["@lhci/cli"],
|
|
2662
|
-
a11y: ["@playwright/test", "@axe-core/playwright"]
|
|
2663
|
-
};
|
|
2664
|
-
var FRAMEWORK_DEP_NAMES = ["@sveltejs/adapter-netlify"];
|
|
2665
|
-
var FRAMEWORK_DEPS = FRAMEWORK_DEP_NAMES.map(
|
|
2666
|
-
(name) => {
|
|
2667
|
-
const version = baselineVersions[name];
|
|
2668
|
-
if (!version) {
|
|
2669
|
-
throw new Error(
|
|
2670
|
-
`baseline-versions is missing framework dep "${name}" \u2014 add it to src/configs/baseline-versions.ts`
|
|
2671
|
-
);
|
|
2672
|
-
}
|
|
2673
|
-
return { name, version };
|
|
2674
|
-
}
|
|
2675
|
-
);
|
|
2676
|
-
var AUDIT_DEPS = Object.fromEntries(
|
|
2677
|
-
Object.entries(AUDIT_DEP_NAMES).map(([audit, names]) => [
|
|
2678
|
-
audit,
|
|
2679
|
-
names.map((name) => {
|
|
2680
|
-
const version = baselineVersions[name];
|
|
2681
|
-
if (!version) {
|
|
2682
|
-
throw new Error(
|
|
2683
|
-
`baseline-versions is missing audit dep "${name}" \u2014 add it to src/configs/baseline-versions.ts`
|
|
2684
|
-
);
|
|
2685
|
-
}
|
|
2686
|
-
return { name, version };
|
|
2687
|
-
})
|
|
2688
|
-
])
|
|
2689
|
-
);
|
|
2690
|
-
async function exists4(path) {
|
|
2691
|
-
try {
|
|
2692
|
-
await stat4(path);
|
|
2693
|
-
return true;
|
|
2694
|
-
} catch {
|
|
2695
|
-
return false;
|
|
2696
|
-
}
|
|
2697
|
-
}
|
|
2698
|
-
function isDeclared(pkg, name) {
|
|
2699
|
-
return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
|
|
2700
|
-
}
|
|
2701
|
-
async function onboard(site, opts = {}) {
|
|
2702
|
-
const spawn2 = opts.spawn ?? defaultSpawn;
|
|
2703
|
-
const audits = opts.audits ?? ["lighthouse", "a11y"];
|
|
2704
|
-
const packageVersion = opts.packageVersion ?? selfCaretRange(import.meta.url);
|
|
2705
|
-
return withRecipe({
|
|
2706
|
-
name: "onboard",
|
|
2707
|
-
site,
|
|
2708
|
-
plan: async () => {
|
|
2709
|
-
if (!await exists4(join18(site.path, "pnpm-lock.yaml"))) {
|
|
2710
|
-
return {
|
|
2711
|
-
kind: "failed",
|
|
2712
|
-
notes: "no pnpm-lock.yaml at site root \u2014 run convert-to-pnpm first"
|
|
2713
|
-
};
|
|
2714
|
-
}
|
|
2715
|
-
const pkgPath = join18(site.path, "package.json");
|
|
2716
|
-
const pkg = await readPackageJson(pkgPath);
|
|
2717
|
-
const toAdd = [];
|
|
2718
|
-
if (!isDeclared(pkg, PACKAGE_NAME)) {
|
|
2719
|
-
toAdd.push({ name: PACKAGE_NAME, version: packageVersion });
|
|
2720
|
-
}
|
|
2721
|
-
for (const dep of FRAMEWORK_DEPS) {
|
|
2722
|
-
if (!isDeclared(pkg, dep.name)) toAdd.push(dep);
|
|
2723
|
-
}
|
|
2724
|
-
for (const audit of audits) {
|
|
2725
|
-
for (const dep of AUDIT_DEPS[audit]) {
|
|
2726
|
-
if (!isDeclared(pkg, dep.name)) toAdd.push(dep);
|
|
2727
|
-
}
|
|
2728
|
-
}
|
|
2729
|
-
if (toAdd.length === 0) {
|
|
2730
|
-
return {
|
|
2731
|
-
kind: "noop",
|
|
2732
|
-
notes: `site already has ${PACKAGE_NAME}, framework deps, and audit deps (${audits.join("+")})`
|
|
2733
|
-
};
|
|
2734
|
-
}
|
|
2735
|
-
return { kind: "apply", plan: { pkg, toAdd } };
|
|
2736
|
-
},
|
|
2737
|
-
apply: async ({ pkg, toAdd }, { commit: commit2, cwd }) => {
|
|
2738
|
-
const pkgPath = join18(cwd, "package.json");
|
|
2739
|
-
let next = pkg;
|
|
2740
|
-
for (const dep of toAdd) {
|
|
2741
|
-
next = bumpDep(next, dep.name, dep.version);
|
|
2742
|
-
}
|
|
2743
|
-
await writePackageJson(pkgPath, next);
|
|
2744
|
-
const installResult = await spawn2("pnpm", ["install"], { cwd, streaming: true });
|
|
2745
|
-
if (installResult.code !== 0) {
|
|
2746
|
-
return {
|
|
2747
|
-
kind: "failed",
|
|
2748
|
-
notes: `pnpm install failed (exit ${installResult.code})`
|
|
2749
|
-
};
|
|
2750
|
-
}
|
|
2751
|
-
await commit2(`chore(reddoor): onboard with ${PACKAGE_NAME} ${packageVersion}`);
|
|
2752
|
-
return {
|
|
2753
|
-
kind: "ok",
|
|
2754
|
-
notes: `Added ${toAdd.length} dep(s): ${toAdd.map((d) => d.name).join(", ")}`
|
|
2755
|
-
};
|
|
2756
|
-
}
|
|
2757
|
-
});
|
|
2758
|
-
}
|
|
2759
|
-
|
|
2760
|
-
// src/recipes/a11y-fixtures-page/index.ts
|
|
2761
|
-
import { access, mkdir as mkdir2, writeFile as writeFile9 } from "fs/promises";
|
|
2762
|
-
import { dirname as dirname3, join as join19 } from "path";
|
|
2763
|
-
|
|
2764
|
-
// src/recipes/a11y-fixtures-page/template.ts
|
|
2765
|
-
var A11Y_FIXTURES_PAGE_RELATIVE = "src/routes/dev/a11y-fixtures/+page.svelte";
|
|
2766
|
-
var A11Y_FIXTURES_PAGE_TEMPLATE = `<svelte:head>
|
|
2767
|
-
<title>a11y fixtures \u2014 Reddoor</title>
|
|
2768
|
-
<meta
|
|
2769
|
-
name="description"
|
|
2770
|
-
content="Reddoor accessibility fixtures \u2014 semantic landmarks, heading hierarchy, and a stable target for @lhci/cli and Playwright + axe-core coverage. Not linked from the public site."
|
|
2771
|
-
/>
|
|
2772
|
-
</svelte:head>
|
|
2773
|
-
|
|
2774
|
-
<main>
|
|
2775
|
-
<header>
|
|
2776
|
-
<h1>Accessibility fixtures</h1>
|
|
2777
|
-
<p>
|
|
2778
|
-
This page exists so <code>@lhci/cli</code> and Playwright + axe-core have a
|
|
2779
|
-
stable target with predictable a11y characteristics. It is not linked from
|
|
2780
|
-
the public site.
|
|
2781
|
-
</p>
|
|
2782
|
-
</header>
|
|
2783
|
-
|
|
2784
|
-
<section aria-labelledby="landmarks-heading">
|
|
2785
|
-
<h2 id="landmarks-heading">Landmarks</h2>
|
|
2786
|
-
<p>
|
|
2787
|
-
A single <code>main</code> wraps the page; sections each declare
|
|
2788
|
-
<code>aria-labelledby</code> matched to their heading id so screen readers
|
|
2789
|
-
and axe both see a clean outline.
|
|
2790
|
-
</p>
|
|
2791
|
-
</section>
|
|
2792
|
-
|
|
2793
|
-
<section aria-labelledby="links-heading">
|
|
2794
|
-
<h2 id="links-heading">Links</h2>
|
|
2795
|
-
<p>
|
|
2796
|
-
<a href="/">Back to home</a> \u2014 relative link with descriptive visible text,
|
|
2797
|
-
so no <code>aria-label</code> override is needed.
|
|
2798
|
-
</p>
|
|
2799
|
-
</section>
|
|
2800
|
-
</main>
|
|
2801
|
-
`;
|
|
2802
|
-
|
|
2803
|
-
// src/recipes/a11y-fixtures-page/index.ts
|
|
2804
|
-
async function fileExists(path) {
|
|
2805
|
-
try {
|
|
2806
|
-
await access(path);
|
|
2807
|
-
return true;
|
|
2808
|
-
} catch {
|
|
2809
|
-
return false;
|
|
2810
|
-
}
|
|
2811
|
-
}
|
|
2812
|
-
async function a11yFixturesPage(site) {
|
|
2813
|
-
const target = join19(site.path, A11Y_FIXTURES_PAGE_RELATIVE);
|
|
2814
|
-
return withRecipe({
|
|
2815
|
-
name: "a11y-fixtures-page",
|
|
2816
|
-
site,
|
|
2817
|
-
plan: async () => {
|
|
2818
|
-
if (await fileExists(target)) {
|
|
2819
|
-
return { kind: "noop", notes: `${A11Y_FIXTURES_PAGE_RELATIVE} already exists` };
|
|
2820
|
-
}
|
|
2821
|
-
return { kind: "apply", plan: { target } };
|
|
2822
|
-
},
|
|
2823
|
-
apply: async (planned, { commit: commit2 }) => {
|
|
2824
|
-
await mkdir2(dirname3(planned.target), { recursive: true });
|
|
2825
|
-
await writeFile9(planned.target, A11Y_FIXTURES_PAGE_TEMPLATE, "utf-8");
|
|
2826
|
-
await commit2("feat: add /dev/a11y-fixtures starter route");
|
|
2827
|
-
return { kind: "ok" };
|
|
2828
|
-
}
|
|
2829
|
-
});
|
|
2830
|
-
}
|
|
2831
|
-
|
|
2832
|
-
// src/recipes/init.ts
|
|
2833
|
-
function recipeStep(name, fn) {
|
|
2834
|
-
return {
|
|
2835
|
-
name,
|
|
2836
|
-
run: async (site) => ({ kind: "recipe", result: await fn(site) })
|
|
2837
|
-
};
|
|
2838
|
-
}
|
|
2839
|
-
var DEFAULT_INIT_STEPS = [
|
|
2840
|
-
recipeStep("convert-to-pnpm", convertToPnpm),
|
|
2841
|
-
recipeStep("onboard", onboard),
|
|
2842
|
-
recipeStep("sync-configs", syncConfigs),
|
|
2843
|
-
recipeStep("svelte-codemods", svelteCodemods),
|
|
2844
|
-
recipeStep("a11y-fixtures-page", a11yFixturesPage),
|
|
2845
|
-
{
|
|
2846
|
-
name: "audit",
|
|
2847
|
-
run: async (site) => ({ kind: "audit", results: await runAudits(site) })
|
|
2848
|
-
}
|
|
2849
|
-
];
|
|
2850
|
-
async function init(site, opts = {}) {
|
|
2851
|
-
const steps = opts.steps ?? DEFAULT_INIT_STEPS;
|
|
2852
|
-
const out = [];
|
|
2853
|
-
for (const step of steps) {
|
|
2854
|
-
let result;
|
|
2855
|
-
try {
|
|
2856
|
-
result = await step.run(site);
|
|
2857
|
-
} catch (err) {
|
|
2858
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
2859
|
-
out.push({ name: step.name, result: { kind: "error", message } });
|
|
2860
|
-
return { site: siteLabel(site), steps: out, complete: false };
|
|
2861
|
-
}
|
|
2862
|
-
out.push({ name: step.name, result });
|
|
2863
|
-
if (result.kind === "recipe" && result.result.status === "failed") {
|
|
2864
|
-
return { site: siteLabel(site), steps: out, complete: false };
|
|
2865
|
-
}
|
|
2866
|
-
}
|
|
2867
|
-
return { site: siteLabel(site), steps: out, complete: true };
|
|
2868
|
-
}
|
|
2869
|
-
|
|
2870
|
-
// src/recipes/index.ts
|
|
2871
|
-
var ALL_RECIPE_NAMES = [
|
|
2872
|
-
"sync-configs",
|
|
2873
|
-
"bump-deps",
|
|
2874
|
-
"svelte-4-to-5",
|
|
2875
|
-
"svelte-codemods",
|
|
2876
|
-
"convert-to-pnpm",
|
|
2877
|
-
"onboard",
|
|
2878
|
-
"a11y-fixtures-page",
|
|
2879
|
-
"self-updating",
|
|
2880
|
-
"init"
|
|
2881
|
-
];
|
|
2882
|
-
function isRecipeName(value) {
|
|
2883
|
-
return ALL_RECIPE_NAMES.includes(value);
|
|
2884
|
-
}
|
|
2885
|
-
|
|
2886
|
-
// src/inventory/local.ts
|
|
2887
|
-
import { basename } from "path";
|
|
2888
|
-
function localPath(path, opts = {}) {
|
|
2889
|
-
const site = { path, name: opts.name || basename(path) };
|
|
2890
|
-
return async () => [site];
|
|
2891
|
-
}
|
|
2892
|
-
|
|
2893
|
-
// src/inventory/json.ts
|
|
2894
|
-
import { readFile as readFile10 } from "fs/promises";
|
|
2895
|
-
import { isAbsolute } from "path";
|
|
2896
|
-
|
|
2897
|
-
// src/util/url.ts
|
|
2898
|
-
function isHttpUrl(s) {
|
|
2899
|
-
let parsed;
|
|
2900
|
-
try {
|
|
2901
|
-
parsed = new URL(s);
|
|
2902
|
-
} catch {
|
|
2903
|
-
return false;
|
|
2904
|
-
}
|
|
2905
|
-
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
2906
|
-
}
|
|
2907
|
-
function isNetlifyAppUrl(s) {
|
|
2908
|
-
let parsed;
|
|
2909
|
-
try {
|
|
2910
|
-
parsed = new URL(s);
|
|
2911
|
-
} catch {
|
|
2912
|
-
return false;
|
|
2913
|
-
}
|
|
2914
|
-
const host = parsed.hostname.toLowerCase();
|
|
2915
|
-
return host === "netlify.app" || host.endsWith(".netlify.app");
|
|
2916
|
-
}
|
|
2917
|
-
|
|
2918
|
-
// src/inventory/json.ts
|
|
2919
|
-
function validate(raw) {
|
|
2920
|
-
if (!Array.isArray(raw)) {
|
|
2921
|
-
throw new Error("inventory JSON must be an array of sites");
|
|
2922
|
-
}
|
|
2923
|
-
return raw.map((entry, i) => {
|
|
2924
|
-
if (!entry || typeof entry !== "object") {
|
|
2925
|
-
throw new Error(`inventory entry ${i} is not an object`);
|
|
2926
|
-
}
|
|
2927
|
-
const e = entry;
|
|
2928
|
-
if (typeof e.path !== "string" || e.path.length === 0) {
|
|
2929
|
-
throw new Error(`inventory entry ${i} is missing required field: path`);
|
|
2930
|
-
}
|
|
2931
|
-
if (!isAbsolute(e.path)) {
|
|
2932
|
-
throw new Error(
|
|
2933
|
-
`inventory entry ${i}: path must be absolute (got "${e.path}"). Relative paths are rejected so cwd at invocation can't change which site is targeted.`
|
|
2934
|
-
);
|
|
2935
|
-
}
|
|
2936
|
-
const site = { path: e.path };
|
|
2937
|
-
if (typeof e.name === "string") site.name = e.name;
|
|
2938
|
-
if (typeof e.repoUrl === "string") site.repoUrl = e.repoUrl;
|
|
2939
|
-
if (typeof e.gitRepo === "string") site.gitRepo = e.gitRepo;
|
|
2940
|
-
if (typeof e.deployedUrl === "string") {
|
|
2941
|
-
if (isHttpUrl(e.deployedUrl)) {
|
|
2942
|
-
site.deployedUrl = e.deployedUrl;
|
|
2943
|
-
} else {
|
|
2944
|
-
console.warn(
|
|
2945
|
-
`[inventory] entry ${i}: ignoring deployedUrl that is not http(s): ${JSON.stringify(e.deployedUrl)}`
|
|
2946
|
-
);
|
|
2947
|
-
}
|
|
2948
|
-
}
|
|
2949
|
-
if (typeof e.meta === "object" && e.meta !== null) {
|
|
2950
|
-
site.meta = e.meta;
|
|
2951
|
-
}
|
|
2952
|
-
return site;
|
|
2953
|
-
});
|
|
2954
|
-
}
|
|
2955
|
-
function fromJsonFile(path) {
|
|
2956
|
-
return async () => {
|
|
2957
|
-
const text = await readFile10(path, "utf-8");
|
|
2958
|
-
let raw;
|
|
2959
|
-
try {
|
|
2960
|
-
raw = JSON.parse(text);
|
|
2961
|
-
} catch (e) {
|
|
2962
|
-
throw new Error(`could not parse inventory file ${path}: ${e.message}`, {
|
|
2963
|
-
cause: e
|
|
2964
|
-
});
|
|
2965
|
-
}
|
|
2966
|
-
return validate(raw);
|
|
2967
|
-
};
|
|
2968
|
-
}
|
|
2969
|
-
|
|
2970
|
-
// src/reports/airtable/websites.ts
|
|
2971
|
-
var WEBSITES_TABLE = "Websites";
|
|
2972
|
-
function siteSlug(name) {
|
|
2973
|
-
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
2974
|
-
}
|
|
2975
|
-
function trimToNull(raw) {
|
|
2976
|
-
if (typeof raw !== "string") return null;
|
|
2977
|
-
const trimmed = raw.trim();
|
|
2978
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
2979
|
-
}
|
|
2980
|
-
function parseNotifyRouting(raw) {
|
|
2981
|
-
if (typeof raw !== "string") return null;
|
|
2982
|
-
const trimmed = raw.trim();
|
|
2983
|
-
if (!trimmed) return null;
|
|
2984
|
-
let parsed;
|
|
2985
|
-
try {
|
|
2986
|
-
parsed = JSON.parse(trimmed);
|
|
2987
|
-
} catch {
|
|
2988
|
-
return null;
|
|
2989
|
-
}
|
|
2990
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
2991
|
-
const o = parsed;
|
|
2992
|
-
if (typeof o.field !== "string" || !o.field.trim()) return null;
|
|
2993
|
-
if (!o.routes || typeof o.routes !== "object" || Array.isArray(o.routes)) return null;
|
|
2994
|
-
const routing = {
|
|
2995
|
-
field: o.field,
|
|
2996
|
-
routes: o.routes
|
|
2997
|
-
};
|
|
2998
|
-
if (o.default !== void 0) routing.default = o.default;
|
|
2999
|
-
if (Array.isArray(o.cc)) routing.cc = o.cc.filter((x) => typeof x === "string");
|
|
3000
|
-
return routing;
|
|
3001
|
-
}
|
|
3002
|
-
var ACTIVE_STATUSES = /* @__PURE__ */ new Set([
|
|
3003
|
-
"maintenance",
|
|
3004
|
-
"launch period"
|
|
3005
|
-
]);
|
|
3006
|
-
var FREQUENCIES = ["None", "Monthly", "Quarterly", "Yearly"];
|
|
3007
|
-
function toFrequency(raw) {
|
|
3008
|
-
return typeof raw === "string" && FREQUENCIES.includes(raw) ? raw : "None";
|
|
3009
|
-
}
|
|
3010
|
-
function mapRow(rec) {
|
|
3011
|
-
const f = rec.fields;
|
|
3012
|
-
const attachments = f["Header image"] ?? [];
|
|
3013
|
-
const header = attachments[0] ?? null;
|
|
3014
|
-
return {
|
|
3015
|
-
id: rec.id,
|
|
3016
|
-
name: String(f["Name"] ?? ""),
|
|
3017
|
-
url: String(f["url"] ?? ""),
|
|
3018
|
-
status: f["Status"] ?? null,
|
|
3019
|
-
pointOfContact: f["point of contact"] ?? null,
|
|
3020
|
-
maintenanceFreq: toFrequency(f["maintenence freq"]),
|
|
3021
|
-
testingFreq: toFrequency(f["testing freq"]),
|
|
3022
|
-
maintenanceDay: f["maintenance day"] ?? null,
|
|
3023
|
-
testingDay: f["testing day"] ?? null,
|
|
3024
|
-
ga4PropertyId: f["GA4 property ID"] ?? null,
|
|
3025
|
-
searchQuery: f["Search query"] ?? null,
|
|
3026
|
-
searchConsoleProperty: f["Search Console property"] ?? null,
|
|
3027
|
-
analyticsSoftFailAt: f["Analytics soft-fail at"] ?? null,
|
|
3028
|
-
gitRepo: f["Git repo"] ?? null,
|
|
3029
|
-
reportRecipientsTo: f["Report recipients (To)"] ?? null,
|
|
3030
|
-
reportRecipientsCc: f["Report recipients (CC)"] ?? null,
|
|
3031
|
-
headerImage: header,
|
|
3032
|
-
pScore: f["pScore"] ?? null,
|
|
3033
|
-
rScore: f["rScore"] ?? null,
|
|
3034
|
-
bpScore: f["bpScore"] ?? null,
|
|
3035
|
-
seoScore: f["seoScore"] ?? null,
|
|
3036
|
-
lastLighthouseAuditAt: f["Last lighthouse audit at"] ?? null,
|
|
3037
|
-
a11yViolations: f["A11y Violations"] ?? null,
|
|
3038
|
-
depsDrifted: f["Deps Drifted"] ?? null,
|
|
3039
|
-
depsMajorBehind: f["Deps Major Behind"] ?? null,
|
|
3040
|
-
depsOutdated: f["Deps Outdated"] ?? null,
|
|
3041
|
-
securityVulnsCritical: f["Security Vulns Critical"] ?? null,
|
|
3042
|
-
securityVulnsHigh: f["Security Vulns High"] ?? null,
|
|
3043
|
-
securityVulnsModerate: f["Security Vulns Moderate"] ?? null,
|
|
3044
|
-
securityVulnsLow: f["Security Vulns Low"] ?? null,
|
|
3045
|
-
lastSecurityAuditAt: f["Last security audit at"] ?? null,
|
|
3046
|
-
securityAdvisories: parseSecurityAdvisories(f["Security advisories"]),
|
|
3047
|
-
certDaysRemaining: f["Cert days remaining"] ?? null,
|
|
3048
|
-
domainCheckedAt: f["Domain checked at"] ?? null,
|
|
3049
|
-
crossbrowserOk: typeof f["Crossbrowser OK"] === "boolean" ? f["Crossbrowser OK"] : null,
|
|
3050
|
-
mobileOk: typeof f["Mobile OK"] === "boolean" ? f["Mobile OK"] : null,
|
|
3051
|
-
linksOk: typeof f["Links OK"] === "boolean" ? f["Links OK"] : null,
|
|
3052
|
-
brokenLinks: typeof f["Broken links"] === "number" ? f["Broken links"] : null,
|
|
3053
|
-
browserCheckedAt: f["Browser checked at"] ?? null,
|
|
3054
|
-
copyIntro: trimToNull(f["Copy \u2014 Intro"]),
|
|
3055
|
-
copyContact: trimToNull(f["Copy \u2014 Contact"]),
|
|
3056
|
-
copyFooter: trimToNull(f["Copy \u2014 Footer"]),
|
|
3057
|
-
launchedAt: f["Launched at"] ?? null,
|
|
3058
|
-
newsletterWebhook: trimToNull(f["Newsletter Webhook"]),
|
|
3059
|
-
notifyRouting: parseNotifyRouting(f["Notify Routing"]),
|
|
3060
|
-
mailchimpApiKey: trimToNull(f["Mailchimp API Key"]),
|
|
3061
|
-
mailchimpAudienceId: trimToNull(f["Mailchimp Audience ID"]),
|
|
3062
|
-
renovateFailingCis: f["Renovate Failing CIs"] ?? null,
|
|
3063
|
-
defaultBranchCi: f["Default Branch CI"] ?? null,
|
|
3064
|
-
lastCommitAt: f["Last Commit At"] ?? null,
|
|
3065
|
-
githubSignalsAt: f["GitHub Signals At"] ?? null
|
|
3066
|
-
};
|
|
3067
|
-
}
|
|
3068
|
-
async function listWebsites(base) {
|
|
3069
|
-
const out = [];
|
|
3070
|
-
await base(WEBSITES_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
|
|
3071
|
-
for (const rec of records) out.push(mapRow({ id: rec.id, fields: rec.fields }));
|
|
3072
|
-
fetchNextPage();
|
|
3073
|
-
});
|
|
3074
|
-
return out;
|
|
3075
|
-
}
|
|
3076
|
-
var SEVERITY_RANK = {
|
|
3077
|
-
critical: 0,
|
|
3078
|
-
high: 1,
|
|
3079
|
-
moderate: 2,
|
|
3080
|
-
low: 3
|
|
3081
|
-
};
|
|
3082
|
-
function normalizeSecurityAdvisory(raw) {
|
|
3083
|
-
if (!raw || typeof raw !== "object") return null;
|
|
3084
|
-
const e = raw;
|
|
3085
|
-
const module = typeof e["module"] === "string" ? e["module"] : null;
|
|
3086
|
-
const severity = e["severity"];
|
|
3087
|
-
if (module === null) return null;
|
|
3088
|
-
if (severity !== "low" && severity !== "moderate" && severity !== "high" && severity !== "critical")
|
|
3089
|
-
return null;
|
|
3090
|
-
const cves = Array.isArray(e["cves"]) ? e["cves"].filter((c) => typeof c === "string") : [];
|
|
3091
|
-
return {
|
|
3092
|
-
module,
|
|
3093
|
-
severity,
|
|
3094
|
-
title: typeof e["title"] === "string" ? e["title"] : "",
|
|
3095
|
-
cves,
|
|
3096
|
-
url: typeof e["url"] === "string" ? e["url"] : null
|
|
3097
|
-
};
|
|
3098
|
-
}
|
|
3099
|
-
function parseSecurityAdvisories(raw) {
|
|
3100
|
-
if (typeof raw !== "string" || raw.trim() === "") return null;
|
|
3101
|
-
let parsed;
|
|
3102
|
-
try {
|
|
3103
|
-
parsed = JSON.parse(raw);
|
|
3104
|
-
} catch {
|
|
3105
|
-
return null;
|
|
3106
|
-
}
|
|
3107
|
-
if (!Array.isArray(parsed)) return null;
|
|
3108
|
-
return parsed.map(normalizeSecurityAdvisory).filter((a) => a !== null);
|
|
3109
|
-
}
|
|
3110
|
-
async function updateAnalyticsHealth(base, recordId, at) {
|
|
3111
|
-
const fields = { "Analytics soft-fail at": at };
|
|
3112
|
-
await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
|
|
3113
|
-
}
|
|
3114
|
-
async function updateLaunched(base, recordId, at) {
|
|
3115
|
-
const fields = { Status: "maintenance", "Launched at": at };
|
|
3116
|
-
await base(WEBSITES_TABLE).update([{ id: recordId, fields }]);
|
|
3117
|
-
}
|
|
3118
|
-
|
|
3119
|
-
// src/inventory/airtable.ts
|
|
3120
|
-
function fromAirtableBase(base, opts = {}) {
|
|
3121
|
-
return async () => {
|
|
3122
|
-
const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;
|
|
3123
|
-
if (!workdir) {
|
|
3124
|
-
throw new Error(
|
|
3125
|
-
"fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)"
|
|
3126
|
-
);
|
|
3127
|
-
}
|
|
3128
|
-
const websites = await listWebsites(base);
|
|
3129
|
-
return websites.filter((w) => w.status !== null && ACTIVE_STATUSES.has(w.status) && w.url.length > 0).flatMap((w) => {
|
|
3130
|
-
const slug = siteSlug(w.name);
|
|
3131
|
-
if (slug.length === 0) {
|
|
3132
|
-
console.warn(
|
|
3133
|
-
`[inventory] skipping "${w.name}" (row ${w.id}): Name has no slug-able characters (empty slug)`
|
|
3134
|
-
);
|
|
3135
|
-
return [];
|
|
3136
|
-
}
|
|
3137
|
-
const site = {
|
|
3138
|
-
path: `${workdir}/${slug}`,
|
|
3139
|
-
name: slug,
|
|
3140
|
-
meta: { airtableRowId: w.id, displayName: w.name }
|
|
3141
|
-
};
|
|
3142
|
-
if (isHttpUrl(w.url)) {
|
|
3143
|
-
site.deployedUrl = w.url;
|
|
3144
|
-
} else {
|
|
3145
|
-
console.warn(
|
|
3146
|
-
`[inventory] skipping deployed audit for "${w.name}": url is not http(s): ${JSON.stringify(w.url)}`
|
|
3147
|
-
);
|
|
3148
|
-
}
|
|
3149
|
-
if (w.gitRepo) site.gitRepo = w.gitRepo;
|
|
3150
|
-
return [site];
|
|
3151
|
-
});
|
|
3152
|
-
};
|
|
3153
|
-
}
|
|
3154
|
-
|
|
3155
|
-
// src/reports/draft.ts
|
|
3156
|
-
import { mkdir as mkdir3, writeFile as writeFile10 } from "fs/promises";
|
|
3157
|
-
import { dirname as dirname6 } from "path";
|
|
3158
|
-
|
|
3159
|
-
// src/reports/render.ts
|
|
3160
|
-
import mjml2html from "mjml";
|
|
3161
|
-
|
|
3162
|
-
// src/reports/copy.ts
|
|
3163
|
-
var DEFAULT_COPY = {
|
|
3164
|
-
maintenanceIntro: "Includes checking the hosting, DNS, Content Management System (CMS, if applicable), search indexing and security of the site for major flaws and updating as necessary.",
|
|
3165
|
-
maintenanceChecks: [
|
|
3166
|
-
"Deploy & Function Health",
|
|
3167
|
-
"CMS Checked",
|
|
3168
|
-
"Domain, DNS & SSL",
|
|
3169
|
-
"Google Indexed",
|
|
3170
|
-
"Security Updates",
|
|
3171
|
-
"Uptime Checked"
|
|
3172
|
-
],
|
|
3173
|
-
testingIntro: "Testing includes checks similar to those at launch: testing on common browsers and operating systems, at different screen sizes, and checking every function, and updating all packages for performance rather than just those needed for security.",
|
|
3174
|
-
testingChecklist: [
|
|
3175
|
-
"Desktop Browsers",
|
|
3176
|
-
"Mobile Browsers",
|
|
3177
|
-
"Page Titles & Meta",
|
|
3178
|
-
"Links & Navigation",
|
|
3179
|
-
"Form Functionality",
|
|
3180
|
-
"Interactions & Animations",
|
|
3181
|
-
"Tested After Updates"
|
|
3182
|
-
],
|
|
3183
|
-
notesHeader: "NOTES",
|
|
3184
|
-
seoCta: "Contact us if you are interested in more in-depth data or have questions about SEO.",
|
|
3185
|
-
contact: ["Just hit reply.", "We're here to help in any way we can."],
|
|
3186
|
-
footerOrg: "Reddoor Creative, LLC",
|
|
3187
|
-
footerAddress: ["29027 Dapper Dan", "Fair Oaks Ranch, TX 78015"],
|
|
3188
|
-
launchHeading: "LAUNCHED",
|
|
3189
|
-
launchBody: "Your site is live. We've set it up on the Reddoor stack with hosting, security, and automatic maintenance so it stays fast and healthy. Here's what's in place:",
|
|
3190
|
-
launchSetupItems: [
|
|
3191
|
-
"Hosting, DNS, and SSL configured",
|
|
3192
|
-
"Continuous integration + automatic dependency updates",
|
|
3193
|
-
"Analytics and uptime monitoring"
|
|
3194
|
-
],
|
|
3195
|
-
announceHeading: "YOUR ONGOING SITE CARE",
|
|
3196
|
-
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:",
|
|
3197
|
-
announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
|
|
3198
|
-
announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
|
|
3199
|
-
announceCadence: "After each one we'll send you a short report like this \u2014 there's nothing you need to do.",
|
|
3200
|
-
announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know."
|
|
3201
|
-
};
|
|
3202
|
-
function override(v) {
|
|
3203
|
-
if (typeof v !== "string") return null;
|
|
3204
|
-
const t = v.trim();
|
|
3205
|
-
return t.length > 0 ? t : null;
|
|
3206
|
-
}
|
|
3207
|
-
function splitLines(s) {
|
|
3208
|
-
return s.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
|
3209
|
-
}
|
|
3210
|
-
function resolveCopy(site) {
|
|
3211
|
-
const intro = override(site.copyIntro);
|
|
3212
|
-
const contact = override(site.copyContact);
|
|
3213
|
-
const footer = override(site.copyFooter);
|
|
3214
|
-
const footerLines = footer ? splitLines(footer) : null;
|
|
3215
|
-
return {
|
|
3216
|
-
...DEFAULT_COPY,
|
|
3217
|
-
maintenanceIntro: intro ?? DEFAULT_COPY.maintenanceIntro,
|
|
3218
|
-
contact: contact ? splitLines(contact) : DEFAULT_COPY.contact,
|
|
3219
|
-
footerOrg: footerLines?.[0] ?? DEFAULT_COPY.footerOrg,
|
|
3220
|
-
footerAddress: footerLines ? footerLines.slice(1) : DEFAULT_COPY.footerAddress
|
|
3221
|
-
};
|
|
3222
|
-
}
|
|
3223
|
-
|
|
3224
|
-
// src/reports/maintenance-email/assets/index.ts
|
|
3225
|
-
import { readFile as readFile11 } from "fs/promises";
|
|
3226
|
-
import { existsSync as existsSync3 } from "fs";
|
|
3227
|
-
import { dirname as dirname4, join as join20 } from "path";
|
|
3228
|
-
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
3229
|
-
var CHECK_CID = "rd-check-png";
|
|
3230
|
-
var BLURRED_CID = "rd-blurred-tests-jpg";
|
|
3231
|
-
var cachedAssetsDir = null;
|
|
3232
|
-
function resolveAssetsDir() {
|
|
3233
|
-
if (cachedAssetsDir) return cachedAssetsDir;
|
|
3234
|
-
let dir = dirname4(fileURLToPath2(import.meta.url));
|
|
3235
|
-
while (true) {
|
|
3236
|
-
const srcCandidate = join20(dir, "src", "reports", "maintenance-email", "assets", "check.png");
|
|
3237
|
-
if (existsSync3(srcCandidate)) {
|
|
3238
|
-
cachedAssetsDir = dirname4(srcCandidate);
|
|
3239
|
-
return cachedAssetsDir;
|
|
3240
|
-
}
|
|
3241
|
-
const distCandidate = join20(dir, "dist", "reports", "maintenance-email", "assets", "check.png");
|
|
3242
|
-
if (existsSync3(distCandidate)) {
|
|
3243
|
-
cachedAssetsDir = dirname4(distCandidate);
|
|
3244
|
-
return cachedAssetsDir;
|
|
3245
|
-
}
|
|
3246
|
-
const parent = dirname4(dir);
|
|
3247
|
-
if (parent === dir) {
|
|
3248
|
-
throw new Error(
|
|
3249
|
-
`loadBundledImages: could not locate maintenance-email assets dir by walking up from ${fileURLToPath2(import.meta.url)}. Checked both src/ and dist/ layouts.`
|
|
3250
|
-
);
|
|
3251
|
-
}
|
|
3252
|
-
dir = parent;
|
|
3253
|
-
}
|
|
3254
|
-
}
|
|
3255
|
-
async function loadBundledImages() {
|
|
3256
|
-
const assetsDir = resolveAssetsDir();
|
|
3257
|
-
const [check, blurred] = await Promise.all([
|
|
3258
|
-
readFile11(join20(assetsDir, "check.png")),
|
|
3259
|
-
readFile11(join20(assetsDir, "blurredTests.jpg"))
|
|
3260
|
-
]);
|
|
3261
|
-
return {
|
|
3262
|
-
check: {
|
|
3263
|
-
bytes: new Uint8Array(check),
|
|
3264
|
-
contentType: "image/png",
|
|
3265
|
-
cid: CHECK_CID,
|
|
3266
|
-
filename: "check.png"
|
|
3267
|
-
},
|
|
3268
|
-
blurred: {
|
|
3269
|
-
bytes: new Uint8Array(blurred),
|
|
3270
|
-
contentType: "image/jpeg",
|
|
3271
|
-
cid: BLURRED_CID,
|
|
3272
|
-
filename: "blurredTests.jpg"
|
|
3273
|
-
}
|
|
3274
|
-
};
|
|
3275
|
-
}
|
|
3276
|
-
|
|
3277
|
-
// src/util/html.ts
|
|
3278
|
-
function escapeHtml(s) {
|
|
3279
|
-
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
3280
|
-
}
|
|
3281
|
-
function safeUrl(raw) {
|
|
3282
|
-
try {
|
|
3283
|
-
const u = new URL(raw);
|
|
3284
|
-
if (u.protocol === "http:" || u.protocol === "https:") return raw;
|
|
3285
|
-
} catch {
|
|
3286
|
-
}
|
|
3287
|
-
return "#";
|
|
3288
|
-
}
|
|
3289
|
-
|
|
3290
|
-
// src/reports/email-sections.ts
|
|
3291
|
-
var escapeXml = escapeHtml;
|
|
3292
|
-
var RED = "#C00";
|
|
3293
|
-
var GREY = "#757575";
|
|
3294
|
-
var BORDER = "#CCCCCC";
|
|
3295
|
-
var TREND_UP = "#2E7D32";
|
|
3296
|
-
var TREND_NEUTRAL = GREY;
|
|
3297
|
-
var CHECK_PNG = `cid:${CHECK_CID}`;
|
|
3298
|
-
function fmtUsers(n) {
|
|
3299
|
-
return n.toLocaleString("en-US");
|
|
3300
|
-
}
|
|
3301
|
-
function checklistRowsSection(rows, opts) {
|
|
3302
|
-
return rows.map((label, i) => {
|
|
3303
|
-
const isLast = i === rows.length - 1;
|
|
3304
|
-
const border = isLast ? "" : ` border-bottom="solid ${BORDER} 1px"`;
|
|
3305
|
-
const lastPad = isLast ? ` padding-bottom="${opts.lastPaddingBottom}"` : "";
|
|
3306
|
-
return `
|
|
3307
|
-
<mj-section background-color="${opts.background}" padding="0px"${lastPad}>
|
|
3308
|
-
<mj-group>
|
|
3309
|
-
<mj-column padding-left="0px" width="90%"${border}>
|
|
3310
|
-
<mj-text height="25px" padding-left="0px" color="${GREY}" padding-top="20px" padding-bottom="7.5px" font-size="16px">${escapeXml(label)}</mj-text>
|
|
3311
|
-
</mj-column>
|
|
3312
|
-
<mj-column width="10%"${border} padding-top="15px">
|
|
3313
|
-
<mj-image align="right" padding-right="0px" width="20px" height="20px" padding-top="2.5px" padding-bottom="15px" src="${CHECK_PNG}" />
|
|
3314
|
-
</mj-column>
|
|
3315
|
-
</mj-group>
|
|
3316
|
-
</mj-section>`;
|
|
3317
|
-
}).join("");
|
|
3318
|
-
}
|
|
3319
|
-
var LIGHTHOUSE_ROWS = [
|
|
3320
|
-
{ label: "Performance", key: "performance", range: "Acceptable 50\u201389 // Ideal 90\u2013100" },
|
|
3321
|
-
{ label: "Readability (A11y)", key: "accessibility", range: "Acceptable 80\u201399 // Ideal 100" },
|
|
3322
|
-
{ label: "Best Practices", key: "bestPractices", range: "Acceptable 60\u201379 // Ideal 80\u2013100" },
|
|
3323
|
-
{ label: "Site Structure", key: "seo", range: "Acceptable 50\u201389 // Ideal 90\u2013100" }
|
|
3324
|
-
];
|
|
3325
|
-
function lighthouseScoresSection(lighthouse2, opts = {}) {
|
|
3326
|
-
const background = opts.background ?? "#F4F4F4";
|
|
3327
|
-
const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
|
|
3328
|
-
const labelTop = opts.pad ?? "55px";
|
|
3329
|
-
const footnoteBottom = opts.pad ? "0px" : "36px";
|
|
3330
|
-
const rows = LIGHTHOUSE_ROWS.map(
|
|
3331
|
-
({ label, key, range }, i) => `
|
|
3332
|
-
<mj-text color="${RED}" font-size="20px" font-weight="300" padding-top="25px">${label}</mj-text>
|
|
3333
|
-
<mj-text color="${RED}" font-size="44px" font-weight="400" padding-top="0px">${lighthouse2[key]}</mj-text>
|
|
3334
|
-
<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 ? `
|
|
3335
|
-
<mj-divider border-width="1px" border-style="solid" border-color="${BORDER}" padding="0" />` : ""}`
|
|
3336
|
-
).join("");
|
|
3337
|
-
return `
|
|
3338
|
-
<mj-section background-color="${background}"${sectionPad}>
|
|
3339
|
-
<mj-column>
|
|
3340
|
-
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">LIGHTHOUSE SCORES*</mj-text>${rows}
|
|
3341
|
-
<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>
|
|
3342
|
-
</mj-column>
|
|
3343
|
-
</mj-section>`;
|
|
3344
|
-
}
|
|
3345
|
-
function analyticsTrendLine(cur, prev) {
|
|
3346
|
-
if (cur === void 0 || prev === void 0) {
|
|
3347
|
-
return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
|
|
3348
|
-
}
|
|
3349
|
-
if (prev === 0) {
|
|
3350
|
-
return cur > 0 ? trendLine(TREND_UP, "\u25B2 New this period (0 last period)") : trendLine(TREND_NEUTRAL, "Last Period: 0");
|
|
3351
|
-
}
|
|
3352
|
-
const pct = Math.round((cur - prev) / prev * 100);
|
|
3353
|
-
const range = `(${fmtUsers(prev)} \u2192 ${fmtUsers(cur)})`;
|
|
3354
|
-
if (pct > 0) return trendLine(TREND_UP, `\u25B2 ${pct}% vs last period ${range}`);
|
|
3355
|
-
if (pct < 0) return trendLine(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs last period ${range}`);
|
|
3356
|
-
return trendLine(TREND_NEUTRAL, `No change vs last period (${fmtUsers(prev)})`);
|
|
3357
|
-
}
|
|
3358
|
-
function trendLine(color, text) {
|
|
3359
|
-
return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
|
|
3360
|
-
}
|
|
3361
|
-
function footnoteLine(text) {
|
|
3362
|
-
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>`;
|
|
3363
|
-
}
|
|
3364
|
-
function analyticsSection(opts) {
|
|
3365
|
-
const users = opts.current !== void 0 ? fmtUsers(opts.current) : "\u2014";
|
|
3366
|
-
const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join("\n ");
|
|
3367
|
-
const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join("\n ");
|
|
3368
|
-
const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
|
|
3369
|
-
const labelTop = opts.pad ?? "75px";
|
|
3370
|
-
return `
|
|
3371
|
-
<mj-section background-color="${opts.background}"${sectionPad}>
|
|
3372
|
-
<mj-column>
|
|
3373
|
-
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">ANALYTICS</mj-text>
|
|
3374
|
-
<mj-text color="${RED}" font-size="44px" font-weight="400">${users} Users</mj-text>
|
|
3375
|
-
${analyticsTrendLine(opts.current, opts.previous)}
|
|
3376
|
-
${body}
|
|
3377
|
-
${footnotes}
|
|
3378
|
-
</mj-column>
|
|
3379
|
-
</mj-section>`;
|
|
3380
|
-
}
|
|
3381
|
-
|
|
3382
|
-
// src/reports/maintenance-email/template.ts
|
|
3383
|
-
var escapeXml2 = escapeHtml;
|
|
3384
|
-
var BLURRED_TESTS = `cid:${BLURRED_CID}`;
|
|
3385
|
-
function fmtDate(d) {
|
|
3386
|
-
if (!d || Number.isNaN(d.getTime())) return "";
|
|
3387
|
-
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
3388
|
-
const dd = String(d.getUTCDate()).padStart(2, "0");
|
|
3389
|
-
const yyyy = d.getUTCFullYear();
|
|
3390
|
-
return `${mm}.${dd}.${yyyy}`;
|
|
3391
|
-
}
|
|
3392
|
-
function maintenanceChecksSection(copy, searchPosition) {
|
|
3393
|
-
const googleLabel = searchPosition !== void 0 ? `Page 1 Google Result (#${searchPosition})` : copy.maintenanceChecks[3] ?? "";
|
|
3394
|
-
const rows = copy.maintenanceChecks.map((label, i) => i === 3 ? googleLabel : label);
|
|
3395
|
-
return checklistRowsSection(rows, { background: "white", lastPaddingBottom: "36px" });
|
|
3396
|
-
}
|
|
3397
|
-
function testingChecklistSection(copy) {
|
|
3398
|
-
return checklistRowsSection(copy.testingChecklist, {
|
|
3399
|
-
background: "#F4F4F4",
|
|
3400
|
-
lastPaddingBottom: "60px"
|
|
3401
|
-
});
|
|
3402
|
-
}
|
|
3403
|
-
function maintenanceTestingPlaceholder(lastTested) {
|
|
3404
|
-
return `
|
|
3405
|
-
<mj-section background-color="#F4F4F4">
|
|
3406
|
-
<mj-column>
|
|
3407
|
-
<mj-image href="mailto:info@reddoorla.com" src="${BLURRED_TESTS}" />
|
|
3408
|
-
</mj-column>
|
|
3409
|
-
</mj-section>
|
|
3410
|
-
<mj-section background-color="#F4F4F4" padding-top="0px">
|
|
3411
|
-
<mj-column>
|
|
3412
|
-
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">Last Tested: ${fmtDate(lastTested)}</mj-text>
|
|
3413
|
-
</mj-column>
|
|
3414
|
-
</mj-section>`;
|
|
3415
|
-
}
|
|
3416
|
-
function testingIntroSection(copy) {
|
|
3417
|
-
return `
|
|
3418
|
-
<mj-section background-color="#F4F4F4">
|
|
3419
|
-
<mj-column>
|
|
3420
|
-
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">TESTING</mj-text>
|
|
3421
|
-
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.testingIntro)}</mj-text>
|
|
3422
|
-
</mj-column>
|
|
3423
|
-
</mj-section>`;
|
|
3424
|
-
}
|
|
3425
|
-
function commentarySection(text, copy) {
|
|
3426
|
-
return `
|
|
3427
|
-
<mj-section background-color="white">
|
|
3428
|
-
<mj-column>
|
|
3429
|
-
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="55px">${escapeXml2(copy.notesHeader)}</mj-text>
|
|
3430
|
-
<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>
|
|
3431
|
-
</mj-column>
|
|
3432
|
-
</mj-section>`;
|
|
3433
|
-
}
|
|
3434
|
-
function hasHeaderDims(data) {
|
|
3435
|
-
return Boolean(data.headerWidth && data.headerHeight && data.headerBgColor);
|
|
3436
|
-
}
|
|
3437
|
-
function headerImageTag(data) {
|
|
3438
|
-
const src = `cid:${data.headerImageCid}`;
|
|
3439
|
-
const alt = `${escapeXml2(data.siteName)} maintenance report`;
|
|
3440
|
-
const href = isHttpUrl(data.siteUrl) ? escapeXml2(data.siteUrl) : "#";
|
|
3441
|
-
if (hasHeaderDims(data)) {
|
|
3442
|
-
return `<mj-image href="${href}" src="${src}" alt="${alt}" width="${data.headerWidth}px" css-class="rd-header" container-background-color="${data.headerBgColor}" />`;
|
|
3443
|
-
}
|
|
3444
|
-
return `<mj-image href="${href}" src="${src}" alt="${alt}" />`;
|
|
3445
|
-
}
|
|
3446
|
-
function headerStyleBlock(data) {
|
|
3447
|
-
if (!hasHeaderDims(data)) return "";
|
|
3448
|
-
return `<mj-style>.rd-header img { height: auto !important; aspect-ratio: ${data.headerWidth} / ${data.headerHeight}; }</mj-style>`;
|
|
3449
|
-
}
|
|
3450
|
-
function buildMjml(data) {
|
|
3451
|
-
const copy = data.copy ?? DEFAULT_COPY;
|
|
3452
|
-
const isTesting = data.reportType === "Testing";
|
|
3453
|
-
const previewText = `Checked up on ${escapeXml2(data.siteName)}`;
|
|
3454
|
-
return `<mjml>
|
|
3455
|
-
<mj-head>
|
|
3456
|
-
<mj-attributes>
|
|
3457
|
-
<mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
|
|
3458
|
-
<mj-section padding-left="11%" padding-right="11%"/>
|
|
3459
|
-
<mj-image padding="0px" />
|
|
3460
|
-
</mj-attributes>
|
|
3461
|
-
<mj-preview>${previewText}</mj-preview>
|
|
3462
|
-
${headerStyleBlock(data)}
|
|
3463
|
-
</mj-head>
|
|
3464
|
-
<mj-body background-color="white">
|
|
3465
|
-
<mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
|
|
3466
|
-
<mj-column>
|
|
3467
|
-
${headerImageTag(data)}
|
|
3468
|
-
</mj-column>
|
|
3469
|
-
</mj-section>
|
|
3470
|
-
<mj-section background-color="white">
|
|
3471
|
-
<mj-column>
|
|
3472
|
-
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">COMPLETED ON</mj-text>
|
|
3473
|
-
<mj-text color="#C00" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
|
|
3474
|
-
<mj-text color="#C00" font-size="20px" font-weight="700" padding-top="75px">MAINTENANCE CHECKS</mj-text>
|
|
3475
|
-
<mj-text color="#757575" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${escapeXml2(copy.maintenanceIntro)}</mj-text>
|
|
3476
|
-
</mj-column>
|
|
3477
|
-
</mj-section>
|
|
3478
|
-
${maintenanceChecksSection(copy, data.searchPosition)}
|
|
3479
|
-
${lighthouseScoresSection(data.lighthouse)}
|
|
3480
|
-
${analyticsSection({
|
|
3481
|
-
current: data.gaUsersCurrent,
|
|
3482
|
-
previous: data.gaUsersPrevious,
|
|
3483
|
-
background: "white",
|
|
3484
|
-
footnoteLines: [escapeXml2(copy.seoCta)]
|
|
3485
|
-
})}
|
|
3486
|
-
${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder(data.lastTestedDate)}
|
|
3487
|
-
${data.commentary ? commentarySection(data.commentary, copy) : ""}
|
|
3488
|
-
<mj-section background-color="white">
|
|
3489
|
-
<mj-column padding-top="36px">
|
|
3490
|
-
<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>
|
|
3491
|
-
${copy.contact.map(
|
|
3492
|
-
(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>`
|
|
3493
|
-
).join("\n ")}
|
|
3494
|
-
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
3495
|
-
<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>
|
|
3496
|
-
<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>
|
|
3497
|
-
${[copy.footerOrg, ...copy.footerAddress].map(
|
|
3498
|
-
(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>`
|
|
3499
|
-
).join("\n ")}
|
|
3500
|
-
</mj-column>
|
|
3501
|
-
</mj-section>
|
|
3502
|
-
</mj-body>
|
|
3503
|
-
</mjml>`;
|
|
3504
|
-
}
|
|
3505
|
-
|
|
3506
|
-
// src/reports/launch-email/template.ts
|
|
3507
|
-
var RED2 = "#C00";
|
|
3508
|
-
var GREY2 = "#757575";
|
|
3509
|
-
function buildLaunchMjml(data) {
|
|
3510
|
-
const copy = data.copy ?? DEFAULT_COPY;
|
|
3511
|
-
const previewText = `${escapeXml2(data.siteName)} is live`;
|
|
3512
|
-
const setupRows = copy.launchSetupItems.map(
|
|
3513
|
-
(item) => `
|
|
3514
|
-
<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>`
|
|
3515
|
-
).join("");
|
|
3516
|
-
const contactRows = copy.contact.map(
|
|
3517
|
-
(line) => `
|
|
3518
|
-
<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
|
|
3519
|
-
).join("");
|
|
3520
|
-
const footerAddressRows = copy.footerAddress.map(
|
|
3521
|
-
(line) => `
|
|
3522
|
-
<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>`
|
|
3523
|
-
).join("");
|
|
3524
|
-
return `<mjml>
|
|
3525
|
-
<mj-head>
|
|
3526
|
-
<mj-attributes>
|
|
3527
|
-
<mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
|
|
3528
|
-
<mj-section padding-left="11%" padding-right="11%"/>
|
|
3529
|
-
<mj-image padding="0px" />
|
|
3530
|
-
</mj-attributes>
|
|
3531
|
-
<mj-preview>${previewText}</mj-preview>
|
|
3532
|
-
${headerStyleBlock(data)}
|
|
3533
|
-
</mj-head>
|
|
3534
|
-
<mj-body background-color="white">
|
|
3535
|
-
<mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
|
|
3536
|
-
<mj-column>${headerImageTag(data)}</mj-column>
|
|
3537
|
-
</mj-section>
|
|
3538
|
-
<mj-section background-color="white">
|
|
3539
|
-
<mj-column>
|
|
3540
|
-
<mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="75px">${escapeXml2(copy.launchHeading)}</mj-text>
|
|
3541
|
-
<mj-text color="${RED2}" font-size="44px" font-weight="400">${fmtDate(data.completedOn)}</mj-text>
|
|
3542
|
-
<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>
|
|
3543
|
-
${setupRows}
|
|
3544
|
-
</mj-column>
|
|
3545
|
-
</mj-section>
|
|
3546
|
-
<mj-section background-color="white">
|
|
3547
|
-
<mj-column padding-top="36px">
|
|
3548
|
-
<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>
|
|
3549
|
-
${contactRows}
|
|
3550
|
-
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
3551
|
-
<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>
|
|
3552
|
-
<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>
|
|
3553
|
-
<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>
|
|
3554
|
-
${footerAddressRows}
|
|
3555
|
-
</mj-column>
|
|
3556
|
-
</mj-section>
|
|
3557
|
-
</mj-body>
|
|
3558
|
-
</mjml>`;
|
|
3559
|
-
}
|
|
3560
|
-
|
|
3561
|
-
// src/reports/announcement-email/template.ts
|
|
3562
|
-
var FREQ_PHRASE = {
|
|
3563
|
-
Monthly: "every month",
|
|
3564
|
-
Quarterly: "every quarter",
|
|
3565
|
-
Yearly: "every year"
|
|
3566
|
-
};
|
|
3567
|
-
var RED3 = "#C00";
|
|
3568
|
-
var GREY3 = "#757575";
|
|
3569
|
-
var SECTION_PAD = "40px";
|
|
3570
|
-
function sectionLabel(text) {
|
|
3571
|
-
return `<mj-text color="${RED3}" font-size="20px" font-weight="700" padding-top="0px">${escapeXml2(text)}</mj-text>`;
|
|
3572
|
-
}
|
|
3573
|
-
function bodyLine(text, paddingTop = "8px") {
|
|
3574
|
-
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>`;
|
|
3575
|
-
}
|
|
3576
|
-
function announcementSiteExtras(site) {
|
|
3577
|
-
return {
|
|
3578
|
-
cadence: { maintenance: site.maintenanceFreq, testing: site.testingFreq },
|
|
3579
|
-
improvements: { resendForms: true, svelte5: true }
|
|
3580
|
-
};
|
|
3581
|
-
}
|
|
3582
|
-
function buildAnnouncementMjml(data) {
|
|
3583
|
-
const copy = data.copy ?? DEFAULT_COPY;
|
|
3584
|
-
const previewText = "Your monthly report from Reddoor";
|
|
3585
|
-
const cad = data.cadence;
|
|
3586
|
-
const hasMaint = Boolean(cad && cad.maintenance !== "None");
|
|
3587
|
-
const hasTesting = Boolean(cad && cad.testing !== "None");
|
|
3588
|
-
const improvementItems = [];
|
|
3589
|
-
if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
|
|
3590
|
-
if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
|
|
3591
|
-
const hasImpr = improvementItems.length > 0;
|
|
3592
|
-
const BANDS = ["white", "#F4F4F4"];
|
|
3593
|
-
let bandN = 0;
|
|
3594
|
-
const nextBg = () => BANDS[bandN++ % 2];
|
|
3595
|
-
const introBg = nextBg();
|
|
3596
|
-
const maintBg = hasMaint ? nextBg() : "";
|
|
3597
|
-
const testBg = hasTesting ? nextBg() : "";
|
|
3598
|
-
const lighthouseBg = nextBg();
|
|
3599
|
-
const analyticsBg = nextBg();
|
|
3600
|
-
const improvementsBg = hasImpr ? nextBg() : "";
|
|
3601
|
-
const contactBg = nextBg();
|
|
3602
|
-
const maintenanceSection = cad && cad.maintenance !== "None" ? `
|
|
3603
|
-
<mj-section background-color="${maintBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
|
|
3604
|
-
<mj-column>
|
|
3605
|
-
${sectionLabel("MAINTENANCE CHECKS")}
|
|
3606
|
-
${bodyLine(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.${cad.testing === "None" ? ` ${copy.announceCadence}` : ""}`)}
|
|
3607
|
-
</mj-column>
|
|
3608
|
-
</mj-section>${checklistRowsSection(copy.maintenanceChecks, {
|
|
3609
|
-
background: maintBg,
|
|
3610
|
-
lastPaddingBottom: SECTION_PAD
|
|
3611
|
-
})}` : "";
|
|
3612
|
-
const testingSection = cad && cad.testing !== "None" ? `
|
|
3613
|
-
<mj-section background-color="${testBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
|
|
3614
|
-
<mj-column>
|
|
3615
|
-
${sectionLabel("TESTING")}
|
|
3616
|
-
${bodyLine(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}. ${copy.announceCadence}`)}
|
|
3617
|
-
</mj-column>
|
|
3618
|
-
</mj-section>${checklistRowsSection(copy.testingChecklist, {
|
|
3619
|
-
background: testBg,
|
|
3620
|
-
lastPaddingBottom: SECTION_PAD
|
|
3621
|
-
})}` : "";
|
|
3622
|
-
const analytics = analyticsSection({
|
|
3623
|
-
current: data.gaUsersCurrent,
|
|
3624
|
-
previous: data.gaUsersPrevious,
|
|
3625
|
-
background: analyticsBg,
|
|
3626
|
-
pad: SECTION_PAD,
|
|
3627
|
-
bodyLines: data.searchPosition !== void 0 ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`] : []
|
|
3628
|
-
});
|
|
3629
|
-
const improvementsSection = hasImpr ? `
|
|
3630
|
-
<mj-section background-color="${improvementsBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
|
|
3631
|
-
<mj-column>
|
|
3632
|
-
${sectionLabel("RECENT IMPROVEMENTS")}
|
|
3633
|
-
${improvementItems.map((item) => bodyLine(item)).join("\n ")}
|
|
3634
|
-
${bodyLine(copy.announceOpenDoor, "16px")}
|
|
3635
|
-
</mj-column>
|
|
3636
|
-
</mj-section>` : "";
|
|
3637
|
-
const contactRows = copy.contact.map(
|
|
3638
|
-
(line) => `
|
|
3639
|
-
<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
|
|
3640
|
-
).join("");
|
|
3641
|
-
const footerAddressRows = copy.footerAddress.map(
|
|
3642
|
-
(line) => `
|
|
3643
|
-
<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>`
|
|
3644
|
-
).join("");
|
|
3645
|
-
return `<mjml>
|
|
3646
|
-
<mj-head>
|
|
3647
|
-
<mj-attributes>
|
|
3648
|
-
<mj-text font-family="helvetica, sans-serif" padding-left="5px" padding-right="5px" />
|
|
3649
|
-
<mj-section padding-left="11%" padding-right="11%"/>
|
|
3650
|
-
<mj-image padding="0px" />
|
|
3651
|
-
</mj-attributes>
|
|
3652
|
-
<mj-preview>${escapeXml2(previewText)}</mj-preview>
|
|
3653
|
-
${headerStyleBlock(data)}
|
|
3654
|
-
</mj-head>
|
|
3655
|
-
<mj-body background-color="white">
|
|
3656
|
-
<mj-section background-color="#F4F4F4" padding-top="0px" padding-bottom="0px" padding-left="0px" padding-right="0px">
|
|
3657
|
-
<mj-column>${headerImageTag(data)}</mj-column>
|
|
3658
|
-
</mj-section>
|
|
3659
|
-
<mj-section background-color="${introBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
|
|
3660
|
-
<mj-column>
|
|
3661
|
-
${sectionLabel(copy.announceHeading)}
|
|
3662
|
-
<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>
|
|
3663
|
-
${bodyLine(copy.announceBody)}
|
|
3664
|
-
</mj-column>
|
|
3665
|
-
</mj-section>
|
|
3666
|
-
${maintenanceSection}
|
|
3667
|
-
${testingSection}
|
|
3668
|
-
${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}
|
|
3669
|
-
${analytics}
|
|
3670
|
-
${improvementsSection}
|
|
3671
|
-
<mj-section background-color="${contactBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
|
|
3672
|
-
<mj-column>
|
|
3673
|
-
<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>
|
|
3674
|
-
${contactRows}
|
|
3675
|
-
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
3676
|
-
<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>
|
|
3677
|
-
<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>
|
|
3678
|
-
<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>
|
|
3679
|
-
${footerAddressRows}
|
|
3680
|
-
</mj-column>
|
|
3681
|
-
</mj-section>
|
|
3682
|
-
</mj-body>
|
|
3683
|
-
</mjml>`;
|
|
3684
|
-
}
|
|
3685
|
-
|
|
3686
|
-
// src/reports/render.ts
|
|
3687
|
-
async function renderReportHtml(data) {
|
|
3688
|
-
const mjml = data.reportType === "Launch" ? buildLaunchMjml(data) : data.reportType === "Announcement" ? buildAnnouncementMjml(data) : buildMjml(data);
|
|
3689
|
-
const out = await mjml2html(mjml, { validationLevel: "strict" });
|
|
3690
|
-
return { html: out.html, warnings: out.errors ?? [] };
|
|
3691
|
-
}
|
|
3692
|
-
|
|
3693
|
-
// src/reports/checklist.ts
|
|
3694
|
-
var MAINTENANCE_CHECKLIST = [
|
|
3695
|
-
{ key: "deploy", label: "Deploy & Function Health", field: "Maint: Deploy & Function Health" },
|
|
3696
|
-
{ key: "cms", label: "CMS Checked", field: "Maint: CMS Checked" },
|
|
3697
|
-
{ key: "domain", label: "Domain, DNS & SSL", field: "Maint: Domain, DNS & SSL" },
|
|
3698
|
-
{ key: "google", label: "Google Indexed", field: "Maint: Google Indexed" },
|
|
3699
|
-
{ key: "security", label: "Security Updates", field: "Maint: Security Updates" },
|
|
3700
|
-
{ key: "uptime", label: "Uptime Checked", field: "Maint: Uptime Checked" }
|
|
3701
|
-
];
|
|
3702
|
-
var TESTING_CHECKLIST = [
|
|
3703
|
-
{ key: "desktop", label: "Desktop Browsers", field: "Test: Desktop Browsers" },
|
|
3704
|
-
{ key: "mobile", label: "Mobile Browsers", field: "Test: Mobile Browsers" },
|
|
3705
|
-
{ key: "titles", label: "Page Titles & Meta", field: "Test: Page Titles & Meta" },
|
|
3706
|
-
{ key: "links", label: "Links & Navigation", field: "Test: Links & Navigation" },
|
|
3707
|
-
{ key: "forms", label: "Form Functionality", field: "Test: Form Functionality" },
|
|
3708
|
-
{
|
|
3709
|
-
key: "interactions",
|
|
3710
|
-
label: "Interactions & Animations",
|
|
3711
|
-
field: "Test: Interactions & Animations"
|
|
3712
|
-
},
|
|
3713
|
-
// `field` keeps its original Airtable column name ("…Verified After Updates") even though
|
|
3714
|
-
// the client-facing label is now "Tested After Updates" — the column holds operator data,
|
|
3715
|
-
// so renaming the label is display-only and avoids a live-base column migration.
|
|
3716
|
-
{ key: "updates", label: "Tested After Updates", field: "Test: Verified After Updates" }
|
|
3717
|
-
];
|
|
3718
|
-
var ALL_CHECKLIST_FIELDS = [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST].map(
|
|
3719
|
-
(i) => i.field
|
|
3720
|
-
);
|
|
3721
|
-
function checklistFor(type) {
|
|
3722
|
-
if (type === "Maintenance") return MAINTENANCE_CHECKLIST;
|
|
3723
|
-
if (type === "Testing") return [...MAINTENANCE_CHECKLIST, ...TESTING_CHECKLIST];
|
|
3724
|
-
return [];
|
|
3725
|
-
}
|
|
3726
|
-
function isChecklistComplete(report) {
|
|
3727
|
-
return checklistFor(report.reportType).every((i) => report.checklist[i.field] === true);
|
|
3728
|
-
}
|
|
3729
|
-
|
|
3730
|
-
// src/reports/airtable/reports.ts
|
|
3731
|
-
var REPORTS_TABLE = "Reports";
|
|
3732
|
-
var REPORT_TYPES = ["Maintenance", "Testing", "Launch", "Announcement"];
|
|
3733
|
-
function toReportType(raw) {
|
|
3734
|
-
if (raw && REPORT_TYPES.includes(raw)) return raw;
|
|
3735
|
-
if (raw)
|
|
3736
|
-
console.warn(`[reports] unknown Report type ${JSON.stringify(raw)} \u2014 treating as Maintenance`);
|
|
3737
|
-
return "Maintenance";
|
|
3738
|
-
}
|
|
3739
|
-
function isPendingApproval(r) {
|
|
3740
|
-
return r.draftReady && !r.approvedToSend && r.sentAt === null;
|
|
3741
|
-
}
|
|
3742
|
-
function mapRow2(rec) {
|
|
3743
|
-
const f = rec.fields;
|
|
3744
|
-
const linkSites = f["Site"] ?? [];
|
|
3745
|
-
const html = (f["Rendered HTML"] ?? [])[0] ?? null;
|
|
3746
|
-
return {
|
|
3747
|
-
id: rec.id,
|
|
3748
|
-
reportId: String(f["Report ID"] ?? ""),
|
|
3749
|
-
siteId: linkSites[0] ?? "",
|
|
3750
|
-
reportType: toReportType(f["Report type"]),
|
|
3751
|
-
period: f["Period"] ?? null,
|
|
3752
|
-
periodStart: f["Period start"] ?? null,
|
|
3753
|
-
periodEnd: f["Period end"] ?? null,
|
|
3754
|
-
completedOn: f["Completed on"] ?? null,
|
|
3755
|
-
lighthouse: lighthouseFromFields(f),
|
|
3756
|
-
gaUsersCurrent: f["GA users (period)"] ?? null,
|
|
3757
|
-
gaUsersPrevious: f["GA users (prev period)"] ?? null,
|
|
3758
|
-
searchFoundPage1: typeof f["Search found page 1"] === "boolean" ? f["Search found page 1"] : null,
|
|
3759
|
-
searchPosition: f["Search position"] ?? null,
|
|
3760
|
-
lastTestedDate: f["Last tested date"] ?? null,
|
|
3761
|
-
commentary: f["Commentary"] ?? null,
|
|
3762
|
-
subjectOverride: f["Subject override"] ?? null,
|
|
3763
|
-
draftReady: Boolean(f["Draft ready"]),
|
|
3764
|
-
approvedToSend: Boolean(f["Approved to send"]),
|
|
3765
|
-
sentAt: f["Sent at"] ?? null,
|
|
3766
|
-
approvedAt: f["Approved At"] ?? null,
|
|
3767
|
-
approvedBy: f["Approved By"] ?? null,
|
|
3768
|
-
deliveryStatus: f["Delivery status"] ?? "pending",
|
|
3769
|
-
renderedHtmlAttachment: html,
|
|
3770
|
-
resendMessageId: f["Resend message ID"] ?? null,
|
|
3771
|
-
checklist: Object.fromEntries(ALL_CHECKLIST_FIELDS.map((name) => [name, Boolean(f[name])])),
|
|
3772
|
-
autoEvidence: parseAutoEvidence(f["Checklist auto-evidence"])
|
|
3773
|
-
};
|
|
3774
|
-
}
|
|
3775
|
-
function parseAutoEvidence(raw) {
|
|
3776
|
-
if (typeof raw !== "string" || !raw.trim()) return null;
|
|
3777
|
-
let parsed;
|
|
3778
|
-
try {
|
|
3779
|
-
parsed = JSON.parse(raw);
|
|
3780
|
-
} catch {
|
|
3781
|
-
return null;
|
|
3782
|
-
}
|
|
3783
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
3784
|
-
const out = {};
|
|
3785
|
-
for (const [field, v] of Object.entries(parsed)) {
|
|
3786
|
-
if (!v || typeof v !== "object") continue;
|
|
3787
|
-
const o = v;
|
|
3788
|
-
if (o.result !== "pass" && o.result !== "fail" && o.result !== "unknown") continue;
|
|
3789
|
-
out[field] = {
|
|
3790
|
-
result: o.result,
|
|
3791
|
-
checkedAt: typeof o.checkedAt === "string" ? o.checkedAt : null,
|
|
3792
|
-
note: typeof o.note === "string" ? o.note : ""
|
|
3793
|
-
};
|
|
3794
|
-
}
|
|
3795
|
-
return Object.keys(out).length > 0 ? out : null;
|
|
3796
|
-
}
|
|
3797
|
-
function lighthouseFromFields(f) {
|
|
3798
|
-
const p = f["Lighthouse \u2014 Performance"];
|
|
3799
|
-
const a = f["Lighthouse \u2014 Accessibility"];
|
|
3800
|
-
const b = f["Lighthouse \u2014 Best Practices"];
|
|
3801
|
-
const s = f["Lighthouse \u2014 SEO"];
|
|
3802
|
-
if (typeof p !== "number" || typeof a !== "number" || typeof b !== "number" || typeof s !== "number")
|
|
3803
|
-
return null;
|
|
3804
|
-
return { performance: p, accessibility: a, bestPractices: b, seo: s };
|
|
3805
|
-
}
|
|
3806
|
-
function ymd(d) {
|
|
3807
|
-
return d.toISOString().slice(0, 10);
|
|
3808
|
-
}
|
|
3809
|
-
async function createDraft(base, input) {
|
|
3810
|
-
const fields = {
|
|
3811
|
-
"Report ID": input.reportId,
|
|
3812
|
-
Site: [input.siteId],
|
|
3813
|
-
"Report type": input.reportType,
|
|
3814
|
-
"Period start": ymd(input.periodStart),
|
|
3815
|
-
"Period end": ymd(input.periodEnd),
|
|
3816
|
-
"Completed on": ymd(input.completedOn),
|
|
3817
|
-
"Lighthouse \u2014 Performance": input.lighthouse.performance,
|
|
3818
|
-
"Lighthouse \u2014 Accessibility": input.lighthouse.accessibility,
|
|
3819
|
-
"Lighthouse \u2014 Best Practices": input.lighthouse.bestPractices,
|
|
3820
|
-
"Lighthouse \u2014 SEO": input.lighthouse.seo,
|
|
3821
|
-
"Delivery status": "pending"
|
|
3822
|
-
};
|
|
3823
|
-
if (input.lastTestedDate) fields["Last tested date"] = ymd(input.lastTestedDate);
|
|
3824
|
-
if (input.gaUsersCurrent !== void 0) fields["GA users (period)"] = input.gaUsersCurrent;
|
|
3825
|
-
if (input.gaUsersPrevious !== void 0) fields["GA users (prev period)"] = input.gaUsersPrevious;
|
|
3826
|
-
if (input.searchFoundPage1 !== void 0) fields["Search found page 1"] = input.searchFoundPage1;
|
|
3827
|
-
if (input.searchPosition !== void 0) fields["Search position"] = input.searchPosition;
|
|
3828
|
-
if (input.period !== void 0) fields["Period"] = input.period;
|
|
3829
|
-
if (input.subjectOverride !== void 0) fields["Subject override"] = input.subjectOverride;
|
|
3830
|
-
for (const field of input.checklistTicks ?? []) fields[field] = true;
|
|
3831
|
-
if (input.autoEvidence && Object.keys(input.autoEvidence).length > 0) {
|
|
3832
|
-
fields["Checklist auto-evidence"] = JSON.stringify(input.autoEvidence);
|
|
3833
|
-
}
|
|
3834
|
-
const created = await base(REPORTS_TABLE).create([{ fields }]);
|
|
3835
|
-
const rec = created[0];
|
|
3836
|
-
if (!rec) throw new Error("Airtable create returned no records");
|
|
3837
|
-
return mapRow2({ id: rec.id, fields: rec.fields });
|
|
3838
|
-
}
|
|
3839
|
-
async function setDraftReady(base, recordId, ready) {
|
|
3840
|
-
await base(REPORTS_TABLE).update([{ id: recordId, fields: { "Draft ready": ready } }]);
|
|
3841
|
-
}
|
|
3842
|
-
async function listSendableReports(base) {
|
|
3843
|
-
const out = [];
|
|
3844
|
-
await base(REPORTS_TABLE).select({
|
|
3845
|
-
filterByFormula: "AND({Draft ready} = TRUE(), {Approved to send} = TRUE(), {Sent at} = BLANK())",
|
|
3846
|
-
pageSize: 100
|
|
3847
|
-
}).eachPage((records, fetchNextPage) => {
|
|
3848
|
-
for (const rec of records) out.push(mapRow2({ id: rec.id, fields: rec.fields }));
|
|
3849
|
-
fetchNextPage();
|
|
3850
|
-
});
|
|
3851
|
-
return out;
|
|
3852
|
-
}
|
|
3853
|
-
async function listAllReports(base) {
|
|
3854
|
-
const out = [];
|
|
3855
|
-
await base(REPORTS_TABLE).select({ pageSize: 100 }).eachPage((records, fetchNextPage) => {
|
|
3856
|
-
for (const rec of records) out.push(mapRow2({ id: rec.id, fields: rec.fields }));
|
|
3857
|
-
fetchNextPage();
|
|
3858
|
-
});
|
|
3859
|
-
return out;
|
|
3860
|
-
}
|
|
3861
|
-
async function listReportsForSite(base, siteId) {
|
|
3862
|
-
return (await listAllReports(base)).filter((r) => r.siteId === siteId);
|
|
3863
|
-
}
|
|
3864
|
-
async function stampSent(base, recordId, sentAt, messageId) {
|
|
3865
|
-
const fields = { "Sent at": sentAt.toISOString() };
|
|
3866
|
-
if (messageId !== null) fields["Resend message ID"] = messageId;
|
|
3867
|
-
await base(REPORTS_TABLE).update([
|
|
3868
|
-
{
|
|
3869
|
-
id: recordId,
|
|
3870
|
-
fields
|
|
3871
|
-
}
|
|
3872
|
-
]);
|
|
3873
|
-
}
|
|
3874
|
-
|
|
3875
|
-
// src/reports/queue.ts
|
|
3876
|
-
var REPORT_TIER = {
|
|
3877
|
-
Maintenance: 1,
|
|
3878
|
-
Testing: 2,
|
|
3879
|
-
Announcement: 3,
|
|
3880
|
-
Launch: 3
|
|
3881
|
-
};
|
|
3882
|
-
function reportTier(type) {
|
|
3883
|
-
return REPORT_TIER[type];
|
|
3884
|
-
}
|
|
3885
|
-
async function queueDraft(base, report) {
|
|
3886
|
-
const newTier = reportTier(report.reportType);
|
|
3887
|
-
const others = (await listReportsForSite(base, report.siteId)).filter(isPendingApproval).filter((r) => r.id !== report.id);
|
|
3888
|
-
const blocker = others.find((r) => reportTier(r.reportType) >= newTier);
|
|
3889
|
-
if (blocker) {
|
|
3890
|
-
await setDraftReady(base, report.id, false);
|
|
3891
|
-
return { queued: false, blockedBy: blocker.reportType, supersededIds: [] };
|
|
3892
|
-
}
|
|
3893
|
-
const supersededIds = [];
|
|
3894
|
-
for (const r of others) {
|
|
3895
|
-
await setDraftReady(base, r.id, false);
|
|
3896
|
-
supersededIds.push(r.id);
|
|
3897
|
-
}
|
|
3898
|
-
await setDraftReady(base, report.id, true);
|
|
3899
|
-
return { queued: true, supersededIds };
|
|
3900
|
-
}
|
|
3901
|
-
|
|
3902
|
-
// src/reports/auto-tick.ts
|
|
3903
|
-
var STALE_DAYS = 3;
|
|
3904
|
-
var MS_PER_DAY2 = 24 * 60 * 60 * 1e3;
|
|
3905
|
-
function isFresh(checkedAt, now) {
|
|
3906
|
-
if (!checkedAt) return false;
|
|
3907
|
-
const t = new Date(checkedAt).getTime();
|
|
3908
|
-
if (Number.isNaN(t)) return false;
|
|
3909
|
-
return now.getTime() - t <= STALE_DAYS * MS_PER_DAY2;
|
|
3910
|
-
}
|
|
3911
|
-
var CERT_MIN_DAYS = 14;
|
|
3912
|
-
function autoTickChecklist(site, reportType, now, signals) {
|
|
3913
|
-
const out = /* @__PURE__ */ new Map();
|
|
3914
|
-
const fields = new Set(checklistFor(reportType).map((i) => i.field));
|
|
3915
|
-
if (fields.has("Maint: Google Indexed")) {
|
|
3916
|
-
const g = googleEvidence(now, signals.search);
|
|
3917
|
-
if (g) out.set("Maint: Google Indexed", g);
|
|
3918
|
-
}
|
|
3919
|
-
if (fields.has("Maint: Security Updates")) {
|
|
3920
|
-
const s = securityEvidence(site, now);
|
|
3921
|
-
if (s) out.set("Maint: Security Updates", s);
|
|
3922
|
-
}
|
|
3923
|
-
if (fields.has("Maint: Domain, DNS & SSL")) {
|
|
3924
|
-
const d = domainEvidence(site, now);
|
|
3925
|
-
if (d) out.set("Maint: Domain, DNS & SSL", d);
|
|
3926
|
-
}
|
|
3927
|
-
if (fields.has("Test: Desktop Browsers")) {
|
|
3928
|
-
const e = browserEvidence(
|
|
3929
|
-
site.crossbrowserOk,
|
|
3930
|
-
site,
|
|
3931
|
-
now,
|
|
3932
|
-
"Desktop renders cleanly",
|
|
3933
|
-
"render errors"
|
|
3934
|
-
);
|
|
3935
|
-
if (e) out.set("Test: Desktop Browsers", e);
|
|
3936
|
-
}
|
|
3937
|
-
if (fields.has("Test: Mobile Browsers")) {
|
|
3938
|
-
const e = browserEvidence(
|
|
3939
|
-
site.mobileOk,
|
|
3940
|
-
site,
|
|
3941
|
-
now,
|
|
3942
|
-
"Mobile renders cleanly",
|
|
3943
|
-
"overflow/errors"
|
|
3944
|
-
);
|
|
3945
|
-
if (e) out.set("Test: Mobile Browsers", e);
|
|
3946
|
-
}
|
|
3947
|
-
if (fields.has("Test: Links & Navigation")) {
|
|
3948
|
-
const broken = site.brokenLinks;
|
|
3949
|
-
const failNote = broken && broken > 0 ? `${broken} broken link(s)` : "broken links / nav";
|
|
3950
|
-
const e = browserEvidence(site.linksOk, site, now, "All internal links resolve", failNote);
|
|
3951
|
-
if (e) out.set("Test: Links & Navigation", e);
|
|
3952
|
-
}
|
|
3953
|
-
return out;
|
|
3954
|
-
}
|
|
3955
|
-
function browserEvidence(ok, site, now, passNote, failNote) {
|
|
3956
|
-
if (ok === null || !site.browserCheckedAt) return null;
|
|
3957
|
-
const at = site.browserCheckedAt;
|
|
3958
|
-
if (!isFresh(at, now)) {
|
|
3959
|
-
return { result: "unknown", checkedAt: at, note: "Browser check is stale (>3d)" };
|
|
3960
|
-
}
|
|
3961
|
-
return ok ? { result: "pass", checkedAt: at, note: passNote } : { result: "fail", checkedAt: at, note: failNote };
|
|
3962
|
-
}
|
|
3963
|
-
function securityEvidence(site, now) {
|
|
3964
|
-
const crit = site.securityVulnsCritical;
|
|
3965
|
-
const high = site.securityVulnsHigh;
|
|
3966
|
-
if (crit === null || high === null || !site.lastSecurityAuditAt) return null;
|
|
3967
|
-
const at = site.lastSecurityAuditAt;
|
|
3968
|
-
if (!isFresh(at, now)) {
|
|
3969
|
-
return { result: "unknown", checkedAt: at, note: "Security audit is stale (>3d)" };
|
|
3970
|
-
}
|
|
3971
|
-
if (crit === 0 && high === 0) {
|
|
3972
|
-
return { result: "pass", checkedAt: at, note: "No known critical/high vulnerabilities" };
|
|
3973
|
-
}
|
|
3974
|
-
return { result: "fail", checkedAt: at, note: `${crit} critical / ${high} high vuln(s)` };
|
|
3975
|
-
}
|
|
3976
|
-
function googleEvidence(now, search) {
|
|
3977
|
-
const at = now.toISOString();
|
|
3978
|
-
if (search.softFailed) {
|
|
3979
|
-
return { result: "unknown", checkedAt: at, note: "Search Console unavailable this run" };
|
|
3980
|
-
}
|
|
3981
|
-
if (search.value === null) return null;
|
|
3982
|
-
if (search.value.foundOnPage1) {
|
|
3983
|
-
const pos2 = search.value.position;
|
|
3984
|
-
return {
|
|
3985
|
-
result: "pass",
|
|
3986
|
-
checkedAt: at,
|
|
3987
|
-
note: `Page 1 on Google${pos2 !== null ? ` (#${pos2})` : ""}`
|
|
3988
|
-
};
|
|
3989
|
-
}
|
|
3990
|
-
const pos = search.value.position;
|
|
3991
|
-
return {
|
|
3992
|
-
result: "fail",
|
|
3993
|
-
checkedAt: at,
|
|
3994
|
-
note: `Not on page 1${pos !== null ? ` (avg #${pos})` : ""}`
|
|
3995
|
-
};
|
|
3996
|
-
}
|
|
3997
|
-
function domainEvidence(site, now) {
|
|
3998
|
-
if (!site.url || isNetlifyAppUrl(site.url)) return null;
|
|
3999
|
-
if (!site.domainCheckedAt) return null;
|
|
4000
|
-
const at = site.domainCheckedAt;
|
|
4001
|
-
if (!isFresh(site.domainCheckedAt, now)) {
|
|
4002
|
-
return { result: "unknown", checkedAt: at, note: "Domain check is stale (>3d)" };
|
|
4003
|
-
}
|
|
4004
|
-
const days = site.certDaysRemaining;
|
|
4005
|
-
if (days === null) {
|
|
4006
|
-
return { result: "fail", checkedAt: at, note: "Did not resolve, or no valid TLS cert" };
|
|
4007
|
-
}
|
|
4008
|
-
if (days <= CERT_MIN_DAYS) {
|
|
4009
|
-
return { result: "fail", checkedAt: at, note: `TLS cert expires in ${days}d` };
|
|
4010
|
-
}
|
|
4011
|
-
return { result: "pass", checkedAt: at, note: `Custom domain, valid cert (${days}d left)` };
|
|
4012
|
-
}
|
|
4013
|
-
|
|
4014
|
-
// src/reports/airtable/attachments.ts
|
|
4015
|
-
function looksLikeHtml(bytes) {
|
|
4016
|
-
const start = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
|
|
4017
|
-
const head = Buffer.from(bytes.slice(start, start + 64)).toString("ascii").replace(/^[\s]+/, "").toLowerCase();
|
|
4018
|
-
return head.startsWith("<!doctype html") || head.startsWith("<html") || head.startsWith("<head");
|
|
4019
|
-
}
|
|
4020
|
-
async function fetchAttachmentBytes(url) {
|
|
4021
|
-
const res = await fetch(url);
|
|
4022
|
-
if (!res.ok) {
|
|
4023
|
-
throw new Error(
|
|
4024
|
-
`Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`
|
|
4025
|
-
);
|
|
4026
|
-
}
|
|
4027
|
-
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
|
4028
|
-
const ab = await res.arrayBuffer();
|
|
4029
|
-
const bytes = new Uint8Array(ab);
|
|
4030
|
-
const isImageType = contentType.toLowerCase().startsWith("image/");
|
|
4031
|
-
if (!isImageType && looksLikeHtml(bytes)) {
|
|
4032
|
-
throw new Error(
|
|
4033
|
-
`Airtable attachment did not return image data (content-type="${contentType}", body looks like an HTML page \u2014 the signed URL may have expired) (url=${url})`
|
|
4034
|
-
);
|
|
4035
|
-
}
|
|
4036
|
-
return { bytes, contentType };
|
|
4037
|
-
}
|
|
4038
|
-
async function uploadAttachment(recordId, fieldName, body, filename, contentType) {
|
|
4039
|
-
const apiKey = process.env.AIRTABLE_PAT;
|
|
4040
|
-
const baseId = process.env.AIRTABLE_BASE_ID;
|
|
4041
|
-
if (!apiKey || !baseId) {
|
|
4042
|
-
throw new Error("AIRTABLE_PAT and AIRTABLE_BASE_ID must be set");
|
|
4043
|
-
}
|
|
4044
|
-
const base64 = typeof body === "string" ? Buffer.from(body, "utf-8").toString("base64") : Buffer.from(body).toString("base64");
|
|
4045
|
-
const payload = { contentType, file: base64, filename };
|
|
4046
|
-
const url = `https://content.airtable.com/v0/${baseId}/${recordId}/${encodeURIComponent(fieldName)}/uploadAttachment`;
|
|
4047
|
-
const res = await fetch(url, {
|
|
4048
|
-
method: "POST",
|
|
4049
|
-
headers: {
|
|
4050
|
-
Authorization: `Bearer ${apiKey}`,
|
|
4051
|
-
"Content-Type": "application/json"
|
|
4052
|
-
},
|
|
4053
|
-
body: JSON.stringify(payload)
|
|
4054
|
-
});
|
|
4055
|
-
if (!res.ok) {
|
|
4056
|
-
throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);
|
|
4057
|
-
}
|
|
4058
|
-
}
|
|
4059
|
-
|
|
4060
|
-
// src/reports/ga/config.ts
|
|
4061
|
-
import { dirname as dirname5, join as join22 } from "path";
|
|
4062
|
-
|
|
4063
|
-
// src/util/credentials.ts
|
|
4064
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
4065
|
-
import { homedir } from "os";
|
|
4066
|
-
import { join as join21 } from "path";
|
|
4067
|
-
function defaultCredentialsPath() {
|
|
4068
|
-
const base = process.env.XDG_CONFIG_HOME ?? join21(homedir(), ".config");
|
|
4069
|
-
return join21(base, "reddoor-maint", "credentials.env");
|
|
4070
|
-
}
|
|
4071
|
-
|
|
4072
|
-
// src/reports/ga/config.ts
|
|
4073
|
-
function readGaConfig() {
|
|
4074
|
-
const subject = process.env.GA_SUBJECT?.trim();
|
|
4075
|
-
if (!subject) return null;
|
|
4076
|
-
const keyPath = process.env.GA_SA_KEY_PATH?.trim() || join22(dirname5(defaultCredentialsPath()), "ga-service-account.json");
|
|
4077
|
-
return { subject, keyPath };
|
|
4078
|
-
}
|
|
4079
|
-
|
|
4080
|
-
// src/reports/ga/client.ts
|
|
4081
|
-
import { readFileSync as readFileSync3 } from "fs";
|
|
4082
|
-
import { JWT } from "google-auth-library";
|
|
4083
|
-
import { BetaAnalyticsDataClient } from "@google-analytics/data";
|
|
4084
|
-
var ANALYTICS_READONLY = "https://www.googleapis.com/auth/analytics.readonly";
|
|
4085
|
-
var MS_PER_DAY3 = 864e5;
|
|
4086
|
-
function ymd2(d) {
|
|
4087
|
-
return d.toISOString().slice(0, 10);
|
|
4088
|
-
}
|
|
4089
|
-
async function fetchPeriodUsers(query, periodStart, periodEnd) {
|
|
4090
|
-
const key = JSON.parse(readFileSync3(query.keyPath, "utf8"));
|
|
4091
|
-
const authClient = new JWT({
|
|
4092
|
-
email: key.client_email,
|
|
4093
|
-
key: key.private_key,
|
|
4094
|
-
scopes: [ANALYTICS_READONLY],
|
|
4095
|
-
subject: query.subject
|
|
4096
|
-
});
|
|
4097
|
-
const client = new BetaAnalyticsDataClient({ authClient });
|
|
4098
|
-
const lengthDays = Math.round((periodEnd.getTime() - periodStart.getTime()) / MS_PER_DAY3);
|
|
4099
|
-
const prevEnd = new Date(periodStart.getTime() - MS_PER_DAY3);
|
|
4100
|
-
const prevStart = new Date(prevEnd.getTime() - lengthDays * MS_PER_DAY3);
|
|
4101
|
-
const property = `properties/${query.propertyId}`;
|
|
4102
|
-
const run = async (start, end) => {
|
|
4103
|
-
const [resp] = await client.runReport({
|
|
4104
|
-
property,
|
|
4105
|
-
dateRanges: [{ startDate: ymd2(start), endDate: ymd2(end) }],
|
|
4106
|
-
metrics: [{ name: "activeUsers" }]
|
|
4107
|
-
});
|
|
4108
|
-
const raw = resp.rows?.[0]?.metricValues?.[0]?.value ?? "0";
|
|
4109
|
-
const n = Number.parseInt(raw, 10);
|
|
4110
|
-
return Number.isFinite(n) ? n : 0;
|
|
4111
|
-
};
|
|
4112
|
-
const current = await run(periodStart, periodEnd);
|
|
4113
|
-
const previous = await run(prevStart, prevEnd);
|
|
4114
|
-
return { current, previous };
|
|
4115
|
-
}
|
|
4116
|
-
|
|
4117
|
-
// src/reports/search/client.ts
|
|
4118
|
-
import { readFileSync as readFileSync4 } from "fs";
|
|
4119
|
-
import { JWT as JWT2 } from "google-auth-library";
|
|
4120
|
-
var WEBMASTERS_READONLY = "https://www.googleapis.com/auth/webmasters.readonly";
|
|
4121
|
-
var SC_BASE = "https://searchconsole.googleapis.com/webmasters/v3";
|
|
4122
|
-
var PAGE_1_MAX_POSITION = 10;
|
|
4123
|
-
var BRAND_QUERY_ROW_LIMIT = 100;
|
|
4124
|
-
function bareHost(s) {
|
|
4125
|
-
return s.trim().replace(/^sc-domain:/i, "").replace(/^https?:\/\//i, "").split("/")[0].replace(/^www\./i, "").toLowerCase();
|
|
4126
|
-
}
|
|
4127
|
-
function resolvePropertyCandidates(entries, host) {
|
|
4128
|
-
const target = bareHost(host);
|
|
4129
|
-
const matches = entries.filter((e) => bareHost(e.siteUrl) === target).map((e) => e.siteUrl);
|
|
4130
|
-
const domains = matches.filter((s) => s.toLowerCase().startsWith("sc-domain:"));
|
|
4131
|
-
const prefixes = matches.filter((s) => !s.toLowerCase().startsWith("sc-domain:"));
|
|
4132
|
-
return [...domains, ...prefixes];
|
|
4133
|
-
}
|
|
4134
|
-
function ymd3(d) {
|
|
4135
|
-
return d.toISOString().slice(0, 10);
|
|
4136
|
-
}
|
|
4137
|
-
function pickBrandQuery(rows) {
|
|
4138
|
-
let best;
|
|
4139
|
-
for (const r of rows) {
|
|
4140
|
-
if (typeof r.position !== "number") continue;
|
|
4141
|
-
const impressions = r.impressions ?? 0;
|
|
4142
|
-
if (best === void 0 || impressions > best.impressions || impressions === best.impressions && r.position < best.position) {
|
|
4143
|
-
best = { position: r.position, impressions };
|
|
4144
|
-
}
|
|
4145
|
-
}
|
|
4146
|
-
return best?.position;
|
|
4147
|
-
}
|
|
4148
|
-
function selectBrandPosition(rows, exactQuery) {
|
|
4149
|
-
const want = exactQuery.toLowerCase();
|
|
4150
|
-
const exact = rows.find(
|
|
4151
|
-
(r) => typeof r.position === "number" && (r.keys?.[0] ?? "").toLowerCase() === want
|
|
4152
|
-
);
|
|
4153
|
-
if (exact) return exact.position;
|
|
4154
|
-
return pickBrandQuery(rows);
|
|
4155
|
-
}
|
|
4156
|
-
async function fetchSearchPresence(q, periodStart, periodEnd) {
|
|
4157
|
-
const key = JSON.parse(readFileSync4(q.keyPath, "utf8"));
|
|
4158
|
-
const jwt = new JWT2({
|
|
4159
|
-
email: key.client_email,
|
|
4160
|
-
key: key.private_key,
|
|
4161
|
-
scopes: [WEBMASTERS_READONLY],
|
|
4162
|
-
subject: q.subject
|
|
4163
|
-
});
|
|
4164
|
-
const explicit = q.property?.trim();
|
|
4165
|
-
let candidates;
|
|
4166
|
-
if (explicit) {
|
|
4167
|
-
candidates = [explicit];
|
|
4168
|
-
} else {
|
|
4169
|
-
const list = await jwt.request({
|
|
4170
|
-
url: `${SC_BASE}/sites`,
|
|
4171
|
-
method: "GET"
|
|
4172
|
-
});
|
|
4173
|
-
candidates = resolvePropertyCandidates(list.data.siteEntry ?? [], q.host);
|
|
4174
|
-
if (candidates.length === 0) return { foundOnPage1: false, position: null };
|
|
4175
|
-
}
|
|
4176
|
-
for (const property of candidates) {
|
|
4177
|
-
const res = await jwt.request({
|
|
4178
|
-
url: `${SC_BASE}/sites/${encodeURIComponent(property)}/searchAnalytics/query`,
|
|
4179
|
-
method: "POST",
|
|
4180
|
-
data: {
|
|
4181
|
-
startDate: ymd3(periodStart),
|
|
4182
|
-
endDate: ymd3(periodEnd),
|
|
4183
|
-
dimensions: ["query"],
|
|
4184
|
-
dimensionFilterGroups: [
|
|
4185
|
-
{
|
|
4186
|
-
filters: [
|
|
4187
|
-
{ dimension: "query", operator: "contains", expression: q.query.toLowerCase() }
|
|
4188
|
-
]
|
|
4189
|
-
}
|
|
4190
|
-
],
|
|
4191
|
-
rowLimit: BRAND_QUERY_ROW_LIMIT
|
|
4192
|
-
}
|
|
4193
|
-
});
|
|
4194
|
-
const pos = selectBrandPosition(res.data.rows ?? [], q.query.toLowerCase());
|
|
4195
|
-
if (typeof pos === "number") {
|
|
4196
|
-
return { foundOnPage1: pos <= PAGE_1_MAX_POSITION, position: Math.max(1, Math.round(pos)) };
|
|
4197
|
-
}
|
|
4198
|
-
}
|
|
4199
|
-
return { foundOnPage1: false, position: null };
|
|
4200
|
-
}
|
|
4201
|
-
|
|
4202
|
-
// src/reports/draft.ts
|
|
4203
|
-
function scoresFromWebsite(siteRow) {
|
|
4204
|
-
const { pScore, rScore, bpScore, seoScore } = siteRow;
|
|
4205
|
-
if (pScore === null || rScore === null || bpScore === null || seoScore === null) {
|
|
4206
|
-
throw new Error(
|
|
4207
|
-
`Site '${siteRow.name}' is missing one or more Lighthouse scores on the Websites row (pScore, rScore, bpScore, seoScore). Run 'reddoor-maint audit lighthouse' from the site's checkout and paste the four numbers into Airtable, then retry.`
|
|
4208
|
-
);
|
|
4209
|
-
}
|
|
4210
|
-
return { performance: pScore, accessibility: rScore, bestPractices: bpScore, seo: seoScore };
|
|
4211
|
-
}
|
|
4212
|
-
function daysAgo(today, n) {
|
|
4213
|
-
const out = new Date(today);
|
|
4214
|
-
out.setUTCDate(out.getUTCDate() - n);
|
|
4215
|
-
return out;
|
|
4216
|
-
}
|
|
4217
|
-
async function draftReportForSite(base, siteRow, reportType, options = {}) {
|
|
4218
|
-
const scores = scoresFromWebsite(siteRow);
|
|
4219
|
-
const today = /* @__PURE__ */ new Date();
|
|
4220
|
-
const slug = siteSlug(siteRow.name);
|
|
4221
|
-
const periodStart = base !== null ? await derivePeriodStart(base, siteRow, reportType, today) : daysAgo(today, 30);
|
|
4222
|
-
const periodEnd = today;
|
|
4223
|
-
const completedOn = today;
|
|
4224
|
-
const lastTestedDate = reportType === "Maintenance" && siteRow.lastLighthouseAuditAt ? new Date(siteRow.lastLighthouseAuditAt) : null;
|
|
4225
|
-
const gaResult = base !== null ? await fetchGaUsers(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
|
|
4226
|
-
const searchResult = base !== null ? await fetchSearch(siteRow, periodStart, periodEnd) : NO_ENRICHMENT;
|
|
4227
|
-
const gaUsers = gaResult.value;
|
|
4228
|
-
const search = searchResult.value;
|
|
4229
|
-
const softFailures = [
|
|
4230
|
-
...gaResult.softFailed ? ["ga"] : [],
|
|
4231
|
-
...searchResult.softFailed ? ["search"] : []
|
|
4232
|
-
];
|
|
4233
|
-
const cidName = `${slug}-header`;
|
|
4234
|
-
const { html } = await renderReportHtml({
|
|
4235
|
-
siteName: siteRow.name,
|
|
4236
|
-
siteUrl: siteRow.url,
|
|
4237
|
-
reportType,
|
|
4238
|
-
completedOn,
|
|
4239
|
-
lighthouse: scores,
|
|
4240
|
-
gaUsersCurrent: gaUsers?.current,
|
|
4241
|
-
gaUsersPrevious: gaUsers?.previous,
|
|
4242
|
-
searchPosition: search?.foundOnPage1 ? search.position ?? void 0 : void 0,
|
|
4243
|
-
lastTestedDate,
|
|
4244
|
-
commentary: null,
|
|
4245
|
-
copy: resolveCopy(siteRow),
|
|
4246
|
-
headerImageCid: cidName
|
|
4247
|
-
});
|
|
4248
|
-
if (options.previewOnly) {
|
|
4249
|
-
const path = options.previewPath ?? `reports/${slug}/draft.html`;
|
|
4250
|
-
await mkdir3(dirname6(path), { recursive: true });
|
|
4251
|
-
await writeFile10(path, html, "utf-8");
|
|
4252
|
-
return { reportRow: null, htmlPath: path, html, softFailures, queued: null, supersededIds: [] };
|
|
4253
|
-
}
|
|
4254
|
-
if (base === null) throw new Error("base required when previewOnly=false");
|
|
4255
|
-
if (readGaConfig() !== null && Boolean(siteRow.ga4PropertyId || siteRow.searchQuery)) {
|
|
4256
|
-
try {
|
|
4257
|
-
await updateAnalyticsHealth(
|
|
4258
|
-
base,
|
|
4259
|
-
siteRow.id,
|
|
4260
|
-
softFailures.length > 0 ? today.toISOString() : null
|
|
4261
|
-
);
|
|
4262
|
-
} catch (e) {
|
|
4263
|
-
console.warn(`\u26A0 analytics-health write skipped for ${siteRow.name}: ${e.message}`);
|
|
4264
|
-
}
|
|
4265
|
-
}
|
|
4266
|
-
if (options.completeRowId) {
|
|
4267
|
-
await uploadDraftHtml(options.completeRowId, slug, periodEnd, html);
|
|
4268
|
-
const outcome2 = await queueDraft(base, {
|
|
4269
|
-
id: options.completeRowId,
|
|
4270
|
-
siteId: siteRow.id,
|
|
4271
|
-
reportType
|
|
4272
|
-
});
|
|
4273
|
-
return {
|
|
4274
|
-
reportRow: options.existingRow ?? null,
|
|
4275
|
-
htmlPath: null,
|
|
4276
|
-
html,
|
|
4277
|
-
softFailures,
|
|
4278
|
-
queued: outcome2.queued,
|
|
4279
|
-
supersededIds: outcome2.supersededIds
|
|
4280
|
-
};
|
|
4281
|
-
}
|
|
4282
|
-
const evidence = autoTickChecklist(siteRow, reportType, completedOn, { search: searchResult });
|
|
4283
|
-
const checklistTicks = [...evidence.entries()].filter(([, e]) => e.result === "pass").map(([field]) => field);
|
|
4284
|
-
const autoEvidence = Object.fromEntries(evidence);
|
|
4285
|
-
const reportId = `${siteRow.name} \u2014 ${reportType} \u2014 ${periodEnd.toISOString().slice(0, 10)}`;
|
|
4286
|
-
const created = await createDraft(base, {
|
|
4287
|
-
reportId,
|
|
4288
|
-
siteId: siteRow.id,
|
|
4289
|
-
reportType,
|
|
4290
|
-
period: options.period ?? periodEnd.toISOString().slice(0, 7),
|
|
4291
|
-
periodStart,
|
|
4292
|
-
periodEnd,
|
|
4293
|
-
completedOn,
|
|
4294
|
-
lighthouse: scores,
|
|
4295
|
-
lastTestedDate,
|
|
4296
|
-
...gaUsers ? { gaUsersCurrent: gaUsers.current, gaUsersPrevious: gaUsers.previous } : {},
|
|
4297
|
-
...search ? { searchFoundPage1: search.foundOnPage1 } : {},
|
|
4298
|
-
...search?.foundOnPage1 && search.position !== null ? { searchPosition: search.position } : {},
|
|
4299
|
-
checklistTicks,
|
|
4300
|
-
autoEvidence
|
|
4301
|
-
});
|
|
4302
|
-
await uploadDraftHtml(created.id, slug, periodEnd, html);
|
|
4303
|
-
const outcome = await queueDraft(base, {
|
|
4304
|
-
id: created.id,
|
|
4305
|
-
siteId: siteRow.id,
|
|
4306
|
-
reportType
|
|
4307
|
-
});
|
|
4308
|
-
return {
|
|
4309
|
-
reportRow: created,
|
|
4310
|
-
htmlPath: null,
|
|
4311
|
-
html,
|
|
4312
|
-
softFailures,
|
|
4313
|
-
queued: outcome.queued,
|
|
4314
|
-
supersededIds: outcome.supersededIds
|
|
4315
|
-
};
|
|
4316
|
-
}
|
|
4317
|
-
async function uploadDraftHtml(rowId, slug, periodEnd, html) {
|
|
4318
|
-
const htmlFilename = `${slug}-${periodEnd.toISOString().slice(0, 10)}.html`;
|
|
4319
|
-
await uploadAttachment(rowId, "Rendered HTML", html, htmlFilename, "text/html");
|
|
4320
|
-
}
|
|
4321
|
-
var NO_ENRICHMENT = { value: null, softFailed: false };
|
|
4322
|
-
async function fetchGaUsers(siteRow, periodStart, periodEnd) {
|
|
4323
|
-
const cfg = readGaConfig();
|
|
4324
|
-
if (!cfg || !siteRow.ga4PropertyId) return NO_ENRICHMENT;
|
|
4325
|
-
try {
|
|
4326
|
-
const value = await fetchPeriodUsers(
|
|
4327
|
-
{ propertyId: siteRow.ga4PropertyId, subject: cfg.subject, keyPath: cfg.keyPath },
|
|
4328
|
-
periodStart,
|
|
4329
|
-
periodEnd
|
|
4330
|
-
);
|
|
4331
|
-
return { value, softFailed: false };
|
|
4332
|
-
} catch (e) {
|
|
4333
|
-
console.warn(`\u26A0 GA skipped for ${siteRow.name}: ${e.message}`);
|
|
4334
|
-
return { value: null, softFailed: true };
|
|
4335
|
-
}
|
|
4336
|
-
}
|
|
4337
|
-
async function fetchSearch(siteRow, periodStart, periodEnd) {
|
|
4338
|
-
const cfg = readGaConfig();
|
|
4339
|
-
if (!cfg || !siteRow.searchQuery) return NO_ENRICHMENT;
|
|
4340
|
-
try {
|
|
4341
|
-
const value = await fetchSearchPresence(
|
|
4342
|
-
{
|
|
4343
|
-
keyPath: cfg.keyPath,
|
|
4344
|
-
subject: cfg.subject,
|
|
4345
|
-
property: siteRow.searchConsoleProperty ?? void 0,
|
|
4346
|
-
host: siteRow.url,
|
|
4347
|
-
query: siteRow.searchQuery
|
|
4348
|
-
},
|
|
4349
|
-
periodStart,
|
|
4350
|
-
periodEnd
|
|
4351
|
-
);
|
|
4352
|
-
return { value, softFailed: false };
|
|
4353
|
-
} catch (e) {
|
|
4354
|
-
console.warn(`\u26A0 Search presence skipped for ${siteRow.name}: ${e.message}`);
|
|
4355
|
-
return { value: null, softFailed: true };
|
|
4356
|
-
}
|
|
4357
|
-
}
|
|
4358
|
-
async function derivePeriodStart(base, siteRow, reportType, today) {
|
|
4359
|
-
const prior = await listReportsForSite(base, siteRow.id);
|
|
4360
|
-
const sameType = prior.filter((r) => r.reportType === reportType && r.periodEnd).map((r) => r.periodEnd).sort();
|
|
4361
|
-
const latest = sameType[sameType.length - 1];
|
|
4362
|
-
if (!latest) return daysAgo(today, 30);
|
|
4363
|
-
const start = new Date(latest);
|
|
4364
|
-
start.setUTCDate(start.getUTCDate() + 1);
|
|
4365
|
-
return start;
|
|
4366
|
-
}
|
|
4367
|
-
|
|
4368
|
-
// src/reports/airtable/client.ts
|
|
4369
|
-
import Airtable from "airtable";
|
|
4370
|
-
|
|
4371
|
-
// src/reports/airtable/throttle.ts
|
|
4372
|
-
function createMinIntervalThrottle(opts) {
|
|
4373
|
-
const { minIntervalMs, now, delay } = opts;
|
|
4374
|
-
return function wrap(fn) {
|
|
4375
|
-
let chain = Promise.resolve();
|
|
4376
|
-
let last = Number.NEGATIVE_INFINITY;
|
|
4377
|
-
return (...args) => {
|
|
4378
|
-
chain = chain.then(async () => {
|
|
4379
|
-
const wait = minIntervalMs - (now() - last);
|
|
4380
|
-
if (wait > 0) await delay(wait);
|
|
4381
|
-
last = now();
|
|
4382
|
-
fn(...args);
|
|
4383
|
-
}).catch(() => {
|
|
4384
|
-
});
|
|
4385
|
-
};
|
|
4386
|
-
};
|
|
4387
|
-
}
|
|
4388
|
-
function applyThrottle(base, opts) {
|
|
4389
|
-
const real = base._base?.runAction;
|
|
4390
|
-
if (typeof real !== "function") return base;
|
|
4391
|
-
const wrap = createMinIntervalThrottle(opts);
|
|
4392
|
-
const throttled = wrap(real.bind(base._base));
|
|
4393
|
-
base._base.runAction = throttled;
|
|
4394
|
-
base.runAction = throttled;
|
|
4395
|
-
return base;
|
|
4396
|
-
}
|
|
4397
|
-
|
|
4398
|
-
// src/reports/airtable/client.ts
|
|
4399
|
-
var MIN_REQUEST_INTERVAL_MS = 220;
|
|
4400
|
-
function missing(name) {
|
|
4401
|
-
return Object.assign(
|
|
4402
|
-
new Error(
|
|
4403
|
-
`${name} not set. Export it in your shell or put it in ${defaultCredentialsPath()} as ${name}=...`
|
|
4404
|
-
),
|
|
4405
|
-
{ exitCode: 2 }
|
|
4406
|
-
);
|
|
4407
|
-
}
|
|
4408
|
-
function readAirtableConfig() {
|
|
4409
|
-
const apiKey = process.env.AIRTABLE_PAT;
|
|
4410
|
-
const baseId = process.env.AIRTABLE_BASE_ID;
|
|
4411
|
-
if (!apiKey) throw missing("AIRTABLE_PAT");
|
|
4412
|
-
if (!baseId) throw missing("AIRTABLE_BASE_ID");
|
|
4413
|
-
return { apiKey, baseId };
|
|
4414
|
-
}
|
|
4415
|
-
function openBase(cfg) {
|
|
4416
|
-
const base = new Airtable({ apiKey: cfg.apiKey }).base(cfg.baseId);
|
|
4417
|
-
return applyThrottle(base, {
|
|
4418
|
-
minIntervalMs: MIN_REQUEST_INTERVAL_MS,
|
|
4419
|
-
now: () => Date.now(),
|
|
4420
|
-
delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
4421
|
-
});
|
|
4422
|
-
}
|
|
4423
|
-
|
|
4424
|
-
// src/reports/maintenance-email/header-image.ts
|
|
4425
|
-
import sharp from "sharp";
|
|
4426
|
-
var DEFAULT_DISPLAY_WIDTH = 600;
|
|
4427
|
-
var RETINA_SCALE = 2;
|
|
4428
|
-
var JPEG_QUALITY = 82;
|
|
4429
|
-
function channelToHex(value) {
|
|
4430
|
-
return Math.max(0, Math.min(255, Math.round(value))).toString(16).padStart(2, "0");
|
|
4431
|
-
}
|
|
4432
|
-
async function prepareHeaderImage(bytes, options = {}) {
|
|
4433
|
-
const requestedDisplayWidth = options.displayWidth ?? DEFAULT_DISPLAY_WIDTH;
|
|
4434
|
-
const input = Buffer.from(bytes);
|
|
4435
|
-
const meta = await sharp(input).metadata();
|
|
4436
|
-
const origWidth = meta.width;
|
|
4437
|
-
const origHeight = meta.height;
|
|
4438
|
-
if (!origWidth || !origHeight) {
|
|
4439
|
-
throw new Error("prepareHeaderImage: could not read source image dimensions");
|
|
4440
|
-
}
|
|
4441
|
-
const displayWidth = Math.min(requestedDisplayWidth, origWidth);
|
|
4442
|
-
const displayHeight = Math.round(displayWidth * origHeight / origWidth);
|
|
4443
|
-
const targetSourceWidth = Math.min(origWidth, displayWidth * RETINA_SCALE);
|
|
4444
|
-
const out = await sharp(input).resize({ width: targetSourceWidth, withoutEnlargement: true }).flatten({ background: "#ffffff" }).jpeg({ quality: JPEG_QUALITY }).toBuffer();
|
|
4445
|
-
const { dominant } = await sharp(out).stats();
|
|
4446
|
-
const placeholderColor = `#${channelToHex(dominant.r)}${channelToHex(dominant.g)}${channelToHex(dominant.b)}`;
|
|
4447
|
-
return {
|
|
4448
|
-
bytes: new Uint8Array(out),
|
|
4449
|
-
contentType: "image/jpeg",
|
|
4450
|
-
displayWidth,
|
|
4451
|
-
displayHeight,
|
|
4452
|
-
placeholderColor
|
|
4453
|
-
};
|
|
4454
|
-
}
|
|
4455
|
-
|
|
4456
|
-
// src/reports/send/resend.ts
|
|
4457
|
-
import { Resend } from "resend";
|
|
4458
|
-
function defaultResendClient() {
|
|
4459
|
-
const key = process.env.RESEND_API_KEY;
|
|
4460
|
-
if (!key) throw Object.assign(new Error("RESEND_API_KEY not set"), { exitCode: 2 });
|
|
4461
|
-
const resend = new Resend(key);
|
|
4462
|
-
return {
|
|
4463
|
-
async send(input) {
|
|
4464
|
-
const payload = {
|
|
4465
|
-
from: input.from,
|
|
4466
|
-
to: input.to,
|
|
4467
|
-
subject: input.subject,
|
|
4468
|
-
html: input.html
|
|
4469
|
-
};
|
|
4470
|
-
if (input.cc) payload.cc = input.cc;
|
|
4471
|
-
if (input.replyTo) payload.replyTo = input.replyTo;
|
|
4472
|
-
if (input.attachments) payload.attachments = input.attachments;
|
|
4473
|
-
const options = {};
|
|
4474
|
-
if (input.idempotencyKey) options.idempotencyKey = input.idempotencyKey;
|
|
4475
|
-
const { data, error } = await resend.emails.send(payload, options);
|
|
4476
|
-
if (error) throw new Error(`Resend error: ${error.message}`);
|
|
4477
|
-
if (!data?.id) throw new Error("Resend returned no message id");
|
|
4478
|
-
return { messageId: data.id };
|
|
4479
|
-
}
|
|
4480
|
-
};
|
|
4481
|
-
}
|
|
4482
|
-
|
|
4483
|
-
// src/reports/send/idempotency.ts
|
|
4484
|
-
function isIdempotencyConflict(err) {
|
|
4485
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
4486
|
-
if (/idempotency key has been used/i.test(message)) return true;
|
|
4487
|
-
const e = err;
|
|
4488
|
-
if (e.name === "invalid_idempotent_request") return true;
|
|
4489
|
-
if (e.statusCode === 409) return true;
|
|
4490
|
-
return false;
|
|
4491
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_INIT_STEPS,
|
|
3
|
+
a11yFixturesPage,
|
|
4
|
+
init
|
|
5
|
+
} from "./chunk-XO2RUYVM.js";
|
|
6
|
+
import {
|
|
7
|
+
svelteCodemods
|
|
8
|
+
} from "./chunk-FUD4I2ZW.js";
|
|
9
|
+
import {
|
|
10
|
+
onboard,
|
|
11
|
+
selfCaretRange,
|
|
12
|
+
selfPackageVersion
|
|
13
|
+
} from "./chunk-ZYBOT5PM.js";
|
|
14
|
+
import {
|
|
15
|
+
findDueReports
|
|
16
|
+
} from "./chunk-WVRPOXKH.js";
|
|
17
|
+
import {
|
|
18
|
+
draftReportForSite
|
|
19
|
+
} from "./chunk-W3G7XFBZ.js";
|
|
20
|
+
import "./chunk-6LIWDIXU.js";
|
|
21
|
+
import {
|
|
22
|
+
sendApprovedReports
|
|
23
|
+
} from "./chunk-5L2QXUTU.js";
|
|
24
|
+
import "./chunk-OGGRFBL4.js";
|
|
25
|
+
import {
|
|
26
|
+
BLURRED_CID,
|
|
27
|
+
CHECK_CID,
|
|
28
|
+
loadBundledImages,
|
|
29
|
+
renderReportHtml
|
|
30
|
+
} from "./chunk-BWLUNY7L.js";
|
|
31
|
+
import {
|
|
32
|
+
checklistFor,
|
|
33
|
+
escapeHtml,
|
|
34
|
+
isChecklistComplete,
|
|
35
|
+
isPendingApproval,
|
|
36
|
+
safeUrl
|
|
37
|
+
} from "./chunk-OCS7SZUZ.js";
|
|
38
|
+
import {
|
|
39
|
+
bumpDeps
|
|
40
|
+
} from "./chunk-D3DQFC32.js";
|
|
41
|
+
import {
|
|
42
|
+
upgradeSvelte4to5
|
|
43
|
+
} from "./chunk-O65I7G7G.js";
|
|
44
|
+
import "./chunk-OQPMZ6BM.js";
|
|
45
|
+
import {
|
|
46
|
+
convertToPnpm
|
|
47
|
+
} from "./chunk-2X322ZES.js";
|
|
48
|
+
import {
|
|
49
|
+
syncConfigs
|
|
50
|
+
} from "./chunk-JNXCDUYB.js";
|
|
51
|
+
import "./chunk-CASCWQQX.js";
|
|
52
|
+
import "./chunk-XKOZUVK2.js";
|
|
53
|
+
import "./chunk-OTGB6YQO.js";
|
|
54
|
+
import {
|
|
55
|
+
ALL_AUDIT_NAMES,
|
|
56
|
+
a11yAudit,
|
|
57
|
+
depsAudit,
|
|
58
|
+
lighthouseAudit,
|
|
59
|
+
lintAudit,
|
|
60
|
+
runAudits,
|
|
61
|
+
runAuditsAcross,
|
|
62
|
+
securityAudit
|
|
63
|
+
} from "./chunk-4PY4I5OR.js";
|
|
64
|
+
import "./chunk-LKGVSM2O.js";
|
|
65
|
+
import {
|
|
66
|
+
fromJsonFile,
|
|
67
|
+
localPath
|
|
68
|
+
} from "./chunk-FDIFTIXC.js";
|
|
69
|
+
import "./chunk-HVOOCK6L.js";
|
|
70
|
+
import "./chunk-XXTZBPUY.js";
|
|
71
|
+
import "./chunk-XTK5VIQB.js";
|
|
72
|
+
import {
|
|
73
|
+
fromAirtableBase
|
|
74
|
+
} from "./chunk-JQG222CI.js";
|
|
75
|
+
import "./chunk-U5SZ4FOP.js";
|
|
76
|
+
import {
|
|
77
|
+
SEVERITY_RANK,
|
|
78
|
+
siteSlug
|
|
79
|
+
} from "./chunk-CLVTVDOU.js";
|
|
80
|
+
import "./chunk-HN6I2XWI.js";
|
|
81
|
+
import "./chunk-WEKL2HYJ.js";
|
|
82
|
+
import "./chunk-6VAL7XJT.js";
|
|
83
|
+
import "./chunk-EYODPUHV.js";
|
|
84
|
+
import "./chunk-LYCKN7ZR.js";
|
|
85
|
+
import "./chunk-QTHKOIFJ.js";
|
|
4492
86
|
|
|
4493
|
-
// src/
|
|
4494
|
-
var
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
"
|
|
4498
|
-
"
|
|
4499
|
-
"
|
|
4500
|
-
"
|
|
4501
|
-
"
|
|
4502
|
-
"
|
|
4503
|
-
"
|
|
4504
|
-
"August",
|
|
4505
|
-
"September",
|
|
4506
|
-
"October",
|
|
4507
|
-
"November",
|
|
4508
|
-
"December"
|
|
87
|
+
// src/recipes/index.ts
|
|
88
|
+
var ALL_RECIPE_NAMES = [
|
|
89
|
+
"sync-configs",
|
|
90
|
+
"bump-deps",
|
|
91
|
+
"svelte-4-to-5",
|
|
92
|
+
"svelte-codemods",
|
|
93
|
+
"convert-to-pnpm",
|
|
94
|
+
"onboard",
|
|
95
|
+
"a11y-fixtures-page",
|
|
96
|
+
"self-updating",
|
|
97
|
+
"init"
|
|
4509
98
|
];
|
|
4510
|
-
function
|
|
4511
|
-
return
|
|
4512
|
-
}
|
|
4513
|
-
function toInlineAttachment(a) {
|
|
4514
|
-
return {
|
|
4515
|
-
filename: a.filename,
|
|
4516
|
-
content: Buffer.from(a.bytes).toString("base64"),
|
|
4517
|
-
contentType: a.contentType,
|
|
4518
|
-
inlineContentId: a.cid
|
|
4519
|
-
};
|
|
4520
|
-
}
|
|
4521
|
-
async function sendApprovedReports(options = {}) {
|
|
4522
|
-
const base = openBase(readAirtableConfig());
|
|
4523
|
-
const client = options.resend ?? defaultResendClient();
|
|
4524
|
-
const sendable = await listSendableReports(base);
|
|
4525
|
-
if (sendable.length === 0) return { output: "No reports ready to send.", code: 0 };
|
|
4526
|
-
const websites = await listWebsites(base);
|
|
4527
|
-
const sites = new Map(websites.map((w) => [w.id, w]));
|
|
4528
|
-
const lines = [];
|
|
4529
|
-
let anyFailed = false;
|
|
4530
|
-
for (const report of sendable) {
|
|
4531
|
-
const site = sites.get(report.siteId);
|
|
4532
|
-
if (!site) {
|
|
4533
|
-
lines.push(`\u2717 ${report.reportId} \u2014 Site row not found for id=${report.siteId}`);
|
|
4534
|
-
anyFailed = true;
|
|
4535
|
-
continue;
|
|
4536
|
-
}
|
|
4537
|
-
try {
|
|
4538
|
-
const messageId = await sendOne(client, base, site, report);
|
|
4539
|
-
lines.push(`\u2713 sent: ${report.reportId} (${messageId})`);
|
|
4540
|
-
if (report.reportType === "Launch") {
|
|
4541
|
-
try {
|
|
4542
|
-
await updateLaunched(base, site.id, (/* @__PURE__ */ new Date()).toISOString());
|
|
4543
|
-
lines.push(` \u21B3 launched: ${site.name} flipped to maintenance`);
|
|
4544
|
-
} catch (e) {
|
|
4545
|
-
lines.push(` \u26A0 launch flip failed for ${site.name}: ${e.message}`);
|
|
4546
|
-
}
|
|
4547
|
-
}
|
|
4548
|
-
} catch (e) {
|
|
4549
|
-
lines.push(`\u2717 ${report.reportId} \u2014 ${e.message}`);
|
|
4550
|
-
anyFailed = true;
|
|
4551
|
-
}
|
|
4552
|
-
}
|
|
4553
|
-
return { output: lines.join("\n"), code: anyFailed ? 1 : 0 };
|
|
4554
|
-
}
|
|
4555
|
-
async function sendOne(client, base, site, report) {
|
|
4556
|
-
if (!isChecklistComplete(report)) {
|
|
4557
|
-
const items = checklistFor(report.reportType);
|
|
4558
|
-
const done = items.filter((i) => report.checklist[i.field] === true).length;
|
|
4559
|
-
throw new Error(
|
|
4560
|
-
`Report ${report.reportId} checklist incomplete \u2014 ${done}/${items.length} items checked`
|
|
4561
|
-
);
|
|
4562
|
-
}
|
|
4563
|
-
if (!site.headerImage) {
|
|
4564
|
-
throw new Error(`Site '${site.name}' has no Header image set on the Websites row`);
|
|
4565
|
-
}
|
|
4566
|
-
if (!report.lighthouse) {
|
|
4567
|
-
throw new Error(
|
|
4568
|
-
`Report ${report.reportId} has no Lighthouse scores \u2014 all four cells (Lighthouse \u2014 Performance / Accessibility / Best Practices / SEO) must be numeric on the Reports row; one non-numeric or blank cell nulls all four`
|
|
4569
|
-
);
|
|
4570
|
-
}
|
|
4571
|
-
const explicitTo = parseAddresses(site.reportRecipientsTo);
|
|
4572
|
-
const fallbackTo = parseAddresses(site.pointOfContact);
|
|
4573
|
-
const to = explicitTo ?? fallbackTo ?? [];
|
|
4574
|
-
if (to.length === 0) {
|
|
4575
|
-
throw new Error(
|
|
4576
|
-
`Site '${site.name}' has no recipients (Report recipients (To) AND point of contact are both empty)`
|
|
4577
|
-
);
|
|
4578
|
-
}
|
|
4579
|
-
for (const addr of to) {
|
|
4580
|
-
if (!isProbablyEmail(addr)) {
|
|
4581
|
-
throw new Error(
|
|
4582
|
-
`Site '${site.name}' recipient is malformed: ${addr} \u2014 use a bare address only (no \`Name <addr>\` display-name syntax); fix Report recipients (To) or point of contact in Airtable`
|
|
4583
|
-
);
|
|
4584
|
-
}
|
|
4585
|
-
}
|
|
4586
|
-
const cc = parseAddresses(site.reportRecipientsCc);
|
|
4587
|
-
if (cc) {
|
|
4588
|
-
for (const addr of cc) {
|
|
4589
|
-
if (!isProbablyEmail(addr)) {
|
|
4590
|
-
throw new Error(
|
|
4591
|
-
`Site '${site.name}' CC is malformed: ${addr} \u2014 fix Report recipients (CC) in Airtable`
|
|
4592
|
-
);
|
|
4593
|
-
}
|
|
4594
|
-
}
|
|
4595
|
-
}
|
|
4596
|
-
const original = await fetchAttachmentBytes(site.headerImage.url);
|
|
4597
|
-
const header = await prepareHeaderImage(original.bytes);
|
|
4598
|
-
const bundled = await loadBundledImages();
|
|
4599
|
-
const slug = siteSlug(site.name);
|
|
4600
|
-
const cidName = `${slug}-header`;
|
|
4601
|
-
const { html } = await renderReportHtml({
|
|
4602
|
-
siteName: site.name,
|
|
4603
|
-
siteUrl: site.url,
|
|
4604
|
-
reportType: report.reportType,
|
|
4605
|
-
completedOn: report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date(),
|
|
4606
|
-
lighthouse: report.lighthouse,
|
|
4607
|
-
gaUsersCurrent: report.gaUsersCurrent ?? void 0,
|
|
4608
|
-
gaUsersPrevious: report.gaUsersPrevious ?? void 0,
|
|
4609
|
-
searchPosition: report.searchFoundPage1 && report.searchPosition !== null ? report.searchPosition : void 0,
|
|
4610
|
-
lastTestedDate: report.lastTestedDate ? new Date(report.lastTestedDate) : null,
|
|
4611
|
-
commentary: report.commentary,
|
|
4612
|
-
copy: resolveCopy(site),
|
|
4613
|
-
headerImageCid: cidName,
|
|
4614
|
-
headerWidth: header.displayWidth,
|
|
4615
|
-
headerHeight: header.displayHeight,
|
|
4616
|
-
headerBgColor: header.placeholderColor,
|
|
4617
|
-
// Announcement-only: re-derive cadence + improvements from the site row so the SENT email
|
|
4618
|
-
// keeps its cadence copy + improvement callouts. Without this the send-time re-render drops
|
|
4619
|
-
// them entirely (they're not stored on the Reports row). Ignored by the other templates.
|
|
4620
|
-
...report.reportType === "Announcement" ? announcementSiteExtras(site) : {}
|
|
4621
|
-
});
|
|
4622
|
-
const reportDate = report.completedOn ? new Date(report.completedOn) : /* @__PURE__ */ new Date();
|
|
4623
|
-
const subject = report.subjectOverride ?? `${site.name} \u2014 ${monthYear(reportDate)} ${report.reportType} Report`;
|
|
4624
|
-
const attachments = [
|
|
4625
|
-
toInlineAttachment({
|
|
4626
|
-
bytes: header.bytes,
|
|
4627
|
-
filename: `${cidName}.jpg`,
|
|
4628
|
-
contentType: header.contentType,
|
|
4629
|
-
cid: cidName
|
|
4630
|
-
})
|
|
4631
|
-
];
|
|
4632
|
-
for (const img of [bundled.check, bundled.blurred]) {
|
|
4633
|
-
if (html.includes(`cid:${img.cid}`)) {
|
|
4634
|
-
attachments.push(
|
|
4635
|
-
toInlineAttachment({
|
|
4636
|
-
bytes: img.bytes,
|
|
4637
|
-
filename: img.filename,
|
|
4638
|
-
contentType: img.contentType,
|
|
4639
|
-
cid: img.cid
|
|
4640
|
-
})
|
|
4641
|
-
);
|
|
4642
|
-
}
|
|
4643
|
-
}
|
|
4644
|
-
const payload = {
|
|
4645
|
-
from: FROM_ADDRESS,
|
|
4646
|
-
to,
|
|
4647
|
-
replyTo: REPLY_TO,
|
|
4648
|
-
subject,
|
|
4649
|
-
html,
|
|
4650
|
-
attachments,
|
|
4651
|
-
// Stable across retries of the same row — if Airtable stamping fails after a
|
|
4652
|
-
// successful Resend, the next --send-ready replays with the same key and
|
|
4653
|
-
// Resend returns the original message id rather than sending a duplicate.
|
|
4654
|
-
idempotencyKey: `report:${report.id}`
|
|
4655
|
-
};
|
|
4656
|
-
if (cc) payload.cc = cc;
|
|
4657
|
-
let result;
|
|
4658
|
-
try {
|
|
4659
|
-
result = await client.send(payload);
|
|
4660
|
-
} catch (err) {
|
|
4661
|
-
if (isIdempotencyConflict(err)) {
|
|
4662
|
-
await stampSent(base, report.id, /* @__PURE__ */ new Date(), null);
|
|
4663
|
-
console.log(`\u21BB already sent (idempotency conflict), stamped: ${report.reportId}`);
|
|
4664
|
-
return "idempotent-conflict";
|
|
4665
|
-
}
|
|
4666
|
-
throw err;
|
|
4667
|
-
}
|
|
4668
|
-
await stampSent(base, report.id, /* @__PURE__ */ new Date(), result.messageId);
|
|
4669
|
-
return result.messageId;
|
|
4670
|
-
}
|
|
4671
|
-
function parseAddresses(field) {
|
|
4672
|
-
if (!field) return null;
|
|
4673
|
-
const seen = /* @__PURE__ */ new Set();
|
|
4674
|
-
const list = [];
|
|
4675
|
-
for (const raw of field.split(/[,\n]/)) {
|
|
4676
|
-
const trimmed = raw.trim().toLowerCase();
|
|
4677
|
-
if (!trimmed) continue;
|
|
4678
|
-
if (seen.has(trimmed)) continue;
|
|
4679
|
-
seen.add(trimmed);
|
|
4680
|
-
list.push(trimmed);
|
|
4681
|
-
}
|
|
4682
|
-
return list.length > 0 ? list : null;
|
|
4683
|
-
}
|
|
4684
|
-
function isProbablyEmail(s) {
|
|
4685
|
-
const at = s.indexOf("@");
|
|
4686
|
-
if (at < 1 || at !== s.lastIndexOf("@")) return false;
|
|
4687
|
-
const local = s.slice(0, at);
|
|
4688
|
-
const domain = s.slice(at + 1);
|
|
4689
|
-
if (!local || !domain) return false;
|
|
4690
|
-
if (!domain.includes(".")) return false;
|
|
4691
|
-
if (/\s/.test(s)) return false;
|
|
4692
|
-
return true;
|
|
4693
|
-
}
|
|
4694
|
-
|
|
4695
|
-
// src/reports/due.ts
|
|
4696
|
-
var ELIGIBLE_STATUSES = /* @__PURE__ */ new Set([
|
|
4697
|
-
"in development",
|
|
4698
|
-
"launch period",
|
|
4699
|
-
"maintenance",
|
|
4700
|
-
"hosting"
|
|
4701
|
-
]);
|
|
4702
|
-
var MONTHS2 = {
|
|
4703
|
-
Monthly: 1,
|
|
4704
|
-
Quarterly: 3,
|
|
4705
|
-
Yearly: 12
|
|
4706
|
-
};
|
|
4707
|
-
function addMonths(d, n) {
|
|
4708
|
-
const out = new Date(d);
|
|
4709
|
-
const day = out.getUTCDate();
|
|
4710
|
-
out.setUTCDate(1);
|
|
4711
|
-
out.setUTCMonth(out.getUTCMonth() + n);
|
|
4712
|
-
const lastDayOfTargetMonth = new Date(
|
|
4713
|
-
Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)
|
|
4714
|
-
).getUTCDate();
|
|
4715
|
-
out.setUTCDate(Math.min(day, lastDayOfTargetMonth));
|
|
4716
|
-
return out;
|
|
4717
|
-
}
|
|
4718
|
-
function startOfDay(d) {
|
|
4719
|
-
const out = new Date(d);
|
|
4720
|
-
out.setUTCHours(0, 0, 0, 0);
|
|
4721
|
-
return out;
|
|
4722
|
-
}
|
|
4723
|
-
function lastSentForType(reports, siteId, type) {
|
|
4724
|
-
const candidates = reports.filter((r) => r.siteId === siteId && r.reportType === type && r.sentAt !== null).map((r) => r.sentAt).sort();
|
|
4725
|
-
return candidates[candidates.length - 1] ?? null;
|
|
4726
|
-
}
|
|
4727
|
-
function findDueReports(websites, reports, today) {
|
|
4728
|
-
const out = [];
|
|
4729
|
-
const todayStart = startOfDay(today);
|
|
4730
|
-
for (const site of websites) {
|
|
4731
|
-
if (site.status !== null && !ELIGIBLE_STATUSES.has(site.status)) continue;
|
|
4732
|
-
for (const type of ["Maintenance", "Testing"]) {
|
|
4733
|
-
const rawFreq = type === "Maintenance" ? site.maintenanceFreq : site.testingFreq;
|
|
4734
|
-
const freq = typeof rawFreq === "string" ? rawFreq.trim() : rawFreq;
|
|
4735
|
-
if (freq === "None" || freq === "") continue;
|
|
4736
|
-
if (!(freq in MONTHS2)) {
|
|
4737
|
-
console.warn(
|
|
4738
|
-
`\u26A0 ${site.name}: unrecognized ${type === "Maintenance" ? "maintenance" : "testing"} frequency '${rawFreq}' \u2014 not scheduling; fix the Airtable value`
|
|
4739
|
-
);
|
|
4740
|
-
continue;
|
|
4741
|
-
}
|
|
4742
|
-
const lastSent = lastSentForType(reports, site.id, type);
|
|
4743
|
-
const fallback = type === "Maintenance" ? site.maintenanceDay : site.testingDay;
|
|
4744
|
-
const baseIso = lastSent ?? fallback;
|
|
4745
|
-
if (!baseIso) {
|
|
4746
|
-
out.push({ site, reportType: type, dueDate: todayStart, lastSent });
|
|
4747
|
-
continue;
|
|
4748
|
-
}
|
|
4749
|
-
const dueDate = addMonths(new Date(baseIso), MONTHS2[freq]);
|
|
4750
|
-
if (todayStart.getTime() >= startOfDay(dueDate).getTime()) {
|
|
4751
|
-
out.push({ site, reportType: type, dueDate, lastSent });
|
|
4752
|
-
}
|
|
4753
|
-
}
|
|
4754
|
-
}
|
|
4755
|
-
return out;
|
|
99
|
+
function isRecipeName(value) {
|
|
100
|
+
return ALL_RECIPE_NAMES.includes(value);
|
|
4756
101
|
}
|
|
4757
102
|
|
|
4758
103
|
// src/dashboard/relative-time.ts
|
|
@@ -5027,8 +372,8 @@ function spamScreenSection(totals, submissions, now) {
|
|
|
5027
372
|
}
|
|
5028
373
|
function setupSection(site) {
|
|
5029
374
|
const { score, total } = onboardingStatus(site);
|
|
5030
|
-
const
|
|
5031
|
-
const detail =
|
|
375
|
+
const missing = missingOnboarding(site);
|
|
376
|
+
const detail = missing.length === 0 ? `<span class="setup-ok">complete</span>` : `<span class="setup-missing">Missing: ${escapeHtml(missing.join(", "))}</span>`;
|
|
5032
377
|
return `<div class="setup-line">Setup ${score}/${total} \u2014 ${detail}</div>`;
|
|
5033
378
|
}
|
|
5034
379
|
function detailRow(label, value) {
|
|
@@ -5243,9 +588,9 @@ function card(site) {
|
|
|
5243
588
|
const name = escapeHtml(site.name);
|
|
5244
589
|
const href = `/s/${escapeHtml(siteSlug(site.name))}`;
|
|
5245
590
|
const onboarding = onboardingStatus(site);
|
|
5246
|
-
const
|
|
591
|
+
const missing = missingOnboarding(site);
|
|
5247
592
|
const setupTitle = escapeHtml(
|
|
5248
|
-
|
|
593
|
+
missing.length === 0 ? "Setup complete" : `Missing: ${missing.join(", ")}`
|
|
5249
594
|
);
|
|
5250
595
|
const audited = relativeTimeFromNow(site.lastLighthouseAuditAt);
|
|
5251
596
|
const safeSiteUrl = escapeHtml(safeUrl(site.url));
|