From d4e9814a3f54fa7f54f155e950336cf1162ac9a6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 11:44:36 -0400 Subject: [PATCH 01/10] Export PDFs with the Chrome command line, without puppeteer The export script drove Chrome through puppeteer's DevTools connection, which fails where an administrator disables remote debugging. It now runs headless Chrome only through --dump-dom, --screenshot and --print-to-pdf. - ?export=1 hides the viewer's navigation bar, and
carries data-total-steps so a script can read the step count. - The script captures ?slide=N&export=1 for every step and prints the screenshots to a 16:9 PDF. - --url captures a deployed site; without it the script builds into .next-export with NEXT_PUBLIC_EXPORT_BUILD=1, which skips the password gate so private decks still export. - puppeteer leaves devDependencies. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + CLAUDE.md | 11 +- bun.lock | 153 ---------- components/core/PasswordGate.tsx | 4 +- components/core/SlideshowViewer.tsx | 11 +- eslint.config.mjs | 1 + lib/slide-url.test.ts | 24 +- lib/slide-url.ts | 8 + next.config.ts | 3 + package.json | 1 - scripts/export-pdf-lib.mjs | 113 ++++++++ scripts/export-pdf-lib.test.mjs | 112 ++++++++ scripts/export-pdf.mjs | 419 ++++++++++++---------------- 13 files changed, 468 insertions(+), 393 deletions(-) create mode 100644 scripts/export-pdf-lib.mjs create mode 100644 scripts/export-pdf-lib.test.mjs diff --git a/.gitignore b/.gitignore index f206ebc3..fb6caaf9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .next/ +.next-export/ out/ .DS_Store .vercel diff --git a/CLAUDE.md b/CLAUDE.md index b8d153fe..0ec587c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,9 +43,18 @@ lib/ bun dev # Dev server at localhost:3000 bun run build # Production build bun run new-slideshow # Scaffold new slideshow -bun run export <id> [output.pdf] # Export slideshow to PDF +bun run export <id> [output.pdf] [--url <origin>] # Export slideshow to PDF (headless Chrome CLI) ``` +## Exporting to PDF + +`bun run export` drives headless Chrome through its command line only (`--dump-dom`, `--screenshot`, `--print-to-pdf`), never through a DevTools connection, so it works where remote debugging is disabled. + +- `?slide=N&export=1` renders step N without the navigation bar; the viewer's `<main>` carries `data-total-steps`. +- Without `--url`, the script builds into `.next-export` with `NEXT_PUBLIC_EXPORT_BUILD=1`, which skips the password gate so private decks export; deployed builds never set it. +- With `--url https://www.policyengine.org`, it captures the deployed site (public decks only). +- `EXPORT_WAIT_MS` (default 10000) sets how much virtual time each slide gets to render; raise it for slides with slow live embeds. `CHROME_PATH` overrides the Chrome binary. + ## Adding a new slideshow 1. `bun run new-slideshow my-talk "My talk title"` diff --git a/bun.lock b/bun.lock index 51bfdb81..69cb4116 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,6 @@ "globals": "^16.5.0", "jsdom": "^28.1.0", "postcss": "^8.5.6", - "puppeteer": "^24.37.5", "tailwindcss": "^4.2.4", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", @@ -279,8 +278,6 @@ "@policyengine/ui-kit": ["@policyengine/ui-kit@0.9.0", "", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "d3-geo": "^3.1.0", "dayjs": "^1.11.13", "lucide-react": "^0.577.0", "radix-ui": "^1.4.3", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0" }, "peerDependencies": { "react": ">=19", "react-dom": ">=19", "recharts": ">=2.12" } }, "sha512-oPqMSr+qSdY/63xZ8b7dO3q5Bhceg5UQCiZ/m4tOqcywviQk5VO8FKzkUR8pzmFIv8Q4TB3b2OB0ZKfo6UzHQw=="], - "@puppeteer/browsers": ["@puppeteer/browsers@2.13.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA=="], - "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -495,8 +492,6 @@ "@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="], - "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -537,8 +532,6 @@ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], - "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="], @@ -619,8 +612,6 @@ "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], - "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], @@ -647,8 +638,6 @@ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], - "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], - "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], @@ -659,26 +648,10 @@ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], - "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], - - "bare-fs": ["bare-fs@4.5.5", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w=="], - - "bare-os": ["bare-os@3.7.0", "", {}, "sha512-64Rcwj8qlnTZU8Ps6JJEdSmxBEUGgI7g8l+lMtsJLl4IsfTcHMTfJ188u2iGV6P6YPRZrtv72B2kjn+hp+Yv3g=="], - - "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], - - "bare-stream": ["bare-stream@2.8.0", "", { "dependencies": { "streamx": "^2.21.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer", "bare-events"] }, "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA=="], - - "bare-url": ["bare-url@2.3.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": "dist/cli.js" }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], - "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], - "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], @@ -687,8 +660,6 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": "cli.js" }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], - "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -703,14 +674,10 @@ "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], - "chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="], - "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], - "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], - "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], @@ -725,8 +692,6 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], @@ -761,8 +726,6 @@ "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], - "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], - "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], @@ -785,14 +748,10 @@ "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], - "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "devtools-protocol": ["devtools-protocol@0.0.1566079", "", {}, "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ=="], - "doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -801,16 +760,10 @@ "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "enhanced-resolve": ["enhanced-resolve@5.21.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], - "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - - "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], - "es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -837,8 +790,6 @@ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], - "eslint": ["eslint@9.39.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.5", "@eslint/js": "9.39.4", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ=="], "eslint-config-next": ["eslint-config-next@16.2.6", "", { "dependencies": { "@next/eslint-plugin-next": "16.2.6", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.0.0", "globals": "16.4.0", "typescript-eslint": "^8.46.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-z2ELYSkyrrJ6cuunTU8vhsT/RpouPkjaSah06nVW6Rg2Hpg0Vs8s497/e5s8G8qtdp4ccsiovz5P1rv+5VSW2Q=="], @@ -863,8 +814,6 @@ "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], - "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], - "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], @@ -877,16 +826,10 @@ "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], - "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], - "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], - "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], - "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], - "fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -895,8 +838,6 @@ "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], - "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], @@ -923,22 +864,16 @@ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], - "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], - "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], - "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], - "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], @@ -985,12 +920,8 @@ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], - "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], - "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], - "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], - "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], @@ -1011,8 +942,6 @@ "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -1063,8 +992,6 @@ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], - "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], - "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], @@ -1107,8 +1034,6 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], - "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], - "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], @@ -1133,8 +1058,6 @@ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], - "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], @@ -1143,8 +1066,6 @@ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], - "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], - "next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="], "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], @@ -1169,8 +1090,6 @@ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], - "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -1179,14 +1098,8 @@ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], - "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], - - "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], - "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], - "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], - "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], @@ -1197,8 +1110,6 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], - "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -1209,22 +1120,10 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], - "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], - "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], - - "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], - - "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "puppeteer": ["puppeteer@24.37.5", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1566079", "puppeteer-core": "24.37.5", "typed-query-selector": "^2.12.0" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-3PAOIQLceyEmn1Fi76GkGO2EVxztv5OtdlB1m8hMUZL3f8KDHnlvXbvCXv+Ls7KzF1R0KdKBqLuT/Hhrok12hQ=="], - - "puppeteer-core": ["puppeteer-core@24.37.5", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1566079", "typed-query-selector": "^2.12.0", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-ybL7iE78YPN4T6J+sPLO7r0lSByp/0NN6PvfBEql219cOnttoTFzCWKiBOjstXSqi/OKpwae623DWAsL7cn2MQ=="], - "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], @@ -1253,8 +1152,6 @@ "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], - "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], @@ -1305,14 +1202,6 @@ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], - "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], - - "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], - - "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - - "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], @@ -1323,10 +1212,6 @@ "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], - "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], - - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], "string.prototype.matchall": ["string.prototype.matchall@4.0.12", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-abstract": "^1.23.6", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.3", "set-function-name": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA=="], @@ -1339,8 +1224,6 @@ "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], - "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], @@ -1359,14 +1242,6 @@ "tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="], - "tar-fs": ["tar-fs@3.1.1", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg=="], - - "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], - - "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], - - "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], - "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], @@ -1405,8 +1280,6 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], - "typed-query-selector": ["typed-query-selector@2.12.0", "", {}, "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg=="], - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript-eslint": ["typescript-eslint@8.59.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="], @@ -1437,8 +1310,6 @@ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], - "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], - "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], @@ -1459,26 +1330,12 @@ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], - "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - - "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - - "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], - "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], - "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], - - "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - - "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1491,8 +1348,6 @@ "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], - "@puppeteer/browsers/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@reduxjs/toolkit/immer": ["immer@11.1.8", "", {}, "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -1509,8 +1364,6 @@ "@tailwindcss/postcss/postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], - "@types/yauzl/@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], - "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], @@ -1535,16 +1388,10 @@ "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], - "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], - "@types/yauzl/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/components/core/PasswordGate.tsx b/components/core/PasswordGate.tsx index a8fbb07f..176fbc5e 100644 --- a/components/core/PasswordGate.tsx +++ b/components/core/PasswordGate.tsx @@ -13,7 +13,9 @@ export default function PasswordGate({ children }: PasswordGateProps) { const correctPassword = process.env.NEXT_PUBLIC_PRIVATE_PASSWORD || ''; - if (unlocked) { + // `bun run export` builds with NEXT_PUBLIC_EXPORT_BUILD=1 so a headless + // browser can capture private decks. Deployed builds never set it. + if (unlocked || process.env.NEXT_PUBLIC_EXPORT_BUILD === '1') { return <>{children}</>; } diff --git a/components/core/SlideshowViewer.tsx b/components/core/SlideshowViewer.tsx index f7037d41..7d75331f 100644 --- a/components/core/SlideshowViewer.tsx +++ b/components/core/SlideshowViewer.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo, Suspense } from 'react'; import { useSearchParams } from 'next/navigation'; import { SlideshowProvider, SlideshowContextValue } from './SlideshowContext'; import { SlideshowConfig, flattenSlides, getSlideComponent } from '@/lib/types'; -import { buildSlideUrl } from '@/lib/slide-url'; +import { buildSlideUrl, isExportMode } from '@/lib/slide-url'; interface SlideshowViewerProps { config: SlideshowConfig; @@ -18,6 +18,7 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) { const steps = useMemo(() => flattenSlides(config.slides), [config.slides]); const totalSteps = steps.length; + const exportMode = isExportMode(searchParams); useEffect(() => { setMounted(true); @@ -89,7 +90,11 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) { return ( <SlideshowProvider value={contextValue}> - <main className="relative cursor-pointer" onClick={handleMainClick}> + <main + className="relative cursor-pointer" + onClick={handleMainClick} + data-total-steps={totalSteps} + > {mounted && config.slides.map((slide, slideIndex) => { const SlideComponent = getSlideComponent(slide); const isNeighbor = Math.abs(slideIndex - (currentStepData?.slideIndex ?? 0)) <= 1; @@ -110,7 +115,7 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) { ); })} - {!isFullscreen && ( + {!isFullscreen && !exportMode && ( <div className="fixed bottom-0 left-0 right-0 h-20 z-[100] flex items-center justify-between px-8 pointer-events-none"> <div className="flex items-center gap-3 pointer-events-auto"> <span className="text-white/60 text-xs"> diff --git a/eslint.config.mjs b/eslint.config.mjs index 1cd2c927..e03b807f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,6 +29,7 @@ export default defineConfig([ }, globalIgnores([ '.next/**', + '.next-export/**', 'out/**', 'build/**', 'coverage/**', diff --git a/lib/slide-url.test.ts b/lib/slide-url.test.ts index 1b7be01b..0fc92c85 100644 --- a/lib/slide-url.test.ts +++ b/lib/slide-url.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildSlideUrl } from "./slide-url"; +import { buildSlideUrl, isExportMode } from "./slide-url"; describe("buildSlideUrl", () => { it("preserves basePath when on step 0", () => { @@ -50,3 +50,25 @@ describe("buildSlideUrl", () => { expect(result).toBe("/slides/abundance-dmv?slide=4"); }); }); + +describe("isExportMode", () => { + it("is on when export=1", () => { + expect(isExportMode(new URLSearchParams("slide=4&export=1"))).toBe(true); + }); + + it("is off without the param or with any other value", () => { + expect(isExportMode(new URLSearchParams("slide=4"))).toBe(false); + expect(isExportMode(new URLSearchParams("export=0"))).toBe(false); + expect(isExportMode(new URLSearchParams("export=true"))).toBe(false); + }); + + it("survives buildSlideUrl, so the address bar keeps export mode", () => { + const url = buildSlideUrl( + "http://localhost:3000/slides/abundance-dmv?slide=2&export=1", + 5, + ); + expect(isExportMode(new URL(url, "http://localhost").searchParams)).toBe( + true, + ); + }); +}); diff --git a/lib/slide-url.ts b/lib/slide-url.ts index dd201ee7..d4a85d19 100644 --- a/lib/slide-url.ts +++ b/lib/slide-url.ts @@ -15,3 +15,11 @@ export function buildSlideUrl( } return url.pathname + url.search; } + +/** + * Export mode (`?export=1`) renders slides without the navigation bar, so a + * headless browser can capture each step as it will appear in a PDF. + */ +export function isExportMode(params: Pick<URLSearchParams, "get">): boolean { + return params.get("export") === "1"; +} diff --git a/next.config.ts b/next.config.ts index c66c230c..51c99815 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,9 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { basePath: '/slides', + // `bun run export` builds into .next-export so its password-free build + // never replaces the regular .next one. + distDir: process.env.SLIDES_DIST_DIR || '.next', images: { unoptimized: true, }, diff --git a/package.json b/package.json index 356b90dc..ad7d9229 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ "globals": "^16.5.0", "jsdom": "^28.1.0", "postcss": "^8.5.6", - "puppeteer": "^24.37.5", "tailwindcss": "^4.2.4", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", diff --git a/scripts/export-pdf-lib.mjs b/scripts/export-pdf-lib.mjs new file mode 100644 index 00000000..bc9a6924 --- /dev/null +++ b/scripts/export-pdf-lib.mjs @@ -0,0 +1,113 @@ +/** + * Pure helpers for scripts/export-pdf.mjs, kept separate so Vitest can cover + * them without launching Chrome or a server. + */ + +export const USAGE = `Usage: bun run export <slideshow-id> [output.pdf] [--url <origin>] + + Without --url, builds the site locally and captures that build, so private + decks and unmerged changes export too. + With --url, captures a deployed site, e.g. --url https://www.policyengine.org + +Environment: + CHROME_PATH Chrome or Chromium binary (found automatically if unset) + EXPORT_WAIT_MS virtual time each slide gets to render (default 10000) + EXPORT_CONCURRENCY slides captured at once (default 3)`; + +/** Parse `<id> [output] [--url <origin>]` in any order. */ +export function parseArgs(argv) { + const positional = []; + let url = null; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--url") { + url = argv[i + 1]; + if (!url || url.startsWith("--")) { + throw new Error("--url needs an origin, e.g. https://www.policyengine.org"); + } + i++; + } else if (arg.startsWith("--url=")) { + url = arg.slice("--url=".length); + } else if (arg === "-h" || arg === "--help") { + return { help: true }; + } else if (arg.startsWith("--")) { + throw new Error(`Unknown option ${arg}`); + } else { + positional.push(arg); + } + } + const [id, output] = positional; + if (!id) throw new Error("Missing slideshow id"); + if (positional.length > 2) { + throw new Error(`Unexpected argument ${positional[2]}`); + } + return { + help: false, + id, + output: output || `${id}.pdf`, + url: url ? url.replace(/\/+$/, "") : null, + }; +} + +/** URL of one step in export mode, which hides the navigation bar. */ +export function stepUrl(origin, id, step) { + const url = new URL(`/slides/${encodeURIComponent(id)}`, origin); + url.searchParams.set("slide", String(step)); + url.searchParams.set("export", "1"); + return url.toString(); +} + +/** + * Read the deck's step count from the DOM that `chrome --dump-dom` prints. + * `gated` is true when the page shows the private-deck password form instead. + */ +export function parseDeckDom(dom) { + const match = dom.match(/data-total-steps="(\d+)"/); + const total = match ? parseInt(match[1], 10) : null; + const gated = /<input[^>]*type="password"/.test(dom); + return { total: total && total > 0 ? total : null, gated }; +} + +/** Zero-padded file name for a step's screenshot, so names sort in order. */ +export function shotName(step, total) { + const width = String(Math.max(total - 1, 0)).length; + return `step-${String(step).padStart(width, "0")}.jpg`; +} + +/** + * HTML that Chrome prints to a PDF with one 1920 × 1080 page per screenshot. + * At 96 px per inch, each page is 1440 × 810 points: 16:9, like the slides. + */ +export function deckHtml(fileNames) { + const images = fileNames + .map((name) => `<img src="${encodeURI(name)}" alt="">`) + .join("\n"); + return `<!DOCTYPE html> +<html><head><meta charset="utf-8"><style> + @page { size: 1920px 1080px; margin: 0; } + html, body { margin: 0; padding: 0; } + img { width: 1920px; height: 1080px; display: block; break-after: page; } + img:last-child { break-after: auto; } +</style></head><body> +${images} +</body></html> +`; +} + +/** Map `items` through async `fn` with at most `limit` running at once. */ +export async function mapLimit(items, limit, fn) { + const results = new Array(items.length); + let next = 0; + async function worker() { + while (next < items.length) { + const i = next++; + results[i] = await fn(items[i], i); + } + } + const workers = Array.from( + { length: Math.max(1, Math.min(limit, items.length)) }, + worker, + ); + await Promise.all(workers); + return results; +} diff --git a/scripts/export-pdf-lib.test.mjs b/scripts/export-pdf-lib.test.mjs new file mode 100644 index 00000000..42301212 --- /dev/null +++ b/scripts/export-pdf-lib.test.mjs @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { + parseArgs, + stepUrl, + parseDeckDom, + shotName, + deckHtml, + mapLimit, +} from "./export-pdf-lib.mjs"; + +describe("parseArgs", () => { + it("defaults the output to <id>.pdf and builds locally", () => { + expect(parseArgs(["bls-taxsim-2026"])).toEqual({ + help: false, + id: "bls-taxsim-2026", + output: "bls-taxsim-2026.pdf", + url: null, + }); + }); + + it("takes an output path and --url in either order", () => { + const expected = { + help: false, + id: "deck", + output: "out/deck.pdf", + url: "https://www.policyengine.org", + }; + expect( + parseArgs(["deck", "out/deck.pdf", "--url", "https://www.policyengine.org/"]), + ).toEqual(expected); + expect( + parseArgs(["--url=https://www.policyengine.org", "deck", "out/deck.pdf"]), + ).toEqual(expected); + }); + + it("rejects a missing id, a bare --url and unknown options", () => { + expect(() => parseArgs([])).toThrow(/Missing slideshow id/); + expect(() => parseArgs(["deck", "--url"])).toThrow(/--url needs an origin/); + expect(() => parseArgs(["deck", "--wait", "5"])).toThrow(/Unknown option/); + expect(() => parseArgs(["deck", "a.pdf", "b.pdf"])).toThrow(/Unexpected/); + }); + + it("returns help for -h", () => { + expect(parseArgs(["-h"])).toEqual({ help: true }); + }); +}); + +describe("stepUrl", () => { + it("puts the step and export mode in the query", () => { + expect(stepUrl("http://localhost:4000", "bls-taxsim-2026", 0)).toBe( + "http://localhost:4000/slides/bls-taxsim-2026?slide=0&export=1", + ); + expect(stepUrl("https://www.policyengine.org", "deck", 35)).toBe( + "https://www.policyengine.org/slides/deck?slide=35&export=1", + ); + }); +}); + +describe("parseDeckDom", () => { + it("reads the step count from the viewer", () => { + const dom = '<main class="relative cursor-pointer" data-total-steps="36">'; + expect(parseDeckDom(dom)).toEqual({ total: 36, gated: false }); + }); + + it("flags the password gate and a missing count", () => { + const dom = '<form><input type="password" placeholder="Password"></form>'; + expect(parseDeckDom(dom)).toEqual({ total: null, gated: true }); + }); + + it("treats a zero count as missing", () => { + expect(parseDeckDom('<main data-total-steps="0">').total).toBeNull(); + }); +}); + +describe("shotName", () => { + it("pads to the widest step so names sort in slide order", () => { + const names = Array.from({ length: 120 }, (_, i) => shotName(i, 120)); + expect([...names].sort()).toEqual(names); + expect(names[0]).toBe("step-000.jpg"); + expect(shotName(4, 9)).toBe("step-4.jpg"); + expect(shotName(0, 1)).toBe("step-0.jpg"); + }); +}); + +describe("deckHtml", () => { + it("lays out one 16:9 page per screenshot, in order", () => { + const html = deckHtml(["step-0.jpg", "step-1.jpg", "step-2.jpg"]); + expect(html).toContain("@page { size: 1920px 1080px; margin: 0; }"); + const srcs = [...html.matchAll(/<img src="([^"]+)"/g)].map((m) => m[1]); + expect(srcs).toEqual(["step-0.jpg", "step-1.jpg", "step-2.jpg"]); + }); +}); + +describe("mapLimit", () => { + it("keeps input order and never exceeds the limit", async () => { + let running = 0; + let peak = 0; + const out = await mapLimit([5, 1, 4, 2, 3], 2, async (n) => { + running++; + peak = Math.max(peak, running); + await new Promise((r) => setTimeout(r, n)); + running--; + return n * 10; + }); + expect(out).toEqual([50, 10, 40, 20, 30]); + expect(peak).toBe(2); + }); + + it("handles an empty list", async () => { + expect(await mapLimit([], 3, async (x) => x)).toEqual([]); + }); +}); diff --git a/scripts/export-pdf.mjs b/scripts/export-pdf.mjs index df457eac..a0c42444 100644 --- a/scripts/export-pdf.mjs +++ b/scripts/export-pdf.mjs @@ -1,286 +1,239 @@ /** * Export a slideshow to PDF. * - * Usage: bun run export <slideshow-id> [output-path] - * Example: bun run export abundance-dmv ~/Desktop/abundance-dmv.pdf + * Usage: + * bun run export <slideshow-id> [output.pdf] build locally, then capture + * bun run export <slideshow-id> [output.pdf] --url <origin> capture a deployed site * - * Requires puppeteer: bun add -d puppeteer + * Examples: + * bun run export abundance-dmv ~/Desktop/abundance-dmv.pdf + * bun run export bls-taxsim-2026 bls.pdf --url https://www.policyengine.org * * What it does: - * 1. Builds the project (bun run build) - * 2. Starts a prod server on a free port - * 3. Opens the slideshow in headless Chrome - * 4. Reads the "X / Y" counter to detect total steps - * 5. Arrow-keys through each step, fixing basePath image bug + hiding nav - * 6. Screenshots each step → combines into a single PDF - * 7. Tears everything down + * 1. Without --url, builds into .next-export with NEXT_PUBLIC_EXPORT_BUILD=1 + * (private decks skip the password gate) and serves it on a free port. + * 2. Reads the step count from the viewer's data-total-steps attribute, + * using `chrome --dump-dom`. + * 3. Screenshots every step at ?slide=N&export=1, which hides the nav bar. + * 4. Prints a page of the screenshots to a 16:9 PDF with + * `chrome --print-to-pdf`. + * + * Chrome runs only through its command line. The script never opens a + * DevTools connection (no puppeteer, no remote debugging), so it also works + * where an administrator has disabled remote debugging. */ -import puppeteer from "puppeteer"; -import { spawn } from "child_process"; +import { spawn, spawnSync } from "child_process"; +import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from "fs"; import { createServer } from "net"; +import { tmpdir } from "os"; +import path from "path"; +import { pathToFileURL } from "url"; +import { + USAGE, + deckHtml, + mapLimit, + parseArgs, + parseDeckDom, + shotName, + stepUrl, +} from "./export-pdf-lib.mjs"; + +const WAIT_MS = parseInt(process.env.EXPORT_WAIT_MS || "10000", 10); +const CONCURRENCY = parseInt(process.env.EXPORT_CONCURRENCY || "3", 10); +const DIST_DIR = ".next-export"; + +// ── Chrome ─────────────────────────────────────────────────────────── + +function findChrome() { + if (process.env.CHROME_PATH) return process.env.CHROME_PATH; + const candidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + for (const name of [ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + ]) { + const found = spawnSync("which", [name], { encoding: "utf8" }); + if (found.status === 0 && found.stdout.trim()) return found.stdout.trim(); + } + throw new Error("Chrome not found. Set CHROME_PATH to a Chrome or Chromium binary."); +} -const [id, outputArg] = process.argv.slice(2); -if (!id) { - console.error("Usage: bun run export <slideshow-id> [output-path]"); - console.error("Example: bun run export abundance-dmv ~/Desktop/slides.pdf"); - process.exit(1); +/** Run headless Chrome with `args`; resolve with stdout. */ +function chrome(binary, args, { timeoutMs = WAIT_MS + 60_000 } = {}) { + const flags = [ + "--headless", + "--disable-gpu", + "--hide-scrollbars", + "--no-first-run", + "--no-default-browser-check", + "--window-size=1920,1080", + ]; + // Chrome refuses to start its sandbox as root, e.g. in some CI containers. + if (process.getuid?.() === 0) flags.push("--no-sandbox"); + return new Promise((resolve, reject) => { + const proc = spawn(binary, [...flags, ...args], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + proc.stdout.on("data", (d) => (stdout += d)); + proc.stderr.on("data", (d) => (stderr += d)); + const timer = setTimeout(() => { + proc.kill("SIGKILL"); + reject(new Error(`Chrome timed out after ${timeoutMs} ms: ${args.at(-1)}`)); + }, timeoutMs); + proc.on("error", (e) => { + clearTimeout(timer); + reject(e); + }); + proc.on("close", (code) => { + clearTimeout(timer); + if (code === 0) resolve(stdout); + else reject(new Error(`Chrome exited ${code}: ${stderr.trim().split("\n").at(-1)}`)); + }); + }); } -const OUTPUT = outputArg || `${id}.pdf`; -// ── helpers ────────────────────────────────────────────────────────── +// ── Local build and server ─────────────────────────────────────────── -/** Find a free port. */ function getFreePort() { return new Promise((resolve, reject) => { const srv = createServer(); srv.listen(0, () => { - const port = srv.address().port; + const { port } = srv.address(); srv.close(() => resolve(port)); }); srv.on("error", reject); }); } -/** Start `next start -p <port>` and wait until it responds. */ -async function startServer(port) { - const proc = spawn("npx", ["next", "start", "-p", String(port)], { - cwd: process.cwd(), - stdio: "pipe", - }); - // Wait for server to be ready - const ready = new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error("Server failed to start within 30s")), - 30_000, - ); - proc.stdout.on("data", (d) => { - const line = d.toString(); - if (line.includes("Ready") || line.includes("started")) { - clearTimeout(timeout); - resolve(); - } - }); - proc.stderr.on("data", (d) => { - const line = d.toString(); - if (line.includes("Ready") || line.includes("started")) { - clearTimeout(timeout); - resolve(); - } - }); - proc.on("error", (e) => { - clearTimeout(timeout); - reject(e); - }); - }); - await ready; - return proc; +const nextBin = path.join(process.cwd(), "node_modules", ".bin", "next"); +const exportEnv = { + ...process.env, + SLIDES_DIST_DIR: DIST_DIR, + NEXT_PUBLIC_EXPORT_BUILD: "1", +}; + +function build() { + console.log(`Building into ${DIST_DIR}...`); + const result = spawnSync(nextBin, ["build"], { stdio: "inherit", env: exportEnv }); + if (result.status !== 0) throw new Error(`Build failed (exit ${result.status})`); } -/** Fix images whose src is missing the /slides/ basePath prefix. */ -async function fixImages(page) { - await page.evaluate(() => { - document.querySelectorAll("img").forEach((img) => { - const src = img.getAttribute("src") || ""; - if (src.startsWith("/") && !src.startsWith("/slides/")) { - img.src = "/slides" + src; - } - }); - }); - // Wait for them to load - await page.evaluate(() => - Promise.all( - Array.from(document.querySelectorAll("img")).map((img) => { - if (img.complete && img.naturalWidth > 0) return Promise.resolve(); - return new Promise((resolve) => { - img.onload = resolve; - img.onerror = resolve; - setTimeout(resolve, 3000); - }); - }), - ), - ); - await new Promise((r) => setTimeout(r, 300)); -} - -/** Hide the bottom navigation bar. */ -async function hideNav(page) { - await page.evaluate(() => { - const nav = document.querySelector(".fixed.bottom-0"); - if (nav) nav.style.display = "none"; +async function startServer(port) { + const proc = spawn(nextBin, ["start", "-p", String(port)], { + stdio: "ignore", + env: exportEnv, }); -} - -/** Read total steps from the "X / Y" counter in the nav bar. */ -async function getTotalSteps(page) { - return page.evaluate(() => { - const spans = Array.from(document.querySelectorAll("span")); - for (const span of spans) { - const match = span.textContent?.match(/(\d+)\s*\/\s*(\d+)/); - if (match) return parseInt(match[2], 10); + const origin = `http://localhost:${port}`; + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (proc.exitCode !== null) throw new Error(`Server exited ${proc.exitCode}`); + try { + const res = await fetch(`${origin}/slides`); + if (res.status < 500) return { proc, origin }; + } catch { + // not listening yet } - return null; - }); + await new Promise((r) => setTimeout(r, 500)); + } + proc.kill(); + throw new Error("Server did not respond within 60 s"); } // ── main ───────────────────────────────────────────────────────────── async function main() { - // 1. Build - console.log("Building project..."); - const build = spawn("npx", ["next", "build"], { - cwd: process.cwd(), - stdio: "inherit", - }); - await new Promise((resolve, reject) => { - build.on("close", (code) => - code === 0 ? resolve() : reject(new Error(`Build failed (exit ${code})`)), - ); - }); + let args; + try { + args = parseArgs(process.argv.slice(2)); + } catch (e) { + console.error(`${e.message}\n\n${USAGE}`); + process.exit(1); + } + if (args.help) { + console.log(USAGE); + return; + } - // 2. Start prod server - const port = await getFreePort(); - console.log(`Starting prod server on port ${port}...`); - const server = await startServer(port); + const binary = findChrome(); + const output = path.resolve(args.output); + const work = mkdtempSync(path.join(tmpdir(), `export-${args.id}-`)); + let server = null; - let browser; try { - // 3. Open slideshow - const url = `http://localhost:${port}/slides/${id}`; - console.log(`Opening ${url}`); - // Classic ("shell") headless: the new headless mode has been seen to - // hang on navigation entirely on macOS, while shell mode captures - // slides reliably. - browser = await puppeteer.launch({ - headless: "shell", - protocolTimeout: 60_000, - }); - const page = await browser.newPage(); - await page.setViewport({ width: 1920, height: 1080 }); - await page.goto(url, { waitUntil: "networkidle2", timeout: 60_000 }); - await new Promise((r) => setTimeout(r, 3000)); + let origin = args.url; + if (!origin) { + build(); + server = await startServer(await getFreePort()); + origin = server.origin; + } - // Private decks render a client-side password gate. Unlock it with - // NEXT_PUBLIC_PRIVATE_PASSWORD from the environment (empty when unset, - // which matches the gate's fallback when the env var isn't defined). - const gated = await page.$('input[type="password"]'); + // 1. Count the steps. + const firstUrl = stepUrl(origin, args.id, 0); + console.log(`Reading ${firstUrl}`); + const { total, gated } = parseDeckDom( + await chrome(binary, [`--virtual-time-budget=${WAIT_MS}`, "--dump-dom", firstUrl]), + ); if (gated) { - const pw = process.env.NEXT_PUBLIC_PRIVATE_PASSWORD ?? ""; - console.log("Password gate detected — submitting configured password"); - await page.evaluate((value) => { - const input = document.querySelector('input[type="password"]'); - const setter = Object.getOwnPropertyDescriptor( - window.HTMLInputElement.prototype, - "value", - ).set; - setter.call(input, value); - input.dispatchEvent(new Event("input", { bubbles: true })); - input.closest("form").requestSubmit(); - }, pw); - await new Promise((r) => setTimeout(r, 2000)); + throw new Error( + "This deck is private. Export it from a local build by leaving out --url.", + ); } - - // 4. Detect total steps - let totalSteps = await getTotalSteps(page); - if (!totalSteps) { - console.error( - "Could not detect slide count from DOM. Is the slideshow ID correct?", + if (!total) { + throw new Error( + "No data-total-steps on the page. Is the slideshow id right, and does the site include export mode?", ); - process.exit(1); } - console.log(`Detected ${totalSteps} steps`); - - // 5. Capture each step - const screenshots = []; - for (let i = 1; i <= totalSteps; i++) { - console.log(`Capturing step ${i}/${totalSteps}...`); - if (i > 1) { - // Dispatch the advance key on `document` rather than page.keyboard so - // that an embedded iframe holding keyboard focus (e.g. a scrollytelling - // demo) can't swallow it — otherwise the deck never advances and the - // same slide is captured repeatedly. - await page.evaluate(() => { - const el = document.activeElement; - if (el && el.tagName === "IFRAME") el.blur(); - document.dispatchEvent( - new KeyboardEvent("keydown", { - key: "ArrowRight", - code: "ArrowRight", - keyCode: 39, - which: 39, - bubbles: true, - }), - ); - }); - // Give the next slide (including any live embed) time to paint. - // Heavier embeds (e.g. the Thesis conditional comparison, which loads - // several forecast cells) need a few seconds to finish rendering. - await new Promise((r) => setTimeout(r, 5000)); - } - // The fixImages/screenshot CDP calls intermittently throw a transient - // "detached Frame" or "captureScreenshot timed out" mid-transition. - // Retry the (post-advance) capture so one flake doesn't kill the export. - let shot; - for (let attempt = 1; attempt <= 3; attempt++) { + console.log(`${total} steps`); + + // 2. Screenshot each step. + const names = Array.from({ length: total }, (_, i) => shotName(i, total)); + let done = 0; + await mapLimit(names, CONCURRENCY, async (name, step) => { + const file = path.join(work, name); + for (let attempt = 1; ; attempt++) { try { - // fixImages only rewrites basePath-less <img> srcs; on slides with - // cross-origin iframes its evaluate can hit a permanently detached - // child frame, so a failure here must not block the screenshot. - try { - await fixImages(page); - } catch { - // ignore — live-iframe slides have no basePath images to fix - } - try { - await hideNav(page); - } catch { - // same detached-frame class as fixImages; nav is usually already - // hidden from the prior step, so never let this kill the capture - } - await new Promise((r) => setTimeout(r, 200)); - shot = await page.screenshot({ type: "png", encoding: "base64" }); + await chrome(binary, [ + `--virtual-time-budget=${WAIT_MS}`, + `--screenshot=${file}`, + stepUrl(origin, args.id, step), + ]); + if (statSync(file).size === 0) throw new Error("empty screenshot"); break; - } catch (err) { - if (attempt === 3) throw err; - console.log( - ` step ${i} capture attempt ${attempt} failed (${ - err.message.split("\n")[0] - }); retrying...`, - ); - await new Promise((r) => setTimeout(r, 1500)); + } catch (e) { + if (attempt === 3) throw new Error(`Step ${step + 1}: ${e.message}`); + console.log(` step ${step + 1} attempt ${attempt} failed (${e.message}); retrying`); } } - screenshots.push(shot); - } - - // 6. Combine into PDF - console.log("Building PDF..."); - const html = `<!DOCTYPE html> -<html><head><style> - @page { size: 1920px 1080px; margin: 0; } - body { margin: 0; padding: 0; } - img { width: 1920px; height: 1080px; display: block; page-break-after: always; } - img:last-child { page-break-after: auto; } -</style></head><body> -${screenshots.map((s) => `<img src="data:image/png;base64,${s}" />`).join("\n")} -</body></html>`; - - await page.setContent(html, { waitUntil: "load" }); - await page.pdf({ - path: OUTPUT, - width: "1920px", - height: "1080px", - printBackground: true, + done++; + console.log(`Captured ${done}/${total}`); }); - console.log(`PDF saved to ${OUTPUT}`); + // 3. Print the screenshots to a PDF. + const page = path.join(work, "deck.html"); + writeFileSync(page, deckHtml(names)); + await chrome(binary, [ + "--no-pdf-header-footer", + `--print-to-pdf=${output}`, + pathToFileURL(page).href, + ]); + console.log(`PDF saved to ${output}`); } finally { - if (browser) await browser.close(); - server.kill(); + if (server) server.proc.kill(); + rmSync(work, { recursive: true, force: true }); } } main().catch((e) => { - console.error(e); + console.error(e.message || e); process.exit(1); }); From 6c861436aa7f2f40e7c9ab2f85db99c75bb9fa71 Mon Sep 17 00:00:00 2001 From: Max Ghenis <mghenis@gmail.com> Date: Tue, 13 Oct 2026 11:58:06 -0400 Subject: [PATCH 02/10] Add --size and --scale, render cross-site embeds, and title the PDF - --size sets the viewport and the PDF page size; --scale sets the pixel ratio, so a deck laid out for a laptop fills the page and stays sharp. - Chrome's virtual time budget covers only the page's own process, so a cross-site iframe was still blank when it ended. Capturing with site isolation off keeps embeds in that process. - The PDF's document title comes from the deck page's title. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- next-env.d.ts | 2 +- scripts/export-pdf-lib.mjs | 105 +++++++++++++++++++++++--------- scripts/export-pdf-lib.test.mjs | 78 ++++++++++++++++++++---- scripts/export-pdf.mjs | 27 +++++--- tsconfig.json | 4 +- 5 files changed, 166 insertions(+), 50 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c7..ed2e9992 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> -import "./.next/types/routes.d.ts"; +import "./.next-export/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/scripts/export-pdf-lib.mjs b/scripts/export-pdf-lib.mjs index bc9a6924..a8fc7dc9 100644 --- a/scripts/export-pdf-lib.mjs +++ b/scripts/export-pdf-lib.mjs @@ -3,38 +3,65 @@ * them without launching Chrome or a server. */ -export const USAGE = `Usage: bun run export <slideshow-id> [output.pdf] [--url <origin>] +export const USAGE = `Usage: bun run export <slideshow-id> [output.pdf] [options] - Without --url, builds the site locally and captures that build, so private - decks and unmerged changes export too. - With --url, captures a deployed site, e.g. --url https://www.policyengine.org + --url <origin> Capture a deployed site, e.g. https://www.policyengine.org. + Without it, the script builds the site locally and captures + that build, so private decks and unmerged changes export too. + --size <WxH> Viewport in CSS pixels, and the PDF page size (default + 1920x1080). A deck laid out for a laptop fills the page + better at 1440x810. + --scale <n> Device pixel ratio of the screenshots, 1 to 4 (default 1). + Use 2 with a smaller --size to keep text sharp. Environment: CHROME_PATH Chrome or Chromium binary (found automatically if unset) EXPORT_WAIT_MS virtual time each slide gets to render (default 10000) EXPORT_CONCURRENCY slides captured at once (default 3)`; -/** Parse `<id> [output] [--url <origin>]` in any order. */ +const VALUE_OPTIONS = { + "--url": "an origin, e.g. https://www.policyengine.org", + "--size": "a viewport such as 1440x810", + "--scale": "a number from 1 to 4", +}; + +function parseSize(value) { + const match = /^(\d+)x(\d+)$/.exec(value); + const width = match ? parseInt(match[1], 10) : 0; + const height = match ? parseInt(match[2], 10) : 0; + if (width < 320 || height < 180 || width > 7680 || height > 4320) { + throw new Error(`--size needs ${VALUE_OPTIONS["--size"]}, got ${value}`); + } + return { width, height }; +} + +function parseScale(value) { + const scale = Number(value); + if (!Number.isFinite(scale) || scale < 1 || scale > 4) { + throw new Error(`--scale needs ${VALUE_OPTIONS["--scale"]}, got ${value}`); + } + return scale; +} + +/** Parse `<id> [output]` plus `--url`, `--size` and `--scale` in any order. */ export function parseArgs(argv) { const positional = []; - let url = null; + const options = {}; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; - if (arg === "--url") { - url = argv[i + 1]; - if (!url || url.startsWith("--")) { - throw new Error("--url needs an origin, e.g. https://www.policyengine.org"); - } - i++; - } else if (arg.startsWith("--url=")) { - url = arg.slice("--url=".length); - } else if (arg === "-h" || arg === "--help") { - return { help: true }; - } else if (arg.startsWith("--")) { - throw new Error(`Unknown option ${arg}`); - } else { + if (arg === "-h" || arg === "--help") return { help: true }; + if (!arg.startsWith("--")) { positional.push(arg); + continue; + } + const eq = arg.indexOf("="); + const name = eq === -1 ? arg : arg.slice(0, eq); + if (!(name in VALUE_OPTIONS)) throw new Error(`Unknown option ${name}`); + const value = eq === -1 ? argv[++i] : arg.slice(eq + 1); + if (!value || value.startsWith("--")) { + throw new Error(`${name} needs ${VALUE_OPTIONS[name]}`); } + options[name] = value; } const [id, output] = positional; if (!id) throw new Error("Missing slideshow id"); @@ -45,7 +72,9 @@ export function parseArgs(argv) { help: false, id, output: output || `${id}.pdf`, - url: url ? url.replace(/\/+$/, "") : null, + url: options["--url"] ? options["--url"].replace(/\/+$/, "") : null, + size: parseSize(options["--size"] || "1920x1080"), + scale: parseScale(options["--scale"] || "1"), }; } @@ -57,15 +86,31 @@ export function stepUrl(origin, id, step) { return url.toString(); } +const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', "#39": "'", "#x27": "'" }; + +function decodeEntities(text) { + return text.replace(/&(amp|lt|gt|quot|#39|#x27);/g, (_, name) => ENTITIES[name]); +} + +function escapeHtml(text) { + return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">"); +} + /** - * Read the deck's step count from the DOM that `chrome --dump-dom` prints. - * `gated` is true when the page shows the private-deck password form instead. + * Read the deck's step count and page title from the DOM that + * `chrome --dump-dom` prints. `gated` is true when the page shows the + * private-deck password form instead. */ export function parseDeckDom(dom) { const match = dom.match(/data-total-steps="(\d+)"/); const total = match ? parseInt(match[1], 10) : null; const gated = /<input[^>]*type="password"/.test(dom); - return { total: total && total > 0 ? total : null, gated }; + const title = dom.match(/<title[^>]*>([^<]*)<\/title>/); + return { + total: total && total > 0 ? total : null, + gated, + title: title ? decodeEntities(title[1]).trim() || null : null, + }; } /** Zero-padded file name for a step's screenshot, so names sort in order. */ @@ -75,18 +120,20 @@ export function shotName(step, total) { } /** - * HTML that Chrome prints to a PDF with one 1920 × 1080 page per screenshot. - * At 96 px per inch, each page is 1440 × 810 points: 16:9, like the slides. + * HTML that Chrome prints to a PDF with one page per screenshot. Each page is + * `width` × `height` CSS pixels, the viewport the screenshots were taken at; + * at 96 px per inch, 1920 × 1080 comes out as 1440 × 810 points. `title` + * becomes the PDF's document title. */ -export function deckHtml(fileNames) { +export function deckHtml(fileNames, { width, height, title }) { const images = fileNames .map((name) => `<img src="${encodeURI(name)}" alt="">`) .join("\n"); return `<!DOCTYPE html> -<html><head><meta charset="utf-8"><style> - @page { size: 1920px 1080px; margin: 0; } +<html><head><meta charset="utf-8"><title>${escapeHtml(title || "Slides")} ${images} diff --git a/scripts/export-pdf-lib.test.mjs b/scripts/export-pdf-lib.test.mjs index 42301212..84a6eb37 100644 --- a/scripts/export-pdf-lib.test.mjs +++ b/scripts/export-pdf-lib.test.mjs @@ -9,37 +9,69 @@ import { } from "./export-pdf-lib.mjs"; describe("parseArgs", () => { - it("defaults the output to .pdf and builds locally", () => { + it("defaults to .pdf, a local build and a 1920x1080 page", () => { expect(parseArgs(["bls-taxsim-2026"])).toEqual({ help: false, id: "bls-taxsim-2026", output: "bls-taxsim-2026.pdf", url: null, + size: { width: 1920, height: 1080 }, + scale: 1, }); }); - it("takes an output path and --url in either order", () => { + it("takes an output path and options in any order", () => { const expected = { help: false, id: "deck", output: "out/deck.pdf", url: "https://www.policyengine.org", + size: { width: 1440, height: 810 }, + scale: 2, }; expect( - parseArgs(["deck", "out/deck.pdf", "--url", "https://www.policyengine.org/"]), + parseArgs([ + "deck", + "out/deck.pdf", + "--url", + "https://www.policyengine.org/", + "--size", + "1440x810", + "--scale", + "2", + ]), ).toEqual(expected); expect( - parseArgs(["--url=https://www.policyengine.org", "deck", "out/deck.pdf"]), + parseArgs([ + "--scale=2", + "--url=https://www.policyengine.org", + "deck", + "--size=1440x810", + "out/deck.pdf", + ]), ).toEqual(expected); }); - it("rejects a missing id, a bare --url and unknown options", () => { + it("rejects a missing id, a bare option and unknown options", () => { expect(() => parseArgs([])).toThrow(/Missing slideshow id/); expect(() => parseArgs(["deck", "--url"])).toThrow(/--url needs an origin/); + expect(() => parseArgs(["deck", "--size", "--scale", "2"])).toThrow( + /--size needs a viewport/, + ); expect(() => parseArgs(["deck", "--wait", "5"])).toThrow(/Unknown option/); expect(() => parseArgs(["deck", "a.pdf", "b.pdf"])).toThrow(/Unexpected/); }); + it("rejects sizes and scales Chrome cannot render", () => { + for (const size of ["1440", "1440x", "0x0", "100x100", "9000x9000", "axb"]) { + expect(() => parseArgs(["deck", "--size", size])).toThrow(/--size needs/); + } + for (const scale of ["0", "0.5", "5", "two"]) { + expect(() => parseArgs(["deck", "--scale", scale])).toThrow(/--scale needs/); + } + expect(parseArgs(["deck", "--scale", "1.5"]).scale).toBe(1.5); + }); + it("returns help for -h", () => { expect(parseArgs(["-h"])).toEqual({ help: true }); }); @@ -57,14 +89,20 @@ describe("stepUrl", () => { }); describe("parseDeckDom", () => { - it("reads the step count from the viewer", () => { - const dom = '
'; - expect(parseDeckDom(dom)).toEqual({ total: 36, gated: false }); + it("reads the step count and title from the viewer", () => { + const dom = + "Taxes & benefits | PolicyEngine" + + '
'; + expect(parseDeckDom(dom)).toEqual({ + total: 36, + gated: false, + title: "Taxes & benefits | PolicyEngine", + }); }); it("flags the password gate and a missing count", () => { const dom = '
'; - expect(parseDeckDom(dom)).toEqual({ total: null, gated: true }); + expect(parseDeckDom(dom)).toEqual({ total: null, gated: true, title: null }); }); it("treats a zero count as missing", () => { @@ -83,14 +121,30 @@ describe("shotName", () => { }); describe("deckHtml", () => { - it("lays out one 16:9 page per screenshot, in order", () => { - const html = deckHtml(["step-0.jpg", "step-1.jpg", "step-2.jpg"]); - expect(html).toContain("@page { size: 1920px 1080px; margin: 0; }"); + it("lays out one page per screenshot at the viewport size, in order", () => { + const html = deckHtml(["step-0.jpg", "step-1.jpg", "step-2.jpg"], { + width: 1440, + height: 810, + }); + expect(html).toContain("@page { size: 1440px 810px; margin: 0; }"); + expect(html).toContain("img { width: 1440px; height: 810px;"); + expect(html).toContain("Slides"); const srcs = [...html.matchAll(/ m[1]); expect(srcs).toEqual(["step-0.jpg", "step-1.jpg", "step-2.jpg"]); }); }); +describe("deckHtml title", () => { + it("escapes the title that becomes the PDF's document title", () => { + const html = deckHtml(["step-0.jpg"], { + width: 1920, + height: 1080, + title: "Taxes & ", + }); + expect(html).toContain("Taxes & <benefits>"); + }); +}); + describe("mapLimit", () => { it("keeps input order and never exceeds the limit", async () => { let running = 0; diff --git a/scripts/export-pdf.mjs b/scripts/export-pdf.mjs index a0c42444..b37d5682 100644 --- a/scripts/export-pdf.mjs +++ b/scripts/export-pdf.mjs @@ -4,10 +4,12 @@ * Usage: * bun run export [output.pdf] build locally, then capture * bun run export [output.pdf] --url capture a deployed site + * ... --size 1440x810 --scale 2 viewport and pixel ratio * * Examples: * bun run export abundance-dmv ~/Desktop/abundance-dmv.pdf * bun run export bls-taxsim-2026 bls.pdf --url https://www.policyengine.org + * bun run export bls-taxsim-2026 bls.pdf --size 1440x810 --scale 2 * * What it does: * 1. Without --url, builds into .next-export with NEXT_PUBLIC_EXPORT_BUILD=1 @@ -15,8 +17,8 @@ * 2. Reads the step count from the viewer's data-total-steps attribute, * using `chrome --dump-dom`. * 3. Screenshots every step at ?slide=N&export=1, which hides the nav bar. - * 4. Prints a page of the screenshots to a 16:9 PDF with - * `chrome --print-to-pdf`. + * 4. Prints a page of the screenshots to a PDF with `chrome --print-to-pdf`, + * one page per step at the viewport size. * * Chrome runs only through its command line. The script never opens a * DevTools connection (no puppeteer, no remote debugging), so it also works @@ -74,7 +76,6 @@ function chrome(binary, args, { timeoutMs = WAIT_MS + 60_000 } = {}) { "--hide-scrollbars", "--no-first-run", "--no-default-browser-check", - "--window-size=1920,1080", ]; // Chrome refuses to start its sandbox as root, e.g. in some CI containers. if (process.getuid?.() === 0) flags.push("--no-sandbox"); @@ -177,11 +178,23 @@ async function main() { origin = server.origin; } + // Chrome's virtual time budget covers only the page's own process. With + // site isolation on, a cross-site embed (a live app in an iframe) runs in + // another process and is still blank when the budget ends, so keep + // embeds in the page's process while capturing. + const render = [ + `--window-size=${args.size.width},${args.size.height}`, + `--force-device-scale-factor=${args.scale}`, + `--virtual-time-budget=${WAIT_MS}`, + "--disable-site-isolation-trials", + "--disable-features=IsolateOrigins,site-per-process", + ]; + // 1. Count the steps. const firstUrl = stepUrl(origin, args.id, 0); console.log(`Reading ${firstUrl}`); - const { total, gated } = parseDeckDom( - await chrome(binary, [`--virtual-time-budget=${WAIT_MS}`, "--dump-dom", firstUrl]), + const { total, gated, title } = parseDeckDom( + await chrome(binary, [...render, "--dump-dom", firstUrl]), ); if (gated) { throw new Error( @@ -203,7 +216,7 @@ async function main() { for (let attempt = 1; ; attempt++) { try { await chrome(binary, [ - `--virtual-time-budget=${WAIT_MS}`, + ...render, `--screenshot=${file}`, stepUrl(origin, args.id, step), ]); @@ -220,7 +233,7 @@ async function main() { // 3. Print the screenshots to a PDF. const page = path.join(work, "deck.html"); - writeFileSync(page, deckHtml(names)); + writeFileSync(page, deckHtml(names, { ...args.size, title })); await chrome(binary, [ "--no-pdf-header-footer", `--print-to-pdf=${output}`, diff --git a/tsconfig.json b/tsconfig.json index 803331cc..396d0194 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,7 +33,9 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + ".next/dev/types/**/*.ts", + ".next-export/types/**/*.ts", + ".next-export/dev/types/**/*.ts" ], "exclude": [ "node_modules" From 042ecc0d60bf210a5b05d5bbd2ed2b987606327d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 12:06:49 -0400 Subject: [PATCH 03/10] Mount only the current slide in export mode and cap the capture wait A capture of one slide also mounted its neighbors, so a neighbor's live embed loaded in the background and could keep Chrome from settling: one run timed out three times on a slide next to an embed. Export mode now mounts the current slide alone. Virtual time stops while a request is pending, so the script also passes --timeout to cap the wait in real time. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +++- components/core/SlideshowViewer.tsx | 6 +++++- scripts/export-pdf.mjs | 3 +++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0ec587c4..2a13af10 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,9 +50,11 @@ bun run export [output.pdf] [--url ] # Export slideshow to PDF (he `bun run export` drives headless Chrome through its command line only (`--dump-dom`, `--screenshot`, `--print-to-pdf`), never through a DevTools connection, so it works where remote debugging is disabled. -- `?slide=N&export=1` renders step N without the navigation bar; the viewer's `
` carries `data-total-steps`. +- `?slide=N&export=1` renders step N without the navigation bar and mounts only that slide (no neighbors); the viewer's `
` carries `data-total-steps`. - Without `--url`, the script builds into `.next-export` with `NEXT_PUBLIC_EXPORT_BUILD=1`, which skips the password gate so private decks export; deployed builds never set it. - With `--url https://www.policyengine.org`, it captures the deployed site (public decks only). +- `--size 1440x810 --scale 2` suits decks laid out for a laptop: the content fills the page and text stays sharp. The default is `1920x1080` at scale 1. +- Cross-site embeds render because the capture runs with site isolation off, which keeps an iframe in the page's process where the render budget applies. - `EXPORT_WAIT_MS` (default 10000) sets how much virtual time each slide gets to render; raise it for slides with slow live embeds. `CHROME_PATH` overrides the Chrome binary. ## Adding a new slideshow diff --git a/components/core/SlideshowViewer.tsx b/components/core/SlideshowViewer.tsx index 7d75331f..5473b5a9 100644 --- a/components/core/SlideshowViewer.tsx +++ b/components/core/SlideshowViewer.tsx @@ -97,7 +97,11 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) { > {mounted && config.slides.map((slide, slideIndex) => { const SlideComponent = getSlideComponent(slide); - const isNeighbor = Math.abs(slideIndex - (currentStepData?.slideIndex ?? 0)) <= 1; + // Export mode captures one step per page load, so it mounts only the + // current slide: a neighbor's live embed would load in the background + // and can keep the capture from settling. + const mountRadius = exportMode ? 0 : 1; + const isNeighbor = Math.abs(slideIndex - (currentStepData?.slideIndex ?? 0)) <= mountRadius; const isCurrent = slideIndex === currentStepData?.slideIndex; if (!isNeighbor) { diff --git a/scripts/export-pdf.mjs b/scripts/export-pdf.mjs index b37d5682..2dea7450 100644 --- a/scripts/export-pdf.mjs +++ b/scripts/export-pdf.mjs @@ -186,6 +186,9 @@ async function main() { `--window-size=${args.size.width},${args.size.height}`, `--force-device-scale-factor=${args.scale}`, `--virtual-time-budget=${WAIT_MS}`, + // Virtual time stops while a request is pending, so one stalled request + // would hold the capture forever. Cap the wait in real time as well. + `--timeout=${WAIT_MS + 30_000}`, "--disable-site-isolation-trials", "--disable-features=IsolateOrigins,site-per-process", ]; From b771967535cbe1c966c1dc03855d40b4ea62ca72 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 12:29:21 -0400 Subject: [PATCH 04/10] Address review: clean up on failure, validate settings, unlock by fragment Review of 042ecc0 asked for changes. - A failed capture left the other Chrome processes running while the script removed their files. Chrome processes are now tracked: the first failure stops scheduling, stops the running captures and waits for them. - SIGINT and SIGTERM stop Chrome and the server and remove the screenshots. - EXPORT_CONCURRENCY=abc became NaN and skipped every screenshot. EXPORT_WAIT_MS and EXPORT_CONCURRENCY are validated, and mapLimit rejects a limit that would run nothing. - Server start handles a failed spawn and bounds each readiness request. - The script counts the PDF's pages and fails unless every step has one. Tests print a deck with Chrome and check page count and size, and check that the watchdog stops a Chrome that hangs. - --timeout bounds the page load only; the comments now say so. - --site-isolation keeps Chrome's site isolation on for untrusted sites. Building into .next-export made Next rewrite tsconfig.json and next-env.d.ts, and those rewrites had been committed. The separate build and the NEXT_PUBLIC_EXPORT_BUILD bypass are gone. A private deck now unlocks from the password in the URL fragment, which also works against a deployed site and never unlocks without the real password. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 - CLAUDE.md | 10 +- components/core/PasswordGate.tsx | 16 +- eslint.config.mjs | 1 - lib/slide-url.test.ts | 32 +++- lib/slide-url.ts | 9 + next-env.d.ts | 2 +- next.config.ts | 3 - scripts/export-pdf-chrome.mjs | 91 ++++++++++ scripts/export-pdf-lib.mjs | 105 ++++++++--- scripts/export-pdf-lib.test.mjs | 179 +++++++++++++++++-- scripts/export-pdf.mjs | 297 +++++++++++++++++-------------- tsconfig.json | 4 +- 13 files changed, 559 insertions(+), 191 deletions(-) create mode 100644 scripts/export-pdf-chrome.mjs diff --git a/.gitignore b/.gitignore index fb6caaf9..f206ebc3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ node_modules/ .next/ -.next-export/ out/ .DS_Store .vercel diff --git a/CLAUDE.md b/CLAUDE.md index 2a13af10..62f604bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,11 +51,13 @@ bun run export [output.pdf] [--url ] # Export slideshow to PDF (he `bun run export` drives headless Chrome through its command line only (`--dump-dom`, `--screenshot`, `--print-to-pdf`), never through a DevTools connection, so it works where remote debugging is disabled. - `?slide=N&export=1` renders step N without the navigation bar and mounts only that slide (no neighbors); the viewer's `
` carries `data-total-steps`. -- Without `--url`, the script builds into `.next-export` with `NEXT_PUBLIC_EXPORT_BUILD=1`, which skips the password gate so private decks export; deployed builds never set it. -- With `--url https://www.policyengine.org`, it captures the deployed site (public decks only). +- Without `--url`, the script runs `next build`, serves the build on a free port and captures it, so unmerged changes export too. With `--url https://www.policyengine.org`, it captures the deployed site. - `--size 1440x810 --scale 2` suits decks laid out for a laptop: the content fills the page and text stays sharp. The default is `1920x1080` at scale 1. -- Cross-site embeds render because the capture runs with site isolation off, which keeps an iframe in the page's process where the render budget applies. -- `EXPORT_WAIT_MS` (default 10000) sets how much virtual time each slide gets to render; raise it for slides with slow live embeds. `CHROME_PATH` overrides the Chrome binary. +- Private decks: set `NEXT_PUBLIC_PRIVATE_PASSWORD`. The script passes it in the URL fragment (`#password=...`), which `PasswordGate` accepts and the browser never sends to the server. A local build also needs the variable at build time, as any build of a private deck does. +- Cross-site embeds render because captures run with Chrome's site isolation off, which keeps an iframe in the page's process where the render budget applies. Pass `--site-isolation` to keep it on when you capture a site you do not control; embeds may then come out blank. +- `EXPORT_WAIT_MS` (default 10000) sets how much virtual time each slide gets to render after it loads; raise it for slides with slow embeds or long intro animations. `EXPORT_CONCURRENCY` (default 3) sets how many slides are captured at once. `CHROME_PATH` overrides the Chrome binary. +- A watchdog stops a Chrome that hangs, and each step gets three attempts. A failure or Ctrl-C stops every Chrome the script started, stops the server and removes the screenshots. +- The script checks that the PDF has one page per step before it reports success. ## Adding a new slideshow diff --git a/components/core/PasswordGate.tsx b/components/core/PasswordGate.tsx index 176fbc5e..576d66c6 100644 --- a/components/core/PasswordGate.tsx +++ b/components/core/PasswordGate.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { passwordFromHash } from '@/lib/slide-url'; interface PasswordGateProps { children: React.ReactNode; @@ -13,9 +14,16 @@ export default function PasswordGate({ children }: PasswordGateProps) { const correctPassword = process.env.NEXT_PUBLIC_PRIVATE_PASSWORD || ''; - // `bun run export` builds with NEXT_PUBLIC_EXPORT_BUILD=1 so a headless - // browser can capture private decks. Deployed builds never set it. - if (unlocked || process.env.NEXT_PUBLIC_EXPORT_BUILD === '1') { + // A link can carry the password in its fragment (#password=...), which the + // browser never sends to the server. `bun run export` captures private + // decks this way. The viewer drops the fragment when it rewrites the URL. + useEffect(() => { + if (passwordFromHash(window.location.hash) === correctPassword) { + setUnlocked(true); + } + }, [correctPassword]); + + if (unlocked) { return <>{children}; } diff --git a/eslint.config.mjs b/eslint.config.mjs index e03b807f..1cd2c927 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,7 +29,6 @@ export default defineConfig([ }, globalIgnores([ '.next/**', - '.next-export/**', 'out/**', 'build/**', 'coverage/**', diff --git a/lib/slide-url.test.ts b/lib/slide-url.test.ts index 0fc92c85..b34acdd5 100644 --- a/lib/slide-url.test.ts +++ b/lib/slide-url.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildSlideUrl, isExportMode } from "./slide-url"; +import { buildSlideUrl, isExportMode, passwordFromHash } from "./slide-url"; describe("buildSlideUrl", () => { it("preserves basePath when on step 0", () => { @@ -72,3 +72,33 @@ describe("isExportMode", () => { ); }); }); + +describe("passwordFromHash", () => { + it("reads and decodes the password from the fragment", () => { + expect(passwordFromHash("#password=abc")).toBe("abc"); + expect(passwordFromHash("#password=a%26b%20c%23d")).toBe("a&b c#d"); + expect(passwordFromHash("password=abc")).toBe("abc"); + }); + + it("round-trips any password that encodeURIComponent wrote", () => { + for (const password of ["", "plain", "a&b=c", "sp ace+plus", "ünï/çode#?%"]) { + const hash = `#password=${encodeURIComponent(password)}`; + expect(passwordFromHash(hash)).toBe(password); + } + }); + + it("returns null without a password key", () => { + expect(passwordFromHash("")).toBeNull(); + expect(passwordFromHash("#")).toBeNull(); + expect(passwordFromHash("#section-2")).toBeNull(); + }); + + it("drops out of the address bar once the viewer rewrites the URL", () => { + const shown = buildSlideUrl( + "http://localhost:3000/slides/deck?slide=2&export=1#password=abc", + 2, + ); + expect(shown).toBe("/slides/deck?slide=2&export=1"); + }); +}); + diff --git a/lib/slide-url.ts b/lib/slide-url.ts index d4a85d19..3473d0bc 100644 --- a/lib/slide-url.ts +++ b/lib/slide-url.ts @@ -23,3 +23,12 @@ export function buildSlideUrl( export function isExportMode(params: Pick): boolean { return params.get("export") === "1"; } + +/** + * Read a deck password from a URL fragment such as `#password=abc`. Browsers + * never send the fragment to the server. Returns null when the fragment has + * no `password` key, and "" for an empty one. + */ +export function passwordFromHash(hash: string): string | null { + return new URLSearchParams(hash.replace(/^#/, "")).get("password"); +} diff --git a/next-env.d.ts b/next-env.d.ts index ed2e9992..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next-export/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts index 51c99815..c66c230c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,9 +2,6 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { basePath: '/slides', - // `bun run export` builds into .next-export so its password-free build - // never replaces the regular .next one. - distDir: process.env.SLIDES_DIST_DIR || '.next', images: { unoptimized: true, }, diff --git a/scripts/export-pdf-chrome.mjs b/scripts/export-pdf-chrome.mjs new file mode 100644 index 00000000..31175c92 --- /dev/null +++ b/scripts/export-pdf-chrome.mjs @@ -0,0 +1,91 @@ +/** + * Headless Chrome, driven only through its command line. Every process it + * starts is tracked, so a caller can stop them all on failure or on a signal. + */ + +import { spawn, spawnSync } from "child_process"; +import { existsSync } from "fs"; + +const BASE_FLAGS = [ + "--headless", + "--disable-gpu", + "--hide-scrollbars", + "--no-first-run", + "--no-default-browser-check", +]; + +const running = new Set(); + +/** Path of a Chrome or Chromium binary, or null when none is installed. */ +export function findChrome() { + if (process.env.CHROME_PATH) return process.env.CHROME_PATH; + const candidates = [ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + "/Applications/Chromium.app/Contents/MacOS/Chromium", + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + for (const name of [ + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + ]) { + const found = spawnSync("which", [name], { encoding: "utf8" }); + if (found.status === 0 && found.stdout.trim()) return found.stdout.trim(); + } + return null; +} + +/** + * Run headless Chrome with `args` and resolve with its stdout. `timeoutMs` is + * a watchdog on the whole process: Chrome is killed and the call rejects when + * it runs out. + */ +export function runChrome(binary, args, { timeoutMs, extraFlags = [] }) { + const flags = [...BASE_FLAGS, ...extraFlags]; + // Chrome refuses to start its sandbox as root, e.g. in some CI containers. + if (process.getuid?.() === 0) flags.push("--no-sandbox"); + return new Promise((resolve, reject) => { + const proc = spawn(binary, [...flags, ...args], { + stdio: ["ignore", "pipe", "pipe"], + }); + running.add(proc); + let stdout = ""; + let stderr = ""; + let timedOut = false; + proc.stdout.on("data", (d) => (stdout += d)); + proc.stderr.on("data", (d) => (stderr += d)); + const timer = setTimeout(() => { + timedOut = true; + proc.kill("SIGKILL"); + }, timeoutMs); + const settle = (fn, value) => { + clearTimeout(timer); + running.delete(proc); + fn(value); + }; + proc.on("error", (e) => settle(reject, e)); + proc.on("close", (code, signal) => { + if (timedOut) { + settle(reject, new Error(`Chrome timed out after ${timeoutMs} ms: ${args.at(-1)}`)); + } else if (code === 0) { + settle(resolve, stdout); + } else { + const reason = signal ? `was stopped by ${signal}` : `exited ${code}`; + settle(reject, new Error(`Chrome ${reason}: ${stderr.trim().split("\n").at(-1)}`)); + } + }); + }); +} + +/** Stop every Chrome this module started that is still running. */ +export function killAllChrome() { + for (const proc of running) proc.kill("SIGKILL"); +} + +/** How many Chrome processes this module started are still running. */ +export function runningChromeCount() { + return running.size; +} diff --git a/scripts/export-pdf-lib.mjs b/scripts/export-pdf-lib.mjs index a8fc7dc9..121c2072 100644 --- a/scripts/export-pdf-lib.mjs +++ b/scripts/export-pdf-lib.mjs @@ -5,19 +5,23 @@ export const USAGE = `Usage: bun run export [output.pdf] [options] - --url Capture a deployed site, e.g. https://www.policyengine.org. - Without it, the script builds the site locally and captures - that build, so private decks and unmerged changes export too. - --size Viewport in CSS pixels, and the PDF page size (default - 1920x1080). A deck laid out for a laptop fills the page - better at 1440x810. - --scale Device pixel ratio of the screenshots, 1 to 4 (default 1). - Use 2 with a smaller --size to keep text sharp. + --url Capture a deployed site, e.g. https://www.policyengine.org. + Without it, the script builds the site locally and captures + that build, so unmerged changes export too. + --size Viewport in CSS pixels, and the PDF page size (default + 1920x1080). A deck laid out for a laptop fills the page + better at 1440x810. + --scale Device pixel ratio of the screenshots, 1 to 4 (default 1). + Use 2 with a smaller --size to keep text sharp. + --site-isolation Keep Chrome's site isolation on. Cross-site embeds may then + come out blank. Use it for a site you do not control. Environment: - CHROME_PATH Chrome or Chromium binary (found automatically if unset) - EXPORT_WAIT_MS virtual time each slide gets to render (default 10000) - EXPORT_CONCURRENCY slides captured at once (default 3)`; + CHROME_PATH Chrome or Chromium binary (found automatically) + EXPORT_WAIT_MS virtual time each slide gets to render, in + milliseconds (default 10000) + EXPORT_CONCURRENCY slides captured at once (default 3) + NEXT_PUBLIC_PRIVATE_PASSWORD password for private decks`; const VALUE_OPTIONS = { "--url": "an origin, e.g. https://www.policyengine.org", @@ -25,6 +29,8 @@ const VALUE_OPTIONS = { "--scale": "a number from 1 to 4", }; +const FLAG_OPTIONS = ["--site-isolation"]; + function parseSize(value) { const match = /^(\d+)x(\d+)$/.exec(value); const width = match ? parseInt(match[1], 10) : 0; @@ -43,10 +49,11 @@ function parseScale(value) { return scale; } -/** Parse ` [output]` plus `--url`, `--size` and `--scale` in any order. */ +/** Parse ` [output]` plus the options in USAGE, in any order. */ export function parseArgs(argv) { const positional = []; const options = {}; + const flags = new Set(); for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "-h" || arg === "--help") return { help: true }; @@ -54,6 +61,10 @@ export function parseArgs(argv) { positional.push(arg); continue; } + if (FLAG_OPTIONS.includes(arg)) { + flags.add(arg); + continue; + } const eq = arg.indexOf("="); const name = eq === -1 ? arg : arg.slice(0, eq); if (!(name in VALUE_OPTIONS)) throw new Error(`Unknown option ${name}`); @@ -75,14 +86,33 @@ export function parseArgs(argv) { url: options["--url"] ? options["--url"].replace(/\/+$/, "") : null, size: parseSize(options["--size"] || "1920x1080"), scale: parseScale(options["--scale"] || "1"), + siteIsolation: flags.has("--site-isolation"), }; } -/** URL of one step in export mode, which hides the navigation bar. */ -export function stepUrl(origin, id, step) { +/** + * Read a whole number setting from the environment. An unset or empty value + * takes `fallback`; anything that is not an integer in [min, max] throws. + */ +export function parseIntSetting(name, value, fallback, { min, max }) { + if (value === undefined || value === "") return fallback; + const parsed = /^\d+$/.test(value) ? parseInt(value, 10) : NaN; + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new Error(`${name} must be a whole number from ${min} to ${max}, got ${value}`); + } + return parsed; +} + +/** + * URL of one step in export mode, which hides the navigation bar. A password + * (even an empty one) rides in the fragment, which unlocks a private deck and + * never reaches the server. + */ +export function stepUrl(origin, id, step, password = null) { const url = new URL(`/slides/${encodeURIComponent(id)}`, origin); url.searchParams.set("slide", String(step)); url.searchParams.set("export", "1"); + if (password !== null) url.hash = `password=${encodeURIComponent(password)}`; return url.toString(); } @@ -98,8 +128,8 @@ function escapeHtml(text) { /** * Read the deck's step count and page title from the DOM that - * `chrome --dump-dom` prints. `gated` is true when the page shows the - * private-deck password form instead. + * `chrome --dump-dom` prints. `gated` is true when the page still shows the + * private-deck password form. */ export function parseDeckDom(dom) { const match = dom.match(/data-total-steps="(\d+)"/); @@ -141,20 +171,45 @@ ${images} `; } -/** Map `items` through async `fn` with at most `limit` running at once. */ -export async function mapLimit(items, limit, fn) { +/** + * Count the pages of a PDF from its bytes. It counts page objects, which + * Chrome writes uncompressed (`/Type /Page`, as opposed to the `/Type /Pages` + * tree nodes). + */ +export function countPdfPages(bytes) { + const text = Buffer.from(bytes).toString("latin1"); + return (text.match(/\/Type\s*\/Page(?![a-zA-Z])/g) || []).length; +} + +/** + * Map `items` through async `fn` with at most `limit` running at once, keeping + * input order. After the first failure it starts no more items, calls + * `onFailure` once (so the caller can cancel what is still running), waits for + * the running items to settle, then rejects with that first error. + */ +export async function mapLimit(items, limit, fn, { onFailure } = {}) { + if (!Number.isInteger(limit) || limit < 1) { + throw new Error(`mapLimit needs a whole number limit of at least 1, got ${limit}`); + } const results = new Array(items.length); let next = 0; + let failed = false; + let firstError; async function worker() { - while (next < items.length) { + while (!failed && next < items.length) { const i = next++; - results[i] = await fn(items[i], i); + try { + results[i] = await fn(items[i], i); + } catch (error) { + if (!failed) { + failed = true; + firstError = error; + onFailure?.(error); + } + } } } - const workers = Array.from( - { length: Math.max(1, Math.min(limit, items.length)) }, - worker, - ); - await Promise.all(workers); + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)); + if (failed) throw firstError; return results; } diff --git a/scripts/export-pdf-lib.test.mjs b/scripts/export-pdf-lib.test.mjs index 84a6eb37..bc6da3f3 100644 --- a/scripts/export-pdf-lib.test.mjs +++ b/scripts/export-pdf-lib.test.mjs @@ -1,11 +1,20 @@ +// @vitest-environment node +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { createServer } from "net"; +import { tmpdir } from "os"; +import path from "path"; +import { pathToFileURL } from "url"; import { describe, it, expect } from "vitest"; +import { findChrome, runChrome, runningChromeCount } from "./export-pdf-chrome.mjs"; import { + countPdfPages, + deckHtml, + mapLimit, parseArgs, - stepUrl, parseDeckDom, + parseIntSetting, shotName, - deckHtml, - mapLimit, + stepUrl, } from "./export-pdf-lib.mjs"; describe("parseArgs", () => { @@ -17,6 +26,7 @@ describe("parseArgs", () => { url: null, size: { width: 1920, height: 1080 }, scale: 1, + siteIsolation: false, }); }); @@ -28,6 +38,7 @@ describe("parseArgs", () => { url: "https://www.policyengine.org", size: { width: 1440, height: 810 }, scale: 2, + siteIsolation: true, }; expect( parseArgs([ @@ -39,11 +50,13 @@ describe("parseArgs", () => { "1440x810", "--scale", "2", + "--site-isolation", ]), ).toEqual(expected); expect( parseArgs([ "--scale=2", + "--site-isolation", "--url=https://www.policyengine.org", "deck", "--size=1440x810", @@ -77,6 +90,28 @@ describe("parseArgs", () => { }); }); +describe("parseIntSetting", () => { + const range = { min: 1, max: 16 }; + + it("takes the fallback when unset or empty", () => { + expect(parseIntSetting("N", undefined, 3, range)).toBe(3); + expect(parseIntSetting("N", "", 3, range)).toBe(3); + }); + + it("reads a whole number in range", () => { + expect(parseIntSetting("N", "1", 3, range)).toBe(1); + expect(parseIntSetting("N", "16", 3, range)).toBe(16); + }); + + it("rejects everything else, so a typo cannot skip the work", () => { + for (const value of ["abc", "0", "17", "-2", "2.5", "3x", " 3", "1e1", "NaN"]) { + expect(() => parseIntSetting("N", value, 3, range)).toThrow( + /N must be a whole number from 1 to 16/, + ); + } + }); +}); + describe("stepUrl", () => { it("puts the step and export mode in the query", () => { expect(stepUrl("http://localhost:4000", "bls-taxsim-2026", 0)).toBe( @@ -86,6 +121,15 @@ describe("stepUrl", () => { "https://www.policyengine.org/slides/deck?slide=35&export=1", ); }); + + it("carries a password only in the fragment, and round-trips it", () => { + for (const password of ["", "plain", "a&b=c", "sp ace+plus", "ünï/çode#?%"]) { + const url = new URL(stepUrl("http://localhost:4000", "deck", 3, password)); + expect(url.search).toBe("?slide=3&export=1"); + expect(new URLSearchParams(url.hash.slice(1)).get("password")).toBe(password); + } + expect(new URL(stepUrl("http://localhost:4000", "deck", 3)).hash).toBe(""); + }); }); describe("parseDeckDom", () => { @@ -121,27 +165,42 @@ describe("shotName", () => { }); describe("deckHtml", () => { - it("lays out one page per screenshot at the viewport size, in order", () => { - const html = deckHtml(["step-0.jpg", "step-1.jpg", "step-2.jpg"], { - width: 1440, - height: 810, - }); + const html = deckHtml(["step-0.jpg", "step-1.jpg", "step-2.jpg"], { + width: 1440, + height: 810, + }); + + it("lists the screenshots in order at the viewport size", () => { expect(html).toContain("@page { size: 1440px 810px; margin: 0; }"); expect(html).toContain("img { width: 1440px; height: 810px;"); expect(html).toContain("Slides"); const srcs = [...html.matchAll(/ m[1]); expect(srcs).toEqual(["step-0.jpg", "step-1.jpg", "step-2.jpg"]); }); -}); -describe("deckHtml title", () => { + it("breaks the page after every screenshot but the last", () => { + expect(html).toMatch(/img \{[^}]*break-after: page;/); + expect(html).toMatch(/img:last-child \{ break-after: auto; \}/); + }); + it("escapes the title that becomes the PDF's document title", () => { - const html = deckHtml(["step-0.jpg"], { + const titled = deckHtml(["step-0.jpg"], { width: 1920, height: 1080, title: "Taxes & ", }); - expect(html).toContain("Taxes & <benefits>"); + expect(titled).toContain("Taxes & <benefits>"); + }); +}); + +describe("countPdfPages", () => { + it("counts page objects and ignores the page tree", () => { + const pdf = + "1 0 obj <> endobj\n" + + "2 0 obj <> endobj\n" + + "3 0 obj <> endobj\n"; + expect(countPdfPages(Buffer.from(pdf, "latin1"))).toBe(2); + expect(countPdfPages(Buffer.from("%PDF-1.4\n", "latin1"))).toBe(0); }); }); @@ -163,4 +222,100 @@ describe("mapLimit", () => { it("handles an empty list", async () => { expect(await mapLimit([], 3, async (x) => x)).toEqual([]); }); + + it("rejects a limit that would run nothing", async () => { + for (const limit of [0, -1, 1.5, NaN, Infinity, "3"]) { + await expect(mapLimit([1, 2], limit, async (x) => x)).rejects.toThrow( + /whole number limit/, + ); + } + }); + + it("after a failure starts nothing new, waits for running items, and reports the first error", async () => { + const started = []; + const finished = []; + let failures = 0; + const run = mapLimit( + [0, 1, 2, 3, 4, 5], + 2, + async (n) => { + started.push(n); + await new Promise((r) => setTimeout(r, n === 0 ? 5 : 30)); + if (n === 0) throw new Error("first"); + finished.push(n); + if (n === 1) throw new Error("second"); + }, + { onFailure: () => failures++ }, + ); + await expect(run).rejects.toThrow("first"); + // Items 0 and 1 were running when 0 failed. Nothing after them started, + // item 1 ran to its end before mapLimit settled, and onFailure ran once. + expect(started).toEqual([0, 1]); + expect(finished).toEqual([1]); + expect(failures).toBe(1); + }); +}); + +// Chrome prints the page it is given; this checks the one property the unit +// tests cannot: one PDF page per screenshot, at the requested page size. +const chrome = findChrome(); +// A CI runner's Chrome may lack a usable sandbox; the page printed here is a +// local file this test wrote. +const ciFlags = process.env.CI ? ["--no-sandbox"] : []; +// A 1 × 1 white PNG. +const PIXEL = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC", + "base64", +); + +describe.skipIf(!chrome)("printing with Chrome", () => { + it("writes one page per screenshot at the viewport size", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "export-pdf-test-")); + try { + const names = Array.from({ length: 5 }, (_, i) => shotName(i, 5)); + for (const name of names) writeFileSync(path.join(dir, name), PIXEL); + const page = path.join(dir, "deck.html"); + writeFileSync(page, deckHtml(names, { width: 1440, height: 810, title: "Test" })); + const pdf = path.join(dir, "deck.pdf"); + await runChrome( + chrome, + ["--no-pdf-header-footer", `--print-to-pdf=${pdf}`, pathToFileURL(page).href], + { timeoutMs: 90_000, extraFlags: ciFlags }, + ); + const bytes = readFileSync(pdf); + expect(countPdfPages(bytes)).toBe(5); + // 1440 × 810 px at 96 px per inch is 1080 × 607.5 pt. + const box = /\/MediaBox\s*\[\s*0\s+0\s+([\d.]+)\s+([\d.]+)\s*\]/.exec( + bytes.toString("latin1"), + ); + expect(Number(box[1])).toBeCloseTo(1080, 0); + expect(Math.abs(Number(box[2]) - 607.5)).toBeLessThan(1); + expect(runningChromeCount()).toBe(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }, 120_000); + + it("stops a Chrome that outlives the watchdog and stops tracking it", async () => { + // A server that accepts the connection and never answers, so the page + // load cannot finish. + const sockets = new Set(); + const silent = createServer((socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + await new Promise((resolve) => silent.listen(0, "127.0.0.1", resolve)); + try { + const url = `http://127.0.0.1:${silent.address().port}/`; + const hang = runChrome(chrome, ["--dump-dom", url], { + timeoutMs: 2_000, + extraFlags: ciFlags, + }); + await expect(hang).rejects.toThrow(/Chrome timed out after 2000 ms/); + expect(runningChromeCount()).toBe(0); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => silent.close(resolve)); + } + }, 60_000); }); diff --git a/scripts/export-pdf.mjs b/scripts/export-pdf.mjs index 2dea7450..e76475fb 100644 --- a/scripts/export-pdf.mjs +++ b/scripts/export-pdf.mjs @@ -12,94 +12,74 @@ * bun run export bls-taxsim-2026 bls.pdf --size 1440x810 --scale 2 * * What it does: - * 1. Without --url, builds into .next-export with NEXT_PUBLIC_EXPORT_BUILD=1 - * (private decks skip the password gate) and serves it on a free port. + * 1. Without --url, runs `next build` and serves the build on a free port. * 2. Reads the step count from the viewer's data-total-steps attribute, - * using `chrome --dump-dom`. - * 3. Screenshots every step at ?slide=N&export=1, which hides the nav bar. + * using `chrome --dump-dom`. A private deck is unlocked with + * NEXT_PUBLIC_PRIVATE_PASSWORD, passed in the URL fragment. + * 3. Screenshots every step at ?slide=N&export=1, which hides the nav bar + * and mounts only that slide. * 4. Prints a page of the screenshots to a PDF with `chrome --print-to-pdf`, - * one page per step at the viewport size. + * one page per step at the viewport size, and checks the page count. * * Chrome runs only through its command line. The script never opens a * DevTools connection (no puppeteer, no remote debugging), so it also works * where an administrator has disabled remote debugging. + * + * Captures run with Chrome's site isolation off, so that live cross-site + * embeds render (see `render` below). Pass --site-isolation to keep it on + * when you capture a site you do not control. */ import { spawn, spawnSync } from "child_process"; -import { existsSync, mkdtempSync, rmSync, statSync, writeFileSync } from "fs"; +import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; import { createServer } from "net"; import { tmpdir } from "os"; import path from "path"; import { pathToFileURL } from "url"; +import { findChrome, killAllChrome, runChrome } from "./export-pdf-chrome.mjs"; import { USAGE, + countPdfPages, deckHtml, mapLimit, parseArgs, parseDeckDom, + parseIntSetting, shotName, stepUrl, } from "./export-pdf-lib.mjs"; -const WAIT_MS = parseInt(process.env.EXPORT_WAIT_MS || "10000", 10); -const CONCURRENCY = parseInt(process.env.EXPORT_CONCURRENCY || "3", 10); -const DIST_DIR = ".next-export"; +const ATTEMPTS = 3; + +// ── Cleanup ────────────────────────────────────────────────────────── +// Everything the script starts or writes is registered here, so a failure +// or a signal stops Chrome and the server and removes the screenshots +// (which can show a private deck). -// ── Chrome ─────────────────────────────────────────────────────────── +let work = null; +let server = null; +let stopping = false; -function findChrome() { - if (process.env.CHROME_PATH) return process.env.CHROME_PATH; - const candidates = [ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", - "/Applications/Chromium.app/Contents/MacOS/Chromium", - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate; +function cleanup() { + stopping = true; + killAllChrome(); + if (server) { + server.kill(); + server = null; } - for (const name of [ - "google-chrome", - "google-chrome-stable", - "chromium", - "chromium-browser", - ]) { - const found = spawnSync("which", [name], { encoding: "utf8" }); - if (found.status === 0 && found.stdout.trim()) return found.stdout.trim(); + if (work) { + rmSync(work, { recursive: true, force: true }); + work = null; } - throw new Error("Chrome not found. Set CHROME_PATH to a Chrome or Chromium binary."); } -/** Run headless Chrome with `args`; resolve with stdout. */ -function chrome(binary, args, { timeoutMs = WAIT_MS + 60_000 } = {}) { - const flags = [ - "--headless", - "--disable-gpu", - "--hide-scrollbars", - "--no-first-run", - "--no-default-browser-check", - ]; - // Chrome refuses to start its sandbox as root, e.g. in some CI containers. - if (process.getuid?.() === 0) flags.push("--no-sandbox"); - return new Promise((resolve, reject) => { - const proc = spawn(binary, [...flags, ...args], { - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - proc.stdout.on("data", (d) => (stdout += d)); - proc.stderr.on("data", (d) => (stderr += d)); - const timer = setTimeout(() => { - proc.kill("SIGKILL"); - reject(new Error(`Chrome timed out after ${timeoutMs} ms: ${args.at(-1)}`)); - }, timeoutMs); - proc.on("error", (e) => { - clearTimeout(timer); - reject(e); - }); - proc.on("close", (code) => { - clearTimeout(timer); - if (code === 0) resolve(stdout); - else reject(new Error(`Chrome exited ${code}: ${stderr.trim().split("\n").at(-1)}`)); - }); +for (const [signal, code] of [ + ["SIGINT", 130], + ["SIGTERM", 143], +]) { + process.once(signal, () => { + cleanup(); + process.exit(code); }); } @@ -117,36 +97,36 @@ function getFreePort() { } const nextBin = path.join(process.cwd(), "node_modules", ".bin", "next"); -const exportEnv = { - ...process.env, - SLIDES_DIST_DIR: DIST_DIR, - NEXT_PUBLIC_EXPORT_BUILD: "1", -}; function build() { - console.log(`Building into ${DIST_DIR}...`); - const result = spawnSync(nextBin, ["build"], { stdio: "inherit", env: exportEnv }); + console.log("Building..."); + const result = spawnSync(nextBin, ["build"], { stdio: "inherit" }); + if (result.error) throw result.error; if (result.status !== 0) throw new Error(`Build failed (exit ${result.status})`); } +/** Start `next start` on `port` and resolve with its origin once it answers. */ async function startServer(port) { - const proc = spawn(nextBin, ["start", "-p", String(port)], { - stdio: "ignore", - env: exportEnv, - }); + const proc = spawn(nextBin, ["start", "-p", String(port)], { stdio: "ignore" }); + server = proc; + let spawnError = null; + proc.on("error", (e) => (spawnError = e)); const origin = `http://localhost:${port}`; const deadline = Date.now() + 60_000; while (Date.now() < deadline) { + if (spawnError) throw spawnError; if (proc.exitCode !== null) throw new Error(`Server exited ${proc.exitCode}`); try { - const res = await fetch(`${origin}/slides`); - if (res.status < 500) return { proc, origin }; + const remaining = Math.max(1, deadline - Date.now()); + const res = await fetch(`${origin}/slides`, { + signal: AbortSignal.timeout(Math.min(5_000, remaining)), + }); + if (res.status < 500) return origin; } catch { - // not listening yet + // not listening yet, or this request timed out } await new Promise((r) => setTimeout(r, 500)); } - proc.kill(); throw new Error("Server did not respond within 60 s"); } @@ -154,8 +134,20 @@ async function startServer(port) { async function main() { let args; + let waitMs; + let concurrency; try { args = parseArgs(process.argv.slice(2)); + waitMs = parseIntSetting("EXPORT_WAIT_MS", process.env.EXPORT_WAIT_MS, 10_000, { + min: 0, + max: 600_000, + }); + concurrency = parseIntSetting( + "EXPORT_CONCURRENCY", + process.env.EXPORT_CONCURRENCY, + 3, + { min: 1, max: 16 }, + ); } catch (e) { console.error(`${e.message}\n\n${USAGE}`); process.exit(1); @@ -166,90 +158,123 @@ async function main() { } const binary = findChrome(); + if (!binary) { + throw new Error("Chrome not found. Set CHROME_PATH to a Chrome or Chromium binary."); + } const output = path.resolve(args.output); - const work = mkdtempSync(path.join(tmpdir(), `export-${args.id}-`)); - let server = null; - try { - let origin = args.url; - if (!origin) { - build(); - server = await startServer(await getFreePort()); - origin = server.origin; - } + let origin = args.url; + if (!origin) { + build(); + origin = await startServer(await getFreePort()); + } + work = mkdtempSync(path.join(tmpdir(), `export-${args.id}-`)); - // Chrome's virtual time budget covers only the page's own process. With - // site isolation on, a cross-site embed (a live app in an iframe) runs in - // another process and is still blank when the budget ends, so keep - // embeds in the page's process while capturing. - const render = [ + const render = { + // The watchdog covers a whole capture, including a Chrome that hangs. + timeoutMs: waitMs + 60_000, + extraFlags: [ `--window-size=${args.size.width},${args.size.height}`, `--force-device-scale-factor=${args.scale}`, - `--virtual-time-budget=${WAIT_MS}`, - // Virtual time stops while a request is pending, so one stalled request - // would hold the capture forever. Cap the wait in real time as well. - `--timeout=${WAIT_MS + 30_000}`, - "--disable-site-isolation-trials", - "--disable-features=IsolateOrigins,site-per-process", - ]; - - // 1. Count the steps. - const firstUrl = stepUrl(origin, args.id, 0); - console.log(`Reading ${firstUrl}`); - const { total, gated, title } = parseDeckDom( - await chrome(binary, [...render, "--dump-dom", firstUrl]), + // Time the page gets to render after it loads. + `--virtual-time-budget=${waitMs}`, + // Cap on the initial page load, so a stalled request cannot hold it. + `--timeout=${waitMs + 30_000}`, + // The virtual time budget covers only the page's own process. With + // site isolation on, a cross-site embed (a live app in an iframe) runs + // in another process and is still blank when the budget ends. + ...(args.siteIsolation + ? [] + : [ + "--disable-site-isolation-trials", + "--disable-features=IsolateOrigins,site-per-process", + ]), + ], + }; + + // 1. Count the steps, unlocking the deck if it is private. + console.log(`Reading ${stepUrl(origin, args.id, 0)}`); + let password = null; + let deck = parseDeckDom( + await runChrome(binary, ["--dump-dom", stepUrl(origin, args.id, 0)], render), + ); + if (deck.gated) { + password = process.env.NEXT_PUBLIC_PRIVATE_PASSWORD ?? ""; + deck = parseDeckDom( + await runChrome( + binary, + ["--dump-dom", stepUrl(origin, args.id, 0, password)], + render, + ), ); - if (gated) { - throw new Error( - "This deck is private. Export it from a local build by leaving out --url.", - ); - } - if (!total) { + if (deck.gated) { throw new Error( - "No data-total-steps on the page. Is the slideshow id right, and does the site include export mode?", + "This deck is private. Set NEXT_PUBLIC_PRIVATE_PASSWORD to its password.", ); } - console.log(`${total} steps`); + } + const { total, title } = deck; + if (!total) { + throw new Error( + "No data-total-steps on the page. Is the slideshow id right, and does the site include export mode?", + ); + } + console.log(`${total} steps`); - // 2. Screenshot each step. - const names = Array.from({ length: total }, (_, i) => shotName(i, total)); - let done = 0; - await mapLimit(names, CONCURRENCY, async (name, step) => { + // 2. Screenshot each step. A failure stops the other captures. + const names = Array.from({ length: total }, (_, i) => shotName(i, total)); + let done = 0; + await mapLimit( + names, + concurrency, + async (name, step) => { const file = path.join(work, name); for (let attempt = 1; ; attempt++) { try { - await chrome(binary, [ - ...render, - `--screenshot=${file}`, - stepUrl(origin, args.id, step), - ]); + await runChrome( + binary, + [`--screenshot=${file}`, stepUrl(origin, args.id, step, password)], + render, + ); if (statSync(file).size === 0) throw new Error("empty screenshot"); break; } catch (e) { - if (attempt === 3) throw new Error(`Step ${step + 1}: ${e.message}`); + if (stopping || attempt === ATTEMPTS) { + throw new Error(`Step ${step + 1}: ${e.message}`); + } console.log(` step ${step + 1} attempt ${attempt} failed (${e.message}); retrying`); } } done++; console.log(`Captured ${done}/${total}`); - }); + }, + { + onFailure: () => { + stopping = true; + killAllChrome(); + }, + }, + ); - // 3. Print the screenshots to a PDF. - const page = path.join(work, "deck.html"); - writeFileSync(page, deckHtml(names, { ...args.size, title })); - await chrome(binary, [ - "--no-pdf-header-footer", - `--print-to-pdf=${output}`, - pathToFileURL(page).href, - ]); - console.log(`PDF saved to ${output}`); - } finally { - if (server) server.proc.kill(); - rmSync(work, { recursive: true, force: true }); + // 3. Print the screenshots to a PDF and check that every step has a page. + const page = path.join(work, "deck.html"); + writeFileSync(page, deckHtml(names, { ...args.size, title })); + await runChrome( + binary, + ["--no-pdf-header-footer", `--print-to-pdf=${output}`, pathToFileURL(page).href], + { timeoutMs: 120_000 }, + ); + const pages = countPdfPages(readFileSync(output)); + if (pages !== total) { + throw new Error(`The PDF has ${pages} pages for ${total} steps: ${output}`); } + console.log(`PDF saved to ${output} (${pages} pages)`); } -main().catch((e) => { - console.error(e.message || e); - process.exit(1); -}); +main() + .then(() => cleanup()) + .catch((e) => { + cleanup(); + console.error(e.message || e); + process.exit(1); + }); diff --git a/tsconfig.json b/tsconfig.json index 396d0194..803331cc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -33,9 +33,7 @@ "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - ".next-export/types/**/*.ts", - ".next-export/dev/types/**/*.ts" + ".next/dev/types/**/*.ts" ], "exclude": [ "node_modules" From e3856d120ee8e403c9168896b7439a757f912095 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 12:38:01 -0400 Subject: [PATCH 05/10] Retry failed captures alone, with twice the time On a machine under heavy load, three live embeds rendering at once outran the watchdog: one run failed on the CliffWatch, dashboard and axiom.org slides, which each capture in 2 to 8 seconds alone. A step that fails in the parallel pass is now retried afterwards on its own with a doubled watchdog and page-load cap, up to three attempts in all. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- scripts/export-pdf-lib.mjs | 8 ++-- scripts/export-pdf-lib.test.mjs | 23 ++++------- scripts/export-pdf.mjs | 71 +++++++++++++++++++-------------- 4 files changed, 53 insertions(+), 51 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 62f604bb..d17a2ea0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ bun run export [output.pdf] [--url ] # Export slideshow to PDF (he - Private decks: set `NEXT_PUBLIC_PRIVATE_PASSWORD`. The script passes it in the URL fragment (`#password=...`), which `PasswordGate` accepts and the browser never sends to the server. A local build also needs the variable at build time, as any build of a private deck does. - Cross-site embeds render because captures run with Chrome's site isolation off, which keeps an iframe in the page's process where the render budget applies. Pass `--site-isolation` to keep it on when you capture a site you do not control; embeds may then come out blank. - `EXPORT_WAIT_MS` (default 10000) sets how much virtual time each slide gets to render after it loads; raise it for slides with slow embeds or long intro animations. `EXPORT_CONCURRENCY` (default 3) sets how many slides are captured at once. `CHROME_PATH` overrides the Chrome binary. -- A watchdog stops a Chrome that hangs, and each step gets three attempts. A failure or Ctrl-C stops every Chrome the script started, stops the server and removes the screenshots. +- A watchdog stops a Chrome that hangs. A step that fails in the parallel pass is retried afterwards on its own with twice the time, up to three attempts in all. A failure or Ctrl-C stops every Chrome the script started, stops the server and removes the screenshots. - The script checks that the PDF has one page per step before it reports success. ## Adding a new slideshow diff --git a/scripts/export-pdf-lib.mjs b/scripts/export-pdf-lib.mjs index 121c2072..71da337a 100644 --- a/scripts/export-pdf-lib.mjs +++ b/scripts/export-pdf-lib.mjs @@ -183,11 +183,10 @@ export function countPdfPages(bytes) { /** * Map `items` through async `fn` with at most `limit` running at once, keeping - * input order. After the first failure it starts no more items, calls - * `onFailure` once (so the caller can cancel what is still running), waits for - * the running items to settle, then rejects with that first error. + * input order. After the first failure it starts no more items, waits for the + * running items to settle, then rejects with that first error. */ -export async function mapLimit(items, limit, fn, { onFailure } = {}) { +export async function mapLimit(items, limit, fn) { if (!Number.isInteger(limit) || limit < 1) { throw new Error(`mapLimit needs a whole number limit of at least 1, got ${limit}`); } @@ -204,7 +203,6 @@ export async function mapLimit(items, limit, fn, { onFailure } = {}) { if (!failed) { failed = true; firstError = error; - onFailure?.(error); } } } diff --git a/scripts/export-pdf-lib.test.mjs b/scripts/export-pdf-lib.test.mjs index bc6da3f3..c7906401 100644 --- a/scripts/export-pdf-lib.test.mjs +++ b/scripts/export-pdf-lib.test.mjs @@ -234,25 +234,18 @@ describe("mapLimit", () => { it("after a failure starts nothing new, waits for running items, and reports the first error", async () => { const started = []; const finished = []; - let failures = 0; - const run = mapLimit( - [0, 1, 2, 3, 4, 5], - 2, - async (n) => { - started.push(n); - await new Promise((r) => setTimeout(r, n === 0 ? 5 : 30)); - if (n === 0) throw new Error("first"); - finished.push(n); - if (n === 1) throw new Error("second"); - }, - { onFailure: () => failures++ }, - ); + const run = mapLimit([0, 1, 2, 3, 4, 5], 2, async (n) => { + started.push(n); + await new Promise((r) => setTimeout(r, n === 0 ? 5 : 30)); + if (n === 0) throw new Error("first"); + finished.push(n); + if (n === 1) throw new Error("second"); + }); await expect(run).rejects.toThrow("first"); // Items 0 and 1 were running when 0 failed. Nothing after them started, - // item 1 ran to its end before mapLimit settled, and onFailure ran once. + // and item 1 ran to its end before mapLimit settled. expect(started).toEqual([0, 1]); expect(finished).toEqual([1]); - expect(failures).toBe(1); }); }); diff --git a/scripts/export-pdf.mjs b/scripts/export-pdf.mjs index e76475fb..f6cabe5a 100644 --- a/scripts/export-pdf.mjs +++ b/scripts/export-pdf.mjs @@ -221,40 +221,51 @@ async function main() { } console.log(`${total} steps`); - // 2. Screenshot each step. A failure stops the other captures. + // 2. Screenshot each step. A step that fails in the parallel pass is + // retried afterwards on its own with twice the time: on a busy machine, + // several live embeds rendering at once can outrun the watchdog. const names = Array.from({ length: total }, (_, i) => shotName(i, total)); + const capture = async (step, options) => { + const file = path.join(work, names[step]); + await runChrome( + binary, + [`--screenshot=${file}`, stepUrl(origin, args.id, step, password)], + options, + ); + if (statSync(file).size === 0) throw new Error("empty screenshot"); + }; let done = 0; - await mapLimit( - names, - concurrency, - async (name, step) => { - const file = path.join(work, name); - for (let attempt = 1; ; attempt++) { - try { - await runChrome( - binary, - [`--screenshot=${file}`, stepUrl(origin, args.id, step, password)], - render, - ); - if (statSync(file).size === 0) throw new Error("empty screenshot"); - break; - } catch (e) { - if (stopping || attempt === ATTEMPTS) { - throw new Error(`Step ${step + 1}: ${e.message}`); - } - console.log(` step ${step + 1} attempt ${attempt} failed (${e.message}); retrying`); + const retry = []; + await mapLimit(names, concurrency, async (_, step) => { + try { + await capture(step, render); + console.log(`Captured ${++done}/${total}`); + } catch (e) { + if (stopping) throw e; + console.log(` step ${step + 1} failed (${e.message}); will retry alone`); + retry.push(step); + } + }); + const patient = { + timeoutMs: render.timeoutMs * 2, + extraFlags: render.extraFlags.map((flag) => + flag.startsWith("--timeout=") ? `--timeout=${(waitMs + 30_000) * 2}` : flag, + ), + }; + for (const step of retry.sort((a, b) => a - b)) { + for (let attempt = 2; ; attempt++) { + try { + await capture(step, patient); + console.log(`Captured ${++done}/${total} (step ${step + 1}, attempt ${attempt})`); + break; + } catch (e) { + if (stopping || attempt === ATTEMPTS) { + throw new Error(`Step ${step + 1}: ${e.message}`); } + console.log(` step ${step + 1} attempt ${attempt} failed (${e.message}); retrying`); } - done++; - console.log(`Captured ${done}/${total}`); - }, - { - onFailure: () => { - stopping = true; - killAllChrome(); - }, - }, - ); + } + } // 3. Print the screenshots to a PDF and check that every step has a page. const page = path.join(work, "deck.html"); From 63cbea890f0a61a9b82eb10825c89d802a8d9ff1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 13:03:14 -0400 Subject: [PATCH 06/10] Capture at a held load event instead of on Chrome's virtual clock Chrome's virtual time budget pauses while any request is pending, and it covers a cross-site iframe only with site isolation off. Captures of slides with live embeds timed out repeatedly, and nothing capped the wait. Export mode now holds the page's load event: an inline script in opens a document in a hidden iframe, and the viewer releases the hold `settle` milliseconds after the slide mounts. Chrome captures at load, in real time, with site isolation on. When --timeout runs out, Chrome stops the page load, which ends the hold, so a capture cannot wait forever. The --site-isolation option and the virtual time flags are gone. EXPORT_WAIT_MS is now the settle time (default 8000 ms). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 4 +- app/layout.tsx | 5 ++ components/core/SlideshowViewer.tsx | 11 +++- lib/export-hold.ts | 58 +++++++++++++++++++++ lib/slide-url.test.ts | 24 ++++++++- lib/slide-url.ts | 14 +++++ scripts/export-pdf-lib.mjs | 41 +++++++-------- scripts/export-pdf-lib.test.mjs | 79 ++++++++++++++++++++++++++--- scripts/export-pdf.mjs | 57 +++++++-------------- 9 files changed, 221 insertions(+), 72 deletions(-) create mode 100644 lib/export-hold.ts diff --git a/CLAUDE.md b/CLAUDE.md index d17a2ea0..3d8c3bc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,11 +51,11 @@ bun run export [output.pdf] [--url ] # Export slideshow to PDF (he `bun run export` drives headless Chrome through its command line only (`--dump-dom`, `--screenshot`, `--print-to-pdf`), never through a DevTools connection, so it works where remote debugging is disabled. - `?slide=N&export=1` renders step N without the navigation bar and mounts only that slide (no neighbors); the viewer's `
` carries `data-total-steps`. +- Chrome captures a page when its `load` event fires. With `&settle=`, export mode holds that event until the slide has been in the DOM for that many milliseconds of real time, so live embeds have rendered (`lib/export-hold.ts`: an inline script in `` opens the hold, and the viewer releases it once mounted). Chrome's `--timeout` ends a hold that outlasts it, so a capture cannot wait forever. - Without `--url`, the script runs `next build`, serves the build on a free port and captures it, so unmerged changes export too. With `--url https://www.policyengine.org`, it captures the deployed site. - `--size 1440x810 --scale 2` suits decks laid out for a laptop: the content fills the page and text stays sharp. The default is `1920x1080` at scale 1. - Private decks: set `NEXT_PUBLIC_PRIVATE_PASSWORD`. The script passes it in the URL fragment (`#password=...`), which `PasswordGate` accepts and the browser never sends to the server. A local build also needs the variable at build time, as any build of a private deck does. -- Cross-site embeds render because captures run with Chrome's site isolation off, which keeps an iframe in the page's process where the render budget applies. Pass `--site-isolation` to keep it on when you capture a site you do not control; embeds may then come out blank. -- `EXPORT_WAIT_MS` (default 10000) sets how much virtual time each slide gets to render after it loads; raise it for slides with slow embeds or long intro animations. `EXPORT_CONCURRENCY` (default 3) sets how many slides are captured at once. `CHROME_PATH` overrides the Chrome binary. +- `EXPORT_WAIT_MS` (default 8000) is the settle time; raise it for slow embeds or long intro animations. `EXPORT_CONCURRENCY` (default 3) sets how many slides are captured at once. `CHROME_PATH` overrides the Chrome binary. - A watchdog stops a Chrome that hangs. A step that fails in the parallel pass is retried afterwards on its own with twice the time, up to three attempts in all. A failure or Ctrl-C stops every Chrome the script started, stops the server and removes the screenshots. - The script checks that the PDF has one page per step before it reports success. diff --git a/app/layout.tsx b/app/layout.tsx index f023c211..58f8c58f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import './globals.css'; +import { EXPORT_HOLD_SCRIPT } from '@/lib/export-hold'; export const metadata: Metadata = { metadataBase: new URL('https://policyengine.org'), @@ -17,6 +18,10 @@ export default function RootLayout({ }) { return ( + + {/* Runs before load can fire; it acts only on ?export=1&settle=. */} +

