@reddoorla/maintenance 0.80.0 → 0.81.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/{blux-2P4FGCUL.js → blux-EATHOHKK.js} +2 -2
- package/dist/{spam-classifier-VG4ICIWS.js → chunk-SNSTJLQW.js} +4 -3
- package/dist/cli/bin.js +4 -4
- package/dist/{digest-N232AYVG.js → digest-44QNZZKM.js} +1 -1
- package/dist/{forms-notify-target-ZDQUEEEY.js → forms-notify-target-CL3HRVKN.js} +6 -1
- package/dist/forms-notify-target-CL3HRVKN.js.map +1 -0
- package/dist/{freeze-Y5OJFOST.js → freeze-Z7JZTQJS.js} +47 -7
- package/dist/freeze-Z7JZTQJS.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/{report-BGKMN5GU.js → report-MLCIIML5.js} +2 -2
- package/dist/spam-classifier-XLBFCO7G.js +15 -0
- package/dist/spam-classifier-XLBFCO7G.js.map +1 -0
- package/dist/{submissions-SURN7BV5.js → submissions-RAYGOASP.js} +3 -3
- package/package.json +1 -1
- package/dist/forms-notify-target-ZDQUEEEY.js.map +0 -1
- package/dist/freeze-Y5OJFOST.js.map +0 -1
- /package/dist/{blux-2P4FGCUL.js.map → blux-EATHOHKK.js.map} +0 -0
- /package/dist/{spam-classifier-VG4ICIWS.js.map → chunk-SNSTJLQW.js.map} +0 -0
- /package/dist/{digest-N232AYVG.js.map → digest-44QNZZKM.js.map} +0 -0
- /package/dist/{report-BGKMN5GU.js.map → report-MLCIIML5.js.map} +0 -0
- /package/dist/{submissions-SURN7BV5.js.map → submissions-RAYGOASP.js.map} +0 -0
|
@@ -4630,7 +4630,7 @@ skipped (no html): ${missing.map((d) => d.where).join(", ")}` : "") + faviconLin
|
|
|
4630
4630
|
}
|
|
4631
4631
|
if (action === "freeze") {
|
|
4632
4632
|
if (!dir) return { output: "blux freeze needs a Blux export directory.", code: 1 };
|
|
4633
|
-
const { freezeSite, emitFrozen } = await import("./freeze-
|
|
4633
|
+
const { freezeSite, emitFrozen } = await import("./freeze-Z7JZTQJS.js");
|
|
4634
4634
|
const { emitFavicon } = await import("./favicon-SRHSJZ62.js");
|
|
4635
4635
|
const site = (opts.site ?? basename(dir)).replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
4636
4636
|
const outDir = opts.out ?? join(dir, "frozen-out");
|
|
@@ -4767,4 +4767,4 @@ ${lines.join("\n")}`,
|
|
|
4767
4767
|
export {
|
|
4768
4768
|
runBluxCommand
|
|
4769
4769
|
};
|
|
4770
|
-
//# sourceMappingURL=blux-
|
|
4770
|
+
//# sourceMappingURL=blux-EATHOHKK.js.map
|
|
@@ -207,11 +207,12 @@ function classifySpam(input) {
|
|
|
207
207
|
}
|
|
208
208
|
return { score, reasons };
|
|
209
209
|
}
|
|
210
|
+
|
|
210
211
|
export {
|
|
212
|
+
SPAM_THRESHOLD,
|
|
213
|
+
SPAM_KEYWORDS,
|
|
211
214
|
BUYER_KEYWORDS,
|
|
212
215
|
DISPOSABLE_EMAIL_DOMAINS,
|
|
213
|
-
SPAM_KEYWORDS,
|
|
214
|
-
SPAM_THRESHOLD,
|
|
215
216
|
classifySpam
|
|
216
217
|
};
|
|
217
|
-
//# sourceMappingURL=
|
|
218
|
+
//# sourceMappingURL=chunk-SNSTJLQW.js.map
|
package/dist/cli/bin.js
CHANGED
|
@@ -236,7 +236,7 @@ cli.command(
|
|
|
236
236
|
"Show who a form submission would email, and optionally flip the pre-launch guard (read-back confirmed)."
|
|
237
237
|
).option("--set <on|off>", "Flip the guard: on = route to operator, off = restore.").option("--restore <status>", "Status to restore with --set off. Required; never inferred.").action(
|
|
238
238
|
async (site, opts) => runOrExit(
|
|
239
|
-
async () => (await import("../forms-notify-target-
|
|
239
|
+
async () => (await import("../forms-notify-target-CL3HRVKN.js")).runFormsNotifyTargetCommand(
|
|
240
240
|
site,
|
|
241
241
|
opts
|
|
242
242
|
),
|
|
@@ -281,7 +281,7 @@ cli.command("report [site]", "Draft or send maintenance/testing reports.").optio
|
|
|
281
281
|
"Email the operator one daily digest of reports ready for approval (skips when empty)."
|
|
282
282
|
).action(
|
|
283
283
|
async (site, opts) => runOrExit(
|
|
284
|
-
async () => (await import("../report-
|
|
284
|
+
async () => (await import("../report-MLCIIML5.js")).runReportCommand(site, opts),
|
|
285
285
|
opts
|
|
286
286
|
)
|
|
287
287
|
);
|
|
@@ -322,7 +322,7 @@ cli.command(
|
|
|
322
322
|
"Write the re-buckets: status \u2192 spam_auto, spam_score/spam_reason replaced with the new verdict + 'retro-rescore' marker."
|
|
323
323
|
).action(
|
|
324
324
|
async (action, opts) => runOrExit(
|
|
325
|
-
async () => (await import("../submissions-
|
|
325
|
+
async () => (await import("../submissions-RAYGOASP.js")).runSubmissionsCommand(action, opts),
|
|
326
326
|
opts
|
|
327
327
|
)
|
|
328
328
|
);
|
|
@@ -357,7 +357,7 @@ cli.command(
|
|
|
357
357
|
"freeze: JSON declaring slots the template has no token for (video poster, a panel rebuilt from a baked image). Keys must start with 'x.'"
|
|
358
358
|
).action(
|
|
359
359
|
async (action, dir, opts) => runOrExit(
|
|
360
|
-
async () => (await import("../blux-
|
|
360
|
+
async () => (await import("../blux-EATHOHKK.js")).runBluxCommand(action, dir, opts),
|
|
361
361
|
opts
|
|
362
362
|
)
|
|
363
363
|
);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import "./chunk-SNSTJLQW.js";
|
|
1
2
|
import "./chunk-SEEA6RXF.js";
|
|
3
|
+
import "./chunk-2OP4JKIQ.js";
|
|
2
4
|
import {
|
|
3
5
|
listWebsites,
|
|
4
6
|
siteSlug,
|
|
@@ -10,6 +12,9 @@ import {
|
|
|
10
12
|
} from "./chunk-HN6I2XWI.js";
|
|
11
13
|
import "./chunk-6VAL7XJT.js";
|
|
12
14
|
|
|
15
|
+
// src/forms/ingest.ts
|
|
16
|
+
var DUPLICATE_WINDOW_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
17
|
+
|
|
13
18
|
// src/forms/notify.ts
|
|
14
19
|
var OPERATOR_FALLBACK = "tucker@reddoorla.com";
|
|
15
20
|
function operatorEmail() {
|
|
@@ -250,4 +255,4 @@ export {
|
|
|
250
255
|
formatNotifyTarget,
|
|
251
256
|
runFormsNotifyTargetCommand
|
|
252
257
|
};
|
|
253
|
-
//# sourceMappingURL=forms-notify-target-
|
|
258
|
+
//# sourceMappingURL=forms-notify-target-CL3HRVKN.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/forms/ingest.ts","../src/forms/notify.ts","../src/recipes/forms-notify-target.ts","../src/cli/commands/forms-notify-target.ts"],"sourcesContent":["import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type {\n SubmissionRow,\n SubmissionInput,\n NotifyStatus,\n SubmissionStatus,\n} from \"../reports/submission-row.js\";\nimport { normalizeSubmission, type NormalizedSubmission } from \"./payload.js\";\nimport type { TurnstileOutcome, TurnstileVerification } from \"./turnstile.js\";\nimport { SPAM_THRESHOLD, type SpamVerdict } from \"./spam-classifier.js\";\n\n/** How far back the duplicate-spray AND repeat-sender lookups scan. Sprays arrive in\n * bursts but bots also re-run for weeks; 30 days catches repeats without an unbounded\n * table scan. */\nconst DUPLICATE_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;\n\nexport type IngestDeps = {\n getWebsiteBySlug: (slug: string) => Promise<WebsiteRow | null>;\n createSubmission: (input: SubmissionInput) => Promise<SubmissionRow>;\n notify: (\n site: WebsiteRow,\n submission: SubmissionRow,\n ) => Promise<{ status: NotifyStatus; messageId: string | null }>;\n stampNotified: (id: string, status: NotifyStatus, messageId: string | null) => Promise<void>;\n now: () => Date;\n /** Optional spam classifier. When present, its verdict drives the stored\n * status/score/reason; absent → every submission scores 0 (fail-open, clean).\n * Injected so the classifier stays a pure, transport-agnostic leaf. */\n classifySpam?: (n: NormalizedSubmission, turnstile: TurnstileOutcome) => SpamVerdict;\n /** Optional: fleet-wide duplicate/near-duplicate body lookup since `since`. Drives\n * the duplicate-spray signal — the same pitch blasted to many sites shows up as\n * identical (`exact`) or template-substituted (`similar`, token-set Jaccard) bodies.\n * Statuses let the retro re-bucket pick still-'new' prior copies; siteId+email let\n * the caller exempt a genuine same-sender resubmission on the same site. Absent →\n * the check is skipped (fail-open clean). */\n findRecentDuplicates?: (\n message: string,\n since: Date,\n ) => Promise<{\n exact: Array<{ id: string; status: string; siteId: string; email: string }>;\n similar: Array<{ id: string; status: string; siteId: string; email: string }>;\n }>;\n /** Optional: recent non-newsletter submissions from the same email since `since`.\n * Drives the cross-site repeat-sender signal — the fleet's sites are unrelated\n * businesses, so one address contacting 2+ of them within the window is a\n * solicitation tell. Absent → the check is skipped (fail-open clean). */\n listRecentSubmissionsForEmail?: (\n email: string,\n since: Date,\n ) => Promise<Array<{ id: string; siteId: string; status: string }>>;\n /** Optional: retroactively re-bucket prior still-'new' rows once a later copy\n * identifies a spray (the first copy is delivered by design). Implementations\n * must only touch status='new' rows. Best-effort — failures are swallowed. */\n retroBucket?: (ids: string[], reason: string) => Promise<void>;\n /** Optional: POST a newsletter submission to the site's configured webhook\n * (best-effort). Omitted in tests/handlers that don't need it. */\n forwardNewsletter?: (\n webhookUrl: string,\n submission: SubmissionRow,\n site: WebsiteRow,\n ) => Promise<{ ok: boolean; status: number }>;\n /** Optional: add a newsletter submitter to the site's Mailchimp audience\n * (best-effort). Omitted where unused. `tagged:false` reports a member who was\n * upserted but could NOT be tagged — recorded separately, see FANOUT below. */\n addToMailchimp?: (\n site: WebsiteRow,\n submission: SubmissionRow,\n ) => Promise<{ ok: boolean; status: number; tagged?: boolean }>;\n /** Optional: persist the newsletter fan-out outcome on the row (see FANOUT below).\n * Best-effort — failures are swallowed. Absent → the fan-out still runs, just\n * unrecorded (older callers and most tests). */\n stampFanout?: (id: string, fanoutStatus: string) => Promise<void>;\n /** Optional: run the best-effort tail (notify → stamp → fan-out) AFTER the\n * response instead of before it. Production passes Netlify's\n * `context.waitUntil`, which keeps the invocation alive until the promise\n * settles. Absent → the tail runs inline, exactly as it always did; this is a\n * latency change, never a behavioural one.\n *\n * Why it matters: the submitting site waits on this response under an abort\n * budget (forms/client.ts `INGEST_TIMEOUT_MS`). Once the row is written the\n * lead is captured, so every further second spent on email/webhook providers\n * is time in which a captured lead can still be reported to the visitor as a\n * failed submission — which is exactly what happened to 1836dig on\n * 2026-08-03. */\n defer?: (work: Promise<unknown>) => void;\n};\n\nexport type IngestResult =\n | { status: \"accepted\"; submissionId: string; notifyStatus: NotifyStatus | \"deferred\" }\n | { status: \"rejected\"; reason: \"invalid-payload\"; errors: string[] }\n | { status: \"unknown-site\"; slug: string };\n\nexport type ScreenOutDeps = {\n getWebsiteBySlug: (slug: string) => Promise<WebsiteRow | null>;\n recordScreenOut: (siteId: string, reason: \"honeypot\" | \"too-fast\") => Promise<void>;\n};\n\nexport type ScreenOutResult =\n { status: \"recorded\"; slug: string } | { status: \"unknown-site\"; slug: string };\n\n/** Extract the screen-out reason from a beacon body, or null if it isn't one.\n * The beacon key is the reserved `_screenOut` (underscore-namespaced like `_meta`,\n * see payload.ts). The bare `screenOut` key is the DEPRECATED pre-namespacing wire\n * shape — sites run older package versions for a while, so the central receiver\n * keeps accepting it. `_screenOut` wins when both are present. */\nexport function parseScreenOut(payload: unknown): \"honeypot\" | \"too-fast\" | null {\n if (!payload || typeof payload !== \"object\") return null;\n const body = payload as Record<string, unknown>;\n const v = \"_screenOut\" in body ? body[\"_screenOut\"] : body[\"screenOut\"];\n return v === \"honeypot\" || v === \"too-fast\" ? v : null;\n}\n\n/** Resolve the site and record a caught screen-out. Best-effort: a record failure is\n * the caller's to swallow — a missed count must never error a screened bot. */\nexport async function ingestScreenOut(\n deps: ScreenOutDeps,\n slug: string,\n reason: \"honeypot\" | \"too-fast\",\n): Promise<ScreenOutResult> {\n const site = await deps.getWebsiteBySlug(slug);\n if (!site) return { status: \"unknown-site\", slug };\n await deps.recordScreenOut(site.id, reason);\n return { status: \"recorded\", slug };\n}\n\n/**\n * Normalize → resolve site → persist → notify → stamp. The order is load-bearing:\n * the row is written BEFORE notify, and notify/stamp failures are swallowed (logged)\n * so a Resend or Airtable-write-back hiccup can never turn an accepted lead into a 502.\n */\nexport async function ingestSubmission(\n deps: IngestDeps,\n slug: string,\n rawPayload: unknown,\n turnstileInput: TurnstileOutcome | TurnstileVerification = \"unverifiable\",\n): Promise<IngestResult> {\n // Accept either the full verification (the production handler) or a bare outcome\n // string (older callers and virtually every test) — a string simply has no solved-\n // hostname to check. Normalized once so the body has a single shape to reason about.\n const verification: TurnstileVerification =\n typeof turnstileInput === \"string\"\n ? { outcome: turnstileInput, hostname: null }\n : turnstileInput;\n const turnstile = verification.outcome;\n const normalized = normalizeSubmission(rawPayload);\n if (!normalized.ok) {\n return { status: \"rejected\", reason: \"invalid-payload\", errors: normalized.errors };\n }\n const site = await deps.getWebsiteBySlug(slug);\n if (!site) return { status: \"unknown-site\", slug };\n\n // Synthetic end-to-end probe (the `form-e2e` fleet audit). A central-only marker\n // on the payload routes the submission away from EVERY real sink: no row is\n // persisted, no spam classification, no operator/autoresponder email, no\n // newsletter fan-out — and Turnstile enforcement is bypassed (the short-circuit\n // sits before that check). The marker therefore grants a bot NO benefit: it\n // reaches no inbox/DB/webhook, so skipping Turnstile costs nothing. This\n // suppression MUST be central — the submitting site alone cannot stop the real\n // inbox firing. Validity + site resolution are still enforced above (a junk body\n // is rejected, an unknown slug is unknown-site), so the marker can't smuggle\n // anything through. Return accepted+skipped so the probe asserts success.\n if (isTestMode(rawPayload)) {\n return { status: \"accepted\", submissionId: \"test-mode\", notifyStatus: \"skipped\" };\n }\n\n const n = normalized.value;\n\n // Fold the content signals + the Turnstile verdict into ONE spam decision.\n // Absent classifier → treat as clean (fail-open). A throwing classifier is\n // swallowed the same way — a bug in the heuristic must never turn an\n // otherwise-good lead into a 500; it just scores clean. On a `requireTurnstile`\n // site, both an ACTUAL \"fail\" (forged token) AND an \"absent\" token escalate to\n // auto-spam regardless of score — a real browser that renders the widget ALWAYS\n // sends a token, so a completely missing one is the direct-POST-bot signature. A\n // present-but-\"unverifiable\" token (expired/duplicate — a real browser DID render\n // the widget) stays neutral, as does anything on a site that hasn't opted in.\n let verdict: SpamVerdict = { score: 0, reasons: [] };\n if (deps.classifySpam) {\n try {\n verdict = deps.classifySpam(n, turnstile);\n } catch (err) {\n console.error(`[ingest] classifySpam threw: ${String(err)}`);\n }\n }\n const reasons = [...verdict.reasons];\n let status: SubmissionStatus = verdict.score >= SPAM_THRESHOLD ? \"spam_auto\" : \"new\";\n if (site.requireTurnstile && (turnstile === \"fail\" || turnstile === \"absent\")) {\n status = \"spam_auto\";\n const reason = turnstile === \"fail\" ? \"turnstile-required-failed\" : \"turnstile-required-absent\";\n if (!reasons.includes(reason)) reasons.push(reason);\n } else if (\n site.requireTurnstile &&\n turnstile === \"pass\" &&\n verification.hostname !== null &&\n !turnstileHostnameAcceptable(verification.hostname, site.url)\n ) {\n // Defense-in-depth vs token farming: the token PASSED, but Cloudflare says it was\n // solved on a host unrelated to this site. Cloudflare domain-binds sitekeys, so\n // this only trips on a loose widget allowlist — still, a passing token from a\n // foreign host accompanying a gated site's submission is not a real visitor. A\n // null hostname (older responses) or an unparseable site.url skips the check\n // entirely (fail-open); subdomains of the site's host (www., previews) match.\n status = \"spam_auto\";\n if (!reasons.includes(\"turnstile-required-hostname\"))\n reasons.push(\"turnstile-required-hostname\");\n }\n\n // Cross-site repeat-sender signal: the fleet's sites are UNRELATED businesses\n // (art gallery, realtor, home builder…), so the same email writing to 2+ different\n // sites within the window is a solicitation tell no single-message scan can see.\n // Same-site repeats alone must NOT trigger — those are genuine follow-ups. The\n // operator explicitly accepts overblocking here (spam_auto is recoverable). Prior\n // still-'new' rows on OTHER sites are retro-bucketed too: the first copy of a\n // spray is delivered by design, so the copy that identifies it also cleans the\n // queue. The scan runs EVEN WHEN the incoming copy is already spam_auto — after the\n // classifier began catching whole spray families, an already-bucketed copy was\n // skipping this scan and the retro cleanup never fired for exactly the sprays it\n // was built for; escalation/reason still only applies when not already spam.\n // Best-effort — a lookup failure never blocks a lead.\n if (n.formType !== \"newsletter\" && deps.listRecentSubmissionsForEmail) {\n try {\n const since = new Date(deps.now().getTime() - DUPLICATE_WINDOW_MS);\n const prior = await deps.listRecentSubmissionsForEmail(n.email, since);\n const otherSites = prior.filter((p) => p.siteId !== site.id);\n if (otherSites.length > 0) {\n if (status !== \"spam_auto\") {\n status = \"spam_auto\";\n if (!reasons.includes(\"repeat-sender\")) reasons.push(\"repeat-sender\");\n }\n const retroIds = otherSites.filter((p) => p.status === \"new\").map((p) => p.id);\n if (deps.retroBucket && retroIds.length > 0) {\n try {\n await deps.retroBucket(retroIds, \"retro:repeat-sender\");\n } catch (err) {\n console.error(`[ingest] retroBucket (repeat-sender) threw: ${String(err)}`);\n }\n }\n }\n } catch (err) {\n console.error(`[ingest] listRecentSubmissionsForEmail threw: ${String(err)}`);\n }\n }\n\n // Duplicate/spray signal: the same pitch blasted across the fleet (or repeated) is\n // a bot tell a lone content scan can't see. An identical body ('exact') OR a\n // template-substituted near-copy ('similar' — the live dog-harness spray differed\n // only in greeting; SEO sprays swap the target domain per site) → auto-spam\n // (recoverable). Prior still-'new' copies are retro-bucketed for the same reason\n // as above, and the scan runs even when the incoming copy is already spam_auto\n // (see the repeat-sender note). GENUINE-RESUBMIT EXEMPTION: a match from the SAME\n // sender on the SAME site is a real visitor double-submitting or resending after\n // silence, not spray evidence — without this exemption the resend was silently\n // bucketed AND retro-flipped the delivered original, vanishing an active lead\n // with no signal. Only cross-site or different-sender copies count. Guarded:\n // non-newsletter forms with a real body; the db helper ignores short bodies /\n // small token sets. Best-effort — a lookup failure never blocks a lead.\n if (n.formType !== \"newsletter\" && n.message !== undefined && deps.findRecentDuplicates) {\n try {\n const since = new Date(deps.now().getTime() - DUPLICATE_WINDOW_MS);\n const dupes = await deps.findRecentDuplicates(n.message, since);\n const senderEmail = n.email.trim().toLowerCase();\n const isOwnResend = (m: { siteId: string; email: string }) =>\n m.siteId === site.id && m.email.trim().toLowerCase() === senderEmail;\n const exact = dupes.exact.filter((m) => !isOwnResend(m));\n const similar = dupes.similar.filter((m) => !isOwnResend(m));\n const reason =\n exact.length > 0 ? \"duplicate-body\" : similar.length > 0 ? \"similar-body\" : null;\n if (reason !== null) {\n if (status !== \"spam_auto\") {\n status = \"spam_auto\";\n if (!reasons.includes(reason)) reasons.push(reason);\n }\n const retroIds = [...exact, ...similar].filter((m) => m.status === \"new\").map((m) => m.id);\n if (deps.retroBucket && retroIds.length > 0) {\n try {\n await deps.retroBucket(retroIds, \"retro:duplicate-body\");\n } catch (err) {\n console.error(`[ingest] retroBucket (duplicate-body) threw: ${String(err)}`);\n }\n }\n }\n } catch (err) {\n console.error(`[ingest] findRecentDuplicates threw: ${String(err)}`);\n }\n }\n const spamReason = reasons.length > 0 ? reasons.join(\",\") : null;\n\n const row = await deps.createSubmission({\n siteId: site.id,\n formType: n.formType,\n name: n.name,\n email: n.email,\n extraFields: n.extraFields,\n status,\n spamScore: verdict.score,\n spamReason,\n // Optional fields spread only when present — exactOptionalPropertyTypes\n // forbids assigning `undefined` to an optional `phone?: string` etc.\n ...(n.phone !== undefined ? { phone: n.phone } : {}),\n ...(n.message !== undefined ? { message: n.message } : {}),\n ...(n.sourceUrl !== undefined ? { sourceUrl: n.sourceUrl } : {}),\n ...(n.utm !== undefined ? { utm: n.utm } : {}),\n submittedAt: deps.now(),\n });\n\n // Auto-spam (and operator-marked spam) is captured but silent: no operator\n // email, no autoresponder, no newsletter fan-out. Skip notify entirely and\n // record the honest \"skipped\" stamp. notify.ts also nulls both builders for\n // these statuses (defense in depth), but short-circuiting here means the\n // injected notify dep is never even invoked for a spam row.\n const isSpam = row.status === \"spam_auto\" || row.status === \"spam\";\n\n // ── best-effort tail ───────────────────────────────────────────────────────\n // notify → stamp → newsletter fan-out. NOTHING below can cost the lead: the\n // row is already durable, and every step here is swallowed+logged rather than\n // allowed to turn an accepted lead into a 502. That is precisely why it does\n // not belong on the visitor's critical path — the submitting site waits on\n // this response under an abort budget (forms/client.ts INGEST_TIMEOUT_MS), so\n // Resend latency used to be able to make a captured lead read as a failed\n // submission in the visitor's browser (1836dig, 2026-08-03).\n //\n // `deps.defer` (Netlify's `context.waitUntil` in production) runs the tail\n // AFTER the response goes out. Absent — every test, and any runtime without\n // post-response execution — it runs inline exactly as before, so this is a\n // latency change and never a behavioural one.\n const runTail = async (): Promise<NotifyStatus> => {\n let notify: { status: NotifyStatus; messageId: string | null };\n if (isSpam) {\n notify = { status: \"skipped\", messageId: null };\n } else {\n try {\n notify = await deps.notify(site, row);\n } catch (err) {\n console.error(`[ingest] notify threw: ${String(err)}`);\n notify = { status: \"failed\", messageId: null };\n }\n }\n try {\n await deps.stampNotified(row.id, notify.status, notify.messageId);\n } catch (err) {\n console.error(`[ingest] stampNotified failed: ${String(err)}`);\n }\n\n await runFanout();\n return notify.status;\n };\n\n // Newsletter fan-out: each configured destination fires best-effort and is\n // swallowed+logged — the lead is already persisted; never turn it into a 502.\n // Guarded on the row status so a spam signup is never forwarded to a site\n // webhook or added to a Mailchimp audience.\n //\n // FANOUT provenance (2026-07-31): the outcome is ALSO recorded on the row, one\n // `<destination>:<outcome>` token per attempt, comma-joined in attempt order —\n // `webhook:ok,mailchimp:401`. Outcome is `ok`, the HTTP status of a non-2xx\n // (`0` for a network error / misconfiguration, see mailchimp.ts), or `threw`.\n // `mailchimp-tags:failed` is appended when the member was upserted but the tag\n // write did not land. Until this existed the results were console.error-only, so\n // an expired Mailchimp key stopped signups reaching the audience while the row\n // still read `notify=sent` — healthy-looking, with the failure visible nowhere the\n // operator ever looks. Nothing attempted → nothing stamped (null stays \"no\n // destination configured\", NOT \"we tried and lost the answer\"). The stamp is\n // best-effort like everything else here: a provenance write must never cost a lead.\n // A const arrow, not a hoisted declaration: `site` is narrowed non-null above,\n // and that narrowing only survives into a closure created after it. Declared\n // below runTail (which calls it) but before either call site, so there is no TDZ.\n const runFanout = async (): Promise<void> => {\n const fanout: string[] = [];\n if (n.formType === \"newsletter\" && !isSpam) {\n if (site.newsletterWebhook && deps.forwardNewsletter) {\n try {\n const fwd = await deps.forwardNewsletter(site.newsletterWebhook, row, site);\n if (!fwd.ok)\n console.error(`[ingest] newsletter webhook → ${fwd.status} for ${site.name}`);\n fanout.push(fwd.ok ? \"webhook:ok\" : `webhook:${fwd.status}`);\n } catch (err) {\n console.error(`[ingest] newsletter webhook threw: ${String(err)}`);\n fanout.push(\"webhook:threw\");\n }\n }\n if (site.mailchimpApiKey && site.mailchimpAudienceId && deps.addToMailchimp) {\n try {\n const mc = await deps.addToMailchimp(site, row);\n if (!mc.ok) console.error(`[ingest] mailchimp add → ${mc.status} for ${site.name}`);\n fanout.push(mc.ok ? \"mailchimp:ok\" : `mailchimp:${mc.status}`);\n // Only meaningful alongside a successful add — a failed add was never tagged\n // either, and one token per real problem keeps the row readable.\n if (mc.ok && mc.tagged === false) {\n console.error(`[ingest] mailchimp tags not applied for ${site.name}`);\n fanout.push(\"mailchimp-tags:failed\");\n }\n } catch (err) {\n console.error(`[ingest] mailchimp add threw: ${String(err)}`);\n fanout.push(\"mailchimp:threw\");\n }\n }\n }\n if (fanout.length > 0 && deps.stampFanout) {\n try {\n await deps.stampFanout(row.id, fanout.join(\",\"));\n } catch (err) {\n console.error(`[ingest] stampFanout failed: ${String(err)}`);\n }\n }\n };\n\n if (deps.defer) {\n // A rejection escaping here would land as an unhandled rejection in the\n // platform's post-response context, where nothing is left to catch it — so\n // the boundary is made unrejectable. Everything inside is already swallowed;\n // this guards the shape, not a known throw.\n deps.defer(\n runTail().catch((err) => {\n console.error(`[ingest] deferred tail threw: ${String(err)}`);\n }),\n );\n // The lead is durable and the response can go out now. The real notify\n // outcome lands on the ROW via stampNotified moments later — this field is\n // only ever read by callers wanting the in-request outcome, which by\n // definition does not exist once the tail is deferred.\n return { status: \"accepted\", submissionId: row.id, notifyStatus: \"deferred\" };\n }\n return { status: \"accepted\", submissionId: row.id, notifyStatus: await runTail() };\n}\n\n/** True when `a` and `b` are the same host or one is a subdomain of the other\n * (case-insensitive): `www.reddoorla.com` vs `reddoorla.com` matches both ways.\n * Exported for tests. PURE. */\nexport function hostsMatch(a: string, b: string): boolean {\n const ha = a.trim().toLowerCase();\n const hb = b.trim().toLowerCase();\n if (ha.length === 0 || hb.length === 0) return false;\n return ha === hb || ha.endsWith(`.${hb}`) || hb.endsWith(`.${ha}`);\n}\n\n/** Whether a passing token's solved-hostname is acceptable for the site at `siteUrl`.\n * An unparseable/hostless `siteUrl` returns TRUE — the check self-disables rather\n * than punishing a possibly-real visitor for an operator data problem (fail-open,\n * same philosophy as verifyTurnstile). PURE. */\nexport function turnstileHostnameAcceptable(tokenHostname: string, siteUrl: string): boolean {\n let siteHost: string;\n try {\n siteHost = new URL(siteUrl).hostname;\n } catch {\n return true;\n }\n if (!siteHost) return true;\n return hostsMatch(tokenHostname, siteHost);\n}\n\n/** True when an untrusted ingest payload carries the synthetic-probe marker\n * (top-level `testMode: true`). Read from the RAW payload so the branch never\n * depends on normalization internals; any non-`true` value is ignored (a real\n * visitor's form never sets it — the starter only forwards it when the submitted\n * form field `testMode` equals \"true\"). */\nexport function isTestMode(rawPayload: unknown): boolean {\n if (!rawPayload || typeof rawPayload !== \"object\") return false;\n return (rawPayload as Record<string, unknown>).testMode === true;\n}\n","import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { SubmissionRow, NotifyStatus } from \"../reports/submission-row.js\";\nimport type { ResendSendInput } from \"../reports/send/resend.js\";\nimport { hostsMatch } from \"./ingest.js\";\nimport { escapeHtml } from \"../util/html.js\";\n\nconst FORMS_FROM = \"forms@reddoorla.com\";\nconst FALLBACK_REPLY_TO = \"info@reddoorla.com\";\n\n// Single-operator fleet fallback when OPERATOR_EMAIL is unset. Deliberately the\n// monitored personal inbox (not the digest's info@ alias) — a missed pre-launch\n// LEAD is higher-stakes than a missed digest, so it should land somewhere watched.\nconst OPERATOR_FALLBACK = \"tucker@reddoorla.com\";\n\nfunction operatorEmail(): string {\n return process.env.OPERATOR_EMAIL?.trim() || OPERATOR_FALLBACK;\n}\n\n/** Strip characters that would break an RFC 5322 display name. */\nfunction displayName(raw: string): string {\n return raw.replace(/[\"\\r\\n]/g, \"\").trim() || \"Reddoor\";\n}\n\nfunction pocAddress(site: WebsiteRow): string | null {\n return site.pointOfContact ?? site.reportRecipientsTo ?? null;\n}\n\n/** Coerce a string|string[] recipient config into a clean, de-duped address list. */\nfunction normalizeRecipients(v: string | string[] | undefined): string[] {\n const arr = Array.isArray(v) ? v : v ? [v] : [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const raw of arr) {\n if (typeof raw !== \"string\") continue;\n const t = raw.trim();\n if (t && !seen.has(t)) {\n seen.add(t);\n out.push(t);\n }\n }\n return out;\n}\n\nexport type Recipients = { to: string[]; cc: string[] };\n\n/**\n * Where a submission notification goes.\n * - Pre-launch (status !== \"maintenance\"): the operator only — no routing, no CC.\n * Preserves the verify guard (flip a site to \"launch period\" to route tests to\n * yourself).\n * - Maintenance + a `Notify Routing` config: address by the routing field's value\n * (`extraFields[field]`) → its matched route, else the config `default`; CC from\n * the config. If nothing resolves, fall through to the single POC.\n * - Maintenance, no routing: the single site POC (pointOfContact ?? reportRecipientsTo).\n * Returns null only when nothing resolves — the lead is still persisted; notify skips.\n */\nexport function resolveRecipients(site: WebsiteRow, submission: SubmissionRow): Recipients | null {\n if (site.status !== \"maintenance\") {\n return { to: [operatorEmail()], cc: [] };\n }\n const routing = site.notifyRouting;\n if (routing) {\n const value = parseExtraFields(submission.extraFields)[routing.field];\n const match = typeof value === \"string\" ? routing.routes[value] : undefined;\n const to = normalizeRecipients(match ?? routing.default);\n if (to.length > 0) return { to, cc: normalizeRecipients(routing.cc) };\n // routing matched nothing → fall through to the POC below\n }\n const poc = pocAddress(site);\n return poc ? { to: [poc], cc: [] } : null;\n}\n\n/** Who a submission to this site would notify, answered WITHOUT sending one. */\nexport type NotifyTarget = {\n /** `client` means a real point of contact receives it. `operator` means the\n * pre-launch guard is holding and only you do. `nobody` means nothing\n * resolves — the lead is still stored, but no notification goes out. */\n audience: \"client\" | \"operator\" | \"nobody\";\n /** Every address a submission could reach, across ALL routing branches. */\n to: string[];\n cc: string[];\n /** Plain-language reason, naming the field that decided it. */\n reason: string;\n};\n\n/** A submission that exists only to ask resolveRecipients a question. */\nfunction probeSubmission(extraFields: string | null): SubmissionRow {\n return {\n id: \"probe\",\n submissionId: null,\n siteId: \"probe\",\n formType: \"contact\" as SubmissionRow[\"formType\"],\n name: \"probe\",\n email: \"probe@example.com\",\n phone: null,\n message: null,\n extraFields,\n sourceUrl: null,\n utm: null,\n submittedAt: null,\n status: \"new\" as SubmissionRow[\"status\"],\n notifyStatus: \"pending\" as NotifyStatus,\n resendMessageId: null,\n };\n}\n\n/**\n * Resolve who a form submission would notify, for a site, right now.\n *\n * This exists because the pre-launch guard had no feedback. Its whole state is\n * one Airtable `Status` cell nobody is looking at while testing, the site\n * reports nothing about it, and the only way to learn the answer was to submit\n * a form and read the `resend_message_id` afterwards. On 2026-08-03 an intended\n * flip never landed and a real client received a test lead — an email cannot be\n * recalled. A guard whose state you cannot see is a guard you will eventually\n * assume is on.\n *\n * Every address here comes back out of `resolveRecipients` itself rather than\n * from a second reading of the same rules — for a routed site each branch is\n * probed with a synthetic submission and the results unioned. A copy of the\n * routing logic that drifted would be worse than no answer at all, because it\n * would be a confident wrong one.\n */\nexport function describeNotifyTarget(site: WebsiteRow): NotifyTarget {\n if (site.status !== \"maintenance\") {\n const guarded = resolveRecipients(site, probeSubmission(null));\n return {\n audience: \"operator\",\n to: guarded?.to ?? [],\n cc: [],\n reason: `Status is ${site.status === null ? \"blank\" : `\"${site.status}\"`}, not \"maintenance\" — the pre-launch guard routes every notification to the operator.`,\n };\n }\n\n const routing = site.notifyRouting;\n if (!routing) {\n const direct = resolveRecipients(site, probeSubmission(null));\n if (!direct || direct.to.length === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintenance\" but the site has no Point of Contact and no Notify Routing — the lead is stored and no notification is sent.`,\n };\n }\n return {\n audience: \"client\",\n to: direct.to,\n cc: direct.cc,\n reason: `Status is \"maintenance\" — notifications go to the site's Point of Contact.`,\n };\n }\n\n // Probe every declared route value, plus one value that matches none so the\n // `default` (and the POC fall-through behind it) is covered too.\n const to = new Set<string>();\n const cc = new Set<string>();\n const values = [...Object.keys(routing.routes), \"\u0000no-such-route\"];\n for (const value of values) {\n const r = resolveRecipients(site, probeSubmission(JSON.stringify({ [routing.field]: value })));\n r?.to.forEach((a) => to.add(a));\n r?.cc.forEach((a) => cc.add(a));\n }\n if (to.size === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintenance\" with Notify Routing on \"${routing.field}\", but no route, default or Point of Contact resolves to an address.`,\n };\n }\n return {\n audience: \"client\",\n to: [...to],\n cc: [...cc],\n reason: `Status is \"maintenance\" with Notify Routing on \"${routing.field}\" — the recipient depends on that field's value; every address it could reach is listed.`,\n };\n}\n\n/** Humanize an extraFields key for display: \"appointment_date\" → \"Appointment date\". */\nfunction humanizeKey(k: string): string {\n const spaced = k.replace(/[_-]+/g, \" \").trim();\n return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : k;\n}\n\nfunction formatValue(v: unknown): string {\n if (v === null || v === undefined) return \"—\";\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n return JSON.stringify(v);\n}\n\n/** Parse the stored `extraFields` JSON into a plain object — bad JSON or a\n * non-object yields {} (never throws). Shared by the email renderer and routing. */\nfunction parseExtraFields(raw: string | null): Record<string, unknown> {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n /* malformed JSON → no fields */\n }\n return {};\n}\n\nfunction extraFieldRows(raw: string | null): Array<[string, string]> {\n return Object.entries(parseExtraFields(raw))\n .filter(([, v]) => !(typeof v === \"string\" && v.trim() === \"\"))\n .map(([k, v]) => [humanizeKey(k), formatValue(v)] as [string, string]);\n}\n\nfunction fieldsTable(submission: SubmissionRow): string {\n const rows: Array<[string, string]> = [\n [\"Form\", submission.formType],\n [\"Name\", submission.name || \"—\"],\n [\"Email\", submission.email || \"—\"],\n ];\n if (submission.phone) rows.push([\"Phone\", submission.phone]);\n // Site-specific context (the artwork an inquiry is about, the event an rsvp is\n // for, etc.) lives in extraFields — surface it so the recipient sees what the\n // submitter was looking at, not just their name and email.\n rows.push(...extraFieldRows(submission.extraFields));\n if (submission.sourceUrl) rows.push([\"Page\", submission.sourceUrl]);\n if (submission.utm) rows.push([\"UTM\", submission.utm]);\n const body = rows\n .map(([k, v]) => `<tr><td><strong>${escapeHtml(k)}</strong></td><td>${escapeHtml(v)}</td></tr>`)\n .join(\"\");\n const message = submission.message\n ? `<p style=\"white-space:pre-wrap\">${escapeHtml(submission.message)}</p>`\n : \"\";\n return `<table>${body}</table>${message}`;\n}\n\n/** POC notification — the primary email; null when the site has no contact address. */\nexport function buildPocNotification(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n const recipients = resolveRecipients(site, submission);\n if (!recipients || recipients.to.length === 0) return null;\n const input: ResendSendInput = {\n from: `${displayName(site.name)} Forms <${FORMS_FROM}>`,\n to: recipients.to,\n subject: `New ${submission.formType} from ${site.name}`,\n html: `<h2>New ${escapeHtml(submission.formType)} submission — ${escapeHtml(\n site.name,\n )}</h2>${fieldsTable(submission)}`,\n };\n if (recipients.cc.length > 0) input.cc = recipients.cc;\n // Reply straight to the lead.\n if (submission.email) input.replyTo = submission.email;\n return input;\n}\n\n/** Autoresponder to the submitter — null when there's no submitter email. */\nexport function buildAutoresponder(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n if (!submission.email) return null;\n // A submitter email on the site's OWN domain is never a real lead wanting a\n // confirmation — it is the spoofed-sender backscatter case (a bot writes the\n // site's info@ as its email and the \"We got your message\" lands in the\n // client's inbox). The POC notification still sends; only this email is\n // suppressed. Unparseable/blank site url → fail open and send, same\n // philosophy as turnstileHostnameAcceptable.\n const emailDomain = submission.email.split(\"@\").pop() ?? \"\";\n let siteHost = \"\";\n try {\n siteHost = new URL(site.url).hostname;\n } catch {\n /* fail open */\n }\n if (siteHost && hostsMatch(emailDomain, siteHost)) return null;\n const intro = site.copyIntro ?? `Thanks for reaching out to ${site.name}.`;\n const contact = site.copyContact ?? \"We've received your message and will be in touch soon.\";\n const footer = site.copyFooter ?? site.name;\n return {\n from: `${displayName(site.name)} <${FORMS_FROM}>`,\n to: [submission.email],\n replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,\n subject: \"We got your message\",\n html: `<p>${escapeHtml(intro)}</p><p>${escapeHtml(contact)}</p><p>${escapeHtml(footer)}</p>`,\n };\n}\n\nexport type NotifyDeps = {\n send: (input: ResendSendInput) => Promise<{ messageId: string }>;\n};\n\nexport type NotifyOutcome = { status: NotifyStatus; messageId: string | null };\n\n/**\n * Send the POC notification (primary — drives notifyStatus) then the submitter\n * autoresponder (best-effort — logged, never changes the outcome). The submission\n * is already persisted before this runs, so a Resend outage degrades to\n * notifyStatus=\"failed\", never a lost lead.\n */\nexport async function notifySubmission(\n deps: NotifyDeps,\n site: WebsiteRow,\n submission: SubmissionRow,\n): Promise<NotifyOutcome> {\n const poc = buildPocNotification(site, submission);\n let outcome: NotifyOutcome;\n if (!poc) {\n outcome = { status: \"skipped\", messageId: null };\n } else {\n try {\n const { messageId } = await deps.send(poc);\n outcome = { status: \"sent\", messageId };\n } catch (err) {\n console.error(`[submissions] POC notification failed: ${String(err)}`);\n outcome = { status: \"failed\", messageId: null };\n }\n }\n const auto = buildAutoresponder(site, submission);\n if (auto) {\n try {\n await deps.send(auto);\n } catch (err) {\n console.error(`[submissions] autoresponder failed: ${String(err)}`);\n }\n }\n return outcome;\n}\n\n/**\n * Build the ingest `notify` dependency from a Resend send fn — or `null` when the\n * Resend client couldn't even be constructed (e.g. `RESEND_API_KEY` unset). A null\n * send marks the notification `failed` WITHOUT attempting it, so a Resend\n * misconfiguration degrades to a captured-but-unemailed lead rather than aborting\n * ingest and losing it. Mirrors the in-flight failure isolation in notifySubmission.\n */\nexport function makeNotify(\n send: NotifyDeps[\"send\"] | null,\n): (site: WebsiteRow, submission: SubmissionRow) => Promise<NotifyOutcome> {\n return (site, submission) =>\n send\n ? notifySubmission({ send }, site, submission)\n : Promise.resolve({ status: \"failed\", messageId: null });\n}\n","import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateSiteField,\n type Status,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { describeNotifyTarget, type NotifyTarget } from \"../forms/notify.js\";\n\n/** The Airtable column the pre-launch guard actually lives in. */\nexport const STATUS_COLUMN = \"Status\";\n\n/** The two ends of the verify flip. Deliberately the ONLY transition this\n * performs: a site in any other status is already guarded (or is deliberately\n * something else, like \"hosting\"), and silently rewriting that would be the\n * same class of unseen change as the incident. */\nexport const LIVE_STATUS: Status = \"maintenance\";\nexport const VERIFY_STATUS: Status = \"launch period\";\n\nexport type FormsNotifyTargetDeps = {\n base?: AirtableBase;\n /** Site slug or the Airtable Websites NAME (both accepted). */\n site: string;\n /** `on` routes notifications to the operator; `off` restores. Omit to read. */\n set?: \"on\" | \"off\";\n /** Status to restore with `--set off`. Required, never inferred. */\n restore?: string;\n};\n\nexport type FormsNotifyTargetResult = {\n site: string;\n status: Status | null;\n target: NotifyTarget;\n /** Present only when a flip was attempted. `confirmed` is the whole point:\n * it comes from RE-READING the row, not from the write call returning. */\n flip?: { from: Status | null; to: Status; confirmed: boolean };\n};\n\nfunction findSite(rows: WebsiteRow[], site: string): WebsiteRow | undefined {\n const wanted = site.trim().toLowerCase();\n return rows.find(\n (r) => siteSlug(r.name) === siteSlug(site) || r.name.trim().toLowerCase() === wanted,\n );\n}\n\n/**\n * Answer \"who would a form submission on this site email?\" — and optionally\n * flip the pre-launch guard, confirming the flip by reading it back.\n *\n * The guard is a single Airtable `Status` cell. Nothing between \"I intended to\n * flip it\" and \"the client received a test lead\" reported the current state, so\n * on 2026-08-03 a flip that never landed sent a real client a test submission.\n * The fix is not a better intention, it is feedback: this reads the row back\n * after every write and refuses to call the flip confirmed on anything less.\n */\nexport async function formsNotifyTarget(\n deps: FormsNotifyTargetDeps,\n): Promise<FormsNotifyTargetResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const rows = await listWebsites(base);\n const row = findSite(rows, deps.site);\n if (!row) {\n // The Websites NAME is not the repo slug (\"Sonder\", not \"gallerysonder\"),\n // and that mismatch has cost time before — so name the near misses rather\n // than making the operator go read Airtable to find the spelling.\n const needle = siteSlug(deps.site);\n const near = rows\n .map((r) => r.name)\n .filter((n) => siteSlug(n).includes(needle) || needle.includes(siteSlug(n)));\n throw Object.assign(\n new Error(\n `No Websites row matches '${deps.site}'. The Websites NAME is not always the repo ` +\n `slug (Sonder, not gallerysonder).` +\n (near.length > 0 ? ` Did you mean: ${near.join(\", \")}?` : \"\"),\n ),\n { exitCode: 2 },\n );\n }\n\n if (!deps.set) {\n return { site: row.name, status: row.status, target: describeNotifyTarget(row) };\n }\n\n const to = deps.set === \"on\" ? VERIFY_STATUS : (deps.restore?.trim() as Status | undefined);\n if (deps.set === \"off\" && !to) {\n throw Object.assign(\n new Error(\n `--set off needs --restore <status>: the status to return to is never inferred. ` +\n `Guessing \"${LIVE_STATUS}\" for a site that was \"hosting\" or \"legacy\" would start ` +\n `sending real client notifications — the inverse of the failure this command exists ` +\n `to prevent.`,\n ),\n { exitCode: 2 },\n );\n }\n // Only ever flip a LIVE site into verify mode. A site already outside\n // \"maintenance\" is guarded already, and rewriting its status would destroy a\n // real value nobody asked us to touch.\n if (deps.set === \"on\" && row.status !== LIVE_STATUS) {\n throw Object.assign(\n new Error(\n `${row.name} is \"${row.status ?? \"blank\"}\", not \"${LIVE_STATUS}\" — notifications ` +\n `already go to the operator only, so there is nothing to flip. Leaving the status ` +\n `untouched.`,\n ),\n { exitCode: 2 },\n );\n }\n\n await updateSiteField(base, row.id, STATUS_COLUMN, to!);\n\n // Read it back. The write returning is NOT evidence the field changed.\n const after = findSite(await listWebsites(base), deps.site);\n if (!after) {\n throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {\n exitCode: 1,\n });\n }\n return {\n site: after.name,\n status: after.status,\n target: describeNotifyTarget(after),\n flip: { from: row.status, to: to!, confirmed: after.status === to },\n };\n}\n","import {\n formsNotifyTarget,\n LIVE_STATUS,\n VERIFY_STATUS,\n type FormsNotifyTargetResult,\n} from \"../../recipes/forms-notify-target.js\";\n\nexport type FormsNotifyTargetCommandOptions = {\n set?: string;\n restore?: string;\n cwd?: string;\n};\n\nconst AUDIENCE_LABEL = {\n client: \"THE CLIENT\",\n operator: \"OPERATOR ONLY\",\n nobody: \"NOBODY\",\n} as const;\n\n/** The line that decides whether it is safe to test-submit. Deliberately the\n * loudest thing on screen: the incident happened because the answer was\n * invisible, not because it was subtle. */\nexport function formatNotifyTarget(r: FormsNotifyTargetResult): string {\n const lines: string[] = [];\n if (r.flip) {\n const arrow = `${r.flip.from ?? \"blank\"} → ${r.flip.to}`;\n lines.push(\n r.flip.confirmed\n ? `${r.site} Status: ${arrow} ✓ confirmed by read-back`\n : `${r.site} Status: ${arrow} ✗ NOT CONFIRMED — the row still reads \"${r.status ?? \"blank\"}\". Nothing was verified; do not test-submit.`,\n );\n } else {\n lines.push(`${r.site} Status: ${r.status ?? \"blank\"}`);\n }\n\n lines.push(`A submission right now would notify: ${AUDIENCE_LABEL[r.target.audience]}`);\n if (r.target.to.length > 0) lines.push(` to: ${r.target.to.join(\", \")}`);\n if (r.target.cc.length > 0) lines.push(` cc: ${r.target.cc.join(\", \")}`);\n lines.push(` ${r.target.reason}`);\n\n if (r.target.audience === \"client\") {\n lines.push(\n \"\",\n `⚠️ A test submission WILL email the client, and email cannot be recalled.`,\n ` Route it to yourself first: reddoor forms-notify-target ${r.site} --set on`,\n );\n }\n if (r.flip?.confirmed && r.flip.to === VERIFY_STATUS) {\n lines.push(\n \"\",\n `Safe to test. When you are done, restore it:`,\n ` reddoor forms-notify-target ${r.site} --set off --restore ${r.flip.from ?? LIVE_STATUS}`,\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * `forms-notify-target <site>` — show who a form submission would email, and\n * optionally flip the pre-launch guard with a read-back confirmation.\n *\n * Read-only by default: answering the question must never be riskier than not\n * asking it.\n */\nexport async function runFormsNotifyTargetCommand(\n site: string | undefined,\n opts: FormsNotifyTargetCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (!site?.trim()) {\n return { output: \"forms-notify-target requires <site> (slug or Airtable name)\", code: 2 };\n }\n const set = opts.set?.trim().toLowerCase();\n if (set !== undefined && set !== \"on\" && set !== \"off\") {\n return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };\n }\n try {\n const result = await formsNotifyTarget({\n site: site.trim(),\n ...(set ? { set: set as \"on\" | \"off\" } : {}),\n ...(opts.restore ? { restore: opts.restore } : {}),\n });\n // An unconfirmed flip must not exit 0: a script (or a person skimming) that\n // reads exit status would otherwise take \"I flipped it\" on faith, which is\n // exactly the assumption that sent a client a test lead.\n return {\n output: formatNotifyTarget(result),\n code: result.flip && !result.flip.confirmed ? 1 : 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -113,6 +113,28 @@ async function auditAnchors(page) {
|
|
|
113
113
|
}
|
|
114
114
|
return targets;
|
|
115
115
|
}
|
|
116
|
+
async function stampMediaBoxes(page) {
|
|
117
|
+
await page.evaluate(() => {
|
|
118
|
+
const read = (el) => {
|
|
119
|
+
const r = el.getBoundingClientRect();
|
|
120
|
+
return { w: Math.round(r.width), h: Math.round(r.height) };
|
|
121
|
+
};
|
|
122
|
+
for (const el of document.querySelectorAll("[data-media]")) {
|
|
123
|
+
let box = read(el);
|
|
124
|
+
if (box.w === 0) {
|
|
125
|
+
const hidden = [];
|
|
126
|
+
for (let n = el; n && n !== document.body; n = n.parentElement) {
|
|
127
|
+
if (getComputedStyle(n).display !== "none") continue;
|
|
128
|
+
hidden.push([n, n.style.display]);
|
|
129
|
+
n.style.display = "block";
|
|
130
|
+
}
|
|
131
|
+
box = read(el);
|
|
132
|
+
for (const [n, prev] of hidden.reverse()) n.style.display = prev;
|
|
133
|
+
}
|
|
134
|
+
if (box.w > 0) el.setAttribute("data-rd-box", `${box.w}x${box.h}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
}
|
|
116
138
|
async function settleExport(indexHtmlPath, opts = {}) {
|
|
117
139
|
const width = opts.width ?? 1440;
|
|
118
140
|
const height = opts.height ?? 1e3;
|
|
@@ -131,6 +153,7 @@ async function settleExport(indexHtmlPath, opts = {}) {
|
|
|
131
153
|
}
|
|
132
154
|
await page.evaluate(() => window.scrollTo(0, 0));
|
|
133
155
|
await page.waitForTimeout(1200);
|
|
156
|
+
await stampMediaBoxes(page);
|
|
134
157
|
const html = await page.content();
|
|
135
158
|
const { url, server } = await serveExport(dirname(indexHtmlPath));
|
|
136
159
|
try {
|
|
@@ -145,7 +168,7 @@ async function settleExport(indexHtmlPath, opts = {}) {
|
|
|
145
168
|
await auditPage.waitForTimeout(800);
|
|
146
169
|
const anchorTargets = await auditAnchors(auditPage);
|
|
147
170
|
await auditPage.close();
|
|
148
|
-
return { html, anchorTargets };
|
|
171
|
+
return { html, anchorTargets, viewport: width };
|
|
149
172
|
} finally {
|
|
150
173
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
151
174
|
}
|
|
@@ -242,6 +265,7 @@ function bakeImages(html) {
|
|
|
242
265
|
const sectionIndex = sectionIndexOf(root);
|
|
243
266
|
const counters = /* @__PURE__ */ new Map();
|
|
244
267
|
const slots = [];
|
|
268
|
+
const boxes = {};
|
|
245
269
|
const nextKey = (el) => {
|
|
246
270
|
const section = sectionKeyOf(el, sectionIndex);
|
|
247
271
|
const n = counters.get(section) ?? 0;
|
|
@@ -259,6 +283,15 @@ function bakeImages(html) {
|
|
|
259
283
|
const url = `${base}${sizeSeg}${media}`;
|
|
260
284
|
const key = nextKey(el);
|
|
261
285
|
slots.push({ key, kind: "image", url, section: sectionKeyOf(el, sectionIndex) });
|
|
286
|
+
const painted = /^(\d+)x(\d+)$/.exec(el.getAttribute("data-rd-box") ?? "");
|
|
287
|
+
if (painted) {
|
|
288
|
+
boxes[key] = {
|
|
289
|
+
w: Number(painted[1]),
|
|
290
|
+
h: Number(painted[2]),
|
|
291
|
+
source: size ? Number(size) : null
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
el.removeAttribute("data-rd-box");
|
|
262
295
|
const decl = `background-image:url(${tokenFor("image", key)})`;
|
|
263
296
|
const style = el.getAttribute("style");
|
|
264
297
|
el.setAttribute("style", style ? `${style};${decl}` : decl);
|
|
@@ -273,8 +306,11 @@ function bakeImages(html) {
|
|
|
273
306
|
el.setAttribute(attr, tokenFor("image", key));
|
|
274
307
|
}
|
|
275
308
|
}
|
|
309
|
+
for (const el of root.querySelectorAll("[data-rd-box]")) {
|
|
310
|
+
el.removeAttribute("data-rd-box");
|
|
311
|
+
}
|
|
276
312
|
stripUrlHintAttrs(root);
|
|
277
|
-
return { html: root.toString(), slots };
|
|
313
|
+
return { html: root.toString(), slots, boxes };
|
|
278
314
|
}
|
|
279
315
|
|
|
280
316
|
// src/blux/freeze/tokenize-text.ts
|
|
@@ -387,7 +423,7 @@ function validateExtraSlots(file, derived) {
|
|
|
387
423
|
|
|
388
424
|
// src/blux/freeze/index.ts
|
|
389
425
|
async function freezeSite(opts) {
|
|
390
|
-
const { html: settled, anchorTargets } = await settleExport(opts.indexHtmlPath);
|
|
426
|
+
const { html: settled, anchorTargets, viewport } = await settleExport(opts.indexHtmlPath);
|
|
391
427
|
const exportHtml = await readFile2(opts.indexHtmlPath, "utf-8");
|
|
392
428
|
const mapped = mapPlaceholder(settled, exportHtml);
|
|
393
429
|
const pinned = pinSliders(mapped);
|
|
@@ -411,7 +447,8 @@ async function freezeSite(opts) {
|
|
|
411
447
|
},
|
|
412
448
|
templateHtml: fin.templateHtml,
|
|
413
449
|
styleCss: fin.styleCss,
|
|
414
|
-
mapConfig
|
|
450
|
+
mapConfig,
|
|
451
|
+
imageBoxes: { viewport, boxes: baked.boxes }
|
|
415
452
|
};
|
|
416
453
|
}
|
|
417
454
|
async function emitFrozen(outDir, result) {
|
|
@@ -426,10 +463,13 @@ async function emitFrozen(outDir, result) {
|
|
|
426
463
|
await writeFile(style, result.styleCss, "utf-8");
|
|
427
464
|
await writeFile(fonts, JSON.stringify(result.manifest.fontLinks, null, 2), "utf-8");
|
|
428
465
|
await writeFile(manifest, JSON.stringify(result.manifest, null, 2), "utf-8");
|
|
429
|
-
|
|
466
|
+
const imageBoxes = join2(frozenDir, `${uid}.image-boxes.json`);
|
|
467
|
+
await writeFile(imageBoxes, `${JSON.stringify(result.imageBoxes, null, 2)}
|
|
468
|
+
`, "utf-8");
|
|
469
|
+
if (!result.mapConfig) return { template, style, fonts, manifest, imageBoxes };
|
|
430
470
|
const map = join2(frozenDir, `${uid}.map.json`);
|
|
431
471
|
await writeFile(map, JSON.stringify(result.mapConfig, null, 2), "utf-8");
|
|
432
|
-
return { template, style, fonts, manifest, map };
|
|
472
|
+
return { template, style, fonts, manifest, imageBoxes, map };
|
|
433
473
|
}
|
|
434
474
|
export {
|
|
435
475
|
EXTRA_SLOT_PREFIX,
|
|
@@ -439,4 +479,4 @@ export {
|
|
|
439
479
|
frozenPageCustomType,
|
|
440
480
|
validateExtraSlots
|
|
441
481
|
};
|
|
442
|
-
//# sourceMappingURL=freeze-
|
|
482
|
+
//# sourceMappingURL=freeze-Z7JZTQJS.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/blux/freeze/index.ts","../src/blux/freeze/settle.ts","../src/blux/freeze/map-placeholder.ts","../src/blux/freeze/slider-pin.ts","../src/blux/freeze/resolve-anchors.ts","../src/blux/freeze/bake-images.ts","../src/blux/freeze/types.ts","../src/blux/freeze/section.ts","../src/blux/freeze/tokenize-text.ts","../src/blux/freeze/finalize.ts","../src/blux/freeze/extra-slots.ts"],"sourcesContent":["import { mkdir, writeFile, readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { settleExport } from \"./settle.js\";\nimport { mapPlaceholder } from \"./map-placeholder.js\";\nimport { pinSliders } from \"./slider-pin.js\";\nimport { resolveAnchors } from \"./resolve-anchors.js\";\nimport { bakeImages } from \"./bake-images.js\";\nimport { tokenizeText } from \"./tokenize-text.js\";\nimport { finalize } from \"./finalize.js\";\nimport { extractMapConfig } from \"../grid/extract-map.js\";\nimport { validateExtraSlots } from \"./extra-slots.js\";\nimport type { FrozenResult } from \"./types.js\";\n\nexport type {\n Slot,\n FrozenManifest,\n FrozenResult,\n ExtraSlotsFile,\n ImageBox,\n ImageBoxFile,\n} from \"./types.js\";\nexport { frozenPageCustomType, FROZEN_PAGE_TYPE } from \"./frozen-page-type.js\";\nexport { validateExtraSlots } from \"./extra-slots.js\";\nexport { EXTRA_SLOT_PREFIX } from \"./types.js\";\n\nexport interface FreezeOptions {\n /** Path to the export's index.html. */\n indexHtmlPath: string;\n /** Site slug — names the emitted template/style/manifest files. */\n site: string;\n /** Prismic uid for the page doc (default \"home\"). */\n uid?: string;\n /** A site's own extra-slots declaration (parsed `ExtraSlotsFile`), for\n * editable content the template carries no token for. See types.ts. */\n extraSlots?: unknown;\n}\n\n/**\n * Freeze one Blux page into a byte-faithful template + editable slot manifest.\n * Order matters: bake images (their urls become slots) BEFORE tokenizing text,\n * so image tokens live in style attributes and never collide with text tokens.\n */\nexport async function freezeSite(opts: FreezeOptions): Promise<FrozenResult> {\n const { html: settled, anchorTargets, viewport } = await settleExport(opts.indexHtmlPath);\n // Settled-DOM repairs run before image/text tokenizing: swap the dead\n // Google-Map DOM for a placeholder (using the raw export for the KML mid),\n // pin each hero slider to slide 1, and bake nav hashlink targets (settle's\n // click audit measured them against the export's own runtime).\n const exportHtml = await readFile(opts.indexHtmlPath, \"utf-8\");\n const mapped = mapPlaceholder(settled, exportHtml);\n const pinned = pinSliders(mapped);\n const anchored = resolveAnchors(pinned, anchorTargets);\n const baked = bakeImages(anchored);\n const tokenized = tokenizeText(baked.html);\n const fin = finalize(tokenized.html);\n\n // The full map widget config (layers/toggles/styles) lives in the export's\n // initMap script — carried alongside the template so the render can hydrate\n // the real map. Only meaningful when its mount survived into the template.\n const rawMap = extractMapConfig(exportHtml);\n const mapConfig =\n rawMap && fin.templateHtml.includes(`id=\"${rawMap.mountId}\"`) ? rawMap : undefined;\n\n // Derived slots first, then the site's own. Validated against the derived\n // keys so a declaration can never shadow real page content.\n const derived = [...baked.slots, ...tokenized.slots];\n const extra = validateExtraSlots(opts.extraSlots, derived);\n\n return {\n manifest: {\n site: opts.site,\n uid: opts.uid ?? \"home\",\n title: fin.title,\n metaTitle: fin.metaTitle,\n metaImageUrl: fin.metaImageUrl,\n fontLinks: fin.fontLinks,\n slots: [...derived, ...extra],\n },\n templateHtml: fin.templateHtml,\n styleCss: fin.styleCss,\n mapConfig,\n imageBoxes: { viewport, boxes: baked.boxes },\n };\n}\n\n/**\n * Write the freeze artifacts under `outDir`. Returns their paths.\n *\n * `frozen/` is site-repo-ready: uid-keyed `<uid>.html` / `<uid>.style.css` /\n * `<uid>.fonts.json` / `<uid>.image-boxes.json` (+ `<uid>.map.json` when the\n * export has a map widget),\n * copied verbatim into the site repo's `src/lib/blux-frozen/frozen/` — exactly\n * the names the starter's artifact globs load. The slots manifest stays\n * site-keyed at the out-dir root, where `blux migrate-frozen` finds it.\n */\nexport async function emitFrozen(\n outDir: string,\n result: FrozenResult,\n): Promise<{\n template: string;\n style: string;\n fonts: string;\n manifest: string;\n imageBoxes: string;\n map?: string;\n}> {\n const { site, uid } = result.manifest;\n const frozenDir = join(outDir, \"frozen\");\n await mkdir(frozenDir, { recursive: true });\n const template = join(frozenDir, `${uid}.html`);\n const style = join(frozenDir, `${uid}.style.css`);\n const fonts = join(frozenDir, `${uid}.fonts.json`);\n const manifest = join(outDir, `${site}.slots.json`);\n await writeFile(template, result.templateHtml, \"utf-8\");\n await writeFile(style, result.styleCss, \"utf-8\");\n await writeFile(fonts, JSON.stringify(result.manifest.fontLinks, null, 2), \"utf-8\");\n await writeFile(manifest, JSON.stringify(result.manifest, null, 2), \"utf-8\");\n const imageBoxes = join(frozenDir, `${uid}.image-boxes.json`);\n await writeFile(imageBoxes, `${JSON.stringify(result.imageBoxes, null, 2)}\\n`, \"utf-8\");\n if (!result.mapConfig) return { template, style, fonts, manifest, imageBoxes };\n const map = join(frozenDir, `${uid}.map.json`);\n await writeFile(map, JSON.stringify(result.mapConfig, null, 2), \"utf-8\");\n return { template, style, fonts, manifest, imageBoxes, map };\n}\n","/// <reference lib=\"dom\" />\n// The evaluate() callbacks below run in the browser page context, so they use\n// DOM globals (document/window). The CLI's base tsconfig is Node-only (lib\n// ES2022); this directive adds DOM types for this file's type-checking.\nimport { createServer, type Server } from \"node:http\";\nimport { readFile } from \"node:fs/promises\";\nimport { dirname, extname, join, normalize, sep } from \"node:path\";\nimport { chromium, type Page } from \"@playwright/test\";\n\n// The Blux export's index.html renders its final layout only after its runtime\n// JS applies height/position adjustments. Freezing the RAW html renders the\n// wrong height (the-pointe: 16873px); freezing the JS-SETTLED DOM renders the\n// live height (15333px). So the freeze hydrates the page once in headless\n// chromium, scrolls top→bottom to trigger the layout pass, and snapshots the\n// settled DOM. (Proven in the design POC scratchpad/freeze-final.mjs.)\n\nexport interface SettleOptions {\n /** Layout viewport — Blux desktop layout is authored at 1440. */\n width?: number;\n height?: number;\n /** Scroll step in px (smaller = more reliable lazy triggers, slower). */\n step?: number;\n}\n\nexport interface SettledExport {\n /** The serialized settled DOM (full document HTML). */\n html: string;\n /** Layout width the DOM was settled and measured at. */\n viewport: number;\n /**\n * Nav-anchor answer key, measured from the export's OWN runtime: hash index\n * → the id its JS actually scrolls to. Blux core maps `/#N` to\n * `page-block-N`, but site-specific custom scripts override that (the-pointe\n * embeds one sending any \"Contact Us\" link to `footer0`) — behavior no\n * static rule can recover, so it is recorded by clicking each hashlink with\n * `scrollPageToTarget` instrumented. Empty when the runtime is absent.\n */\n anchorTargets: Record<string, string>;\n}\n\n// Blux runtime globals reached from inside evaluate() callbacks. Types are\n// erased before serialization, so declaring this at module level is safe.\ntype BluxWindow = Window & {\n DANDOM?: { prototype: { scrollPageToTarget?: (t: string) => unknown } };\n __rdAnchorCalls?: string[];\n alreadyScrolling?: boolean;\n};\n\nconst MIME: Record<string, string> = {\n \".html\": \"text/html; charset=utf-8\",\n \".css\": \"text/css\",\n \".js\": \"text/javascript\",\n \".json\": \"application/json\",\n \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\",\n \".gif\": \"image/gif\",\n \".svg\": \"image/svg+xml\",\n \".webp\": \"image/webp\",\n \".ico\": \"image/x-icon\",\n \".woff\": \"font/woff\",\n \".woff2\": \"font/woff2\",\n};\n\n/**\n * One-shot local static server over the export dir, for the anchor audit ONLY.\n * The audit must run on an http origin: there `/#N` is a same-document hash\n * change (exactly the live site's semantics), while on `file://` it resolves\n * to the filesystem ROOT — a cross-document navigation that Blux's handler\n * chain triggers even past preventDefault + timer stubs, tearing the page down\n * mid-audit. Serving beats intercepting.\n */\nasync function serveExport(dir: string): Promise<{ url: string; server: Server }> {\n const root = normalize(dir);\n const server = createServer((req, res) => {\n void (async () => {\n try {\n const path = decodeURIComponent((req.url ?? \"/\").split(/[?#]/)[0]!);\n const rel = path === \"/\" ? \"index.html\" : path.slice(1);\n // Separator-anchored: a bare prefix test would also pass sibling\n // dirs that merely share the prefix (/exports/site-secret vs\n // /exports/site).\n const file = normalize(join(root, rel));\n if (!file.startsWith(root + sep)) throw new Error(\"traversal\");\n const body = await readFile(file);\n res.writeHead(200, {\n \"content-type\": MIME[extname(file)] ?? \"application/octet-stream\",\n });\n res.end(body);\n } catch {\n res.writeHead(404);\n res.end();\n }\n })();\n });\n await new Promise<void>((resolve) => server.listen(0, \"127.0.0.1\", resolve));\n const address = server.address();\n if (address === null || typeof address === \"string\")\n throw new Error(\"export server failed to bind\");\n return { url: `http://127.0.0.1:${address.port}/`, server };\n}\n\n/**\n * Click every distinct `/#N` hashlink on the SERVED page and record the first\n * target the site's runtime passes to `scrollPageToTarget` — first, because on\n * the live site later calls in a click's handler chain are gated by\n * `alreadyScrolling`, so the first call is the one users see. The scroll\n * itself is suppressed (the wrapper records and returns), so clicks leave no\n * scroll state behind; the settle snapshot is taken from a separate file://\n * page that the audit never touches.\n */\nasync function auditAnchors(page: Page): Promise<Record<string, string>> {\n const ns = await page.evaluate(() => {\n const found = new Set<string>();\n for (const a of document.querySelectorAll('a[href^=\"/#\"]')) {\n const m = /^\\/#(\\d+)$/.exec(a.getAttribute(\"href\") ?? \"\");\n if (m?.[1]) found.add(m[1]);\n }\n return [...found];\n });\n if (ns.length === 0) return {};\n\n const instrumented = await page.evaluate(() => {\n const w = window as unknown as BluxWindow;\n const proto = w.DANDOM?.prototype;\n if (!proto?.scrollPageToTarget) return false;\n w.__rdAnchorCalls = [];\n proto.scrollPageToTarget = function (t: string) {\n w.__rdAnchorCalls?.push(t);\n return this; // record only — never actually scroll\n };\n return true;\n });\n if (!instrumented) return {};\n\n const targets: Record<string, string> = {};\n for (const n of ns) {\n // Audit EVERY link sharing this index, not just the first: custom scripts\n // intercept by link semantics (the-pointe keys on \"Contact Us\" text), so\n // two /#N links can legitimately scroll to different targets. The bake is\n // per-index, so disagreement can't be represented — surface it loudly and\n // bake the first link's (document-order) behavior.\n const count = await page.evaluate(\n (nn) => document.querySelectorAll(`a[href=\"/#${nn}\"]`).length,\n n,\n );\n const seen: string[] = [];\n for (let i = 0; i < count; i++) {\n await page.evaluate(\n ({ nn, idx }) => {\n const w = window as unknown as BluxWindow;\n w.__rdAnchorCalls = [];\n w.alreadyScrolling = false;\n history.replaceState(null, \"\", \"/\");\n document.querySelectorAll<HTMLElement>(`a[href=\"/#${nn}\"]`)[idx]?.click();\n },\n { nn: n, idx: i },\n );\n // checkScrollHash defers its core scroll 100ms past the hashchange (and\n // re-navigates at +50ms); 400ms covers the whole handler chain.\n await page.waitForTimeout(400);\n const first = await page.evaluate(\n () => (window as unknown as BluxWindow).__rdAnchorCalls?.[0] ?? null,\n );\n if (first) seen.push(first);\n }\n const distinct = [...new Set(seen)];\n if (distinct.length > 1) {\n console.warn(\n `[freeze] anchor /#${n}: links disagree on target (${distinct.join(\", \")}) — ` +\n `baking \"${seen[0]}\" for all; verify that page's nav fidelity by hand`,\n );\n }\n if (seen[0]) targets[n] = seen[0];\n }\n return targets;\n}\n\n/**\n * Stamp each media element with the box it is actually painted into.\n *\n * A frozen page inherits whatever CDN variant the export happened to use, and\n * that bears no relation to the size the browser paints it at. Measured on\n * the-pointe: a 5774px-wide file (1.34MB) into an 823px box, 5341px into a\n * 1425px band, 3960px carousel slides into 1425x760 — 4.03MB of 5.06MB in\n * images larger than their box. The render can only ask a CDN for the right\n * size if it knows that size, and the size cannot be derived from the markup:\n * Blux sets it in CSS, so an element carrying `width:5774px` renders at 823.\n *\n * A laid-out page is therefore the only honest source, and this is the one\n * place in the pipeline that has one. `bakeImages` reads the attribute back off\n * when it assigns slot keys and strips it again, so the template is unchanged.\n *\n * An element the export settled hidden measures zero. Each is revealed on its\n * own, measured, and put straight back, so no other element's layout is\n * disturbed while it is being read.\n */\nasync function stampMediaBoxes(page: Page): Promise<void> {\n await page.evaluate(() => {\n const read = (el: Element): { w: number; h: number } => {\n const r = el.getBoundingClientRect();\n return { w: Math.round(r.width), h: Math.round(r.height) };\n };\n for (const el of document.querySelectorAll(\"[data-media]\")) {\n let box = read(el);\n if (box.w === 0) {\n const hidden: Array<[HTMLElement, string]> = [];\n for (\n let n: HTMLElement | null = el as HTMLElement;\n n && n !== document.body;\n n = n.parentElement\n ) {\n if (getComputedStyle(n).display !== \"none\") continue;\n hidden.push([n, n.style.display]);\n n.style.display = \"block\";\n }\n box = read(el);\n for (const [n, prev] of hidden.reverse()) n.style.display = prev;\n }\n if (box.w > 0) el.setAttribute(\"data-rd-box\", `${box.w}x${box.h}`);\n }\n });\n}\n\n/**\n * Render a local `index.html` in headless chromium, let its JS settle the\n * layout, and return the serialized settled DOM plus the measured nav-anchor\n * answer key.\n */\nexport async function settleExport(\n indexHtmlPath: string,\n opts: SettleOptions = {},\n): Promise<SettledExport> {\n const width = opts.width ?? 1440;\n const height = opts.height ?? 1000;\n const step = opts.step ?? 500;\n\n const browser = await chromium.launch();\n try {\n const page = await browser.newPage({ viewport: { width, height } });\n await page.goto(`file://${indexHtmlPath}`, {\n waitUntil: \"networkidle\",\n timeout: 60000,\n });\n // Trigger the JS layout pass across the whole page.\n const total = await page.evaluate(() => document.body.scrollHeight);\n for (let y = 0; y < total + 1000; y += step) {\n await page.evaluate((yy) => window.scrollTo(0, yy), y);\n await page.waitForTimeout(120);\n }\n await page.evaluate(() => window.scrollTo(0, 0));\n await page.waitForTimeout(1200);\n // Measured on the settled page, before serializing — this is the only\n // point in the freeze that has a laid-out document.\n await stampMediaBoxes(page);\n const html = await page.content();\n\n // Anchor audit on a separate, HTTP-served copy (see serveExport). Media\n // is aborted for speed — the audit needs handlers, not layout.\n const { url, server } = await serveExport(dirname(indexHtmlPath));\n try {\n const auditPage = await browser.newPage({\n viewport: { width, height },\n });\n await auditPage.route(\n \"**/*\",\n (route) =>\n void ([\"image\", \"media\", \"font\"].includes(route.request().resourceType())\n ? route.abort()\n : route.continue()),\n );\n await auditPage.goto(url, { waitUntil: \"load\", timeout: 60000 });\n await auditPage.waitForTimeout(800);\n const anchorTargets = await auditAnchors(auditPage);\n await auditPage.close();\n return { html, anchorTargets, viewport: width };\n } finally {\n await new Promise<void>((resolve) => server.close(() => resolve()));\n }\n } finally {\n await browser.close();\n }\n}\n","import { parse } from \"node-html-parser\";\n\n// The frozen export's Google-Map band is a CUSTOM Google My Maps: a KmlLayer\n// keyed by a My Maps id (`…/kml?…&mid=<ID>`) rendered at runtime. With the Blux\n// JS stripped, the settled `gm-style` DOM Google left behind is dead (broken\n// tiles + CSP console noise). Replace it with a clean, height-preserving\n// placeholder that carries the `mid` so a future (production) hydrator can bring\n// the real map back once a domain-scoped Maps key exists.\n\n// The My Maps id, pulled from the export's KML url (deterministic, not from the\n// settled DOM which no longer holds it).\nconst MID_RE = /[?&]mid=([\\w-]+)/;\n\n/**\n * Convert each rendered Google-Map container into a `.blux-frozen-map`\n * placeholder. A container is an element whose `id` names a map (`/map/i`) AND\n * that actually rendered (has a `.gm-style` descendant); its dead children are\n * removed and the KML `mid` (if found in `exportHtml`) is attached. Its existing\n * id + inline height are kept so the band's box is unchanged. `exportHtml` is the\n * raw Blux export string.\n */\nexport function mapPlaceholder(html: string, exportHtml: string): string {\n const root = parse(html);\n const mid = exportHtml.match(MID_RE)?.[1];\n\n for (const el of root.querySelectorAll(\"[id]\")) {\n const id = el.getAttribute(\"id\") ?? \"\";\n if (!/map/i.test(id)) continue;\n if (!el.querySelector(\".gm-style\")) continue; // only actually-rendered maps\n el.set_content(\"\"); // drop the dead gm-style DOM\n el.classList.add(\"blux-frozen-map\");\n if (mid) el.setAttribute(\"data-kml-mid\", mid);\n }\n\n return root.toString();\n}\n","import { parse, HTMLElement } from \"node-html-parser\";\n\n// Blux single-column hero sliders (`.caslider` with `data-columns` 1 or absent)\n// animate through their slides at runtime. The settle snapshot freezes whichever\n// slide happened to be active, which is not reproducible across re-freezes. Pin\n// such a slider to its first slide: force slide 1 visible + in position, hide the\n// rest with `display:none`. Appended declarations win (inline-style last-wins),\n// so the first slide's own styles are preserved and height is unchanged (slide 1\n// keeps the band's natural height).\n//\n// A MULTI-column slider (`data-columns` >= 2) shows several slides at once — the\n// settled snapshot already renders those columns faithfully — so pinning would\n// hide the simultaneously-visible columns and regress the band. Leave those\n// untouched; the pin is only for the single-column hero case.\n\n/**\n * Pin each single-column `.caslider` to its first slide, deterministically.\n * Multi-column sliders (`data-columns` >= 2) are left as settled.\n */\nexport function pinSliders(html: string): string {\n const root = parse(html);\n\n for (const slider of root.querySelectorAll(\".caslider\")) {\n const columns = parseInt(slider.getAttribute(\"data-columns\") ?? \"1\", 10) || 1;\n if (columns > 1) continue; // multi-column carousel: keep the settled columns\n const slides = slider.childNodes.filter((n): n is HTMLElement => n instanceof HTMLElement);\n slides.forEach((slide, i) => {\n const style = slide.getAttribute(\"style\") ?? \"\";\n const pin = i === 0 ? \"display:block;transform:translateX(0%);opacity:1\" : \"display:none\";\n slide.setAttribute(\"style\", style ? `${style};${pin}` : pin);\n });\n }\n\n return root.toString();\n}\n","// Blux nav anchors are JS-driven: core runtime maps `href=\"/#N\"` to the band\n// id `page-block-N` — but site-specific custom scripts embedded in the export\n// can send a link anywhere (the-pointe's sends \"Contact Us\" to `footer0`), so\n// the authoritative mapping is the answer key MEASURED from the export's own\n// runtime by settle's click audit. With the runtime stripped those hrefs are\n// dead; bake the resolved target into the frozen markup. Unmeasured indexes\n// fall back to Blux core's `page-block-N` (when that band is missing, Blux\n// core no-ops — exactly what a dead in-page anchor does, so the fallback is\n// faithful either way). Digit-only fragments only — real named anchors\n// (`/#about`) are untouched. The starter's render-time enhance layer keeps its\n// own `/#N` rewrite as a repair path for pre-bake artifacts; baked hrefs no\n// longer match it.\n\nconst HASHLINK_RE = /href=\"\\/#(\\d+)\"/g;\n\nexport function resolveAnchors(html: string, targets: Record<string, string> = {}): string {\n return html.replace(\n HASHLINK_RE,\n (_full, n: string) => `href=\"#${targets[n] ?? `page-block-${n}`}\"`,\n );\n}\n","import { parse, HTMLElement } from \"node-html-parser\";\nimport { slotKey, tokenFor, type ImageBox, type Slot } from \"./types.js\";\nimport { sectionIndexOf, sectionKeyOf } from \"./section.js\";\nimport { CDN_HOSTS } from \"../assets.js\";\n\nconst isCdnUrl = (v: string): boolean => CDN_HOSTS.some((h) => v.includes(h));\n\n// Blux media elements carry the image as data-attributes, not a live src: the\n// runtime reads `data-base` (CDN folder) + `data-size` (width) + `data-media`\n// (filename) and paints a background on a lazy `.load-media-element` span\n// (loading/unloading per viewport). We assemble the url deterministically —\n// `{data-base}w:{data-size}/{data-media}` (verified 200) — and bake it as an\n// inline background-image token so no runtime is needed. `data-bgmedia` is a\n// full-bleed layer whose filename wins over `data-media`. A second pass catches\n// any other CDN url still living in src/href/poster (e.g. a <video>), so the\n// frozen template leaks no CDN host at all.\n\n// Runtime-only url hints the (removed) Blux JS used; not rendered — drop them so\n// they don't leak a CDN host.\nfunction stripUrlHintAttrs(root: HTMLElement): void {\n for (const el of root.querySelectorAll(\"*\")) {\n for (const name of Object.keys(el.attributes)) {\n if (name === \"data-og-src\" || name.startsWith(\"data-swaps\")) {\n el.removeAttribute(name);\n }\n }\n }\n}\n\n/**\n * Tokenize every media url in the document as a `⟦i:KEY⟧` image slot. Pass 1:\n * data-attribute backgrounds → inline `background-image:url(token)`. Pass 2: any\n * remaining CDN url in `src`/`href`/`poster` → bare `token`. Keys are\n * `s{section}.i{n}` in document order.\n */\nexport function bakeImages(html: string): {\n html: string;\n slots: Slot[];\n boxes: Record<string, ImageBox>;\n} {\n const root = parse(html);\n const sectionIndex = sectionIndexOf(root);\n const counters = new Map<string, number>();\n const slots: Slot[] = [];\n // Painted box per image slot, so the render can ask the CDN for the size the\n // page needs instead of whatever variant the export happened to use. `settle`\n // stamped these on a laid-out page (`data-rd-box`); they are read off here,\n // where slot keys are assigned, and the attribute is stripped so the template\n // is byte-identical to what it was before the measurement existed.\n const boxes: Record<string, ImageBox> = {};\n\n const nextKey = (el: HTMLElement): string => {\n const section = sectionKeyOf(el, sectionIndex);\n const n = counters.get(section) ?? 0;\n counters.set(section, n + 1);\n return slotKey(section, \"image\", n);\n };\n\n // Pass 1: data-* backgrounds. The filename is ALWAYS `data-media`;\n // `data-bgmedia` is a boolean flag (\"1\") marking a full-bleed layer — it is\n // NOT a filename. When an element is unsized we default full-bleed layers to a\n // wide variant and inline images to the raw file.\n for (const el of root.querySelectorAll(\"[data-media]\")) {\n const base = el.getAttribute(\"data-base\");\n if (!base) continue;\n const media = el.getAttribute(\"data-media\");\n if (!media) continue;\n const isBg = el.getAttribute(\"data-bgmedia\") != null;\n const size = el.getAttribute(\"data-size\");\n const sizeSeg = size ? `w:${size}/` : isBg ? \"w:1600/\" : \"\";\n const url = `${base}${sizeSeg}${media}`;\n\n const key = nextKey(el);\n slots.push({ key, kind: \"image\", url, section: sectionKeyOf(el, sectionIndex) });\n\n // `data-size` is the CDN variant Blux served, i.e. the widest render that\n // exists — a ceiling, not a suggestion, because image CDNs upscale past it\n // rather than refusing. Recorded so the render never asks for more.\n const painted = /^(\\d+)x(\\d+)$/.exec(el.getAttribute(\"data-rd-box\") ?? \"\");\n if (painted) {\n boxes[key] = {\n w: Number(painted[1]),\n h: Number(painted[2]),\n source: size ? Number(size) : null,\n };\n }\n el.removeAttribute(\"data-rd-box\");\n\n const decl = `background-image:url(${tokenFor(\"image\", key)})`;\n const style = el.getAttribute(\"style\");\n el.setAttribute(\"style\", style ? `${style};${decl}` : decl);\n el.removeAttribute(\"data-base\");\n }\n\n // Pass 2: any remaining CDN url in src/href/poster (video, source, links…).\n for (const el of root.querySelectorAll(\"[src],[href],[poster]\")) {\n for (const attr of [\"src\", \"href\", \"poster\"] as const) {\n const v = el.getAttribute(attr);\n if (!v || !isCdnUrl(v)) continue;\n const key = nextKey(el);\n slots.push({ key, kind: \"image\", url: v, section: sectionKeyOf(el, sectionIndex) });\n el.setAttribute(attr, tokenFor(\"image\", key));\n }\n }\n\n // Any element that never became a slot keeps no measurement either.\n for (const el of root.querySelectorAll(\"[data-rd-box]\")) {\n el.removeAttribute(\"data-rd-box\");\n }\n\n stripUrlHintAttrs(root);\n return { html: root.toString(), slots, boxes };\n}\n","// Frozen-page freeze: shared types + token contract. A frozen site keeps the\n// Blux export's own markup as the layout (byte-faithful) and exposes only the\n// editable leaves — text runs and image urls — as Prismic slots. The template\n// carries a placeholder token per slot; the render substitutes current Prismic\n// values back in. See docs/superpowers/specs/2026-07-24-blux-frozen-page-design.md.\n\nimport type { MapConfig } from \"../grid/extract-map.js\";\n\nexport type SlotKind = \"text\" | \"image\";\n\nexport interface Slot {\n /** Stable, document-order key `s{section}.{t|i}{n}` (see `slotKey`). */\n key: string;\n kind: SlotKind;\n /** Present when kind === \"text\": the original text-node value. */\n text?: string;\n /** Present when kind === \"image\": the assembled (cloudfront) image url. */\n url?: string;\n /** Grouping label for editor sanity — the owning section key. */\n section: string;\n}\n\n/**\n * Slots a SITE declares, on top of the ones the freeze derives from the export.\n *\n * Every derived slot exists because the template carries a token for it. Some\n * editable content has no token and never can: the render composes it itself,\n * from markup the export never had. Two real cases on the-pointe — a `<video>`\n * poster (the export ships none, so there is no attribute to tokenize) and a\n * data panel the export baked as a flattened PNG (rebuilt as real text, so the\n * text exists only in the render).\n *\n * Without this, that content can only live in committed files and a deploy is\n * required to change a leasing number. Declaring it here puts it in Prismic\n * alongside everything else, and — because `blux migrate-frozen` PUTs the whole\n * document — keeps it there across re-migrations, which a row added by hand in\n * the Prismic UI would not survive.\n *\n * Kept generic on purpose: the tool serves several frozen sites, so it knows\n * only that extra slots exist, never what any one site puts in them.\n */\nexport interface ExtraSlotsFile {\n /** Free-form note for whoever opens the file; ignored by the freeze. */\n comment?: string;\n slots: Slot[];\n}\n\n/** Prefix reserved for site-declared slots, so a synthetic key can never\n * collide with a derived `s{n}.t{n}` / `h.i{n}` one. */\nexport const EXTRA_SLOT_PREFIX = \"x.\";\n\nexport interface FrozenManifest {\n site: string;\n uid: string;\n title: string;\n metaTitle?: string | undefined;\n metaImageUrl?: string | undefined;\n /** External stylesheet hrefs from the export <head> (e.g. Google Fonts) that\n * the render must re-inject — font metrics are load-bearing for layout. */\n fontLinks: string[];\n slots: Slot[];\n}\n\n/** The emitted freeze artifact: the manifest plus the repo files' bodies. */\n/**\n * One image's painted box, measured on the settled page, with the widest render\n * that exists behind it.\n *\n * Both halves are needed to size a request safely: the box says what the page\n * paints, and `source` is a hard ceiling because image CDNs UPSCALE past it —\n * asking a 123px badge for 900px took it from 4.9KB to 30KB — so an image whose\n * source is already at or below what its box needs must be left alone.\n */\nexport interface ImageBox {\n /** Painted width in CSS px at the settle viewport. */\n w: number;\n /** Painted height in CSS px at the settle viewport. */\n h: number;\n /** The CDN variant width the export was serving, or null if it declared none. */\n source: number | null;\n}\n\n/**\n * Painted boxes per image slot, emitted as `frozen/<uid>.image-boxes.json`.\n *\n * A sidecar rather than a field on each slot: the slots manifest is what\n * `blux migrate-frozen` PUTs into Prismic, and a measurement is a property of\n * the LAYOUT, not of the content an editor owns.\n */\nexport interface ImageBoxFile {\n /** Layout width the boxes were measured at — the freeze's own viewport. */\n viewport: number;\n boxes: Record<string, ImageBox>;\n}\n\nexport interface FrozenResult {\n manifest: FrozenManifest;\n /** Tokenized `<body>` innerHTML — the repo template. */\n templateHtml: string;\n /** The extracted `<style>` block text — the repo style file. */\n styleCss: string;\n /** Present when the export carries a Blux map widget whose mount survives\n * in the template — emitted as `frozen/<uid>.map.json`, hydrated by the\n * starter's frozen-map layer. */\n mapConfig?: MapConfig | undefined;\n /** Painted box per image slot — emitted as `frozen/<uid>.image-boxes.json`,\n * read by the starter's image-size layer to request the size the page\n * actually paints. */\n imageBoxes: ImageBoxFile;\n}\n\n/** The placeholder a slot's value is substituted for at render time.\n * Text: replaces the whole text node. Image: sits inside `url(...)`. */\nexport function tokenFor(kind: SlotKind, key: string): string {\n return `⟦${kind === \"text\" ? \"t\" : \"i\"}:${key}⟧`;\n}\n\n/** Build a slot key. `section` is the nearest-ancestor <section> index (or\n * \"h\" for chrome above the first section); `n` is a per-kind counter. */\nexport function slotKey(section: string, kind: SlotKind, n: number): string {\n return `${section}.${kind === \"text\" ? \"t\" : \"i\"}${n}`;\n}\n\n/** Matches any slot token; group 1 = \"t\"|\"i\", group 2 = key. Global — callers\n * that reuse it across `.test()`/`.replace()` should construct a fresh copy or\n * reset `lastIndex`. Use `TOKEN_RE()` to get a fresh instance. */\nexport const TOKEN_RE = (): RegExp => /⟦([ti]):([^⟧]+)⟧/g;\n","import { HTMLElement } from \"node-html-parser\";\n\n/** Map each <section> element to its document-order index. */\nexport function sectionIndexOf(root: HTMLElement): Map<HTMLElement, number> {\n const index = new Map<HTMLElement, number>();\n root.querySelectorAll(\"section\").forEach((s, i) => index.set(s, i));\n return index;\n}\n\n/** Nearest-ancestor <section> key (`s{index}`), or `h` for chrome (nav/footer)\n * that lives above/outside any <section>. */\nexport function sectionKeyOf(el: HTMLElement, sectionIndex: Map<HTMLElement, number>): string {\n let a: HTMLElement | null | undefined = el;\n while (a) {\n if (a.tagName === \"SECTION\" && sectionIndex.has(a)) {\n return `s${sectionIndex.get(a)}`;\n }\n a = a.parentNode as HTMLElement | null | undefined;\n }\n return \"h\";\n}\n","import { parse, HTMLElement, NodeType } from \"node-html-parser\";\nimport { slotKey, tokenFor, type Slot } from \"./types.js\";\nimport { sectionIndexOf, sectionKeyOf } from \"./section.js\";\n\n// Elements whose text is not editable page copy.\nconst SKIP = new Set([\"SCRIPT\", \"STYLE\", \"NOSCRIPT\", \"HEAD\", \"TITLE\", \"SVG\"]);\n\n/**\n * Replace every text node that carries CONTENT with a `⟦t:KEY⟧` token and\n * collect the originals as text slots. Keys are `s{section}.t{n}` in document\n * order so the template + manifest stay stable across re-freezes (golden\n * canary). The raw source text is preserved verbatim (entities and surrounding\n * whitespace) so a frozen render is byte-faithful before any Prismic edit.\n *\n * \"Carries content\" is decided on the DECODED text, not the raw source. Page\n * builders emit whitespace-only leaves as layout — a list item or table cell\n * holding ` ` purely to occupy a line — and those must stay literal in the\n * template rather than becoming CMS fields, for two reasons:\n *\n * 1. They are not editable copy. Nobody wants a Prismic field whose only\n * correct value is \"one blank line\".\n * 2. Prismic Rich Text CANNOT store a whitespace-only value. It round-trips to\n * \"\", the row collapses to its padding, and the page silently loses a line\n * of vertical rhythm — a defect that only shows up after the migration, on\n * the live site, in a place nobody thought to re-measure.\n *\n * `rawText.trim()` cannot see this: for a ` ` leaf it trims to the literal\n * string \" \", which is not empty, so the leaf looked like content. Testing\n * the decoded `.text` catches every spelling — ` `, ` `, ` `,\n * ` `, ` ` — because JS `String.trim()` strips the whole Unicode\n * whitespace class. A real character such as `&` still decodes to content\n * and is tokenized as before.\n */\nexport function tokenizeText(html: string): { html: string; slots: Slot[] } {\n const root = parse(html);\n const sectionIndex = sectionIndexOf(root);\n\n const counters = new Map<string, number>();\n const slots: Slot[] = [];\n\n const walk = (el: HTMLElement): void => {\n for (const child of el.childNodes) {\n if (child.nodeType === NodeType.TEXT_NODE) {\n const raw = child.rawText;\n // Decoded, so entity-encoded whitespace is treated as the whitespace it\n // is. A skipped leaf does not advance the section counter — same as a\n // plain-whitespace leaf, which this check has always skipped.\n if (raw && child.text.trim() !== \"\") {\n const section = sectionKeyOf(el, sectionIndex);\n const n = counters.get(section) ?? 0;\n counters.set(section, n + 1);\n const key = slotKey(section, \"text\", n);\n slots.push({ key, kind: \"text\", text: raw, section });\n child.rawText = tokenFor(\"text\", key);\n }\n } else if (child.nodeType === NodeType.ELEMENT_NODE) {\n const elChild = child as HTMLElement;\n if (!SKIP.has(elChild.tagName)) walk(elChild);\n }\n }\n };\n walk(root);\n\n return { html: root.toString(), slots };\n}\n","import { parse } from \"node-html-parser\";\n\n// Reveal effects hide content until the Blux runtime adds `block-effects-applied`\n// on scroll. With the runtime stripped, force the end-state so everything is\n// visible without JS. (`.block-effects{opacity:0}` is the initial rule.)\nexport const REVEAL_FORCE_CSS =\n \".block-effects{opacity:1!important;transform:none!important;animation:none!important;visibility:visible!important}\";\n\nexport interface Finalized {\n /** `<body>` inner HTML wrapped so the body's own class/style survive. */\n templateHtml: string;\n /** All page `<style>` blocks + the reveal-force override. */\n styleCss: string;\n title: string;\n metaTitle?: string | undefined;\n metaImageUrl?: string | undefined;\n /** External stylesheet hrefs (Google Fonts) to re-inject at render. */\n fontLinks: string[];\n}\n\n/**\n * Turn a tokenized settled document into the two repo artifacts: strip all\n * `<script>`, extract every `<style>` block (+ reveal-force) into a stylesheet,\n * and wrap the body's inner HTML in a `.frozen-root` div that carries the\n * body's own class/style (which `body.innerHTML` would otherwise drop).\n */\nexport function finalize(html: string): Finalized {\n const root = parse(html);\n const title = root.querySelector(\"title\")?.text?.trim() ?? \"\";\n const metaOf = (prop: string): string | undefined =>\n root.querySelector(`meta[property=\"${prop}\"]`)?.getAttribute(\"content\") ?? undefined;\n const metaTitle = metaOf(\"og:title\") ?? (title || undefined);\n const metaImageUrl = metaOf(\"og:image\");\n\n const fontLinks = root\n .querySelectorAll('link[rel=\"stylesheet\"]')\n .map((l) => l.getAttribute(\"href\"))\n .filter((h): h is string => !!h);\n\n const styles: string[] = [];\n for (const s of root.querySelectorAll(\"style\")) {\n styles.push(s.innerHTML);\n s.remove();\n }\n for (const sc of root.querySelectorAll(\"script\")) sc.remove();\n // Remove the Blux runtime's lazy media spans: they carry raw cloudfront urls\n // for whichever images were on-screen at snapshot (the rest unloaded), and the\n // deterministic baked token on the parent media div is what renders instead.\n for (const holder of root.querySelectorAll(\".load-media-holder, .load-media-element\")) {\n holder.remove();\n }\n\n const body = root.querySelector(\"body\");\n const inner = body ? body.innerHTML : root.toString();\n const bodyClass = body?.getAttribute(\"class\") ?? \"\";\n const bodyStyle = body?.getAttribute(\"style\") ?? \"\";\n const cls = `frozen-root ${bodyClass}`.trim();\n const styleAttr = bodyStyle ? ` style=\"${bodyStyle}\"` : \"\";\n const templateHtml = `<div class=\"${cls}\"${styleAttr}>${inner}</div>`;\n\n const styleCss = [...styles, REVEAL_FORCE_CSS].join(\"\\n\");\n return { templateHtml, styleCss, title, metaTitle, metaImageUrl, fontLinks };\n}\n","// Site-declared slots, merged into the freeze manifest alongside the derived\n// ones. See `ExtraSlotsFile` in types.ts for why they exist.\n//\n// This module is deliberately incurious about what a site declares: it checks\n// the shape, the reserved prefix and key uniqueness, and nothing else. The\n// freeze tool serves several frozen sites and must not learn any one site's\n// content model.\n\nimport { EXTRA_SLOT_PREFIX, type Slot } from \"./types.js\";\n\n/** Thrown with the offending path so a bad declaration names itself. */\nfunction fail(path: string, why: string): never {\n throw new Error(`extra slots: ${path} ${why}`);\n}\n\nfunction asString(v: unknown, path: string): string {\n if (typeof v !== \"string\" || v.trim() === \"\") {\n fail(path, `must be a non-empty string (got ${JSON.stringify(v)})`);\n }\n return v as string;\n}\n\n/**\n * Validate a site's extra-slots declaration against the slots the freeze\n * derived, returning the extras to append.\n *\n * A declaration that is wrong must stop the freeze rather than ship: these\n * slots become live CMS fields, and a key that collides with a derived one\n * would let a site-declared value silently overwrite real page content when\n * the render builds its value map.\n */\nexport function validateExtraSlots(file: unknown, derived: Slot[]): Slot[] {\n if (file === undefined || file === null) return [];\n if (typeof file !== \"object\" || Array.isArray(file)) {\n fail(\"root\", \"must be an object with a `slots` array\");\n }\n // Read as `unknown`, not as `ExtraSlotsFile` — the whole point here is that\n // the input has not been checked yet, and typing it as the target would make\n // every access below assert what this function is supposed to prove.\n const slots: unknown = (file as { slots?: unknown }).slots;\n if (!Array.isArray(slots)) fail(\"slots\", \"must be an array\");\n\n const derivedKeys = new Set(derived.map((s) => s.key));\n const seen = new Set<string>();\n const out: Slot[] = [];\n\n slots.forEach((raw, i) => {\n const at = `slots[${i}]`;\n if (typeof raw !== \"object\" || raw === null) fail(at, \"must be an object\");\n const s = raw as Record<string, unknown>;\n\n const key = asString(s.key, `${at}.key`);\n if (!key.startsWith(EXTRA_SLOT_PREFIX)) {\n fail(\n `${at}.key`,\n `must start with \"${EXTRA_SLOT_PREFIX}\" so it cannot collide with a derived slot (got \"${key}\")`,\n );\n }\n if (derivedKeys.has(key)) fail(`${at}.key`, `collides with a derived slot (\"${key}\")`);\n if (seen.has(key)) fail(`${at}.key`, `is declared twice (\"${key}\")`);\n seen.add(key);\n\n const kind = s.kind;\n if (kind !== \"text\" && kind !== \"image\") {\n fail(`${at}.kind`, `must be \"text\" or \"image\" (got ${JSON.stringify(kind)})`);\n }\n\n const section = typeof s.section === \"string\" && s.section.trim() ? s.section : \"x\";\n if (kind === \"text\") {\n out.push({ key, kind, text: asString(s.text, `${at}.text`), section });\n } else {\n out.push({ key, kind, url: asString(s.url, `${at}.url`), section });\n }\n });\n\n return out;\n}\n"],"mappings":"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","names":["readFile","join","parse","parse","parse","parse","parse","parse","readFile","join"]}
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import "./chunk-2SLED3NB.js";
|
|
1
2
|
import "./chunk-KKIKJWM2.js";
|
|
2
3
|
import {
|
|
3
4
|
draftReportForSite
|
|
@@ -14,7 +15,6 @@ import {
|
|
|
14
15
|
import {
|
|
15
16
|
findDueReports
|
|
16
17
|
} from "./chunk-D4FVAIEJ.js";
|
|
17
|
-
import "./chunk-2SLED3NB.js";
|
|
18
18
|
import {
|
|
19
19
|
upgradeSvelte4to5
|
|
20
20
|
} from "./chunk-B5ZUQZFL.js";
|
|
@@ -73,7 +73,7 @@ function dashboardBaseUrl() {
|
|
|
73
73
|
}
|
|
74
74
|
async function runReportCommand(slug, opts) {
|
|
75
75
|
if (opts.digest) {
|
|
76
|
-
const { runDigest } = await import("./digest-
|
|
76
|
+
const { runDigest } = await import("./digest-44QNZZKM.js");
|
|
77
77
|
return runDigest({ baseUrl: dashboardBaseUrl() });
|
|
78
78
|
}
|
|
79
79
|
if (opts.sendReady) {
|
|
@@ -272,4 +272,4 @@ export {
|
|
|
272
272
|
parseSingleSiteReportType,
|
|
273
273
|
runReportCommand
|
|
274
274
|
};
|
|
275
|
-
//# sourceMappingURL=report-
|
|
275
|
+
//# sourceMappingURL=report-MLCIIML5.js.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BUYER_KEYWORDS,
|
|
3
|
+
DISPOSABLE_EMAIL_DOMAINS,
|
|
4
|
+
SPAM_KEYWORDS,
|
|
5
|
+
SPAM_THRESHOLD,
|
|
6
|
+
classifySpam
|
|
7
|
+
} from "./chunk-SNSTJLQW.js";
|
|
8
|
+
export {
|
|
9
|
+
BUYER_KEYWORDS,
|
|
10
|
+
DISPOSABLE_EMAIL_DOMAINS,
|
|
11
|
+
SPAM_KEYWORDS,
|
|
12
|
+
SPAM_THRESHOLD,
|
|
13
|
+
classifySpam
|
|
14
|
+
};
|
|
15
|
+
//# sourceMappingURL=spam-classifier-XLBFCO7G.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
|
@@ -11,7 +11,7 @@ function parseExtraFields(raw) {
|
|
|
11
11
|
}
|
|
12
12
|
async function rescoreNewSubmissions(db, opts) {
|
|
13
13
|
const { listSubmissionsFiltered, rescoreSubmissionSpam } = await import("./submissions-3I3GAQAX.js");
|
|
14
|
-
const { classifySpam, SPAM_THRESHOLD } = await import("./spam-classifier-
|
|
14
|
+
const { classifySpam, SPAM_THRESHOLD } = await import("./spam-classifier-XLBFCO7G.js");
|
|
15
15
|
const rows = [];
|
|
16
16
|
const PAGE = 500;
|
|
17
17
|
for (let offset = 0; ; offset += PAGE) {
|
|
@@ -79,7 +79,7 @@ async function runSubmissionsCommand(action, opts) {
|
|
|
79
79
|
}
|
|
80
80
|
const apply = opts.apply === true;
|
|
81
81
|
const { openDb, readDbConfig } = await import("./client-X5WUJWDE.js");
|
|
82
|
-
const { SPAM_THRESHOLD } = await import("./spam-classifier-
|
|
82
|
+
const { SPAM_THRESHOLD } = await import("./spam-classifier-XLBFCO7G.js");
|
|
83
83
|
const cfg = opts.url ? { url: opts.url } : readDbConfig();
|
|
84
84
|
const db = await openDb(cfg);
|
|
85
85
|
const { scanned, flagged } = await rescoreNewSubmissions(db, { apply });
|
|
@@ -119,4 +119,4 @@ export {
|
|
|
119
119
|
rescoreNewSubmissions,
|
|
120
120
|
runSubmissionsCommand
|
|
121
121
|
};
|
|
122
|
-
//# sourceMappingURL=submissions-
|
|
122
|
+
//# sourceMappingURL=submissions-RAYGOASP.js.map
|
package/package.json
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/forms/notify.ts","../src/recipes/forms-notify-target.ts","../src/cli/commands/forms-notify-target.ts"],"sourcesContent":["import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { SubmissionRow, NotifyStatus } from \"../reports/submission-row.js\";\nimport type { ResendSendInput } from \"../reports/send/resend.js\";\nimport { escapeHtml } from \"../util/html.js\";\n\nconst FORMS_FROM = \"forms@reddoorla.com\";\nconst FALLBACK_REPLY_TO = \"info@reddoorla.com\";\n\n// Single-operator fleet fallback when OPERATOR_EMAIL is unset. Deliberately the\n// monitored personal inbox (not the digest's info@ alias) — a missed pre-launch\n// LEAD is higher-stakes than a missed digest, so it should land somewhere watched.\nconst OPERATOR_FALLBACK = \"tucker@reddoorla.com\";\n\nfunction operatorEmail(): string {\n return process.env.OPERATOR_EMAIL?.trim() || OPERATOR_FALLBACK;\n}\n\n/** Strip characters that would break an RFC 5322 display name. */\nfunction displayName(raw: string): string {\n return raw.replace(/[\"\\r\\n]/g, \"\").trim() || \"Reddoor\";\n}\n\nfunction pocAddress(site: WebsiteRow): string | null {\n return site.pointOfContact ?? site.reportRecipientsTo ?? null;\n}\n\n/** Coerce a string|string[] recipient config into a clean, de-duped address list. */\nfunction normalizeRecipients(v: string | string[] | undefined): string[] {\n const arr = Array.isArray(v) ? v : v ? [v] : [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const raw of arr) {\n if (typeof raw !== \"string\") continue;\n const t = raw.trim();\n if (t && !seen.has(t)) {\n seen.add(t);\n out.push(t);\n }\n }\n return out;\n}\n\nexport type Recipients = { to: string[]; cc: string[] };\n\n/**\n * Where a submission notification goes.\n * - Pre-launch (status !== \"maintenance\"): the operator only — no routing, no CC.\n * Preserves the verify guard (flip a site to \"launch period\" to route tests to\n * yourself).\n * - Maintenance + a `Notify Routing` config: address by the routing field's value\n * (`extraFields[field]`) → its matched route, else the config `default`; CC from\n * the config. If nothing resolves, fall through to the single POC.\n * - Maintenance, no routing: the single site POC (pointOfContact ?? reportRecipientsTo).\n * Returns null only when nothing resolves — the lead is still persisted; notify skips.\n */\nexport function resolveRecipients(site: WebsiteRow, submission: SubmissionRow): Recipients | null {\n if (site.status !== \"maintenance\") {\n return { to: [operatorEmail()], cc: [] };\n }\n const routing = site.notifyRouting;\n if (routing) {\n const value = parseExtraFields(submission.extraFields)[routing.field];\n const match = typeof value === \"string\" ? routing.routes[value] : undefined;\n const to = normalizeRecipients(match ?? routing.default);\n if (to.length > 0) return { to, cc: normalizeRecipients(routing.cc) };\n // routing matched nothing → fall through to the POC below\n }\n const poc = pocAddress(site);\n return poc ? { to: [poc], cc: [] } : null;\n}\n\n/** Who a submission to this site would notify, answered WITHOUT sending one. */\nexport type NotifyTarget = {\n /** `client` means a real point of contact receives it. `operator` means the\n * pre-launch guard is holding and only you do. `nobody` means nothing\n * resolves — the lead is still stored, but no notification goes out. */\n audience: \"client\" | \"operator\" | \"nobody\";\n /** Every address a submission could reach, across ALL routing branches. */\n to: string[];\n cc: string[];\n /** Plain-language reason, naming the field that decided it. */\n reason: string;\n};\n\n/** A submission that exists only to ask resolveRecipients a question. */\nfunction probeSubmission(extraFields: string | null): SubmissionRow {\n return {\n id: \"probe\",\n submissionId: null,\n siteId: \"probe\",\n formType: \"contact\" as SubmissionRow[\"formType\"],\n name: \"probe\",\n email: \"probe@example.com\",\n phone: null,\n message: null,\n extraFields,\n sourceUrl: null,\n utm: null,\n submittedAt: null,\n status: \"new\" as SubmissionRow[\"status\"],\n notifyStatus: \"pending\" as NotifyStatus,\n resendMessageId: null,\n };\n}\n\n/**\n * Resolve who a form submission would notify, for a site, right now.\n *\n * This exists because the pre-launch guard had no feedback. Its whole state is\n * one Airtable `Status` cell nobody is looking at while testing, the site\n * reports nothing about it, and the only way to learn the answer was to submit\n * a form and read the `resend_message_id` afterwards. On 2026-08-03 an intended\n * flip never landed and a real client received a test lead — an email cannot be\n * recalled. A guard whose state you cannot see is a guard you will eventually\n * assume is on.\n *\n * Every address here comes back out of `resolveRecipients` itself rather than\n * from a second reading of the same rules — for a routed site each branch is\n * probed with a synthetic submission and the results unioned. A copy of the\n * routing logic that drifted would be worse than no answer at all, because it\n * would be a confident wrong one.\n */\nexport function describeNotifyTarget(site: WebsiteRow): NotifyTarget {\n if (site.status !== \"maintenance\") {\n const guarded = resolveRecipients(site, probeSubmission(null));\n return {\n audience: \"operator\",\n to: guarded?.to ?? [],\n cc: [],\n reason: `Status is ${site.status === null ? \"blank\" : `\"${site.status}\"`}, not \"maintenance\" — the pre-launch guard routes every notification to the operator.`,\n };\n }\n\n const routing = site.notifyRouting;\n if (!routing) {\n const direct = resolveRecipients(site, probeSubmission(null));\n if (!direct || direct.to.length === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintenance\" but the site has no Point of Contact and no Notify Routing — the lead is stored and no notification is sent.`,\n };\n }\n return {\n audience: \"client\",\n to: direct.to,\n cc: direct.cc,\n reason: `Status is \"maintenance\" — notifications go to the site's Point of Contact.`,\n };\n }\n\n // Probe every declared route value, plus one value that matches none so the\n // `default` (and the POC fall-through behind it) is covered too.\n const to = new Set<string>();\n const cc = new Set<string>();\n const values = [...Object.keys(routing.routes), \"\u0000no-such-route\"];\n for (const value of values) {\n const r = resolveRecipients(site, probeSubmission(JSON.stringify({ [routing.field]: value })));\n r?.to.forEach((a) => to.add(a));\n r?.cc.forEach((a) => cc.add(a));\n }\n if (to.size === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintenance\" with Notify Routing on \"${routing.field}\", but no route, default or Point of Contact resolves to an address.`,\n };\n }\n return {\n audience: \"client\",\n to: [...to],\n cc: [...cc],\n reason: `Status is \"maintenance\" with Notify Routing on \"${routing.field}\" — the recipient depends on that field's value; every address it could reach is listed.`,\n };\n}\n\n/** Humanize an extraFields key for display: \"appointment_date\" → \"Appointment date\". */\nfunction humanizeKey(k: string): string {\n const spaced = k.replace(/[_-]+/g, \" \").trim();\n return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : k;\n}\n\nfunction formatValue(v: unknown): string {\n if (v === null || v === undefined) return \"—\";\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n return JSON.stringify(v);\n}\n\n/** Parse the stored `extraFields` JSON into a plain object — bad JSON or a\n * non-object yields {} (never throws). Shared by the email renderer and routing. */\nfunction parseExtraFields(raw: string | null): Record<string, unknown> {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n /* malformed JSON → no fields */\n }\n return {};\n}\n\nfunction extraFieldRows(raw: string | null): Array<[string, string]> {\n return Object.entries(parseExtraFields(raw))\n .filter(([, v]) => !(typeof v === \"string\" && v.trim() === \"\"))\n .map(([k, v]) => [humanizeKey(k), formatValue(v)] as [string, string]);\n}\n\nfunction fieldsTable(submission: SubmissionRow): string {\n const rows: Array<[string, string]> = [\n [\"Form\", submission.formType],\n [\"Name\", submission.name || \"—\"],\n [\"Email\", submission.email || \"—\"],\n ];\n if (submission.phone) rows.push([\"Phone\", submission.phone]);\n // Site-specific context (the artwork an inquiry is about, the event an rsvp is\n // for, etc.) lives in extraFields — surface it so the recipient sees what the\n // submitter was looking at, not just their name and email.\n rows.push(...extraFieldRows(submission.extraFields));\n if (submission.sourceUrl) rows.push([\"Page\", submission.sourceUrl]);\n if (submission.utm) rows.push([\"UTM\", submission.utm]);\n const body = rows\n .map(([k, v]) => `<tr><td><strong>${escapeHtml(k)}</strong></td><td>${escapeHtml(v)}</td></tr>`)\n .join(\"\");\n const message = submission.message\n ? `<p style=\"white-space:pre-wrap\">${escapeHtml(submission.message)}</p>`\n : \"\";\n return `<table>${body}</table>${message}`;\n}\n\n/** POC notification — the primary email; null when the site has no contact address. */\nexport function buildPocNotification(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n const recipients = resolveRecipients(site, submission);\n if (!recipients || recipients.to.length === 0) return null;\n const input: ResendSendInput = {\n from: `${displayName(site.name)} Forms <${FORMS_FROM}>`,\n to: recipients.to,\n subject: `New ${submission.formType} from ${site.name}`,\n html: `<h2>New ${escapeHtml(submission.formType)} submission — ${escapeHtml(\n site.name,\n )}</h2>${fieldsTable(submission)}`,\n };\n if (recipients.cc.length > 0) input.cc = recipients.cc;\n // Reply straight to the lead.\n if (submission.email) input.replyTo = submission.email;\n return input;\n}\n\n/** Autoresponder to the submitter — null when there's no submitter email. */\nexport function buildAutoresponder(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n if (!submission.email) return null;\n const intro = site.copyIntro ?? `Thanks for reaching out to ${site.name}.`;\n const contact = site.copyContact ?? \"We've received your message and will be in touch soon.\";\n const footer = site.copyFooter ?? site.name;\n return {\n from: `${displayName(site.name)} <${FORMS_FROM}>`,\n to: [submission.email],\n replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,\n subject: \"We got your message\",\n html: `<p>${escapeHtml(intro)}</p><p>${escapeHtml(contact)}</p><p>${escapeHtml(footer)}</p>`,\n };\n}\n\nexport type NotifyDeps = {\n send: (input: ResendSendInput) => Promise<{ messageId: string }>;\n};\n\nexport type NotifyOutcome = { status: NotifyStatus; messageId: string | null };\n\n/**\n * Send the POC notification (primary — drives notifyStatus) then the submitter\n * autoresponder (best-effort — logged, never changes the outcome). The submission\n * is already persisted before this runs, so a Resend outage degrades to\n * notifyStatus=\"failed\", never a lost lead.\n */\nexport async function notifySubmission(\n deps: NotifyDeps,\n site: WebsiteRow,\n submission: SubmissionRow,\n): Promise<NotifyOutcome> {\n const poc = buildPocNotification(site, submission);\n let outcome: NotifyOutcome;\n if (!poc) {\n outcome = { status: \"skipped\", messageId: null };\n } else {\n try {\n const { messageId } = await deps.send(poc);\n outcome = { status: \"sent\", messageId };\n } catch (err) {\n console.error(`[submissions] POC notification failed: ${String(err)}`);\n outcome = { status: \"failed\", messageId: null };\n }\n }\n const auto = buildAutoresponder(site, submission);\n if (auto) {\n try {\n await deps.send(auto);\n } catch (err) {\n console.error(`[submissions] autoresponder failed: ${String(err)}`);\n }\n }\n return outcome;\n}\n\n/**\n * Build the ingest `notify` dependency from a Resend send fn — or `null` when the\n * Resend client couldn't even be constructed (e.g. `RESEND_API_KEY` unset). A null\n * send marks the notification `failed` WITHOUT attempting it, so a Resend\n * misconfiguration degrades to a captured-but-unemailed lead rather than aborting\n * ingest and losing it. Mirrors the in-flight failure isolation in notifySubmission.\n */\nexport function makeNotify(\n send: NotifyDeps[\"send\"] | null,\n): (site: WebsiteRow, submission: SubmissionRow) => Promise<NotifyOutcome> {\n return (site, submission) =>\n send\n ? notifySubmission({ send }, site, submission)\n : Promise.resolve({ status: \"failed\", messageId: null });\n}\n","import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateSiteField,\n type Status,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { describeNotifyTarget, type NotifyTarget } from \"../forms/notify.js\";\n\n/** The Airtable column the pre-launch guard actually lives in. */\nexport const STATUS_COLUMN = \"Status\";\n\n/** The two ends of the verify flip. Deliberately the ONLY transition this\n * performs: a site in any other status is already guarded (or is deliberately\n * something else, like \"hosting\"), and silently rewriting that would be the\n * same class of unseen change as the incident. */\nexport const LIVE_STATUS: Status = \"maintenance\";\nexport const VERIFY_STATUS: Status = \"launch period\";\n\nexport type FormsNotifyTargetDeps = {\n base?: AirtableBase;\n /** Site slug or the Airtable Websites NAME (both accepted). */\n site: string;\n /** `on` routes notifications to the operator; `off` restores. Omit to read. */\n set?: \"on\" | \"off\";\n /** Status to restore with `--set off`. Required, never inferred. */\n restore?: string;\n};\n\nexport type FormsNotifyTargetResult = {\n site: string;\n status: Status | null;\n target: NotifyTarget;\n /** Present only when a flip was attempted. `confirmed` is the whole point:\n * it comes from RE-READING the row, not from the write call returning. */\n flip?: { from: Status | null; to: Status; confirmed: boolean };\n};\n\nfunction findSite(rows: WebsiteRow[], site: string): WebsiteRow | undefined {\n const wanted = site.trim().toLowerCase();\n return rows.find(\n (r) => siteSlug(r.name) === siteSlug(site) || r.name.trim().toLowerCase() === wanted,\n );\n}\n\n/**\n * Answer \"who would a form submission on this site email?\" — and optionally\n * flip the pre-launch guard, confirming the flip by reading it back.\n *\n * The guard is a single Airtable `Status` cell. Nothing between \"I intended to\n * flip it\" and \"the client received a test lead\" reported the current state, so\n * on 2026-08-03 a flip that never landed sent a real client a test submission.\n * The fix is not a better intention, it is feedback: this reads the row back\n * after every write and refuses to call the flip confirmed on anything less.\n */\nexport async function formsNotifyTarget(\n deps: FormsNotifyTargetDeps,\n): Promise<FormsNotifyTargetResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const rows = await listWebsites(base);\n const row = findSite(rows, deps.site);\n if (!row) {\n // The Websites NAME is not the repo slug (\"Sonder\", not \"gallerysonder\"),\n // and that mismatch has cost time before — so name the near misses rather\n // than making the operator go read Airtable to find the spelling.\n const needle = siteSlug(deps.site);\n const near = rows\n .map((r) => r.name)\n .filter((n) => siteSlug(n).includes(needle) || needle.includes(siteSlug(n)));\n throw Object.assign(\n new Error(\n `No Websites row matches '${deps.site}'. The Websites NAME is not always the repo ` +\n `slug (Sonder, not gallerysonder).` +\n (near.length > 0 ? ` Did you mean: ${near.join(\", \")}?` : \"\"),\n ),\n { exitCode: 2 },\n );\n }\n\n if (!deps.set) {\n return { site: row.name, status: row.status, target: describeNotifyTarget(row) };\n }\n\n const to = deps.set === \"on\" ? VERIFY_STATUS : (deps.restore?.trim() as Status | undefined);\n if (deps.set === \"off\" && !to) {\n throw Object.assign(\n new Error(\n `--set off needs --restore <status>: the status to return to is never inferred. ` +\n `Guessing \"${LIVE_STATUS}\" for a site that was \"hosting\" or \"legacy\" would start ` +\n `sending real client notifications — the inverse of the failure this command exists ` +\n `to prevent.`,\n ),\n { exitCode: 2 },\n );\n }\n // Only ever flip a LIVE site into verify mode. A site already outside\n // \"maintenance\" is guarded already, and rewriting its status would destroy a\n // real value nobody asked us to touch.\n if (deps.set === \"on\" && row.status !== LIVE_STATUS) {\n throw Object.assign(\n new Error(\n `${row.name} is \"${row.status ?? \"blank\"}\", not \"${LIVE_STATUS}\" — notifications ` +\n `already go to the operator only, so there is nothing to flip. Leaving the status ` +\n `untouched.`,\n ),\n { exitCode: 2 },\n );\n }\n\n await updateSiteField(base, row.id, STATUS_COLUMN, to!);\n\n // Read it back. The write returning is NOT evidence the field changed.\n const after = findSite(await listWebsites(base), deps.site);\n if (!after) {\n throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {\n exitCode: 1,\n });\n }\n return {\n site: after.name,\n status: after.status,\n target: describeNotifyTarget(after),\n flip: { from: row.status, to: to!, confirmed: after.status === to },\n };\n}\n","import {\n formsNotifyTarget,\n LIVE_STATUS,\n VERIFY_STATUS,\n type FormsNotifyTargetResult,\n} from \"../../recipes/forms-notify-target.js\";\n\nexport type FormsNotifyTargetCommandOptions = {\n set?: string;\n restore?: string;\n cwd?: string;\n};\n\nconst AUDIENCE_LABEL = {\n client: \"THE CLIENT\",\n operator: \"OPERATOR ONLY\",\n nobody: \"NOBODY\",\n} as const;\n\n/** The line that decides whether it is safe to test-submit. Deliberately the\n * loudest thing on screen: the incident happened because the answer was\n * invisible, not because it was subtle. */\nexport function formatNotifyTarget(r: FormsNotifyTargetResult): string {\n const lines: string[] = [];\n if (r.flip) {\n const arrow = `${r.flip.from ?? \"blank\"} → ${r.flip.to}`;\n lines.push(\n r.flip.confirmed\n ? `${r.site} Status: ${arrow} ✓ confirmed by read-back`\n : `${r.site} Status: ${arrow} ✗ NOT CONFIRMED — the row still reads \"${r.status ?? \"blank\"}\". Nothing was verified; do not test-submit.`,\n );\n } else {\n lines.push(`${r.site} Status: ${r.status ?? \"blank\"}`);\n }\n\n lines.push(`A submission right now would notify: ${AUDIENCE_LABEL[r.target.audience]}`);\n if (r.target.to.length > 0) lines.push(` to: ${r.target.to.join(\", \")}`);\n if (r.target.cc.length > 0) lines.push(` cc: ${r.target.cc.join(\", \")}`);\n lines.push(` ${r.target.reason}`);\n\n if (r.target.audience === \"client\") {\n lines.push(\n \"\",\n `⚠️ A test submission WILL email the client, and email cannot be recalled.`,\n ` Route it to yourself first: reddoor forms-notify-target ${r.site} --set on`,\n );\n }\n if (r.flip?.confirmed && r.flip.to === VERIFY_STATUS) {\n lines.push(\n \"\",\n `Safe to test. When you are done, restore it:`,\n ` reddoor forms-notify-target ${r.site} --set off --restore ${r.flip.from ?? LIVE_STATUS}`,\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * `forms-notify-target <site>` — show who a form submission would email, and\n * optionally flip the pre-launch guard with a read-back confirmation.\n *\n * Read-only by default: answering the question must never be riskier than not\n * asking it.\n */\nexport async function runFormsNotifyTargetCommand(\n site: string | undefined,\n opts: FormsNotifyTargetCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (!site?.trim()) {\n return { output: \"forms-notify-target requires <site> (slug or Airtable name)\", code: 2 };\n }\n const set = opts.set?.trim().toLowerCase();\n if (set !== undefined && set !== \"on\" && set !== \"off\") {\n return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };\n }\n try {\n const result = await formsNotifyTarget({\n site: site.trim(),\n ...(set ? { set: set as \"on\" | \"off\" } : {}),\n ...(opts.restore ? { restore: opts.restore } : {}),\n });\n // An unconfirmed flip must not exit 0: a script (or a person skimming) that\n // reads exit status would otherwise take \"I flipped it\" on faith, which is\n // exactly the assumption that sent a client a test lead.\n return {\n output: formatNotifyTarget(result),\n code: result.flip && !result.flip.confirmed ? 1 : 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":";;;;;;;;;;;;;AAWA,IAAM,oBAAoB;AAE1B,SAAS,gBAAwB;AAC/B,SAAO,QAAQ,IAAI,gBAAgB,KAAK,KAAK;AAC/C;AAOA,SAAS,WAAW,MAAiC;AACnD,SAAO,KAAK,kBAAkB,KAAK,sBAAsB;AAC3D;AAGA,SAAS,oBAAoB,GAA4C;AACvE,QAAM,MAAM,MAAM,QAAQ,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,IAAI,CAAC;AAC9C,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,MAAgB,CAAC;AACvB,aAAW,OAAO,KAAK;AACrB,QAAI,OAAO,QAAQ,SAAU;AAC7B,UAAM,IAAI,IAAI,KAAK;AACnB,QAAI,KAAK,CAAC,KAAK,IAAI,CAAC,GAAG;AACrB,WAAK,IAAI,CAAC;AACV,UAAI,KAAK,CAAC;AAAA,IACZ;AAAA,EACF;AACA,SAAO;AACT;AAeO,SAAS,kBAAkB,MAAkB,YAA8C;AAChG,MAAI,KAAK,WAAW,eAAe;AACjC,WAAO,EAAE,IAAI,CAAC,cAAc,CAAC,GAAG,IAAI,CAAC,EAAE;AAAA,EACzC;AACA,QAAM,UAAU,KAAK;AACrB,MAAI,SAAS;AACX,UAAM,QAAQ,iBAAiB,WAAW,WAAW,EAAE,QAAQ,KAAK;AACpE,UAAM,QAAQ,OAAO,UAAU,WAAW,QAAQ,OAAO,KAAK,IAAI;AAClE,UAAM,KAAK,oBAAoB,SAAS,QAAQ,OAAO;AACvD,QAAI,GAAG,SAAS,EAAG,QAAO,EAAE,IAAI,IAAI,oBAAoB,QAAQ,EAAE,EAAE;AAAA,EAEtE;AACA,QAAM,MAAM,WAAW,IAAI;AAC3B,SAAO,MAAM,EAAE,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,EAAE,IAAI;AACvC;AAgBA,SAAS,gBAAgB,aAA2C;AAClE,SAAO;AAAA,IACL,IAAI;AAAA,IACJ,cAAc;AAAA,IACd,QAAQ;AAAA,IACR,UAAU;AAAA,IACV,MAAM;AAAA,IACN,OAAO;AAAA,IACP,OAAO;AAAA,IACP,SAAS;AAAA,IACT;AAAA,IACA,WAAW;AAAA,IACX,KAAK;AAAA,IACL,aAAa;AAAA,IACb,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,iBAAiB;AAAA,EACnB;AACF;AAmBO,SAAS,qBAAqB,MAAgC;AACnE,MAAI,KAAK,WAAW,eAAe;AACjC,UAAM,UAAU,kBAAkB,MAAM,gBAAgB,IAAI,CAAC;AAC7D,WAAO;AAAA,MACL,UAAU;AAAA,MACV,IAAI,SAAS,MAAM,CAAC;AAAA,MACpB,IAAI,CAAC;AAAA,MACL,QAAQ,aAAa,KAAK,WAAW,OAAO,UAAU,IAAI,KAAK,MAAM,GAAG;AAAA,IAC1E;AAAA,EACF;AAEA,QAAM,UAAU,KAAK;AACrB,MAAI,CAAC,SAAS;AACZ,UAAM,SAAS,kBAAkB,MAAM,gBAAgB,IAAI,CAAC;AAC5D,QAAI,CAAC,UAAU,OAAO,GAAG,WAAW,GAAG;AACrC,aAAO;AAAA,QACL,UAAU;AAAA,QACV,IAAI,CAAC;AAAA,QACL,IAAI,CAAC;AAAA,QACL,QAAQ;AAAA,MACV;AAAA,IACF;AACA,WAAO;AAAA,MACL,UAAU;AAAA,MACV,IAAI,OAAO;AAAA,MACX,IAAI,OAAO;AAAA,MACX,QAAQ;AAAA,IACV;AAAA,EACF;AAIA,QAAM,KAAK,oBAAI,IAAY;AAC3B,QAAM,KAAK,oBAAI,IAAY;AAC3B,QAAM,SAAS,CAAC,GAAG,OAAO,KAAK,QAAQ,MAAM,GAAG,iBAAgB;AAChE,aAAW,SAAS,QAAQ;AAC1B,UAAM,IAAI,kBAAkB,MAAM,gBAAgB,KAAK,UAAU,EAAE,CAAC,QAAQ,KAAK,GAAG,MAAM,CAAC,CAAC,CAAC;AAC7F,OAAG,GAAG,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAC9B,OAAG,GAAG,QAAQ,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;AAAA,EAChC;AACA,MAAI,GAAG,SAAS,GAAG;AACjB,WAAO;AAAA,MACL,UAAU;AAAA,MACV,IAAI,CAAC;AAAA,MACL,IAAI,CAAC;AAAA,MACL,QAAQ,mDAAmD,QAAQ,KAAK;AAAA,IAC1E;AAAA,EACF;AACA,SAAO;AAAA,IACL,UAAU;AAAA,IACV,IAAI,CAAC,GAAG,EAAE;AAAA,IACV,IAAI,CAAC,GAAG,EAAE;AAAA,IACV,QAAQ,mDAAmD,QAAQ,KAAK;AAAA,EAC1E;AACF;AAiBA,SAAS,iBAAiB,KAA6C;AACrE,MAAI,CAAC,IAAK,QAAO,CAAC;AAClB,MAAI;AACF,UAAM,SAAS,KAAK,MAAM,GAAG;AAC7B,QAAI,UAAU,OAAO,WAAW,YAAY,CAAC,MAAM,QAAQ,MAAM,GAAG;AAClE,aAAO;AAAA,IACT;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO,CAAC;AACV;;;AChMO,IAAM,gBAAgB;AAMtB,IAAM,cAAsB;AAC5B,IAAM,gBAAwB;AAqBrC,SAAS,SAAS,MAAoB,MAAsC;AAC1E,QAAM,SAAS,KAAK,KAAK,EAAE,YAAY;AACvC,SAAO,KAAK;AAAA,IACV,CAAC,MAAM,SAAS,EAAE,IAAI,MAAM,SAAS,IAAI,KAAK,EAAE,KAAK,KAAK,EAAE,YAAY,MAAM;AAAA,EAChF;AACF;AAYA,eAAsB,kBACpB,MACkC;AAClC,QAAM,OAAO,KAAK,QAAQ,SAAS,mBAAmB,CAAC;AACvD,QAAM,OAAO,MAAM,aAAa,IAAI;AACpC,QAAM,MAAM,SAAS,MAAM,KAAK,IAAI;AACpC,MAAI,CAAC,KAAK;AAIR,UAAM,SAAS,SAAS,KAAK,IAAI;AACjC,UAAM,OAAO,KACV,IAAI,CAAC,MAAM,EAAE,IAAI,EACjB,OAAO,CAAC,MAAM,SAAS,CAAC,EAAE,SAAS,MAAM,KAAK,OAAO,SAAS,SAAS,CAAC,CAAC,CAAC;AAC7E,UAAM,OAAO;AAAA,MACX,IAAI;AAAA,QACF,4BAA4B,KAAK,IAAI,mFAElC,KAAK,SAAS,IAAI,kBAAkB,KAAK,KAAK,IAAI,CAAC,MAAM;AAAA,MAC9D;AAAA,MACA,EAAE,UAAU,EAAE;AAAA,IAChB;AAAA,EACF;AAEA,MAAI,CAAC,KAAK,KAAK;AACb,WAAO,EAAE,MAAM,IAAI,MAAM,QAAQ,IAAI,QAAQ,QAAQ,qBAAqB,GAAG,EAAE;AAAA,EACjF;AAEA,QAAM,KAAK,KAAK,QAAQ,OAAO,gBAAiB,KAAK,SAAS,KAAK;AACnE,MAAI,KAAK,QAAQ,SAAS,CAAC,IAAI;AAC7B,UAAM,OAAO;AAAA,MACX,IAAI;AAAA,QACF,4FACe,WAAW;AAAA,MAG5B;AAAA,MACA,EAAE,UAAU,EAAE;AAAA,IAChB;AAAA,EACF;AAIA,MAAI,KAAK,QAAQ,QAAQ,IAAI,WAAW,aAAa;AACnD,UAAM,OAAO;AAAA,MACX,IAAI;AAAA,QACF,GAAG,IAAI,IAAI,QAAQ,IAAI,UAAU,OAAO,WAAW,WAAW;AAAA,MAGhE;AAAA,MACA,EAAE,UAAU,EAAE;AAAA,IAChB;AAAA,EACF;AAEA,QAAM,gBAAgB,MAAM,IAAI,IAAI,eAAe,EAAG;AAGtD,QAAM,QAAQ,SAAS,MAAM,aAAa,IAAI,GAAG,KAAK,IAAI;AAC1D,MAAI,CAAC,OAAO;AACV,UAAM,OAAO,OAAO,IAAI,MAAM,GAAG,IAAI,IAAI,0CAA0C,GAAG;AAAA,MACpF,UAAU;AAAA,IACZ,CAAC;AAAA,EACH;AACA,SAAO;AAAA,IACL,MAAM,MAAM;AAAA,IACZ,QAAQ,MAAM;AAAA,IACd,QAAQ,qBAAqB,KAAK;AAAA,IAClC,MAAM,EAAE,MAAM,IAAI,QAAQ,IAAS,WAAW,MAAM,WAAW,GAAG;AAAA,EACpE;AACF;;;ACjHA,IAAM,iBAAiB;AAAA,EACrB,QAAQ;AAAA,EACR,UAAU;AAAA,EACV,QAAQ;AACV;AAKO,SAAS,mBAAmB,GAAoC;AACrE,QAAM,QAAkB,CAAC;AACzB,MAAI,EAAE,MAAM;AACV,UAAM,QAAQ,GAAG,EAAE,KAAK,QAAQ,OAAO,WAAM,EAAE,KAAK,EAAE;AACtD,UAAM;AAAA,MACJ,EAAE,KAAK,YACH,GAAG,EAAE,IAAI,aAAa,KAAK,oCAC3B,GAAG,EAAE,IAAI,aAAa,KAAK,sDAA4C,EAAE,UAAU,OAAO;AAAA,IAChG;AAAA,EACF,OAAO;AACL,UAAM,KAAK,GAAG,EAAE,IAAI,aAAa,EAAE,UAAU,OAAO,EAAE;AAAA,EACxD;AAEA,QAAM,KAAK,wCAAwC,eAAe,EAAE,OAAO,QAAQ,CAAC,EAAE;AACtF,MAAI,EAAE,OAAO,GAAG,SAAS,EAAG,OAAM,KAAK,SAAS,EAAE,OAAO,GAAG,KAAK,IAAI,CAAC,EAAE;AACxE,MAAI,EAAE,OAAO,GAAG,SAAS,EAAG,OAAM,KAAK,SAAS,EAAE,OAAO,GAAG,KAAK,IAAI,CAAC,EAAE;AACxE,QAAM,KAAK,KAAK,EAAE,OAAO,MAAM,EAAE;AAEjC,MAAI,EAAE,OAAO,aAAa,UAAU;AAClC,UAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA,+DAA+D,EAAE,IAAI;AAAA,IACvE;AAAA,EACF;AACA,MAAI,EAAE,MAAM,aAAa,EAAE,KAAK,OAAO,eAAe;AACpD,UAAM;AAAA,MACJ;AAAA,MACA;AAAA,MACA,iCAAiC,EAAE,IAAI,wBAAwB,EAAE,KAAK,QAAQ,WAAW;AAAA,IAC3F;AAAA,EACF;AACA,SAAO,MAAM,KAAK,IAAI;AACxB;AASA,eAAsB,4BACpB,MACA,MAC2C;AAC3C,MAAI,CAAC,MAAM,KAAK,GAAG;AACjB,WAAO,EAAE,QAAQ,+DAA+D,MAAM,EAAE;AAAA,EAC1F;AACA,QAAM,MAAM,KAAK,KAAK,KAAK,EAAE,YAAY;AACzC,MAAI,QAAQ,UAAa,QAAQ,QAAQ,QAAQ,OAAO;AACtD,WAAO,EAAE,QAAQ,qCAAqC,KAAK,GAAG,MAAM,MAAM,EAAE;AAAA,EAC9E;AACA,MAAI;AACF,UAAM,SAAS,MAAM,kBAAkB;AAAA,MACrC,MAAM,KAAK,KAAK;AAAA,MAChB,GAAI,MAAM,EAAE,IAAyB,IAAI,CAAC;AAAA,MAC1C,GAAI,KAAK,UAAU,EAAE,SAAS,KAAK,QAAQ,IAAI,CAAC;AAAA,IAClD,CAAC;AAID,WAAO;AAAA,MACL,QAAQ,mBAAmB,MAAM;AAAA,MACjC,MAAM,OAAO,QAAQ,CAAC,OAAO,KAAK,YAAY,IAAI;AAAA,IACpD;AAAA,EACF,SAAS,KAAK;AACZ,UAAM,IAAI;AACV,WAAO,EAAE,QAAQ,EAAE,WAAW,OAAO,GAAG,GAAG,MAAM,EAAE,YAAY,EAAE;AAAA,EACnE;AACF;","names":[]}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/blux/freeze/index.ts","../src/blux/freeze/settle.ts","../src/blux/freeze/map-placeholder.ts","../src/blux/freeze/slider-pin.ts","../src/blux/freeze/resolve-anchors.ts","../src/blux/freeze/bake-images.ts","../src/blux/freeze/types.ts","../src/blux/freeze/section.ts","../src/blux/freeze/tokenize-text.ts","../src/blux/freeze/finalize.ts","../src/blux/freeze/extra-slots.ts"],"sourcesContent":["import { mkdir, writeFile, readFile } from \"node:fs/promises\";\nimport { join } from \"node:path\";\nimport { settleExport } from \"./settle.js\";\nimport { mapPlaceholder } from \"./map-placeholder.js\";\nimport { pinSliders } from \"./slider-pin.js\";\nimport { resolveAnchors } from \"./resolve-anchors.js\";\nimport { bakeImages } from \"./bake-images.js\";\nimport { tokenizeText } from \"./tokenize-text.js\";\nimport { finalize } from \"./finalize.js\";\nimport { extractMapConfig } from \"../grid/extract-map.js\";\nimport { validateExtraSlots } from \"./extra-slots.js\";\nimport type { FrozenResult } from \"./types.js\";\n\nexport type { Slot, FrozenManifest, FrozenResult, ExtraSlotsFile } from \"./types.js\";\nexport { frozenPageCustomType, FROZEN_PAGE_TYPE } from \"./frozen-page-type.js\";\nexport { validateExtraSlots } from \"./extra-slots.js\";\nexport { EXTRA_SLOT_PREFIX } from \"./types.js\";\n\nexport interface FreezeOptions {\n /** Path to the export's index.html. */\n indexHtmlPath: string;\n /** Site slug — names the emitted template/style/manifest files. */\n site: string;\n /** Prismic uid for the page doc (default \"home\"). */\n uid?: string;\n /** A site's own extra-slots declaration (parsed `ExtraSlotsFile`), for\n * editable content the template carries no token for. See types.ts. */\n extraSlots?: unknown;\n}\n\n/**\n * Freeze one Blux page into a byte-faithful template + editable slot manifest.\n * Order matters: bake images (their urls become slots) BEFORE tokenizing text,\n * so image tokens live in style attributes and never collide with text tokens.\n */\nexport async function freezeSite(opts: FreezeOptions): Promise<FrozenResult> {\n const { html: settled, anchorTargets } = await settleExport(opts.indexHtmlPath);\n // Settled-DOM repairs run before image/text tokenizing: swap the dead\n // Google-Map DOM for a placeholder (using the raw export for the KML mid),\n // pin each hero slider to slide 1, and bake nav hashlink targets (settle's\n // click audit measured them against the export's own runtime).\n const exportHtml = await readFile(opts.indexHtmlPath, \"utf-8\");\n const mapped = mapPlaceholder(settled, exportHtml);\n const pinned = pinSliders(mapped);\n const anchored = resolveAnchors(pinned, anchorTargets);\n const baked = bakeImages(anchored);\n const tokenized = tokenizeText(baked.html);\n const fin = finalize(tokenized.html);\n\n // The full map widget config (layers/toggles/styles) lives in the export's\n // initMap script — carried alongside the template so the render can hydrate\n // the real map. Only meaningful when its mount survived into the template.\n const rawMap = extractMapConfig(exportHtml);\n const mapConfig =\n rawMap && fin.templateHtml.includes(`id=\"${rawMap.mountId}\"`) ? rawMap : undefined;\n\n // Derived slots first, then the site's own. Validated against the derived\n // keys so a declaration can never shadow real page content.\n const derived = [...baked.slots, ...tokenized.slots];\n const extra = validateExtraSlots(opts.extraSlots, derived);\n\n return {\n manifest: {\n site: opts.site,\n uid: opts.uid ?? \"home\",\n title: fin.title,\n metaTitle: fin.metaTitle,\n metaImageUrl: fin.metaImageUrl,\n fontLinks: fin.fontLinks,\n slots: [...derived, ...extra],\n },\n templateHtml: fin.templateHtml,\n styleCss: fin.styleCss,\n mapConfig,\n };\n}\n\n/**\n * Write the freeze artifacts under `outDir`. Returns their paths.\n *\n * `frozen/` is site-repo-ready: uid-keyed `<uid>.html` / `<uid>.style.css` /\n * `<uid>.fonts.json` (+ `<uid>.map.json` when the export has a map widget),\n * copied verbatim into the site repo's `src/lib/blux-frozen/frozen/` — exactly\n * the names the starter's artifact globs load. The slots manifest stays\n * site-keyed at the out-dir root, where `blux migrate-frozen` finds it.\n */\nexport async function emitFrozen(\n outDir: string,\n result: FrozenResult,\n): Promise<{\n template: string;\n style: string;\n fonts: string;\n manifest: string;\n map?: string;\n}> {\n const { site, uid } = result.manifest;\n const frozenDir = join(outDir, \"frozen\");\n await mkdir(frozenDir, { recursive: true });\n const template = join(frozenDir, `${uid}.html`);\n const style = join(frozenDir, `${uid}.style.css`);\n const fonts = join(frozenDir, `${uid}.fonts.json`);\n const manifest = join(outDir, `${site}.slots.json`);\n await writeFile(template, result.templateHtml, \"utf-8\");\n await writeFile(style, result.styleCss, \"utf-8\");\n await writeFile(fonts, JSON.stringify(result.manifest.fontLinks, null, 2), \"utf-8\");\n await writeFile(manifest, JSON.stringify(result.manifest, null, 2), \"utf-8\");\n if (!result.mapConfig) return { template, style, fonts, manifest };\n const map = join(frozenDir, `${uid}.map.json`);\n await writeFile(map, JSON.stringify(result.mapConfig, null, 2), \"utf-8\");\n return { template, style, fonts, manifest, map };\n}\n","/// <reference lib=\"dom\" />\n// The evaluate() callbacks below run in the browser page context, so they use\n// DOM globals (document/window). The CLI's base tsconfig is Node-only (lib\n// ES2022); this directive adds DOM types for this file's type-checking.\nimport { createServer, type Server } from \"node:http\";\nimport { readFile } from \"node:fs/promises\";\nimport { dirname, extname, join, normalize, sep } from \"node:path\";\nimport { chromium, type Page } from \"@playwright/test\";\n\n// The Blux export's index.html renders its final layout only after its runtime\n// JS applies height/position adjustments. Freezing the RAW html renders the\n// wrong height (the-pointe: 16873px); freezing the JS-SETTLED DOM renders the\n// live height (15333px). So the freeze hydrates the page once in headless\n// chromium, scrolls top→bottom to trigger the layout pass, and snapshots the\n// settled DOM. (Proven in the design POC scratchpad/freeze-final.mjs.)\n\nexport interface SettleOptions {\n /** Layout viewport — Blux desktop layout is authored at 1440. */\n width?: number;\n height?: number;\n /** Scroll step in px (smaller = more reliable lazy triggers, slower). */\n step?: number;\n}\n\nexport interface SettledExport {\n /** The serialized settled DOM (full document HTML). */\n html: string;\n /**\n * Nav-anchor answer key, measured from the export's OWN runtime: hash index\n * → the id its JS actually scrolls to. Blux core maps `/#N` to\n * `page-block-N`, but site-specific custom scripts override that (the-pointe\n * embeds one sending any \"Contact Us\" link to `footer0`) — behavior no\n * static rule can recover, so it is recorded by clicking each hashlink with\n * `scrollPageToTarget` instrumented. Empty when the runtime is absent.\n */\n anchorTargets: Record<string, string>;\n}\n\n// Blux runtime globals reached from inside evaluate() callbacks. Types are\n// erased before serialization, so declaring this at module level is safe.\ntype BluxWindow = Window & {\n DANDOM?: { prototype: { scrollPageToTarget?: (t: string) => unknown } };\n __rdAnchorCalls?: string[];\n alreadyScrolling?: boolean;\n};\n\nconst MIME: Record<string, string> = {\n \".html\": \"text/html; charset=utf-8\",\n \".css\": \"text/css\",\n \".js\": \"text/javascript\",\n \".json\": \"application/json\",\n \".png\": \"image/png\",\n \".jpg\": \"image/jpeg\",\n \".jpeg\": \"image/jpeg\",\n \".gif\": \"image/gif\",\n \".svg\": \"image/svg+xml\",\n \".webp\": \"image/webp\",\n \".ico\": \"image/x-icon\",\n \".woff\": \"font/woff\",\n \".woff2\": \"font/woff2\",\n};\n\n/**\n * One-shot local static server over the export dir, for the anchor audit ONLY.\n * The audit must run on an http origin: there `/#N` is a same-document hash\n * change (exactly the live site's semantics), while on `file://` it resolves\n * to the filesystem ROOT — a cross-document navigation that Blux's handler\n * chain triggers even past preventDefault + timer stubs, tearing the page down\n * mid-audit. Serving beats intercepting.\n */\nasync function serveExport(dir: string): Promise<{ url: string; server: Server }> {\n const root = normalize(dir);\n const server = createServer((req, res) => {\n void (async () => {\n try {\n const path = decodeURIComponent((req.url ?? \"/\").split(/[?#]/)[0]!);\n const rel = path === \"/\" ? \"index.html\" : path.slice(1);\n // Separator-anchored: a bare prefix test would also pass sibling\n // dirs that merely share the prefix (/exports/site-secret vs\n // /exports/site).\n const file = normalize(join(root, rel));\n if (!file.startsWith(root + sep)) throw new Error(\"traversal\");\n const body = await readFile(file);\n res.writeHead(200, {\n \"content-type\": MIME[extname(file)] ?? \"application/octet-stream\",\n });\n res.end(body);\n } catch {\n res.writeHead(404);\n res.end();\n }\n })();\n });\n await new Promise<void>((resolve) => server.listen(0, \"127.0.0.1\", resolve));\n const address = server.address();\n if (address === null || typeof address === \"string\")\n throw new Error(\"export server failed to bind\");\n return { url: `http://127.0.0.1:${address.port}/`, server };\n}\n\n/**\n * Click every distinct `/#N` hashlink on the SERVED page and record the first\n * target the site's runtime passes to `scrollPageToTarget` — first, because on\n * the live site later calls in a click's handler chain are gated by\n * `alreadyScrolling`, so the first call is the one users see. The scroll\n * itself is suppressed (the wrapper records and returns), so clicks leave no\n * scroll state behind; the settle snapshot is taken from a separate file://\n * page that the audit never touches.\n */\nasync function auditAnchors(page: Page): Promise<Record<string, string>> {\n const ns = await page.evaluate(() => {\n const found = new Set<string>();\n for (const a of document.querySelectorAll('a[href^=\"/#\"]')) {\n const m = /^\\/#(\\d+)$/.exec(a.getAttribute(\"href\") ?? \"\");\n if (m?.[1]) found.add(m[1]);\n }\n return [...found];\n });\n if (ns.length === 0) return {};\n\n const instrumented = await page.evaluate(() => {\n const w = window as unknown as BluxWindow;\n const proto = w.DANDOM?.prototype;\n if (!proto?.scrollPageToTarget) return false;\n w.__rdAnchorCalls = [];\n proto.scrollPageToTarget = function (t: string) {\n w.__rdAnchorCalls?.push(t);\n return this; // record only — never actually scroll\n };\n return true;\n });\n if (!instrumented) return {};\n\n const targets: Record<string, string> = {};\n for (const n of ns) {\n // Audit EVERY link sharing this index, not just the first: custom scripts\n // intercept by link semantics (the-pointe keys on \"Contact Us\" text), so\n // two /#N links can legitimately scroll to different targets. The bake is\n // per-index, so disagreement can't be represented — surface it loudly and\n // bake the first link's (document-order) behavior.\n const count = await page.evaluate(\n (nn) => document.querySelectorAll(`a[href=\"/#${nn}\"]`).length,\n n,\n );\n const seen: string[] = [];\n for (let i = 0; i < count; i++) {\n await page.evaluate(\n ({ nn, idx }) => {\n const w = window as unknown as BluxWindow;\n w.__rdAnchorCalls = [];\n w.alreadyScrolling = false;\n history.replaceState(null, \"\", \"/\");\n document.querySelectorAll<HTMLElement>(`a[href=\"/#${nn}\"]`)[idx]?.click();\n },\n { nn: n, idx: i },\n );\n // checkScrollHash defers its core scroll 100ms past the hashchange (and\n // re-navigates at +50ms); 400ms covers the whole handler chain.\n await page.waitForTimeout(400);\n const first = await page.evaluate(\n () => (window as unknown as BluxWindow).__rdAnchorCalls?.[0] ?? null,\n );\n if (first) seen.push(first);\n }\n const distinct = [...new Set(seen)];\n if (distinct.length > 1) {\n console.warn(\n `[freeze] anchor /#${n}: links disagree on target (${distinct.join(\", \")}) — ` +\n `baking \"${seen[0]}\" for all; verify that page's nav fidelity by hand`,\n );\n }\n if (seen[0]) targets[n] = seen[0];\n }\n return targets;\n}\n\n/**\n * Render a local `index.html` in headless chromium, let its JS settle the\n * layout, and return the serialized settled DOM plus the measured nav-anchor\n * answer key.\n */\nexport async function settleExport(\n indexHtmlPath: string,\n opts: SettleOptions = {},\n): Promise<SettledExport> {\n const width = opts.width ?? 1440;\n const height = opts.height ?? 1000;\n const step = opts.step ?? 500;\n\n const browser = await chromium.launch();\n try {\n const page = await browser.newPage({ viewport: { width, height } });\n await page.goto(`file://${indexHtmlPath}`, {\n waitUntil: \"networkidle\",\n timeout: 60000,\n });\n // Trigger the JS layout pass across the whole page.\n const total = await page.evaluate(() => document.body.scrollHeight);\n for (let y = 0; y < total + 1000; y += step) {\n await page.evaluate((yy) => window.scrollTo(0, yy), y);\n await page.waitForTimeout(120);\n }\n await page.evaluate(() => window.scrollTo(0, 0));\n await page.waitForTimeout(1200);\n const html = await page.content();\n\n // Anchor audit on a separate, HTTP-served copy (see serveExport). Media\n // is aborted for speed — the audit needs handlers, not layout.\n const { url, server } = await serveExport(dirname(indexHtmlPath));\n try {\n const auditPage = await browser.newPage({\n viewport: { width, height },\n });\n await auditPage.route(\n \"**/*\",\n (route) =>\n void ([\"image\", \"media\", \"font\"].includes(route.request().resourceType())\n ? route.abort()\n : route.continue()),\n );\n await auditPage.goto(url, { waitUntil: \"load\", timeout: 60000 });\n await auditPage.waitForTimeout(800);\n const anchorTargets = await auditAnchors(auditPage);\n await auditPage.close();\n return { html, anchorTargets };\n } finally {\n await new Promise<void>((resolve) => server.close(() => resolve()));\n }\n } finally {\n await browser.close();\n }\n}\n","import { parse } from \"node-html-parser\";\n\n// The frozen export's Google-Map band is a CUSTOM Google My Maps: a KmlLayer\n// keyed by a My Maps id (`…/kml?…&mid=<ID>`) rendered at runtime. With the Blux\n// JS stripped, the settled `gm-style` DOM Google left behind is dead (broken\n// tiles + CSP console noise). Replace it with a clean, height-preserving\n// placeholder that carries the `mid` so a future (production) hydrator can bring\n// the real map back once a domain-scoped Maps key exists.\n\n// The My Maps id, pulled from the export's KML url (deterministic, not from the\n// settled DOM which no longer holds it).\nconst MID_RE = /[?&]mid=([\\w-]+)/;\n\n/**\n * Convert each rendered Google-Map container into a `.blux-frozen-map`\n * placeholder. A container is an element whose `id` names a map (`/map/i`) AND\n * that actually rendered (has a `.gm-style` descendant); its dead children are\n * removed and the KML `mid` (if found in `exportHtml`) is attached. Its existing\n * id + inline height are kept so the band's box is unchanged. `exportHtml` is the\n * raw Blux export string.\n */\nexport function mapPlaceholder(html: string, exportHtml: string): string {\n const root = parse(html);\n const mid = exportHtml.match(MID_RE)?.[1];\n\n for (const el of root.querySelectorAll(\"[id]\")) {\n const id = el.getAttribute(\"id\") ?? \"\";\n if (!/map/i.test(id)) continue;\n if (!el.querySelector(\".gm-style\")) continue; // only actually-rendered maps\n el.set_content(\"\"); // drop the dead gm-style DOM\n el.classList.add(\"blux-frozen-map\");\n if (mid) el.setAttribute(\"data-kml-mid\", mid);\n }\n\n return root.toString();\n}\n","import { parse, HTMLElement } from \"node-html-parser\";\n\n// Blux single-column hero sliders (`.caslider` with `data-columns` 1 or absent)\n// animate through their slides at runtime. The settle snapshot freezes whichever\n// slide happened to be active, which is not reproducible across re-freezes. Pin\n// such a slider to its first slide: force slide 1 visible + in position, hide the\n// rest with `display:none`. Appended declarations win (inline-style last-wins),\n// so the first slide's own styles are preserved and height is unchanged (slide 1\n// keeps the band's natural height).\n//\n// A MULTI-column slider (`data-columns` >= 2) shows several slides at once — the\n// settled snapshot already renders those columns faithfully — so pinning would\n// hide the simultaneously-visible columns and regress the band. Leave those\n// untouched; the pin is only for the single-column hero case.\n\n/**\n * Pin each single-column `.caslider` to its first slide, deterministically.\n * Multi-column sliders (`data-columns` >= 2) are left as settled.\n */\nexport function pinSliders(html: string): string {\n const root = parse(html);\n\n for (const slider of root.querySelectorAll(\".caslider\")) {\n const columns = parseInt(slider.getAttribute(\"data-columns\") ?? \"1\", 10) || 1;\n if (columns > 1) continue; // multi-column carousel: keep the settled columns\n const slides = slider.childNodes.filter((n): n is HTMLElement => n instanceof HTMLElement);\n slides.forEach((slide, i) => {\n const style = slide.getAttribute(\"style\") ?? \"\";\n const pin = i === 0 ? \"display:block;transform:translateX(0%);opacity:1\" : \"display:none\";\n slide.setAttribute(\"style\", style ? `${style};${pin}` : pin);\n });\n }\n\n return root.toString();\n}\n","// Blux nav anchors are JS-driven: core runtime maps `href=\"/#N\"` to the band\n// id `page-block-N` — but site-specific custom scripts embedded in the export\n// can send a link anywhere (the-pointe's sends \"Contact Us\" to `footer0`), so\n// the authoritative mapping is the answer key MEASURED from the export's own\n// runtime by settle's click audit. With the runtime stripped those hrefs are\n// dead; bake the resolved target into the frozen markup. Unmeasured indexes\n// fall back to Blux core's `page-block-N` (when that band is missing, Blux\n// core no-ops — exactly what a dead in-page anchor does, so the fallback is\n// faithful either way). Digit-only fragments only — real named anchors\n// (`/#about`) are untouched. The starter's render-time enhance layer keeps its\n// own `/#N` rewrite as a repair path for pre-bake artifacts; baked hrefs no\n// longer match it.\n\nconst HASHLINK_RE = /href=\"\\/#(\\d+)\"/g;\n\nexport function resolveAnchors(html: string, targets: Record<string, string> = {}): string {\n return html.replace(\n HASHLINK_RE,\n (_full, n: string) => `href=\"#${targets[n] ?? `page-block-${n}`}\"`,\n );\n}\n","import { parse, HTMLElement } from \"node-html-parser\";\nimport { slotKey, tokenFor, type Slot } from \"./types.js\";\nimport { sectionIndexOf, sectionKeyOf } from \"./section.js\";\nimport { CDN_HOSTS } from \"../assets.js\";\n\nconst isCdnUrl = (v: string): boolean => CDN_HOSTS.some((h) => v.includes(h));\n\n// Blux media elements carry the image as data-attributes, not a live src: the\n// runtime reads `data-base` (CDN folder) + `data-size` (width) + `data-media`\n// (filename) and paints a background on a lazy `.load-media-element` span\n// (loading/unloading per viewport). We assemble the url deterministically —\n// `{data-base}w:{data-size}/{data-media}` (verified 200) — and bake it as an\n// inline background-image token so no runtime is needed. `data-bgmedia` is a\n// full-bleed layer whose filename wins over `data-media`. A second pass catches\n// any other CDN url still living in src/href/poster (e.g. a <video>), so the\n// frozen template leaks no CDN host at all.\n\n// Runtime-only url hints the (removed) Blux JS used; not rendered — drop them so\n// they don't leak a CDN host.\nfunction stripUrlHintAttrs(root: HTMLElement): void {\n for (const el of root.querySelectorAll(\"*\")) {\n for (const name of Object.keys(el.attributes)) {\n if (name === \"data-og-src\" || name.startsWith(\"data-swaps\")) {\n el.removeAttribute(name);\n }\n }\n }\n}\n\n/**\n * Tokenize every media url in the document as a `⟦i:KEY⟧` image slot. Pass 1:\n * data-attribute backgrounds → inline `background-image:url(token)`. Pass 2: any\n * remaining CDN url in `src`/`href`/`poster` → bare `token`. Keys are\n * `s{section}.i{n}` in document order.\n */\nexport function bakeImages(html: string): { html: string; slots: Slot[] } {\n const root = parse(html);\n const sectionIndex = sectionIndexOf(root);\n const counters = new Map<string, number>();\n const slots: Slot[] = [];\n\n const nextKey = (el: HTMLElement): string => {\n const section = sectionKeyOf(el, sectionIndex);\n const n = counters.get(section) ?? 0;\n counters.set(section, n + 1);\n return slotKey(section, \"image\", n);\n };\n\n // Pass 1: data-* backgrounds. The filename is ALWAYS `data-media`;\n // `data-bgmedia` is a boolean flag (\"1\") marking a full-bleed layer — it is\n // NOT a filename. When an element is unsized we default full-bleed layers to a\n // wide variant and inline images to the raw file.\n for (const el of root.querySelectorAll(\"[data-media]\")) {\n const base = el.getAttribute(\"data-base\");\n if (!base) continue;\n const media = el.getAttribute(\"data-media\");\n if (!media) continue;\n const isBg = el.getAttribute(\"data-bgmedia\") != null;\n const size = el.getAttribute(\"data-size\");\n const sizeSeg = size ? `w:${size}/` : isBg ? \"w:1600/\" : \"\";\n const url = `${base}${sizeSeg}${media}`;\n\n const key = nextKey(el);\n slots.push({ key, kind: \"image\", url, section: sectionKeyOf(el, sectionIndex) });\n const decl = `background-image:url(${tokenFor(\"image\", key)})`;\n const style = el.getAttribute(\"style\");\n el.setAttribute(\"style\", style ? `${style};${decl}` : decl);\n el.removeAttribute(\"data-base\");\n }\n\n // Pass 2: any remaining CDN url in src/href/poster (video, source, links…).\n for (const el of root.querySelectorAll(\"[src],[href],[poster]\")) {\n for (const attr of [\"src\", \"href\", \"poster\"] as const) {\n const v = el.getAttribute(attr);\n if (!v || !isCdnUrl(v)) continue;\n const key = nextKey(el);\n slots.push({ key, kind: \"image\", url: v, section: sectionKeyOf(el, sectionIndex) });\n el.setAttribute(attr, tokenFor(\"image\", key));\n }\n }\n\n stripUrlHintAttrs(root);\n return { html: root.toString(), slots };\n}\n","// Frozen-page freeze: shared types + token contract. A frozen site keeps the\n// Blux export's own markup as the layout (byte-faithful) and exposes only the\n// editable leaves — text runs and image urls — as Prismic slots. The template\n// carries a placeholder token per slot; the render substitutes current Prismic\n// values back in. See docs/superpowers/specs/2026-07-24-blux-frozen-page-design.md.\n\nimport type { MapConfig } from \"../grid/extract-map.js\";\n\nexport type SlotKind = \"text\" | \"image\";\n\nexport interface Slot {\n /** Stable, document-order key `s{section}.{t|i}{n}` (see `slotKey`). */\n key: string;\n kind: SlotKind;\n /** Present when kind === \"text\": the original text-node value. */\n text?: string;\n /** Present when kind === \"image\": the assembled (cloudfront) image url. */\n url?: string;\n /** Grouping label for editor sanity — the owning section key. */\n section: string;\n}\n\n/**\n * Slots a SITE declares, on top of the ones the freeze derives from the export.\n *\n * Every derived slot exists because the template carries a token for it. Some\n * editable content has no token and never can: the render composes it itself,\n * from markup the export never had. Two real cases on the-pointe — a `<video>`\n * poster (the export ships none, so there is no attribute to tokenize) and a\n * data panel the export baked as a flattened PNG (rebuilt as real text, so the\n * text exists only in the render).\n *\n * Without this, that content can only live in committed files and a deploy is\n * required to change a leasing number. Declaring it here puts it in Prismic\n * alongside everything else, and — because `blux migrate-frozen` PUTs the whole\n * document — keeps it there across re-migrations, which a row added by hand in\n * the Prismic UI would not survive.\n *\n * Kept generic on purpose: the tool serves several frozen sites, so it knows\n * only that extra slots exist, never what any one site puts in them.\n */\nexport interface ExtraSlotsFile {\n /** Free-form note for whoever opens the file; ignored by the freeze. */\n comment?: string;\n slots: Slot[];\n}\n\n/** Prefix reserved for site-declared slots, so a synthetic key can never\n * collide with a derived `s{n}.t{n}` / `h.i{n}` one. */\nexport const EXTRA_SLOT_PREFIX = \"x.\";\n\nexport interface FrozenManifest {\n site: string;\n uid: string;\n title: string;\n metaTitle?: string | undefined;\n metaImageUrl?: string | undefined;\n /** External stylesheet hrefs from the export <head> (e.g. Google Fonts) that\n * the render must re-inject — font metrics are load-bearing for layout. */\n fontLinks: string[];\n slots: Slot[];\n}\n\n/** The emitted freeze artifact: the manifest plus the repo files' bodies. */\nexport interface FrozenResult {\n manifest: FrozenManifest;\n /** Tokenized `<body>` innerHTML — the repo template. */\n templateHtml: string;\n /** The extracted `<style>` block text — the repo style file. */\n styleCss: string;\n /** Present when the export carries a Blux map widget whose mount survives\n * in the template — emitted as `frozen/<uid>.map.json`, hydrated by the\n * starter's frozen-map layer. */\n mapConfig?: MapConfig | undefined;\n}\n\n/** The placeholder a slot's value is substituted for at render time.\n * Text: replaces the whole text node. Image: sits inside `url(...)`. */\nexport function tokenFor(kind: SlotKind, key: string): string {\n return `⟦${kind === \"text\" ? \"t\" : \"i\"}:${key}⟧`;\n}\n\n/** Build a slot key. `section` is the nearest-ancestor <section> index (or\n * \"h\" for chrome above the first section); `n` is a per-kind counter. */\nexport function slotKey(section: string, kind: SlotKind, n: number): string {\n return `${section}.${kind === \"text\" ? \"t\" : \"i\"}${n}`;\n}\n\n/** Matches any slot token; group 1 = \"t\"|\"i\", group 2 = key. Global — callers\n * that reuse it across `.test()`/`.replace()` should construct a fresh copy or\n * reset `lastIndex`. Use `TOKEN_RE()` to get a fresh instance. */\nexport const TOKEN_RE = (): RegExp => /⟦([ti]):([^⟧]+)⟧/g;\n","import { HTMLElement } from \"node-html-parser\";\n\n/** Map each <section> element to its document-order index. */\nexport function sectionIndexOf(root: HTMLElement): Map<HTMLElement, number> {\n const index = new Map<HTMLElement, number>();\n root.querySelectorAll(\"section\").forEach((s, i) => index.set(s, i));\n return index;\n}\n\n/** Nearest-ancestor <section> key (`s{index}`), or `h` for chrome (nav/footer)\n * that lives above/outside any <section>. */\nexport function sectionKeyOf(el: HTMLElement, sectionIndex: Map<HTMLElement, number>): string {\n let a: HTMLElement | null | undefined = el;\n while (a) {\n if (a.tagName === \"SECTION\" && sectionIndex.has(a)) {\n return `s${sectionIndex.get(a)}`;\n }\n a = a.parentNode as HTMLElement | null | undefined;\n }\n return \"h\";\n}\n","import { parse, HTMLElement, NodeType } from \"node-html-parser\";\nimport { slotKey, tokenFor, type Slot } from \"./types.js\";\nimport { sectionIndexOf, sectionKeyOf } from \"./section.js\";\n\n// Elements whose text is not editable page copy.\nconst SKIP = new Set([\"SCRIPT\", \"STYLE\", \"NOSCRIPT\", \"HEAD\", \"TITLE\", \"SVG\"]);\n\n/**\n * Replace every text node that carries CONTENT with a `⟦t:KEY⟧` token and\n * collect the originals as text slots. Keys are `s{section}.t{n}` in document\n * order so the template + manifest stay stable across re-freezes (golden\n * canary). The raw source text is preserved verbatim (entities and surrounding\n * whitespace) so a frozen render is byte-faithful before any Prismic edit.\n *\n * \"Carries content\" is decided on the DECODED text, not the raw source. Page\n * builders emit whitespace-only leaves as layout — a list item or table cell\n * holding ` ` purely to occupy a line — and those must stay literal in the\n * template rather than becoming CMS fields, for two reasons:\n *\n * 1. They are not editable copy. Nobody wants a Prismic field whose only\n * correct value is \"one blank line\".\n * 2. Prismic Rich Text CANNOT store a whitespace-only value. It round-trips to\n * \"\", the row collapses to its padding, and the page silently loses a line\n * of vertical rhythm — a defect that only shows up after the migration, on\n * the live site, in a place nobody thought to re-measure.\n *\n * `rawText.trim()` cannot see this: for a ` ` leaf it trims to the literal\n * string \" \", which is not empty, so the leaf looked like content. Testing\n * the decoded `.text` catches every spelling — ` `, ` `, ` `,\n * ` `, ` ` — because JS `String.trim()` strips the whole Unicode\n * whitespace class. A real character such as `&` still decodes to content\n * and is tokenized as before.\n */\nexport function tokenizeText(html: string): { html: string; slots: Slot[] } {\n const root = parse(html);\n const sectionIndex = sectionIndexOf(root);\n\n const counters = new Map<string, number>();\n const slots: Slot[] = [];\n\n const walk = (el: HTMLElement): void => {\n for (const child of el.childNodes) {\n if (child.nodeType === NodeType.TEXT_NODE) {\n const raw = child.rawText;\n // Decoded, so entity-encoded whitespace is treated as the whitespace it\n // is. A skipped leaf does not advance the section counter — same as a\n // plain-whitespace leaf, which this check has always skipped.\n if (raw && child.text.trim() !== \"\") {\n const section = sectionKeyOf(el, sectionIndex);\n const n = counters.get(section) ?? 0;\n counters.set(section, n + 1);\n const key = slotKey(section, \"text\", n);\n slots.push({ key, kind: \"text\", text: raw, section });\n child.rawText = tokenFor(\"text\", key);\n }\n } else if (child.nodeType === NodeType.ELEMENT_NODE) {\n const elChild = child as HTMLElement;\n if (!SKIP.has(elChild.tagName)) walk(elChild);\n }\n }\n };\n walk(root);\n\n return { html: root.toString(), slots };\n}\n","import { parse } from \"node-html-parser\";\n\n// Reveal effects hide content until the Blux runtime adds `block-effects-applied`\n// on scroll. With the runtime stripped, force the end-state so everything is\n// visible without JS. (`.block-effects{opacity:0}` is the initial rule.)\nexport const REVEAL_FORCE_CSS =\n \".block-effects{opacity:1!important;transform:none!important;animation:none!important;visibility:visible!important}\";\n\nexport interface Finalized {\n /** `<body>` inner HTML wrapped so the body's own class/style survive. */\n templateHtml: string;\n /** All page `<style>` blocks + the reveal-force override. */\n styleCss: string;\n title: string;\n metaTitle?: string | undefined;\n metaImageUrl?: string | undefined;\n /** External stylesheet hrefs (Google Fonts) to re-inject at render. */\n fontLinks: string[];\n}\n\n/**\n * Turn a tokenized settled document into the two repo artifacts: strip all\n * `<script>`, extract every `<style>` block (+ reveal-force) into a stylesheet,\n * and wrap the body's inner HTML in a `.frozen-root` div that carries the\n * body's own class/style (which `body.innerHTML` would otherwise drop).\n */\nexport function finalize(html: string): Finalized {\n const root = parse(html);\n const title = root.querySelector(\"title\")?.text?.trim() ?? \"\";\n const metaOf = (prop: string): string | undefined =>\n root.querySelector(`meta[property=\"${prop}\"]`)?.getAttribute(\"content\") ?? undefined;\n const metaTitle = metaOf(\"og:title\") ?? (title || undefined);\n const metaImageUrl = metaOf(\"og:image\");\n\n const fontLinks = root\n .querySelectorAll('link[rel=\"stylesheet\"]')\n .map((l) => l.getAttribute(\"href\"))\n .filter((h): h is string => !!h);\n\n const styles: string[] = [];\n for (const s of root.querySelectorAll(\"style\")) {\n styles.push(s.innerHTML);\n s.remove();\n }\n for (const sc of root.querySelectorAll(\"script\")) sc.remove();\n // Remove the Blux runtime's lazy media spans: they carry raw cloudfront urls\n // for whichever images were on-screen at snapshot (the rest unloaded), and the\n // deterministic baked token on the parent media div is what renders instead.\n for (const holder of root.querySelectorAll(\".load-media-holder, .load-media-element\")) {\n holder.remove();\n }\n\n const body = root.querySelector(\"body\");\n const inner = body ? body.innerHTML : root.toString();\n const bodyClass = body?.getAttribute(\"class\") ?? \"\";\n const bodyStyle = body?.getAttribute(\"style\") ?? \"\";\n const cls = `frozen-root ${bodyClass}`.trim();\n const styleAttr = bodyStyle ? ` style=\"${bodyStyle}\"` : \"\";\n const templateHtml = `<div class=\"${cls}\"${styleAttr}>${inner}</div>`;\n\n const styleCss = [...styles, REVEAL_FORCE_CSS].join(\"\\n\");\n return { templateHtml, styleCss, title, metaTitle, metaImageUrl, fontLinks };\n}\n","// Site-declared slots, merged into the freeze manifest alongside the derived\n// ones. See `ExtraSlotsFile` in types.ts for why they exist.\n//\n// This module is deliberately incurious about what a site declares: it checks\n// the shape, the reserved prefix and key uniqueness, and nothing else. The\n// freeze tool serves several frozen sites and must not learn any one site's\n// content model.\n\nimport { EXTRA_SLOT_PREFIX, type Slot } from \"./types.js\";\n\n/** Thrown with the offending path so a bad declaration names itself. */\nfunction fail(path: string, why: string): never {\n throw new Error(`extra slots: ${path} ${why}`);\n}\n\nfunction asString(v: unknown, path: string): string {\n if (typeof v !== \"string\" || v.trim() === \"\") {\n fail(path, `must be a non-empty string (got ${JSON.stringify(v)})`);\n }\n return v as string;\n}\n\n/**\n * Validate a site's extra-slots declaration against the slots the freeze\n * derived, returning the extras to append.\n *\n * A declaration that is wrong must stop the freeze rather than ship: these\n * slots become live CMS fields, and a key that collides with a derived one\n * would let a site-declared value silently overwrite real page content when\n * the render builds its value map.\n */\nexport function validateExtraSlots(file: unknown, derived: Slot[]): Slot[] {\n if (file === undefined || file === null) return [];\n if (typeof file !== \"object\" || Array.isArray(file)) {\n fail(\"root\", \"must be an object with a `slots` array\");\n }\n // Read as `unknown`, not as `ExtraSlotsFile` — the whole point here is that\n // the input has not been checked yet, and typing it as the target would make\n // every access below assert what this function is supposed to prove.\n const slots: unknown = (file as { slots?: unknown }).slots;\n if (!Array.isArray(slots)) fail(\"slots\", \"must be an array\");\n\n const derivedKeys = new Set(derived.map((s) => s.key));\n const seen = new Set<string>();\n const out: Slot[] = [];\n\n slots.forEach((raw, i) => {\n const at = `slots[${i}]`;\n if (typeof raw !== \"object\" || raw === null) fail(at, \"must be an object\");\n const s = raw as Record<string, unknown>;\n\n const key = asString(s.key, `${at}.key`);\n if (!key.startsWith(EXTRA_SLOT_PREFIX)) {\n fail(\n `${at}.key`,\n `must start with \"${EXTRA_SLOT_PREFIX}\" so it cannot collide with a derived slot (got \"${key}\")`,\n );\n }\n if (derivedKeys.has(key)) fail(`${at}.key`, `collides with a derived slot (\"${key}\")`);\n if (seen.has(key)) fail(`${at}.key`, `is declared twice (\"${key}\")`);\n seen.add(key);\n\n const kind = s.kind;\n if (kind !== \"text\" && kind !== \"image\") {\n fail(`${at}.kind`, `must be \"text\" or \"image\" (got ${JSON.stringify(kind)})`);\n }\n\n const section = typeof s.section === \"string\" && s.section.trim() ? s.section : \"x\";\n if (kind === \"text\") {\n out.push({ key, kind, text: asString(s.text, `${at}.text`), section });\n } else {\n out.push({ key, kind, url: asString(s.url, `${at}.url`), section });\n }\n });\n\n return out;\n}\n"],"mappings":"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","names":["readFile","join","parse","parse","parse","parse","parse","parse","readFile","join"]}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|