@reddoorla/maintenance 0.61.0 → 0.62.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/airtable-HHAWHBNM.js +9 -0
- package/dist/{announce-Q2DXP5OO.js → announce-N6VWO5QZ.js} +16 -6
- package/dist/announce-N6VWO5QZ.js.map +1 -0
- package/dist/{bump-deps-RYHQIKW4.js → bump-deps-2U6GIHM5.js} +2 -2
- package/dist/{chunk-D3LFQNJK.js → chunk-3HM4S422.js} +2 -2
- package/dist/{chunk-LMXIZITU.js → chunk-4REUHZAW.js} +40 -4
- package/dist/chunk-4REUHZAW.js.map +1 -0
- package/dist/chunk-5L3FPQIJ.js +92 -0
- package/dist/chunk-5L3FPQIJ.js.map +1 -0
- package/dist/{client-JA6QLEDW.js → chunk-6ZKM7OIJ.js} +5 -4
- package/dist/{chunk-L4ULXHIN.js → chunk-BLD6AYJO.js} +15 -1
- package/dist/chunk-BLD6AYJO.js.map +1 -0
- package/dist/{chunk-VDBENQUA.js → chunk-BMDBOPN3.js} +2 -2
- package/dist/{chunk-KC4HU7BC.js → chunk-DXRKWALS.js} +31 -6
- package/dist/chunk-DXRKWALS.js.map +1 -0
- package/dist/{chunk-HB7QPECR.js → chunk-KTPPKO2C.js} +6 -6
- package/dist/chunk-L5F664ZA.js +45 -0
- package/dist/chunk-L5F664ZA.js.map +1 -0
- package/dist/{chunk-TZN2G6FD.js → chunk-NPJFHBAX.js} +3 -3
- package/dist/{chunk-XB6T4ETK.js → chunk-OSRFDGQM.js} +21 -1
- package/dist/chunk-OSRFDGQM.js.map +1 -0
- package/dist/{chunk-6CSCLBAA.js → chunk-SAOCVNX7.js} +2 -2
- package/dist/{chunk-5PHCZ6L5.js → chunk-SS7BKOIX.js} +89 -2
- package/dist/chunk-SS7BKOIX.js.map +1 -0
- package/dist/{chunk-BWLUNY7L.js → chunk-U3MF6RCS.js} +40 -21
- package/dist/chunk-U3MF6RCS.js.map +1 -0
- package/dist/{chunk-HG5DTFNF.js → chunk-XKKTK6WA.js} +17 -1
- package/dist/chunk-XKKTK6WA.js.map +1 -0
- package/dist/{chunk-JNXCDUYB.js → chunk-XVR7UM6Z.js} +4 -4
- package/dist/{chunk-HPSGCDWY.js → chunk-YYUY2OVP.js} +2 -2
- package/dist/{chunk-TLZ4V2BE.js → chunk-ZEX2T6JH.js} +3 -2
- package/dist/{chunk-TLZ4V2BE.js.map → chunk-ZEX2T6JH.js.map} +1 -1
- package/dist/cli/bin.js +16 -15
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +3 -2
- package/dist/cli/commands/audit.js +29 -8
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-GTMJTXBK.js +11 -0
- package/dist/{convert-to-pnpm-KNCXU6FM.js → convert-to-pnpm-JG3767ZQ.js} +2 -2
- package/dist/{db-AXKMRTVA.js → db-Y6BVTNH7.js} +3 -3
- package/dist/{digest-WAS3EJNC.js → digest-ABJROO4H.js} +3 -3
- package/dist/{github-signals-UL2BBS3Q.js → github-signals-XTJLKOXJ.js} +26 -5
- package/dist/github-signals-XTJLKOXJ.js.map +1 -0
- package/dist/index.d.ts +60 -3
- package/dist/index.js +86 -21
- package/dist/index.js.map +1 -1
- package/dist/{init-KD56UENX.js → init-DMAIKUQE.js} +8 -8
- package/dist/{launch-UWCI3JRM.js → launch-IBP23PNY.js} +10 -9
- package/dist/{launch-UWCI3JRM.js.map → launch-IBP23PNY.js.map} +1 -1
- package/dist/{lighthouse-airtable-N3KCBPVA.js → lighthouse-airtable-HTEW6BBT.js} +3 -3
- package/dist/migrate-BFOG2OCP.js +7 -0
- package/dist/{onboard-SCZ3UKZ2.js → onboard-TTIC3YZI.js} +2 -2
- package/dist/{orchestrate-D72PB6NG.js → orchestrate-I3KAJGFB.js} +7 -4
- package/dist/recipes/sync-configs.d.ts +1 -1
- package/dist/recipes/sync-configs.js +2 -2
- package/dist/{renovate-dispatch-NG5KUQKY.js → renovate-dispatch-QDDMTATD.js} +3 -3
- package/dist/{report-EPHXV2T2.js → report-PY6O3YMX.js} +6 -6
- package/dist/{self-updating-CFHXJSAX.js → self-updating-2RLUP4AA.js} +4 -4
- package/dist/{svelte-codemods-HNITLWQM.js → svelte-codemods-BPLKSVIA.js} +2 -2
- package/dist/{sync-configs-BZXBYKTI.js → sync-configs-VFRV3TJ4.js} +4 -4
- package/dist/{types-QG-QhCYh.d.ts → types-Dg4mHvyf.d.ts} +6 -1
- package/dist/{upgrade-6YDUELFA.js → upgrade-WCFD2TRN.js} +2 -2
- package/dist/{websites-IYP7IGLU.js → websites-HM2NMZMR.js} +2 -2
- package/dist/{write-audits-to-airtable-VDGHCZGQ.js → write-audits-to-airtable-QF26PCAR.js} +5 -4
- package/dist/write-audits-to-airtable-QF26PCAR.js.map +1 -0
- package/package.json +1 -1
- package/dist/airtable-YZ6VMSQZ.js +0 -9
- package/dist/announce-Q2DXP5OO.js.map +0 -1
- package/dist/chunk-5PHCZ6L5.js.map +0 -1
- package/dist/chunk-BWLUNY7L.js.map +0 -1
- package/dist/chunk-HG5DTFNF.js.map +0 -1
- package/dist/chunk-KC4HU7BC.js.map +0 -1
- package/dist/chunk-L4ULXHIN.js.map +0 -1
- package/dist/chunk-LMXIZITU.js.map +0 -1
- package/dist/chunk-XB6T4ETK.js.map +0 -1
- package/dist/github-signals-UL2BBS3Q.js.map +0 -1
- package/dist/migrate-P2LS4WXI.js +0 -7
- /package/dist/{airtable-YZ6VMSQZ.js.map → airtable-HHAWHBNM.js.map} +0 -0
- /package/dist/{bump-deps-RYHQIKW4.js.map → bump-deps-2U6GIHM5.js.map} +0 -0
- /package/dist/{chunk-D3LFQNJK.js.map → chunk-3HM4S422.js.map} +0 -0
- /package/dist/{client-JA6QLEDW.js.map → chunk-6ZKM7OIJ.js.map} +0 -0
- /package/dist/{chunk-VDBENQUA.js.map → chunk-BMDBOPN3.js.map} +0 -0
- /package/dist/{chunk-HB7QPECR.js.map → chunk-KTPPKO2C.js.map} +0 -0
- /package/dist/{chunk-TZN2G6FD.js.map → chunk-NPJFHBAX.js.map} +0 -0
- /package/dist/{chunk-6CSCLBAA.js.map → chunk-SAOCVNX7.js.map} +0 -0
- /package/dist/{chunk-JNXCDUYB.js.map → chunk-XVR7UM6Z.js.map} +0 -0
- /package/dist/{chunk-HPSGCDWY.js.map → chunk-YYUY2OVP.js.map} +0 -0
- /package/dist/{lighthouse-airtable-N3KCBPVA.js.map → client-GTMJTXBK.js.map} +0 -0
- /package/dist/{convert-to-pnpm-KNCXU6FM.js.map → convert-to-pnpm-JG3767ZQ.js.map} +0 -0
- /package/dist/{db-AXKMRTVA.js.map → db-Y6BVTNH7.js.map} +0 -0
- /package/dist/{digest-WAS3EJNC.js.map → digest-ABJROO4H.js.map} +0 -0
- /package/dist/{init-KD56UENX.js.map → init-DMAIKUQE.js.map} +0 -0
- /package/dist/{migrate-P2LS4WXI.js.map → lighthouse-airtable-HTEW6BBT.js.map} +0 -0
- /package/dist/{orchestrate-D72PB6NG.js.map → migrate-BFOG2OCP.js.map} +0 -0
- /package/dist/{onboard-SCZ3UKZ2.js.map → onboard-TTIC3YZI.js.map} +0 -0
- /package/dist/{websites-IYP7IGLU.js.map → orchestrate-I3KAJGFB.js.map} +0 -0
- /package/dist/{renovate-dispatch-NG5KUQKY.js.map → renovate-dispatch-QDDMTATD.js.map} +0 -0
- /package/dist/{report-EPHXV2T2.js.map → report-PY6O3YMX.js.map} +0 -0
- /package/dist/{self-updating-CFHXJSAX.js.map → self-updating-2RLUP4AA.js.map} +0 -0
- /package/dist/{svelte-codemods-HNITLWQM.js.map → svelte-codemods-BPLKSVIA.js.map} +0 -0
- /package/dist/{sync-configs-BZXBYKTI.js.map → sync-configs-VFRV3TJ4.js.map} +0 -0
- /package/dist/{upgrade-6YDUELFA.js.map → upgrade-WCFD2TRN.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-VDGHCZGQ.js.map → websites-HM2NMZMR.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/audits/deps.ts","../src/audits/deps-outdated.ts","../src/audits/lint.ts","../src/audits/security.ts","../src/audits/lighthouse.ts","../src/audits/util/site-config.ts","../src/util/free-port.ts","../src/audits/a11y.ts","../src/audits/domain.ts","../src/audits/route-discovery.ts","../src/audits/browser.ts","../src/audits/netlify-deploy.ts","../src/audits/index.ts"],"sourcesContent":["import { readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { AuditResult } from \"../types.js\";\nimport { siteLabel } from \"../util/site.js\";\nimport { baselineVersions } from \"../configs/baseline-versions.js\";\nimport type { AuditContext } from \"./util/inject.js\";\nimport { defaultSpawn } from \"./util/spawn.js\";\nimport { scanOutdated, type OutdatedCounts } from \"./deps-outdated.js\";\n\nexport type Drift = \"same\" | \"patch\" | \"minor\" | \"major\" | \"newer\";\n\nexport type DepsDriftEntry = {\n pkg: string;\n baseline: string;\n actual: string;\n drift: Drift;\n};\n\n/** The deps audit reports TWO signals:\n * - `entries`: declared-range drift vs the canonical baseline (what the\n * package.json *asks for*, caret-stripped) — the long-standing signal.\n * - `outdated`: real installed-version drift vs the registry's latest, from\n * the committed lockfile (null when it can't be determined). Added so the\n * \"Deps Drifted\" dashboard number stops being the only — and misleading —\n * deps signal. */\nexport type DepsDetails = {\n entries: DepsDriftEntry[];\n outdated: OutdatedCounts | null;\n};\n\nfunction stripCaret(range: string): string {\n return range.replace(/^[\\^~]/, \"\");\n}\n\n/** A spec we can drift-compare against a semver baseline: a plain version or\n * caret/tilde range like \"5.55.10\", \"^5.55.10\", \"~5.0.0\". Excludes \"*\",\n * \"latest\", \"workspace:*\", \"npm:\"-aliases, and git/URL/file specs — those used\n * to parse to NaN and produce bogus drift, so they're skipped instead. */\nfunction isComparableRange(spec: string): boolean {\n return /^[\\^~]?\\d/.test(spec.trim());\n}\n\nfunction parseSemver(v: string): [number, number, number] {\n const cleaned = stripCaret(v).split(\"-\")[0] ?? \"0.0.0\";\n const parts = cleaned.split(\".\").map((n) => Number.parseInt(n, 10));\n return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];\n}\n\nfunction compareSemver(actual: string, baseline: string): Drift {\n const [aMajor, aMinor, aPatch] = parseSemver(actual);\n const [bMajor, bMinor, bPatch] = parseSemver(baseline);\n if (aMajor > bMajor) return \"newer\";\n if (aMajor < bMajor) return \"major\";\n if (aMinor > bMinor) return \"newer\";\n if (aMinor < bMinor) return \"minor\";\n if (aPatch > bPatch) return \"newer\";\n if (aPatch < bPatch) return \"patch\";\n return \"same\";\n}\n\nexport async function depsAudit(ctx: AuditContext): Promise<AuditResult> {\n const pkgPath = join(ctx.site.path, \"package.json\");\n let pkgRaw: string;\n try {\n pkgRaw = await readFile(pkgPath, \"utf-8\");\n } catch (err) {\n return {\n audit: \"deps\",\n site: siteLabel(ctx.site),\n status: \"skip\",\n summary: `no package.json at ${pkgPath}`,\n details: { error: String(err) },\n };\n }\n\n let pkg: { dependencies?: Record<string, string>; devDependencies?: Record<string, string> };\n try {\n pkg = JSON.parse(pkgRaw) as {\n dependencies?: Record<string, string>;\n devDependencies?: Record<string, string>;\n };\n } catch (err) {\n return {\n audit: \"deps\",\n site: siteLabel(ctx.site),\n status: \"fail\",\n summary: `package.json is not valid JSON: ${(err as Error).message}`,\n details: { error: String(err) },\n };\n }\n const installed: Record<string, string> = {\n ...(pkg.dependencies ?? {}),\n ...(pkg.devDependencies ?? {}),\n };\n\n const entries: DepsDriftEntry[] = [];\n for (const [name, baseline] of Object.entries(baselineVersions)) {\n const actual = installed[name];\n if (!actual) continue;\n // Skip non-semver specs (\"*\", \"workspace:*\", \"npm:\"-aliases, git/URL): they\n // can't be drift-compared and used to yield NaN-driven bogus drift (LOW-3).\n if (!isComparableRange(actual)) continue;\n entries.push({\n pkg: name,\n baseline,\n actual,\n drift: compareSemver(actual, baseline),\n });\n }\n\n const anyMajor = entries.some((d) => d.drift === \"major\");\n const anyMinor = entries.some((d) => d.drift === \"minor\");\n const anyNewer = entries.some((d) => d.drift === \"newer\");\n\n // Status stays driven by the declared-range baseline drift (unchanged\n // behavior). The outdated count is an independent, informational signal.\n const status: AuditResult[\"status\"] = anyMajor ? \"fail\" : anyMinor || anyNewer ? \"warn\" : \"pass\";\n\n const driftSummary =\n status === \"pass\"\n ? `all ${entries.length} tracked deps in line with baseline`\n : status === \"warn\"\n ? `${entries.filter((d) => d.drift !== \"same\").length} of ${entries.length} tracked deps drifted`\n : `${entries.filter((d) => d.drift === \"major\").length} deps lagging by a major version`;\n\n const outdated = await scanOutdated(ctx.site.path, ctx.spawn ?? defaultSpawn);\n const summary = outdated\n ? `${driftSummary}; ${outdated.outdated} outdated install(s) (${outdated.major} major)`\n : driftSummary;\n\n return {\n audit: \"deps\",\n site: siteLabel(ctx.site),\n status,\n summary,\n details: { entries, outdated } satisfies DepsDetails,\n };\n}\n","import { stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { SpawnFn } from \"./util/spawn.js\";\n\n/** Real installed-version drift, distinct from the declared-range \"drift\" the\n * deps audit computes against the baseline: how many dependencies are behind\n * the registry's latest, per the committed lockfile. */\nexport type OutdatedCounts = { outdated: number; major: number };\n\nasync function exists(path: string): Promise<boolean> {\n try {\n await stat(path);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction majorOf(version: string): number {\n const head = version.replace(/^[\\^~]/, \"\").split(\".\")[0] ?? \"0\";\n const n = Number.parseInt(head, 10);\n return Number.isNaN(n) ? 0 : n;\n}\n\n/**\n * Count outdated dependencies for a site, using its committed lockfile as the\n * source of truth for \"what's installed/deployed\". Returns `null` (skip — the\n * caller degrades gracefully) when it can't determine this:\n * - no `pnpm-lock.yaml` (not a pnpm site, or never installed)\n * - the lockfile is stale vs package.json (`--frozen-lockfile` install fails)\n * - `pnpm outdated` output isn't parseable\n *\n * `pnpm outdated` exits non-zero precisely WHEN there are outdated packages, so\n * its exit code is ignored and only its JSON is parsed. `--frozen-lockfile`\n * never mutates the lockfile, so this stays read-only with respect to the repo.\n */\nexport async function scanOutdated(\n sitePath: string,\n spawn: SpawnFn,\n): Promise<OutdatedCounts | null> {\n if (!(await exists(join(sitePath, \"pnpm-lock.yaml\")))) return null;\n\n // Everything below is best-effort: a thrown spawn (timeout, `pnpm` not on\n // PATH, spawn error) must degrade to a skip (null), NOT bubble up and flip the\n // whole deps audit to a hard fail — the declared-range drift is independent of\n // pnpm and must still report. (Mirrors securityAudit's try/catch.)\n try {\n // Materialize node_modules from the lockfile, but only when it's missing —\n // an already-installed checkout skips the cold install. `--frozen-lockfile`\n // never rewrites the lockfile (read-only wrt the repo) and fails fast on a\n // lockfile out of sync with package.json → skip.\n if (!(await exists(join(sitePath, \"node_modules\")))) {\n const install = await spawn(\"pnpm\", [\"install\", \"--frozen-lockfile\"], {\n cwd: sitePath,\n timeoutMs: 180_000,\n });\n if (install.code !== 0) return null;\n }\n\n // `pnpm outdated` exits non-zero precisely WHEN there are outdated packages,\n // so its exit code is ignored and only its JSON is parsed.\n const res = await spawn(\"pnpm\", [\"outdated\", \"--json\"], {\n cwd: sitePath,\n timeoutMs: 60_000,\n });\n const parsed = JSON.parse(res.stdout || \"{}\") as Record<\n string,\n { current?: string; latest?: string }\n >;\n const entries = Object.values(parsed);\n return {\n outdated: entries.length,\n major: entries.filter((e) => e.current && e.latest && majorOf(e.latest) > majorOf(e.current))\n .length,\n };\n } catch {\n return null;\n }\n}\n","import { existsSync } from \"node:fs\";\nimport { readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { ESLint } from \"eslint\";\nimport { check as prettierCheck, resolveConfig as prettierResolveConfig } from \"prettier\";\nimport { glob } from \"tinyglobby\";\nimport type { AuditResult } from \"../types.js\";\nimport { siteLabel } from \"../util/site.js\";\nimport type { AuditContext } from \"./util/inject.js\";\n\nconst TARGET_GLOBS = [\"**/*.{ts,js,svelte}\"];\nconst IGNORE = [\"node_modules/**\", \"dist/**\", \".svelte-kit/**\", \"build/**\", \".netlify/**\"];\n\nasync function listFiles(cwd: string): Promise<string[]> {\n return glob(TARGET_GLOBS, { cwd, ignore: IGNORE, absolute: false });\n}\n\nexport async function lintAudit(ctx: AuditContext): Promise<AuditResult> {\n const { site } = ctx;\n const configPath = join(site.path, \"eslint.config.js\");\n\n if (!existsSync(configPath)) {\n return {\n audit: \"lint\",\n site: siteLabel(site),\n status: \"skip\",\n summary: \"no eslint config at site root\",\n };\n }\n\n const eslint = new ESLint({\n cwd: site.path,\n overrideConfigFile: configPath,\n errorOnUnmatchedPattern: false,\n });\n\n const relFiles = await listFiles(site.path);\n\n // Pass relative paths to ESLint; its cwd is already site.path. Avoids\n // dereferencing symlinks on pnpm workspaces.\n const eslintResults = await eslint.lintFiles(relFiles);\n const eslintErrors = eslintResults.reduce((n, r) => n + r.errorCount, 0);\n const eslintWarnings = eslintResults.reduce((n, r) => n + r.warningCount, 0);\n\n const prettierUnformatted: string[] = [];\n for (const rel of relFiles) {\n const absForResolve = join(site.path, rel);\n const source = await readFile(absForResolve, \"utf-8\");\n const options = (await prettierResolveConfig(absForResolve)) ?? {};\n const ok = await prettierCheck(source, { ...options, filepath: absForResolve });\n if (!ok) prettierUnformatted.push(rel);\n }\n\n const status: AuditResult[\"status\"] =\n eslintErrors > 0 || prettierUnformatted.length > 0\n ? \"fail\"\n : eslintWarnings > 0\n ? \"warn\"\n : \"pass\";\n\n const summary =\n status === \"pass\"\n ? `lint clean across ${relFiles.length} files`\n : `${eslintErrors} eslint errors, ${eslintWarnings} warnings, ${prettierUnformatted.length} unformatted`;\n\n return {\n audit: \"lint\",\n site: siteLabel(site),\n status,\n summary,\n details: {\n eslintErrors,\n eslintWarnings,\n prettierUnformatted,\n files: relFiles.length,\n },\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport { siteLabel } from \"../util/site.js\";\nimport { defaultSpawn, type SpawnResult } from \"./util/spawn.js\";\nimport type { AuditContext } from \"./util/inject.js\";\n\ntype Severity = \"low\" | \"moderate\" | \"high\" | \"critical\";\n\ntype Counts = { low: number; moderate: number; high: number; critical: number };\n\ntype AdvisoryEntry = {\n module: string;\n severity: Severity;\n title: string;\n cves?: string[];\n url?: string;\n};\n\n// pnpm audit output (npm-compat with extra advisories map keyed by ID).\ntype PnpmAuditJson = {\n metadata?: { vulnerabilities?: Partial<Counts> };\n advisories?: Record<\n string,\n {\n id?: number;\n title?: string;\n module_name?: string;\n severity?: string;\n cves?: string[];\n url?: string;\n }\n >;\n};\n\n// npm v7+ shape (vulnerabilities keyed by package name).\ntype NpmAuditJson = {\n metadata?: { vulnerabilities?: Partial<Counts> };\n vulnerabilities?: Record<\n string,\n {\n name?: string;\n severity?: string;\n via?: unknown;\n url?: string;\n }\n >;\n};\n\nfunction classify(v: Counts) {\n if (v.critical > 0 || v.high > 0) return \"fail\" as const;\n if (v.moderate > 0 || v.low > 0) return \"warn\" as const;\n return \"pass\" as const;\n}\n\nfunction normalizeSeverity(s: unknown): Severity {\n if (s === \"low\" || s === \"moderate\" || s === \"high\" || s === \"critical\") return s;\n // npm/pnpm sometimes emit \"info\" for informational advisories. Map down\n // rather than defaulting to \"moderate\" (which would inflate severity).\n return \"low\";\n}\n\nfunction extractAdvisoriesFromPnpm(parsed: PnpmAuditJson): AdvisoryEntry[] {\n const out: AdvisoryEntry[] = [];\n for (const a of Object.values(parsed.advisories ?? {})) {\n if (!a) continue;\n out.push({\n module: a.module_name ?? \"unknown\",\n severity: normalizeSeverity(a.severity),\n title: a.title ?? \"(no title)\",\n ...(a.cves ? { cves: a.cves } : {}),\n ...(a.url ? { url: a.url } : {}),\n });\n }\n return out;\n}\n\n/** Walk an npm v7+ `via` chain to find the root entry whose `via` array\n * contains a real advisory object (rather than another package name string).\n * Returns the package name at the root and the advisory detail. */\nfunction resolveNpmAdvisoryRoot(\n startName: string,\n vulnerabilities: NonNullable<NpmAuditJson[\"vulnerabilities\"]>,\n): { rootName: string; detail?: { title?: string; url?: string } } {\n const seen = new Set<string>();\n let current = startName;\n while (!seen.has(current)) {\n seen.add(current);\n const entry = vulnerabilities[current];\n if (!entry || !Array.isArray(entry.via)) return { rootName: current };\n\n const detailed = entry.via.find(\n (e): e is { title?: string; url?: string } => typeof e === \"object\" && e !== null,\n );\n if (detailed) return { rootName: current, detail: detailed };\n\n const next = entry.via.find((e): e is string => typeof e === \"string\");\n if (!next || next === current) return { rootName: current };\n current = next;\n }\n return { rootName: current };\n}\n\nfunction extractAdvisoriesFromNpm(parsed: NpmAuditJson): AdvisoryEntry[] {\n const vulnerabilities = parsed.vulnerabilities ?? {};\n const roots = new Map<string, AdvisoryEntry>();\n\n for (const [name, v] of Object.entries(vulnerabilities)) {\n if (!v) continue;\n const { rootName, detail } = resolveNpmAdvisoryRoot(name, vulnerabilities);\n if (roots.has(rootName)) continue; // already surfaced via another transitive entry\n\n const rootEntry = vulnerabilities[rootName];\n const severity = normalizeSeverity(rootEntry?.severity ?? v.severity);\n const title = detail?.title ?? rootName;\n const url = detail?.url;\n\n roots.set(rootName, {\n module: rootEntry?.name ?? rootName,\n severity,\n title,\n ...(url ? { url } : {}),\n });\n }\n\n return [...roots.values()];\n}\n\ntype ToolResult =\n | { kind: \"missing\" }\n | { kind: \"error\"; reason: string }\n | { kind: \"ok\"; parsed: PnpmAuditJson & NpmAuditJson };\n\nasync function runAuditTool(\n spawn: (cmd: string, args: readonly string[], opts?: { cwd?: string }) => Promise<SpawnResult>,\n cmd: string,\n args: readonly string[],\n cwd: string,\n): Promise<ToolResult> {\n let raw: SpawnResult;\n try {\n raw = await spawn(cmd, args, { cwd });\n } catch (err) {\n const e = err as NodeJS.ErrnoException;\n if (e.code === \"ENOENT\" || /ENOENT/.test(String(err))) return { kind: \"missing\" };\n return { kind: \"error\", reason: `spawn failed: ${String(err).slice(0, 200)}` };\n }\n\n // 0 = clean, 1 = vulns found. Anything else is a real error.\n if (raw.code !== 0 && raw.code !== 1) {\n return {\n kind: \"error\",\n reason: `exit ${raw.code}${raw.stderr ? `: ${raw.stderr.slice(0, 150)}` : \"\"}`,\n };\n }\n\n let parsed: PnpmAuditJson & NpmAuditJson;\n try {\n parsed = JSON.parse(raw.stdout || \"{}\") as PnpmAuditJson & NpmAuditJson;\n } catch (err) {\n return { kind: \"error\", reason: `unparseable JSON: ${String(err).slice(0, 100)}` };\n }\n\n // pnpm error envelope: { error: { code, message } }. npm sometimes emits\n // a top-level error too. Either means the audit didn't actually run.\n const errEnvelope = (parsed as unknown as { error?: { code?: string } }).error;\n if (errEnvelope && typeof errEnvelope === \"object\") {\n return { kind: \"error\", reason: errEnvelope.code ?? \"error envelope returned\" };\n }\n\n // Without metadata.vulnerabilities there are no counts to report and we\n // can't trust the result. An empty `{}` is just as suspect as a missing\n // key — counts default to 0 and we'd silently report \"pass\". Treat both\n // as a tool failure so the caller can fall through to the other audit.\n const vulnsMeta = parsed.metadata?.vulnerabilities;\n if (!vulnsMeta || Object.keys(vulnsMeta).length === 0) {\n return { kind: \"error\", reason: \"no metadata.vulnerabilities in output\" };\n }\n\n return { kind: \"ok\", parsed };\n}\n\nexport async function securityAudit(ctx: AuditContext): Promise<AuditResult> {\n const spawn = ctx.spawn ?? defaultSpawn;\n const site = ctx.site;\n const label = siteLabel(site);\n\n let used: \"pnpm audit\" | \"npm audit\" = \"pnpm audit\";\n let result = await runAuditTool(spawn, \"pnpm\", [\"audit\", \"--json\", \"--prod\"], site.path);\n\n // Fall through to npm if pnpm is missing OR pnpm couldn't actually\n // audit the project (e.g., no pnpm-lock.yaml). Previously we only fell\n // through on ENOENT, which meant npm-using sites silently reported \"pass\"\n // because pnpm returned an error envelope with no metadata.\n if (result.kind !== \"ok\") {\n const pnpmReason = result.kind === \"missing\" ? \"not installed\" : result.reason;\n const npmResult = await runAuditTool(\n spawn,\n \"npm\",\n [\"audit\", \"--json\", \"--omit=dev\"],\n site.path,\n );\n if (npmResult.kind === \"ok\") {\n result = npmResult;\n used = \"npm audit\";\n } else {\n const npmReason = npmResult.kind === \"missing\" ? \"not installed\" : npmResult.reason;\n return {\n audit: \"security\",\n site: label,\n status: \"skip\",\n summary: `cannot run audit — pnpm: ${pnpmReason}; npm: ${npmReason}`,\n };\n }\n }\n\n const parsed = result.parsed;\n\n const counts: Counts = {\n low: parsed.metadata?.vulnerabilities?.low ?? 0,\n moderate: parsed.metadata?.vulnerabilities?.moderate ?? 0,\n high: parsed.metadata?.vulnerabilities?.high ?? 0,\n critical: parsed.metadata?.vulnerabilities?.critical ?? 0,\n };\n\n const advisories =\n used === \"pnpm audit\" ? extractAdvisoriesFromPnpm(parsed) : extractAdvisoriesFromNpm(parsed);\n\n const status = classify(counts);\n const total = counts.low + counts.moderate + counts.high + counts.critical;\n const summary =\n status === \"pass\"\n ? `${used}: 0 vulnerabilities`\n : `${used}: ${total} vulnerabilities (${counts.critical}C/${counts.high}H/${counts.moderate}M/${counts.low}L)`;\n\n return {\n audit: \"security\",\n site: label,\n status,\n summary,\n details: { counts, advisories },\n };\n}\n","import { readFile, writeFile, mkdtemp, rm, readdir } from \"node:fs/promises\";\nimport { tmpdir } from \"node:os\";\nimport { join } from \"node:path\";\nimport type { AuditResult, Site } from \"../types.js\";\nimport { siteLabel } from \"../util/site.js\";\nimport { lighthouseConfig } from \"../configs/lighthouse.js\";\nimport { defaultSpawn } from \"./util/spawn.js\";\nimport type { SpawnFn, SpawnResult } from \"./util/spawn.js\";\nimport type { AuditContext } from \"./util/inject.js\";\nimport { readSiteConfig } from \"./util/site-config.js\";\nimport { findFreePort, withFreePort } from \"../util/free-port.js\";\n\ntype ManifestEntry = {\n url: string;\n summary: Record<string, number>;\n htmlPath?: string;\n jsonPath?: string;\n};\n\ntype AssertionResult = {\n name: string;\n actual: number;\n expected: number;\n operator: string;\n passed: boolean;\n level: \"warn\" | \"error\";\n auditProperty?: string;\n auditId?: string;\n};\n\ntype NormalizedLhciResult = {\n summary: Record<string, number>;\n assertionsFailed: number;\n assertions: Array<{ category: string; level: \"warn\" | \"error\"; message: string }>;\n};\n\nasync function readJsonMaybe<T>(path: string): Promise<T | null> {\n try {\n const raw = await readFile(path, \"utf-8\");\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\ntype LhrFile = {\n requestedUrl: string;\n finalUrl?: string;\n categories: Record<string, { score: number | null }>;\n};\n\n/**\n * Build manifest-equivalent entries by scanning the `.lighthouseci/` dir\n * for `lhr-*.json` files written by `lhci collect`. We used to read\n * `manifest.json` directly, but lhci 0.15+ no longer writes it — the\n * audit would silently return \"no manifest written\" against a perfectly\n * healthy run. Reproduced on caltex 2026-05-28 (0.10.5 dogfood).\n */\nasync function readLhrEntries(resultsDir: string): Promise<ManifestEntry[]> {\n const files = await readdir(resultsDir).catch(() => [] as string[]);\n const entries: ManifestEntry[] = [];\n for (const f of files) {\n if (!f.startsWith(\"lhr-\") || !f.endsWith(\".json\")) continue;\n const lhr = await readJsonMaybe<LhrFile>(join(resultsDir, f));\n if (!lhr || !lhr.categories) continue;\n const summary: Record<string, number> = {};\n for (const [k, v] of Object.entries(lhr.categories)) {\n if (typeof v?.score === \"number\") summary[k] = v.score;\n }\n entries.push({ url: lhr.requestedUrl, summary });\n }\n return entries;\n}\n\nfunction averageSummaries(entries: ManifestEntry[]): Record<string, number> {\n if (entries.length === 0) return {};\n const sums: Record<string, number> = {};\n const counts: Record<string, number> = {};\n for (const e of entries) {\n for (const [k, v] of Object.entries(e.summary ?? {})) {\n if (typeof v !== \"number\") continue;\n sums[k] = (sums[k] ?? 0) + v;\n counts[k] = (counts[k] ?? 0) + 1;\n }\n }\n const out: Record<string, number> = {};\n for (const k of Object.keys(sums)) {\n const total = sums[k] ?? 0;\n const count = counts[k] ?? 1;\n out[k] = total / count;\n }\n return out;\n}\n\nfunction categoryFromAssertion(a: AssertionResult): string {\n // `name` looks like \"categories:accessibility\" or \"audits:uses-http2\".\n const colonIdx = a.name.indexOf(\":\");\n return colonIdx >= 0 ? a.name.slice(colonIdx + 1) : a.name;\n}\n\nfunction messageForAssertion(a: AssertionResult): string {\n // `a.actual` is parsed from external lhci/Lighthouse JSON; a malformed or\n // missing value (not a number) would make `.toFixed` throw and crash the whole\n // audit. Guard it and fall back to a readable string instead.\n const actual = typeof a.actual === \"number\" ? a.actual.toFixed(2) : \"n/a\";\n return `${a.name} ${a.operator} ${a.expected} (actual: ${actual})`;\n}\n\n/** Shared tail: scan `.lighthouseci/` for lhr-*.json + assertion-results.json and\n * build the AuditResult. Identical for the checkout and deployed paths. */\nasync function parseLhciResults(\n resultsDir: string,\n label: string,\n raw: SpawnResult,\n): Promise<AuditResult> {\n const manifest = await readLhrEntries(resultsDir);\n\n if (manifest.length === 0) {\n return {\n audit: \"lighthouse\",\n site: label,\n status: \"fail\",\n summary: `lighthouse: no lhr-*.json written (exit ${raw.code})${\n raw.stderr ? ` — ${raw.stderr.slice(0, 200)}` : \"\"\n }`,\n };\n }\n\n const assertionResults =\n (await readJsonMaybe<AssertionResult[]>(join(resultsDir, \"assertion-results.json\"))) ?? [];\n\n const failed = assertionResults.filter((a) => !a.passed);\n const assertions = failed.map((a) => ({\n category: categoryFromAssertion(a),\n level: a.level,\n message: messageForAssertion(a),\n }));\n\n const anyError = assertions.some((a) => a.level === \"error\");\n const anyWarn = assertions.some((a) => a.level === \"warn\");\n const status: AuditResult[\"status\"] = anyError ? \"fail\" : anyWarn ? \"warn\" : \"pass\";\n\n const normalized: NormalizedLhciResult = {\n summary: averageSummaries(manifest),\n assertionsFailed: failed.length,\n assertions,\n };\n\n const summary =\n status === \"pass\"\n ? \"lighthouse: all categories passing\"\n : `lighthouse: ${failed.length} assertion(s) failed`;\n\n return { audit: \"lighthouse\", site: label, status, summary, details: normalized };\n}\n\n/** Checkout mode (unchanged behavior): boot the site's vite dev server on a\n * pinned free port and audit the local fixtures/override URL. */\nasync function checkoutLighthouse(spawn: SpawnFn, site: Site, label: string): Promise<AuditResult> {\n const siteCfg = await readSiteConfig(site.path);\n // Allocate a free port + force vite to `--strictPort` so the spawned dev\n // server either binds the port we picked or fails loudly (caltex 2026-05-28\n // zombie-vite incident).\n const port = await findFreePort();\n const baseUrl = siteCfg.lighthouseUrl ?? lighthouseConfig.ci.collect.url[0];\n const resolvedConfig = {\n ...lighthouseConfig,\n ci: {\n ...lighthouseConfig.ci,\n collect: {\n ...lighthouseConfig.ci.collect,\n url: [withFreePort(baseUrl, port)],\n startServerCommand: `npm run vite:dev -- --port ${port} --strictPort`,\n },\n },\n };\n\n const configDir = await mkdtemp(join(tmpdir(), \"reddoor-lhci-\"));\n const configPath = join(configDir, \"lighthouserc.json\");\n await writeFile(configPath, JSON.stringify(resolvedConfig), \"utf-8\");\n\n const resultsDir = join(site.path, \".lighthouseci\");\n await rm(resultsDir, { recursive: true, force: true });\n\n let raw: SpawnResult;\n try {\n raw = await spawn(\"npx\", [\"--yes\", \"@lhci/cli\", \"autorun\", `--config=${configPath}`], {\n cwd: site.path,\n timeoutMs: 5 * 60_000,\n });\n } catch (err) {\n await rm(configDir, { recursive: true, force: true });\n const e = err as NodeJS.ErrnoException;\n if (e.code === \"ENOENT\" || /ENOENT/.test(String(err))) {\n return {\n audit: \"lighthouse\",\n site: label,\n status: \"skip\",\n summary: \"npx/@lhci/cli not available\",\n };\n }\n throw err;\n }\n await rm(configDir, { recursive: true, force: true });\n\n return parseLhciResults(resultsDir, label, raw);\n}\n\n/** Deployed mode: audit a production URL directly — no checkout, no dev server.\n * Runs in a throwaway tmp cwd; uploads to the filesystem so fleet runs never\n * push 200 public reports to temporary-public-storage. */\nasync function deployedLighthouse(\n spawn: SpawnFn,\n deployedUrl: string,\n label: string,\n): Promise<AuditResult> {\n const workDir = await mkdtemp(join(tmpdir(), \"reddoor-lh-deployed-\"));\n const resolvedConfig = {\n ci: {\n // Deliberately NOT spread from lighthouseConfig.ci.collect: deployed mode\n // must omit startServerCommand and the dev-server settings entirely.\n collect: {\n url: [deployedUrl],\n // 3 runs to damp Lighthouse's run-to-run variance; parseLhciResults\n // averages the lhr files. (Median is a tracked future refinement.)\n numberOfRuns: 3,\n // Use devtools (applied) throttling instead of the desktop preset's\n // default `simulate` (Lantern). Lantern estimates LCP from the\n // dependency graph and can't model an LCP element revealed by a\n // JS-load opacity fade (e.g. gallerysonder's GridImage): on a fast\n // observed load it finds no LCP node and throws NO_LCP, nulling the\n // perf score, which the cockpit then coerces to a misleading 0.\n // devtools reads the real LCP paint event from the throttled trace\n // instead. Trade-off: slightly noisier run-to-run, damped by the\n // numberOfRuns:3 average above.\n settings: { preset: \"desktop\", throttlingMethod: \"devtools\", skipAudits: [\"uses-http2\"] },\n },\n assert: lighthouseConfig.ci.assert,\n upload: { target: \"filesystem\", outputDir: join(workDir, \"lhci-report\") },\n },\n };\n\n const configPath = join(workDir, \"lighthouserc.json\");\n await writeFile(configPath, JSON.stringify(resolvedConfig), \"utf-8\");\n\n const resultsDir = join(workDir, \".lighthouseci\");\n\n let raw: SpawnResult;\n try {\n raw = await spawn(\"npx\", [\"--yes\", \"@lhci/cli\", \"autorun\", `--config=${configPath}`], {\n cwd: workDir,\n // 3 serial cold runs of a slow deployed site (lhci's own maxWaitForLoad\n // ~45-60s each) + first-use Chrome download can plausibly exceed 3 min →\n // SIGTERM → no lhr-*.json → spurious \"no scores\". Match the 5-min budget\n // the checkout path already gives (erp-industrials nightly flake,\n // morning-brief 2026-06-10 MEDIUM-F).\n timeoutMs: 5 * 60_000,\n });\n } catch (err) {\n await rm(workDir, { recursive: true, force: true });\n const e = err as NodeJS.ErrnoException;\n if (e.code === \"ENOENT\" || /ENOENT/.test(String(err))) {\n return {\n audit: \"lighthouse\",\n site: label,\n status: \"skip\",\n summary: \"npx/@lhci/cli not available\",\n };\n }\n throw err;\n }\n\n try {\n return await parseLhciResults(resultsDir, label, raw);\n } finally {\n await rm(workDir, { recursive: true, force: true });\n }\n}\n\nexport async function lighthouseAudit(ctx: AuditContext): Promise<AuditResult> {\n const spawn = ctx.spawn ?? defaultSpawn;\n const site = ctx.site;\n const label = siteLabel(site);\n\n return site.deployedUrl\n ? deployedLighthouse(spawn, site.deployedUrl, label)\n : checkoutLighthouse(spawn, site, label);\n}\n","import { readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nexport type SiteConfig = {\n /** Override URL the lighthouse audit hits. Sites without the default\n * `/dev/a11y-fixtures` dev route set this to their homepage. */\n lighthouseUrl?: string;\n};\n\n/**\n * Read per-site overrides from `package.json#reddoor`. Returns `{}` on any\n * failure (missing file, malformed JSON, missing key, wrong type) so every\n * caller can safely fall back to its built-in default. Never throws.\n */\nexport async function readSiteConfig(sitePath: string): Promise<SiteConfig> {\n let raw: string;\n try {\n raw = await readFile(join(sitePath, \"package.json\"), \"utf-8\");\n } catch {\n return {};\n }\n let pkg: unknown;\n try {\n pkg = JSON.parse(raw);\n } catch {\n return {};\n }\n if (!pkg || typeof pkg !== \"object\") return {};\n const cfg = (pkg as { reddoor?: unknown }).reddoor;\n if (!cfg || typeof cfg !== \"object\") return {};\n\n const out: SiteConfig = {};\n const url = (cfg as { lighthouseUrl?: unknown }).lighthouseUrl;\n if (typeof url === \"string\" && url.length > 0) {\n out.lighthouseUrl = url;\n }\n return out;\n}\n","import { createServer } from \"node:net\";\n\n/**\n * Bind an ephemeral TCP port, capture it, release it, and return it. Used\n * by the lighthouse and a11y audits to pick a port the audit's own dev\n * server will then bind via `--strictPort`.\n *\n * Why: vite's default behavior on a busy port is to bump to the next free\n * one (5173 → 5174 → …). When zombie vite processes (or any squatter) are\n * already on 5173, the audit's spawned vite lands on a higher port, but\n * the audit tooling (lhci, playwright) still probes 5173 — hits the\n * zombie — gets stale 404s — fails with \"no manifest written\" / \"no\n * results written (exit 1)\". Reproduced on caltex 2026-05-28 with 10\n * orphaned vite processes accumulated across this repo, the reports repo,\n * and caltex itself. Allocating a free port up front + `--strictPort`\n * makes the audit immune to port collisions.\n *\n * TOCTOU note: the small window between close() and the spawned vite\n * binding is theoretically racy, but in practice we run one audit at a\n * time and the OS keeps the port free for re-use. If vite still fails to\n * bind under `--strictPort`, the audit fails loudly — that's the correct\n * outcome (vs. silently auditing the wrong server).\n */\nexport async function findFreePort(): Promise<number> {\n return new Promise((resolve, reject) => {\n const server = createServer();\n server.unref();\n server.on(\"error\", reject);\n server.listen(0, \"127.0.0.1\", () => {\n const addr = server.address();\n if (typeof addr === \"object\" && addr) {\n const port = addr.port;\n server.close(() => resolve(port));\n } else {\n server.close();\n reject(new Error(\"findFreePort: could not determine assigned port from socket\"));\n }\n });\n });\n}\n\n/**\n * Swap the port (and force `localhost` host) on a URL so it points at the\n * audit's freshly-allocated dev server. Preserves the path + any query.\n * Used to rewrite the lighthouse `url` so lhci probes the correct port.\n */\nexport function withFreePort(url: string, port: number): string {\n const u = new URL(url);\n u.hostname = \"localhost\";\n u.port = String(port);\n return u.toString();\n}\n","import { readFile, writeFile, mkdtemp, rm } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport type { AuditResult } from \"../types.js\";\nimport { siteLabel } from \"../util/site.js\";\nimport { a11yRoutes, smokeRoutes } from \"../configs/playwright-a11y.js\";\nimport { defaultSpawn } from \"./util/spawn.js\";\nimport type { AuditContext } from \"./util/inject.js\";\nimport { findFreePort } from \"../util/free-port.js\";\n\ntype Impact = \"minor\" | \"moderate\" | \"serious\" | \"critical\";\n\ntype AxeViolation = {\n id: string;\n impact: Impact;\n route: string;\n help?: string;\n helpUrl?: string;\n nodes?: Array<{ html?: string; target?: string[] }>;\n};\n\ntype NormalizedA11y = {\n totalViolations: number;\n byImpact: Partial<Record<Impact, number>>;\n violations: AxeViolation[];\n};\n\nconst RESULTS_REL = \".reddoor-a11y/results.json\";\n\nasync function readJsonMaybe<T>(path: string): Promise<T | null> {\n try {\n const raw = await readFile(path, \"utf-8\");\n return JSON.parse(raw) as T;\n } catch {\n return null;\n }\n}\n\n// The audit-controlled playwright config. We synthesize it (rather than\n// rely on the site's playwright.config.ts) so we can pin the dev server\n// port + force `--strictPort` — same fix as the lighthouse audit, same\n// reason (zombie vite processes squatting on 5173 would otherwise eat\n// the audit's request and return stale 404s).\nfunction buildPlaywrightConfig(port: number, sitePath: string): string {\n return `import { defineConfig } from \"@playwright/test\";\n\nexport default defineConfig({\n testDir: \".\",\n testMatch: /.*\\\\.spec\\\\.ts$/,\n fullyParallel: true,\n forbidOnly: !!process.env.CI,\n retries: process.env.CI ? 2 : 0,\n reporter: process.env.CI ? \"github\" : \"list\",\n use: {\n baseURL: \"http://localhost:${port}\",\n trace: \"on-first-retry\",\n },\n webServer: {\n // --strictPort: refuse to bump to a different port if ours is taken,\n // so the audit fails loudly instead of probing a zombie.\n // reuseExistingServer:false: never reuse — we control the lifecycle.\n // cwd: playwright's default webServer.cwd is the config file's\n // directory. Our config lives in /tmp so without this override,\n // \"npm run vite:dev\" tries to read /tmp/.../package.json and\n // ENOENTs before vite ever starts. Caltex 2026-05-28 (0.10.5).\n command: \"npm run vite:dev -- --port ${port} --strictPort\",\n url: \"http://localhost:${port}/dev/a11y-fixtures\",\n cwd: ${JSON.stringify(sitePath)},\n reuseExistingServer: false,\n timeout: 120_000,\n },\n});\n`;\n}\n\n// The spec the audit writes runs all configured routes through axe in a single\n// test (so worker isolation doesn't fragment the collected violations) and\n// writes the structured result to <cwd>/.reddoor-a11y/results.json before\n// asserting. That way, the audit can read real axe details even when the\n// expect(...).toEqual([]) assertion fails.\nfunction buildSpec(): string {\n return `import { test, expect } from \"@playwright/test\";\nimport AxeBuilder from \"@axe-core/playwright\";\nimport { mkdir, writeFile } from \"node:fs/promises\";\nimport { dirname } from \"node:path\";\n\nconst pages = ${JSON.stringify(a11yRoutes)};\nconst smokePages = ${JSON.stringify(smokeRoutes)};\nconst OUTPUT = process.env.REDDOOR_A11Y_OUTPUT;\n\n// Playwright's default per-test timeout is 30s. We loop through every\n// configured route in a single test, so the budget needs to scale.\ntest.setTimeout(5 * 60_000);\n\ntest(\"a11y + hydration across configured routes\", async ({ page }) => {\n const violations = [];\n\n // Capture uncaught client-side exceptions across every route we visit. A page\n // that builds + SSRs cleanly can still throw on hydrate and blank itself\n // (data-dynamiq: a Svelte 4->5 run() referenced a $state declared after it) --\n // axe never sees that, so we listen for it directly and tag the route in scope.\n let currentRoute = \"\";\n page.on(\"pageerror\", (err) => {\n violations.push({\n id: \"client-error\",\n impact: \"critical\",\n route: currentRoute,\n help: String(err && err.message ? err.message : err),\n });\n });\n\n for (const { path, name } of pages) {\n currentRoute = name;\n await page.goto(path);\n // Snap CSS transitions/animations to their resting state before axe runs.\n // AnimateIn-style fixtures transition opacity 0->1; sampling mid-transition\n // makes axe compute color-contrast against semi-transparent text, yielding a\n // flaky \"serious\" color-contrast violation (~1/3 of runs on /dev/animate-in).\n // Disabling transitions/animations forces the final, rendered state\n // deterministically -- which is also what users (and prefers-reduced-motion\n // users) actually see, so it's the correct thing to assert.\n await page.addStyleTag({\n content: \"*,*::before,*::after{transition:none!important;animation:none!important;}\",\n });\n const results = await new AxeBuilder({ page })\n .withTags([\"wcag2a\",\"wcag2aa\",\"wcag21a\",\"wcag21aa\",\"wcag22aa\"])\n .analyze();\n for (const v of results.violations) {\n violations.push({\n id: v.id,\n impact: v.impact ?? \"moderate\",\n route: name,\n help: v.help,\n helpUrl: v.helpUrl,\n nodes: v.nodes.map((n) => ({ html: n.html, target: n.target })),\n });\n }\n }\n\n // Hydration smoke check: load real routes (the homepage) and fail on any\n // uncaught client-side error. No axe here -- real routes carry pre-existing\n // a11y debt we don't gate on; we only assert they don't crash on hydrate.\n // HTTP/SSR errors don't fire 'pageerror', so a data-less CI homepage that\n // renders empty-but-valid won't false-fail -- only a real client crash does.\n for (const { path, name } of smokePages) {\n currentRoute = name;\n await page.goto(path);\n // Let hydration + first effects run so a TDZ/ReferenceError surfaces.\n await page.waitForTimeout(2000);\n }\n\n const byImpact = {};\n for (const v of violations) {\n byImpact[v.impact] = (byImpact[v.impact] ?? 0) + 1;\n }\n if (OUTPUT) {\n await mkdir(dirname(OUTPUT), { recursive: true });\n await writeFile(\n OUTPUT,\n JSON.stringify({ totalViolations: violations.length, byImpact, violations }, null, 2),\n );\n }\n expect(violations).toEqual([]);\n});\n`;\n}\n\nexport async function a11yAudit(ctx: AuditContext): Promise<AuditResult> {\n const spawn = ctx.spawn ?? defaultSpawn;\n const site = ctx.site;\n const label = siteLabel(site);\n\n // specDir lives INSIDE site.path (not /tmp) so the spec's\n // `import AxeBuilder from \"@axe-core/playwright\"` resolves via Node's\n // walk-up — the site's node_modules is the nearest one. A spec written\n // to /tmp ENOENTs at module resolution before any test runs. Caltex\n // 2026-05-28 (0.10.6 dogfood), third layer of the same class as the\n // webServer.cwd bug.\n const specDir = await mkdtemp(join(site.path, \".reddoor-a11y-spec-\"));\n // Everything past mkdtemp is wrapped so the transient specDir is removed on\n // EVERY catchable exit — success, skip-return, or any throw (a failed\n // writeFile/findFreePort used to orphan it). A timeout-SIGKILL of the parent\n // can't be caught here; `.reddoor-a11y-spec-*/` is fleet-gitignored as the\n // backstop for that. (2026-06-10 MEDIUM-D; recurred from 06-05 M3.)\n try {\n const specPath = join(specDir, \"a11y.spec.ts\");\n await writeFile(specPath, buildSpec(), \"utf-8\");\n\n const port = await findFreePort();\n const configPath = join(specDir, \"playwright.config.ts\");\n await writeFile(configPath, buildPlaywrightConfig(port, site.path), \"utf-8\");\n\n const resultsPath = join(site.path, RESULTS_REL);\n // Clear stale artifacts so a failed spawn never reports old data.\n await rm(join(site.path, \".reddoor-a11y\"), { recursive: true, force: true });\n\n let raw;\n try {\n raw = await spawn(\n \"npx\",\n [\"--yes\", \"playwright\", \"test\", `--config=${configPath}`, \"--reporter=line\", specPath],\n {\n cwd: site.path,\n env: { ...process.env, REDDOOR_A11Y_OUTPUT: resultsPath },\n // playwright on a cold tree downloads Chrome, boots the site's dev\n // server, and runs axe over every configured route. The shared 30 s\n // default in runAudits is fine for deps/lint/security but starves\n // playwright (mirrors the lighthouse fix shipped earlier).\n timeoutMs: 5 * 60_000,\n },\n );\n } catch (err) {\n const e = err as NodeJS.ErrnoException;\n if (e.code === \"ENOENT\" || /ENOENT/.test(String(err))) {\n return {\n audit: \"a11y\",\n site: label,\n status: \"skip\",\n summary: \"npx/playwright not available\",\n };\n }\n throw err;\n }\n\n const artifact = await readJsonMaybe<NormalizedA11y>(resultsPath);\n\n if (!artifact) {\n return {\n audit: \"a11y\",\n site: label,\n status: \"fail\",\n summary: `a11y: no results written (exit ${raw.code})${\n raw.stderr ? ` — ${raw.stderr.slice(0, 200)}` : \"\"\n }`,\n };\n }\n\n const hasSerious =\n (artifact.byImpact.serious ?? 0) > 0 || (artifact.byImpact.critical ?? 0) > 0;\n const hasAny = artifact.totalViolations > 0;\n\n const status: AuditResult[\"status\"] = hasSerious ? \"fail\" : hasAny ? \"warn\" : \"pass\";\n const summary =\n status === \"pass\"\n ? `a11y: 0 violations across ${a11yRoutes.length} routes (+${smokeRoutes.length} hydration smoke)`\n : `a11y: ${artifact.totalViolations} violations`;\n\n return {\n audit: \"a11y\",\n site: label,\n status,\n summary,\n details: artifact,\n };\n } finally {\n await rm(specDir, { recursive: true, force: true });\n }\n}\n","import { promises as dnsPromises } from \"node:dns\";\nimport tls from \"node:tls\";\nimport type { AuditResult } from \"../types.js\";\nimport type { AuditContext } from \"./util/inject.js\";\nimport { siteLabel } from \"../util/site.js\";\n\n/** Result of probing a site's domain: did DNS resolve, and how many days until the TLS cert\n * expires (null when unresolved or no usable cert). The auto-tick verdict is recomputed from\n * the persisted fields — this is just the raw signal. */\nexport type DomainCheck = {\n resolved: boolean;\n certDaysRemaining: number | null;\n};\n\n/** Injected IO so the check is unit-testable without real DNS/TLS. `lookup` throws when the host\n * doesn't resolve; `certValidTo` returns the cert's notAfter date, or null when there's none. */\nexport type DomainDeps = {\n lookup: (host: string) => Promise<void>;\n certValidTo: (host: string) => Promise<Date | null>;\n now: Date;\n};\n\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/**\n * Probe one URL's domain: resolve DNS, then read the TLS certificate expiry. PURE given its deps.\n * Any failure degrades safely — an unresolvable host or a missing/unreadable cert yields\n * `certDaysRemaining: null` (which the auto-tick rule treats as \"not provable\", never a pass).\n */\nexport async function checkDomain(url: string, deps: DomainDeps): Promise<DomainCheck> {\n let host: string;\n try {\n host = new URL(url).hostname;\n } catch {\n return { resolved: false, certDaysRemaining: null };\n }\n try {\n await deps.lookup(host);\n } catch {\n return { resolved: false, certDaysRemaining: null };\n }\n let validTo: Date | null;\n try {\n validTo = await deps.certValidTo(host);\n } catch {\n validTo = null;\n }\n if (!validTo || Number.isNaN(validTo.getTime()))\n return { resolved: true, certDaysRemaining: null };\n return {\n resolved: true,\n certDaysRemaining: Math.floor((validTo.getTime() - deps.now.getTime()) / MS_PER_DAY),\n };\n}\n\n/** Real DNS + TLS. `certValidTo` opens a TLS socket (SNI = host), reads the peer cert's\n * `valid_to`, and closes immediately — no HTTP request. */\nexport function defaultDomainDeps(now: Date): DomainDeps {\n return {\n lookup: async (host) => {\n await dnsPromises.lookup(host);\n },\n certValidTo: (host) =>\n new Promise<Date | null>((resolvePromise) => {\n // `rejectUnauthorized: true` is what makes this check HONEST and the green box truthful:\n // the secureConnect callback only fires on a trusted, host-matching (SNI), unexpired\n // chain — an expired / self-signed / untrusted-CA / SAN-mismatched cert fires \"error\"\n // → null → fail. The whole \"valid cert\" claim depends on this. NEVER set it false.\n // `socket.authorized` is asserted belt-and-suspenders before trusting `valid_to`.\n const socket = tls.connect(\n { host, port: 443, servername: host, timeout: 10_000, rejectUnauthorized: true },\n () => {\n const cert = socket.authorized ? socket.getPeerCertificate() : null;\n socket.end();\n const validTo = cert && cert.valid_to ? new Date(cert.valid_to) : null;\n resolvePromise(validTo);\n },\n );\n socket.on(\"error\", () => resolvePromise(null));\n socket.on(\"timeout\", () => {\n socket.destroy();\n resolvePromise(null);\n });\n }),\n now,\n };\n}\n\n/**\n * Audit a site's domain (DNS + TLS) against its deployed URL. Checkout-free — needs only\n * `site.deployedUrl`. Skips a site with no deployed URL. Writes `certDaysRemaining` + a checked-at\n * timestamp via the Airtable layer; the auto-tick rule decides pass/fail from those.\n */\nexport async function domainAudit(ctx: AuditContext): Promise<AuditResult> {\n const { site } = ctx;\n const label = siteLabel(site);\n if (!site.deployedUrl) {\n return { audit: \"domain\", site: label, status: \"skip\", summary: \"no deployed URL\" };\n }\n const now = ctx.now ?? new Date();\n const deps = ctx.domainDeps ?? defaultDomainDeps(now);\n const check = await checkDomain(site.deployedUrl, deps);\n const checkedAt = now.toISOString();\n const status: AuditResult[\"status\"] =\n check.resolved && check.certDaysRemaining !== null && check.certDaysRemaining > 14\n ? \"pass\"\n : \"warn\";\n const summary = !check.resolved\n ? \"did not resolve\"\n : check.certDaysRemaining === null\n ? \"resolved, no usable TLS cert\"\n : `resolved, cert ${check.certDaysRemaining}d remaining`;\n return {\n audit: \"domain\",\n site: label,\n status,\n summary,\n details: { resolved: check.resolved, certDaysRemaining: check.certDaysRemaining, checkedAt },\n };\n}\n","/**\n * Route discovery for the deployed-URL browser audit. Pulls the site's sitemap, then samples a\n * REPRESENTATIVE set of paths — bucketed by path family (first segment) and sampled round-robin —\n * so every page *type* is covered, including the dynamic CMS-generated templates (Prismic\n * `[uid]`/`[slug]` detail pages: blog posts, projects, portfolio items) where broken\n * images / overflowing galleries / dead links hide. Taking the first N sitemap entries would skew\n * to top-level static pages and miss them. Pure functions + one fetch-injected entry point.\n */\n\nexport type DiscoverDeps = {\n /** Fetch a URL, returning its text body, or null on any non-2xx / network error. */\n fetchText: (url: string) => Promise<string | null>;\n};\n\nexport type DiscoveredRoutes = {\n /** Absolute URLs (rebuilt on the deployed origin), always including the homepage. */\n routes: string[];\n source: \"sitemap\" | \"homepage-links\" | \"root-only\";\n /** Path-family → count, for the evidence note (\"/, /work ×4, /blog ×4\"). */\n familyCounts: Record<string, number>;\n};\n\nconst DEFAULT_CAP = 15;\n\n/** Extract `<loc>` URLs from sitemap XML. Tolerant of namespaces/whitespace; ignores junk. */\nexport function parseSitemapUrls(xml: string): string[] {\n const out: string[] = [];\n const re = /<loc>\\s*([^<\\s]+)\\s*<\\/loc>/gi;\n let m: RegExpExecArray | null;\n while ((m = re.exec(xml)) !== null) {\n const url = m[1];\n if (url) out.push(url.trim());\n }\n return out;\n}\n\n/** Same-origin `<a href>` paths from an HTML body (homepage fallback). Returns root-relative\n * pathnames; off-origin / anchor / mailto / tel links are dropped. */\nexport function parseHtmlLinks(html: string, baseUrl: string): string[] {\n const out = new Set<string>();\n const re = /<a\\b[^>]*\\bhref\\s*=\\s*[\"']([^\"']+)[\"']/gi;\n let m: RegExpExecArray | null;\n while ((m = re.exec(html)) !== null) {\n const href = m[1];\n if (!href || href.startsWith(\"#\") || /^(mailto:|tel:|javascript:)/i.test(href)) continue;\n try {\n const u = new URL(href, baseUrl);\n if (u.origin !== new URL(baseUrl).origin) continue;\n out.add(u.pathname);\n } catch {\n /* skip unparseable */\n }\n }\n return [...out];\n}\n\n/** First non-empty path segment (\"/\" → \"\", \"/work/x\" → \"work\"). The \"family\" we bucket by. */\nfunction family(pathname: string): string {\n return pathname.split(\"/\").filter(Boolean)[0] ?? \"\";\n}\n\n/**\n * Sample a representative set of PATHNAMES from a list of URLs/paths. Always includes \"/\". Buckets\n * the rest by path family and takes them round-robin (one per family per pass), so every family\n * is represented before any family gets a second page — guaranteeing CMS templates aren't skipped.\n * Caps the total. PURE.\n */\nexport function sampleRoutePaths(urlsOrPaths: string[], cap: number = DEFAULT_CAP): string[] {\n const seen = new Set<string>([\"/\"]);\n const buckets = new Map<string, string[]>();\n for (const raw of urlsOrPaths) {\n let pathname: string;\n try {\n pathname = raw.startsWith(\"/\")\n ? new URL(raw, \"https://x.invalid\").pathname\n : new URL(raw).pathname;\n } catch {\n continue;\n }\n if (pathname === \"/\") continue; // root already guaranteed\n if (seen.has(pathname)) continue;\n seen.add(pathname);\n const fam = family(pathname);\n const arr = buckets.get(fam) ?? [];\n arr.push(pathname);\n buckets.set(fam, arr);\n }\n const result = [\"/\"];\n const families = [...buckets.values()];\n let guard = 0;\n while (result.length < cap && families.some((f) => f.length > 0) && guard++ < 10_000) {\n for (const fam of families) {\n if (result.length >= cap) break;\n const next = fam.shift();\n if (next) result.push(next);\n }\n }\n return result;\n}\n\n/** Per-family counts of a sampled path list, for the evidence note. */\nexport function familyCountsOf(paths: string[]): Record<string, number> {\n const counts: Record<string, number> = {};\n for (const p of paths) {\n const key = p === \"/\" ? \"/\" : `/${family(p)}`;\n counts[key] = (counts[key] ?? 0) + 1;\n }\n return counts;\n}\n\n/**\n * Discover a representative route set for `deployedUrl`. Tries `<origin>/sitemap.xml` first\n * (family-sampled); falls back to the homepage's same-origin links; worst case the homepage only.\n * Returns absolute URLs rebuilt on the deployed origin.\n */\nexport async function discoverRoutes(\n deployedUrl: string,\n deps: DiscoverDeps,\n cap: number = DEFAULT_CAP,\n): Promise<DiscoveredRoutes> {\n const origin = new URL(deployedUrl).origin;\n const abs = (paths: string[]) => paths.map((p) => new URL(p, origin).href);\n\n const sitemapXml = await deps.fetchText(new URL(\"/sitemap.xml\", origin).href);\n if (sitemapXml) {\n const urls = parseSitemapUrls(sitemapXml);\n if (urls.length > 0) {\n const paths = sampleRoutePaths(urls, cap);\n return { routes: abs(paths), source: \"sitemap\", familyCounts: familyCountsOf(paths) };\n }\n }\n\n const homeHtml = await deps.fetchText(origin);\n if (homeHtml) {\n const links = parseHtmlLinks(homeHtml, origin);\n if (links.length > 0) {\n const paths = sampleRoutePaths(links, cap);\n return { routes: abs(paths), source: \"homepage-links\", familyCounts: familyCountsOf(paths) };\n }\n }\n\n return { routes: [new URL(\"/\", origin).href], source: \"root-only\", familyCounts: { \"/\": 1 } };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { AuditContext } from \"./util/inject.js\";\nimport { siteLabel } from \"../util/site.js\";\nimport {\n discoverRoutes,\n familyCountsOf,\n type DiscoverDeps,\n type DiscoveredRoutes,\n} from \"./route-discovery.js\";\n\n/** One route probed across desktop engines + mobile devices, plus the internal links found on it. */\nexport type RouteResult = {\n url: string;\n /** Per desktop engine (chromium/firefox/webkit): loaded with no JS error + a visible main landmark. */\n desktop: Array<{ engine: string; ok: boolean }>;\n /** Per mobile device: loaded with no JS error and no horizontal overflow. */\n mobile: Array<{ device: string; ok: boolean }>;\n /** Same-origin links discovered on the page (absolute URLs), for the Links check. */\n links: string[];\n};\n\nexport type LinkResult = { url: string; status: number | null };\n\n/** Injected browser IO. The real impl drives Playwright; tests pass a fake. */\nexport type BrowserRunner = {\n probe: (urls: string[]) => Promise<RouteResult[]>;\n checkLinks: (urls: string[]) => Promise<LinkResult[]>;\n close?: () => Promise<void>;\n};\n\nexport type BrowserSummary = {\n desktopOk: boolean;\n mobileOk: boolean;\n linksOk: boolean;\n brokenLinks: number;\n routesChecked: number;\n note: string;\n};\n\n/** A link status counts as broken when it didn't return a 2xx/3xx (null = unreachable). */\nfunction isBroken(status: number | null): boolean {\n return status === null || status >= 400;\n}\n\n/**\n * Reduce raw per-route observations to the three checklist verdicts. PURE.\n * - desktopOk: EVERY route loaded cleanly in EVERY desktop engine.\n * - mobileOk: EVERY route loaded cleanly (no overflow / error) on EVERY mobile device.\n * - linksOk: NO internal link is broken.\n * Empty observations (a probe that produced nothing) → not ok (fail-safe: prove, don't assume).\n */\nexport function summarizeBrowser(\n routes: RouteResult[],\n links: LinkResult[],\n familyCounts: Record<string, number>,\n): BrowserSummary {\n const desktopChecks = routes.flatMap((r) => r.desktop);\n const mobileChecks = routes.flatMap((r) => r.mobile);\n // Per-route completeness: EVERY route must carry observations AND every observation must be ok.\n // (Guarding only the flattened array would excuse a route that produced no observations at all —\n // a false green.) A route with an empty desktop[]/mobile[] (never rendered) → not ok.\n const desktopOk =\n routes.length > 0 && routes.every((r) => r.desktop.length > 0 && r.desktop.every((d) => d.ok));\n const mobileOk =\n routes.length > 0 && routes.every((r) => r.mobile.length > 0 && r.mobile.every((m) => m.ok));\n const brokenLinks = links.filter((l) => isBroken(l.status)).length;\n // linksOk requires links to have ACTUALLY been checked — zero links checked is \"not proven\",\n // never a pass (e.g. a JS-rendered page whose hrefs weren't in the DOM, or a chromium evaluate\n // that threw → []). Otherwise the box would assert \"all links resolve\" having checked nothing.\n const linksOk = links.length > 0 && brokenLinks === 0;\n\n const engines = [...new Set(desktopChecks.map((d) => d.engine))];\n const devices = [...new Set(mobileChecks.map((m) => m.device))];\n const families = Object.entries(familyCounts)\n .map(([f, n]) => (f === \"/\" ? \"/\" : `${f} ×${n}`))\n .join(\", \");\n const note =\n `${routes.length} routes (${families}); ` +\n `desktop ${engines.join(\"/\") || \"—\"}; mobile ${devices.join(\"/\") || \"—\"}; ` +\n `${links.length} links, ${brokenLinks} broken`;\n\n return { desktopOk, mobileOk, linksOk, brokenLinks, routesChecked: routes.length, note };\n}\n\n/**\n * Deployed-URL browser audit: discovers a representative route set (incl. CMS templates), probes\n * each across desktop engines + mobile devices, and checks internal links — all against the LIVE\n * url (checkout-free). Skips a site with no deployed URL. Produces ONE AuditResult whose details\n * carry the three verdicts (Crossbrowser / Mobile / Links); the Airtable layer fans them out and\n * the auto-tick rule gates each on freshness.\n */\nexport async function browserAudit(ctx: AuditContext): Promise<AuditResult> {\n const { site } = ctx;\n const label = siteLabel(site);\n if (!site.deployedUrl) {\n return { audit: \"browser\", site: label, status: \"skip\", summary: \"no deployed URL\" };\n }\n const now = ctx.now ?? new Date();\n const discoverDeps = ctx.discoverDeps ?? defaultDiscoverDeps();\n const runner = ctx.browserRunner ?? (await defaultBrowserRunner());\n try {\n const discovered: DiscoveredRoutes = await discoverRoutes(site.deployedUrl, discoverDeps);\n const routeResults = await runner.probe(discovered.routes);\n const internalLinks = [...new Set(routeResults.flatMap((r) => r.links))];\n const linkResults = await runner.checkLinks(internalLinks);\n const summary = summarizeBrowser(\n routeResults,\n linkResults,\n discovered.familyCounts ?? familyCountsOf(discovered.routes),\n );\n const status: AuditResult[\"status\"] =\n summary.desktopOk && summary.mobileOk && summary.linksOk ? \"pass\" : \"warn\";\n return {\n audit: \"browser\",\n site: label,\n status,\n summary: summary.note,\n details: { ...summary, checkedAt: now.toISOString() },\n };\n } finally {\n await runner.close?.();\n }\n}\n\n/** Bound the plain fetch()es (route-discovery GET + link HEAD/GET) so a host that hangs\n * WITHOUT erroring can't stall the sequential fleet audit indefinitely. Playwright's\n * page.goto already has PAGE_TIMEOUT_MS; these fetches had no ceiling. On abort the existing\n * catch path treats it exactly like a network error (→ null), so a slow host degrades, never throws. */\nconst FETCH_TIMEOUT_MS = 10_000;\n\n/** Real route-discovery fetch: GET text, null on any non-2xx / error / timeout. */\nexport function defaultDiscoverDeps(): DiscoverDeps {\n return {\n fetchText: async (url) => {\n try {\n const res = await fetch(url, {\n redirect: \"follow\",\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n });\n if (!res.ok) return null;\n return await res.text();\n } catch {\n return null;\n }\n },\n };\n}\n\nconst DESKTOP_VIEWPORT = { width: 1366, height: 900 };\nconst PAGE_TIMEOUT_MS = 30_000;\n\n/**\n * Real Playwright runner. Launches chromium/firefox/webkit (desktop) + mobile-emulated chromium &\n * webkit, probes each route, and HEAD/GET-checks internal links. Every failure degrades to a\n * `false`/`null` (never throws past the audit), so a flaky run yields a non-pass (→ the box stays\n * manual) rather than a false green. Imported lazily so unit tests (which inject a fake runner)\n * never load Playwright.\n */\nexport async function defaultBrowserRunner(): Promise<BrowserRunner> {\n const { chromium, firefox, webkit, devices } = await import(\"@playwright/test\");\n const desktopEngines = [\n { engine: \"chromium\", type: chromium },\n { engine: \"firefox\", type: firefox },\n { engine: \"webkit\", type: webkit },\n ];\n const mobileTargets = [\n { device: \"Pixel 7\", descriptor: devices[\"Pixel 7\"] },\n { device: \"iPhone 14\", descriptor: devices[\"iPhone 14\"] },\n ];\n\n return {\n async probe(urls) {\n const results: RouteResult[] = [];\n const browsers = await Promise.all(desktopEngines.map((e) => e.type.launch()));\n const mobileBrowsers = await Promise.all(mobileTargets.map(() => chromium.launch()));\n try {\n for (const url of urls) {\n const desktop: RouteResult[\"desktop\"] = [];\n const linkSet = new Set<string>();\n for (let i = 0; i < desktopEngines.length; i++) {\n const engine = desktopEngines[i]!.engine;\n const browser = browsers[i]!;\n const ctx = await browser.newContext({ viewport: DESKTOP_VIEWPORT });\n const page = await ctx.newPage();\n const errors: string[] = [];\n page.on(\"pageerror\", (e) => errors.push(String(e)));\n let ok = false;\n try {\n const resp = await page.goto(url, {\n waitUntil: \"domcontentloaded\",\n timeout: PAGE_TIMEOUT_MS,\n });\n const hasMain = await page\n .locator(\"main, [role=main]\")\n .first()\n .isVisible()\n .catch(() => false);\n ok = !!resp && resp.ok() && errors.length === 0 && hasMain;\n if (engine === \"chromium\") {\n // Link discovery runs on chromium only (cost). Links coverage therefore depends on\n // chromium loading this route + the evaluate succeeding; if it yields nothing, the\n // Links verdict is \"not proven\" (links.length===0 ⇒ linksOk false), never a pass.\n // String-form evaluate so the browser-context code isn't type-checked against the\n // Node lib (no DOM globals in this project's tsconfig). Returns absolute hrefs.\n const hrefs = (await page\n .evaluate(\"Array.from(document.querySelectorAll('a[href]')).map((a) => a.href)\")\n .catch(() => [])) as string[];\n const origin = new URL(url).origin;\n for (const h of hrefs) {\n try {\n if (new URL(h).origin === origin) linkSet.add(new URL(h).href);\n } catch {\n /* skip */\n }\n }\n }\n } catch {\n ok = false;\n } finally {\n await ctx.close().catch(() => {});\n }\n desktop.push({ engine, ok });\n }\n\n const mobile: RouteResult[\"mobile\"] = [];\n for (let i = 0; i < mobileTargets.length; i++) {\n const { device, descriptor } = mobileTargets[i]!;\n const browser = mobileBrowsers[i]!;\n const ctx = await browser.newContext({ ...descriptor });\n const page = await ctx.newPage();\n const errors: string[] = [];\n page.on(\"pageerror\", (e) => errors.push(String(e)));\n let ok = false;\n try {\n const resp = await page.goto(url, {\n waitUntil: \"domcontentloaded\",\n timeout: PAGE_TIMEOUT_MS,\n });\n const overflow = (await page\n .evaluate(\"document.documentElement.scrollWidth > window.innerWidth + 2\")\n .catch(() => true)) as boolean;\n ok = !!resp && resp.ok() && errors.length === 0 && !overflow;\n } catch {\n ok = false;\n } finally {\n await ctx.close().catch(() => {});\n }\n mobile.push({ device, ok });\n }\n\n results.push({ url, desktop, mobile, links: [...linkSet] });\n }\n } finally {\n await Promise.all([...browsers, ...mobileBrowsers].map((b) => b.close().catch(() => {})));\n }\n return results;\n },\n\n async checkLinks(urls) {\n const out: LinkResult[] = [];\n for (const url of urls) {\n let status: number | null;\n try {\n let res = await fetch(url, {\n method: \"HEAD\",\n redirect: \"follow\",\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n });\n // Some servers reject HEAD (405/501) — retry GET before declaring it broken.\n if (res.status === 405 || res.status === 501) {\n res = await fetch(url, {\n method: \"GET\",\n redirect: \"follow\",\n signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),\n });\n }\n status = res.status;\n } catch {\n status = null;\n }\n out.push({ url, status });\n }\n return out;\n },\n };\n}\n","import type { AuditResult } from \"../types.js\";\nimport type { AuditContext } from \"./util/inject.js\";\nimport { siteLabel } from \"../util/site.js\";\n\n/** Result of probing a site's latest PRODUCTION Netlify deploy. Every field is\n * nullable: a network/parse failure (or a site with no deploys yet) degrades to\n * all-nulls rather than throwing, so the audit always returns a verdict.\n * - `state` is Netlify's raw deploy state (`ready`/`error`/`building`/…), lower-cased,\n * or null when it couldn't be read.\n * - `deployedAt` is the deploy's publish/create time (ISO), or null.\n * - `logUrl` links to the deploy (its live URL or the Netlify admin/build-log page).\n * - `errorMessage` is Netlify's `error_message` for a failed build, or null. */\nexport type NetlifyDeployCheck = {\n state: string | null;\n deployedAt: string | null;\n logUrl: string | null;\n errorMessage: string | null;\n};\n\n/** Injected IO so the check is unit-testable without hitting the Netlify API.\n * `fetchLatestProductionDeploy` returns the parsed first (latest) production\n * deploy object, or null when the site has none / the call failed. `now` is the\n * clock for the checked-at stamp. */\nexport type NetlifyDeployDeps = {\n fetchLatestProductionDeploy: (siteId: string) => Promise<NetlifyDeployCheck | null>;\n now: Date;\n};\n\n/** The Netlify deploy states we treat as a successful production build. */\nconst READY_STATES: ReadonlySet<string> = new Set([\"ready\"]);\n/** States that mean the build actually failed — the \"needs attention\" case. */\nconst FAILED_STATES: ReadonlySet<string> = new Set([\"error\", \"failed\", \"rejected\"]);\n/** States that mean a build is still in flight — neutral, not yet a verdict. */\nconst IN_PROGRESS_STATES: ReadonlySet<string> = new Set([\n \"building\",\n \"enqueued\",\n \"new\",\n \"processing\",\n \"uploading\",\n \"preparing\",\n]);\n\nexport function isReadyState(state: string | null): boolean {\n return state !== null && READY_STATES.has(state);\n}\nexport function isFailedState(state: string | null): boolean {\n return state !== null && FAILED_STATES.has(state);\n}\nexport function isInProgressState(state: string | null): boolean {\n return state !== null && IN_PROGRESS_STATES.has(state);\n}\n\n/**\n * Probe one Netlify site's latest production deploy. PURE given its deps — the\n * real network/parse work lives in `defaultNetlifyDeployDeps`. A null return from\n * the fetch (no deploys, or a failed/unparseable call) degrades to an all-null\n * check, never a throw.\n */\nexport async function checkNetlifyDeploy(\n siteId: string,\n deps: NetlifyDeployDeps,\n): Promise<NetlifyDeployCheck> {\n const fetched = await deps.fetchLatestProductionDeploy(siteId).catch(() => null);\n if (!fetched) {\n return { state: null, deployedAt: null, logUrl: null, errorMessage: null };\n }\n return fetched;\n}\n\n/** Coerce an untrusted value to a trimmed non-empty string, or null. */\nfunction str(raw: unknown): string | null {\n if (typeof raw !== \"string\") return null;\n const trimmed = raw.trim();\n return trimmed.length > 0 ? trimmed : null;\n}\n\n/**\n * Real Netlify API deps. Fetches the latest production deploy for `siteId` and\n * maps the raw JSON into a `NetlifyDeployCheck`. Any failure — network error,\n * non-2xx, empty list, malformed JSON — resolves to null (the check degrades to\n * all-nulls), NEVER throws: a fleet sweep must not red an unrelated site because\n * one site's Netlify call hiccuped.\n *\n * `fetchImpl` is injected (defaults to the global `fetch`) only so the default\n * deps themselves stay testable; production callers pass nothing.\n */\nexport function defaultNetlifyDeployDeps(\n token: string,\n now: Date,\n fetchImpl: typeof fetch = fetch,\n): NetlifyDeployDeps {\n return {\n now,\n fetchLatestProductionDeploy: async (siteId) => {\n // `production=true` + `per_page=1` asks Netlify for ONLY the newest\n // production deploy, so we read `[0]` without paging. siteId is the\n // Airtable-supplied site identity; encode it so it can't break the path.\n const url =\n `https://api.netlify.com/api/v1/sites/${encodeURIComponent(siteId)}/deploys` +\n `?per_page=1&production=true`;\n let res: Response;\n try {\n res = await fetchImpl(url, {\n headers: { Authorization: `Bearer ${token}` },\n });\n } catch {\n return null;\n }\n if (!res.ok) return null;\n let body: unknown;\n try {\n body = await res.json();\n } catch {\n return null;\n }\n if (!Array.isArray(body) || body.length === 0) return null;\n const d = body[0] as Record<string, unknown>;\n const state = str(d[\"state\"]);\n // Prefer the publish time; fall back to create time for an in-progress deploy.\n const deployedAt = str(d[\"published_at\"]) ?? str(d[\"created_at\"]);\n // Prefer the live deploy URL, then the Netlify admin page, then a raw log link.\n const logUrl = str(d[\"deploy_ssl_url\"]) ?? str(d[\"admin_url\"]) ?? str(d[\"deploy_url\"]);\n const errorMessage = str(d[\"error_message\"]);\n return {\n state: state ? state.toLowerCase() : null,\n deployedAt,\n logUrl,\n errorMessage,\n };\n },\n };\n}\n\n/**\n * Audit a site's latest PRODUCTION Netlify deploy health. Checkout-free — needs\n * only the site's Netlify id. Degrades gracefully fleet-wide:\n * - no `site.netlifyId` → skip (\"no netlify id\"); the site simply isn't on Netlify\n * (or hasn't been wired up) so there's nothing to check.\n * - no `NETLIFY_PAT` in the env → skip (\"no NETLIFY_PAT\"); the whole audit is\n * unconfigured, which must not red every site.\n *\n * Status mapping: `ready` → pass; a failed build (`error`/`failed`/`rejected`) →\n * fail (the \"needs attention\" status — it lands the site in the cockpit's attention\n * tier, mirroring a sub-floor Lighthouse score); an in-flight build or any\n * unknown/unreadable state → warn (neutral, no verdict yet). `details` carries\n * `{ state, deployedAt, logUrl, checkedAt }` for the Airtable writer + dashboard.\n */\nexport async function netlifyDeployAudit(ctx: AuditContext): Promise<AuditResult> {\n const { site } = ctx;\n const label = siteLabel(site);\n if (!site.netlifyId) {\n return { audit: \"netlify-deploy\", site: label, status: \"skip\", summary: \"no netlify id\" };\n }\n const token = process.env.NETLIFY_PAT;\n if (!token) {\n return { audit: \"netlify-deploy\", site: label, status: \"skip\", summary: \"no NETLIFY_PAT\" };\n }\n\n const now = ctx.now ?? new Date();\n const deps = ctx.netlifyDeployDeps ?? defaultNetlifyDeployDeps(token, now);\n const check = await checkNetlifyDeploy(site.netlifyId, deps);\n const checkedAt = now.toISOString();\n\n const status: AuditResult[\"status\"] = isReadyState(check.state)\n ? \"pass\"\n : isFailedState(check.state)\n ? \"fail\"\n : \"warn\";\n\n const summary =\n check.state === null\n ? \"no deploy found\"\n : isFailedState(check.state)\n ? `deploy ${check.state}${check.errorMessage ? ` — ${check.errorMessage}` : \"\"}`\n : `deploy ${check.state}`;\n\n return {\n audit: \"netlify-deploy\",\n site: label,\n status,\n summary,\n details: {\n state: check.state,\n deployedAt: check.deployedAt,\n logUrl: check.logUrl,\n checkedAt,\n },\n };\n}\n","import type { AuditName, AuditResult, Site } from \"../types.js\";\nimport type { AuditContext } from \"./util/inject.js\";\nimport { defaultSpawn } from \"./util/spawn.js\";\nimport type { SpawnFn } from \"./util/spawn.js\";\nimport { depsAudit } from \"./deps.js\";\nimport { lintAudit } from \"./lint.js\";\nimport { securityAudit } from \"./security.js\";\nimport { lighthouseAudit } from \"./lighthouse.js\";\nimport { a11yAudit } from \"./a11y.js\";\nimport { domainAudit } from \"./domain.js\";\nimport { browserAudit } from \"./browser.js\";\nimport { netlifyDeployAudit } from \"./netlify-deploy.js\";\n\nconst REGISTRY: Record<AuditName, (ctx: AuditContext) => Promise<AuditResult>> = {\n deps: depsAudit,\n lint: lintAudit,\n security: securityAudit,\n lighthouse: lighthouseAudit,\n a11y: a11yAudit,\n domain: domainAudit,\n browser: browserAudit,\n \"netlify-deploy\": netlifyDeployAudit,\n};\n\nexport const ALL_AUDIT_NAMES = Object.keys(REGISTRY) as AuditName[];\n\n/** Default per-audit spawn timeout when running via runAudits (30 s). */\nconst DEFAULT_AUDIT_TIMEOUT_MS = 30_000;\n\nfunction timedSpawn(timeoutMs: number): SpawnFn {\n return (cmd, args, opts = {}) =>\n defaultSpawn(cmd, args, { ...opts, timeoutMs: opts.timeoutMs ?? timeoutMs });\n}\n\n/** Single-audit runner with the same error-to-result conversion that\n * `runAudits` applies. Exposed so the CLI can wrap each audit in its\n * own progress task (listr2) and surface per-audit completion timing,\n * while keeping audit implementations UI-free. */\nexport async function runOneAudit(site: Site, name: AuditName): Promise<AuditResult> {\n if (!(name in REGISTRY)) throw new Error(`unknown audit: ${name}`);\n const spawn = timedSpawn(DEFAULT_AUDIT_TIMEOUT_MS);\n // `||` not `??`: an empty-string slug (Airtable Name with no slug-able chars)\n // must fall back to the path, not render a blank `AuditResult.site` that would\n // then collapse fleet write-back grouping under the \"\" key.\n const label = site.name || site.path;\n try {\n return await REGISTRY[name]({ site, spawn });\n } catch (err) {\n return {\n audit: name,\n site: label,\n status: \"fail\",\n summary: `${name}: unexpected error — ${String(err)}`,\n };\n }\n}\n\nexport async function runAudits(site: Site, which?: AuditName[]): Promise<AuditResult[]> {\n const names = which ?? ALL_AUDIT_NAMES;\n for (const n of names) {\n if (!(n in REGISTRY)) throw new Error(`unknown audit: ${n}`);\n }\n return Promise.all(names.map((n) => runOneAudit(site, n)));\n}\n\nexport async function runAuditsAcross(sites: Site[], which?: AuditName[]): Promise<AuditResult[]> {\n const all = await Promise.all(sites.map((s) => runAudits(s, which)));\n return all.flat();\n}\n\nexport { depsAudit, lintAudit, securityAudit, lighthouseAudit, a11yAudit };\n"],"mappings":"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","names":["join","join","readFile","join","join","readFile","readFile","join","readFile","join","readFile","join","readFile","writeFile","mkdtemp","rm","join","readJsonMaybe","readFile","mkdtemp","join","writeFile","rm"]}
|
|
@@ -42,7 +42,8 @@ var DEFAULT_COPY = {
|
|
|
42
42
|
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:",
|
|
43
43
|
announceImprovementResend: "Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.",
|
|
44
44
|
announceImprovementSvelte5: "We've modernized your site to the latest framework \u2014 it's faster, more secure, and built to last.",
|
|
45
|
-
announceCadence: "After each one we'll send you a short report like this \u2014
|
|
45
|
+
announceCadence: "After each one we'll send you a short report like this \u2014",
|
|
46
|
+
announceCadenceNote: "there's nothing you need to do.",
|
|
46
47
|
announceOpenDoor: "And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know."
|
|
47
48
|
};
|
|
48
49
|
function override(v) {
|
|
@@ -171,11 +172,12 @@ function lighthouseScoresSection(lighthouse, opts = {}) {
|
|
|
171
172
|
<mj-section background-color="${background}"${sectionPad}>
|
|
172
173
|
<mj-column>
|
|
173
174
|
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">LIGHTHOUSE SCORES*</mj-text>${rows}
|
|
174
|
-
<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
|
|
175
|
+
<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 <a href="https://developer.chrome.com/docs/lighthouse/overview" style="color:${RED}; text-decoration:underline;">Google's Lighthouse tool</a>, which evaluates various aspects of a web page's quality.</mj-text>
|
|
175
176
|
</mj-column>
|
|
176
177
|
</mj-section>`;
|
|
177
178
|
}
|
|
178
|
-
function analyticsTrendLine(cur, prev) {
|
|
179
|
+
function analyticsTrendLine(cur, prev, periodDays) {
|
|
180
|
+
const priorLabel = periodDays && periodDays > 0 ? `the previous ${periodDays} days` : "last period";
|
|
179
181
|
if (cur === void 0 || prev === void 0) {
|
|
180
182
|
return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== void 0 ? fmtUsers(prev) : "\u2014"}`);
|
|
181
183
|
}
|
|
@@ -184,9 +186,9 @@ function analyticsTrendLine(cur, prev) {
|
|
|
184
186
|
}
|
|
185
187
|
const pct = Math.round((cur - prev) / prev * 100);
|
|
186
188
|
const range = `(${fmtUsers(prev)} \u2192 ${fmtUsers(cur)})`;
|
|
187
|
-
if (pct > 0) return trendLine(TREND_UP, `\u25B2 ${pct}% vs
|
|
188
|
-
if (pct < 0) return trendLine(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs
|
|
189
|
-
return trendLine(TREND_NEUTRAL, `No change vs
|
|
189
|
+
if (pct > 0) return trendLine(TREND_UP, `\u25B2 ${pct}% vs ${priorLabel} ${range}`);
|
|
190
|
+
if (pct < 0) return trendLine(TREND_NEUTRAL, `\u25BC ${Math.abs(pct)}% vs ${priorLabel} ${range}`);
|
|
191
|
+
return trendLine(TREND_NEUTRAL, `No change vs ${priorLabel} (${fmtUsers(prev)})`);
|
|
190
192
|
}
|
|
191
193
|
function trendLine(color, text) {
|
|
192
194
|
return `<mj-text color="${color}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px">${text}</mj-text>`;
|
|
@@ -194,18 +196,22 @@ function trendLine(color, text) {
|
|
|
194
196
|
function footnoteLine(text) {
|
|
195
197
|
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>`;
|
|
196
198
|
}
|
|
199
|
+
function hasAnalyticsData(opts) {
|
|
200
|
+
return opts.current !== void 0 || (opts.bodyLines?.length ?? 0) > 0;
|
|
201
|
+
}
|
|
197
202
|
function analyticsSection(opts) {
|
|
198
|
-
|
|
203
|
+
if (!hasAnalyticsData(opts)) return "";
|
|
199
204
|
const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join("\n ");
|
|
200
205
|
const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join("\n ");
|
|
201
206
|
const sectionPad = opts.pad ? ` padding-top="${opts.pad}" padding-bottom="${opts.pad}"` : "";
|
|
202
207
|
const labelTop = opts.pad ?? "75px";
|
|
208
|
+
const usersBlock = opts.current !== void 0 ? `
|
|
209
|
+
<mj-text color="${RED}" font-size="44px" font-weight="400">${fmtUsers(opts.current)} Users</mj-text>
|
|
210
|
+
${analyticsTrendLine(opts.current, opts.previous, opts.periodDays)}` : "";
|
|
203
211
|
return `
|
|
204
212
|
<mj-section background-color="${opts.background}"${sectionPad}>
|
|
205
213
|
<mj-column>
|
|
206
|
-
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">ANALYTICS</mj-text
|
|
207
|
-
<mj-text color="${RED}" font-size="44px" font-weight="400">${users} Users</mj-text>
|
|
208
|
-
${analyticsTrendLine(opts.current, opts.previous)}
|
|
214
|
+
<mj-text color="${RED}" font-size="20px" font-weight="700" padding-top="${labelTop}">ANALYTICS</mj-text>${usersBlock}
|
|
209
215
|
${body}
|
|
210
216
|
${footnotes}
|
|
211
217
|
</mj-column>
|
|
@@ -313,6 +319,7 @@ function buildMjml(data) {
|
|
|
313
319
|
${analyticsSection({
|
|
314
320
|
current: data.gaUsersCurrent,
|
|
315
321
|
previous: data.gaUsersPrevious,
|
|
322
|
+
periodDays: data.gaPeriodDays,
|
|
316
323
|
background: "white",
|
|
317
324
|
footnoteLines: [escapeXml2(copy.seoCta)]
|
|
318
325
|
})}
|
|
@@ -348,8 +355,14 @@ var SECTION_PAD = "40px";
|
|
|
348
355
|
function sectionLabel(text) {
|
|
349
356
|
return `<mj-text color="${RED2}" font-size="20px" font-weight="700" padding-top="0px">${escapeXml2(text)}</mj-text>`;
|
|
350
357
|
}
|
|
358
|
+
function bodyLineHtml(html, paddingTop = "8px") {
|
|
359
|
+
return `<mj-text color="${GREY2}" font-family="helvetica, sans-serif" font-size="16px" font-weight="300" line-height="24px" padding-top="${paddingTop}">${html}</mj-text>`;
|
|
360
|
+
}
|
|
351
361
|
function bodyLine(text, paddingTop = "8px") {
|
|
352
|
-
return
|
|
362
|
+
return bodyLineHtml(escapeXml2(text), paddingTop);
|
|
363
|
+
}
|
|
364
|
+
function cadenceHtml(copy) {
|
|
365
|
+
return `${escapeXml2(copy.announceCadence)} <em>${escapeXml2(copy.announceCadenceNote)}</em>`;
|
|
353
366
|
}
|
|
354
367
|
function announcementSiteExtras(site) {
|
|
355
368
|
return {
|
|
@@ -367,6 +380,11 @@ function buildAnnouncementMjml(data) {
|
|
|
367
380
|
if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);
|
|
368
381
|
if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);
|
|
369
382
|
const hasImpr = improvementItems.length > 0;
|
|
383
|
+
const analyticsBodyLines = data.searchPosition !== void 0 ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`] : [];
|
|
384
|
+
const hasAnalytics = hasAnalyticsData({
|
|
385
|
+
current: data.gaUsersCurrent,
|
|
386
|
+
bodyLines: analyticsBodyLines
|
|
387
|
+
});
|
|
370
388
|
const BANDS = ["white", "#F4F4F4"];
|
|
371
389
|
let bandN = 0;
|
|
372
390
|
const nextBg = () => BANDS[bandN++ % 2];
|
|
@@ -374,14 +392,14 @@ function buildAnnouncementMjml(data) {
|
|
|
374
392
|
const maintBg = hasMaint ? nextBg() : "";
|
|
375
393
|
const testBg = hasTesting ? nextBg() : "";
|
|
376
394
|
const lighthouseBg = nextBg();
|
|
377
|
-
const analyticsBg = nextBg();
|
|
395
|
+
const analyticsBg = hasAnalytics ? nextBg() : "";
|
|
378
396
|
const improvementsBg = hasImpr ? nextBg() : "";
|
|
379
397
|
const contactBg = nextBg();
|
|
380
398
|
const maintenanceSection = cad && cad.maintenance !== "None" ? `
|
|
381
399
|
<mj-section background-color="${maintBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
|
|
382
400
|
<mj-column>
|
|
383
401
|
${sectionLabel("MAINTENANCE CHECKS")}
|
|
384
|
-
${
|
|
402
|
+
${bodyLineHtml(`${escapeXml2(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.`)}${cad.testing === "None" ? ` ${cadenceHtml(copy)}` : ""}`)}
|
|
385
403
|
</mj-column>
|
|
386
404
|
</mj-section>${checklistRowsSection(copy.maintenanceChecks, {
|
|
387
405
|
background: maintBg,
|
|
@@ -391,19 +409,20 @@ function buildAnnouncementMjml(data) {
|
|
|
391
409
|
<mj-section background-color="${testBg}" padding-top="${SECTION_PAD}" padding-bottom="0px">
|
|
392
410
|
<mj-column>
|
|
393
411
|
${sectionLabel("TESTING")}
|
|
394
|
-
${
|
|
412
|
+
${bodyLineHtml(`${escapeXml2(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}.`)} ${cadenceHtml(copy)}`)}
|
|
395
413
|
</mj-column>
|
|
396
414
|
</mj-section>${checklistRowsSection(copy.testingChecklist, {
|
|
397
415
|
background: testBg,
|
|
398
416
|
lastPaddingBottom: SECTION_PAD
|
|
399
417
|
})}` : "";
|
|
400
|
-
const analytics = analyticsSection({
|
|
418
|
+
const analytics = hasAnalytics ? analyticsSection({
|
|
401
419
|
current: data.gaUsersCurrent,
|
|
402
420
|
previous: data.gaUsersPrevious,
|
|
421
|
+
periodDays: data.gaPeriodDays,
|
|
403
422
|
background: analyticsBg,
|
|
404
423
|
pad: SECTION_PAD,
|
|
405
|
-
bodyLines:
|
|
406
|
-
});
|
|
424
|
+
bodyLines: analyticsBodyLines
|
|
425
|
+
}) : "";
|
|
407
426
|
const improvementsSection = hasImpr ? `
|
|
408
427
|
<mj-section background-color="${improvementsBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
|
|
409
428
|
<mj-column>
|
|
@@ -413,8 +432,8 @@ function buildAnnouncementMjml(data) {
|
|
|
413
432
|
</mj-column>
|
|
414
433
|
</mj-section>` : "";
|
|
415
434
|
const contactRows = copy.contact.map(
|
|
416
|
-
(line) => `
|
|
417
|
-
<mj-text font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
|
|
435
|
+
(line, i) => `
|
|
436
|
+
<mj-text ${i === 0 ? "" : `color="${GREY2}" `}font-family="helvetica, sans-serif" font-size="24px" font-weight="300" line-height="30px">${escapeXml2(line)}</mj-text>`
|
|
418
437
|
).join("");
|
|
419
438
|
const footerAddressRows = copy.footerAddress.map(
|
|
420
439
|
(line) => `
|
|
@@ -448,7 +467,7 @@ function buildAnnouncementMjml(data) {
|
|
|
448
467
|
${improvementsSection}
|
|
449
468
|
<mj-section background-color="${contactBg}" padding-top="${SECTION_PAD}" padding-bottom="${SECTION_PAD}">
|
|
450
469
|
<mj-column>
|
|
451
|
-
<mj-text color="${RED2}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="0px" line-height="36px">
|
|
470
|
+
<mj-text color="${RED2}" font-family="helvetica, sans-serif" font-size="24px" font-weight="700" padding-top="0px" line-height="36px">Questions, concerns or requests?</mj-text>
|
|
452
471
|
${contactRows}
|
|
453
472
|
<mj-divider border-width="1px" border-style="solid" border-color="#CCCCCC" padding="0" />
|
|
454
473
|
<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>
|
|
@@ -582,4 +601,4 @@ export {
|
|
|
582
601
|
fetchAttachmentBytes,
|
|
583
602
|
uploadAttachment
|
|
584
603
|
};
|
|
585
|
-
//# sourceMappingURL=chunk-
|
|
604
|
+
//# sourceMappingURL=chunk-U3MF6RCS.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/reports/copy.ts","../src/reports/maintenance-email/assets/index.ts","../src/reports/email-sections.ts","../src/reports/maintenance-email/template.ts","../src/reports/announcement-email/template.ts","../src/reports/render.ts","../src/reports/launch-email/template.ts","../src/reports/airtable/attachments.ts"],"sourcesContent":["import type { WebsiteRow } from \"./airtable/websites.js\";\n\nexport type ResolvedCopy = {\n maintenanceIntro: string;\n maintenanceChecks: string[]; // 6; index 3 is the Google row's no-position default\n testingIntro: string;\n testingChecklist: string[]; // 7\n notesHeader: string;\n seoCta: string;\n contact: string[]; // closing invitation lines\n footerOrg: string;\n footerAddress: string[];\n launchHeading: string;\n launchBody: string;\n launchSetupItems: string[];\n announceHeading: string;\n announceBody: string;\n announceImprovementResend: string;\n announceImprovementSvelte5: string;\n /** The cadence lead-in, ending at the em dash. Rendered followed by `announceCadenceNote`\n * in italics. */\n announceCadence: string;\n /** The reassurance tail, rendered italic after `announceCadence`. */\n announceCadenceNote: string;\n announceOpenDoor: string;\n};\n\nexport const DEFAULT_COPY: ResolvedCopy = {\n maintenanceIntro:\n \"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.\",\n maintenanceChecks: [\n \"Deploy & Function Health\",\n \"CMS Checked\",\n \"Domain, DNS & SSL\",\n \"Google Indexed\",\n \"Security Updates\",\n \"Uptime Checked\",\n ],\n testingIntro:\n \"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.\",\n testingChecklist: [\n \"Desktop Browsers\",\n \"Mobile Browsers\",\n \"Page Titles & Meta\",\n \"Links & Navigation\",\n \"Form Functionality\",\n \"Interactions & Animations\",\n \"Tested After Updates\",\n ],\n notesHeader: \"NOTES\",\n seoCta: \"Contact us if you are interested in more in-depth data or have questions about SEO.\",\n contact: [\"Just hit reply.\", \"We're here to help in any way we can.\"],\n footerOrg: \"Reddoor Creative, LLC\",\n footerAddress: [\"29027 Dapper Dan\", \"Fair Oaks Ranch, TX 78015\"],\n launchHeading: \"LAUNCHED\",\n launchBody:\n \"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:\",\n launchSetupItems: [\n \"Hosting, DNS, and SSL configured\",\n \"Continuous integration + automatic dependency updates\",\n \"Analytics and uptime monitoring\",\n ],\n announceHeading: \"YOUR ONGOING SITE CARE\",\n announceBody:\n \"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:\",\n announceImprovementResend:\n \"Your contact forms now deliver straight to your inbox through reliable infrastructure, so no inquiry slips through the cracks.\",\n announceImprovementSvelte5:\n \"We've modernized your site to the latest framework — it's faster, more secure, and built to last.\",\n announceCadence: \"After each one we'll send you a short report like this —\",\n announceCadenceNote: \"there's nothing you need to do.\",\n announceOpenDoor:\n \"And if you'd ever like to expand the scope, add features, or freshen anything up, just let us know.\",\n};\n\n/** Trim an override to null when blank (mirrors the trim-to-null handling). */\nfunction override(v: string | null): string | null {\n if (typeof v !== \"string\") return null;\n const t = v.trim();\n return t.length > 0 ? t : null;\n}\n\n/**\n * Resolve a site's effective copy: DEFAULT_COPY with the three per-site narrative\n * overrides applied. Only maintenanceIntro/contact/footer are per-site (M6a §2);\n * everything else is the shared default. PURE.\n */\n/** Split an operator override into lines: tolerate CRLF, drop blank lines (a stray\n * blank in the Airtable cell shouldn't render an empty address row). */\nfunction splitLines(s: string): string[] {\n return s.split(/\\r?\\n/).filter((l) => l.trim().length > 0);\n}\n\nexport function resolveCopy(site: WebsiteRow): ResolvedCopy {\n const intro = override(site.copyIntro);\n const contact = override(site.copyContact);\n const footer = override(site.copyFooter);\n const footerLines = footer ? splitLines(footer) : null;\n return {\n ...DEFAULT_COPY,\n maintenanceIntro: intro ?? DEFAULT_COPY.maintenanceIntro,\n contact: contact ? splitLines(contact) : DEFAULT_COPY.contact,\n footerOrg: footerLines?.[0] ?? DEFAULT_COPY.footerOrg,\n footerAddress: footerLines ? footerLines.slice(1) : DEFAULT_COPY.footerAddress,\n };\n}\n","import { readFile } from \"node:fs/promises\";\nimport { existsSync } from \"node:fs\";\nimport { dirname, join } from \"node:path\";\nimport { fileURLToPath } from \"node:url\";\n\nexport const CHECK_CID = \"rd-check-png\";\nexport const BLURRED_CID = \"rd-blurred-tests-jpg\";\n\nexport type BundledImage = {\n bytes: Uint8Array;\n contentType: string;\n cid: string;\n filename: string;\n};\n\n// Walk up from the current module's URL looking for the assets dir in either\n// the dev layout (src/reports/maintenance-email/assets/) or the published\n// layout (dist/reports/maintenance-email/assets/). REQUIRED because tsup\n// inlines this module into dist/cli/bin.js — so `import.meta.url`-based\n// sibling resolution looks in dist/cli/ for the PNGs and fails with ENOENT.\n// Regression that shipped in 0.10.0–0.10.1; tests passed in dev because\n// vitest evaluates the source file where import.meta.url is already correct.\nlet cachedAssetsDir: string | null = null;\nfunction resolveAssetsDir(): string {\n if (cachedAssetsDir) return cachedAssetsDir;\n let dir = dirname(fileURLToPath(import.meta.url));\n while (true) {\n // Source layout preferred — single source of truth in the workspace\n // and the only one present in dev/test environments.\n const srcCandidate = join(dir, \"src\", \"reports\", \"maintenance-email\", \"assets\", \"check.png\");\n if (existsSync(srcCandidate)) {\n cachedAssetsDir = dirname(srcCandidate);\n return cachedAssetsDir;\n }\n // Published layout — only `dist/` ships per package.json#files, so\n // consumers fall through to here.\n const distCandidate = join(dir, \"dist\", \"reports\", \"maintenance-email\", \"assets\", \"check.png\");\n if (existsSync(distCandidate)) {\n cachedAssetsDir = dirname(distCandidate);\n return cachedAssetsDir;\n }\n const parent = dirname(dir);\n if (parent === dir) {\n throw new Error(\n `loadBundledImages: could not locate maintenance-email assets dir by walking up from ${fileURLToPath(import.meta.url)}. Checked both src/ and dist/ layouts.`,\n );\n }\n dir = parent;\n }\n}\n\n/**\n * Read the bundled image bytes from disk. Both Maintenance and Testing\n * variants reference `check.png`; only the Maintenance variant references\n * `blurredTests.jpg`.\n */\nexport async function loadBundledImages(): Promise<{\n check: BundledImage;\n blurred: BundledImage;\n}> {\n const assetsDir = resolveAssetsDir();\n const [check, blurred] = await Promise.all([\n readFile(join(assetsDir, \"check.png\")),\n readFile(join(assetsDir, \"blurredTests.jpg\")),\n ]);\n return {\n check: {\n bytes: new Uint8Array(check),\n contentType: \"image/png\",\n cid: CHECK_CID,\n filename: \"check.png\",\n },\n blurred: {\n bytes: new Uint8Array(blurred),\n contentType: \"image/jpeg\",\n cid: BLURRED_CID,\n filename: \"blurredTests.jpg\",\n },\n };\n}\n","import { escapeHtml } from \"../util/html.js\";\nimport { CHECK_CID } from \"./maintenance-email/assets/index.js\";\nimport type { ReportData } from \"./types.js\";\n\n/**\n * Shared MJML section builders for the report family of emails (maintenance/testing report,\n * announcement). Centralizing them here guarantees the announcement renders the SAME polished\n * components as the monthly report — the checklist rows, the full Lighthouse block, and the\n * analytics block — so the two can't drift in design. PURE string builders; no I/O.\n *\n * Escaping: callers pass already-trusted copy for fixed labels, but any site/operator string\n * (check labels, trailing notes) is escaped here via `escapeXml`.\n */\nexport const escapeXml = escapeHtml;\n\nconst RED = \"#C00\";\nconst GREY = \"#757575\";\nconst BORDER = \"#CCCCCC\";\nconst TREND_UP = \"#2E7D32\"; // positive green — growth reads as good\nconst TREND_NEUTRAL = GREY; // muted grey — dips/flat aren't failures (brand red is reserved)\n\n// The report's bundled green check (cid:rd-check-png), attached inline by orchestrate.ts at\n// send time. Standalone previews (no attachments) show the image's alt instead.\nconst CHECK_PNG = `cid:${CHECK_CID}`;\n\n/** Thousands-grouped user/visitor count. */\nexport function fmtUsers(n: number): string {\n return n.toLocaleString(\"en-US\");\n}\n\n/**\n * A checklist \"table\": one ruled row per label, the label on the left and the green check\n * right-aligned, matching the monthly report. `background` tints the band (white for the\n * maintenance list, #F4F4F4 for the testing list); `lastPaddingBottom` is the trailing gap\n * under the final row. Labels are escaped. PURE.\n */\nexport function checklistRowsSection(\n rows: string[],\n opts: { background: string; lastPaddingBottom: string },\n): string {\n return rows\n .map((label, i) => {\n const isLast = i === rows.length - 1;\n const border = isLast ? \"\" : ` border-bottom=\"solid ${BORDER} 1px\"`;\n const lastPad = isLast ? ` padding-bottom=\"${opts.lastPaddingBottom}\"` : \"\";\n return `\n <mj-section background-color=\"${opts.background}\" padding=\"0px\"${lastPad}>\n <mj-group>\n <mj-column padding-left=\"0px\" width=\"90%\"${border}>\n <mj-text height=\"25px\" padding-left=\"0px\" color=\"${GREY}\" padding-top=\"20px\" padding-bottom=\"7.5px\" font-size=\"16px\">${escapeXml(label)}</mj-text>\n </mj-column>\n <mj-column width=\"10%\"${border} padding-top=\"15px\">\n <mj-image align=\"right\" padding-right=\"0px\" width=\"20px\" height=\"20px\" padding-top=\"2.5px\" padding-bottom=\"15px\" src=\"${CHECK_PNG}\" />\n </mj-column>\n </mj-group>\n </mj-section>`;\n })\n .join(\"\");\n}\n\n/** The four Lighthouse scores with their client-facing labels and acceptable/ideal bands.\n * Ideal always tops at 100 (the metric's ceiling). */\nconst LIGHTHOUSE_ROWS: ReadonlyArray<{\n label: string;\n key: keyof ReportData[\"lighthouse\"];\n range: string;\n}> = [\n { label: \"Performance\", key: \"performance\", range: \"Acceptable 50–89 // Ideal 90–100\" },\n { label: \"Readability (A11y)\", key: \"accessibility\", range: \"Acceptable 80–99 // Ideal 100\" },\n { label: \"Best Practices\", key: \"bestPractices\", range: \"Acceptable 60–79 // Ideal 80–100\" },\n { label: \"Site Structure\", key: \"seo\", range: \"Acceptable 50–89 // Ideal 90–100\" },\n];\n\n/**\n * The full \"LIGHTHOUSE SCORES*\" block: each score as a big red number under its label with the\n * acceptable/ideal band beneath, ruled between scores, closed by the explanatory footnote.\n * `background` tints the section (default #F4F4F4, matching the report). When `pad` is given the\n * band's top/bottom padding moves to the section (symmetric bands for the announcement); omitting\n * it keeps the report's original inner paddings. PURE.\n */\nexport function lighthouseScoresSection(\n lighthouse: ReportData[\"lighthouse\"],\n opts: { background?: string; pad?: string } = {},\n): string {\n const background = opts.background ?? \"#F4F4F4\";\n const sectionPad = opts.pad ? ` padding-top=\"${opts.pad}\" padding-bottom=\"${opts.pad}\"` : \"\";\n const labelTop = opts.pad ?? \"55px\";\n const footnoteBottom = opts.pad ? \"0px\" : \"36px\";\n const rows = LIGHTHOUSE_ROWS.map(\n ({ label, key, range }, i) => `\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"300\" padding-top=\"25px\">${label}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\" padding-top=\"0px\">${lighthouse[key]}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"0px\" padding-bottom=\"36px\">${range}</mj-text>${\n i < LIGHTHOUSE_ROWS.length - 1\n ? `\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"${BORDER}\" padding=\"0\" />`\n : \"\"\n }`,\n ).join(\"\");\n return `\n <mj-section background-color=\"${background}\"${sectionPad}>\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"${labelTop}\">LIGHTHOUSE SCORES*</mj-text>${rows}\n <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 <a href=\"https://developer.chrome.com/docs/lighthouse/overview\" style=\"color:${RED}; text-decoration:underline;\">Google's Lighthouse tool</a>, which evaluates various aspects of a web page's quality.</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\n/** The line under \"{N} Users\": a directional trend vs the previous period when both numbers\n * are real, else a graceful fallback. `undefined` = GA unavailable (distinct from a real 0).\n * Up = green; down/flat = muted grey (a traffic dip isn't a failure). PURE. */\nexport function analyticsTrendLine(\n cur: number | undefined,\n prev: number | undefined,\n periodDays?: number,\n): string {\n // The prior window the trend compares against: a concrete \"the previous N days\" when the\n // caller knows the window length, else the generic \"last period\" (keeps the label honest for\n // callers — and tests — that don't supply it).\n const priorLabel =\n periodDays && periodDays > 0 ? `the previous ${periodDays} days` : \"last period\";\n if (cur === undefined || prev === undefined) {\n return trendLine(TREND_NEUTRAL, `Last Period: ${prev !== undefined ? fmtUsers(prev) : \"—\"}`);\n }\n if (prev === 0) {\n return cur > 0\n ? trendLine(TREND_UP, \"▲ New this period (0 last period)\")\n : trendLine(TREND_NEUTRAL, \"Last Period: 0\");\n }\n const pct = Math.round(((cur - prev) / prev) * 100);\n const range = `(${fmtUsers(prev)} → ${fmtUsers(cur)})`;\n if (pct > 0) return trendLine(TREND_UP, `▲ ${pct}% vs ${priorLabel} ${range}`);\n if (pct < 0) return trendLine(TREND_NEUTRAL, `▼ ${Math.abs(pct)}% vs ${priorLabel} ${range}`);\n return trendLine(TREND_NEUTRAL, `No change vs ${priorLabel} (${fmtUsers(prev)})`);\n}\n\n/** A 16px coloured line (the trend, and the announcement's search line). */\nfunction trendLine(color: string, text: string): string {\n return `<mj-text color=\"${color}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${text}</mj-text>`;\n}\n\n/** A 12px muted footnote line (the SEO call-to-action / Lighthouse note style). */\nfunction footnoteLine(text: string): string {\n 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>`;\n}\n\n/** True when the analytics block has at least one real datum to show — a GA user count or a body\n * line (e.g. the announcement's page-1 search callout). The static SEO call-to-action footnote\n * alone does NOT qualify: a block with no data is hidden rather than rendered empty. PURE. */\nexport function hasAnalyticsData(opts: {\n current?: number | undefined;\n bodyLines?: string[];\n}): boolean {\n return opts.current !== undefined || (opts.bodyLines?.length ?? 0) > 0;\n}\n\n/**\n * The \"ANALYTICS\" block: a big red user count, the trend line, then any 16px `bodyLines`\n * (e.g. the announcement's Google-position line) and 12px muted `footnoteLines` (e.g. the\n * report's SEO call-to-action). Callers pass already-escaped line text. PURE.\n *\n * Returns \"\" when there's no data ({@link hasAnalyticsData} false) — an empty \"— Users\" block\n * reads as broken, so the section (and its data-contextual SEO footnote) is omitted. When a body\n * line exists but no GA count (a GA-less site still ranking on page 1), the user count + trend\n * are suppressed and only the body shows under the label.\n */\nexport function analyticsSection(opts: {\n current?: number | undefined;\n previous?: number | undefined;\n /** Length in days of the current window; drives the trend's \"vs the previous N days\" label. */\n periodDays?: number | undefined;\n background: string;\n bodyLines?: string[];\n footnoteLines?: string[];\n pad?: string;\n}): string {\n if (!hasAnalyticsData(opts)) return \"\";\n const body = (opts.bodyLines ?? []).map((l) => trendLine(TREND_NEUTRAL, l)).join(\"\\n \");\n const footnotes = (opts.footnoteLines ?? []).map(footnoteLine).join(\"\\n \");\n const sectionPad = opts.pad ? ` padding-top=\"${opts.pad}\" padding-bottom=\"${opts.pad}\"` : \"\";\n const labelTop = opts.pad ?? \"75px\";\n const usersBlock =\n opts.current !== undefined\n ? `\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtUsers(opts.current)} Users</mj-text>\n ${analyticsTrendLine(opts.current, opts.previous, opts.periodDays)}`\n : \"\";\n return `\n <mj-section background-color=\"${opts.background}\"${sectionPad}>\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"${labelTop}\">ANALYTICS</mj-text>${usersBlock}\n ${body}\n ${footnotes}\n </mj-column>\n </mj-section>`;\n}\n","import type { ReportData } from \"../types.js\";\nimport { DEFAULT_COPY, type ResolvedCopy } from \"../copy.js\";\nimport { BLURRED_CID } from \"./assets/index.js\";\nimport {\n checklistRowsSection,\n lighthouseScoresSection,\n analyticsSection,\n} from \"../email-sections.js\";\nimport { escapeHtml } from \"../../util/html.js\";\nimport { isHttpUrl } from \"../../util/url.js\";\n\n/**\n * Escape operator/site-controlled strings before interpolating into the MJML markup.\n * MJML parses as XML with `validationLevel: \"strict\"`. Under mjml@4.18 a raw `&`, `<`,\n * or `>` does NOT throw — it passes straight through into the rendered output, so an\n * unescaped value (e.g. a site name \"Brown & Co\", a URL, or commentary) silently\n * injects HTML/markup into the email. A raw `\"` inside an ATTRIBUTE value (e.g. the\n * image `href`/`alt`) is the one that throws — it terminates the attribute and trips a\n * parse error that blocks the send. So we escape for two reasons: prevent\n * HTML/markup injection in text, and prevent the attribute-quote parse error. Apply\n * to every interpolation of siteName / siteUrl / commentary / copy.\n *\n * This IS `src/util/html.ts`'s `escapeHtml` (the strict-XML set is identical),\n * re-exported under the name the email templates import (the launch template imports\n * `escapeXml` from here).\n */\nexport const escapeXml = escapeHtml;\n\n// Bundled images: shipped in dist/ via tsup onSuccess copy, attached inline via\n// CID by orchestrate.ts at send time. No external CDN dependency. (The green check\n// image lives in the shared email-sections checklist component.)\nconst BLURRED_TESTS = `cid:${BLURRED_CID}`;\n\nexport function fmtDate(d: Date | null): string {\n // Guard BOTH null AND an Invalid Date — `new Date(\"not-a-date\")` (a malformed\n // Airtable date string) is a truthy Date whose getUTC* accessors all return\n // NaN, which would render \"NaN.NaN.NaN\" into a real client email. `!d` alone\n // misses it; `Number.isNaN(d.getTime())` catches it.\n if (!d || Number.isNaN(d.getTime())) return \"\";\n // Airtable date fields are wall-clock YYYY-MM-DD strings parsed as UTC midnight.\n // Use UTC accessors so the rendered date matches what the operator entered.\n // US format: MM.DD.YYYY (Reddoor is Texas-based, clients are US).\n const mm = String(d.getUTCMonth() + 1).padStart(2, \"0\");\n const dd = String(d.getUTCDate()).padStart(2, \"0\");\n const yyyy = d.getUTCFullYear();\n return `${mm}.${dd}.${yyyy}`;\n}\n\nfunction maintenanceChecksSection(copy: ResolvedCopy, searchPosition?: number): string {\n // The Google row shows the live search position when available, else the plain label.\n const googleLabel =\n searchPosition !== undefined\n ? `Page 1 Google Result (#${searchPosition})`\n : (copy.maintenanceChecks[3] ?? \"\");\n const rows = copy.maintenanceChecks.map((label, i) => (i === 3 ? googleLabel : label));\n return checklistRowsSection(rows, { background: \"white\", lastPaddingBottom: \"36px\" });\n}\n\nfunction testingChecklistSection(copy: ResolvedCopy): string {\n return checklistRowsSection(copy.testingChecklist, {\n background: \"#F4F4F4\",\n lastPaddingBottom: \"60px\",\n });\n}\n\nfunction maintenanceTestingPlaceholder(lastTested: Date | null): string {\n return `\n <mj-section background-color=\"#F4F4F4\">\n <mj-column>\n <mj-image href=\"mailto:info@reddoorla.com\" src=\"${BLURRED_TESTS}\" />\n </mj-column>\n </mj-section>\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\">\n <mj-column>\n <mj-text color=\"#757575\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">Last Tested: ${fmtDate(lastTested)}</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\nfunction testingIntroSection(copy: ResolvedCopy): string {\n return `\n <mj-section background-color=\"#F4F4F4\">\n <mj-column>\n <mj-text color=\"#C00\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">TESTING</mj-text>\n <mj-text color=\"#757575\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(copy.testingIntro)}</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\nfunction commentarySection(text: string, copy: ResolvedCopy): string {\n return `\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"#C00\" font-size=\"20px\" font-weight=\"700\" padding-top=\"55px\">${escapeXml(copy.notesHeader)}</mj-text>\n <mj-text color=\"#757575\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(text).replace(/\\r\\n?|\\n/g, \"<br/>\")}</mj-text>\n </mj-column>\n </mj-section>`;\n}\n\nfunction hasHeaderDims(\n data: ReportData,\n): data is ReportData & { headerWidth: number; headerHeight: number; headerBgColor: string } {\n return Boolean(data.headerWidth && data.headerHeight && data.headerBgColor);\n}\n\nexport function headerImageTag(data: ReportData): string {\n const src = `cid:${data.headerImageCid}`;\n const alt = `${escapeXml(data.siteName)} maintenance report`;\n // escapeXml only escapes markup chars — it does NOT neutralize a dangerous URL\n // scheme. A `javascript:`/`data:` siteUrl would survive escaping and become a live\n // header href. Gate on isHttpUrl (the same http(s) allowlist the audit path uses)\n // and DROP a non-http(s) href entirely (fall back to \"#\") rather than linking it.\n const href = isHttpUrl(data.siteUrl) ? escapeXml(data.siteUrl) : \"#\";\n // Reserve the box and show a matched placeholder while the image loads / if blocked.\n // Critically, we do NOT set an mj-image `height` — MJML would emit `height:<px>` while\n // keeping `width:100%`, locking the height while the width scales and distorting the\n // image at any rendered width != the design width (mobile, narrow panes). Instead the\n // image stays `height:auto` (proportional) and the box is reserved via `aspect-ratio`\n // in the head <mj-style> below (see headerStyleBlock). `container-background-color` is\n // the placeholder; the bare fallback (no dims, e.g. local preview) keeps today's behavior.\n if (hasHeaderDims(data)) {\n return `<mj-image href=\"${href}\" src=\"${src}\" alt=\"${alt}\" width=\"${data.headerWidth}px\" css-class=\"rd-header\" container-background-color=\"${data.headerBgColor}\" />`;\n }\n return `<mj-image href=\"${href}\" src=\"${src}\" alt=\"${alt}\" />`;\n}\n\nexport function headerStyleBlock(data: ReportData): string {\n if (!hasHeaderDims(data)) return \"\";\n // Reserve the header's vertical space by aspect ratio so it scales proportionally with\n // its fluid (width:100%) width — no fixed pixel height, so it never squishes.\n // `height:auto !important` defends against any client honoring MJML's inline height.\n return `<mj-style>.rd-header img { height: auto !important; aspect-ratio: ${data.headerWidth} / ${data.headerHeight}; }</mj-style>`;\n}\n\nexport function buildMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n const isTesting = data.reportType === \"Testing\";\n const previewText = `Checked up on ${escapeXml(data.siteName)}`;\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${previewText}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>\n ${headerImageTag(data)}\n </mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"#C00\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">COMPLETED ON</mj-text>\n <mj-text color=\"#C00\" font-size=\"44px\" font-weight=\"400\">${fmtDate(data.completedOn)}</mj-text>\n <mj-text color=\"#C00\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">MAINTENANCE CHECKS</mj-text>\n <mj-text color=\"#757575\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\">${escapeXml(copy.maintenanceIntro)}</mj-text>\n </mj-column>\n </mj-section>\n ${maintenanceChecksSection(copy, data.searchPosition)}\n ${lighthouseScoresSection(data.lighthouse)}\n ${analyticsSection({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n periodDays: data.gaPeriodDays,\n background: \"white\",\n footnoteLines: [escapeXml(copy.seoCta)],\n })}\n ${isTesting ? testingIntroSection(copy) + testingChecklistSection(copy) : maintenanceTestingPlaceholder(data.lastTestedDate)}\n ${data.commentary ? commentarySection(data.commentary, copy) : \"\"}\n <mj-section background-color=\"white\">\n <mj-column padding-top=\"36px\">\n <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>\n ${copy.contact\n .map((line, i) =>\n i === copy.contact.length - 1\n ? `<mj-text font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" padding-top=\"0px\" line-height=\"30px\" padding-bottom=\"36px\">${escapeXml(line)}</mj-text>`\n : `<mj-text font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" line-height=\"30px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\\n \")}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <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 ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <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>\n ${[copy.footerOrg, ...copy.footerAddress]\n .map(\n (line) =>\n `<mj-text color=\"#757575\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\\n \")}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n","import type { ReportData, ReportFrequency } from \"../types.js\";\nimport type { WebsiteRow } from \"../airtable/websites.js\";\nimport { DEFAULT_COPY, type ResolvedCopy } from \"../copy.js\";\nimport { escapeXml, headerImageTag, headerStyleBlock } from \"../maintenance-email/template.js\";\nimport {\n checklistRowsSection,\n lighthouseScoresSection,\n analyticsSection,\n hasAnalyticsData,\n} from \"../email-sections.js\";\n\n/** Frequency → client-facing phrase. \"None\" is never rendered (the line is omitted). */\nconst FREQ_PHRASE: Record<Exclude<ReportFrequency, \"None\">, string> = {\n Monthly: \"every month\",\n Quarterly: \"every quarter\",\n Yearly: \"every year\",\n};\n\nconst RED = \"#C00\";\nconst GREY = \"#757575\";\n\n// Equal top/bottom padding for every alternating-background band, applied at the mj-section\n// level so each colored band has symmetric breathing room. (Starting baseline — easy to tune.)\nconst SECTION_PAD = \"40px\";\n\n/** A red all-caps section label. Top spacing comes from the section padding, so this is flush. */\nfunction sectionLabel(text: string): string {\n return `<mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"0px\">${escapeXml(text)}</mj-text>`;\n}\n\n/** A grey 16px body paragraph whose caller passes already-escaped HTML, so partial inline markup\n * (e.g. the cadence line's italic <em>…</em> tail) survives. */\nfunction bodyLineHtml(html: string, paddingTop = \"8px\"): string {\n return `<mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"${paddingTop}\">${html}</mj-text>`;\n}\n\n/** A grey 16px body paragraph, matching the report's section intros. Escapes `text`. */\nfunction bodyLine(text: string, paddingTop = \"8px\"): string {\n return bodyLineHtml(escapeXml(text), paddingTop);\n}\n\n/** The go-forward cadence sentence as HTML: the lead-in followed by its reassurance tail in\n * italics (\"…short report like this — <em>there's nothing you need to do.</em>\"). Both escaped. */\nfunction cadenceHtml(copy: ResolvedCopy): string {\n return `${escapeXml(copy.announceCadence)} <em>${escapeXml(copy.announceCadenceNote)}</em>`;\n}\n\n/**\n * The announcement-only ReportData extras derived from the Websites row: the go-forward\n * cadence and the default-on improvement callouts. Used by BOTH the draft (announce recipe)\n * and the send-time re-render (orchestrate) so the sent email matches the reviewed preview —\n * `renderReportHtml` in the send path otherwise omits these, dropping the cadence + improvements.\n */\nexport function announcementSiteExtras(\n site: WebsiteRow,\n): Pick<ReportData, \"cadence\" | \"improvements\"> {\n return {\n cadence: { maintenance: site.maintenanceFreq, testing: site.testingFreq },\n improvements: { resendForms: true, svelte5: true },\n };\n}\n\n/**\n * One-time onboarding announcement, built from the SAME components as the monthly report so it\n * reads as a testing report with extra explanation: header · intro (\"your ongoing care\") ·\n * MAINTENANCE CHECKS · TESTING (each: intro with the cadence baked into the copy + the report's\n * checklist rows) · LIGHTHOUSE SCORES · ANALYTICS (users + trend + the Google-position line) ·\n * RECENT IMPROVEMENTS (conditional; closes with the open-door invitation). Reuses the M6a copy\n * layer (contact/footer honor per-site overrides). No pricing. A pace set to None omits its\n * checklist section. Every band carries equal top/bottom padding (SECTION_PAD).\n */\nexport function buildAnnouncementMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n const previewText = \"Your monthly report from Reddoor\";\n const cad = data.cadence;\n\n const hasMaint = Boolean(cad && cad.maintenance !== \"None\");\n const hasTesting = Boolean(cad && cad.testing !== \"None\");\n const improvementItems: string[] = [];\n if (data.improvements?.resendForms) improvementItems.push(copy.announceImprovementResend);\n if (data.improvements?.svelte5) improvementItems.push(copy.announceImprovementSvelte5);\n const hasImpr = improvementItems.length > 0;\n\n // ANALYTICS renders only with real data — a GA user count or a page-1 search callout (the latter\n // keeps the block alive for a GA-less site that still ranks). Computed up here so the band\n // counter below can SKIP its background slot when analytics is hidden; otherwise a consumed-but-\n // invisible slot would push the next band to the wrong color and two same-color bands would abut.\n const analyticsBodyLines =\n data.searchPosition !== undefined\n ? [`Page 1 Google result (#${data.searchPosition}) for your brand search`]\n : [];\n const hasAnalytics = hasAnalyticsData({\n current: data.gaUsersCurrent,\n bodyLines: analyticsBodyLines,\n });\n\n // Alternating band backgrounds assigned in render order, so the white/#F4F4F4 pattern holds\n // no matter which optional sections (maintenance / testing / improvements) actually render —\n // a counter that only advances for bands that appear avoids two same-colored bands abutting.\n const BANDS = [\"white\", \"#F4F4F4\"] as const;\n let bandN = 0;\n const nextBg = (): string => BANDS[bandN++ % 2]!;\n const introBg = nextBg();\n const maintBg = hasMaint ? nextBg() : \"\";\n const testBg = hasTesting ? nextBg() : \"\";\n const lighthouseBg = nextBg();\n const analyticsBg = hasAnalytics ? nextBg() : \"\";\n const improvementsBg = hasImpr ? nextBg() : \"\";\n const contactBg = nextBg();\n\n // MAINTENANCE CHECKS (first) — intro with the maintenance cadence baked into the copy, then the\n // report's checklist rows. The report-frequency reassurance (announceCadence) trails the LAST\n // check block, so it sits here only when there's no testing block after it. Omitted when None.\n const maintenanceSection =\n cad && cad.maintenance !== \"None\"\n ? `\n <mj-section background-color=\"${maintBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"0px\">\n <mj-column>\n ${sectionLabel(\"MAINTENANCE CHECKS\")}\n ${bodyLineHtml(`${escapeXml(`${copy.maintenanceIntro} We do this ${FREQ_PHRASE[cad.maintenance]}.`)}${cad.testing === \"None\" ? ` ${cadenceHtml(copy)}` : \"\"}`)}\n </mj-column>\n </mj-section>${checklistRowsSection(copy.maintenanceChecks, {\n background: maintBg,\n lastPaddingBottom: SECTION_PAD,\n })}`\n : \"\";\n\n // TESTING (second) — intro with the testing cadence + the report-frequency note baked in, then\n // the report's checklist rows. Omitted when None.\n const testingSection =\n cad && cad.testing !== \"None\"\n ? `\n <mj-section background-color=\"${testBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"0px\">\n <mj-column>\n ${sectionLabel(\"TESTING\")}\n ${bodyLineHtml(`${escapeXml(`${copy.testingIntro} We run a full test ${FREQ_PHRASE[cad.testing]}.`)} ${cadenceHtml(copy)}`)}\n </mj-column>\n </mj-section>${checklistRowsSection(copy.testingChecklist, {\n background: testBg,\n lastPaddingBottom: SECTION_PAD,\n })}`\n : \"\";\n\n // ANALYTICS — the report's big user count + trend, plus the Google search-position line. Hidden\n // entirely when there's no traffic or search data (hasAnalytics false), so it never shows empty.\n const analytics = hasAnalytics\n ? analyticsSection({\n current: data.gaUsersCurrent,\n previous: data.gaUsersPrevious,\n periodDays: data.gaPeriodDays,\n background: analyticsBg,\n pad: SECTION_PAD,\n bodyLines: analyticsBodyLines,\n })\n : \"\";\n\n // RECENT IMPROVEMENTS — the toggled callouts, closed by the open-door invitation. Omitted\n // entirely when there are no improvements (the open-door rides along with this block).\n const improvementsSection = hasImpr\n ? `\n <mj-section background-color=\"${improvementsBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n ${sectionLabel(\"RECENT IMPROVEMENTS\")}\n ${improvementItems.map((item) => bodyLine(item)).join(\"\\n \")}\n ${bodyLine(copy.announceOpenDoor, \"16px\")}\n </mj-column>\n </mj-section>`\n : \"\";\n\n // First contact line (\"Just hit reply.\") stays black for emphasis; any following lines\n // (e.g. \"We're here to help in any way we can.\") render in muted grey.\n const contactRows = copy.contact\n .map(\n (line, i) => `\n <mj-text ${i === 0 ? \"\" : `color=\"${GREY}\" `}font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" line-height=\"30px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n const footerAddressRows = copy.footerAddress\n .map(\n (line) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${escapeXml(previewText)}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>${headerImageTag(data)}</mj-column>\n </mj-section>\n <mj-section background-color=\"${introBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n ${sectionLabel(copy.announceHeading)}\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"20px\">Prepared for ${escapeXml(data.siteName)}</mj-text>\n ${bodyLine(copy.announceBody)}\n </mj-column>\n </mj-section>\n ${maintenanceSection}\n ${testingSection}\n ${lighthouseScoresSection(data.lighthouse, { background: lighthouseBg, pad: SECTION_PAD })}\n ${analytics}\n ${improvementsSection}\n <mj-section background-color=\"${contactBg}\" padding-top=\"${SECTION_PAD}\" padding-bottom=\"${SECTION_PAD}\">\n <mj-column>\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"0px\" line-height=\"36px\">Questions, concerns or requests?</mj-text>\n ${contactRows}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(copy.footerOrg)}</mj-text>\n ${footerAddressRows}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n","import mjml2html from \"mjml\";\nimport type { ReportData } from \"./types.js\";\nimport { buildMjml } from \"./maintenance-email/template.js\";\nimport { buildLaunchMjml } from \"./launch-email/template.js\";\nimport { buildAnnouncementMjml } from \"./announcement-email/template.js\";\n\nexport type RenderResult = {\n html: string;\n warnings: Array<{ line: number; message: string }>;\n};\n\nexport async function renderReportHtml(data: ReportData): Promise<RenderResult> {\n const mjml =\n data.reportType === \"Launch\"\n ? buildLaunchMjml(data)\n : data.reportType === \"Announcement\"\n ? buildAnnouncementMjml(data)\n : buildMjml(data);\n const out = await mjml2html(mjml, { validationLevel: \"strict\" });\n return { html: out.html, warnings: out.errors ?? [] };\n}\n","import type { ReportData } from \"../types.js\";\nimport { DEFAULT_COPY } from \"../copy.js\";\nimport {\n escapeXml,\n fmtDate,\n headerImageTag,\n headerStyleBlock,\n} from \"../maintenance-email/template.js\";\n\nconst RED = \"#C00\";\nconst GREY = \"#757575\";\n\n/** Purpose-built go-live email: header · LAUNCHED + date · message · what-we-set-up\n * · contact · footer. Reuses the M6a copy layer (contact/footer honor per-site\n * overrides). No maintenance checklist / Lighthouse / analytics. */\nexport function buildLaunchMjml(data: ReportData): string {\n const copy = data.copy ?? DEFAULT_COPY;\n const previewText = `${escapeXml(data.siteName)} is live`;\n // All copy — launchHeading/launchBody/launchSetupItems included — is escaped\n // (spec §3.3: all copy escaped). It keeps strict MJML from choking on a stray\n // `&`/`<` if the default copy ever gains one, matching contact/footer below.\n const setupRows = copy.launchSetupItems\n .map(\n (item) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"4px\" padding-bottom=\"4px\">• ${escapeXml(item)}</mj-text>`,\n )\n .join(\"\");\n const contactRows = copy.contact\n .map(\n (line) => `\n <mj-text font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"300\" line-height=\"30px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n const footerAddressRows = copy.footerAddress\n .map(\n (line) => `\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(line)}</mj-text>`,\n )\n .join(\"\");\n\n return `<mjml>\n <mj-head>\n <mj-attributes>\n <mj-text font-family=\"helvetica, sans-serif\" padding-left=\"5px\" padding-right=\"5px\" />\n <mj-section padding-left=\"11%\" padding-right=\"11%\"/>\n <mj-image padding=\"0px\" />\n </mj-attributes>\n <mj-preview>${previewText}</mj-preview>\n ${headerStyleBlock(data)}\n </mj-head>\n <mj-body background-color=\"white\">\n <mj-section background-color=\"#F4F4F4\" padding-top=\"0px\" padding-bottom=\"0px\" padding-left=\"0px\" padding-right=\"0px\">\n <mj-column>${headerImageTag(data)}</mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column>\n <mj-text color=\"${RED}\" font-size=\"20px\" font-weight=\"700\" padding-top=\"75px\">${escapeXml(copy.launchHeading)}</mj-text>\n <mj-text color=\"${RED}\" font-size=\"44px\" font-weight=\"400\">${fmtDate(data.completedOn)}</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"16px\" font-weight=\"300\" line-height=\"24px\" padding-top=\"20px\">${escapeXml(copy.launchBody)}</mj-text>\n ${setupRows}\n </mj-column>\n </mj-section>\n <mj-section background-color=\"white\">\n <mj-column padding-top=\"36px\">\n <mj-text color=\"${RED}\" font-family=\"helvetica, sans-serif\" font-size=\"24px\" font-weight=\"700\" padding-top=\"36px\" line-height=\"36px\">Any questions, concerns or requests?</mj-text>\n ${contactRows}\n <mj-divider border-width=\"1px\" border-style=\"solid\" border-color=\"#CCCCCC\" padding=\"0\" />\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" padding-top=\"24px\" line-height=\"20px\" font-style=\"italic\">Copyright ${new Date().getUTCFullYear()} ${escapeXml(copy.footerOrg)}. All rights reserved.</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"700\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">Our mailing address is:</mj-text>\n <mj-text color=\"${GREY}\" font-family=\"helvetica, sans-serif\" font-size=\"12px\" font-weight=\"300\" line-height=\"16px\" padding-top=\"0\" padding-bottom=\"0px\">${escapeXml(copy.footerOrg)}</mj-text>\n ${footerAddressRows}\n </mj-column>\n </mj-section>\n </mj-body>\n</mjml>`;\n}\n","/** Cheap HTML sniff: an Airtable signed-URL \"200\" that is really a login/error page\n * starts with `<!doctype html`, `<html`, or `<head` after an optional UTF-8 BOM /\n * leading whitespace. We only need to catch the common error-page case, not parse\n * HTML. */\nfunction looksLikeHtml(bytes: Uint8Array): boolean {\n // Inspect the first ~64 bytes as ASCII (1 byte → 1 char; enough for a doctype /\n // opening tag). Skip a leading UTF-8 BOM (bytes EF BB BF) by index, then strip any\n // leading ASCII whitespace, and match the common HTML openers case-insensitively.\n const start = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf ? 3 : 0;\n const head = Buffer.from(bytes.slice(start, start + 64))\n .toString(\"ascii\")\n .replace(/^[\\s]+/, \"\")\n .toLowerCase();\n return head.startsWith(\"<!doctype html\") || head.startsWith(\"<html\") || head.startsWith(\"<head\");\n}\n\nexport async function fetchAttachmentBytes(\n url: string,\n): Promise<{ bytes: Uint8Array; contentType: string }> {\n const res = await fetch(url);\n if (!res.ok) {\n throw new Error(\n `Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`,\n );\n }\n const contentType = res.headers.get(\"content-type\") ?? \"application/octet-stream\";\n const ab = await res.arrayBuffer();\n const bytes = new Uint8Array(ab);\n // Sanity-gate the body: a 200 that is actually an HTML error/login page (expired\n // signed URL, auth wall) would otherwise be attached as the \"image\" and ship a\n // broken header. Accept an explicit image/* content-type; otherwise reject anything\n // that sniffs as HTML — so the send fails loudly rather than emailing a broken image.\n const isImageType = contentType.toLowerCase().startsWith(\"image/\");\n if (!isImageType && looksLikeHtml(bytes)) {\n throw new Error(\n `Airtable attachment did not return image data (content-type=\"${contentType}\", ` +\n `body looks like an HTML page — the signed URL may have expired) (url=${url})`,\n );\n }\n return { bytes, contentType };\n}\n\n/**\n * Upload bytes (or a string) as an attachment to a specific record + field.\n * Uses Airtable's content.airtable.com upload endpoint (base64 body) because\n * the standard SDK only accepts public URLs for attachments, and we don't\n * host the generated content anywhere public.\n *\n * Docs: https://airtable.com/developers/web/api/upload-attachment\n *\n * Requires AIRTABLE_PAT + AIRTABLE_BASE_ID in env (same as the rest of the\n * reports module). The fieldName is URL-encoded for the request path.\n */\nexport async function uploadAttachment(\n recordId: string,\n fieldName: string,\n body: Uint8Array | string,\n filename: string,\n contentType: string,\n): Promise<void> {\n const apiKey = process.env.AIRTABLE_PAT;\n const baseId = process.env.AIRTABLE_BASE_ID;\n if (!apiKey || !baseId) {\n throw new Error(\"AIRTABLE_PAT and AIRTABLE_BASE_ID must be set\");\n }\n const base64 =\n typeof body === \"string\"\n ? Buffer.from(body, \"utf-8\").toString(\"base64\")\n : Buffer.from(body).toString(\"base64\");\n const payload = { contentType, file: base64, filename };\n const url = `https://content.airtable.com/v0/${baseId}/${recordId}/${encodeURIComponent(fieldName)}/uploadAttachment`;\n const res = await fetch(url, {\n method: \"POST\",\n headers: {\n Authorization: `Bearer ${apiKey}`,\n \"Content-Type\": \"application/json\",\n },\n body: JSON.stringify(payload),\n });\n if (!res.ok) {\n throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);\n }\n}\n"],"mappings":"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","names":["escapeXml","RED","GREY","escapeXml","RED","GREY","escapeXml"]}
|