parsed

`; + + const dump = async (html, query, timeout) => { + const dir = mkdtempSync(path.join(tmpdir(), "export-hold-test-")); + try { + const page = path.join(dir, "page.html"); + writeFileSync(page, html); + return await runChrome( + chrome, + [`--timeout=${timeout}`, "--dump-dom", `${pathToFileURL(page).href}${query}`], + { timeoutMs: 120_000, extraFlags: ciFlags }, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + const loadedAt = (dom) => { + const match = /

(\d+)<\/p>/.exec(dom); + return match ? Number(match[1]) : null; + }; + + it("holds load until the settle time after the release has passed", async () => { + const dom = await dump(holdPage(1500), "?export=1&settle=1500", 60_000); + expect(dom).toContain('

mounted

'); + // Released 300 ms in, then 1500 ms of settle. + expect(loadedAt(dom)).toBeGreaterThanOrEqual(1800); + }, 150_000); + + it("leaves load alone without export=1 or without a settle time", async () => { + for (const query of ["", "?settle=1500", "?export=1", "?export=1&settle=0"]) { + const dom = await dump(holdPage(1500), query, 60_000); + expect(loadedAt(dom)).not.toBeNull(); + expect(loadedAt(dom)).toBeLessThan(1000); + } + }, 300_000); + + it("lets Chrome's --timeout end a hold that is never released", async () => { + // Nothing releases this hold, so alone it would last MAX_HOLD_MS (two + // minutes). When --timeout runs out, Chrome stops the page load, which + // ends the hold and fires load. + const dom = await dump(holdPage(0), "?export=1&settle=5000", 2_000); + expect(dom).toContain('

parsed

'); + expect(loadedAt(dom)).toBeGreaterThanOrEqual(1000); + expect(loadedAt(dom)).toBeLessThan(60_000); + }, 150_000); + it("stops a Chrome that outlives the watchdog and stops tracking it", async () => { // A server that accepts the connection and never answers, so the page // load cannot finish. diff --git a/scripts/export-pdf.mjs b/scripts/export-pdf.mjs index f6cabe5a..bcd3614b 100644 --- a/scripts/export-pdf.mjs +++ b/scripts/export-pdf.mjs @@ -16,18 +16,16 @@ * 2. Reads the step count from the viewer's data-total-steps attribute, * using `chrome --dump-dom`. A private deck is unlocked with * NEXT_PUBLIC_PRIVATE_PASSWORD, passed in the URL fragment. - * 3. Screenshots every step at ?slide=N&export=1, which hides the nav bar - * and mounts only that slide. + * 3. Screenshots every step at ?slide=N&export=1&settle=. Export mode + * hides the nav bar, mounts only that slide, and holds the page's load + * event until the slide has been in the DOM for of real time, so + * live embeds have rendered when Chrome captures at load. * 4. Prints a page of the screenshots to a PDF with `chrome --print-to-pdf`, * one page per step at the viewport size, and checks the page count. * * Chrome runs only through its command line. The script never opens a * DevTools connection (no puppeteer, no remote debugging), so it also works * where an administrator has disabled remote debugging. - * - * Captures run with Chrome's site isolation off, so that live cross-site - * embeds render (see `render` below). Pass --site-isolation to keep it on - * when you capture a site you do not control. */ import { spawn, spawnSync } from "child_process"; @@ -138,9 +136,9 @@ async function main() { let concurrency; try { args = parseArgs(process.argv.slice(2)); - waitMs = parseIntSetting("EXPORT_WAIT_MS", process.env.EXPORT_WAIT_MS, 10_000, { + waitMs = parseIntSetting("EXPORT_WAIT_MS", process.env.EXPORT_WAIT_MS, 8_000, { min: 0, - max: 600_000, + max: 60_000, }); concurrency = parseIntSetting( "EXPORT_CONCURRENCY", @@ -170,42 +168,29 @@ async function main() { } work = mkdtempSync(path.join(tmpdir(), `export-${args.id}-`)); + // Chrome captures when the page's load event fires, and export mode holds + // that event until the slide has settled. When --timeout runs out, Chrome + // stops the page load and captures the page as it stands. The watchdog + // covers the whole process, including a Chrome that hangs. const render = { - // The watchdog covers a whole capture, including a Chrome that hangs. - timeoutMs: waitMs + 60_000, + timeoutMs: waitMs + 120_000, extraFlags: [ `--window-size=${args.size.width},${args.size.height}`, `--force-device-scale-factor=${args.scale}`, - // Time the page gets to render after it loads. - `--virtual-time-budget=${waitMs}`, - // Cap on the initial page load, so a stalled request cannot hold it. `--timeout=${waitMs + 30_000}`, - // The virtual time budget covers only the page's own process. With - // site isolation on, a cross-site embed (a live app in an iframe) runs - // in another process and is still blank when the budget ends. - ...(args.siteIsolation - ? [] - : [ - "--disable-site-isolation-trials", - "--disable-features=IsolateOrigins,site-per-process", - ]), ], }; + const at = (step, password = null) => + stepUrl(origin, args.id, step, { settle: waitMs, password }); // 1. Count the steps, unlocking the deck if it is private. - console.log(`Reading ${stepUrl(origin, args.id, 0)}`); + console.log(`Reading ${at(0)}`); let password = null; - let deck = parseDeckDom( - await runChrome(binary, ["--dump-dom", stepUrl(origin, args.id, 0)], render), - ); + let deck = parseDeckDom(await runChrome(binary, ["--dump-dom", at(0)], render)); if (deck.gated) { password = process.env.NEXT_PUBLIC_PRIVATE_PASSWORD ?? ""; deck = parseDeckDom( - await runChrome( - binary, - ["--dump-dom", stepUrl(origin, args.id, 0, password)], - render, - ), + await runChrome(binary, ["--dump-dom", at(0, password)], render), ); if (deck.gated) { throw new Error( @@ -222,16 +207,12 @@ async function main() { console.log(`${total} steps`); // 2. Screenshot each step. A step that fails in the parallel pass is - // retried afterwards on its own with twice the time: on a busy machine, - // several live embeds rendering at once can outrun the watchdog. + // retried afterwards on its own with twice the time, since a busy machine + // can make several Chromes at once outrun the watchdog. const names = Array.from({ length: total }, (_, i) => shotName(i, total)); const capture = async (step, options) => { const file = path.join(work, names[step]); - await runChrome( - binary, - [`--screenshot=${file}`, stepUrl(origin, args.id, step, password)], - options, - ); + await runChrome(binary, [`--screenshot=${file}`, at(step, password)], options); if (statSync(file).size === 0) throw new Error("empty screenshot"); }; let done = 0; From 14d2c067618d5b51bdad963c21629300e1df59b9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 14 Oct 2026 11:42:32 -0400 Subject: [PATCH 07/10] Address second review: verify each capture, redact passwords, fix the hold The second review of 63cbea8 asked for changes. - The hold script's regex sat in a template literal, which dropped its backslash: /^\d+$/ became /^d+$/, so no hold ever opened. It now uses [0-9], and jsdom tests run the generated script, so this is caught without Chrome. - A capture could record a slide that never mounted, since Chrome's --timeout captures whatever is there. A hold ended by a release now marks with data-export-settled, the viewer exposes data-current-step, and each capture dumps its DOM; the script rejects and retries a capture that is not the requested step, settled. settle=0 still waits for the slide to mount. - Chrome error messages included the capture URL with its #password fragment. They now drop fragments and password values. - The local build ran through spawnSync, which blocked the signal handlers. It now runs asynchronously and cleanup stops it. - The release test passes only if the release ends the hold well before Chrome's timeout, and the ordinary-page test checks that no hold opened instead of timing load. - The PDF page count skips string literals, streams and comments, so a title like (/Type /Page) cannot add a page. - A locked password gate releases the hold at once, so a capture sees the form without waiting for Chrome's timeout. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 9 +- components/core/PasswordGate.tsx | 5 + components/core/SlideshowViewer.tsx | 1 + lib/export-hold.test.ts | 98 ++++++++++++++++++ lib/export-hold.ts | 21 ++-- lib/slide-url.ts | 2 +- scripts/export-pdf-chrome.mjs | 16 ++- scripts/export-pdf-lib.mjs | 98 +++++++++++++++--- scripts/export-pdf-lib.test.mjs | 155 +++++++++++++++++++++++----- scripts/export-pdf.mjs | 40 +++++-- 10 files changed, 377 insertions(+), 68 deletions(-) create mode 100644 lib/export-hold.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3d8c3bc7..d79de30d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,13 +50,14 @@ bun run export [output.pdf] [--url ] # Export slideshow to PDF (he `bun run export` drives headless Chrome through its command line only (`--dump-dom`, `--screenshot`, `--print-to-pdf`), never through a DevTools connection, so it works where remote debugging is disabled. -- `?slide=N&export=1` renders step N without the navigation bar and mounts only that slide (no neighbors); the viewer's `
` carries `data-total-steps`. -- Chrome captures a page when its `load` event fires. With `&settle=`, export mode holds that event until the slide has been in the DOM for that many milliseconds of real time, so live embeds have rendered (`lib/export-hold.ts`: an inline script in `` opens the hold, and the viewer releases it once mounted). Chrome's `--timeout` ends a hold that outlasts it, so a capture cannot wait forever. +- `?slide=N&export=1` renders step N without the navigation bar and mounts only that slide (no neighbors); the viewer's `
` carries `data-total-steps` and `data-current-step`. +- Chrome captures a page when its `load` event fires. With `&settle=` (0 included), export mode holds that event until the slide has been in the DOM for that many milliseconds of real time, so live embeds have that long to render (`lib/export-hold.ts`: an inline script in `` opens the hold, and the viewer releases it once mounted). A hold that a release ends marks `` with `data-export-settled`. Chrome's `--timeout` ends a hold that outlasts it, unmarked, so a capture cannot wait forever. +- Each capture also dumps the DOM, and the script rejects a capture that does not show the requested step with the settled mark (a slide that never mounted, or one the timeout cut off); the step is retried. - Without `--url`, the script runs `next build`, serves the build on a free port and captures it, so unmerged changes export too. With `--url https://www.policyengine.org`, it captures the deployed site. - `--size 1440x810 --scale 2` suits decks laid out for a laptop: the content fills the page and text stays sharp. The default is `1920x1080` at scale 1. - Private decks: set `NEXT_PUBLIC_PRIVATE_PASSWORD`. The script passes it in the URL fragment (`#password=...`), which `PasswordGate` accepts and the browser never sends to the server. A local build also needs the variable at build time, as any build of a private deck does. -- `EXPORT_WAIT_MS` (default 8000) is the settle time; raise it for slow embeds or long intro animations. `EXPORT_CONCURRENCY` (default 3) sets how many slides are captured at once. `CHROME_PATH` overrides the Chrome binary. -- A watchdog stops a Chrome that hangs. A step that fails in the parallel pass is retried afterwards on its own with twice the time, up to three attempts in all. A failure or Ctrl-C stops every Chrome the script started, stops the server and removes the screenshots. +- `EXPORT_WAIT_MS` (default 8000, 0 to 60000) is the settle time; raise it for slow embeds or long intro animations. `EXPORT_CONCURRENCY` (default 3) sets how many slides are captured at once. `CHROME_PATH` overrides the Chrome binary. +- A watchdog stops a Chrome that hangs. A step that fails in the parallel pass is retried afterwards on its own with twice the time, up to three attempts in all. A failure, Ctrl-C or SIGTERM stops the build, every Chrome the script started and the server, and removes the screenshots. Error messages never include a URL fragment, so a deck password stays out of logs. - The script checks that the PDF has one page per step before it reports success. ## Adding a new slideshow diff --git a/components/core/PasswordGate.tsx b/components/core/PasswordGate.tsx index 576d66c6..db81dada 100644 --- a/components/core/PasswordGate.tsx +++ b/components/core/PasswordGate.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { passwordFromHash } from '@/lib/slide-url'; +import { releaseExportHold } from '@/lib/export-hold'; interface PasswordGateProps { children: React.ReactNode; @@ -17,9 +18,13 @@ export default function PasswordGate({ children }: PasswordGateProps) { // A link can carry the password in its fragment (#password=...), which the // browser never sends to the server. `bun run export` captures private // decks this way. The viewer drops the fragment when it rewrites the URL. + // Without the password the form stays, so an export capture (whose hold the + // viewer would otherwise release) is let go at once and sees the form. useEffect(() => { if (passwordFromHash(window.location.hash) === correctPassword) { setUnlocked(true); + } else { + releaseExportHold(0); } }, [correctPassword]); diff --git a/components/core/SlideshowViewer.tsx b/components/core/SlideshowViewer.tsx index 3b571db4..99dba714 100644 --- a/components/core/SlideshowViewer.tsx +++ b/components/core/SlideshowViewer.tsx @@ -103,6 +103,7 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) { className="relative cursor-pointer" onClick={handleMainClick} data-total-steps={totalSteps} + data-current-step={currentStep} > {mounted && config.slides.map((slide, slideIndex) => { const SlideComponent = getSlideComponent(slide); diff --git a/lib/export-hold.test.ts b/lib/export-hold.test.ts new file mode 100644 index 00000000..03444d83 --- /dev/null +++ b/lib/export-hold.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EXPORT_HOLD_SCRIPT, MAX_HOLD_MS, releaseExportHold } from "./export-hold"; + +// Runs the generated script, as the browser does from , at `query`. +function runAt(query: string) { + window.history.replaceState(null, "", `/slides/deck${query}`); + new Function(EXPORT_HOLD_SCRIPT)(); +} + +const holdFrames = () => document.head.querySelectorAll("iframe[hidden]").length; + +afterEach(() => { + delete window.__slideshowExportHold; + document.head.querySelectorAll("iframe").forEach((frame) => frame.remove()); + document.documentElement.removeAttribute("data-export-settled"); + vi.useRealTimers(); +}); + +describe("EXPORT_HOLD_SCRIPT", () => { + it("opens a hold for export=1 with a whole-number settle, 0 included", () => { + for (const query of ["?export=1&settle=8000", "?slide=3&export=1&settle=0"]) { + runAt(query); + expect(window.__slideshowExportHold).toBeDefined(); + expect(holdFrames()).toBe(1); + delete window.__slideshowExportHold; + document.head.querySelectorAll("iframe").forEach((frame) => frame.remove()); + } + }); + + it("does nothing for any other URL", () => { + for (const query of [ + "", + "?slide=3", + "?settle=8000", + "?export=1", + "?export=0&settle=8000", + "?export=true&settle=8000", + "?export=1&settle=", + "?export=1&settle=abc", + "?export=1&settle=-5", + "?export=1&settle=1.5", + "?export=1&settle=1e3", + "?export=1&settle=%208000", + ]) { + runAt(query); + expect(window.__slideshowExportHold).toBeUndefined(); + expect(holdFrames()).toBe(0); + } + }); + + it("ends the hold the settle time after a release, marking the page settled", () => { + vi.useFakeTimers(); + runAt("?export=1&settle=8000"); + releaseExportHold(1500); + vi.advanceTimersByTime(1499); + expect(holdFrames()).toBe(1); + expect(document.documentElement.hasAttribute("data-export-settled")).toBe(false); + vi.advanceTimersByTime(1); + expect(holdFrames()).toBe(0); + expect(document.documentElement.hasAttribute("data-export-settled")).toBe(true); + }); + + it("takes only the first release", () => { + vi.useFakeTimers(); + runAt("?export=1&settle=8000"); + releaseExportHold(1000); + releaseExportHold(0); + vi.advanceTimersByTime(999); + expect(holdFrames()).toBe(1); + vi.advanceTimersByTime(1); + expect(holdFrames()).toBe(0); + }); + + it("ends an unreleased hold after MAX_HOLD_MS without marking the page", () => { + vi.useFakeTimers(); + runAt("?export=1&settle=8000"); + vi.advanceTimersByTime(MAX_HOLD_MS - 1); + expect(holdFrames()).toBe(1); + vi.advanceTimersByTime(1); + expect(holdFrames()).toBe(0); + expect(document.documentElement.hasAttribute("data-export-settled")).toBe(false); + }); + + it("lets a release after the fallback do nothing", () => { + vi.useFakeTimers(); + runAt("?export=1&settle=8000"); + vi.advanceTimersByTime(MAX_HOLD_MS); + releaseExportHold(0); + vi.advanceTimersByTime(1); + expect(document.documentElement.hasAttribute("data-export-settled")).toBe(false); + }); +}); + +describe("releaseExportHold", () => { + it("does nothing when no hold is open", () => { + expect(() => releaseExportHold(0)).not.toThrow(); + }); +}); diff --git a/lib/export-hold.ts b/lib/export-hold.ts index 0fa6b246..1226b8c1 100644 --- a/lib/export-hold.ts +++ b/lib/export-hold.ts @@ -3,11 +3,13 @@ * captures the page at `load` and a live embed needs real time to render. * * EXPORT_HOLD_SCRIPT runs inline in , so it runs before `load` can - * fire. On a URL with `export=1` and `settle=` it opens a document in a - * hidden iframe; a frame that is still loading keeps its parent's `load` - * from firing. The viewer calls `releaseExportHold(ms)` once the slide is - * mounted, and the hold ends `ms` later. If nothing releases it, the hold - * ends by itself after MAX_HOLD_MS. + * fire. On a URL with `export=1` and `settle=` (a whole number, 0 + * included) it opens a document in a hidden iframe; a frame that is still + * loading keeps its parent's `load` from firing. The viewer calls + * `releaseExportHold(ms)` once the slide is mounted, and the hold ends `ms` + * later. A hold that a release ends marks with `data-export-settled`, + * so a capture can tell a settled slide from one cut off by a timeout. If + * nothing releases it, the hold ends unmarked after MAX_HOLD_MS. */ /** Longest settle time a URL can ask for. */ @@ -18,7 +20,7 @@ export const MAX_HOLD_MS = 120_000; export const EXPORT_HOLD_SCRIPT = `(function () { var query = new URLSearchParams(location.search); - if (query.get("export") !== "1" || !(Number(query.get("settle")) > 0)) return; + if (query.get("export") !== "1" || !/^[0-9]+$/.test(query.get("settle") || "")) return; var frame = document.createElement("iframe"); frame.hidden = true; frame.setAttribute("aria-hidden", "true"); @@ -29,19 +31,20 @@ export const EXPORT_HOLD_SCRIPT = `(function () { doc.open(); var ended = false; var released = false; - function end() { + function end(settled) { if (ended) return; ended = true; + if (settled) document.documentElement.setAttribute("data-export-settled", ""); try { doc.close(); } catch (e) {} frame.remove(); } - var fallback = setTimeout(end, ${MAX_HOLD_MS}); + var fallback = setTimeout(function () { end(false); }, ${MAX_HOLD_MS}); window.__slideshowExportHold = { release: function (ms) { if (released) return; released = true; clearTimeout(fallback); - setTimeout(end, ms); + setTimeout(function () { end(true); }, ms); }, }; })();`; diff --git a/lib/slide-url.ts b/lib/slide-url.ts index 818620a0..a30a9111 100644 --- a/lib/slide-url.ts +++ b/lib/slide-url.ts @@ -27,7 +27,7 @@ export function isExportMode(params: Pick): boolean { /** * Milliseconds an export capture asks the page to stay still after the slide * mounts (`settle=`), as a whole number from 0 to `max`. Anything else - * reads as 0. + * reads as 0; EXPORT_HOLD_SCRIPT opens no hold for those values. */ export function exportSettleMs( params: Pick, diff --git a/scripts/export-pdf-chrome.mjs b/scripts/export-pdf-chrome.mjs index 31175c92..8395b1ad 100644 --- a/scripts/export-pdf-chrome.mjs +++ b/scripts/export-pdf-chrome.mjs @@ -16,6 +16,16 @@ const BASE_FLAGS = [ const running = new Set(); +/** + * Remove what must not reach a log: URL fragments (where a deck password + * rides) and any `password=` value. + */ +export function redact(text) { + return String(text) + .replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s#]*)#\S*/gi, "$1") + .replace(/(password=)[^\s&]*/gi, "$1…"); +} + /** Path of a Chrome or Chromium binary, or null when none is installed. */ export function findChrome() { if (process.env.CHROME_PATH) return process.env.CHROME_PATH; @@ -68,13 +78,15 @@ export function runChrome(binary, args, { timeoutMs, extraFlags = [] }) { }; proc.on("error", (e) => settle(reject, e)); proc.on("close", (code, signal) => { + const target = redact(args.at(-1)); if (timedOut) { - settle(reject, new Error(`Chrome timed out after ${timeoutMs} ms: ${args.at(-1)}`)); + settle(reject, new Error(`Chrome timed out after ${timeoutMs} ms: ${target}`)); } else if (code === 0) { settle(resolve, stdout); } else { const reason = signal ? `was stopped by ${signal}` : `exited ${code}`; - settle(reject, new Error(`Chrome ${reason}: ${stderr.trim().split("\n").at(-1)}`)); + const detail = redact(stderr.trim().split("\n").at(-1)); + settle(reject, new Error(`Chrome ${reason} on ${target}: ${detail}`)); } }); }); diff --git a/scripts/export-pdf-lib.mjs b/scripts/export-pdf-lib.mjs index 5b04ecb5..d3f4541c 100644 --- a/scripts/export-pdf-lib.mjs +++ b/scripts/export-pdf-lib.mjs @@ -96,15 +96,16 @@ export function parseIntSetting(name, value, fallback, { min, max }) { /** * URL of one step in export mode, which hides the navigation bar. `settle` - * asks the page to stay still that many milliseconds after the slide mounts - * before its load event fires. A password (even an empty one) rides in the - * fragment, which unlocks a private deck and never reaches the server. + * (0 included) holds the page's load event until the slide has mounted and + * then stayed still that many milliseconds. A password (even an empty one) + * rides in the fragment, which unlocks a private deck and never reaches the + * server. */ -export function stepUrl(origin, id, step, { settle = 0, password = null } = {}) { +export function stepUrl(origin, id, step, { settle = null, password = null } = {}) { const url = new URL(`/slides/${encodeURIComponent(id)}`, origin); url.searchParams.set("slide", String(step)); url.searchParams.set("export", "1"); - if (settle > 0) url.searchParams.set("settle", String(settle)); + if (settle !== null) url.searchParams.set("settle", String(settle)); if (password !== null) url.hash = `password=${encodeURIComponent(password)}`; return url.toString(); } @@ -120,22 +121,48 @@ function escapeHtml(text) { } /** - * Read the deck's step count and page title from the DOM that - * `chrome --dump-dom` prints. `gated` is true when the page still shows the - * private-deck password form. + * Read what a capture needs from the DOM that `chrome --dump-dom` prints at + * the moment of capture: + * - `total`: the deck's step count, from the viewer's `data-total-steps`; + * - `step`: the step the viewer shows, from `data-current-step`; + * - `settled`: whether the export hold ended by release, which marks + * with `data-export-settled` (a hold cut off by a timeout leaves no mark); + * - `gated`: whether the page shows the private-deck password form; + * - `title`: the page title. */ export function parseDeckDom(dom) { - const match = dom.match(/data-total-steps="(\d+)"/); - const total = match ? parseInt(match[1], 10) : null; - const gated = /]*type="password"/.test(dom); + const number = (name) => { + const match = dom.match(new RegExp(`${name}="(\\d+)"`)); + return match ? parseInt(match[1], 10) : null; + }; + const total = number("data-total-steps"); + const html = dom.match(/]*>/); const title = dom.match(/]*>([^<]*)<\/title>/); return { total: total && total > 0 ? total : null, - gated, + step: number("data-current-step"), + settled: html ? /\sdata-export-settled(?:=|\s|>|$)/.test(html[0]) : false, + gated: /]*type="password"/.test(dom), title: title ? decodeEntities(title[1]).trim() || null : null, }; } +/** + * Throw unless a capture's DOM shows step `step`, settled. A slide that never + * mounted, or one that Chrome's --timeout cut off, fails here instead of + * becoming a page of the PDF. + */ +export function checkCapture(dom, step) { + const { step: shown, settled, gated } = parseDeckDom(dom); + if (gated) throw new Error("the page showed the password form"); + if (shown !== step) { + throw new Error( + shown === null ? "the slide had not mounted" : `the page showed step ${shown + 1}`, + ); + } + if (!settled) throw new Error("the slide had not settled when Chrome captured it"); +} + /** Zero-padded file name for a step's screenshot, so names sort in order. */ export function shotName(step, total) { const width = String(Math.max(total - 1, 0)).length; @@ -165,13 +192,50 @@ ${images} } /** - * Count the pages of a PDF from its bytes. It counts page objects, which - * Chrome writes uncompressed (`/Type /Page`, as opposed to the `/Type /Pages` - * tree nodes). + * The parts of a PDF that are not string literals, stream data or comments, + * so a search for keywords cannot match text inside them. */ -export function countPdfPages(bytes) { +export function pdfSyntax(bytes) { const text = Buffer.from(bytes).toString("latin1"); - return (text.match(/\/Type\s*\/Page(?![a-zA-Z])/g) || []).length; + let out = ""; + let i = 0; + while (i < text.length) { + const c = text[i]; + if (c === "(") { + // A literal string: balanced parentheses, with backslash escapes. + let depth = 1; + i++; + while (i < text.length && depth > 0) { + if (text[i] === "\\") i += 2; + else { + if (text[i] === "(") depth++; + else if (text[i] === ")") depth--; + i++; + } + } + out += "()"; + } else if (c === "%") { + while (i < text.length && text[i] !== "\n" && text[i] !== "\r") i++; + } else if (text.startsWith("stream", i) && !/[A-Za-z]/.test(text[i - 1] || "")) { + const end = text.indexOf("endstream", i + 6); + out += "stream endstream"; + i = end === -1 ? text.length : end + "endstream".length; + } else { + out += c; + i++; + } + } + return out; +} + +/** + * Count the pages of a PDF from its bytes: the `/Type /Page` objects (not the + * `/Type /Pages` tree nodes) outside strings, streams and comments. Chrome + * writes page objects uncompressed; a PDF that packs them into object streams + * counts 0, which the export reports as a mismatch rather than a success. + */ +export function countPdfPages(bytes) { + return (pdfSyntax(bytes).match(/\/Type\s*\/Page(?![A-Za-z])/g) || []).length; } /** diff --git a/scripts/export-pdf-lib.test.mjs b/scripts/export-pdf-lib.test.mjs index 4fc81c1c..cd58e421 100644 --- a/scripts/export-pdf-lib.test.mjs +++ b/scripts/export-pdf-lib.test.mjs @@ -6,8 +6,9 @@ import path from "path"; import { pathToFileURL } from "url"; import { describe, it, expect } from "vitest"; import { EXPORT_HOLD_SCRIPT } from "../lib/export-hold"; -import { findChrome, runChrome, runningChromeCount } from "./export-pdf-chrome.mjs"; +import { findChrome, redact, runChrome, runningChromeCount } from "./export-pdf-chrome.mjs"; import { + checkCapture, countPdfPages, deckHtml, mapLimit, @@ -119,12 +120,12 @@ describe("stepUrl", () => { ); }); - it("adds the settle time only when there is one", () => { + it("adds the settle time when given, 0 included", () => { expect(stepUrl("http://localhost:4000", "deck", 2, { settle: 8000 })).toBe( "http://localhost:4000/slides/deck?slide=2&export=1&settle=8000", ); expect(stepUrl("http://localhost:4000", "deck", 2, { settle: 0 })).toBe( - "http://localhost:4000/slides/deck?slide=2&export=1", + "http://localhost:4000/slides/deck?slide=2&export=1&settle=0", ); }); @@ -141,25 +142,60 @@ describe("stepUrl", () => { }); describe("parseDeckDom", () => { - it("reads the step count and title from the viewer", () => { + it("reads the step count, shown step and title from the viewer", () => { const dom = - "Taxes & benefits | PolicyEngine" + - '
'; + "Taxes & benefits | PolicyEngine" + + '
'; expect(parseDeckDom(dom)).toEqual({ total: 36, + step: 4, + settled: true, gated: false, title: "Taxes & benefits | PolicyEngine", }); }); - it("flags the password gate and a missing count", () => { - const dom = '
'; - expect(parseDeckDom(dom)).toEqual({ total: null, gated: true, title: null }); + it("flags the password gate, a missing count and an unsettled page", () => { + const dom = + '
'; + expect(parseDeckDom(dom)).toEqual({ + total: null, + step: null, + settled: false, + gated: true, + title: null, + }); }); it("treats a zero count as missing", () => { expect(parseDeckDom('
').total).toBeNull(); }); + + it("reads the settled mark only from ", () => { + const elsewhere = '

