diff --git a/CLAUDE.md b/CLAUDE.md index b8d153fe..d79de30d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,9 +43,23 @@ 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 and mounts only that slide (no neighbors); the viewer's `<main>` carries `data-total-steps` and `data-current-step`. +- Chrome captures a page when its `load` event fires. With `&settle=<ms>` (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 `<head>` opens the hold, and the viewer releases it once mounted). A hold that a release ends marks `<html>` 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, 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 1. `bun run new-slideshow my-talk "My talk title"` 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 ( <html lang="en"> + <head> + {/* Runs before load can fire; it acts only on ?export=1&settle=<ms>. */} + <script dangerouslySetInnerHTML={{ __html: EXPORT_HOLD_SCRIPT }} /> + </head> <body>{children}</body> </html> ); 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..db81dada 100644 --- a/components/core/PasswordGate.tsx +++ b/components/core/PasswordGate.tsx @@ -1,6 +1,8 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { passwordFromHash } from '@/lib/slide-url'; +import { releaseExportHold } from '@/lib/export-hold'; interface PasswordGateProps { children: React.ReactNode; @@ -13,6 +15,19 @@ export default function PasswordGate({ children }: PasswordGateProps) { const correctPassword = process.env.NEXT_PUBLIC_PRIVATE_PASSWORD || ''; + // 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]); + if (unlocked) { return <>{children}</>; } diff --git a/components/core/SlideshowViewer.tsx b/components/core/SlideshowViewer.tsx index 173361fe..e369650f 100644 --- a/components/core/SlideshowViewer.tsx +++ b/components/core/SlideshowViewer.tsx @@ -4,7 +4,8 @@ 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, parseSlideParam } from '@/lib/slide-url'; +import { buildSlideUrl, exportSettleMs, isExportMode, parseSlideParam } from '@/lib/slide-url'; +import { MAX_SETTLE_MS, releaseExportHold } from '@/lib/export-hold'; interface SlideshowViewerProps { config: SlideshowConfig; @@ -14,6 +15,7 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) { const searchParams = useSearchParams(); const steps = useMemo(() => flattenSlides(config.slides), [config.slides]); const totalSteps = steps.length; + const exportMode = isExportMode(searchParams); // Read the deep-link step once. After that, currentStep owns the URL: the // effect below writes it with replaceState, and Next mirrors that write back @@ -41,6 +43,14 @@ function SlideshowViewerClient({ config }: SlideshowViewerProps) { window.history.replaceState(null, '', url); }, [currentStep, config.id]); + // An export capture holds the page's load event open (lib/export-hold.ts). + // Start its settle time once the slide is in the DOM. + useEffect(() => { + if (mounted && exportMode) { + releaseExportHold(exportSettleMs(searchParams, MAX_SETTLE_MS)); + } + }, [mounted, exportMode, searchParams]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'ArrowRight' || e.key === ' ') { @@ -92,10 +102,19 @@ 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} + data-current-step={currentStep} + > {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) { @@ -113,7 +132,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/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 <head>, 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 new file mode 100644 index 00000000..1226b8c1 --- /dev/null +++ b/lib/export-hold.ts @@ -0,0 +1,61 @@ +/** + * Export mode holds the page's `load` event open, because a headless browser + * captures the page at `load` and a live embed needs real time to render. + * + * EXPORT_HOLD_SCRIPT runs inline in <head>, so it runs before `load` can + * fire. On a URL with `export=1` and `settle=<ms>` (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 <html> 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. */ +export const MAX_SETTLE_MS = 60_000; + +/** How long the hold lasts when the viewer never mounts to release it. */ +export const MAX_HOLD_MS = 120_000; + +export const EXPORT_HOLD_SCRIPT = `(function () { + var query = new URLSearchParams(location.search); + 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"); + frame.tabIndex = -1; + document.head.appendChild(frame); + var doc = frame.contentDocument; + if (!doc) return; + doc.open(); + var ended = false; + var released = false; + 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(function () { end(false); }, ${MAX_HOLD_MS}); + window.__slideshowExportHold = { + release: function (ms) { + if (released) return; + released = true; + clearTimeout(fallback); + setTimeout(function () { end(true); }, ms); + }, + }; +})();`; + +declare global { + interface Window { + __slideshowExportHold?: { release: (ms: number) => void }; + } +} + +/** End the export hold `ms` from now. Does nothing when no hold is open. */ +export function releaseExportHold(ms: number): void { + window.__slideshowExportHold?.release(ms); +} diff --git a/lib/slide-url.test.ts b/lib/slide-url.test.ts index a10a9b9d..fac5bd52 100644 --- a/lib/slide-url.test.ts +++ b/lib/slide-url.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { buildSlideUrl, parseSlideParam } from "./slide-url"; +import { + buildSlideUrl, + exportSettleMs, + isExportMode, + parseSlideParam, + passwordFromHash, +} from "./slide-url"; describe("buildSlideUrl", () => { it("preserves basePath when on step 0", () => { @@ -51,6 +57,74 @@ describe("buildSlideUrl", () => { }); }); +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, + ); + }); +}); + +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"); + }); +}); + +describe("exportSettleMs", () => { + const read = (query: string) => exportSettleMs(new URLSearchParams(query), 60_000); + + it("reads a whole number of milliseconds", () => { + expect(read("export=1&settle=8000")).toBe(8000); + expect(read("settle=0")).toBe(0); + }); + + it("stays within 0 and the maximum for any input", () => { + expect(read("settle=999999999")).toBe(60_000); + for (const value of ["", "-5", "1.5", "abc", "1e9", " 8000", "8000ms", "Infinity"]) { + expect(read(`settle=${encodeURIComponent(value)}`)).toBe(0); + } + expect(read("export=1")).toBe(0); + }); +}); + describe("parseSlideParam", () => { it("returns the step for a valid param", () => { expect(parseSlideParam("5", 10)).toBe(5); diff --git a/lib/slide-url.ts b/lib/slide-url.ts index 18d963d0..c070177f 100644 --- a/lib/slide-url.ts +++ b/lib/slide-url.ts @@ -28,3 +28,34 @@ 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"; +} + +/** + * Milliseconds an export capture asks the page to stay still after the slide + * mounts (`settle=<ms>`), as a whole number from 0 to `max`. Anything else + * reads as 0; EXPORT_HOLD_SCRIPT opens no hold for those values. + */ +export function exportSettleMs( + params: Pick<URLSearchParams, "get">, + max: number, +): number { + const raw = params.get("settle") ?? ""; + if (!/^\d+$/.test(raw)) return 0; + return Math.min(parseInt(raw, 10), max); +} + +/** + * 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/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-chrome.mjs b/scripts/export-pdf-chrome.mjs new file mode 100644 index 00000000..8395b1ad --- /dev/null +++ b/scripts/export-pdf-chrome.mjs @@ -0,0 +1,103 @@ +/** + * 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(); + +/** + * 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; + 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) => { + const target = redact(args.at(-1)); + if (timedOut) { + 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}`; + const detail = redact(stderr.trim().split("\n").at(-1)); + settle(reject, new Error(`Chrome ${reason} on ${target}: ${detail}`)); + } + }); + }); +} + +/** 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 new file mode 100644 index 00000000..034e8a8e --- /dev/null +++ b/scripts/export-pdf-lib.mjs @@ -0,0 +1,366 @@ +/** + * 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] [options] + + --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 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) + EXPORT_WAIT_MS time each slide stays still after it mounts, + before the capture, in milliseconds (default + 8000, at most 60000) + 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", + "--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 the options in USAGE, in any order. */ +export function parseArgs(argv) { + const positional = []; + const options = {}; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + 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"); + if (positional.length > 2) { + throw new Error(`Unexpected argument ${positional[2]}`); + } + return { + help: false, + id, + output: output || `${id}.pdf`, + url: options["--url"] ? options["--url"].replace(/\/+$/, "") : null, + size: parseSize(options["--size"] || "1920x1080"), + scale: parseScale(options["--scale"] || "1"), + }; +} + +/** + * 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. `settle` + * (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 = null, password = null } = {}) { + const url = new URL(`/slides/${encodeURIComponent(id)}`, origin); + url.searchParams.set("slide", String(step)); + url.searchParams.set("export", "1"); + if (settle !== null) url.searchParams.set("settle", String(settle)); + if (password !== null) url.hash = `password=${encodeURIComponent(password)}`; + 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 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 <html> 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 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(/<html\b[^>]*>/); + const title = dom.match(/<title[^>]*>([^<]*)<\/title>/); + return { + total: total && total > 0 ? total : null, + step: number("data-current-step"), + settled: html ? /\sdata-export-settled(?:=|\s|>|$)/.test(html[0]) : false, + gated: /<input[^>]*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; + return `step-${String(step).padStart(width, "0")}.jpg`; +} + +/** + * 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, { width, height, title }) { + const images = fileNames + .map((name) => `<img src="${encodeURI(name)}" alt="">`) + .join("\n"); + return `<!DOCTYPE html> +<html><head><meta charset="utf-8"><title>${escapeHtml(title || "Slides")} +${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. `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 - 3; 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; +} + +/** 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, " "); + } + return flat; +} + +/** + * 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); + // 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; +} + +/** + * 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"); + 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++; + } + } + 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); + if (close === -1) throw new Error("Malformed PDF: a hex string has no end"); + i = 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) && /[\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 from = dictionaryStart(out, close); + const length = from === -1 ? null : streamLength(out.slice(from, close - 2), text); + let end; + 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 { + 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 + "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, 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; +} + +/** + * Map `items` through async `fn` with at most `limit` running at once, keeping + * 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) { + 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 (!failed && next < items.length) { + const i = next++; + try { + results[i] = await fn(items[i], i); + } catch (error) { + if (!failed) { + failed = true; + firstError = error; + } + } + } + } + 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 new file mode 100644 index 00000000..8b1c89ff --- /dev/null +++ b/scripts/export-pdf-lib.test.mjs @@ -0,0 +1,571 @@ +// @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 { EXPORT_HOLD_SCRIPT } from "../lib/export-hold"; +import { findChrome, redact, runChrome, runningChromeCount } from "./export-pdf-chrome.mjs"; +import { + checkCapture, + countPdfPages, + deckHtml, + mapLimit, + parseArgs, + parseDeckDom, + parseIntSetting, + shotName, + stepUrl, +} from "./export-pdf-lib.mjs"; + +describe("parseArgs", () => { + 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 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/", + "--size", + "1440x810", + "--scale", + "2", + ]), + ).toEqual(expected); + expect( + parseArgs([ + "--scale=2", + "--url=https://www.policyengine.org", + "deck", + "--size=1440x810", + "out/deck.pdf", + ]), + ).toEqual(expected); + }); + + 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 }); + }); +}); + +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( + "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", + ); + }); + + 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&settle=0", + ); + }); + + 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, { settle: 500, password }), + ); + expect(url.search).toBe("?slide=3&export=1&settle=500"); + expect(new URLSearchParams(url.hash.slice(1)).get("password")).toBe(password); + } + expect(new URL(stepUrl("http://localhost:4000", "deck", 3)).hash).toBe(""); + }); +}); + +describe("parseDeckDom", () => { + it("reads the step count, shown step and title from the viewer", () => { + const dom = + "Taxes & benefits | PolicyEngine" + + '
'; + expect(parseDeckDom(dom)).toEqual({ + total: 36, + step: 4, + settled: true, + gated: false, + title: "Taxes & benefits | PolicyEngine", + }); + }); + + 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", () => { + 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", () => { + 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"]); + }); + + 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 titled = deckHtml(["step-0.jpg"], { + width: 1920, + height: 1080, + title: "Taxes & ", + }); + expect(titled).toContain("Taxes & <benefits>"); + }); +}); + +describe("countPdfPages", () => { + // 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 = + "1 0 obj <> endobj\n" + + "2 0 obj <> endobj\n" + + "3 0 obj <> endobj\n"; + expect(count(pdf)).toBe(2); + 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", () => { + 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 <>\r\nstream\r\nabc\r\nendstream\r\nendobj\r\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("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"; + 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); + }); + + 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/, + ); + 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/, + ); + 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); + }); + + 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("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"; + 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"); + }); +}); + +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([]); + }); + + 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 = []; + 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, + // and item 1 ran to its end before mapLimit settled. + expect(started).toEqual([0, 1]); + expect(finished).toEqual([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); + + // A page with the export hold in its head. `release` stands in for the + // 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) => { + 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; + }; + const holdState = (dom) => /

