diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8d56cfc --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1403 @@ +{ + "name": "ghostoff", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ghostoff", + "version": "0.1.0", + "dependencies": { + "@atcute/atproto": "^4.0.3", + "@atcute/client": "^5.1.1", + "@atcute/lexicons": "^2.0.2", + "@atcute/password-session": "^1.0.1", + "@atcute/standard-site": "^2.0.2", + "commander": "^12.1.0", + "dotenv": "^16.4.7", + "linkedom": "^0.18.9", + "sharp": "^0.33.5" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "tsx": "^4.19.2", + "typescript": "^5.8.3" + } + }, + "node_modules/@atcute/atproto": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@atcute/atproto/-/atproto-4.0.3.tgz", + "integrity": "sha512-BNylfO7nK0yYBpSpnGhOYgrJTeZWrXHPrb6tOQmp9A3Am0epctIWm6/5lPC4ZNPHpUbwr5w/LzH/v7kjAoKEDg==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.2" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/client": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@atcute/client/-/client-5.1.1.tgz", + "integrity": "sha512-cn5/Zi/qo37WtQG6gzIC7JPs0RDzX9Z4eaceX45SpKgLZoc3fCFDJcE7C8xsbxBNfjry2T6PmUxWA8obebZsEQ==", + "license": "0BSD", + "dependencies": { + "@atcute/identity": "^2.0.1", + "@atcute/lexicons": "^2.0.2" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/identity": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-2.0.1.tgz", + "integrity": "sha512-FEURUvl30SyyWWikkvm+MLz0Snuf0OF10L/qxRhWjj6qDB5Ib+XWhiBuwidjvhCkrCepTUNLbj4TlUm/gHaUig==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.2", + "valibot": "^1.4.1" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/lexicons": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-2.0.2.tgz", + "integrity": "sha512-ATBADJAy4KQ76NB86BjgYKrRdbDRUo76Cbqna4WIfQAgN105Rcy972MiNKs+BSmcOOM3WakilgTm0CXD4RC0iA==", + "license": "0BSD", + "dependencies": { + "@atcute/uint8array": "^1.1.3", + "@atcute/util-text": "^1.3.3", + "@standard-schema/spec": "^1.1.0", + "esm-env": "^1.2.2" + } + }, + "node_modules/@atcute/password-session": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@atcute/password-session/-/password-session-1.0.1.tgz", + "integrity": "sha512-AH9lUVutB1SH7hwdbSJHh0iYeIBWlFrKqp7hEQ5eYb6czBVo5tOEcbBeoO+sAPazzrdykPGxbP1zftsdGMloJA==", + "license": "0BSD", + "dependencies": { + "@atcute/client": "^5.1.1", + "@atcute/identity": "^2.0.1", + "@atcute/lexicons": "^2.0.2" + }, + "peerDependencies": { + "@atcute/identity": "^2.0.0", + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/standard-site": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@atcute/standard-site/-/standard-site-2.0.2.tgz", + "integrity": "sha512-TZawTr7zVRNDP3kT1DuSryloyK8Xalt/JOXEGIkEHIB48n92/OGoNaaKMi4BlWjSDZrhpta3yTHKGUJZhAh3PQ==", + "license": "0BSD", + "dependencies": { + "@atcute/atproto": "^4.0.3", + "@atcute/lexicons": "^2.0.2" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/uint8array": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.1.3.tgz", + "integrity": "sha512-2KLcMQHUFtntY3tEjdyqqq1tR9hvPFndluWFCa637QY0cMyvq0fHSnhmZeWaSRIXMCwVDY3TLLWHNOHEWFb11g==", + "license": "0BSD" + }, + "node_modules/@atcute/util-text": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@atcute/util-text/-/util-text-1.3.3.tgz", + "integrity": "sha512-WhedTmg/msFhrdwXw9RjnNcDl8Vmisxl4+Vzyf5k3+8Gj5TKQg72dLSDtBNmNLd61RbHjgfQRBgE0ez6q/jciw==", + "license": "0BSD", + "dependencies": { + "unicode-segmenter": "^0.14.5" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", + "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", + "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", + "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", + "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", + "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", + "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", + "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", + "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", + "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.0.5" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", + "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", + "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.0.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", + "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", + "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.2.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", + "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", + "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssom": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-escaper": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", + "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==", + "license": "MIT" + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/is-arrayish": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", + "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", + "license": "MIT" + }, + "node_modules/linkedom": { + "version": "0.18.12", + "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.12.tgz", + "integrity": "sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==", + "license": "ISC", + "dependencies": { + "css-select": "^5.1.0", + "cssom": "^0.5.0", + "html-escaper": "^3.0.3", + "htmlparser2": "^10.0.0", + "uhyphen": "^0.2.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": ">= 2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", + "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "color": "^4.2.3", + "detect-libc": "^2.0.3", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.33.5", + "@img/sharp-darwin-x64": "0.33.5", + "@img/sharp-libvips-darwin-arm64": "1.0.4", + "@img/sharp-libvips-darwin-x64": "1.0.4", + "@img/sharp-libvips-linux-arm": "1.0.5", + "@img/sharp-libvips-linux-arm64": "1.0.4", + "@img/sharp-libvips-linux-s390x": "1.0.4", + "@img/sharp-libvips-linux-x64": "1.0.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", + "@img/sharp-linux-arm": "0.33.5", + "@img/sharp-linux-arm64": "0.33.5", + "@img/sharp-linux-s390x": "0.33.5", + "@img/sharp-linux-x64": "0.33.5", + "@img/sharp-linuxmusl-arm64": "0.33.5", + "@img/sharp-linuxmusl-x64": "0.33.5", + "@img/sharp-wasm32": "0.33.5", + "@img/sharp-win32-ia32": "0.33.5", + "@img/sharp-win32-x64": "0.33.5" + } + }, + "node_modules/simple-swizzle": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", + "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uhyphen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz", + "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", + "license": "ISC" + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unicode-segmenter": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==", + "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..61f64fa --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "ghostoff", + "version": "0.1.0", + "description": "Export Ghost CMS posts to Offprint / Standard.site over AT Protocol", + "type": "module", + "scripts": { + "dev": "tsx src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "typecheck": "tsc --noEmit", + "lint": "tsc --noEmit" + }, + "dependencies": { + "@atcute/atproto": "^4.0.3", + "@atcute/client": "^5.1.1", + "@atcute/lexicons": "^2.0.2", + "@atcute/password-session": "^1.0.1", + "@atcute/standard-site": "^2.0.2", + "commander": "^12.1.0", + "dotenv": "^16.4.7", + "linkedom": "^0.18.9", + "sharp": "^0.33.5" + }, + "devDependencies": { + "@types/node": "^22.14.0", + "tsx": "^4.19.2", + "typescript": "^5.8.3" + } +} diff --git a/src/assets.ts b/src/assets.ts new file mode 100644 index 0000000..62d70d7 --- /dev/null +++ b/src/assets.ts @@ -0,0 +1,232 @@ +import { createHash } from 'node:crypto'; +import { mkdir } from 'node:fs/promises'; +import { dirname, extname, join } from 'node:path'; +import sharp from 'sharp'; +import type { BlobRef } from './types.js'; +import type { Logger } from './logger.js'; +import { writeFile, readFile, stat } from 'node:fs/promises'; +import { respectRateLimit, shouldRetry, backoffDelay, parseRateLimitHeaders, sleep } from './rate-limit.js'; + +export interface AssetInfo { + url: string; + localPath: string; + blob: BlobRef; + width?: number; + height?: number; +} + +export interface AssetState { + url: string; + blob: BlobRef; + width?: number; + height?: number; +} + +const BLOB_SIZE_LIMIT = 900_000; // stay comfortably under the 1 MB atproto limit + +export function resolveUrl(base: string, url: string): string { + if (!url) return url; + if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) { + return url.startsWith('//') ? `https:${url}` : url; + } + return new URL(url, base).toString(); +} + +function assetFilePath(exportDir: string, url: string): { path: string; ext: string } { + let ext = extname(new URL(url, 'https://example.com').pathname).toLowerCase(); + if (!ext || ext.length > 5) ext = '.bin'; + if (!['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif'].includes(ext)) { + ext = '.bin'; + } + const hash = createHash('sha256').update(url).digest('hex'); + return { path: join(exportDir, 'assets', `${hash}${ext}`), ext }; +} + +function guessMimeType(path: string): string { + const ext = extname(path).toLowerCase(); + switch (ext) { + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.gif': + return 'image/gif'; + case '.webp': + return 'image/webp'; + case '.avif': + return 'image/avif'; + default: + return 'image/jpeg'; + } +} + +export async function downloadImage(url: string, localPath: string, log: Logger): Promise { + try { + await stat(localPath); + log.debug(`using cached image: ${url}`); + return readFile(localPath); + } catch { + // not cached, continue to download + } + + const response = await fetch(url, { + headers: { 'User-Agent': 'GhostOff/0.1.0' }, + }); + if (!response.ok) { + throw new Error(`download failed: ${response.status} ${response.statusText}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + await mkdir(dirname(localPath), { recursive: true }); + await writeFile(localPath, buffer); + log.debug(`downloaded image: ${url} (${buffer.length} bytes)`); + return buffer; +} + +async function processImageBuffer(buffer: Buffer, mimeType: string, log: Logger): Promise<{ buffer: Buffer; width: number; height: number; mimeType: string }> { + let image = sharp(buffer); + const metadata = await image.metadata(); + let width = metadata.width ?? 1; + let height = metadata.height ?? 1; + + // Resize very large images first + if (width > 2048 || height > 2048) { + image = image.resize({ width: 2048, height: 2048, fit: 'inside', withoutEnlargement: true }); + } + + // Use JPEG for the blob unless the source is a PNG that is already small + const targetFormat = mimeType === 'image/png' && buffer.length <= BLOB_SIZE_LIMIT ? 'png' : 'jpeg'; + + for (let attempt = 0; attempt < 10; attempt++) { + let output = image.clone(); + if (targetFormat === 'jpeg') { + const quality = Math.max(40, 90 - attempt * 6); + output = output.jpeg({ quality, progressive: true, force: true }); + } else { + output = output.png({ compressionLevel: 9, force: true }); + } + + const processed = await output.toBuffer({ resolveWithObject: true }); + if (processed.data.length <= BLOB_SIZE_LIMIT || attempt === 9) { + return { + buffer: processed.data, + width: processed.info.width, + height: processed.info.height, + mimeType: targetFormat === 'jpeg' ? 'image/jpeg' : 'image/png', + }; + } + + // If still too large, shrink the dimensions + image = image.resize({ width: Math.max(400, Math.round(width * 0.7)), height: Math.max(400, Math.round(height * 0.7)), fit: 'inside' }); + const nextMeta = await image.metadata(); + width = nextMeta.width ?? width; + height = nextMeta.height ?? height; + } + + throw new Error('unable to compress image under blob limit'); +} + +export async function getOrUploadImage( + url: string, + base: string, + exportDir: string, + stateAssets: Record, + accessJwt: string, + service: string, + log: Logger +): Promise { + const resolved = resolveUrl(base, url); + const cached = stateAssets[resolved]; + if (cached) { + log.debug(`reusing uploaded blob for ${resolved}`); + return { + url: resolved, + localPath: '', + blob: cached.blob, + width: cached.width, + height: cached.height, + }; + } + + const { path: localPath } = assetFilePath(exportDir, resolved); + let rawBuffer: Buffer; + try { + rawBuffer = await downloadImage(resolved, localPath, log); + } catch (err: any) { + log.error(`image download failed (${resolved}): ${err?.message ?? err}`); + return undefined; + } + + if (rawBuffer.length === 0) { + log.warn(`empty image: ${resolved}`); + return undefined; + } + + let processed: { buffer: Buffer; width: number; height: number; mimeType: string }; + try { + const sourceMime = guessMimeType(localPath); + processed = await processImageBuffer(rawBuffer, sourceMime, log); + } catch (err: any) { + log.error(`image processing failed (${resolved}): ${err?.message ?? err}`); + return undefined; + } + + log.debug(`uploading blob for ${resolved} (${processed.buffer.length} bytes)`); + const uploadUrl = new URL('/xrpc/com.atproto.repo.uploadBlob', service); + + let response!: Response; + for (let attempt = 0; attempt < 3; attempt++) { + response = await fetch(uploadUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessJwt}`, + 'Content-Type': processed.mimeType, + Accept: 'application/json', + }, + body: new Uint8Array(processed.buffer), + }); + + if (response.ok) break; + + const text = await response.text().catch(() => response.statusText); + log.warn(`blob upload attempt ${attempt + 1} failed (${resolved}): ${response.status} ${text}`); + + if (attempt < 2 && shouldRetry(response.status)) { + await respectRateLimit(response, log, 'uploadBlob'); + await sleep(backoffDelay(attempt)); + continue; + } + log.error(`blob upload failed (${resolved}): ${response.status} ${text}`); + return undefined; + } + + if (!response.ok) { + return undefined; + } + + const json = (await response.json()) as { blob: BlobRef }; + if (!json.blob) { + log.error(`blob upload returned no blob ref (${resolved})`); + return undefined; + } + + const rateInfo = parseRateLimitHeaders(response.headers); + if (rateInfo?.remaining !== undefined) { + log.debug(`uploadBlob rate limit: ${rateInfo.remaining}/${rateInfo.limit} remaining`); + } + + stateAssets[resolved] = { + url: resolved, + blob: json.blob, + width: processed.width, + height: processed.height, + }; + + return { + url: resolved, + localPath, + blob: json.blob, + width: processed.width, + height: processed.height, + }; +} diff --git a/src/atproto.ts b/src/atproto.ts new file mode 100644 index 0000000..ae7fd0b --- /dev/null +++ b/src/atproto.ts @@ -0,0 +1,203 @@ +import { Client, retryFetchHandler } from '@atcute/client'; +import { PasswordSession } from '@atcute/password-session'; +import type { PasswordSessionData } from '@atcute/password-session'; +import { parseResourceUri } from '@atcute/lexicons/syntax'; +import { safeParse } from '@atcute/lexicons'; +import { SiteStandardDocument } from '@atcute/standard-site'; +import { readFile, writeFile } from 'node:fs/promises'; +import type { Config } from './config.js'; +import type { BlobRef } from './types.js'; +import type { Logger } from './logger.js'; + +export type { BlobRef }; + +export interface AtprotoSession { + client: Client; + did: string; + handle: string; + accessJwt: string; + service: string; + passwordSession: PasswordSession; +} + +async function readSessionFile(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + return JSON.parse(raw) as PasswordSessionData; + } catch { + return undefined; + } +} + +async function writeSessionFile(path: string, data: PasswordSessionData): Promise { + await writeFile(path, JSON.stringify(data, null, 2), 'utf-8'); +} + +export async function getSession(config: Config, log: Logger): Promise { + const cached = await readSessionFile(config.sessionFile); + + let session: PasswordSession; + if (cached) { + log.debug('resuming cached atproto session'); + session = await PasswordSession.resume(cached, { + onUpdate(data) { + writeSessionFile(config.sessionFile, data).catch(() => {}); + }, + onDelete() { + writeSessionFile(config.sessionFile, {} as any).catch(() => {}); + }, + }); + } else { + log.info(`logging in to ${config.atpService} as ${config.atpIdentifier}`); + session = await PasswordSession.login( + { + service: config.atpService, + identifier: config.atpIdentifier, + password: config.atpAppPassword, + }, + { + onUpdate(data) { + writeSessionFile(config.sessionFile, data).catch(() => {}); + }, + onDelete() { + writeSessionFile(config.sessionFile, {} as any).catch(() => {}); + }, + } + ); + } + + const data = (session as any).session as PasswordSessionData; + const client = new Client({ + handler: retryFetchHandler({ handler: session, maxRetries: 3 }), + }); + + const sessionInfo = await client.get('com.atproto.server.getSession'); + if (!sessionInfo.ok) { + throw new Error(`failed to get session info: ${(sessionInfo.data as any).message ?? sessionInfo.status}`); + } + + return { + client, + did: sessionInfo.data.did, + handle: sessionInfo.data.handle, + accessJwt: data.accessJwt, + service: config.atpService, + passwordSession: session, + }; +} + +export async function uploadBlob( + session: AtprotoSession, + buffer: Buffer, + mimeType: string, + log: Logger +): Promise { + const url = new URL('/xrpc/com.atproto.repo.uploadBlob', session.service); + const response = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${session.accessJwt}`, + 'Content-Type': mimeType, + Accept: 'application/json', + }, + body: new Uint8Array(buffer), + }); + + if (!response.ok) { + const text = await response.text().catch(() => response.statusText); + throw new Error(`uploadBlob failed ${response.status}: ${text}`); + } + + const json = (await response.json()) as { blob: BlobRef }; + if (!json.blob) { + throw new Error('uploadBlob response missing blob'); + } + return json.blob; +} + +export interface StandardDocumentRecord { + $type: 'site.standard.document'; + site: string; + path: string; + title: string; + description?: string; + publishedAt: string; + updatedAt?: string; + textContent: string; + tags: string[]; + coverImage?: BlobRef; + content?: { $type: 'app.offprint.content'; items: unknown[] }; +} + +export function validateStandardDocument(record: StandardDocumentRecord, log: Logger): void { + const result = safeParse(SiteStandardDocument.mainSchema, record); + if (!result.ok) { + log.error(`site.standard.document validation failed: ${result.message}`); + if (result.issues) { + for (const issue of result.issues.slice(0, 10)) { + const i = issue as any; + log.error(` - ${i.path?.join('.') ?? ''}: ${i.message}`); + } + } + throw new Error(`site.standard.document validation failed: ${result.message}`); + } +} + +export interface RecordRef { + uri: string; + cid: string; +} + +export async function createRecord( + session: AtprotoSession, + collection: string, + record: unknown, + log: Logger +): Promise { + const response = await session.client.post('com.atproto.repo.createRecord', { + input: { + repo: session.did, + collection, + record, + } as any, + }); + if (!response.ok) { + throw new Error(`createRecord ${collection} failed: ${(response.data as any).message ?? response.status}`); + } + const data = response.data as any; + log.debug(`created ${collection}: ${data.uri}`); + return { uri: data.uri, cid: data.cid }; +} + +export async function putRecord( + session: AtprotoSession, + collection: string, + rkey: string, + record: unknown, + swapRecord: string, + log: Logger +): Promise { + const response = await session.client.post('com.atproto.repo.putRecord', { + input: { + repo: session.did, + collection, + rkey, + record, + swapRecord, + } as any, + }); + if (!response.ok) { + throw new Error(`putRecord ${collection}/${rkey} failed: ${(response.data as any).message ?? response.status}`); + } + const data = response.data as any; + log.debug(`updated ${collection}/${rkey}: ${data.uri}`); + return { uri: data.uri, cid: data.cid }; +} + +export function rkeyFromUri(uri: string): string { + const parsed = parseResourceUri(uri); + if (!parsed.rkey) { + throw new Error(`AT-URI missing rkey: ${uri}`); + } + return parsed.rkey; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..01238a3 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,67 @@ +import 'dotenv/config'; +import { program } from 'commander'; + +export interface Config { + ghostUrl: string; + ghostApiKey: string; + atpIdentifier: string; + atpAppPassword: string; + atpService: string; + publicationAtUri: string; + exportDir: string; + stateFile: string; + sessionFile: string; + dryRun: boolean; + verbose: boolean; +} + +function requireEnv(name: string, value: string | undefined): string { + if (!value) { + throw new Error(`Missing required configuration: ${name}`); + } + return value; +} + +export function loadConfig(argv?: string[]): Config { + program + .name('ghostoff') + .description('Export Ghost posts to Offprint via AT Protocol') + .option('--ghost-url ', 'Ghost publication URL') + .option('--ghost-api-key ', 'Ghost Content API key') + .option('--atproto-identifier ', 'atproto handle or DID') + .option('--atproto-app-password ', 'atproto app password') + .option('--atproto-service ', 'atproto PDS/service URL') + .option('--publication-at-uri ', 'existing site.standard.publication AT-URI') + .option('--export-dir ', 'local asset export/cache directory', 'ghostoff-export') + .option('--state-file ', 'idempotency state file', 'ghostoff-state.json') + .option('--session-file ', 'persisted session file', 'ghostoff-session.json') + .option('--dry-run', 'build records without uploading', false) + .option('--verbose', 'verbose logging', false); + + program.parse(argv); + const opts = program.opts(); + + const ghostUrl = requireEnv('GHOST_URL or --ghost-url', opts.ghostUrl ?? process.env.GHOST_URL); + const ghostApiKey = requireEnv('GHOST_API_KEY or --ghost-api-key', opts.ghostApiKey ?? process.env.GHOST_API_KEY); + const atpIdentifier = requireEnv('ATP_IDENTIFIER or --atproto-identifier', opts.atprotoIdentifier ?? process.env.ATP_IDENTIFIER); + const atpAppPassword = requireEnv('ATP_APP_PASSWORD or --atproto-app-password', opts.atpAppPassword ?? process.env.ATP_APP_PASSWORD); + const atpService = opts.atprotoService ?? process.env.ATP_SERVICE ?? 'https://bsky.social'; + const publicationAtUri = requireEnv( + 'ATPUBLICATION_AT_URI or --publication-at-uri', + opts.publicationAtUri ?? process.env.ATPUBLICATION_AT_URI + ); + + return { + ghostUrl: ghostUrl.replace(/\/$/, ''), + ghostApiKey, + atpIdentifier, + atpAppPassword, + atpService: atpService.replace(/\/$/, ''), + publicationAtUri, + exportDir: opts.exportDir, + stateFile: opts.stateFile, + sessionFile: opts.sessionFile, + dryRun: !!opts.dryRun, + verbose: !!opts.verbose, + }; +} diff --git a/src/facets.ts b/src/facets.ts new file mode 100644 index 0000000..ee6cf39 --- /dev/null +++ b/src/facets.ts @@ -0,0 +1,115 @@ +import type { Logger } from './logger.js'; + +export interface TextSegment { + text: string; + features: FacetFeature[]; +} + +export interface Facet { + index: { byteStart: number; byteEnd: number }; + features: FacetFeature[]; +} + +export type FacetFeature = + | { $type: 'app.offprint.richtext.facet#bold' } + | { $type: 'app.offprint.richtext.facet#italic' } + | { $type: 'app.offprint.richtext.facet#underline' } + | { $type: 'app.offprint.richtext.facet#strikethrough' } + | { $type: 'app.offprint.richtext.facet#code' } + | { $type: 'app.offprint.richtext.facet#link'; uri: string } + | { $type: 'app.offprint.richtext.facet#highlight'; color?: string }; + +export interface TextBlock { + plaintext: string; + facets?: Facet[]; +} + +export function buildTextFromInline(node: Node, activeFeatures: FacetFeature[], log: Logger): TextSegment[] { + const segments: TextSegment[] = []; + + for (const child of Array.from(node.childNodes) as Node[]) { + if (child.nodeType === 3) { + // text node + const text = (child as Text).data ?? ''; + if (text) { + segments.push({ text, features: [...activeFeatures] }); + } + } else if (child.nodeType === 1) { + const el = child as Element; + const tag = el.tagName.toLowerCase(); + const href = el.getAttribute('href'); + const newFeatures = [...activeFeatures]; + + switch (tag) { + case 'strong': + case 'b': + newFeatures.push({ $type: 'app.offprint.richtext.facet#bold' }); + break; + case 'em': + case 'i': + newFeatures.push({ $type: 'app.offprint.richtext.facet#italic' }); + break; + case 'u': + newFeatures.push({ $type: 'app.offprint.richtext.facet#underline' }); + break; + case 's': + case 'del': + newFeatures.push({ $type: 'app.offprint.richtext.facet#strikethrough' }); + break; + case 'code': + newFeatures.push({ $type: 'app.offprint.richtext.facet#code' }); + break; + case 'mark': + newFeatures.push({ $type: 'app.offprint.richtext.facet#highlight' }); + break; + case 'a': + if (href) { + newFeatures.push({ $type: 'app.offprint.richtext.facet#link', uri: href }); + } + break; + case 'br': + segments.push({ text: '\n', features: [...activeFeatures] }); + continue; + default: + // fall through to recursive processing without adding features + break; + } + + segments.push(...buildTextFromInline(el, newFeatures, log)); + } + } + + return segments; +} + +export function assembleTextBlock(segments: TextSegment[]): TextBlock { + const plaintext = segments.map((s) => s.text).join(''); + const encoder = new TextEncoder(); + let byteCursor = 0; + const facetList: Facet[] = []; + + for (const segment of segments) { + const segmentBytes = encoder.encode(segment.text); + const byteStart = byteCursor; + const byteEnd = byteCursor + segmentBytes.length; + + for (const feature of segment.features) { + facetList.push({ + index: { byteStart, byteEnd }, + features: [feature], + }); + } + + byteCursor = byteEnd; + } + + return { + plaintext, + facets: facetList.length > 0 ? facetList : undefined, + }; +} + +export function inlineToTextBlock(node: Node, log: Logger): TextBlock { + const segments = buildTextFromInline(node, [], log); + return assembleTextBlock(segments); +} diff --git a/src/ghost.ts b/src/ghost.ts new file mode 100644 index 0000000..35221b2 --- /dev/null +++ b/src/ghost.ts @@ -0,0 +1,59 @@ +import type { GhostPost, GhostSettings } from './types.js'; + +export interface GhostClientOptions { + ghostUrl: string; + ghostApiKey: string; +} + +function buildUrl(base: string, endpoint: string, params: Record): string { + const url = new URL(endpoint, base); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + return url.toString(); +} + +export async function fetchSettings({ ghostUrl, ghostApiKey }: GhostClientOptions): Promise { + const url = buildUrl(`${ghostUrl}/ghost/api/content/`, 'settings/', { + key: ghostApiKey, + }); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Ghost settings fetch failed: ${response.status} ${response.statusText}`); + } + const data = (await response.json()) as { settings: GhostSettings }; + return data.settings; +} + +export async function fetchAllPosts({ ghostUrl, ghostApiKey }: GhostClientOptions): Promise { + const posts: GhostPost[] = []; + let page = 1; + + while (true) { + const url = buildUrl(`${ghostUrl}/ghost/api/content/`, 'posts/', { + key: ghostApiKey, + include: 'authors,tags', + formats: 'html,plaintext', + limit: '100', + page: String(page), + }); + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Ghost posts fetch failed: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as { posts: GhostPost[]; meta?: { pagination?: { pages?: number } } }; + if (!Array.isArray(data.posts) || data.posts.length === 0) { + break; + } + + posts.push(...data.posts); + + const totalPages = data.meta?.pagination?.pages ?? page; + if (page >= totalPages) break; + page++; + } + + return posts; +} diff --git a/src/html-to-offprint.ts b/src/html-to-offprint.ts new file mode 100644 index 0000000..468f8eb --- /dev/null +++ b/src/html-to-offprint.ts @@ -0,0 +1,374 @@ +import { parseHTML } from 'linkedom'; +import type { BlobRef } from './types.js'; +import type { Logger } from './logger.js'; +import { resolveUrl } from './assets.js'; +import { inlineToTextBlock, type TextBlock } from './facets.js'; + +export type OffprintBlock = + | OffprintTextBlock + | OffprintHeadingBlock + | OffprintBlockquoteBlock + | OffprintCodeBlock + | OffprintBulletListBlock + | OffprintOrderedListBlock + | OffprintHorizontalRuleBlock + | OffprintImageBlock + | OffprintImageGridBlock + | OffprintWebEmbedBlock + | OffprintWebBookmarkBlock; + +export interface OffprintTextBlock { + $type: 'app.offprint.block.text'; + plaintext: string; + facets?: unknown[]; + textAlign?: 'left' | 'center' | 'right' | 'justify'; +} + +export interface OffprintHeadingBlock { + $type: 'app.offprint.block.heading'; + level: 1 | 2 | 3; + plaintext: string; + facets?: unknown[]; + textAlign?: 'left' | 'center' | 'right'; +} + +export interface OffprintCodeBlock { + $type: 'app.offprint.block.codeBlock'; + code: string; + language?: string; + showLineNumbers?: boolean; +} + +export interface OffprintImageBlock { + $type: 'app.offprint.block.image'; + image: BlobRef; + alt?: string; + caption?: string; + width?: string; + alignment?: 'left' | 'center' | 'right'; + aspectRatio?: { width: number; height: number }; +} + +export interface OffprintGridImage { + alt?: string; + blob: BlobRef; + aspectRatio?: { width: number; height: number }; +} + +export interface OffprintImageGridBlock { + $type: 'app.offprint.block.imageGrid'; + images: OffprintGridImage[]; + caption?: string; + gridRows?: 1 | 2; + aspectRatio?: 'landscape' | 'portrait' | 'square' | 'mosaic'; +} + +export interface OffprintListItem { + content: TextBlock; + children?: OffprintListItem[]; +} + +export interface OffprintBulletListBlock { + $type: 'app.offprint.block.bulletList'; + children: OffprintListItem[]; +} + +export interface OffprintOrderedListBlock { + $type: 'app.offprint.block.orderedList'; + start?: number; + children: OffprintListItem[]; +} + +export interface OffprintBlockquoteBlock { + $type: 'app.offprint.block.blockquote'; + content: (OffprintTextBlock | OffprintHeadingBlock)[]; +} + +export interface OffprintHorizontalRuleBlock { + $type: 'app.offprint.block.horizontalRule'; +} + +export interface OffprintWebEmbedBlock { + $type: 'app.offprint.block.webEmbed'; + href: string; + title?: string; + width?: string; +} + +export interface OffprintWebBookmarkBlock { + $type: 'app.offprint.block.webBookmark'; + href: string; + title: string; +} + +export function collectImageUrls(html: string, base: string): string[] { + const { document } = parseHTML(`${html}`); + const urls = new Set(); + + for (const img of Array.from(document.querySelectorAll('img')) as Element[]) { + const src = img.getAttribute('src'); + if (src) urls.add(resolveUrl(base, src)); + } + + return Array.from(urls); +} + +function getClassList(el: Element): string[] { + return (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean); +} + +function hasClass(el: Element, name: string): boolean { + return getClassList(el).includes(name); +} + +function imgBlock(img: Element, assetMap: Map, log: Logger, base: string): OffprintImageBlock | undefined { + const src = img.getAttribute('src'); + if (!src) return undefined; + const resolved = resolveUrl(base, src); + const blob = assetMap.get(resolved); + if (!blob) { + log.warn(`no blob for image ${resolved}`); + return undefined; + } + + const widthAttr = img.getAttribute('width'); + const heightAttr = img.getAttribute('height'); + const aspectRatio = widthAttr && heightAttr ? { width: Number(widthAttr), height: Number(heightAttr) } : undefined; + const alt = img.getAttribute('alt') ?? undefined; + + return { + $type: 'app.offprint.block.image', + image: blob, + alt, + aspectRatio, + }; +} + +function parseList(listEl: Element, assetMap: Map, log: Logger, base: string): OffprintBulletListBlock | OffprintOrderedListBlock { + const isOrdered = listEl.tagName.toLowerCase() === 'ol'; + const children: OffprintListItem[] = []; + + for (const li of Array.from(listEl.children) as Element[]) { + if (li.tagName.toLowerCase() !== 'li') continue; + + // The direct inline content of a list item, then nested lists + const content = inlineToTextBlock(li, log); + const nestedLists = (Array.from(li.children) as Element[]).filter((c) => ['ul', 'ol'].includes(c.tagName.toLowerCase())); + + const item: OffprintListItem = { + content: { plaintext: content.plaintext, facets: content.facets }, + children: nestedLists.length > 0 ? nestedLists.map((n) => parseListItem(n, assetMap, log, base)) : undefined, + }; + children.push(item); + } + + if (isOrdered) { + const start = listEl.getAttribute('start'); + return { + $type: 'app.offprint.block.orderedList', + start: start ? Number(start) : undefined, + children, + }; + } + + return { $type: 'app.offprint.block.bulletList', children }; +} + +function parseListItem(listEl: Element, assetMap: Map, log: Logger, base: string): OffprintListItem { + const parsed = parseList(listEl, assetMap, log, base) as OffprintBulletListBlock | OffprintOrderedListBlock; + // A nested list is turned into a single item whose content is empty and children are the real list items + return { + content: { plaintext: '' }, + children: parsed.children, + }; +} + +function parseBlockquote(el: Element, assetMap: Map, log: Logger, base: string): OffprintBlockquoteBlock | OffprintWebEmbedBlock | undefined { + // Bluesky embeds expose the AT URI on data-bluesky-uri + const blueskyUri = el.getAttribute('data-bluesky-uri'); + if (blueskyUri) { + const title = el.textContent?.trim().slice(0, 300) ?? 'Embedded post'; + return { + $type: 'app.offprint.block.webEmbed', + href: blueskyUri, + title, + }; + } + + const content: (OffprintTextBlock | OffprintHeadingBlock)[] = []; + for (const child of Array.from(el.children) as Element[]) { + const block = convertElement(child, assetMap, log, base, { inBlockquote: true }); + for (const b of Array.isArray(block) ? block : [block]) { + if (b && (b.$type === 'app.offprint.block.text' || b.$type === 'app.offprint.block.heading')) { + content.push(b); + } + } + } + + return { $type: 'app.offprint.block.blockquote', content }; +} + +function parseGallery(figure: Element, assetMap: Map, log: Logger, base: string): OffprintBlock[] { + const images: OffprintGridImage[] = []; + for (const img of Array.from(figure.querySelectorAll('img')) as Element[]) { + const src = img.getAttribute('src'); + if (!src) continue; + const resolved = resolveUrl(base, src); + const blob = assetMap.get(resolved); + if (!blob) { + log.warn(`gallery image missing blob: ${resolved}`); + continue; + } + const widthAttr = img.getAttribute('width'); + const heightAttr = img.getAttribute('height'); + const aspectRatio = widthAttr && heightAttr ? { width: Number(widthAttr), height: Number(heightAttr) } : undefined; + images.push({ alt: img.getAttribute('alt') ?? undefined, blob, aspectRatio }); + } + + const captionEl = figure.querySelector('figcaption'); + const caption = captionEl ? inlineToTextBlock(captionEl, log).plaintext || undefined : undefined; + + if (images.length === 0) return []; + if (images.length === 1) { + return [{ + $type: 'app.offprint.block.image', + image: images[0].blob, + alt: images[0].alt, + caption, + aspectRatio: images[0].aspectRatio, + }]; + } + + const blocks: OffprintBlock[] = []; + for (let i = 0; i < images.length; i += 6) { + const chunk = images.slice(i, i + 6); + if (chunk.length === 1) { + blocks.push({ + $type: 'app.offprint.block.image', + image: chunk[0].blob, + alt: chunk[0].alt, + aspectRatio: chunk[0].aspectRatio, + }); + } else { + blocks.push({ + $type: 'app.offprint.block.imageGrid', + images: chunk, + caption: i === 0 ? caption : undefined, + gridRows: chunk.length <= 2 ? 1 : 2, + }); + } + } + return blocks; +} + +function parseFigure(figure: Element, assetMap: Map, log: Logger, base: string): OffprintBlock[] { + if (hasClass(figure, 'kg-gallery-card')) { + return parseGallery(figure, assetMap, log, base); + } + + const img = figure.querySelector('img'); + if (img) { + const block = imgBlock(img, assetMap, log, base); + if (!block) return []; + const captionEl = figure.querySelector('figcaption'); + if (captionEl) { + block.caption = inlineToTextBlock(captionEl, log).plaintext || undefined; + } + return [block]; + } + + return []; +} + +interface ConvertContext { + inBlockquote?: boolean; +} + +function convertElement(el: Element, assetMap: Map, log: Logger, base: string, ctx: ConvertContext = {}): OffprintBlock | OffprintBlock[] | undefined { + const tag = el.tagName.toLowerCase(); + + switch (tag) { + case 'script': + case 'style': + case 'noscript': + return undefined; + case 'p': { + const text = inlineToTextBlock(el, log); + if (!text.plaintext.trim() && (!text.facets || text.facets.length === 0)) return undefined; + return { $type: 'app.offprint.block.text', ...text }; + } + case 'h1': + case 'h2': + case 'h3': + case 'h4': + case 'h5': + case 'h6': { + const level = Math.min(3, Number(tag[1])) as 1 | 2 | 3; + const text = inlineToTextBlock(el, log); + return { $type: 'app.offprint.block.heading', level, ...text }; + } + case 'blockquote': + return parseBlockquote(el, assetMap, log, base); + case 'pre': { + const code = el.querySelector('code'); + let language: string | undefined; + if (code) { + const cls = code.getAttribute('class') ?? ''; + const match = cls.match(/language-(\w+)/); + if (match) language = match[1]; + } + return { $type: 'app.offprint.block.codeBlock', code: (code ?? el).textContent ?? '', language }; + } + case 'ul': + case 'ol': + return parseList(el, assetMap, log, base); + case 'hr': + return { $type: 'app.offprint.block.horizontalRule' }; + case 'figure': + return parseFigure(el, assetMap, log, base); + case 'img': + return imgBlock(el, assetMap, log, base); + case 'iframe': { + const src = el.getAttribute('src'); + if (src) { + return { $type: 'app.offprint.block.webEmbed', href: src }; + } + return undefined; + } + case 'div': + case 'section': + case 'article': + case 'main': { + const results: OffprintBlock[] = []; + for (const child of Array.from(el.children) as Element[]) { + const converted = convertElement(child, assetMap, log, base, ctx); + if (converted) results.push(...(Array.isArray(converted) ? converted : [converted])); + } + return results; + } + default: + // Unknown block-level element: try to extract text + log.warn(`unsupported element <${tag}>, falling back to text`); + const text = inlineToTextBlock(el, log); + if (!text.plaintext.trim()) return undefined; + return { $type: 'app.offprint.block.text', ...text }; + } +} + +export function convertHtmlToOffprint( + html: string, + base: string, + assetMap: Map, + log: Logger +): { items: OffprintBlock[] } { + const { document } = parseHTML(`${html}`); + const items: OffprintBlock[] = []; + + for (const child of Array.from(document.body?.children ?? []) as Element[]) { + const converted = convertElement(child, assetMap, log, base); + if (!converted) continue; + items.push(...(Array.isArray(converted) ? converted : [converted])); + } + + return { items }; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..07a9c43 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,175 @@ +import { mkdir } from 'node:fs/promises'; +import { loadConfig } from './config.js'; +import { createLogger } from './logger.js'; +import { loadState, saveState, type AppState, type PostState } from './state.js'; +import { fetchAllPosts } from './ghost.js'; +import { collectImageUrls, convertHtmlToOffprint } from './html-to-offprint.js'; +import { getOrUploadImage, resolveUrl, type AssetState } from './assets.js'; +import { + getSession, + validateStandardDocument, + createRecord, + putRecord, + rkeyFromUri, + type StandardDocumentRecord, + type AtprotoSession, +} from './atproto.js'; +import type { BlobRef } from './types.js'; + +async function main() { + const config = loadConfig(); + const log = createLogger(config.verbose); + log.info('starting GhostOff'); + + await mkdir(config.exportDir, { recursive: true }); + const state = await loadState(config.stateFile); + state.publicationAtUri = config.publicationAtUri; + + const session = config.dryRun ? undefined : await getSession(config, log); + const posts = await fetchAllPosts({ ghostUrl: config.ghostUrl, ghostApiKey: config.ghostApiKey }); + log.info(`fetched ${posts.length} posts`); + + let processed = 0; + let failed = 0; + let skipped = 0; + + for (const post of posts) { + const existing = state.posts[post.uuid]; + try { + if (post.visibility !== 'public') { + log.info(`skipping non-public post: ${post.title}`); + skipped++; + continue; + } + + log.info(`processing: ${post.title}`); + const assetMap = new Map(); + + // Collect and upload inline images + const inlineUrls = collectImageUrls(post.html, config.ghostUrl); + if (post.feature_image) { + inlineUrls.push(post.feature_image); + } + + if (!config.dryRun && session) { + state.assets ??= {}; + for (const url of new Set(inlineUrls)) { + const info = await getOrUploadImage( + url, + config.ghostUrl, + config.exportDir, + state.assets as Record, + session.accessJwt, + config.atpService, + log + ); + if (info) { + assetMap.set(info.url, info.blob); + } + } + await saveState(config.stateFile, state); + } else if (config.dryRun) { + const DUMMY_CID = 'bafyreiaouvhh2kl5s3jo2uaqnn23hlpl2kut2n2qsgfvme54umlro3iari'; + for (const url of new Set(inlineUrls)) { + assetMap.set(url, { $type: 'blob', ref: { $link: DUMMY_CID }, mimeType: 'image/jpeg', size: 1000 }); + } + } + + const contentWrapper = convertHtmlToOffprint(post.html, config.ghostUrl, assetMap, log); + + const tags = (post.tags ?? []).map((t) => t.name); + const description = post.custom_excerpt ?? post.excerpt ?? undefined; + + const documentRecord: StandardDocumentRecord = { + $type: 'site.standard.document', + site: config.publicationAtUri, + path: `/${post.slug}`, + title: post.title, + description, + publishedAt: post.published_at, + updatedAt: post.updated_at, + textContent: post.plaintext ?? '', + tags, + content: { $type: 'app.offprint.content', items: contentWrapper.items }, + }; + + if (post.feature_image) { + const featureBlob = assetMap.get(resolveUrl(config.ghostUrl, post.feature_image)); + if (featureBlob) { + documentRecord.coverImage = featureBlob; + } + } + + validateStandardDocument(documentRecord, log); + + let documentRef; + if (config.dryRun || !session) { + log.info(`[dry-run] would create/update site.standard.document for "${post.title}"`); + documentRef = { uri: 'at://dry-run/site.standard.document/dry', cid: 'dry-run' }; + } else if (existing?.documentUri?.startsWith('at://did:') && existing.documentCid && existing.documentCid !== 'dry-run') { + documentRef = await putRecord( + session, + 'site.standard.document', + rkeyFromUri(existing.documentUri), + documentRecord, + existing.documentCid, + log + ); + } else { + documentRef = await createRecord(session, 'site.standard.document', documentRecord, log); + } + + const articleRecord = { + $type: 'app.offprint.document.article', + document: { + $type: 'com.atproto.repo.strongRef', + uri: documentRef.uri, + cid: documentRef.cid, + }, + }; + + let articleRef; + if (config.dryRun || !session) { + log.info(`[dry-run] would create/update app.offprint.document.article for "${post.title}"`); + articleRef = { uri: 'at://dry-run/app.offprint.document.article/dry', cid: 'dry-run' }; + } else if (existing?.articleUri?.startsWith('at://did:') && existing.articleCid && existing.articleCid !== 'dry-run') { + articleRef = await putRecord( + session, + 'app.offprint.document.article', + rkeyFromUri(existing.articleUri), + articleRecord, + existing.articleCid, + log + ); + } else { + articleRef = await createRecord(session, 'app.offprint.document.article', articleRecord, log); + } + + state.posts[post.uuid] = { + ghostSlug: post.slug, + ghostTitle: post.title, + documentUri: documentRef.uri, + documentCid: documentRef.cid, + articleUri: articleRef.uri, + articleCid: articleRef.cid, + }; + if (!config.dryRun) { + await saveState(config.stateFile, state); + } + + processed++; + } catch (err: any) { + log.error(`failed to process "${post.title ?? post.slug}": ${err?.message ?? err}`); + log.error(err?.stack ?? ''); + failed++; + } + } + + log.info(`done: ${processed} processed, ${failed} failed, ${skipped} skipped`); + process.exit(failed > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error('[ghostoff] FATAL', err); + process.exit(1); +}); diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..2529fcd --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,23 @@ +export interface Logger { + info(message: string): void; + warn(message: string): void; + error(message: string): void; + debug(message: string): void; +} + +export function createLogger(verbose: boolean): Logger { + return { + info(message: string) { + console.log(`[ghostoff] ${message}`); + }, + warn(message: string) { + console.warn(`[ghostoff] WARN ${message}`); + }, + error(message: string) { + console.error(`[ghostoff] ERROR ${message}`); + }, + debug(message: string) { + if (verbose) console.log(`[ghostoff] DEBUG ${message}`); + }, + }; +} diff --git a/src/rate-limit.ts b/src/rate-limit.ts new file mode 100644 index 0000000..e481550 --- /dev/null +++ b/src/rate-limit.ts @@ -0,0 +1,50 @@ +import type { Logger } from './logger.js'; + +export function parseRateLimitHeaders(headers: Headers): { remaining?: number; limit?: number; reset?: Date } | null { + const remaining = headers.get('ratelimit-remaining'); + const limit = headers.get('ratelimit-limit'); + const reset = headers.get('ratelimit-reset'); + + if (!remaining && !limit && !reset) { + return null; + } + + return { + remaining: remaining ? Number(remaining) : undefined, + limit: limit ? Number(limit) : undefined, + reset: reset ? new Date(Number(reset) * 1000) : undefined, + }; +} + +export async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function respectRateLimit(response: Response, log: Logger, label: string): Promise { + const retryAfter = response.headers.get('retry-after'); + if (retryAfter) { + const delay = Number(retryAfter) * 1000; + log.debug(`rate limited on ${label}, sleeping ${delay}ms (Retry-After)`); + await sleep(delay); + return; + } + + const info = parseRateLimitHeaders(response.headers); + if (info?.reset) { + const now = Date.now(); + const wait = info.reset.getTime() - now; + if (wait > 0 && wait < 120_000) { + log.debug(`rate limit reset for ${label} at ${info.reset.toISOString()}, sleeping ${wait}ms`); + await sleep(wait); + return; + } + } +} + +export function shouldRetry(status: number): boolean { + return status === 429 || status >= 500; +} + +export function backoffDelay(attempt: number, base = 1000, max = 60_000): number { + return Math.min(max, base * 2 ** attempt); +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..d23435b --- /dev/null +++ b/src/state.ts @@ -0,0 +1,34 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import type { AssetState } from './assets.js'; + +export interface PostState { + ghostSlug: string; + ghostTitle: string; + documentUri?: string; + documentCid?: string; + articleUri?: string; + articleCid?: string; +} + +export interface AppState { + publicationAtUri?: string; + assets?: Record; + posts: Record; +} + +export async function loadState(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + const parsed = JSON.parse(raw) as AppState; + return Object.assign({ posts: {} }, parsed); + } catch (err: any) { + if (err?.code === 'ENOENT') { + return { posts: {} }; + } + throw err; + } +} + +export async function saveState(path: string, state: AppState): Promise { + await writeFile(path, JSON.stringify(state, null, 2), 'utf-8'); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..fd3e874 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,51 @@ +export interface GhostAuthor { + id: string; + name: string; + slug: string; + profile_image?: string | null; + url?: string; +} + +export interface GhostTag { + id: string; + name: string; + slug: string; +} + +export interface GhostPost { + id: string; + uuid: string; + title: string; + slug: string; + html: string; + plaintext?: string; + published_at: string; + updated_at?: string; + feature_image?: string | null; + feature_image_alt?: string | null; + feature_image_caption?: string | null; + custom_excerpt?: string | null; + excerpt?: string; + url: string; + visibility: string; + featured: boolean; + tags?: GhostTag[]; + primary_tag?: GhostTag | null; + authors?: GhostAuthor[]; +} + +export interface GhostSettings { + title: string; + description?: string; + url: string; + icon?: string | null; + logo?: string | null; + accent_color?: string | null; +} + +export interface BlobRef { + $type: 'blob'; + ref: { $link: string }; + mimeType: string; + size: number; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..859c853 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022", "DOM"], + "rootDir": "./src", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["@atcute/atproto", "@atcute/standard-site", "node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "ghostoff-export"] +}