text

'; + expect(parseDeckDom(elsewhere).settled).toBe(false); + expect(parseDeckDom("").settled).toBe(true); + }); +}); + +describe("checkCapture", () => { + const page = ({ settled = true, step = 3, gate = false } = {}) => + `` + + (gate + ? '' + : `
`); + + it("accepts the requested step, settled", () => { + expect(() => checkCapture(page(), 3)).not.toThrow(); + }); + + it("rejects a page that is not the requested step, settled", () => { + expect(() => checkCapture(page({ step: 2 }), 3)).toThrow(/showed step 3/); + expect(() => checkCapture(page({ step: null }), 3)).toThrow(/had not mounted/); + expect(() => checkCapture(page({ settled: false }), 3)).toThrow(/had not settled/); + expect(() => checkCapture(page({ gate: true }), 3)).toThrow(/password form/); + expect(() => checkCapture("", 3)).toThrow(/had not mounted/); + }); }); describe("shotName", () => { @@ -202,13 +238,46 @@ describe("deckHtml", () => { }); describe("countPdfPages", () => { + const count = (text) => countPdfPages(Buffer.from(text, "latin1")); + it("counts page objects and ignores the page tree", () => { const pdf = "1 0 obj <> endobj\n" + "2 0 obj <> endobj\n" + "3 0 obj <> endobj\n"; - expect(countPdfPages(Buffer.from(pdf, "latin1"))).toBe(2); - expect(countPdfPages(Buffer.from("%PDF-1.4\n", "latin1"))).toBe(0); + expect(count(pdf)).toBe(2); + expect(count("%PDF-1.4\n")).toBe(0); + }); + + it("ignores the keyword inside strings, streams and comments", () => { + const page = "2 0 obj <> endobj\n"; + expect(count(page + "4 0 obj <> endobj\n")).toBe(1); + expect(count(page + "4 0 obj <> endobj\n")).toBe(1); + expect(count(page + "4 0 obj <> endobj\n")).toBe(1); + expect( + count(page + "5 0 obj <>\nstream\n/Type /Page /Type /Page\nendstream\nendobj\n"), + ).toBe(1); + expect(count(page + "% /Type /Page in a comment\n")).toBe(1); + }); + + it("counts a page that follows a string or stream", () => { + const pdf = + "4 0 obj <> endobj\n" + + "5 0 obj <>\nstream\nabc\nendstream\nendobj\n" + + "2 0 obj <> endobj\n"; + expect(count(pdf)).toBe(1); + }); +}); + +describe("redact", () => { + it("drops URL fragments and password values", () => { + const url = "http://localhost:4000/slides/deck?slide=2&export=1#password=s3cr%26t%20pw"; + expect(redact(url)).toBe("http://localhost:4000/slides/deck?slide=2&export=1"); + expect(redact(`Chrome timed out: ${url} (retrying)`)).toBe( + "Chrome timed out: http://localhost:4000/slides/deck?slide=2&export=1 (retrying)", + ); + expect(redact("bad password=hunter2 here")).toBe("bad password=… here"); + expect(redact("no secrets here")).toBe("no secrets here"); }); }); @@ -298,18 +367,21 @@ describe.skipIf(!chrome)("printing with Chrome", () => { }, 120_000); // A page with the export hold in its head. `release` stands in for the - // viewer: it marks the page mounted after 300 ms and releases the hold. - // The page records when its load event fired, in its own clock. + // viewer: 300 ms in, it marks the page mounted and releases the hold with + // that settle time (null: never). At load the page records when load fired + // in its own clock, and whether a hold was opened. const holdPage = (release) => `