(\w+)<\/p>/.exec(dom)?.[1]; + + it("holds load until the settle time after the release, and marks the page settled", async () => { + 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(" { + 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(" { + 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(); + } + }, 300_000); + + 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 + // 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, 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(); + 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 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, + }); + 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(); + await new Promise((resolve) => silent.close(resolve)); + } + }, 60_000); +}); diff --git a/scripts/export-pdf.mjs b/scripts/export-pdf.mjs index df457eac..e114996e 100644 --- a/scripts/export-pdf.mjs +++ b/scripts/export-pdf.mjs @@ -1,286 +1,298 @@ /** * Export a slideshow to PDF. * - * Usage: bun run export [output-path] - * Example: bun run export abundance-dmv ~/Desktop/abundance-dmv.pdf + * 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 * - * 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 + * bun run export bls-taxsim-2026 bls.pdf --size 1440x810 --scale 2 * * 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, 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`. A private deck is unlocked with + * NEXT_PUBLIC_PRIVATE_PASSWORD, passed in the URL fragment. + * 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 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. + * + * 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 { 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, + checkCapture, + countPdfPages, + deckHtml, + mapLimit, + parseArgs, + parseDeckDom, + parseIntSetting, + shotName, + stepUrl, +} from "./export-pdf-lib.mjs"; + +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). + +let work = null; +let builder = null; +let server = null; +let stopping = false; -const [id, outputArg] = process.argv.slice(2); -if (!id) { - console.error("Usage: bun run export [output-path]"); - console.error("Example: bun run export abundance-dmv ~/Desktop/slides.pdf"); - process.exit(1); +function cleanup() { + stopping = true; + killAllChrome(); + if (builder) { + builder.kill(); + builder = null; + } + if (server) { + server.kill(); + server = null; + } + if (work) { + rmSync(work, { recursive: true, force: true }); + work = null; + } } -const OUTPUT = outputArg || `${id}.pdf`; -// ── helpers ────────────────────────────────────────────────────────── +for (const [signal, code] of [ + ["SIGINT", 130], + ["SIGTERM", 143], +]) { + process.once(signal, () => { + cleanup(); + process.exit(code); + }); +} + +// ── 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 ` 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(); - } - }); +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..."); + return new Promise((resolve, reject) => { + const proc = spawn(nextBin, ["build"], { stdio: "inherit" }); + builder = proc; proc.on("error", (e) => { - clearTimeout(timeout); + builder = null; reject(e); }); - }); - await ready; - return proc; -} - -/** 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; - } + proc.on("close", (code, signal) => { + builder = null; + if (code === 0) resolve(); + else reject(new Error(`Build failed (${signal ? `stopped by ${signal}` : `exit ${code}`})`)); }); }); - // 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"; - }); -} - -/** 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); +/** 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" }); + 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 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, or this request timed out } - return null; - }); + await new Promise((r) => setTimeout(r, 500)); + } + 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; + let waitMs; + let concurrency; + try { + args = parseArgs(process.argv.slice(2)); + waitMs = parseIntSetting("EXPORT_WAIT_MS", process.env.EXPORT_WAIT_MS, 8_000, { + min: 0, + max: 60_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); + } + 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(); + if (!binary) { + throw new Error("Chrome not found. Set CHROME_PATH to a Chrome or Chromium binary."); + } + const output = path.resolve(args.output); - 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) { + await build(); + origin = await startServer(await getFreePort()); + } + work = mkdtempSync(path.join(tmpdir(), `export-${args.id}-`)); - // 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"]'); - 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)); - } + // 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 = { + timeoutMs: waitMs + 120_000, + extraFlags: [ + `--window-size=${args.size.width},${args.size.height}`, + `--force-device-scale-factor=${args.scale}`, + `--timeout=${waitMs + 30_000}`, + ], + }; + const at = (step, password = null) => + stepUrl(origin, args.id, step, { settle: waitMs, password }); - // 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?", + // 1. Count the steps, unlocking the deck if it is private. + console.log(`Reading ${at(0)}`); + let password = null; + 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", at(0, password)], render), + ); + if (deck.gated) { + throw new Error( + "This deck is private. Set NEXT_PUBLIC_PRIVATE_PASSWORD to its password.", ); - process.exit(1); } - console.log(`Detected ${totalSteps} 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`); - // 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++) { - try { - // fixImages only rewrites basePath-less 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" }); - 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)); + // 2. Screenshot each step. A step that fails in the parallel pass is + // 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]); + 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; + 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`); } - screenshots.push(shot); } + } - // 6. Combine into PDF - console.log("Building PDF..."); - const html = ` - -${screenshots.map((s) => ``).join("\n")} -`; - - await page.setContent(html, { waitUntil: "load" }); - await page.pdf({ - path: OUTPUT, - width: "1920px", - height: "1080px", - printBackground: true, - }); - - console.log(`PDF saved to ${OUTPUT}`); - } finally { - if (browser) await browser.close(); - server.kill(); + // 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); - process.exit(1); -}); +main() + .then(() => cleanup()) + .catch((e) => { + cleanup(); + console.error(e.message || e); + process.exit(1); + });