@reddoorla/maintenance 0.85.2 → 0.87.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/airtable-ZBUPQJSC.js +9 -0
- package/dist/{announce-GIAYN67S.js → announce-I2Z4JN3I.js} +58 -30
- package/dist/announce-I2Z4JN3I.js.map +1 -0
- package/dist/attachments-NLZIJ5NU.js +9 -0
- package/dist/{bump-deps-SWJB6H5V.js → bump-deps-CGVELRLK.js} +9 -9
- package/dist/{chunk-SDUJIANJ.js → chunk-2NJQLSEU.js} +3 -3
- package/dist/chunk-2NQUCS7O.js +243 -0
- package/dist/chunk-2NQUCS7O.js.map +1 -0
- package/dist/{chunk-ZTPPCHDB.js → chunk-3G25KIWW.js} +4 -4
- package/dist/{chunk-DTTIFYAM.js → chunk-3JQ2FO3D.js} +44 -405
- package/dist/chunk-3JQ2FO3D.js.map +1 -0
- package/dist/chunk-3SF7D4DY.js +359 -0
- package/dist/chunk-3SF7D4DY.js.map +1 -0
- package/dist/{chunk-37S6L6G4.js → chunk-5UINJNAJ.js} +72 -2
- package/dist/chunk-5UINJNAJ.js.map +1 -0
- package/dist/{chunk-THEFTZ7F.js → chunk-62CMMGEO.js} +5 -5
- package/dist/{chunk-FDIFTIXC.js → chunk-6TWWY7ZU.js} +2 -2
- package/dist/{chunk-PBWDDDQH.js → chunk-7BZUOZPU.js} +2 -2
- package/dist/{chunk-577HCGZO.js → chunk-7NYN2P6I.js} +44 -26
- package/dist/chunk-7NYN2P6I.js.map +1 -0
- package/dist/{chunk-UYCAF77E.js → chunk-7RJ2HMMJ.js} +3 -3
- package/dist/{chunk-Q276MZ7E.js → chunk-7UAYMAKR.js} +2 -2
- package/dist/{chunk-MGR2H7F4.js → chunk-BBUSGEES.js} +58 -45
- package/dist/chunk-BBUSGEES.js.map +1 -0
- package/dist/chunk-BNM5POSJ.js +284 -0
- package/dist/chunk-BNM5POSJ.js.map +1 -0
- package/dist/{chunk-R6FPFMOS.js → chunk-BQRFQSGY.js} +3 -3
- package/dist/{chunk-NWB4O5EY.js → chunk-C6CLRKPV.js} +5 -5
- package/dist/chunk-C6CLRKPV.js.map +1 -0
- package/dist/{chunk-7J53SUT6.js → chunk-CROKUHJB.js} +4 -4
- package/dist/chunk-DEWJMMG3.js +274 -0
- package/dist/chunk-DEWJMMG3.js.map +1 -0
- package/dist/{chunk-SEEA6RXF.js → chunk-DJZWYIPP.js} +2 -2
- package/dist/chunk-DJZWYIPP.js.map +1 -0
- package/dist/chunk-EVBCK2KC.js +68 -0
- package/dist/chunk-EVBCK2KC.js.map +1 -0
- package/dist/chunk-F3JWOPTG.js +202 -0
- package/dist/chunk-F3JWOPTG.js.map +1 -0
- package/dist/{chunk-GZNBCHAZ.js → chunk-HEWSRXYV.js} +4 -4
- package/dist/chunk-HHMMUR4P.js +335 -0
- package/dist/chunk-HHMMUR4P.js.map +1 -0
- package/dist/chunk-HSI6Q7MI.js +1238 -0
- package/dist/chunk-HSI6Q7MI.js.map +1 -0
- package/dist/{chunk-MPBXOJ3N.js → chunk-IY6256GK.js} +33 -2
- package/dist/chunk-IY6256GK.js.map +1 -0
- package/dist/{chunk-LS6QPANO.js → chunk-IYSEC543.js} +2 -2
- package/dist/{chunk-DC5H7KEQ.js → chunk-JHGHLKFS.js} +2 -2
- package/dist/{chunk-4DHQCN5H.js → chunk-LBYOLBW7.js} +17 -2
- package/dist/chunk-LBYOLBW7.js.map +1 -0
- package/dist/{chunk-6QRLRT3P.js → chunk-MT2JINSS.js} +16 -265
- package/dist/chunk-MT2JINSS.js.map +1 -0
- package/dist/{chunk-TP5M2NQB.js → chunk-NCMZMLY7.js} +3 -3
- package/dist/{chunk-COZPL62X.js → chunk-NDWYJLRF.js} +24 -139
- package/dist/chunk-NDWYJLRF.js.map +1 -0
- package/dist/{chunk-3YXDSCM6.js → chunk-NXNPDCF3.js} +29 -11
- package/dist/chunk-NXNPDCF3.js.map +1 -0
- package/dist/chunk-NYUYNYQL.js +48 -0
- package/dist/chunk-NYUYNYQL.js.map +1 -0
- package/dist/chunk-P6AORG32.js +415 -0
- package/dist/chunk-P6AORG32.js.map +1 -0
- package/dist/chunk-PTDMVGKL.js +31 -0
- package/dist/chunk-PTDMVGKL.js.map +1 -0
- package/dist/{chunk-D4FVAIEJ.js → chunk-PZR4EBPY.js} +5 -2
- package/dist/chunk-PZR4EBPY.js.map +1 -0
- package/dist/{chunk-LTB6KVJW.js → chunk-SDDPUAWH.js} +13 -11
- package/dist/{chunk-LTB6KVJW.js.map → chunk-SDDPUAWH.js.map} +1 -1
- package/dist/{chunk-WYYQK4D3.js → chunk-TD2NNXLZ.js} +13 -13
- package/dist/{chunk-ZMIPG6TR.js → chunk-TVL5MVUF.js} +4 -4
- package/dist/chunk-UDRHAQT7.js +172 -0
- package/dist/chunk-UDRHAQT7.js.map +1 -0
- package/dist/chunk-VAD43MOR.js +72 -0
- package/dist/chunk-VAD43MOR.js.map +1 -0
- package/dist/chunk-VSBFM7DS.js +229 -0
- package/dist/chunk-VSBFM7DS.js.map +1 -0
- package/dist/{chunk-VRWUL4TR.js → chunk-XHHMM5HX.js} +2 -2
- package/dist/{chunk-RJB3QUSP.js → chunk-XIDUYLSR.js} +3 -3
- package/dist/{chunk-QG6OSL4M.js → chunk-XUGSZP7Z.js} +2 -2
- package/dist/{chunk-S56JQJ4F.js → chunk-Y53WOQIJ.js} +3 -3
- package/dist/{chunk-62VF32IC.js → chunk-YFAXOD7Z.js} +10 -6
- package/dist/chunk-YFAXOD7Z.js.map +1 -0
- package/dist/{chunk-ICXV25N7.js → chunk-Z3KYFUN6.js} +3 -3
- package/dist/chunk-ZBDZTJUO.js +129 -0
- package/dist/chunk-ZBDZTJUO.js.map +1 -0
- package/dist/chunk-ZGBEIWSC.js +146 -0
- package/dist/chunk-ZGBEIWSC.js.map +1 -0
- package/dist/cli/bin.js +51 -28
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.d.ts +30 -1
- package/dist/cli/commands/audit.js +69 -33
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/client-AUE3XUPO.js +11 -0
- package/dist/{convert-to-pnpm-AT52NWZB.js → convert-to-pnpm-QFJOEP5V.js} +9 -9
- package/dist/db-UXNIZ2RG.js +217 -0
- package/dist/db-UXNIZ2RG.js.map +1 -0
- package/dist/{digest-UVHYZUYE.js → digest-FW6QMZZJ.js} +26 -22
- package/dist/{digest-UVHYZUYE.js.map → digest-FW6QMZZJ.js.map} +1 -1
- package/dist/dump-P6EBSIOH.js +68 -0
- package/dist/dump-P6EBSIOH.js.map +1 -0
- package/dist/email-KJXASVH5.js +195 -0
- package/dist/email-KJXASVH5.js.map +1 -0
- package/dist/{ensure-site-S25MDDHW.js → ensure-site-4DCEBAPM.js} +25 -16
- package/dist/ensure-site-4DCEBAPM.js.map +1 -0
- package/dist/fleet-events-cF2Om88w.d.ts +24 -0
- package/dist/fleet-state-7TJ2GYT2.js +39 -0
- package/dist/{forms-notify-target-TWN4EH6K.js → forms-notify-target-2EAWCWBW.js} +42 -133
- package/dist/forms-notify-target-2EAWCWBW.js.map +1 -0
- package/dist/{github-signals-G4ILZAJ6.js → github-signals-E2WPYTBK.js} +42 -21
- package/dist/github-signals-E2WPYTBK.js.map +1 -0
- package/dist/{header-image-T7XD37JL.js → header-image-6IC53W27.js} +36 -13
- package/dist/header-image-6IC53W27.js.map +1 -0
- package/dist/header-images-SQBCE5OD.js +88 -0
- package/dist/header-images-SQBCE5OD.js.map +1 -0
- package/dist/{health-endpoint-242WG2OJ.js → health-endpoint-RFXL2F4A.js} +9 -9
- package/dist/health-mirror-L5JBEJVA.js +41 -0
- package/dist/health-mirror-L5JBEJVA.js.map +1 -0
- package/dist/import-airtable-OHVY344G.js +37 -0
- package/dist/index.d.ts +324 -21
- package/dist/index.js +215 -76
- package/dist/index.js.map +1 -1
- package/dist/{init-JN26NTWF.js → init-XRAQZYID.js} +21 -18
- package/dist/{init-JN26NTWF.js.map → init-XRAQZYID.js.map} +1 -1
- package/dist/{launch-JA7Z4CIM.js → launch-IX4Y5JJC.js} +65 -30
- package/dist/launch-IX4Y5JJC.js.map +1 -0
- package/dist/lighthouse-VHI77PFB.js +11 -0
- package/dist/{lighthouse-airtable-BIPM26SZ.js → lighthouse-airtable-3GUWHETW.js} +3 -3
- package/dist/mailchimp-XYCTEEJY.js +72 -0
- package/dist/mailchimp-XYCTEEJY.js.map +1 -0
- package/dist/migrate-GQH237A6.js +7 -0
- package/dist/migrate-GQH237A6.js.map +1 -0
- package/dist/notify-C645PGD5.js +22 -0
- package/dist/notify-C645PGD5.js.map +1 -0
- package/dist/{onboard-53CITIM7.js → onboard-O4IAHJMO.js} +9 -9
- package/dist/orchestrate-YLWQ6KK2.js +31 -0
- package/dist/orchestrate-YLWQ6KK2.js.map +1 -0
- package/dist/parity-56KA2DGR.js +12 -0
- package/dist/parity-56KA2DGR.js.map +1 -0
- package/dist/pipeline-3AANZFCR.js +12 -0
- package/dist/pipeline-3AANZFCR.js.map +1 -0
- package/dist/{preflight-3XPDUHFJ.js → preflight-TNORKGCX.js} +17 -13
- package/dist/{preflight-3XPDUHFJ.js.map → preflight-TNORKGCX.js.map} +1 -1
- package/dist/{prismic-ci-UTQ6LRR3.js → prismic-ci-G2AK53VL.js} +12 -12
- package/dist/{prismic-models-PVES6R5V.js → prismic-models-O6Z3OK3A.js} +18 -13
- package/dist/prismic-models-O6Z3OK3A.js.map +1 -0
- package/dist/prospect/types.d.ts +267 -0
- package/dist/prospect/types.js +1 -0
- package/dist/prospect/types.js.map +1 -0
- package/dist/prospect-audit-KSXUNQ5J.js +174 -0
- package/dist/prospect-audit-KSXUNQ5J.js.map +1 -0
- package/dist/prospect-audits-3PMIO73D.js +19 -0
- package/dist/prospect-audits-3PMIO73D.js.map +1 -0
- package/dist/{protection-audit-64PLLBLI.js → protection-audit-SJO53MEQ.js} +3 -3
- package/dist/recipes/sync-configs.js +3 -3
- package/dist/render-IFOGFB5T.js +10 -0
- package/dist/render-IFOGFB5T.js.map +1 -0
- package/dist/render-from-row-BWSOIYDS.js +17 -0
- package/dist/render-from-row-BWSOIYDS.js.map +1 -0
- package/dist/{renovate-dispatch-ICJGSFCE.js → renovate-dispatch-6GQWTRKX.js} +12 -12
- package/dist/{renovate-dispatch-ICJGSFCE.js.map → renovate-dispatch-6GQWTRKX.js.map} +1 -1
- package/dist/replay-C2LXMJZP.js +66 -0
- package/dist/replay-C2LXMJZP.js.map +1 -0
- package/dist/{report-6CKSWJNG.js → report-R3E3Y7HR.js} +100 -32
- package/dist/report-R3E3Y7HR.js.map +1 -0
- package/dist/report-mirror-4MNORYQ4.js +49 -0
- package/dist/report-mirror-4MNORYQ4.js.map +1 -0
- package/dist/reports/header-image/assets/headline-announcement.png +0 -0
- package/dist/reports/header-image/assets/headline-launch.png +0 -0
- package/dist/reports/header-image/assets/headline-maintenance.png +0 -0
- package/dist/reports/header-image/assets/headline-testing.png +0 -0
- package/dist/reports/header-image/assets/plate-clean.png +0 -0
- package/dist/rerender-3FM6DNRH.js +32 -0
- package/dist/rerender-3FM6DNRH.js.map +1 -0
- package/dist/{self-updating-PPQVV6UA.js → self-updating-UCIWESAX.js} +9 -9
- package/dist/{selftest-CQ6NOX52.js → selftest-VGHWPN72.js} +31 -24
- package/dist/selftest-VGHWPN72.js.map +1 -0
- package/dist/site-mirror-NIREX5ME.js +52 -0
- package/dist/site-mirror-NIREX5ME.js.map +1 -0
- package/dist/smoke-B2GQOSIZ.js +20 -0
- package/dist/smoke-B2GQOSIZ.js.map +1 -0
- package/dist/{smoke-suite-TMCSOYRK.js → smoke-suite-U3UKD3NS.js} +9 -9
- package/dist/{submissions-GJ6ITBRH.js → submissions-IQEJX6FK.js} +3 -3
- package/dist/{svelte-codemods-ZOBDHIES.js → svelte-codemods-GOKXTBHW.js} +9 -9
- package/dist/sync-CTUVXMCK.js +44 -0
- package/dist/sync-CTUVXMCK.js.map +1 -0
- package/dist/{sync-configs-CJXUX6MD.js → sync-configs-SPTLFJCC.js} +9 -9
- package/dist/{upgrade-WLX27UPC.js → upgrade-W6ZR3FJH.js} +9 -9
- package/dist/webhook-VOGOW3W6.js +38 -0
- package/dist/webhook-VOGOW3W6.js.map +1 -0
- package/dist/{websites-SADZR4XQ.js → websites-D6WYFAES.js} +6 -2
- package/dist/websites-D6WYFAES.js.map +1 -0
- package/dist/{write-audits-to-airtable-7PH75YWY.js → write-audits-to-airtable-CMKHGJNQ.js} +4 -4
- package/dist/write-audits-to-airtable-CMKHGJNQ.js.map +1 -0
- package/package.json +9 -2
- package/dist/airtable-HRKSA7V4.js +0 -9
- package/dist/announce-GIAYN67S.js.map +0 -1
- package/dist/chunk-37S6L6G4.js.map +0 -1
- package/dist/chunk-3YXDSCM6.js.map +0 -1
- package/dist/chunk-4DHQCN5H.js.map +0 -1
- package/dist/chunk-4SOCPK7A.js +0 -108
- package/dist/chunk-4SOCPK7A.js.map +0 -1
- package/dist/chunk-577HCGZO.js.map +0 -1
- package/dist/chunk-62VF32IC.js.map +0 -1
- package/dist/chunk-6QRLRT3P.js.map +0 -1
- package/dist/chunk-COZPL62X.js.map +0 -1
- package/dist/chunk-D4FVAIEJ.js.map +0 -1
- package/dist/chunk-DTTIFYAM.js.map +0 -1
- package/dist/chunk-MGR2H7F4.js.map +0 -1
- package/dist/chunk-MPBXOJ3N.js.map +0 -1
- package/dist/chunk-NWB4O5EY.js.map +0 -1
- package/dist/chunk-SEEA6RXF.js.map +0 -1
- package/dist/chunk-U5SZ4FOP.js +0 -26
- package/dist/chunk-U5SZ4FOP.js.map +0 -1
- package/dist/client-X5WUJWDE.js +0 -11
- package/dist/db-JSIZYT23.js +0 -23
- package/dist/db-JSIZYT23.js.map +0 -1
- package/dist/ensure-site-S25MDDHW.js.map +0 -1
- package/dist/forms-notify-target-TWN4EH6K.js.map +0 -1
- package/dist/github-signals-G4ILZAJ6.js.map +0 -1
- package/dist/header-image-T7XD37JL.js.map +0 -1
- package/dist/launch-JA7Z4CIM.js.map +0 -1
- package/dist/migrate-LJUFGXQZ.js +0 -7
- package/dist/orchestrate-KDNXZQX7.js +0 -27
- package/dist/prismic-models-PVES6R5V.js.map +0 -1
- package/dist/report-6CKSWJNG.js.map +0 -1
- package/dist/reports/header-image/assets/plate.png +0 -0
- package/dist/selftest-CQ6NOX52.js.map +0 -1
- /package/dist/{airtable-HRKSA7V4.js.map → airtable-ZBUPQJSC.js.map} +0 -0
- /package/dist/{client-X5WUJWDE.js.map → attachments-NLZIJ5NU.js.map} +0 -0
- /package/dist/{bump-deps-SWJB6H5V.js.map → bump-deps-CGVELRLK.js.map} +0 -0
- /package/dist/{chunk-SDUJIANJ.js.map → chunk-2NJQLSEU.js.map} +0 -0
- /package/dist/{chunk-ZTPPCHDB.js.map → chunk-3G25KIWW.js.map} +0 -0
- /package/dist/{chunk-THEFTZ7F.js.map → chunk-62CMMGEO.js.map} +0 -0
- /package/dist/{chunk-FDIFTIXC.js.map → chunk-6TWWY7ZU.js.map} +0 -0
- /package/dist/{chunk-PBWDDDQH.js.map → chunk-7BZUOZPU.js.map} +0 -0
- /package/dist/{chunk-UYCAF77E.js.map → chunk-7RJ2HMMJ.js.map} +0 -0
- /package/dist/{chunk-Q276MZ7E.js.map → chunk-7UAYMAKR.js.map} +0 -0
- /package/dist/{chunk-R6FPFMOS.js.map → chunk-BQRFQSGY.js.map} +0 -0
- /package/dist/{chunk-7J53SUT6.js.map → chunk-CROKUHJB.js.map} +0 -0
- /package/dist/{chunk-GZNBCHAZ.js.map → chunk-HEWSRXYV.js.map} +0 -0
- /package/dist/{chunk-LS6QPANO.js.map → chunk-IYSEC543.js.map} +0 -0
- /package/dist/{chunk-DC5H7KEQ.js.map → chunk-JHGHLKFS.js.map} +0 -0
- /package/dist/{chunk-TP5M2NQB.js.map → chunk-NCMZMLY7.js.map} +0 -0
- /package/dist/{chunk-WYYQK4D3.js.map → chunk-TD2NNXLZ.js.map} +0 -0
- /package/dist/{chunk-ZMIPG6TR.js.map → chunk-TVL5MVUF.js.map} +0 -0
- /package/dist/{chunk-VRWUL4TR.js.map → chunk-XHHMM5HX.js.map} +0 -0
- /package/dist/{chunk-RJB3QUSP.js.map → chunk-XIDUYLSR.js.map} +0 -0
- /package/dist/{chunk-QG6OSL4M.js.map → chunk-XUGSZP7Z.js.map} +0 -0
- /package/dist/{chunk-S56JQJ4F.js.map → chunk-Y53WOQIJ.js.map} +0 -0
- /package/dist/{chunk-ICXV25N7.js.map → chunk-Z3KYFUN6.js.map} +0 -0
- /package/dist/{lighthouse-airtable-BIPM26SZ.js.map → client-AUE3XUPO.js.map} +0 -0
- /package/dist/{convert-to-pnpm-AT52NWZB.js.map → convert-to-pnpm-QFJOEP5V.js.map} +0 -0
- /package/dist/{migrate-LJUFGXQZ.js.map → fleet-state-7TJ2GYT2.js.map} +0 -0
- /package/dist/{health-endpoint-242WG2OJ.js.map → health-endpoint-RFXL2F4A.js.map} +0 -0
- /package/dist/{orchestrate-KDNXZQX7.js.map → import-airtable-OHVY344G.js.map} +0 -0
- /package/dist/{websites-SADZR4XQ.js.map → lighthouse-VHI77PFB.js.map} +0 -0
- /package/dist/{write-audits-to-airtable-7PH75YWY.js.map → lighthouse-airtable-3GUWHETW.js.map} +0 -0
- /package/dist/{onboard-53CITIM7.js.map → onboard-O4IAHJMO.js.map} +0 -0
- /package/dist/{prismic-ci-UTQ6LRR3.js.map → prismic-ci-G2AK53VL.js.map} +0 -0
- /package/dist/{protection-audit-64PLLBLI.js.map → protection-audit-SJO53MEQ.js.map} +0 -0
- /package/dist/{self-updating-PPQVV6UA.js.map → self-updating-UCIWESAX.js.map} +0 -0
- /package/dist/{smoke-suite-TMCSOYRK.js.map → smoke-suite-U3UKD3NS.js.map} +0 -0
- /package/dist/{submissions-GJ6ITBRH.js.map → submissions-IQEJX6FK.js.map} +0 -0
- /package/dist/{svelte-codemods-ZOBDHIES.js.map → svelte-codemods-GOKXTBHW.js.map} +0 -0
- /package/dist/{sync-configs-CJXUX6MD.js.map → sync-configs-SPTLFJCC.js.map} +0 -0
- /package/dist/{upgrade-WLX27UPC.js.map → upgrade-W6ZR3FJH.js.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/inventory/airtable.ts"],"sourcesContent":["import type { Site, InventoryProvider } from \"../types.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n ACTIVE_STATUSES,\n isPreLaunch,\n} from \"../reports/airtable/websites.js\";\nimport { isHttpUrl } from \"../util/url.js\";\n\nexport type AirtableInventoryOptions = {\n /**\n * Local workdir to compute each site's path as `{workdir}/{slug}`.\n * Defaults to REDDOOR_FLEET_WORKDIR env var if not provided.\n * Airtable doesn't store local checkout paths, so this is required.\n */\n workdir?: string;\n};\n\n/**\n * Read sites from the Airtable Websites table as an InventoryProvider.\n * Each row becomes one Site; `path` is computed as `{workdir}/{slug}`.\n * Only LIVE `maintenance` sites that have a `url` are included — pre-launch\n * stages (\"launching\" / \"building\", via isPreLaunch) are excluded so a\n * not-yet-live site is never audited as production (its deploy/domain/uptime/CMS\n * audits would fail against nothing and red its Airtable row) nor swept by other\n * `--fleet airtable` ops; it re-enters the fleet when a Launch report flips its\n * Status to \"maintained\". The production URL is exposed as `Site.deployedUrl` so\n * the lighthouse audit can run against it with no checkout. `repoUrl` is\n * intentionally NOT set from `url` — a clone source must come from `gitRepo`\n * (`owner/repo`), never the production URL.\n */\nexport function fromAirtableBase(\n base: AirtableBase,\n opts: AirtableInventoryOptions = {},\n): InventoryProvider {\n return async (): Promise<Site[]> => {\n const workdir = opts.workdir ?? process.env.REDDOOR_FLEET_WORKDIR;\n if (!workdir) {\n throw new Error(\n \"fromAirtableBase requires `workdir` option or REDDOOR_FLEET_WORKDIR env (sites need a local path)\",\n );\n }\n const websites = await listWebsites(base);\n return websites\n .filter(\n (w) =>\n w.status !== null &&\n ACTIVE_STATUSES.has(w.status) &&\n !isPreLaunch(w.status) &&\n w.url.length > 0,\n )\n .flatMap((w) => {\n const slug = siteSlug(w.name);\n // An empty slug (a Name with no slug-able characters) can't form a stable\n // path and — fatally — can't be matched back to its Websites row on\n // write-back: every empty-slug site would collapse under the \"\" key and\n // mis-write or fail. Skip it loudly rather than silently mis-map it.\n if (slug.length === 0) {\n console.warn(\n `[inventory] skipping \"${w.name}\" (row ${w.id}): Name has no slug-able characters (empty slug)`,\n );\n return [];\n }\n const site: Site = {\n path: `${workdir}/${slug}`,\n name: slug,\n meta: { airtableRowId: w.id, displayName: w.name },\n };\n // Scheme-allowlist the Airtable `url` before exposing it as the\n // deployed-audit target (it's handed straight to Chrome/lhci). A\n // `file://`/`gopher://`/internal-host value would be a local-file read\n // or SSRF — skip the deployed audit for that site rather than trust it.\n if (isHttpUrl(w.url)) {\n site.deployedUrl = w.url;\n } else {\n console.warn(\n `[inventory] skipping deployed audit for \"${w.name}\": url is not http(s): ${JSON.stringify(w.url)}`,\n );\n }\n if (w.gitRepo) site.gitRepo = w.gitRepo;\n // Expose the Netlify site id (operator-set identity column) so the\n // netlify-deploy audit can query the API with no checkout. Absent → that\n // audit skips for this site. Not derived from the URL.\n if (w.netlifyId) site.netlifyId = w.netlifyId;\n return [site];\n });\n };\n}\n"],"mappings":";;;;;;;;;;;AAgCO,SAAS,iBACd,MACA,OAAiC,CAAC,GACf;AACnB,SAAO,YAA6B;AAClC,UAAM,UAAU,KAAK,WAAW,QAAQ,IAAI;AAC5C,QAAI,CAAC,SAAS;AACZ,YAAM,IAAI;AAAA,QACR;AAAA,MACF;AAAA,IACF;AACA,UAAM,WAAW,MAAM,aAAa,IAAI;AACxC,WAAO,SACJ;AAAA,MACC,CAAC,MACC,EAAE,WAAW,QACb,gBAAgB,IAAI,EAAE,MAAM,KAC5B,CAAC,YAAY,EAAE,MAAM,KACrB,EAAE,IAAI,SAAS;AAAA,IACnB,EACC,QAAQ,CAAC,MAAM;AACd,YAAM,OAAO,SAAS,EAAE,IAAI;AAK5B,UAAI,KAAK,WAAW,GAAG;AACrB,gBAAQ;AAAA,UACN,yBAAyB,EAAE,IAAI,UAAU,EAAE,EAAE;AAAA,QAC/C;AACA,eAAO,CAAC;AAAA,MACV;AACA,YAAM,OAAa;AAAA,QACjB,MAAM,GAAG,OAAO,IAAI,IAAI;AAAA,QACxB,MAAM;AAAA,QACN,MAAM,EAAE,eAAe,EAAE,IAAI,aAAa,EAAE,KAAK;AAAA,MACnD;AAKA,UAAI,UAAU,EAAE,GAAG,GAAG;AACpB,aAAK,cAAc,EAAE;AAAA,MACvB,OAAO;AACL,gBAAQ;AAAA,UACN,4CAA4C,EAAE,IAAI,0BAA0B,KAAK,UAAU,EAAE,GAAG,CAAC;AAAA,QACnG;AAAA,MACF;AACA,UAAI,EAAE,QAAS,MAAK,UAAU,EAAE;AAIhC,UAAI,EAAE,UAAW,MAAK,YAAY,EAAE;AACpC,aAAO,CAAC,IAAI;AAAA,IACd,CAAC;AAAA,EACL;AACF;","names":[]}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
fromJsonFile,
|
|
3
3
|
localPath
|
|
4
|
-
} from "./chunk-
|
|
4
|
+
} from "./chunk-6TWWY7ZU.js";
|
|
5
5
|
import {
|
|
6
6
|
isHttpUrl
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-EVBCK2KC.js";
|
|
8
8
|
|
|
9
9
|
// src/cli/fleet/resolve-sites.ts
|
|
10
10
|
import { pathToFileURL } from "url";
|
|
@@ -30,7 +30,7 @@ async function resolveSites(input) {
|
|
|
30
30
|
}
|
|
31
31
|
if (input.fleet === "airtable") {
|
|
32
32
|
const { openBase, readAirtableConfig } = await import("./client-EPVO3FEH.js");
|
|
33
|
-
const { fromAirtableBase } = await import("./airtable-
|
|
33
|
+
const { fromAirtableBase } = await import("./airtable-ZBUPQJSC.js");
|
|
34
34
|
const base = openBase(readAirtableConfig());
|
|
35
35
|
const provider = fromAirtableBase(base, input.workdir ? { workdir: input.workdir } : {});
|
|
36
36
|
return provider();
|
|
@@ -61,4 +61,4 @@ async function resolveSites(input) {
|
|
|
61
61
|
export {
|
|
62
62
|
resolveSites
|
|
63
63
|
};
|
|
64
|
-
//# sourceMappingURL=chunk-
|
|
64
|
+
//# sourceMappingURL=chunk-CROKUHJB.js.map
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ANALYZE_SKIPPED,
|
|
3
|
+
PROBES_SKIPPED
|
|
4
|
+
} from "./chunk-HSI6Q7MI.js";
|
|
5
|
+
import {
|
|
6
|
+
escapeHtml,
|
|
7
|
+
safeUrl
|
|
8
|
+
} from "./chunk-DJZWYIPP.js";
|
|
9
|
+
import {
|
|
10
|
+
hostnameOf
|
|
11
|
+
} from "./chunk-EVBCK2KC.js";
|
|
12
|
+
|
|
13
|
+
// src/prospect/render.ts
|
|
14
|
+
var RED = "#d71920";
|
|
15
|
+
var IMPACT_ORDER = { high: 0, medium: 1, low: 2 };
|
|
16
|
+
var KIND_LABEL = {
|
|
17
|
+
branded: "Branded \u2014 asked about the business by name",
|
|
18
|
+
category: "Category \u2014 the questions a buyer would actually type",
|
|
19
|
+
competitor: "Competitor \u2014 head-to-head comparisons"
|
|
20
|
+
};
|
|
21
|
+
var KIND_ORDER = ["category", "branded", "competitor"];
|
|
22
|
+
var AI_AGENT_LABELS = {
|
|
23
|
+
GPTBot: "ChatGPT",
|
|
24
|
+
"OAI-SearchBot": "ChatGPT's search results",
|
|
25
|
+
ClaudeBot: "Claude",
|
|
26
|
+
PerplexityBot: "Perplexity",
|
|
27
|
+
"Google-Extended": "Gemini and Google's AI Overviews",
|
|
28
|
+
CCBot: "Common Crawl, used to train many AI models"
|
|
29
|
+
};
|
|
30
|
+
function describeAgent(agent) {
|
|
31
|
+
const label = AI_AGENT_LABELS[agent];
|
|
32
|
+
return label ? `${agent} (feeds ${label})` : agent;
|
|
33
|
+
}
|
|
34
|
+
var STAGE_FAILED_MESSAGE = "we could not complete this part of the analysis";
|
|
35
|
+
var STYLES = `
|
|
36
|
+
:root { color-scheme: light; }
|
|
37
|
+
* { box-sizing: border-box; }
|
|
38
|
+
body { margin: 0; background: #faf8f5; color: #1a1a1a;
|
|
39
|
+
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
|
|
40
|
+
.wrap { max-width: 860px; margin: 0 auto; padding: 48px 24px 80px; }
|
|
41
|
+
h1, h2, h3 { font-family: Besley, Georgia, "Times New Roman", serif; font-weight: 600; line-height: 1.2; }
|
|
42
|
+
h1 { font-size: 40px; margin: 0 0 8px; }
|
|
43
|
+
h2 { font-size: 26px; margin: 48px 0 12px; border-top: 2px solid #e6e1da; padding-top: 24px; }
|
|
44
|
+
h3 { font-size: 18px; margin: 24px 0 8px; }
|
|
45
|
+
.lede { color: #57544f; margin: 0 0 8px; }
|
|
46
|
+
.scores { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 32px 0; }
|
|
47
|
+
.score { background: #fff; border: 1px solid #e6e1da; border-radius: 8px; padding: 16px; }
|
|
48
|
+
.score .n { font-family: Besley, Georgia, serif; font-size: 38px; line-height: 1; color: ${RED}; }
|
|
49
|
+
.score .n.na { font-size: 18px; color: #8a857e; }
|
|
50
|
+
.score .l { font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: #57544f; margin-top: 8px; }
|
|
51
|
+
.score .hint { font-size: 12px; color: #8a857e; margin-top: 4px; }
|
|
52
|
+
.card { background: #fff; border: 1px solid #e6e1da; border-radius: 8px; padding: 16px; margin: 12px 0; }
|
|
53
|
+
.q { font-weight: 600; }
|
|
54
|
+
.tag { display: inline-block; font-size: 12px; letter-spacing: .06em; text-transform: uppercase;
|
|
55
|
+
border-radius: 999px; padding: 2px 10px; border: 1px solid currentColor; }
|
|
56
|
+
.yes { color: #14663c; } .partial { color: #8a6d00; } .no { color: ${RED}; }
|
|
57
|
+
ul { padding-left: 20px; } li { margin: 6px 0; }
|
|
58
|
+
.muted { color: #8a857e; }
|
|
59
|
+
.cta { margin-top: 56px; background: ${RED}; color: #fff; border-radius: 8px; padding: 24px 28px; }
|
|
60
|
+
.cta h2 { border: 0; padding: 0; margin: 0 0 8px; color: #fff; }
|
|
61
|
+
.cta a { color: #fff; }
|
|
62
|
+
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
|
|
63
|
+
td, th { text-align: left; padding: 6px 8px; border-bottom: 1px solid #eee; vertical-align: top; }
|
|
64
|
+
@media print {
|
|
65
|
+
body { background: #fff; }
|
|
66
|
+
.card, .score, .cta { break-inside: avoid; }
|
|
67
|
+
/* Browsers default "print background graphics" OFF, which drops the red
|
|
68
|
+
.cta background but keeps its white text \u2014 the only ask in the report
|
|
69
|
+
would otherwise print white-on-white and vanish. Fall back to dark
|
|
70
|
+
text on no background with a visible border instead of relying on the
|
|
71
|
+
background for contrast. */
|
|
72
|
+
.cta { background: none; color: #1a1a1a; border: 2px solid ${RED}; }
|
|
73
|
+
.cta h2, .cta a { color: #1a1a1a; }
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
function scoreCard(label, value, hint) {
|
|
77
|
+
const n = value === null ? `<div class="n na">Not measured</div>` : `<div class="n">${value}</div>`;
|
|
78
|
+
const hintHtml = hint ? `<div class="hint">${escapeHtml(hint)}</div>` : "";
|
|
79
|
+
return `<div class="score">${n}<div class="l">${escapeHtml(label)}</div>${hintHtml}</div>`;
|
|
80
|
+
}
|
|
81
|
+
function stageBody(stage, body) {
|
|
82
|
+
return stage.ok ? body(stage.data) : notMeasuredNote();
|
|
83
|
+
}
|
|
84
|
+
function notMeasuredNote() {
|
|
85
|
+
return `<p class="muted">Not measured \u2014 ${STAGE_FAILED_MESSAGE}.</p>`;
|
|
86
|
+
}
|
|
87
|
+
function skippableStageNote(error, skipConstant, skipMessage) {
|
|
88
|
+
if (error === skipConstant) {
|
|
89
|
+
return `<p class="muted">${escapeHtml(skipMessage)}</p>`;
|
|
90
|
+
}
|
|
91
|
+
return notMeasuredNote();
|
|
92
|
+
}
|
|
93
|
+
function formatIsoDate(iso) {
|
|
94
|
+
const d = new Date(iso);
|
|
95
|
+
if (Number.isNaN(d.getTime())) return iso;
|
|
96
|
+
return d.toLocaleDateString("en-US", {
|
|
97
|
+
year: "numeric",
|
|
98
|
+
month: "long",
|
|
99
|
+
day: "numeric",
|
|
100
|
+
timeZone: "UTC"
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
function buildProbesSection(p, businessNameUsed) {
|
|
104
|
+
const byKind = /* @__PURE__ */ new Map();
|
|
105
|
+
for (const a of p.answers) {
|
|
106
|
+
const bucket = byKind.get(a.kind) ?? [];
|
|
107
|
+
bucket.push(a);
|
|
108
|
+
byKind.set(a.kind, bucket);
|
|
109
|
+
}
|
|
110
|
+
const recognition = p.brandedRecognized ? "<p>When the engines were asked about the business by name, they recognized it.</p>" : businessNameUsed ? "<p>When the engines were asked about the business by name, they did not recognize it \u2014 a real citation of the site never showed up, even with the name handed to them.</p>" : "<p>The engines did not recognize the business \u2014 there was no business name available to ask them with, so this reflects a harder starting point than a named query would.</p>";
|
|
111
|
+
const categoryCaveat = byKind.has("category") ? "" : `<p class="muted"><strong>No buyer-question (category) query was tested here</strong> \u2014 only name-recognition. An engine echoing back a name it was just given says nothing about whether a buyer who had never heard of the business would be shown it; treat the line above as a floor, not a visibility signal.</p>`;
|
|
112
|
+
const groups = KIND_ORDER.filter((k) => byKind.has(k)).map((kind) => {
|
|
113
|
+
const answers = byKind.get(kind) ?? [];
|
|
114
|
+
const cards = answers.map(
|
|
115
|
+
(a) => `<div class="card">
|
|
116
|
+
<div class="q">${escapeHtml(a.engine)} \xB7 \u201C${escapeHtml(a.query)}\u201D</div>
|
|
117
|
+
<p class="muted">Asked ${escapeHtml(formatIsoDate(a.askedAt))}</p>
|
|
118
|
+
<p>${escapeHtml(a.snippet)}${a.truncated ? "\u2026" : ""}</p>
|
|
119
|
+
<p class="muted">${a.domainCited || a.brandMentioned ? "You were named in this answer." : "You were not named in this answer."}${a.citedDomains.length ? ` Cited: ${escapeHtml(a.citedDomains.join(", "))}` : ""}</p>
|
|
120
|
+
</div>`
|
|
121
|
+
).join("");
|
|
122
|
+
return `<h3>${escapeHtml(KIND_LABEL[kind])}</h3>${cards}`;
|
|
123
|
+
}).join("");
|
|
124
|
+
const competitors = p.competitorsSeen.length ? `<h3>Who the engines cited instead</h3><ul>${p.competitorsSeen.map((c) => `<li>${escapeHtml(c.domain)} \u2014 ${c.count} time${c.count === 1 ? "" : "s"}</li>`).join("")}</ul>` : "";
|
|
125
|
+
return recognition + categoryCaveat + groups + competitors;
|
|
126
|
+
}
|
|
127
|
+
function buildFindabilitySection(c) {
|
|
128
|
+
const accessBlock = !c.crawlerAccessMeasured ? `<p class="muted">Crawler access: not measured \u2014 ${STAGE_FAILED_MESSAGE}.</p>` : c.crawlerAccess.blockedAi.length ? `<p><strong>Blocked AI crawlers:</strong> ${escapeHtml(
|
|
129
|
+
c.crawlerAccess.blockedAi.map(describeAgent).join(", ")
|
|
130
|
+
)}</p>` : `<p>Every AI crawler we checked can reach the site.</p>`;
|
|
131
|
+
const classical = c.crawlerAccessMeasured && c.crawlerAccess.blockedClassical.length ? `<p><strong>Blocked search crawlers:</strong> ${escapeHtml(
|
|
132
|
+
c.crawlerAccess.blockedClassical.join(", ")
|
|
133
|
+
)}</p>` : "";
|
|
134
|
+
const sitemapLine = c.sitemapMeasured ? `<li>sitemap.xml: ${c.sitemapPresent ? "present" : "missing"}</li>` : `<li>sitemap.xml: not measured \u2014 ${STAGE_FAILED_MESSAGE}.</li>`;
|
|
135
|
+
const llmsLine = c.llmsTxtMeasured ? `<li>llms.txt: ${c.llmsTxtPresent ? "present" : "missing"}</li>` : `<li>llms.txt: not measured \u2014 ${STAGE_FAILED_MESSAGE}.</li>`;
|
|
136
|
+
return `${accessBlock}${classical}
|
|
137
|
+
<ul>
|
|
138
|
+
${sitemapLine}
|
|
139
|
+
${llmsLine}
|
|
140
|
+
<li>Pages missing a meta description: ${c.meta.missingDescription} of ${c.meta.pageCount}</li>
|
|
141
|
+
<li>Pages missing a canonical URL: ${c.meta.missingCanonical} of ${c.meta.pageCount}</li>
|
|
142
|
+
<li>Pages missing share images/titles: ${c.meta.missingSocial} of ${c.meta.pageCount}</li>
|
|
143
|
+
<li>Security headers missing: ${c.securityHeaders.missing.length ? escapeHtml(c.securityHeaders.missing.join(", ")) : "none"}</li>
|
|
144
|
+
${c.meta.pagesWithoutExtract > 0 ? `<li><strong>${c.meta.pagesWithoutExtract}</strong> additional page${c.meta.pagesWithoutExtract === 1 ? "" : "s"} produced no readable content at all and ${c.meta.pagesWithoutExtract === 1 ? "is" : "are"} not counted in the ${c.meta.pageCount} pages above.</li>` : ""}
|
|
145
|
+
</ul>`;
|
|
146
|
+
}
|
|
147
|
+
function buildLighthouseBlock(lh) {
|
|
148
|
+
return lh.ok ? `<p class="muted">Lighthouse \u2014 performance ${lh.data.performance ?? "not measured"},
|
|
149
|
+
SEO ${lh.data.seo ?? "not measured"}, accessibility ${lh.data.accessibility ?? "not measured"}.</p>` : `<p class="muted">Lighthouse not measured \u2014 ${STAGE_FAILED_MESSAGE}.</p>`;
|
|
150
|
+
}
|
|
151
|
+
function buildReadabilitySection(c) {
|
|
152
|
+
const jsLine = c.jsDependence.avgMissing === null ? `<p class="muted">JavaScript-dependence: not measured \u2014 no page produced a comparable raw/rendered pair.</p>` : `<p><strong>${Math.round(c.jsDependence.avgMissing * 100)}%</strong> of the words a visitor reads only appear after JavaScript runs.
|
|
153
|
+
Most AI crawlers do not run JavaScript, so that share of your site is invisible to them.</p>`;
|
|
154
|
+
return `${jsLine}
|
|
155
|
+
<ul>
|
|
156
|
+
<li>Structured data found: ${c.schema.typesFound.length ? escapeHtml(c.schema.typesFound.join(", ")) : "none"}</li>
|
|
157
|
+
<li>Expected structured data missing: ${c.schema.missingExpected.length ? escapeHtml(c.schema.missingExpected.join(", ")) : "none"}</li>
|
|
158
|
+
<li>Pages without a top-level heading: ${c.headings.pagesWithoutH1} of ${c.meta.pageCount}</li>
|
|
159
|
+
${c.meta.pagesWithoutExtract > 0 ? `<li><strong>${c.meta.pagesWithoutExtract}</strong> page${c.meta.pagesWithoutExtract === 1 ? "" : "s"} produced no extract at all \u2014 excluded from every figure above, not counted as readable or unreadable.</li>` : ""}
|
|
160
|
+
</ul>`;
|
|
161
|
+
}
|
|
162
|
+
function buildAnswersSection(a) {
|
|
163
|
+
const rows = a.buyerQuestions.map((q) => {
|
|
164
|
+
const answered = escapeHtml(q.answered);
|
|
165
|
+
const evidenceText = q.evidence ? escapeHtml(q.evidence) : null;
|
|
166
|
+
const evidenceHref = q.evidence && q.page ? safeUrl(q.page) : null;
|
|
167
|
+
const evidenceCell = evidenceText === null ? '<span class="muted">no passage on the site</span>' : evidenceHref && evidenceHref !== "#" ? `<a href="${escapeHtml(evidenceHref)}" target="_blank" rel="noopener noreferrer">${evidenceText}</a>` : evidenceText;
|
|
168
|
+
return `<tr>
|
|
169
|
+
<td>${escapeHtml(q.question)}</td>
|
|
170
|
+
<td><span class="tag ${answered}">${answered}</span></td>
|
|
171
|
+
<td>${evidenceCell}</td>
|
|
172
|
+
</tr>`;
|
|
173
|
+
}).join("");
|
|
174
|
+
return `<p>${escapeHtml(a.narrative.answers)}</p>
|
|
175
|
+
<table><tr><th>What buyers ask</th><th>Answered</th><th>Evidence</th></tr>${rows}</table>`;
|
|
176
|
+
}
|
|
177
|
+
function renderProspectReport(result) {
|
|
178
|
+
const host = hostnameOf(result.url);
|
|
179
|
+
const name = result.businessName && result.businessName.trim() ? result.businessName : host;
|
|
180
|
+
const date = formatIsoDate(result.generatedAt);
|
|
181
|
+
const findabilitySection = stageBody(result.checks, buildFindabilitySection);
|
|
182
|
+
const lighthouseBlock = buildLighthouseBlock(result.lighthouse);
|
|
183
|
+
const readabilitySection = stageBody(result.checks, buildReadabilitySection);
|
|
184
|
+
const businessNameUsed = Boolean(result.businessName && result.businessName.trim());
|
|
185
|
+
const fixes = result.analyze.ok ? (() => {
|
|
186
|
+
const sorted = [...result.analyze.data.fixes].sort(
|
|
187
|
+
(x, y) => IMPACT_ORDER[x.impact] - IMPACT_ORDER[y.impact]
|
|
188
|
+
);
|
|
189
|
+
if (sorted.length === 0) {
|
|
190
|
+
return `<p>Nothing here reads as urgent \u2014 the essentials are already in place.
|
|
191
|
+
Reddoor can still help push this further: tightening structured data, monitoring
|
|
192
|
+
how the answer engines describe you, and keeping pace as they change.</p>`;
|
|
193
|
+
}
|
|
194
|
+
return `<ol>${sorted.map((f) => {
|
|
195
|
+
const impact = escapeHtml(f.impact);
|
|
196
|
+
const effort = escapeHtml(f.effort);
|
|
197
|
+
return `<li><strong>${escapeHtml(f.title)}</strong> \u2014 ${escapeHtml(f.why)}
|
|
198
|
+
<span class="muted">(${impact} impact, ${effort} effort)</span></li>`;
|
|
199
|
+
}).join("")}</ol>`;
|
|
200
|
+
})() : skippableStageNote(
|
|
201
|
+
result.analyze.error,
|
|
202
|
+
ANALYZE_SKIPPED,
|
|
203
|
+
"Skipped \u2014 the checks stage failed, so there was nothing to build fixes from."
|
|
204
|
+
);
|
|
205
|
+
const probesSectionFinal = result.probes.ok ? buildProbesSection(result.probes.data, businessNameUsed) : skippableStageNote(
|
|
206
|
+
result.probes.error,
|
|
207
|
+
PROBES_SKIPPED,
|
|
208
|
+
"This audit did not run the AI-visibility probes."
|
|
209
|
+
);
|
|
210
|
+
const answersSectionFinal = result.analyze.ok ? buildAnswersSection(result.analyze.data) : skippableStageNote(
|
|
211
|
+
result.analyze.error,
|
|
212
|
+
ANALYZE_SKIPPED,
|
|
213
|
+
"Skipped \u2014 the checks stage failed, so there were no buyer questions to check."
|
|
214
|
+
);
|
|
215
|
+
const description = result.analyze.ok ? result.analyze.data.business : null;
|
|
216
|
+
const narrative = result.analyze.ok ? `<p class="lede">${escapeHtml(description ?? "")}</p>
|
|
217
|
+
<p class="lede">${escapeHtml(result.analyze.data.narrative.findability)}
|
|
218
|
+
${escapeHtml(result.analyze.data.narrative.readability)}</p>` : "";
|
|
219
|
+
return `<!doctype html>
|
|
220
|
+
<html lang="en">
|
|
221
|
+
<head>
|
|
222
|
+
<meta charset="utf-8" />
|
|
223
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
224
|
+
<meta name="robots" content="noindex" />
|
|
225
|
+
<title>Can AI find ${escapeHtml(name)}? \u2014 Reddoor audit</title>
|
|
226
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
227
|
+
<link href="https://fonts.googleapis.com/css2?family=Besley:wght@400;600&display=swap" rel="stylesheet" />
|
|
228
|
+
<style>${STYLES}</style>
|
|
229
|
+
</head>
|
|
230
|
+
<body>
|
|
231
|
+
<div class="wrap">
|
|
232
|
+
<h1>Can AI and Google actually find ${escapeHtml(name)}?</h1>
|
|
233
|
+
<p class="lede"><a href="${escapeHtml(safeUrl(result.url))}" target="_blank" rel="noopener noreferrer">${escapeHtml(result.url)}</a> \xB7 audited ${escapeHtml(date)} by Reddoor Creative</p>
|
|
234
|
+
${narrative}
|
|
235
|
+
|
|
236
|
+
<p class="muted">Each score below runs 0\u2013100 \u2014 read it as a score, not a percentage.</p>
|
|
237
|
+
<div class="scores">
|
|
238
|
+
${scoreCard("Findability", result.scores.findability, "How easily AI and search crawlers can find and reach your site \u2014 crawl rules, sitemap, key metadata")}
|
|
239
|
+
${scoreCard("Readability", result.scores.readability, "How much of your site's content those crawlers can actually read once they're in \u2014 most don't run JavaScript")}
|
|
240
|
+
${scoreCard("Answers", result.scores.answers, "How many buyer questions your site's own content answers \u2014 separate from what the AI engines say back")}
|
|
241
|
+
${scoreCard("AI Visibility", result.scores.aiVisibility, "Based on buyer questions \u2014 not questions that name the business directly")}
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<h2>What the AI engines said about you</h2>
|
|
245
|
+
${probesSectionFinal}
|
|
246
|
+
|
|
247
|
+
<h2>What the crawlers can reach</h2>
|
|
248
|
+
${findabilitySection}
|
|
249
|
+
${lighthouseBlock}
|
|
250
|
+
|
|
251
|
+
<h2>What the crawlers can read</h2>
|
|
252
|
+
${readabilitySection}
|
|
253
|
+
|
|
254
|
+
<h2>The questions your buyers ask</h2>
|
|
255
|
+
${answersSectionFinal}
|
|
256
|
+
|
|
257
|
+
<h2>What to fix first</h2>
|
|
258
|
+
${fixes}
|
|
259
|
+
|
|
260
|
+
<div class="cta">
|
|
261
|
+
<h2>Want this fixed?</h2>
|
|
262
|
+
<p>Reddoor Creative rebuilds sites so answer engines can read, quote and recommend them.
|
|
263
|
+
Reply to the email this link came from, or start at
|
|
264
|
+
<a href="https://reddoorla.com/">reddoorla.com</a>.</p>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
</body>
|
|
268
|
+
</html>`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export {
|
|
272
|
+
renderProspectReport
|
|
273
|
+
};
|
|
274
|
+
//# sourceMappingURL=chunk-DEWJMMG3.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/prospect/render.ts"],"sourcesContent":["import { escapeHtml, safeUrl } from \"../util/html.js\";\nimport { hostnameOf } from \"../util/url.js\";\nimport { ANALYZE_SKIPPED, PROBES_SKIPPED } from \"./pipeline.js\";\nimport type {\n AnalyzeResult,\n ChecksResult,\n Fix,\n LighthouseScores,\n ProbeAnswer,\n ProbesResult,\n ProspectAuditResult,\n StageResult,\n} from \"./types.js\";\n\nconst RED = \"#d71920\";\nconst IMPACT_ORDER: Record<Fix[\"impact\"], number> = { high: 0, medium: 1, low: 2 };\nconst KIND_LABEL: Record<ProbeAnswer[\"kind\"], string> = {\n branded: \"Branded — asked about the business by name\",\n category: \"Category — the questions a buyer would actually type\",\n competitor: \"Competitor — head-to-head comparisons\",\n};\nconst KIND_ORDER: ProbeAnswer[\"kind\"][] = [\"category\", \"branded\", \"competitor\"];\n\n/** Terse product mapping so a recipient who has never seen a crawler's raw\n * user-agent string can place it — factual labelling, not a voice change. */\nconst AI_AGENT_LABELS: Record<string, string> = {\n GPTBot: \"ChatGPT\",\n \"OAI-SearchBot\": \"ChatGPT's search results\",\n ClaudeBot: \"Claude\",\n PerplexityBot: \"Perplexity\",\n \"Google-Extended\": \"Gemini and Google's AI Overviews\",\n CCBot: \"Common Crawl, used to train many AI models\",\n};\n\nfunction describeAgent(agent: string): string {\n const label = AI_AGENT_LABELS[agent];\n return label ? `${agent} (feeds ${label})` : agent;\n}\n\n/** Every genuine stage failure collapses to this one phrase — the real error\n * (status codes, retry counts, timeouts — operator vocabulary) stays in the\n * persisted JSON and CLI output for operators, but never reaches a stranger\n * who would read it as broken software rather than a diagnostic. */\nconst STAGE_FAILED_MESSAGE = \"we could not complete this part of the analysis\";\n\nconst STYLES = `\n :root { color-scheme: light; }\n * { box-sizing: border-box; }\n body { margin: 0; background: #faf8f5; color: #1a1a1a;\n font: 16px/1.6 -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif; }\n .wrap { max-width: 860px; margin: 0 auto; padding: 48px 24px 80px; }\n h1, h2, h3 { font-family: Besley, Georgia, \"Times New Roman\", serif; font-weight: 600; line-height: 1.2; }\n h1 { font-size: 40px; margin: 0 0 8px; }\n h2 { font-size: 26px; margin: 48px 0 12px; border-top: 2px solid #e6e1da; padding-top: 24px; }\n h3 { font-size: 18px; margin: 24px 0 8px; }\n .lede { color: #57544f; margin: 0 0 8px; }\n .scores { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 32px 0; }\n .score { background: #fff; border: 1px solid #e6e1da; border-radius: 8px; padding: 16px; }\n .score .n { font-family: Besley, Georgia, serif; font-size: 38px; line-height: 1; color: ${RED}; }\n .score .n.na { font-size: 18px; color: #8a857e; }\n .score .l { font-size: 13px; letter-spacing: .08em; text-transform: uppercase; color: #57544f; margin-top: 8px; }\n .score .hint { font-size: 12px; color: #8a857e; margin-top: 4px; }\n .card { background: #fff; border: 1px solid #e6e1da; border-radius: 8px; padding: 16px; margin: 12px 0; }\n .q { font-weight: 600; }\n .tag { display: inline-block; font-size: 12px; letter-spacing: .06em; text-transform: uppercase;\n border-radius: 999px; padding: 2px 10px; border: 1px solid currentColor; }\n .yes { color: #14663c; } .partial { color: #8a6d00; } .no { color: ${RED}; }\n ul { padding-left: 20px; } li { margin: 6px 0; }\n .muted { color: #8a857e; }\n .cta { margin-top: 56px; background: ${RED}; color: #fff; border-radius: 8px; padding: 24px 28px; }\n .cta h2 { border: 0; padding: 0; margin: 0 0 8px; color: #fff; }\n .cta a { color: #fff; }\n table { width: 100%; border-collapse: collapse; margin-top: 8px; }\n td, th { text-align: left; padding: 6px 8px; border-bottom: 1px solid #eee; vertical-align: top; }\n @media print {\n body { background: #fff; }\n .card, .score, .cta { break-inside: avoid; }\n /* Browsers default \"print background graphics\" OFF, which drops the red\n .cta background but keeps its white text — the only ask in the report\n would otherwise print white-on-white and vanish. Fall back to dark\n text on no background with a visible border instead of relying on the\n background for contrast. */\n .cta { background: none; color: #1a1a1a; border: 2px solid ${RED}; }\n .cta h2, .cta a { color: #1a1a1a; }\n }\n`;\n\nfunction scoreCard(label: string, value: number | null, hint?: string): string {\n const n =\n value === null ? `<div class=\"n na\">Not measured</div>` : `<div class=\"n\">${value}</div>`;\n const hintHtml = hint ? `<div class=\"hint\">${escapeHtml(hint)}</div>` : \"\";\n return `<div class=\"score\">${n}<div class=\"l\">${escapeHtml(label)}</div>${hintHtml}</div>`;\n}\n\n/** Body for a stage that succeeded, or a uniform \"Not measured\" note when it didn't. */\nfunction stageBody<T>(stage: StageResult<T>, body: (data: T) => string): string {\n return stage.ok ? body(stage.data) : notMeasuredNote();\n}\n\n/** The stage's real error is deliberately not a parameter here — see\n * STAGE_FAILED_MESSAGE above. Every genuine failure reads the same\n * client-safe way, regardless of what actually broke. */\nfunction notMeasuredNote(): string {\n return `<p class=\"muted\">Not measured — ${STAGE_FAILED_MESSAGE}.</p>`;\n}\n\n/** A stage a human deliberately turned off (--no-probes, or analyze skipped\n * because checks failed upstream) must read as \"you asked us to skip this\" —\n * never as \"we tried and could not\", which is what a bare \"Not measured —\n * {error}\" would otherwise imply. Compares against the pipeline's exported\n * constants rather than a retyped string literal. */\nfunction skippableStageNote(error: string, skipConstant: string, skipMessage: string): string {\n if (error === skipConstant) {\n return `<p class=\"muted\">${escapeHtml(skipMessage)}</p>`;\n }\n return notMeasuredNote();\n}\n\n/** `new Date(iso).toLocaleDateString()` never throws on a bad timestamp — it\n * silently returns \"Invalid Date\" — so a try/catch here is dead code. Guard\n * on getTime() instead, and fall back to the raw ISO string so a corrupt\n * timestamp is at least visibly a timestamp rather than the literal words\n * \"Invalid Date\". */\nfunction formatIsoDate(iso: string): string {\n const d = new Date(iso);\n if (Number.isNaN(d.getTime())) return iso;\n return d.toLocaleDateString(\"en-US\", {\n year: \"numeric\",\n month: \"long\",\n day: \"numeric\",\n timeZone: \"UTC\",\n });\n}\n\n/** The probes section leads the report, so a branded-only run (analyze\n * failed upstream and supplied no buyer questions, or every category query\n * itself failed) must not let the branded recognition line read as a\n * positive discoverability finding on its own — an engine echoing a name\n * it was just handed proves nothing about whether a buyer who never named\n * the business would find it. */\n/** `businessNameUsed` is whether a real business name was ever resolved and\n * handed to any engine (ProspectAuditResult.businessName, the same value\n * the pipeline actually queried with) — never the raw ProbesResult, which\n * has no way to say \"no name was available\". Without this gate, \"even with\n * the name handed to them\" is a false claim whenever businessName was empty. */\nfunction buildProbesSection(p: ProbesResult, businessNameUsed: boolean): string {\n const byKind = new Map<ProbeAnswer[\"kind\"], ProbeAnswer[]>();\n for (const a of p.answers) {\n const bucket = byKind.get(a.kind) ?? [];\n bucket.push(a);\n byKind.set(a.kind, bucket);\n }\n\n const recognition = p.brandedRecognized\n ? \"<p>When the engines were asked about the business by name, they recognized it.</p>\"\n : businessNameUsed\n ? \"<p>When the engines were asked about the business by name, they did not recognize it — a real citation of the site never showed up, even with the name handed to them.</p>\"\n : \"<p>The engines did not recognize the business — there was no business name available to ask them with, so this reflects a harder starting point than a named query would.</p>\";\n\n const categoryCaveat = byKind.has(\"category\")\n ? \"\"\n : `<p class=\"muted\"><strong>No buyer-question (category) query was tested here</strong> — only name-recognition. An engine echoing back a name it was just given says nothing about whether a buyer who had never heard of the business would be shown it; treat the line above as a floor, not a visibility signal.</p>`;\n\n const groups = KIND_ORDER.filter((k) => byKind.has(k))\n .map((kind) => {\n const answers = byKind.get(kind) ?? [];\n const cards = answers\n .map(\n (a) => `<div class=\"card\">\n <div class=\"q\">${escapeHtml(a.engine)} · “${escapeHtml(a.query)}”</div>\n <p class=\"muted\">Asked ${escapeHtml(formatIsoDate(a.askedAt))}</p>\n <p>${escapeHtml(a.snippet)}${a.truncated ? \"…\" : \"\"}</p>\n <p class=\"muted\">${\n a.domainCited || a.brandMentioned\n ? \"You were named in this answer.\"\n : \"You were not named in this answer.\"\n }${a.citedDomains.length ? ` Cited: ${escapeHtml(a.citedDomains.join(\", \"))}` : \"\"}</p>\n </div>`,\n )\n .join(\"\");\n return `<h3>${escapeHtml(KIND_LABEL[kind])}</h3>${cards}`;\n })\n .join(\"\");\n\n const competitors = p.competitorsSeen.length\n ? `<h3>Who the engines cited instead</h3><ul>${p.competitorsSeen\n .map((c) => `<li>${escapeHtml(c.domain)} — ${c.count} time${c.count === 1 ? \"\" : \"s\"}</li>`)\n .join(\"\")}</ul>`\n : \"\";\n\n return recognition + categoryCaveat + groups + competitors;\n}\n\n/** crawlerAccessMeasured is false only when the robots.txt fetch itself\n * failed, so the blocked/allowed lists are empty out of ignorance, not\n * because we confirmed access. Saying \"every crawler can reach the site\" in\n * that case would manufacture a finding from our own missing data.\n * sitemapMeasured/llmsTxtMeasured get the same treatment as\n * crawlerAccessMeasured: a failed fetch reads as \"not measured\", never as a\n * confirmed \"missing\" — that's a claim about the prospect's site we haven't\n * earned. Item 1: none of the three \"not measured\" lines below take the raw\n * fetch error as input anymore — status codes and transport vocabulary\n * (\"503 Service Unavailable\", \"ETIMEDOUT\") are operator vocabulary, same as\n * a stage failure (see STAGE_FAILED_MESSAGE); the real error still lives in\n * the persisted JSON and CLI output, never in this client-facing page. */\nfunction buildFindabilitySection(c: ChecksResult): string {\n const accessBlock = !c.crawlerAccessMeasured\n ? `<p class=\"muted\">Crawler access: not measured — ${STAGE_FAILED_MESSAGE}.</p>`\n : c.crawlerAccess.blockedAi.length\n ? `<p><strong>Blocked AI crawlers:</strong> ${escapeHtml(\n c.crawlerAccess.blockedAi.map(describeAgent).join(\", \"),\n )}</p>`\n : `<p>Every AI crawler we checked can reach the site.</p>`;\n const classical =\n c.crawlerAccessMeasured && c.crawlerAccess.blockedClassical.length\n ? `<p><strong>Blocked search crawlers:</strong> ${escapeHtml(\n c.crawlerAccess.blockedClassical.join(\", \"),\n )}</p>`\n : \"\";\n const sitemapLine = c.sitemapMeasured\n ? `<li>sitemap.xml: ${c.sitemapPresent ? \"present\" : \"missing\"}</li>`\n : `<li>sitemap.xml: not measured — ${STAGE_FAILED_MESSAGE}.</li>`;\n const llmsLine = c.llmsTxtMeasured\n ? `<li>llms.txt: ${c.llmsTxtPresent ? \"present\" : \"missing\"}</li>`\n : `<li>llms.txt: not measured — ${STAGE_FAILED_MESSAGE}.</li>`;\n return `${accessBlock}${classical}\n <ul>\n ${sitemapLine}\n ${llmsLine}\n <li>Pages missing a meta description: ${c.meta.missingDescription} of ${c.meta.pageCount}</li>\n <li>Pages missing a canonical URL: ${c.meta.missingCanonical} of ${c.meta.pageCount}</li>\n <li>Pages missing share images/titles: ${c.meta.missingSocial} of ${c.meta.pageCount}</li>\n <li>Security headers missing: ${\n c.securityHeaders.missing.length ? escapeHtml(c.securityHeaders.missing.join(\", \")) : \"none\"\n }</li>\n ${\n c.meta.pagesWithoutExtract > 0\n ? `<li><strong>${c.meta.pagesWithoutExtract}</strong> additional page${\n c.meta.pagesWithoutExtract === 1 ? \"\" : \"s\"\n } produced no readable content at all and ${\n c.meta.pagesWithoutExtract === 1 ? \"is\" : \"are\"\n } not counted in the ${c.meta.pageCount} pages above.</li>`\n : \"\"\n }\n </ul>`;\n}\n\n/** Lighthouse runs as its own independent pipeline stage (see pipeline.ts) —\n * it does not depend on `checks` succeeding. Rendering it off `result.checks`\n * would throw away real, measured performance/SEO/accessibility numbers\n * whenever an unrelated stage failed, so this reads `result.lighthouse`\n * directly and is rendered unconditionally, not nested inside the checks\n * degrade path. */\nfunction buildLighthouseBlock(lh: StageResult<LighthouseScores>): string {\n return lh.ok\n ? `<p class=\"muted\">Lighthouse — performance ${lh.data.performance ?? \"not measured\"},\n SEO ${lh.data.seo ?? \"not measured\"}, accessibility ${lh.data.accessibility ?? \"not measured\"}.</p>`\n : `<p class=\"muted\">Lighthouse not measured — ${STAGE_FAILED_MESSAGE}.</p>`;\n}\n\nfunction buildReadabilitySection(c: ChecksResult): string {\n const jsLine =\n c.jsDependence.avgMissing === null\n ? `<p class=\"muted\">JavaScript-dependence: not measured — no page produced a comparable raw/rendered pair.</p>`\n : `<p><strong>${Math.round(c.jsDependence.avgMissing * 100)}%</strong> of the words a visitor reads only appear after JavaScript runs.\n Most AI crawlers do not run JavaScript, so that share of your site is invisible to them.</p>`;\n return `${jsLine}\n <ul>\n <li>Structured data found: ${c.schema.typesFound.length ? escapeHtml(c.schema.typesFound.join(\", \")) : \"none\"}</li>\n <li>Expected structured data missing: ${\n c.schema.missingExpected.length ? escapeHtml(c.schema.missingExpected.join(\", \")) : \"none\"\n }</li>\n <li>Pages without a top-level heading: ${c.headings.pagesWithoutH1} of ${c.meta.pageCount}</li>\n ${\n c.meta.pagesWithoutExtract > 0\n ? `<li><strong>${c.meta.pagesWithoutExtract}</strong> page${\n c.meta.pagesWithoutExtract === 1 ? \"\" : \"s\"\n } produced no extract at all — excluded from every figure above, not counted as readable or unreadable.</li>`\n : \"\"\n }\n </ul>`;\n}\n\n/** The report's whole premise is claims the recipient can go check — so the\n * one quoted passage of evidence links to the page it came from (via\n * safeUrl; a null/non-http page renders as plain escaped text, no dead\n * \"#\" anchor). `answered`/`impact`/`effort` are escaped even though a zod\n * enum constrains them upstream — the renderer should not depend on\n * another file staying in sync to stay safe. */\nfunction buildAnswersSection(a: AnalyzeResult): string {\n const rows = a.buyerQuestions\n .map((q) => {\n const answered = escapeHtml(q.answered);\n const evidenceText = q.evidence ? escapeHtml(q.evidence) : null;\n const evidenceHref = q.evidence && q.page ? safeUrl(q.page) : null;\n const evidenceCell =\n evidenceText === null\n ? '<span class=\"muted\">no passage on the site</span>'\n : evidenceHref && evidenceHref !== \"#\"\n ? `<a href=\"${escapeHtml(evidenceHref)}\" target=\"_blank\" rel=\"noopener noreferrer\">${evidenceText}</a>`\n : evidenceText;\n return `<tr>\n <td>${escapeHtml(q.question)}</td>\n <td><span class=\"tag ${answered}\">${answered}</span></td>\n <td>${evidenceCell}</td>\n </tr>`;\n })\n .join(\"\");\n return `<p>${escapeHtml(a.narrative.answers)}</p>\n <table><tr><th>What buyers ask</th><th>Answered</th><th>Evidence</th></tr>${rows}</table>`;\n}\n\nexport function renderProspectReport(result: ProspectAuditResult): string {\n const host = hostnameOf(result.url);\n // `businessName` is the searchable NAME (possibly \"\"), never the\n // description. An empty/missing name is itself a finding — fall back to\n // the hostname rather than printing nothing, or worse, printing \"\" as if\n // it were a verified fact about the business.\n const name = result.businessName && result.businessName.trim() ? result.businessName : host;\n const date = formatIsoDate(result.generatedAt);\n\n const findabilitySection = stageBody(result.checks, buildFindabilitySection);\n const lighthouseBlock = buildLighthouseBlock(result.lighthouse);\n const readabilitySection = stageBody(result.checks, buildReadabilitySection);\n\n // A real business name was resolved and handed to the engines only when\n // `result.businessName` is non-empty — the same value the pipeline\n // actually queried with (see pipeline.ts). Used to gate the probes\n // section's \"even with the name handed to them\" phrasing (Fix 6).\n const businessNameUsed = Boolean(result.businessName && result.businessName.trim());\n\n const fixes = result.analyze.ok\n ? (() => {\n const sorted = [...result.analyze.data.fixes].sort(\n (x, y) => IMPACT_ORDER[x.impact] - IMPACT_ORDER[y.impact],\n );\n if (sorted.length === 0) {\n // A genuinely well-optimized site: a bare empty list reads as a\n // rendering bug, not a compliment. Say so directly, and keep this\n // section as a pitch — it's the one the salesperson is sending\n // the report to make.\n return `<p>Nothing here reads as urgent — the essentials are already in place.\n Reddoor can still help push this further: tightening structured data, monitoring\n how the answer engines describe you, and keeping pace as they change.</p>`;\n }\n return `<ol>${sorted\n .map((f) => {\n const impact = escapeHtml(f.impact);\n const effort = escapeHtml(f.effort);\n return `<li><strong>${escapeHtml(f.title)}</strong> — ${escapeHtml(f.why)}\n <span class=\"muted\">(${impact} impact, ${effort} effort)</span></li>`;\n })\n .join(\"\")}</ol>`;\n })()\n : skippableStageNote(\n result.analyze.error,\n ANALYZE_SKIPPED,\n \"Skipped — the checks stage failed, so there was nothing to build fixes from.\",\n );\n\n const probesSectionFinal = result.probes.ok\n ? buildProbesSection(result.probes.data, businessNameUsed)\n : skippableStageNote(\n result.probes.error,\n PROBES_SKIPPED,\n \"This audit did not run the AI-visibility probes.\",\n );\n\n const answersSectionFinal = result.analyze.ok\n ? buildAnswersSection(result.analyze.data)\n : skippableStageNote(\n result.analyze.error,\n ANALYZE_SKIPPED,\n \"Skipped — the checks stage failed, so there were no buyer questions to check.\",\n );\n\n const description = result.analyze.ok ? result.analyze.data.business : null;\n const narrative = result.analyze.ok\n ? `<p class=\"lede\">${escapeHtml(description ?? \"\")}</p>\n <p class=\"lede\">${escapeHtml(result.analyze.data.narrative.findability)}\n ${escapeHtml(result.analyze.data.narrative.readability)}</p>`\n : \"\";\n\n return `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\" />\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n<meta name=\"robots\" content=\"noindex\" />\n<title>Can AI find ${escapeHtml(name)}? — Reddoor audit</title>\n<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n<link href=\"https://fonts.googleapis.com/css2?family=Besley:wght@400;600&display=swap\" rel=\"stylesheet\" />\n<style>${STYLES}</style>\n</head>\n<body>\n<div class=\"wrap\">\n <h1>Can AI and Google actually find ${escapeHtml(name)}?</h1>\n <p class=\"lede\"><a href=\"${escapeHtml(safeUrl(result.url))}\" target=\"_blank\" rel=\"noopener noreferrer\">${escapeHtml(result.url)}</a> · audited ${escapeHtml(date)} by Reddoor Creative</p>\n ${narrative}\n\n <p class=\"muted\">Each score below runs 0–100 — read it as a score, not a percentage.</p>\n <div class=\"scores\">\n ${scoreCard(\"Findability\", result.scores.findability, \"How easily AI and search crawlers can find and reach your site — crawl rules, sitemap, key metadata\")}\n ${scoreCard(\"Readability\", result.scores.readability, \"How much of your site's content those crawlers can actually read once they're in — most don't run JavaScript\")}\n ${scoreCard(\"Answers\", result.scores.answers, \"How many buyer questions your site's own content answers — separate from what the AI engines say back\")}\n ${scoreCard(\"AI Visibility\", result.scores.aiVisibility, \"Based on buyer questions — not questions that name the business directly\")}\n </div>\n\n <h2>What the AI engines said about you</h2>\n ${probesSectionFinal}\n\n <h2>What the crawlers can reach</h2>\n ${findabilitySection}\n ${lighthouseBlock}\n\n <h2>What the crawlers can read</h2>\n ${readabilitySection}\n\n <h2>The questions your buyers ask</h2>\n ${answersSectionFinal}\n\n <h2>What to fix first</h2>\n ${fixes}\n\n <div class=\"cta\">\n <h2>Want this fixed?</h2>\n <p>Reddoor Creative rebuilds sites so answer engines can read, quote and recommend them.\n Reply to the email this link came from, or start at\n <a href=\"https://reddoorla.com/\">reddoorla.com</a>.</p>\n </div>\n</div>\n</body>\n</html>`;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -5,7 +5,7 @@ function escapeHtml(s) {
|
|
|
5
5
|
function safeUrl(raw) {
|
|
6
6
|
try {
|
|
7
7
|
const u = new URL(raw);
|
|
8
|
-
if (u.protocol === "http:" || u.protocol === "https:") return
|
|
8
|
+
if (u.protocol === "http:" || u.protocol === "https:") return u.href;
|
|
9
9
|
} catch {
|
|
10
10
|
}
|
|
11
11
|
return "#";
|
|
@@ -15,4 +15,4 @@ export {
|
|
|
15
15
|
escapeHtml,
|
|
16
16
|
safeUrl
|
|
17
17
|
};
|
|
18
|
-
//# sourceMappingURL=chunk-
|
|
18
|
+
//# sourceMappingURL=chunk-DJZWYIPP.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/util/html.ts"],"sourcesContent":["/**\n * Shared HTML/XML escape. One implementation behind the dashboard renderers\n * (`src/dashboard/render.ts`, `fleet-render.ts`), the daily digest\n * (`src/reports/digest.ts`), and the MJML email templates\n * (`src/reports/*-email/template.ts`).\n *\n * The set is the strict-XML set (`& < > \" '`), which is exactly what MJML's\n * `validationLevel: \"strict\"` parser needs and a superset of what plain HTML text\n * interpolation needs — so the SAME function serves both sinks. Site names\n * (e.g. \"Brown & Co\"), URLs, and operator commentary must not break the markup or\n * inject. The MJML templates re-export this as `escapeXml` for their callers.\n */\nexport function escapeHtml(s: string): string {\n return s\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n}\n\n/** Allow only http(s) URLs in an href context; everything else collapses to \"#\".\n *\n * Returns the PARSED, percent-encoded `u.href` — never the caller's raw string.\n * `new URL()` only validates the scheme; it does not sanitize the rest of the\n * string, so a well-formed https URL carrying a quote-breakout payload\n * (`https://x.example/\"><script>...`) parses fine and, if handed back\n * verbatim, produces a live injection wherever a caller interpolates the\n * result into an href without a further escapeHtml pass. Returning `u.href`\n * closes that gap: the URL parser itself percent-encodes `\"`, `<`, `>`, etc.\n * in the path/query. This also normalizes a bare-origin URL to carry a\n * trailing slash (`https://x.example` → `https://x.example/`) — a caller\n * that additionally wraps the result in escapeHtml (the majority pattern in\n * this repo) sees no behavior change beyond that encoding. */\nexport function safeUrl(raw: string): string {\n try {\n const u = new URL(raw);\n if (u.protocol === \"http:\" || u.protocol === \"https:\") return u.href;\n } catch {\n // fall through\n }\n return \"#\";\n}\n"],"mappings":";AAYO,SAAS,WAAW,GAAmB;AAC5C,SAAO,EACJ,QAAQ,MAAM,OAAO,EACrB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,MAAM,EACpB,QAAQ,MAAM,QAAQ,EACtB,QAAQ,MAAM,OAAO;AAC1B;AAeO,SAAS,QAAQ,KAAqB;AAC3C,MAAI;AACF,UAAM,IAAI,IAAI,IAAI,GAAG;AACrB,QAAI,EAAE,aAAa,WAAW,EAAE,aAAa,SAAU,QAAO,EAAE;AAAA,EAClE,QAAQ;AAAA,EAER;AACA,SAAO;AACT;","names":[]}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/util/url.ts
|
|
2
|
+
function isHttpUrl(s) {
|
|
3
|
+
let parsed;
|
|
4
|
+
try {
|
|
5
|
+
parsed = new URL(s);
|
|
6
|
+
} catch {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
10
|
+
}
|
|
11
|
+
function hostnameOf(s) {
|
|
12
|
+
try {
|
|
13
|
+
return new URL(s).hostname;
|
|
14
|
+
} catch {
|
|
15
|
+
return s;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function isNetlifyAppUrl(s) {
|
|
19
|
+
let parsed;
|
|
20
|
+
try {
|
|
21
|
+
parsed = new URL(s);
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const host = parsed.hostname.toLowerCase();
|
|
26
|
+
return host === "netlify.app" || host.endsWith(".netlify.app");
|
|
27
|
+
}
|
|
28
|
+
function isPrivateOrLoopbackHost(hostname) {
|
|
29
|
+
let host = hostname.toLowerCase();
|
|
30
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
31
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
32
|
+
if (host.includes(":")) {
|
|
33
|
+
if (host === "::1" || host === "::") return true;
|
|
34
|
+
if (/^fe[89ab]/.test(host)) return true;
|
|
35
|
+
if (/^f[cd]/.test(host)) return true;
|
|
36
|
+
if (host.startsWith("::ffff:") || host.startsWith("64:ff9b:")) return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) {
|
|
40
|
+
const [a, b] = host.split(".").map(Number);
|
|
41
|
+
if (a === 0 || a === 10 || a === 127) return true;
|
|
42
|
+
if (a === 169 && b === 254) return true;
|
|
43
|
+
if (a === 172 && b !== void 0 && b >= 16 && b <= 31) return true;
|
|
44
|
+
if (a === 192 && b === 168) return true;
|
|
45
|
+
if (a === 100 && b !== void 0 && b >= 64 && b <= 127) return true;
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
function isPublicHttpsUrl(s) {
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = new URL(s);
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
if (parsed.protocol !== "https:") return false;
|
|
58
|
+
return !isPrivateOrLoopbackHost(parsed.hostname);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export {
|
|
62
|
+
isHttpUrl,
|
|
63
|
+
hostnameOf,
|
|
64
|
+
isNetlifyAppUrl,
|
|
65
|
+
isPrivateOrLoopbackHost,
|
|
66
|
+
isPublicHttpsUrl
|
|
67
|
+
};
|
|
68
|
+
//# sourceMappingURL=chunk-EVBCK2KC.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/util/url.ts"],"sourcesContent":["/**\n * True when `s` parses as an absolute URL whose scheme is `http:` or `https:`.\n *\n * The single allowlist gate for any value we hand to Chrome/Lighthouse. A\n * deployed-audit URL flows in from Airtable's `url` column (or a JSON\n * inventory's `deployedUrl`), so a `file://`/`gopher://`/`data:` value — or a\n * value pointing at an internal host — would otherwise become a local-file read\n * or SSRF when lhci drives a headless browser at it. Restricting to http(s)\n * keeps the audit to the real, network-reachable site.\n */\nexport function isHttpUrl(s: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(s);\n } catch {\n return false;\n }\n return parsed.protocol === \"http:\" || parsed.protocol === \"https:\";\n}\n\n/**\n * The hostname of `s`, or `s` itself when it doesn't parse as a URL — a\n * human-facing fallback label, never a thrown error. Used anywhere a short\n * site label is wanted and the full URL would be noisy: the prospect-audit\n * CLI's summary line and its report's <h1>/title both fall back to this when\n * no business name was resolved.\n */\nexport function hostnameOf(s: string): string {\n try {\n return new URL(s).hostname;\n } catch {\n return s;\n }\n}\n\n/**\n * True when `s` is a URL served from Netlify's default `*.netlify.app` host —\n * i.e. the site has no custom domain. Matches the apex `netlify.app` and any\n * subdomain of it (including deploy-preview hosts like `branch--site.netlify.app`),\n * but is not fooled by a look-alike such as `foo.netlify.app.evil.com` (the host\n * must END at `.netlify.app`). An unparseable/empty value is not a match.\n */\nexport function isNetlifyAppUrl(s: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(s);\n } catch {\n return false;\n }\n const host = parsed.hostname.toLowerCase();\n return host === \"netlify.app\" || host.endsWith(\".netlify.app\");\n}\n\n/**\n * True when `hostname` is a loopback, private, link-local, unique-local, or\n * CGNAT literal address (or the `localhost` name) — the address-literal core\n * that used to live only inside `isPublicHttpsUrl`, pulled out so a caller\n * that legitimately handles non-https schemes (the prospect-audit crawler\n * audits plain http sites too) can still refuse to follow a redirect onto an\n * internal target. Scheme-agnostic on purpose: the caller decides which\n * protocols are acceptable, this only judges the host.\n *\n * Best-effort by literal address — it does NOT resolve DNS, so a hostname\n * that resolves to a private IP is not caught. That is a deliberate,\n * proportionate bound for an operator-run CLI, not a complete SSRF guard.\n */\nexport function isPrivateOrLoopbackHost(hostname: string): boolean {\n let host = hostname.toLowerCase();\n if (host === \"localhost\" || host.endsWith(\".localhost\")) return true;\n // Strip IPv6 brackets (`[::1]` → `::1`).\n if (host.startsWith(\"[\") && host.endsWith(\"]\")) host = host.slice(1, -1);\n if (host.includes(\":\")) {\n // IPv6 literal: block loopback (::1), unspecified (::), link-local (fe80::/10),\n // and unique-local (fc00::/7).\n if (host === \"::1\" || host === \"::\") return true;\n if (/^fe[89ab]/.test(host)) return true;\n if (/^f[cd]/.test(host)) return true;\n // IPv4-mapped (`::ffff:a.b.c.d`, normalized by URL to `::ffff:aabb:ccdd`) and\n // NAT64 (`64:ff9b::/96`) embed a v4 address the dotted-quad block below never\n // sees — refuse both wholesale (no legitimate target uses these forms).\n if (host.startsWith(\"::ffff:\") || host.startsWith(\"64:ff9b:\")) return true;\n return false;\n }\n // IPv4 dotted-quad: block the private/loopback/link-local/CGNAT ranges.\n if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(host)) {\n const [a, b] = host.split(\".\").map(Number);\n if (a === 0 || a === 10 || a === 127) return true;\n if (a === 169 && b === 254) return true; // link-local\n if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true; // 172.16/12\n if (a === 192 && b === 168) return true; // 192.168/16\n if (a === 100 && b !== undefined && b >= 64 && b <= 127) return true; // CGNAT 100.64/10\n return false;\n }\n // A registered hostname (not a literal IP) — accept; DNS isn't resolved here.\n return false;\n}\n\n/**\n * True when `s` is an `https:` URL whose host is NOT an obviously-internal target\n * (loopback / private / link-local / unique-local / CGNAT). The newsletter\n * webhook URL is operator-set in Airtable but fires server-side, so this blocks\n * the SSRF vector of pointing it at `127.0.0.1` / `10.x` / `169.254.x` / `::1`.\n *\n * Best-effort by host literal — it does NOT resolve DNS, so a hostname that\n * resolves to a private IP is not caught. Defense-in-depth, not a complete SSRF\n * guard (the response is never returned to the caller either, so it's a log\n * oracle at most).\n */\nexport function isPublicHttpsUrl(s: string): boolean {\n let parsed: URL;\n try {\n parsed = new URL(s);\n } catch {\n return false;\n }\n if (parsed.protocol !== \"https:\") return false;\n return !isPrivateOrLoopbackHost(parsed.hostname);\n}\n"],"mappings":";AAUO,SAAS,UAAU,GAAoB;AAC5C,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,CAAC;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,SAAO,OAAO,aAAa,WAAW,OAAO,aAAa;AAC5D;AASO,SAAS,WAAW,GAAmB;AAC5C,MAAI;AACF,WAAO,IAAI,IAAI,CAAC,EAAE;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AASO,SAAS,gBAAgB,GAAoB;AAClD,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,CAAC;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,QAAM,OAAO,OAAO,SAAS,YAAY;AACzC,SAAO,SAAS,iBAAiB,KAAK,SAAS,cAAc;AAC/D;AAeO,SAAS,wBAAwB,UAA2B;AACjE,MAAI,OAAO,SAAS,YAAY;AAChC,MAAI,SAAS,eAAe,KAAK,SAAS,YAAY,EAAG,QAAO;AAEhE,MAAI,KAAK,WAAW,GAAG,KAAK,KAAK,SAAS,GAAG,EAAG,QAAO,KAAK,MAAM,GAAG,EAAE;AACvE,MAAI,KAAK,SAAS,GAAG,GAAG;AAGtB,QAAI,SAAS,SAAS,SAAS,KAAM,QAAO;AAC5C,QAAI,YAAY,KAAK,IAAI,EAAG,QAAO;AACnC,QAAI,SAAS,KAAK,IAAI,EAAG,QAAO;AAIhC,QAAI,KAAK,WAAW,SAAS,KAAK,KAAK,WAAW,UAAU,EAAG,QAAO;AACtE,WAAO;AAAA,EACT;AAEA,MAAI,0BAA0B,KAAK,IAAI,GAAG;AACxC,UAAM,CAAC,GAAG,CAAC,IAAI,KAAK,MAAM,GAAG,EAAE,IAAI,MAAM;AACzC,QAAI,MAAM,KAAK,MAAM,MAAM,MAAM,IAAK,QAAO;AAC7C,QAAI,MAAM,OAAO,MAAM,IAAK,QAAO;AACnC,QAAI,MAAM,OAAO,MAAM,UAAa,KAAK,MAAM,KAAK,GAAI,QAAO;AAC/D,QAAI,MAAM,OAAO,MAAM,IAAK,QAAO;AACnC,QAAI,MAAM,OAAO,MAAM,UAAa,KAAK,MAAM,KAAK,IAAK,QAAO;AAChE,WAAO;AAAA,EACT;AAEA,SAAO;AACT;AAaO,SAAS,iBAAiB,GAAoB;AACnD,MAAI;AACJ,MAAI;AACF,aAAS,IAAI,IAAI,CAAC;AAAA,EACpB,QAAQ;AACN,WAAO;AAAA,EACT;AACA,MAAI,OAAO,aAAa,SAAU,QAAO;AACzC,SAAO,CAAC,wBAAwB,OAAO,QAAQ;AACjD;","names":[]}
|