parsed

`; const dump = async (html, query, timeout) => { @@ -330,33 +402,51 @@ ${release ? `setTimeout(function () { const match = /

(\d+)<\/p>/.exec(dom); return match ? Number(match[1]) : null; }; + const holdState = (dom) => /

(\w+)<\/p>/.exec(dom)?.[1]; - it("holds load until the settle time after the release has passed", async () => { - const dom = await dump(holdPage(1500), "?export=1&settle=1500", 60_000); + it("holds load until the settle time after the release, and marks the page settled", async () => { + // Chrome's own timeout is 20 s, so a release that did nothing would make + // load wait until then. + const dom = await dump(holdPage(1500), "?export=1&settle=1500", 20_000); expect(dom).toContain('

mounted

'); - // Released 300 ms in, then 1500 ms of settle. + expect(holdState(dom)).toBe("opened"); + expect(parseDeckDom(dom).settled).toBe(true); + expect(dom).not.toContain(" { - for (const query of ["", "?settle=1500", "?export=1", "?export=1&settle=0"]) { + it("holds load until the release with settle=0", async () => { + const dom = await dump(holdPage(0), "?export=1&settle=0", 20_000); + expect(holdState(dom)).toBe("opened"); + expect(parseDeckDom(dom).settled).toBe(true); + expect(loadedAt(dom)).toBeGreaterThanOrEqual(300); + expect(loadedAt(dom)).toBeLessThan(10_000); + }, 150_000); + + it("opens no hold without export=1 and a whole-number settle", async () => { + for (const query of ["", "?settle=1500", "?export=1", "?export=0&settle=1500", "?export=1&settle=abc", "?export=1&settle=-5"]) { const dom = await dump(holdPage(1500), query, 60_000); + expect(holdState(dom)).toBe("none"); + expect(parseDeckDom(dom).settled).toBe(false); expect(loadedAt(dom)).not.toBeNull(); - expect(loadedAt(dom)).toBeLessThan(1000); } }, 300_000); - it("lets Chrome's --timeout end a hold that is never released", async () => { + it("lets Chrome's --timeout end a hold that is never released, unmarked", async () => { // Nothing releases this hold, so alone it would last MAX_HOLD_MS (two // minutes). When --timeout runs out, Chrome stops the page load, which - // ends the hold and fires load. - const dom = await dump(holdPage(0), "?export=1&settle=5000", 2_000); + // fires load; the page is not marked settled, so a capture rejects it. + const dom = await dump(holdPage(null), "?export=1&settle=5000", 2_000); expect(dom).toContain('

parsed

'); + expect(holdState(dom)).toBe("opened"); + expect(parseDeckDom(dom).settled).toBe(false); expect(loadedAt(dom)).toBeGreaterThanOrEqual(1000); expect(loadedAt(dom)).toBeLessThan(60_000); }, 150_000); - it("stops a Chrome that outlives the watchdog and stops tracking it", async () => { + it("stops a Chrome that outlives the watchdog, stops tracking it, and keeps the password out of the error", async () => { // A server that accepts the connection and never answers, so the page // load cannot finish. const sockets = new Set(); @@ -366,12 +456,21 @@ ${release ? `setTimeout(function () { }); await new Promise((resolve) => silent.listen(0, "127.0.0.1", resolve)); try { - const url = `http://127.0.0.1:${silent.address().port}/`; + const password = "s3cr&t pw"; + const url = `http://127.0.0.1:${silent.address().port}/#password=${encodeURIComponent(password)}`; const hang = runChrome(chrome, ["--dump-dom", url], { timeoutMs: 2_000, extraFlags: ciFlags, }); - await expect(hang).rejects.toThrow(/Chrome timed out after 2000 ms/); + const error = await hang.then( + () => null, + (e) => e, + ); + expect(error?.message).toMatch(/Chrome timed out after 2000 ms/); + // The error names the page but never the password, raw or encoded. + expect(error.message).toContain(`127.0.0.1:${silent.address().port}/`); + expect(error.message).not.toContain(password); + expect(error.message).not.toContain(encodeURIComponent(password)); expect(runningChromeCount()).toBe(0); } finally { for (const socket of sockets) socket.destroy(); diff --git a/scripts/export-pdf.mjs b/scripts/export-pdf.mjs index bcd3614b..e114996e 100644 --- a/scripts/export-pdf.mjs +++ b/scripts/export-pdf.mjs @@ -19,7 +19,9 @@ * 3. Screenshots every step at ?slide=N&export=1&settle=. Export mode * hides the nav bar, mounts only that slide, and holds the page's load * event until the slide has been in the DOM for of real time, so - * live embeds have rendered when Chrome captures at load. + * live embeds have that long to render before Chrome captures at load. + * Each capture also dumps the DOM, and a capture that does not show the + * requested step, settled, fails and is retried. * 4. Prints a page of the screenshots to a PDF with `chrome --print-to-pdf`, * one page per step at the viewport size, and checks the page count. * @@ -28,7 +30,7 @@ * where an administrator has disabled remote debugging. */ -import { spawn, spawnSync } from "child_process"; +import { spawn } from "child_process"; import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; import { createServer } from "net"; import { tmpdir } from "os"; @@ -37,6 +39,7 @@ import { pathToFileURL } from "url"; import { findChrome, killAllChrome, runChrome } from "./export-pdf-chrome.mjs"; import { USAGE, + checkCapture, countPdfPages, deckHtml, mapLimit, @@ -55,12 +58,17 @@ const ATTEMPTS = 3; // (which can show a private deck). let work = null; +let builder = null; let server = null; let stopping = false; function cleanup() { stopping = true; killAllChrome(); + if (builder) { + builder.kill(); + builder = null; + } if (server) { server.kill(); server = null; @@ -96,11 +104,22 @@ function getFreePort() { const nextBin = path.join(process.cwd(), "node_modules", ".bin", "next"); +/** Run `next build`, without blocking, so a signal can still stop it. */ function build() { console.log("Building..."); - const result = spawnSync(nextBin, ["build"], { stdio: "inherit" }); - if (result.error) throw result.error; - if (result.status !== 0) throw new Error(`Build failed (exit ${result.status})`); + return new Promise((resolve, reject) => { + const proc = spawn(nextBin, ["build"], { stdio: "inherit" }); + builder = proc; + proc.on("error", (e) => { + builder = null; + reject(e); + }); + proc.on("close", (code, signal) => { + builder = null; + if (code === 0) resolve(); + else reject(new Error(`Build failed (${signal ? `stopped by ${signal}` : `exit ${code}`})`)); + }); + }); } /** Start `next start` on `port` and resolve with its origin once it answers. */ @@ -163,7 +182,7 @@ async function main() { let origin = args.url; if (!origin) { - build(); + await build(); origin = await startServer(await getFreePort()); } work = mkdtempSync(path.join(tmpdir(), `export-${args.id}-`)); @@ -212,7 +231,14 @@ async function main() { const names = Array.from({ length: total }, (_, i) => shotName(i, total)); const capture = async (step, options) => { const file = path.join(work, names[step]); - await runChrome(binary, [`--screenshot=${file}`, at(step, password)], options); + rmSync(file, { force: true }); + // One Chrome run writes the screenshot and prints the DOM it captured. + const dom = await runChrome( + binary, + ["--dump-dom", `--screenshot=${file}`, at(step, password)], + options, + ); + checkCapture(dom, step); if (statSync(file).size === 0) throw new Error("empty screenshot"); }; let done = 0; From 05cb7962f99265bcba8ada29fb80f20c78bd3317 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 22:52:48 -0400 Subject: [PATCH 08/10] Address third review nits: parse PDF streams by length, drop test ceilings - The page counter read the name /stream as the keyword, ended a stream at the first 'endstream' in its data, and accepted a stream with no end. A stream now starts only at the keyword after a dictionary's >>, its data is skipped by the dictionary's direct /Length (or up to endstream when the length is an indirect reference), and a stream that does not end where it should throws, so the export fails instead of miscounting. - The release tests required load within 10 s although Chrome's timeout is 20 s. The settled mark already rules out a release that did nothing, so the ceiling is gone. Co-Authored-By: Claude Opus 5.5 --- scripts/export-pdf-lib.mjs | 51 +++++++++++++++++++++++++++++---- scripts/export-pdf-lib.test.mjs | 45 +++++++++++++++++++++++------ 2 files changed, 83 insertions(+), 13 deletions(-) diff --git a/scripts/export-pdf-lib.mjs b/scripts/export-pdf-lib.mjs index d3f4541c..c49f39ec 100644 --- a/scripts/export-pdf-lib.mjs +++ b/scripts/export-pdf-lib.mjs @@ -191,9 +191,31 @@ ${images} `; } +/** + * Index just past the `<<` that opens the dictionary whose `>>` ends at + * `close` (exclusive), or -1. Nested dictionaries are skipped. + */ +function dictionaryStart(text, close) { + let depth = 0; + for (let i = close - 2; i >= 1; i--) { + if (text[i] === ">" && text[i - 1] === ">") { + depth++; + i--; + } else if (text[i] === "<" && text[i - 1] === "<") { + if (depth === 0) return i + 1; + depth--; + i--; + } + } + return -1; +} + /** * The parts of a PDF that are not string literals, stream data or comments, - * so a search for keywords cannot match text inside them. + * so a search for keywords cannot match text inside them. A stream is the + * keyword `stream` right after a dictionary's `>>`; its data is skipped by the + * dictionary's direct /Length, or up to `endstream` when the length is an + * indirect reference. A stream that does not end where it should throws. */ export function pdfSyntax(bytes) { const text = Buffer.from(bytes).toString("latin1"); @@ -216,10 +238,28 @@ export function pdfSyntax(bytes) { out += "()"; } else if (c === "%") { while (i < text.length && text[i] !== "\n" && text[i] !== "\r") i++; - } else if (text.startsWith("stream", i) && !/[A-Za-z]/.test(text[i - 1] || "")) { - const end = text.indexOf("endstream", i + 6); + } else if (text.startsWith("stream", i) && /(^|>>)\s*$/.test(out.slice(-64))) { + // Data starts after the end-of-line that follows the keyword. + let data = i + "stream".length; + if (text[data] === "\r") data++; + if (text[data] === "\n") data++; + const close = out.trimEnd().length; + const from = dictionaryStart(out, close); + const dict = from === -1 ? "" : out.slice(from, close - 2); + const direct = /\/Length\s+(\d+)(?!\s+\d+\s+R)/.exec(dict); + let end; + if (direct) { + end = data + parseInt(direct[1], 10); + const tail = /^\s*endstream/.exec(text.slice(end, end + 64)); + if (!tail) throw new Error(`Malformed PDF: a stream at byte ${i} does not end at its /Length`); + end += tail[0].length; + } else { + const at = text.indexOf("endstream", data); + if (at === -1) throw new Error(`Malformed PDF: a stream at byte ${i} has no endstream`); + end = at + "endstream".length; + } out += "stream endstream"; - i = end === -1 ? text.length : end + "endstream".length; + i = end; } else { out += c; i++; @@ -232,7 +272,8 @@ export function pdfSyntax(bytes) { * Count the pages of a PDF from its bytes: the `/Type /Page` objects (not the * `/Type /Pages` tree nodes) outside strings, streams and comments. Chrome * writes page objects uncompressed; a PDF that packs them into object streams - * counts 0, which the export reports as a mismatch rather than a success. + * counts 0, and a malformed stream throws, so either way the export reports a + * failure rather than a success. */ export function countPdfPages(bytes) { return (pdfSyntax(bytes).match(/\/Type\s*\/Page(?![A-Za-z])/g) || []).length; diff --git a/scripts/export-pdf-lib.test.mjs b/scripts/export-pdf-lib.test.mjs index cd58e421..a067eb75 100644 --- a/scripts/export-pdf-lib.test.mjs +++ b/scripts/export-pdf-lib.test.mjs @@ -249,15 +249,47 @@ describe("countPdfPages", () => { expect(count("%PDF-1.4\n")).toBe(0); }); + // A stream object whose dictionary declares the true length of `data`. + const stream = (data, extra = "") => + `5 0 obj <>\nstream\n${data}\nendstream\nendobj\n`; + const page = "2 0 obj <> endobj\n"; + it("ignores the keyword inside strings, streams and comments", () => { - const page = "2 0 obj <> endobj\n"; expect(count(page + "4 0 obj <> endobj\n")).toBe(1); expect(count(page + "4 0 obj <> endobj\n")).toBe(1); expect(count(page + "4 0 obj <> endobj\n")).toBe(1); + expect(count(page + stream("/Type /Page /Type /Page"))).toBe(1); + expect(count(page + "% /Type /Page in a comment\n")).toBe(1); + }); + + it("skips stream data by its /Length, even when the data says endstream", () => { + expect(count(page + stream("endstream\n/Type /Page\n"))).toBe(1); + expect(count(page + stream("x", " /Filter /FlateDecode /DecodeParms <>"))).toBe(1); expect( - count(page + "5 0 obj <>\nstream\n/Type /Page /Type /Page\nendstream\nendobj\n"), + count(page + "5 0 obj <>\r\nstream\r\nabc\r\nendstream\r\nendobj\r\n"), ).toBe(1); - expect(count(page + "% /Type /Page in a comment\n")).toBe(1); + }); + + it("reads `stream` as a keyword only after a dictionary", () => { + expect(count("4 0 obj <> endobj\n" + page)).toBe(1); + expect(count("4 0 obj [/stream (stream)] endobj\n" + page)).toBe(1); + }); + + it("falls back to endstream when the length is an indirect reference", () => { + const pdf = page + "5 0 obj <>\nstream\nabc /Type /Page\nendstream\nendobj\n"; + expect(count(pdf)).toBe(1); + }); + + it("throws on a stream that does not end where it should", () => { + expect(() => count(page + "5 0 obj <>\nstream\nabcdef\nendstream\n")).toThrow( + /does not end at its \/Length/, + ); + expect(() => count(page + "5 0 obj <>\nstream\nabc /Type /Page\n")).toThrow( + /has no endstream/, + ); + expect(() => count(page + "5 0 obj <>\nstream\nabc\nendstream\n")).toThrow( + /does not end at its \/Length/, + ); }); it("counts a page that follows a string or stream", () => { @@ -405,16 +437,14 @@ ${release === null ? "" : `setTimeout(function () { const holdState = (dom) => /

(\w+)<\/p>/.exec(dom)?.[1]; it("holds load until the settle time after the release, and marks the page settled", async () => { - // Chrome's own timeout is 20 s, so a release that did nothing would make - // load wait until then. const dom = await dump(holdPage(1500), "?export=1&settle=1500", 20_000); expect(dom).toContain('

mounted

'); expect(holdState(dom)).toBe("opened"); expect(parseDeckDom(dom).settled).toBe(true); expect(dom).not.toContain(" { @@ -422,7 +452,6 @@ ${release === null ? "" : `setTimeout(function () { expect(holdState(dom)).toBe("opened"); expect(parseDeckDom(dom).settled).toBe(true); expect(loadedAt(dom)).toBeGreaterThanOrEqual(300); - expect(loadedAt(dom)).toBeLessThan(10_000); }, 150_000); it("opens no hold without export=1 and a whole-number settle", async () => { From 2794103ff8ba23db7cc3c66e759fdaacd06e96e7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 15 Oct 2026 23:11:06 -0400 Subject: [PATCH 09/10] Parse PDF streams robustly: hex strings, whitespace, top-level and indirect /Length Review of 05cb796 found that the new stream parsing still miscounted valid PDFs: - /Length 16 0 R read as /Length 1: the digits backed off until the negative lookahead passed. Indirect lengths are now parsed whole and resolved against the object in the file; the scan falls back to the next endstream only when that object is missing. - A hex string right before >> (/ID >>) passed for a dictionary bracket. Hex strings are now skipped like literal strings, so every << and >> left is a dictionary bracket, and dictionaryStart starts at the right character. - The keyword check used a 64-character window and JavaScript's \s, which misses NUL. It now scans back over PDF whitespace and requires >> and an end of line after the keyword. - /Length inside a nested dictionary could be read. Only top-level keys count now. - The settle=0 hold test checks that the release removed the frame. All 41 valid-PDF probes from the review count correctly, and the 36-, 36- and 21-page Chrome exports still count 36, 36 and 21. Co-Authored-By: Claude Opus 5.5 --- scripts/export-pdf-lib.mjs | 89 +++++++++++++++++++++++++-------- scripts/export-pdf-lib.test.mjs | 42 +++++++++++++++- 2 files changed, 108 insertions(+), 23 deletions(-) diff --git a/scripts/export-pdf-lib.mjs b/scripts/export-pdf-lib.mjs index c49f39ec..5647bb0f 100644 --- a/scripts/export-pdf-lib.mjs +++ b/scripts/export-pdf-lib.mjs @@ -191,13 +191,17 @@ ${images} `; } +/** PDF whitespace: NUL, tab, line feed, form feed, carriage return, space. */ +const PDF_SPACE = new Set(["\0", "\t", "\n", "\f", "\r", " "]); + /** * Index just past the `<<` that opens the dictionary whose `>>` ends at - * `close` (exclusive), or -1. Nested dictionaries are skipped. + * `close` (exclusive), or -1. `text` holds no strings, so every `<<` and `>>` + * in it is a dictionary bracket; nested dictionaries are skipped. */ function dictionaryStart(text, close) { let depth = 0; - for (let i = close - 2; i >= 1; i--) { + for (let i = close - 3; i >= 1; i--) { if (text[i] === ">" && text[i - 1] === ">") { depth++; i--; @@ -210,12 +214,39 @@ function dictionaryStart(text, close) { return -1; } +/** A dictionary's text with its nested dictionaries removed. */ +function topLevel(dict) { + let flat = dict; + for (let prev = null; prev !== flat; ) { + prev = flat; + flat = flat.replace(/<<[^<>]*>>/g, " "); + } + return flat; +} + /** - * The parts of a PDF that are not string literals, stream data or comments, - * so a search for keywords cannot match text inside them. A stream is the - * keyword `stream` right after a dictionary's `>>`; its data is skipped by the - * dictionary's direct /Length, or up to `endstream` when the length is an - * indirect reference. A stream that does not end where it should throws. + * The stream data length a dictionary declares: a direct integer, or an + * indirect reference resolved against `text`. Null when neither works. + */ +function streamLength(dict, text) { + const match = /\/Length\s+(\d+)(?:\s+(\d+)\s+R)?(?![\d.])/.exec(topLevel(dict)); + if (!match) return null; + if (match[2] === undefined) return parseInt(match[1], 10); + const object = new RegExp(`(?:^|[^0-9])${match[1]}\\s+${match[2]}\\s+obj\\s*(\\d+)\\s*endobj`); + const resolved = object.exec(text); + return resolved ? parseInt(resolved[1], 10) : null; +} + +/** + * The parts of a PDF that are not strings (literal or hex), stream data or + * comments, so a search for keywords cannot match text inside them. + * + * A stream is the keyword `stream` right after a dictionary's `>>`, followed + * by an end of line. Its data is skipped by the dictionary's top-level + * /Length, resolving an indirect reference when the object is in the file. + * Data that does not end with `endstream` at that length throws. Only when the + * length cannot be read does the scan fall back to the next `endstream`, and + * a stream with none throws. */ export function pdfSyntax(bytes) { const text = Buffer.from(bytes).toString("latin1"); @@ -236,30 +267,46 @@ export function pdfSyntax(bytes) { } } out += "()"; + } else if (c === "<" && text[i + 1] !== "<") { + // A hex string, which holds only hex digits and whitespace. + const close = text.indexOf(">", i + 1); + i = close === -1 ? text.length : close + 1; + out += "()"; + } else if (c === "<") { + out += "<<"; + i += 2; + } else if (c === ">" && text[i + 1] === ">") { + out += ">>"; + i += 2; } else if (c === "%") { while (i < text.length && text[i] !== "\n" && text[i] !== "\r") i++; - } else if (text.startsWith("stream", i) && /(^|>>)\s*$/.test(out.slice(-64))) { - // Data starts after the end-of-line that follows the keyword. + } else if (text.startsWith("stream", i) && /[\r\n]/.test(text[i + 6] || "")) { + let close = out.length; + while (close > 0 && PDF_SPACE.has(out[close - 1])) close--; + if (out.slice(close - 2, close) !== ">>") { + out += c; + i++; + continue; + } + // Data starts after the end of line that follows the keyword. let data = i + "stream".length; if (text[data] === "\r") data++; if (text[data] === "\n") data++; - const close = out.trimEnd().length; const from = dictionaryStart(out, close); - const dict = from === -1 ? "" : out.slice(from, close - 2); - const direct = /\/Length\s+(\d+)(?!\s+\d+\s+R)/.exec(dict); + const length = from === -1 ? null : streamLength(out.slice(from, close - 2), text); let end; - if (direct) { - end = data + parseInt(direct[1], 10); - const tail = /^\s*endstream/.exec(text.slice(end, end + 64)); - if (!tail) throw new Error(`Malformed PDF: a stream at byte ${i} does not end at its /Length`); - end += tail[0].length; + if (length !== null) { + end = data + length; + while (end < text.length && PDF_SPACE.has(text[end])) end++; + if (!text.startsWith("endstream", end)) { + throw new Error(`Malformed PDF: a stream at byte ${i} does not end at its /Length`); + } } else { - const at = text.indexOf("endstream", data); - if (at === -1) throw new Error(`Malformed PDF: a stream at byte ${i} has no endstream`); - end = at + "endstream".length; + end = text.indexOf("endstream", data); + if (end === -1) throw new Error(`Malformed PDF: a stream at byte ${i} has no endstream`); } out += "stream endstream"; - i = end; + i = end + "endstream".length; } else { out += c; i++; diff --git a/scripts/export-pdf-lib.test.mjs b/scripts/export-pdf-lib.test.mjs index a067eb75..5b8b54c2 100644 --- a/scripts/export-pdf-lib.test.mjs +++ b/scripts/export-pdf-lib.test.mjs @@ -275,11 +275,46 @@ describe("countPdfPages", () => { expect(count("4 0 obj [/stream (stream)] endobj\n" + page)).toBe(1); }); - it("falls back to endstream when the length is an indirect reference", () => { - const pdf = page + "5 0 obj <>\nstream\nabc /Type /Page\nendstream\nendobj\n"; + it("resolves an indirect /Length, whatever its object number", () => { + for (const ref of ["6 0 R", "16 0 R", "123 0 R"]) { + const id = ref.split(" ")[0]; + const data = "x endstream\n/Type /Page\n"; + const pdf = + page + + `5 0 obj <>\nstream\n${data}\nendstream\nendobj\n` + + `${id} 0 obj ${data.length} endobj\n`; + expect(count(pdf)).toBe(1); + } + }); + + it("falls back to the next endstream when an indirect /Length is not in the file", () => { + const pdf = page + "5 0 obj <>\nstream\nabc /Type /Page\nendstream\nendobj\n"; expect(count(pdf)).toBe(1); }); + it("reads only the stream dictionary's own /Length", () => { + const data = "endstream /Type /Page\n"; + const pdf = page + `5 0 obj <> /Length ${data.length}>>\nstream\n${data}\nendstream\nendobj\n`; + expect(count(pdf)).toBe(1); + }); + + it("skips hex strings, so their brackets cannot pass for a dictionary's", () => { + const data = "endstream /Type /Page"; + for (const extra of [" /ID ", " /K <>", " /A <>>"]) { + const pdf = page + `5 0 obj <>\nstream\n${data}\nendstream\nendobj\n`; + expect(count(pdf)).toBe(1); + } + expect(count(page + "4 0 obj <]>> endobj\n")).toBe(1); + }); + + it("finds the keyword after any PDF whitespace, NUL included", () => { + const data = "/Type /Page /Type /Page"; + for (const gap of [" ".repeat(63), " ".repeat(200), "\0", "\r\n\t\f"]) { + const pdf = page + `5 0 obj <>${gap}stream\n${data}\nendstream\nendobj\n`; + expect(count(pdf)).toBe(1); + } + }); + it("throws on a stream that does not end where it should", () => { expect(() => count(page + "5 0 obj <>\nstream\nabcdef\nendstream\n")).toThrow( /does not end at its \/Length/, @@ -290,6 +325,8 @@ describe("countPdfPages", () => { expect(() => count(page + "5 0 obj <>\nstream\nabc\nendstream\n")).toThrow( /does not end at its \/Length/, ); + // A bare `stream` after a token other than `>>` is not a stream. + expect(count("4 0 obj 1 endobj" + " ".repeat(70) + "stream\n" + page + "endstream\n")).toBe(1); }); it("counts a page that follows a string or stream", () => { @@ -451,6 +488,7 @@ ${release === null ? "" : `setTimeout(function () { const dom = await dump(holdPage(0), "?export=1&settle=0", 20_000); expect(holdState(dom)).toBe("opened"); expect(parseDeckDom(dom).settled).toBe(true); + expect(dom).not.toContain(" Date: Thu, 15 Oct 2026 23:20:55 -0400 Subject: [PATCH 10/10] Reject truncated PDFs and unterminated strings, strip arrays before /Length Nits from the review of 2794103: - Chrome writes the page objects near the end of the file, so a file cut off before its trailer still counted every page. countPdfPages now requires the file to end with %%EOF, and an unterminated literal or hex string throws instead of swallowing the rest of the file. - /Length inside an array (/Foo [/Length 0]) was read as the stream's length. Arrays are now stripped with nested dictionaries. - An indirect /Length resolved to its first definition. It now takes the last, as a PDF updated incrementally does. The 36-, 36-, 36- and 21-page Chrome exports still count correctly, and merged.pdf cut at its trailer now throws. Co-Authored-By: Claude Opus 5.5 --- scripts/export-pdf-lib.mjs | 24 ++++++++++++++++-------- scripts/export-pdf-lib.test.mjs | 28 ++++++++++++++++++++++++++-- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/scripts/export-pdf-lib.mjs b/scripts/export-pdf-lib.mjs index 5647bb0f..034e8a8e 100644 --- a/scripts/export-pdf-lib.mjs +++ b/scripts/export-pdf-lib.mjs @@ -214,12 +214,12 @@ function dictionaryStart(text, close) { return -1; } -/** A dictionary's text with its nested dictionaries removed. */ +/** A dictionary's text with its nested dictionaries and arrays removed. */ function topLevel(dict) { let flat = dict; for (let prev = null; prev !== flat; ) { prev = flat; - flat = flat.replace(/<<[^<>]*>>/g, " "); + flat = flat.replace(/<<[^<>[\]]*>>|\[[^<>[\]]*\]/g, " "); } return flat; } @@ -232,9 +232,10 @@ function streamLength(dict, text) { const match = /\/Length\s+(\d+)(?:\s+(\d+)\s+R)?(?![\d.])/.exec(topLevel(dict)); if (!match) return null; if (match[2] === undefined) return parseInt(match[1], 10); - const object = new RegExp(`(?:^|[^0-9])${match[1]}\\s+${match[2]}\\s+obj\\s*(\\d+)\\s*endobj`); - const resolved = object.exec(text); - return resolved ? parseInt(resolved[1], 10) : null; + // The last definition wins, as in a PDF updated incrementally. + const object = new RegExp(`(?:^|[^0-9])${match[1]}\\s+${match[2]}\\s+obj\\s*(\\d+)\\s*endobj`, "g"); + const all = [...text.matchAll(object)]; + return all.length ? parseInt(all[all.length - 1][1], 10) : null; } /** @@ -266,11 +267,13 @@ export function pdfSyntax(bytes) { i++; } } + if (depth > 0) throw new Error("Malformed PDF: a literal string has no end"); out += "()"; } else if (c === "<" && text[i + 1] !== "<") { // A hex string, which holds only hex digits and whitespace. const close = text.indexOf(">", i + 1); - i = close === -1 ? text.length : close + 1; + if (close === -1) throw new Error("Malformed PDF: a hex string has no end"); + i = close + 1; out += "()"; } else if (c === "<") { out += "<<"; @@ -319,10 +322,15 @@ export function pdfSyntax(bytes) { * Count the pages of a PDF from its bytes: the `/Type /Page` objects (not the * `/Type /Pages` tree nodes) outside strings, streams and comments. Chrome * writes page objects uncompressed; a PDF that packs them into object streams - * counts 0, and a malformed stream throws, so either way the export reports a - * failure rather than a success. + * counts 0, and a malformed stream, an unterminated string or a file without + * %%EOF throws, so either way the export reports a failure, not a success. */ export function countPdfPages(bytes) { + // Chrome writes the page objects near the end of the file, so a file cut + // short can still hold every one. A complete PDF ends with %%EOF. + if (!/%%EOF\s*$/.test(Buffer.from(bytes).subarray(-1024).toString("latin1"))) { + throw new Error("Malformed PDF: the file does not end with %%EOF"); + } return (pdfSyntax(bytes).match(/\/Type\s*\/Page(?![A-Za-z])/g) || []).length; } diff --git a/scripts/export-pdf-lib.test.mjs b/scripts/export-pdf-lib.test.mjs index 5b8b54c2..8b1c89ff 100644 --- a/scripts/export-pdf-lib.test.mjs +++ b/scripts/export-pdf-lib.test.mjs @@ -238,7 +238,8 @@ describe("deckHtml", () => { }); describe("countPdfPages", () => { - const count = (text) => countPdfPages(Buffer.from(text, "latin1")); + // A complete PDF ends with %%EOF; fragments here get one appended. + const count = (text) => countPdfPages(Buffer.from(`${text}\n%%EOF\n`, "latin1")); it("counts page objects and ignores the page tree", () => { const pdf = @@ -294,7 +295,19 @@ describe("countPdfPages", () => { it("reads only the stream dictionary's own /Length", () => { const data = "endstream /Type /Page\n"; - const pdf = page + `5 0 obj <> /Length ${data.length}>>\nstream\n${data}\nendstream\nendobj\n`; + for (const decoy of ["/X <>", "/Foo [/Length 0]", "/Foo [<>]", "/B <>"]) { + const pdf = page + `5 0 obj <<${decoy} /Length ${data.length}>>\nstream\n${data}\nendstream\nendobj\n`; + expect(count(pdf)).toBe(1); + } + }); + + it("takes the last definition of an indirect /Length, as an incremental update does", () => { + const data = "x endstream\n/Type /Page\n"; + const pdf = + "16 0 obj 0 endobj\n" + + page + + `5 0 obj <>\nstream\n${data}\nendstream\nendobj\n` + + `16 0 obj ${data.length} endobj\n`; expect(count(pdf)).toBe(1); }); @@ -325,6 +338,8 @@ describe("countPdfPages", () => { expect(() => count(page + "5 0 obj <>\nstream\nabc\nendstream\n")).toThrow( /does not end at its \/Length/, ); + expect(() => count(page + "4 0 obj <> endobj\n")).toThrow(/literal string has no end/); + expect(() => count(page + "4 0 obj <>` is not a stream. expect(count("4 0 obj 1 endobj" + " ".repeat(70) + "stream\n" + page + "endstream\n")).toBe(1); }); @@ -338,6 +353,15 @@ describe("countPdfPages", () => { }); }); +describe("countPdfPages on a truncated file", () => { + it("throws unless the file ends with %%EOF", () => { + const pdf = "2 0 obj <> endobj\n"; + expect(() => countPdfPages(Buffer.from(pdf, "latin1"))).toThrow(/does not end with %%EOF/); + expect(countPdfPages(Buffer.from(`${pdf}%%EOF`, "latin1"))).toBe(1); + expect(countPdfPages(Buffer.from(`${pdf}%%EOF\r\n`, "latin1"))).toBe(1); + }); +}); + describe("redact", () => { it("drops URL fragments and password values", () => { const url = "http://localhost:4000/slides/deck?slide=2&export=1#password=s3cr%26t%20pw";