@reddoorla/maintenance 0.75.0 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{announce-V6EYPPUR.js → announce-RUT247HS.js} +9 -6
- package/dist/{announce-V6EYPPUR.js.map → announce-RUT247HS.js.map} +1 -1
- package/dist/{blux-C24OVDDW.js → blux-RIJBRVCF.js} +1867 -277
- package/dist/blux-RIJBRVCF.js.map +1 -0
- package/dist/chunk-37S6L6G4.js +170 -0
- package/dist/chunk-37S6L6G4.js.map +1 -0
- package/dist/{chunk-PERP7LSS.js → chunk-3U3IGVAQ.js} +89 -13
- package/dist/chunk-3U3IGVAQ.js.map +1 -0
- package/dist/{chunk-IDBMYDVH.js → chunk-4VAUSRSS.js} +5 -3
- package/dist/{chunk-IDBMYDVH.js.map → chunk-4VAUSRSS.js.map} +1 -1
- package/dist/{run-migration-LJNNZWIV.js → chunk-6DOFNDXU.js} +5 -3
- package/dist/chunk-6DOFNDXU.js.map +1 -0
- package/dist/{chunk-NUIBQPB3.js → chunk-6SZBNALD.js} +62 -9
- package/dist/chunk-6SZBNALD.js.map +1 -0
- package/dist/{chunk-6OYICQSJ.js → chunk-CZAMYVOP.js} +9 -4
- package/dist/chunk-CZAMYVOP.js.map +1 -0
- package/dist/chunk-DRDAEELJ.js +197 -0
- package/dist/chunk-DRDAEELJ.js.map +1 -0
- package/dist/{chunk-KKL3RYFQ.js → chunk-DYKG53IR.js} +2 -2
- package/dist/{chunk-HBFMTM2E.js → chunk-JTRTG5CU.js} +3 -3
- package/dist/{chunk-JCITWZQR.js → chunk-MNE374CZ.js} +2 -50
- package/dist/chunk-MNE374CZ.js.map +1 -0
- package/dist/chunk-MPBXOJ3N.js +51 -0
- package/dist/chunk-MPBXOJ3N.js.map +1 -0
- package/dist/{chunk-I55YWV6T.js → chunk-QWFHC3VH.js} +3 -3
- package/dist/{chunk-KLVMJYZZ.js → chunk-SEQA7MC3.js} +2 -2
- package/dist/chunk-UWJZXL2D.js +9 -0
- package/dist/chunk-UWJZXL2D.js.map +1 -0
- package/dist/cli/bin.js +36 -9
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +1 -1
- package/dist/{digest-LEMZS5ZK.js → digest-75FWSC74.js} +9 -6
- package/dist/{digest-LEMZS5ZK.js.map → digest-75FWSC74.js.map} +1 -1
- package/dist/favicon-SRHSJZ62.js +42 -0
- package/dist/favicon-SRHSJZ62.js.map +1 -0
- package/dist/freeze-Y5OJFOST.js +442 -0
- package/dist/freeze-Y5OJFOST.js.map +1 -0
- package/dist/header-image-H4N54DHN.js +95 -0
- package/dist/header-image-H4N54DHN.js.map +1 -0
- package/dist/index.d.ts +63 -1
- package/dist/index.js +64 -16
- package/dist/index.js.map +1 -1
- package/dist/{init-7PTP4O2K.js → init-2KOISBCZ.js} +3 -3
- package/dist/{launch-W33FWWOZ.js → launch-K4EHKTXC.js} +7 -5
- package/dist/{launch-W33FWWOZ.js.map → launch-K4EHKTXC.js.map} +1 -1
- package/dist/{orchestrate-NYRYEKQM.js → orchestrate-CPR6KVS2.js} +4 -3
- package/dist/{preflight-JCQFKB6M.js → preflight-QRRMPI7K.js} +5 -4
- package/dist/{preflight-JCQFKB6M.js.map → preflight-QRRMPI7K.js.map} +1 -1
- package/dist/{report-POHQEV6F.js → report-SLGBDJ6O.js} +8 -6
- package/dist/{report-POHQEV6F.js.map → report-SLGBDJ6O.js.map} +1 -1
- package/dist/reports/header-image/assets/plate.png +0 -0
- package/dist/run-migration-WP57U5XO.js +9 -0
- package/dist/run-migration-WP57U5XO.js.map +1 -0
- package/dist/{selftest-OO2WDSDM.js → selftest-RVASRO3Z.js} +8 -6
- package/dist/{selftest-OO2WDSDM.js.map → selftest-RVASRO3Z.js.map} +1 -1
- package/dist/webflow-624FEBFD.js +548 -0
- package/dist/webflow-624FEBFD.js.map +1 -0
- package/package.json +3 -3
- package/dist/blux-C24OVDDW.js.map +0 -1
- package/dist/chunk-6OYICQSJ.js.map +0 -1
- package/dist/chunk-JCITWZQR.js.map +0 -1
- package/dist/chunk-NUIBQPB3.js.map +0 -1
- package/dist/chunk-PERP7LSS.js.map +0 -1
- package/dist/run-migration-LJNNZWIV.js.map +0 -1
- /package/dist/{chunk-KKL3RYFQ.js.map → chunk-DYKG53IR.js.map} +0 -0
- /package/dist/{chunk-HBFMTM2E.js.map → chunk-JTRTG5CU.js.map} +0 -0
- /package/dist/{chunk-I55YWV6T.js.map → chunk-QWFHC3VH.js.map} +0 -0
- /package/dist/{chunk-KLVMJYZZ.js.map → chunk-SEQA7MC3.js.map} +0 -0
- /package/dist/{init-7PTP4O2K.js.map → init-2KOISBCZ.js.map} +0 -0
- /package/dist/{orchestrate-NYRYEKQM.js.map → orchestrate-CPR6KVS2.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/alerts/digest-collectors.ts","../src/alerts/digest-state.ts"],"sourcesContent":["// src/alerts/digest-collectors.ts\nimport type { AttentionItem } from \"./attention.js\";\nimport { siteSlug, type WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { ReportRow } from \"../reports/airtable/reports.js\";\nimport { approveBlockers } from \"../reports/preflight.js\";\n\n/** Build the same `/s/<slug>` dashboard link the M3 ready-section uses, trailing-slash-safe.\n * An empty Name slugs to \"\" and `/s/` is a dead link — fall back to the fleet homepage,\n * exactly like the ready-section does. */\nfunction dashboardUrl(baseUrl: string, siteName: string): string {\n const root = baseUrl.replace(/\\/$/, \"\");\n const slug = siteSlug(siteName);\n return slug ? `${root}/s/${slug}` : root;\n}\n\n/**\n * A GitHub-signals sweep older than this (or never run) is no longer trustworthy:\n * a repo whose nightly probe THREW stops being re-swept, so its persisted\n * `Default Branch CI` / `Renovate Failing CIs` freeze at their last value forever\n * — a phantom 🔴 that can never clear. 3 days ≈ 3× the daily sweep interval, so a\n * single missed/flaky run doesn't drop a real signal. The CI/Renovate collectors\n * (only) skip a site whose `githubSignalsAt` is staler than this. Vuln/Lighthouse/\n * delivery signals come from other sweeps and are unaffected.\n */\nconst GITHUB_SIGNALS_STALE_DAYS = 3;\nconst MS_PER_DAY = 24 * 60 * 60 * 1000;\n\n/** True when a site's GitHub-signals sweep is too old (or never ran) to trust the\n * persisted CI/Renovate fields. A null `githubSignalsAt` (never swept) is stale —\n * there's no sweep to vouch for the values. A future timestamp (now - swept < 0)\n * is fresh. `now` is injected so the gate is testable. */\nfunction gitHubSignalsStale(swept: string | null, now: Date): boolean {\n if (swept === null) return true;\n const ageMs = now.getTime() - Date.parse(swept);\n if (!Number.isFinite(ageMs)) return true; // unparseable timestamp → don't trust it\n return ageMs > GITHUB_SIGNALS_STALE_DAYS * MS_PER_DAY;\n}\n\n/** Renovate auto-fix dispatches for one vuln episode before it's \"exhausted\" (manual fix needed). */\nconst AUTO_FIX_EXHAUSTED_CYCLES = 3;\n\n/**\n * One attention item per site carrying current critical+high vulns (medium/low omitted\n * per the locked threshold). PURE: takes already-fetched Websites rows. `metric` is the\n * critical+high count (so a rising count diffs as WORSE); `severity` is `critical` when\n * any critical exists, else `warning`. Null counts (never audited) read as 0 → skipped.\n * Once `securityAutoFixAttempts` reaches AUTO_FIX_EXHAUSTED_CYCLES the item is flagged\n * `autoFixExhausted` (forced-critical, escalated title) — Renovate tried and couldn't fix it.\n *\n * Emission is UNCONDITIONAL — every surface applies its own policy on top:\n * - digest email: includes a vuln only when `autoFixExhausted` (the operator hears\n * about a vuln only after Renovate tried and failed; before that the fleet is\n * still self-patching) — runDigest filters\n * - cockpit: non-exhausted vuln → amber Watch; exhausted → hard break (assignTier),\n * and only an exhausted vuln pierces the pre-launch mute (piercesPreLaunchMute)\n */\nexport function collectVulnAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const critical = s.securityVulnsCritical ?? 0;\n const high = s.securityVulnsHigh ?? 0;\n const metric = critical + high;\n if (metric <= 0) continue;\n const attempts = s.securityAutoFixAttempts ?? 0;\n const exhausted = attempts >= AUTO_FIX_EXHAUSTED_CYCLES;\n const noun = metric === 1 ? \"vuln\" : \"vulns\";\n items.push({\n key: `vuln:${s.id}`,\n kind: \"vuln\",\n siteName: s.name,\n title: exhausted\n ? `${metric} critical/high ${noun} — auto-fix failed (${attempts}×)`\n : `${metric} critical/high ${noun}`,\n url: dashboardUrl(baseUrl, s.name),\n severity: exhausted || critical > 0 ? \"critical\" : \"warning\",\n metric,\n ...(exhausted ? { autoFixExhausted: true } : {}),\n });\n }\n return items;\n}\n\n/** Absolute floor below which a Lighthouse category is \"Needs attention\" (Tucker's call). */\nconst LIGHTHOUSE_FLOOR = 75;\n\n/** The four Lighthouse categories, each mapped to its WebsiteRow score field, URL slug,\n * and the human label rendered in the digest title. Order is the operator's reading order. */\nconst LIGHTHOUSE_CATEGORIES: ReadonlyArray<{\n field: \"pScore\" | \"rScore\" | \"bpScore\" | \"seoScore\";\n slug: string;\n label: string;\n}> = [\n { field: \"pScore\", slug: \"performance\", label: \"Performance\" },\n { field: \"rScore\", slug: \"accessibility\", label: \"Accessibility\" },\n { field: \"bpScore\", slug: \"best-practices\", label: \"Best Practices\" },\n { field: \"seoScore\", slug: \"seo\", label: \"SEO\" },\n];\n\n/**\n * One attention item per Lighthouse category below the absolute floor (75) for each site.\n * PURE: takes already-fetched Websites rows. Categories are Performance/Accessibility/\n * Best-Practices/SEO. A null score (never audited) or a score >= 75 is skipped. The\n * `metric` is the DEFICIT (`100 - score`): a lower score → higher metric, so a category\n * that drops further diffs as WORSE and one that first crosses below 75 diffs as NEW —\n * which is how `diffAttention`'s \"WORSE on increase\" rule reads an inverted score. `key`\n * is `lighthouse:<siteId>:<categorySlug>`, so the four categories stay distinct per site.\n */\nexport function collectLighthouseAlerts(sites: WebsiteRow[], baseUrl: string): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n for (const cat of LIGHTHOUSE_CATEGORIES) {\n const score = s[cat.field];\n if (score === null || score >= LIGHTHOUSE_FLOOR) continue;\n items.push({\n key: `lighthouse:${s.id}:${cat.slug}`,\n kind: \"lighthouse\",\n siteName: s.name,\n title: `Lighthouse ${cat.label} ${score} (below ${LIGHTHOUSE_FLOOR})`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 100 - score,\n });\n }\n }\n return items;\n}\n\n/**\n * One attention item per report whose `deliveryStatus` is a failure (`bounced` or\n * `complained` — `delivered`/`pending` are ignored). PURE: takes already-fetched\n * Reports rows + a record-id→site map. A complaint ranks above a bounce (locked\n * threshold), so `severity` is `critical` for complained / `warning` for bounced.\n * `metric` is 1 (a binary event). Orphans (siteId not in the map) are skipped, as\n * the M3 ready-section does, so the digest never renders a broken link. The diff\n * key is the report RECORD id, so two failures on one site stay distinct.\n */\n/**\n * One attention item per unsent draft whose send is ALREADY known to fail\n * (approveBlockers: recipients / header image / report scores). An APPROVED one\n * is critical — the next 09:23 UTC run will go red on it; a pending one is a\n * warning — approving it just schedules that failure. Keyed `preflight:<reportId>`.\n * PURE; same predicate the approve gate and the dashboard chip use.\n */\nexport function collectPreflightBlocked(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (!r.draftReady || r.sentAt !== null) continue;\n // The approved state rides the KEY so a pending→approved escalation with\n // unchanged blockers re-news as a fresh critical instead of diffing\n // \"standing\" against its old warning self.\n const state = r.approvedToSend ? \"approved\" : \"pending\";\n const site = sitesById.get(r.siteId);\n if (!site) {\n // A dangling/empty Site link is itself a send blocker (sendApprovedReports\n // fails with \"Site row not found\") — surface it; the empty-slug\n // dashboardUrl fallback yields the fleet root, not a broken /s/ link.\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: \"(unlinked site)\",\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — site-not-found`\n : `${r.reportType} draft can't be approved — site-not-found`,\n url: dashboardUrl(baseUrl, \"\"),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: 1,\n });\n continue;\n }\n const fails = approveBlockers(site, r).filter((f) => f.level === \"fail\");\n if (fails.length === 0) continue;\n const first = fails[0]!;\n const more = fails.length > 1 ? ` (+${fails.length - 1} more)` : \"\";\n items.push({\n key: `preflight:${r.id}:${state}`,\n kind: \"preflight\",\n siteName: site.name,\n title: r.approvedToSend\n ? `Approved ${r.reportType} will fail at send — ${first.check}${more}`\n : `${r.reportType} draft can't be approved — ${first.check}${more}`,\n url: dashboardUrl(baseUrl, site.name),\n severity: r.approvedToSend ? \"critical\" : \"warning\",\n metric: fails.length,\n });\n }\n return items;\n}\n\nexport function collectDeliveryFailures(\n reports: ReportRow[],\n sitesById: Map<string, WebsiteRow>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const r of reports) {\n if (r.deliveryStatus !== \"bounced\" && r.deliveryStatus !== \"complained\") continue;\n const site = sitesById.get(r.siteId);\n if (!site) continue; // orphan → skip rather than render a broken link\n const complained = r.deliveryStatus === \"complained\";\n items.push({\n key: `delivery:${r.id}`,\n kind: \"delivery\",\n siteName: site.name,\n title: complained ? \"Spam complaint on a sent report\" : \"A sent report bounced\",\n url: dashboardUrl(baseUrl, site.name),\n severity: complained ? \"critical\" : \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site carrying failing Renovate PRs, read from the\n * slice-2a-persisted `renovateFailingCis` field (the nightly github-signals sweep\n * populates it). PURE. Keyed `renovate:<siteId>` so the digest and the cockpit\n * share one diff key. `metric` is the count (a rising count diffs WORSE); severity\n * `warning`. Null/0 → skipped. A site whose `githubSignalsAt` is >3 days stale (or\n * null) is ALSO skipped — a repo that stopped being swept must not show a phantom\n * count forever (`now` injected, defaults to wall-clock).\n */\nexport function collectRenovateAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n const n = s.renovateFailingCis ?? 0;\n if (n <= 0) continue;\n items.push({\n key: `renovate:${s.id}`,\n kind: \"renovate\",\n siteName: s.name,\n title: `${n} Renovate ${n === 1 ? \"PR\" : \"PRs\"} failing CI`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: n,\n });\n }\n return items;\n}\n\n/**\n * One attention item per site whose persisted default-branch CI rollup is\n * `failing` (slice 2a). PURE. `metric` 1 (binary); severity `warning`. Any other\n * state (passing/pending/none) or null is skipped. A site whose `githubSignalsAt`\n * is >3 days stale (or null) is ALSO skipped — a repo that stopped being swept must\n * not show a phantom 🔴 forever (`now` injected, defaults to wall-clock).\n */\nexport function collectCiAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (gitHubSignalsStale(s.githubSignalsAt, now)) continue;\n if (s.defaultBranchCi !== \"failing\") continue;\n items.push({\n key: `ci:${s.id}`,\n kind: \"ci\",\n siteName: s.name,\n title: \"Default-branch CI failing\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n\n/**\n * A soft-fail older than this is no longer a trustworthy CURRENT signal. Drafting\n * self-clears `analyticsSoftFailAt` on the next clean enrichment, so a non-null\n * value normally means \"errored on the most recent draft and hasn't recovered\" —\n * but a site that stopped being drafted (freq→None, deprecated) would otherwise\n * show a phantom forever. 45 days covers a monthly cadence + margin; older drops.\n */\nconst ANALYTICS_SOFT_FAIL_STALE_DAYS = 45;\n\n/**\n * One attention item per site whose last draft's GA/Search enrichment ERRORED, read\n * from the `analyticsSoftFailAt` timestamp (drafting sets it on a soft-fail, clears\n * it on a clean enrichment). PURE. Keyed `analytics:<siteId>` so the digest and the\n * cockpit share one diff key; `metric` 1 (binary), severity `warning`. A null\n * timestamp (clean, or the operator-added `Analytics soft-fail at` column absent) is\n * skipped, as is one staler than {@link ANALYTICS_SOFT_FAIL_STALE_DAYS}. On a\n * FLEET-WIDE subject outage many sites surface this at once — that breadth IS the\n * signal here; the report cron additionally emails a single concise fleet-wide alert\n * (see `assessAnalyticsAlert`). `now` injected, defaults to wall-clock.\n */\n/** A function-health sweep older than this can't confirm the CURRENT widget state.\n * The sweep is nightly, so 3 days (mirrors GITHUB_SIGNALS_STALE_DAYS) tolerates a\n * weekend of runner flakes without letting a months-old verdict drive an alarm. */\nconst TURNSTILE_WIDGET_STALE_DAYS = 3;\n\n/**\n * One CRITICAL attention item per site with `Require Turnstile` ON whose deployed\n * `/health` reports the Turnstile widget NOT configured (`turnstileWidget === \"fail\"`,\n * fresh per {@link TURNSTILE_WIDGET_STALE_DAYS}). That combination silently buckets\n * 100% of the site's real leads (token-less submissions escalate to spam_auto with\n * notify skipped) and the form-e2e probe cannot see it — testMode bypasses the gate —\n * so the operator's inbox just goes quiet. PURE. Keyed `turnstile:<siteId>`. Because\n * this is an AttentionItem it rides assignTier's items short-circuit, which sits ABOVE\n * the accepted-watch mute loop — an accept key can never silence it. A null verdict or\n * a stale sweep is NOT alarmed here; assignTier raises those as an acceptable\n * \"can't verify\" watch instead. `now` injected, defaults to wall-clock.\n */\nexport function collectTurnstileGuardrailAlerts(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n if (!s.requireTurnstile || s.turnstileWidget !== \"fail\") continue;\n const at = s.functionHealthCheckedAt;\n if (at !== null) {\n const ageMs = now.getTime() - Date.parse(at);\n // Parseable and beyond the window → stale, downgraded to assignTier's watch.\n // Unparseable (NaN) keeps the item — never silently drop a real failure.\n if (Number.isFinite(ageMs) && ageMs > TURNSTILE_WIDGET_STALE_DAYS * MS_PER_DAY) continue;\n }\n items.push({\n key: `turnstile:${s.id}`,\n kind: \"turnstile\",\n siteName: s.name,\n title:\n \"Require Turnstile is ON but /health reports no widget — real leads are being auto-bucketed\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: 1,\n });\n }\n return items;\n}\n\n/** Bounced lead notifications inside the window before a site alarms. One bounce can\n * be a transient greylist/full-mailbox blip; a repeat says the point-of-contact\n * address itself is bad (the Espada mode: apm@ bounced 4 of 8 with nothing alarming). */\nexport const NOTIFY_BOUNCE_THRESHOLD = 2;\n\n/** Window (days) the bounce count covers. Two weeks: long enough that a low-volume\n * site's second bounce still lands in the same window as its first, short enough\n * that a long-fixed address doesn't alarm forever (bounced rows never self-clear —\n * the alarm ages out as bounced submissions leave the window). */\nexport const NOTIFY_BOUNCE_WINDOW_DAYS = 14;\n\n/**\n * One CRITICAL attention item per site with >= {@link NOTIFY_BOUNCE_THRESHOLD} bounced\n * lead notifications in the last {@link NOTIFY_BOUNCE_WINDOW_DAYS} days — leads are\n * being captured but silently never reach the client, which is worse than no form at\n * all (notifyStatus \"sent\" only means Resend accepted the email; the resend-webhook\n * flips it to \"bounced\" when Resend later reports the failure, 2026-07-16). PURE:\n * takes the pre-fetched per-site counts (`countNotifyBouncedBySite`, keyed by the\n * Websites record id). Keyed `notify-bounce:<siteId>` so the digest and the cockpit\n * share one diff key; `metric` is the bounce count (a rising count diffs WORSE).\n * A site absent from the map (or below threshold) is skipped, as is a count for a\n * site id not in `sites` (orphan → never render a broken link).\n */\nexport function collectNotifyBounceAlerts(\n sites: WebsiteRow[],\n bouncedBySite: ReadonlyMap<string, number>,\n baseUrl: string,\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const n = bouncedBySite.get(s.id) ?? 0;\n if (n < NOTIFY_BOUNCE_THRESHOLD) continue;\n items.push({\n key: `notify-bounce:${s.id}`,\n kind: \"notify-bounce\",\n siteName: s.name,\n title: `${n} lead notifications bounced (${NOTIFY_BOUNCE_WINDOW_DAYS}d) — check the point-of-contact address`,\n url: dashboardUrl(baseUrl, s.name),\n severity: \"critical\",\n metric: n,\n });\n }\n return items;\n}\n\nexport function collectAnalyticsFailures(\n sites: WebsiteRow[],\n baseUrl: string,\n now: Date = new Date(),\n): AttentionItem[] {\n const items: AttentionItem[] = [];\n for (const s of sites) {\n const at = s.analyticsSoftFailAt;\n if (at === null) continue;\n const ageMs = now.getTime() - Date.parse(at);\n // Unparseable timestamp (NaN) → keep the item (don't silently drop a real failure\n // on a parse glitch); only a parseable, beyond-window age is skipped as stale.\n if (Number.isFinite(ageMs) && ageMs > ANALYTICS_SOFT_FAIL_STALE_DAYS * MS_PER_DAY) continue;\n items.push({\n key: `analytics:${s.id}`,\n kind: \"analytics\",\n siteName: s.name,\n title: \"GA/Search enrichment failing (analytics blank)\",\n url: dashboardUrl(baseUrl, s.name),\n severity: \"warning\",\n metric: 1,\n });\n }\n return items;\n}\n","// src/alerts/digest-state.ts\nimport type { FieldSet } from \"airtable\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport type { AttentionItem } from \"./attention.js\";\n\n/**\n * The persisted prior-run snapshot: stable item `key` → its last metric + the\n * date it was FIRST flagged. Lives as JSON in the single \"Digest State\" Airtable\n * row (the IO that loads/stores it — readDigestState/writeDigestState — is added\n * in component 2). `next` from diffAttention is what gets written back.\n * `exhausted` is carried only for vuln keys whose Renovate auto-fix has been\n * exhausted (absent = not exhausted; older snapshots without the field read the\n * same way).\n */\nexport type DigestSnapshot = Record<\n string,\n { metric: number; firstFlaggedAt: string; exhausted?: boolean }\n>;\n\n/**\n * PURE diff — the testable core of the hybrid \"snapshot now, mark what's new\".\n * For each current item vs the prior snapshot:\n * - key absent from prior → NEW (firstFlaggedAt = today)\n * - present and metric > prior.metric → WORSE (keep the original firstFlaggedAt)\n * - present and autoFixExhausted flipped on → WORSE (the count didn't move, but\n * \"Renovate gave up\" is an escalation — without this the digest's first mention\n * of a vuln, which is gated on exhaustion, would arrive unbadged)\n * - otherwise (equal or dropped) → STANDING (keep the original firstFlaggedAt)\n * `next` contains EXACTLY the current items' keys: resolved keys drop out, so a\n * fixed-then-recurring problem re-news correctly. Neither input is mutated.\n */\nexport function diffAttention(\n items: AttentionItem[],\n prior: DigestSnapshot,\n today: string,\n): { tagged: AttentionItem[]; next: DigestSnapshot } {\n const tagged: AttentionItem[] = [];\n const next: DigestSnapshot = {};\n for (const it of items) {\n const was = prior[it.key];\n const exhaustedFlip = it.autoFixExhausted === true && was?.exhausted !== true;\n let status: AttentionItem[\"status\"];\n let firstFlaggedAt: string;\n if (!was) {\n status = \"new\";\n firstFlaggedAt = today;\n } else if (it.metric > was.metric || exhaustedFlip) {\n status = \"worse\";\n firstFlaggedAt = was.firstFlaggedAt;\n } else {\n status = \"standing\";\n firstFlaggedAt = was.firstFlaggedAt;\n }\n tagged.push({ ...it, status });\n next[it.key] = {\n metric: it.metric,\n firstFlaggedAt,\n ...(it.autoFixExhausted === true ? { exhausted: true } : {}),\n };\n }\n return { tagged, next };\n}\n\n/** The single-row Airtable table that persists the prior digest snapshot. */\nexport const DIGEST_STATE_TABLE = \"Digest State\";\n\n/**\n * Read the persisted prior snapshot from the \"Digest State\" singleton.\n *\n * Reads the FIRST row of an unfiltered select (the table holds exactly one row;\n * the test fake does not evaluate filterByFormula, so we never rely on one). A\n * read miss (no row) OR a parse error (malformed Snapshot JSON) collapses to `{}`\n * — every key then reads as NEW once, which is safe degradation (never crashes\n * the digest).\n */\nexport async function readDigestState(base: AirtableBase): Promise<DigestSnapshot> {\n const rows: { id: string; fields: Record<string, unknown> }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id, fields: rec.fields });\n fetchNextPage();\n });\n const first = rows[0];\n if (!first) return {};\n const raw = first.fields[\"Snapshot\"];\n if (typeof raw !== \"string\") return {};\n try {\n return JSON.parse(raw) as DigestSnapshot;\n } catch {\n return {};\n }\n}\n\n/**\n * Persist the next snapshot to the \"Digest State\" singleton: get-or-create the\n * one row. If a row exists, UPDATE it (keyed by its record id); otherwise CREATE\n * one. `Snapshot` = JSON.stringify(snap); `Updated At` = the injected ISO\n * timestamp (or now). A caller that catches+logs a write failure keeps the\n * already-sent digest unaffected (next run re-news at worst).\n */\nexport async function writeDigestState(\n base: AirtableBase,\n snap: DigestSnapshot,\n updatedAt: string = new Date().toISOString(),\n): Promise<void> {\n const rows: { id: string }[] = [];\n await base(DIGEST_STATE_TABLE)\n .select({ maxRecords: 1, pageSize: 1 })\n .eachPage((records, fetchNextPage) => {\n for (const rec of records) rows.push({ id: rec.id });\n fetchNextPage();\n });\n const fields: FieldSet = {\n Snapshot: JSON.stringify(snap),\n \"Updated At\": updatedAt,\n };\n const existing = rows[0];\n if (existing) {\n await base(DIGEST_STATE_TABLE).update([{ id: existing.id, fields }]);\n } else {\n await base(DIGEST_STATE_TABLE).create([{ fields }]);\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// src/blux/grid/extract-map.ts
|
|
2
|
+
var SCRIPT_RE = /<script\b[^>]*>([\s\S]*?)<\/script>/g;
|
|
3
|
+
function findScript(html, marker) {
|
|
4
|
+
for (const m of html.matchAll(SCRIPT_RE)) {
|
|
5
|
+
const body = m[1];
|
|
6
|
+
if (body !== void 0 && marker.test(body)) return body;
|
|
7
|
+
}
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
function extractStyles(script) {
|
|
11
|
+
const at = script.indexOf("styles:[");
|
|
12
|
+
if (at === -1) return [];
|
|
13
|
+
const start = at + "styles:".length;
|
|
14
|
+
let depth = 0;
|
|
15
|
+
for (let i = start; i < script.length; i++) {
|
|
16
|
+
const ch = script[i];
|
|
17
|
+
if (ch === "[") depth++;
|
|
18
|
+
else if (ch === "]") {
|
|
19
|
+
depth--;
|
|
20
|
+
if (depth === 0) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = script.slice(start, i + 1);
|
|
23
|
+
const jsonish = raw.replace(/([{,])\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:/g, '$1"$2":');
|
|
24
|
+
const parsed = JSON.parse(jsonish);
|
|
25
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
26
|
+
} catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
var LAYER_RE = /(\w+)\s*:\s*new google\.maps\.KmlLayer\(\{([^}]*)\}\)/g;
|
|
35
|
+
function extractMapConfig(html) {
|
|
36
|
+
const init = findScript(html, /function initMap\(\)[\s\S]*new google\.maps\.Map/);
|
|
37
|
+
if (!init) return null;
|
|
38
|
+
const mountId = /getElementById\(\s*["']([^"']+)["']\s*\)/.exec(init)?.[1];
|
|
39
|
+
if (!mountId) return null;
|
|
40
|
+
const layers = [];
|
|
41
|
+
let mid;
|
|
42
|
+
for (const m of init.matchAll(LAYER_RE)) {
|
|
43
|
+
const name = m[1];
|
|
44
|
+
const args = m[2];
|
|
45
|
+
if (!name || args === void 0) continue;
|
|
46
|
+
const layerMid = /[?&]mid=([^&"']+)/.exec(args)?.[1];
|
|
47
|
+
const lid = /[?&]lid=([^&"']+)/.exec(args)?.[1];
|
|
48
|
+
if (!layerMid || !lid) continue;
|
|
49
|
+
mid ??= layerMid;
|
|
50
|
+
layers.push({
|
|
51
|
+
name,
|
|
52
|
+
lid,
|
|
53
|
+
initiallyVisible: /\bmap\s*:\s*map\b/.test(args),
|
|
54
|
+
preserveViewport: /preserveViewport\s*:\s*true/.test(args)
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
if (!mid || layers.length === 0) return null;
|
|
58
|
+
const centerM = /center\s*:\s*\{\s*lat\s*:\s*(-?[\d.]+)\s*,\s*lng\s*:\s*(-?[\d.]+)\s*\}/.exec(
|
|
59
|
+
init
|
|
60
|
+
);
|
|
61
|
+
const zoomM = /zoom\s*:\s*(\d+)/.exec(init);
|
|
62
|
+
const mountTag = new RegExp(`<[^>]*\\bid=["']${mountId}["'][^>]*>`, "i").exec(html)?.[0] ?? "";
|
|
63
|
+
const height = /height\s*:\s*([\d.]+(?:px|%|vh|vw|em|rem))/i.exec(mountTag)?.[1];
|
|
64
|
+
const toggles = extractToggles(html);
|
|
65
|
+
return {
|
|
66
|
+
mountId,
|
|
67
|
+
mid,
|
|
68
|
+
layers,
|
|
69
|
+
toggles,
|
|
70
|
+
styles: extractStyles(init),
|
|
71
|
+
...centerM?.[1] && centerM[2] ? { center: { lat: Number(centerM[1]), lng: Number(centerM[2]) } } : {},
|
|
72
|
+
...zoomM?.[1] ? { zoom: Number(zoomM[1]) } : {},
|
|
73
|
+
...height ? { height } : {},
|
|
74
|
+
...toggles.length ? { defaultToggle: 0 } : {}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
function makeIsMapMount(config) {
|
|
78
|
+
const marker = `id="${config.mountId}"`;
|
|
79
|
+
return (node) => node.kind === "raw" && node.html.includes(marker);
|
|
80
|
+
}
|
|
81
|
+
function extractToggles(html) {
|
|
82
|
+
const labels = [...html.matchAll(/map_icon_text">([^<]*)</g)].map((m) => m[1]).filter((l) => l !== void 0);
|
|
83
|
+
const click = findScript(html, /clickMap\s*=\s*\{\s*0\s*:/);
|
|
84
|
+
if (!click || labels.length === 0) return [];
|
|
85
|
+
const bodyM = /clickMap\s*=\s*(\{[\s\S]*?\}\})\s*;/.exec(click);
|
|
86
|
+
if (!bodyM?.[1]) return [];
|
|
87
|
+
const groups = [];
|
|
88
|
+
for (const g of bodyM[1].matchAll(/(\d+)\s*:\s*function\s*\(onoff\)\s*\{([^}]*)\}/g)) {
|
|
89
|
+
const idx = Number(g[1]);
|
|
90
|
+
const body = g[2] ?? "";
|
|
91
|
+
const layerNames = [
|
|
92
|
+
...new Set(
|
|
93
|
+
[...body.matchAll(/mapLayers\.(\w+)\./g)].map((m) => m[1]).filter((n) => n !== void 0)
|
|
94
|
+
)
|
|
95
|
+
];
|
|
96
|
+
const label = labels[idx];
|
|
97
|
+
if (label === void 0) return [];
|
|
98
|
+
groups[idx] = { label, layers: layerNames, panelIndex: idx };
|
|
99
|
+
}
|
|
100
|
+
return groups.length === labels.length && groups.every((g) => g !== void 0) ? groups : [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/blux/freeze/frozen-page-type.ts
|
|
104
|
+
var FROZEN_PAGE_TYPE = "frozen_page";
|
|
105
|
+
var FROZEN_PAGE_LABEL = "Frozen Page (Blux migration)";
|
|
106
|
+
function frozenPageCustomType() {
|
|
107
|
+
return {
|
|
108
|
+
id: FROZEN_PAGE_TYPE,
|
|
109
|
+
label: FROZEN_PAGE_LABEL,
|
|
110
|
+
repeatable: true,
|
|
111
|
+
json: {
|
|
112
|
+
id: FROZEN_PAGE_TYPE,
|
|
113
|
+
label: FROZEN_PAGE_LABEL,
|
|
114
|
+
format: "page",
|
|
115
|
+
repeatable: true,
|
|
116
|
+
status: true,
|
|
117
|
+
json: {
|
|
118
|
+
Main: {
|
|
119
|
+
uid: { type: "UID", config: { label: "UID", placeholder: "" } },
|
|
120
|
+
title: {
|
|
121
|
+
type: "Text",
|
|
122
|
+
config: { label: "Title", placeholder: "" }
|
|
123
|
+
},
|
|
124
|
+
slots: {
|
|
125
|
+
type: "Group",
|
|
126
|
+
config: {
|
|
127
|
+
label: "Slots",
|
|
128
|
+
fields: {
|
|
129
|
+
key: { type: "Text", config: { label: "Key", placeholder: "" } },
|
|
130
|
+
kind: {
|
|
131
|
+
type: "Select",
|
|
132
|
+
config: {
|
|
133
|
+
label: "Kind",
|
|
134
|
+
placeholder: "",
|
|
135
|
+
options: ["text", "image"]
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
text: {
|
|
139
|
+
type: "StructuredText",
|
|
140
|
+
config: {
|
|
141
|
+
label: "Text",
|
|
142
|
+
placeholder: "",
|
|
143
|
+
allowTargetBlank: true,
|
|
144
|
+
multi: "paragraph,preformatted,heading1,heading2,heading3,heading4,heading5,heading6,strong,em,hyperlink,image,embed,list-item,o-list-item,rtl"
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
image: {
|
|
148
|
+
type: "Image",
|
|
149
|
+
config: { label: "Image", constraint: {}, thumbnails: [] }
|
|
150
|
+
},
|
|
151
|
+
// Non-image media (video/pdf/etc.) — an Image field rejects a
|
|
152
|
+
// non-image asset, so those slots carry the Prismic-hosted url
|
|
153
|
+
// here instead; the render reads image.url ?? media_url.
|
|
154
|
+
media_url: {
|
|
155
|
+
type: "Text",
|
|
156
|
+
config: { label: "Media URL", placeholder: "" }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
"SEO & Metadata": {
|
|
163
|
+
meta_title: {
|
|
164
|
+
type: "Text",
|
|
165
|
+
config: {
|
|
166
|
+
label: "Meta Title",
|
|
167
|
+
placeholder: "A title of the page used for social media and search engines"
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
meta_description: {
|
|
171
|
+
type: "Text",
|
|
172
|
+
config: {
|
|
173
|
+
label: "Meta Description",
|
|
174
|
+
placeholder: "A brief summary of the page"
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
meta_image: {
|
|
178
|
+
type: "Image",
|
|
179
|
+
config: {
|
|
180
|
+
label: "Meta Image",
|
|
181
|
+
constraint: { width: 2400, height: 1260 },
|
|
182
|
+
thumbnails: []
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export {
|
|
192
|
+
extractMapConfig,
|
|
193
|
+
makeIsMapMount,
|
|
194
|
+
FROZEN_PAGE_TYPE,
|
|
195
|
+
frozenPageCustomType
|
|
196
|
+
};
|
|
197
|
+
//# sourceMappingURL=chunk-DRDAEELJ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/blux/grid/extract-map.ts","../src/blux/freeze/frozen-page-type.ts"],"sourcesContent":["// Plan 4 of docs/superpowers/specs/2026-07-08-blux-faithful-grid-slices-design.md:\n// deterministic extraction of the Blux map widget's config from the rendered\n// index.html. The initMap script carries mount/styles/layers; the toggle-chip\n// labels live in the band markup and the group logic in the site's clickMap\n// script. The Google API key lives only in the separate loader URL and is\n// deliberately NOT extracted — render uses VITE_GOOGLE_MAPS_KEY.\n\nimport type { Node } from \"./types.js\";\n\nexport type MapKmlLayer = {\n /** mapLayers key in the source script, e.g. \"Hotels\". */\n name: string;\n lid: string;\n /** Constructed with `map: map` — visible before any toggle. */\n initiallyVisible: boolean;\n /** Absent in source = false = layer fits the viewport to its KML bounds. */\n preserveViewport: boolean;\n};\n\nexport type MapToggleGroup = {\n label: string;\n layers: string[];\n /** The content panel (0-based `cagrid` cell) this chip reveals — chip index\n * `i` shows panel `i`, hiding the others; pairs with `MapConfig.defaultToggle`. */\n panelIndex: number;\n};\n\nexport type MapConfig = {\n mountId: string;\n mid: string;\n layers: MapKmlLayer[];\n toggles: MapToggleGroup[];\n /** Google Maps style rules, verbatim JSON. */\n styles: unknown[];\n center?: { lat: number; lng: number };\n zoom?: number;\n /** The mount div's inline height (e.g. \"600px\"); absent = let the render decide. */\n height?: string;\n /** Which toggle/panel is active on load (source default = the first, 0). Present\n * only when there are toggles. */\n defaultToggle?: number;\n};\n\nconst SCRIPT_RE = /<script\\b[^>]*>([\\s\\S]*?)<\\/script>/g;\n\nfunction findScript(html: string, marker: RegExp): string | null {\n for (const m of html.matchAll(SCRIPT_RE)) {\n const body = m[1];\n if (body !== undefined && marker.test(body)) return body;\n }\n return null;\n}\n\n/** Balanced-bracket slice of the JSON array starting at `styles:[`. */\nfunction extractStyles(script: string): unknown[] {\n const at = script.indexOf(\"styles:[\");\n if (at === -1) return [];\n const start = at + \"styles:\".length;\n let depth = 0;\n for (let i = start; i < script.length; i++) {\n const ch = script[i];\n if (ch === \"[\") depth++;\n else if (ch === \"]\") {\n depth--;\n if (depth === 0) {\n try {\n // The Blux styles literal uses unquoted keys — quote them for JSON.parse.\n const raw = script.slice(start, i + 1);\n const jsonish = raw.replace(/([{,])\\s*([A-Za-z_][A-Za-z0-9_.]*)\\s*:/g, '$1\"$2\":');\n const parsed: unknown = JSON.parse(jsonish);\n return Array.isArray(parsed) ? parsed : [];\n } catch {\n return [];\n }\n }\n }\n }\n return [];\n}\n\nconst LAYER_RE = /(\\w+)\\s*:\\s*new google\\.maps\\.KmlLayer\\(\\{([^}]*)\\}\\)/g;\n\nexport function extractMapConfig(html: string): MapConfig | null {\n const init = findScript(html, /function initMap\\(\\)[\\s\\S]*new google\\.maps\\.Map/);\n if (!init) return null;\n\n const mountId = /getElementById\\(\\s*[\"']([^\"']+)[\"']\\s*\\)/.exec(init)?.[1];\n if (!mountId) return null;\n\n const layers: MapKmlLayer[] = [];\n let mid: string | undefined;\n for (const m of init.matchAll(LAYER_RE)) {\n const name = m[1];\n const args = m[2];\n if (!name || args === undefined) continue;\n const layerMid = /[?&]mid=([^&\"']+)/.exec(args)?.[1];\n const lid = /[?&]lid=([^&\"']+)/.exec(args)?.[1];\n if (!layerMid || !lid) continue;\n mid ??= layerMid;\n layers.push({\n name,\n lid,\n initiallyVisible: /\\bmap\\s*:\\s*map\\b/.test(args),\n preserveViewport: /preserveViewport\\s*:\\s*true/.test(args),\n });\n }\n if (!mid || layers.length === 0) return null;\n\n const centerM = /center\\s*:\\s*\\{\\s*lat\\s*:\\s*(-?[\\d.]+)\\s*,\\s*lng\\s*:\\s*(-?[\\d.]+)\\s*\\}/.exec(\n init,\n );\n const zoomM = /zoom\\s*:\\s*(\\d+)/.exec(init);\n\n // The mount div's own inline height (`<div id=\"burbank_map\" style=\"…height:600px\">`)\n // — it lives on the inner mount, not the section, so section-style capture misses it.\n const mountTag = new RegExp(`<[^>]*\\\\bid=[\"']${mountId}[\"'][^>]*>`, \"i\").exec(html)?.[0] ?? \"\";\n const height = /height\\s*:\\s*([\\d.]+(?:px|%|vh|vw|em|rem))/i.exec(mountTag)?.[1];\n\n const toggles = extractToggles(html);\n\n return {\n mountId,\n mid,\n layers,\n toggles,\n styles: extractStyles(init),\n ...(centerM?.[1] && centerM[2]\n ? { center: { lat: Number(centerM[1]), lng: Number(centerM[2]) } }\n : {}),\n ...(zoomM?.[1] ? { zoom: Number(zoomM[1]) } : {}),\n ...(height ? { height } : {}),\n ...(toggles.length ? { defaultToggle: 0 } : {}),\n };\n}\n\n/** The classifier predicate (plan-2 `ClassifyOptions.isMapMount`): matches the\n * raw node carrying the map mount element. Mounts parse to `raw` nodes; the\n * mount id survives verbatim in the serialized html. */\nexport function makeIsMapMount(config: MapConfig): (node: Node) => boolean {\n const marker = `id=\"${config.mountId}\"`;\n return (node) => node.kind === \"raw\" && node.html.includes(marker);\n}\n\n/** Pairs the band's map_icon chip labels (DOM order) with the clickMap\n * groups (index order). Either side missing → no toggles (map still renders\n * its initially-visible layers). */\nfunction extractToggles(html: string): MapToggleGroup[] {\n const labels = [...html.matchAll(/map_icon_text\">([^<]*)</g)]\n .map((m) => m[1])\n .filter((l): l is string => l !== undefined);\n const click = findScript(html, /clickMap\\s*=\\s*\\{\\s*0\\s*:/);\n if (!click || labels.length === 0) return [];\n const bodyM = /clickMap\\s*=\\s*(\\{[\\s\\S]*?\\}\\})\\s*;/.exec(click);\n if (!bodyM?.[1]) return [];\n const groups: MapToggleGroup[] = [];\n for (const g of bodyM[1].matchAll(/(\\d+)\\s*:\\s*function\\s*\\(onoff\\)\\s*\\{([^}]*)\\}/g)) {\n const idx = Number(g[1]);\n const body = g[2] ?? \"\";\n const layerNames = [\n ...new Set(\n [...body.matchAll(/mapLayers\\.(\\w+)\\./g)]\n .map((m) => m[1])\n .filter((n): n is string => n !== undefined),\n ),\n ];\n const label = labels[idx];\n if (label === undefined) return [];\n groups[idx] = { label, layers: layerNames, panelIndex: idx };\n }\n // Non-contiguous clickMap indices would leave holes that serialize as null.\n return groups.length === labels.length && groups.every((g) => g !== undefined) ? groups : [];\n}\n","// The migration-owned `frozen_page` custom type. A frozen Blux site keeps the\n// export's own markup as a byte-faithful template (a repo file) and exposes only\n// the editable leaves — text runs and image urls — as Prismic `slots`. `blux\n// migrate-frozen` POSTs one `frozen_page` document per page, so the migration\n// tool must OWN + push the type definition (a cloned starter repo does not ship\n// it — mirrors catalog-page-type.ts). Its `json` is the FULL Prismic Custom\n// Types API definition (id/label/format/status + the tabbed model), so\n// pushCustomTypes can ship it verbatim.\nimport type { PlanCustomType } from \"../emit/plan.js\";\n\nexport const FROZEN_PAGE_TYPE = \"frozen_page\";\nconst FROZEN_PAGE_LABEL = \"Frozen Page (Blux migration)\";\n\n/** The `frozen_page` custom type: a `format:\"page\"` doc holding the editable\n * slots of a frozen Blux page. The Main tab carries uid + title (a plain Text\n * field — the frozen render substitutes it back into the template's <title>)\n * plus the repeatable `slots` group; the \"SEO & Metadata\" tab mirrors the\n * catalog type's meta fields. Each `slots` row is one editable leaf: `key` (the\n * stable document-order slot key the template token carries), `kind` (a Select\n * pinning text|image so the render knows which field to read), `text` (Rich\n * Text — filled for text slots), `image` (Image — filled for image slots).\n * Shipped on EVERY frozen migrate so the migration owns the type it emits. */\nexport function frozenPageCustomType(): PlanCustomType {\n return {\n id: FROZEN_PAGE_TYPE,\n label: FROZEN_PAGE_LABEL,\n repeatable: true,\n json: {\n id: FROZEN_PAGE_TYPE,\n label: FROZEN_PAGE_LABEL,\n format: \"page\",\n repeatable: true,\n status: true,\n json: {\n Main: {\n uid: { type: \"UID\", config: { label: \"UID\", placeholder: \"\" } },\n title: {\n type: \"Text\",\n config: { label: \"Title\", placeholder: \"\" },\n },\n slots: {\n type: \"Group\",\n config: {\n label: \"Slots\",\n fields: {\n key: { type: \"Text\", config: { label: \"Key\", placeholder: \"\" } },\n kind: {\n type: \"Select\",\n config: {\n label: \"Kind\",\n placeholder: \"\",\n options: [\"text\", \"image\"],\n },\n },\n text: {\n type: \"StructuredText\",\n config: {\n label: \"Text\",\n placeholder: \"\",\n allowTargetBlank: true,\n multi:\n \"paragraph,preformatted,heading1,heading2,heading3,heading4,heading5,heading6,strong,em,hyperlink,image,embed,list-item,o-list-item,rtl\",\n },\n },\n image: {\n type: \"Image\",\n config: { label: \"Image\", constraint: {}, thumbnails: [] },\n },\n // Non-image media (video/pdf/etc.) — an Image field rejects a\n // non-image asset, so those slots carry the Prismic-hosted url\n // here instead; the render reads image.url ?? media_url.\n media_url: {\n type: \"Text\",\n config: { label: \"Media URL\", placeholder: \"\" },\n },\n },\n },\n },\n },\n \"SEO & Metadata\": {\n meta_title: {\n type: \"Text\",\n config: {\n label: \"Meta Title\",\n placeholder: \"A title of the page used for social media and search engines\",\n },\n },\n meta_description: {\n type: \"Text\",\n config: {\n label: \"Meta Description\",\n placeholder: \"A brief summary of the page\",\n },\n },\n meta_image: {\n type: \"Image\",\n config: {\n label: \"Meta Image\",\n constraint: { width: 2400, height: 1260 },\n thumbnails: [],\n },\n },\n },\n },\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
} from "./chunk-XKOZUVK2.js";
|
|
22
22
|
import {
|
|
23
23
|
runAudits
|
|
24
|
-
} from "./chunk-
|
|
24
|
+
} from "./chunk-3U3IGVAQ.js";
|
|
25
25
|
import {
|
|
26
26
|
siteLabel
|
|
27
27
|
} from "./chunk-XXTZBPUY.js";
|
|
@@ -143,4 +143,4 @@ export {
|
|
|
143
143
|
DEFAULT_INIT_STEPS,
|
|
144
144
|
init
|
|
145
145
|
};
|
|
146
|
-
//# sourceMappingURL=chunk-
|
|
146
|
+
//# sourceMappingURL=chunk-DYKG53IR.js.map
|
|
@@ -5,12 +5,12 @@ import {
|
|
|
5
5
|
import {
|
|
6
6
|
isProbablyEmail,
|
|
7
7
|
parseAddresses
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-4VAUSRSS.js";
|
|
9
9
|
import {
|
|
10
10
|
gatingHealth,
|
|
11
11
|
isSendOverridden,
|
|
12
12
|
listAllReports
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-MNE374CZ.js";
|
|
14
14
|
import {
|
|
15
15
|
listWebsites,
|
|
16
16
|
siteSlug
|
|
@@ -339,4 +339,4 @@ export {
|
|
|
339
339
|
preflight,
|
|
340
340
|
approveBlockers
|
|
341
341
|
};
|
|
342
|
-
//# sourceMappingURL=chunk-
|
|
342
|
+
//# sourceMappingURL=chunk-JTRTG5CU.js.map
|
|
@@ -797,52 +797,6 @@ async function findReportByPeriod(base, siteId, reportType, period) {
|
|
|
797
797
|
return rows.find((r) => r.siteId === siteId) ?? null;
|
|
798
798
|
}
|
|
799
799
|
|
|
800
|
-
// src/reports/airtable/attachments.ts
|
|
801
|
-
function looksLikeHtml(bytes) {
|
|
802
|
-
const start = bytes[0] === 239 && bytes[1] === 187 && bytes[2] === 191 ? 3 : 0;
|
|
803
|
-
const head = Buffer.from(bytes.slice(start, start + 64)).toString("ascii").replace(/^[\s]+/, "").toLowerCase();
|
|
804
|
-
return head.startsWith("<!doctype html") || head.startsWith("<html") || head.startsWith("<head");
|
|
805
|
-
}
|
|
806
|
-
async function fetchAttachmentBytes(url) {
|
|
807
|
-
const res = await fetch(url);
|
|
808
|
-
if (!res.ok) {
|
|
809
|
-
throw new Error(
|
|
810
|
-
`Failed to fetch Airtable attachment ${res.status} ${res.statusText} (url=${url})`
|
|
811
|
-
);
|
|
812
|
-
}
|
|
813
|
-
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
|
|
814
|
-
const ab = await res.arrayBuffer();
|
|
815
|
-
const bytes = new Uint8Array(ab);
|
|
816
|
-
const isImageType = contentType.toLowerCase().startsWith("image/");
|
|
817
|
-
if (!isImageType && looksLikeHtml(bytes)) {
|
|
818
|
-
throw new Error(
|
|
819
|
-
`Airtable attachment did not return image data (content-type="${contentType}", body looks like an HTML page \u2014 the signed URL may have expired) (url=${url})`
|
|
820
|
-
);
|
|
821
|
-
}
|
|
822
|
-
return { bytes, contentType };
|
|
823
|
-
}
|
|
824
|
-
async function uploadAttachment(recordId, fieldName, body, filename, contentType) {
|
|
825
|
-
const apiKey = process.env.AIRTABLE_PAT;
|
|
826
|
-
const baseId = process.env.AIRTABLE_BASE_ID;
|
|
827
|
-
if (!apiKey || !baseId) {
|
|
828
|
-
throw new Error("AIRTABLE_PAT and AIRTABLE_BASE_ID must be set");
|
|
829
|
-
}
|
|
830
|
-
const base64 = typeof body === "string" ? Buffer.from(body, "utf-8").toString("base64") : Buffer.from(body).toString("base64");
|
|
831
|
-
const payload = { contentType, file: base64, filename };
|
|
832
|
-
const url = `https://content.airtable.com/v0/${baseId}/${recordId}/${encodeURIComponent(fieldName)}/uploadAttachment`;
|
|
833
|
-
const res = await fetch(url, {
|
|
834
|
-
method: "POST",
|
|
835
|
-
headers: {
|
|
836
|
-
Authorization: `Bearer ${apiKey}`,
|
|
837
|
-
"Content-Type": "application/json"
|
|
838
|
-
},
|
|
839
|
-
body: JSON.stringify(payload)
|
|
840
|
-
});
|
|
841
|
-
if (!res.ok) {
|
|
842
|
-
throw new Error(`Airtable upload failed: ${res.status} ${res.statusText} ${await res.text()}`);
|
|
843
|
-
}
|
|
844
|
-
}
|
|
845
|
-
|
|
846
800
|
export {
|
|
847
801
|
resolveCopy,
|
|
848
802
|
CHECK_CID,
|
|
@@ -865,8 +819,6 @@ export {
|
|
|
865
819
|
listAllReports,
|
|
866
820
|
listReportsForSite,
|
|
867
821
|
stampSent,
|
|
868
|
-
findReportByPeriod
|
|
869
|
-
fetchAttachmentBytes,
|
|
870
|
-
uploadAttachment
|
|
822
|
+
findReportByPeriod
|
|
871
823
|
};
|
|
872
|
-
//# sourceMappingURL=chunk-
|
|
824
|
+
//# sourceMappingURL=chunk-MNE374CZ.js.map
|