diff --git a/.gitea/workflows/build-and-deploy.yaml b/.gitea/workflows/build-and-deploy.yaml index 72fdbf2..5d1ca6a 100644 --- a/.gitea/workflows/build-and-deploy.yaml +++ b/.gitea/workflows/build-and-deploy.yaml @@ -36,8 +36,8 @@ jobs: - name: Upload build artifact uses: actions/upload-artifact@v3 with: - name: dist - path: dist/ + name: public + path: public/ build-image: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a31123c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +public/ +dist/ +.env diff --git a/Dockerfile b/Dockerfile index de3d7e4..581f198 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,9 @@ FROM node:22-alpine AS builder WORKDIR /app +# Install build dependencies for sharp (libvips) +RUN apk add --no-cache python3 make g++ vips-dev + # Copy package files and install dependencies COPY package*.json ./ RUN npm ci @@ -15,12 +18,12 @@ RUN npm run build FROM nginx:alpine # Copy built site -COPY --from=builder /app/dist /usr/share/nginx/html +COPY --from=builder /app/public /usr/share/nginx/html # Copy ads.txt if present at root -COPY --from=builder /app/ads.txt /usr/share/nginx/html/ads.txt +COPY --from=builder /app/public/ads.txt /usr/share/nginx/html/ads.txt -# Custom nginx config for SPA/history routing +# Custom nginx config for SPA/history routing with image format support RUN echo 'server { \ listen 80; \ server_name localhost; \ @@ -29,7 +32,7 @@ RUN echo 'server { \ location / { \ try_files $uri $uri/ /index.html; \ } \ - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { \ + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp|avif)$ { \ expires 1y; \ add_header Cache-Control "public, immutable"; \ } \ diff --git a/build.js b/build.js deleted file mode 100644 index 43d23f8..0000000 --- a/build.js +++ /dev/null @@ -1,46 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const srcDir = path.join(__dirname, 'src'); -const distDir = path.join(__dirname, 'dist'); - -if (!fs.existsSync(distDir)) { - fs.mkdirSync(distDir, { recursive: true }); -} - -// Simple build: copy static assets and generate index.html -const indexHtml = ` - - - - - Signal Ledger - - - -
-

Signal Ledger

-

Independent news. Clear signal.

-
-
-

Welcome to Signal Ledger — a subsidiary of Jopdorp.

-
- - - -`; - -fs.writeFileSync(path.join(distDir, 'index.html'), indexHtml); - -// Copy style.css if it exists -const styleSrc = path.join(srcDir, 'style.css'); -const styleDest = path.join(distDir, 'style.css'); -if (fs.existsSync(styleSrc)) { - fs.copyFileSync(styleSrc, styleDest); -} else { - fs.writeFileSync(styleDest, `body{font-family:system-ui,sans-serif;margin:0;padding:2rem;background:#0b0c10;color:#c5c6c7}header{border-bottom:1px solid #45a29e;padding-bottom:1rem;margin-bottom:2rem}h1{color:#66fcf1;margin:0}.tagline{color:#45a29e}footer{margin-top:4rem;padding-top:1rem;border-top:1px solid #45a29e;font-size:.875rem;color:#45a29e}`); -} - -console.log('Build complete: dist/'); diff --git a/dist/index.html b/dist/index.html deleted file mode 100644 index df271e3..0000000 --- a/dist/index.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - Signal Ledger - - - -
-

Signal Ledger

-

Independent news. Clear signal.

-
-
-

Welcome to Signal Ledger — a subsidiary of Jopdorp.

-
- - - diff --git a/dist/style.css b/dist/style.css deleted file mode 100644 index 07fd72d..0000000 --- a/dist/style.css +++ /dev/null @@ -1 +0,0 @@ -body{font-family:system-ui,sans-serif;margin:0;padding:2rem;background:#0b0c10;color:#c5c6c7}header{border-bottom:1px solid #45a29e;padding-bottom:1rem;margin-bottom:2rem}h1{color:#66fcf1;margin:0}.tagline{color:#45a29e}footer{margin-top:4rem;padding-top:1rem;border-top:1px solid #45a29e;font-size:.875rem;color:#45a29e} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 592a5b7..fee0401 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,1043 @@ "": { "name": "signalledger.nl", "version": "1.0.0", - "devDependencies": {} + "dependencies": { + "openai": "^4.104.0", + "rss-parser": "^3.13.0", + "sharp": "^0.34.5" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "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.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "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.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "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.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "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.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "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.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "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-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "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-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "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.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "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.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "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.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "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.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "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-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "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.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "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/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "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/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/rss-parser": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/rss-parser/-/rss-parser-3.13.0.tgz", + "integrity": "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w==", + "license": "MIT", + "dependencies": { + "entities": "^2.0.3", + "xml2js": "^0.5.0" + } + }, + "node_modules/sax": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "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/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } } } } diff --git a/package.json b/package.json index 9a08128..da55612 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,16 @@ { "name": "signalledger.nl", "version": "1.0.0", - "description": "Signal Ledger news site", + "private": true, + "type": "module", "scripts": { - "build": "node build.js", - "test": "node test.js" + "build": "node src/build.js", + "test": "node test.js", + "serve": "python3 -m http.server 8080 -d public" }, - "devDependencies": {} + "dependencies": { + "openai": "^4.104.0", + "rss-parser": "^3.13.0", + "sharp": "^0.34.5" + } } diff --git a/src/build.js b/src/build.js new file mode 100644 index 0000000..f122a38 --- /dev/null +++ b/src/build.js @@ -0,0 +1,718 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import Parser from 'rss-parser'; +import OpenAI from 'openai'; +import sharp from 'sharp'; +import { FEEDS } from './feeds.js'; + +const ROOT = path.resolve(process.cwd()); +const OUT_DIR = path.join(ROOT, 'public'); +const ARTICLES_DIR = path.join(OUT_DIR, 'articles'); +const SECTIONS_DIR = path.join(OUT_DIR, 'sections'); +const IMAGES_DIR = path.join(OUT_DIR, 'images'); +const STATE_FILE = path.join(OUT_DIR, 'articles-state.json'); +const IMAGE_META_FILE = path.join(OUT_DIR, 'image-meta.json'); +const MAX_ITEMS_PER_FEED = Number(process.env.MAX_ITEMS_PER_FEED || 8); +const MAX_HOMEPAGE_ITEMS = Number(process.env.MAX_HOMEPAGE_ITEMS || 36); +const MODEL = process.env.OPENAI_MODEL || 'gpt-4.1-mini'; +const SITE_NAME = process.env.SITE_NAME || 'Signal Ledger'; +const SITE_DOMAIN = process.env.SITE_DOMAIN || 'signalledger.nl'; +const SITE_URL = `https://${SITE_DOMAIN}`; +const SITE_TAGLINE = process.env.SITE_TAGLINE || 'A digital news magazine for readers who want clear reporting, context, and judgment.'; +const EDITOR_NOTE = process.env.EDITOR_NOTE || 'Signal Ledger is built as a serious front page for readers who want more than headlines. We synthesize major reporting, add context, explain why events matter, and keep source attribution visible without turning the site into a mere link directory.'; +const CONTACT_EMAIL = process.env.CONTACT_EMAIL || 'signalledger@jopdorp.nl'; +const PARENT_COMPANY = process.env.PARENT_COMPANY || 'Jopdorp'; +const ADSENSE_CLIENT = process.env.ADSENSE_CLIENT || 'ca-pub-1269854634225826'; +const ADSENSE_SLOT = process.env.ADSENSE_SLOT || '7019613848'; +const IMAGE_MODEL = process.env.OPENAI_IMAGE_MODEL || 'gpt-image-1'; +const GENERATED_IMAGE_LIMIT = Number(process.env.GENERATED_IMAGE_LIMIT || 3); +const parser = new Parser({ timeout: 15000 }); + +function escapeHtml(input = '') { + return String(input) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function stripHtml(input = '') { + return String(input) + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/Article URL:\s*https?:\/\/\S+/gi, ' ') + .replace(/Comments URL:\s*https?:\/\/\S+/gi, ' ') + .replace(/Points:\s*\d+/gi, ' ') + .replace(/#\s*Comments:\s*\d+/gi, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function slugify(input = '') { + return String(input) + .toLowerCase() + .replace(/&/g, ' and ') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 90) || 'story'; +} + +function formatDate(date) { + return new Date(date).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: 'UTC' + }) + ' UTC'; +} + +function xmlEscape(input = '') { + return String(input) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function hashString(input = '') { + let hash = 0; + for (let i = 0; i < input.length; i++) { + hash = ((hash << 5) - hash) + input.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +function trimSentence(input = '', maxLength = 280) { + const cleaned = stripHtml(input) + .replace(/\s*[-–|]\s*(live|updates?|analysis|explained)\b.*$/i, '') + .replace(/\b(read more|click here|watch live)\b.*$/i, '') + .trim(); + if (!cleaned) return ''; + if (cleaned.length <= maxLength) return cleaned; + const slice = cleaned.slice(0, maxLength); + const stop = Math.max(slice.lastIndexOf('. '), slice.lastIndexOf('; '), slice.lastIndexOf(', ')); + return `${(stop > 90 ? slice.slice(0, stop) : slice).trim().replace(/[,:;\-]+$/, '')}.`; +} + +function cleanTitle(title = '') { + return stripHtml(title) + .replace(/\s+-\s+(Reuters|AP News|BBC News|BBC)$/i, '') + .replace(/\s+AP News$/i, '') + .replace(/\s+Reuters$/i, '') + .trim(); +} + +function titleToLowerPhrase(title = '') { + const normalized = cleanTitle(title).trim(); + if (!normalized) return 'the latest development'; + if (/[A-Z]{2,}/.test(normalized) || /[:?]/.test(normalized)) return normalized; + return normalized.charAt(0).toLowerCase() + normalized.slice(1); +} + +function titleToSentence(title = '') { + const normalized = cleanTitle(title).trim(); + if (!normalized) return 'This development'; + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function firstClause(input = '', maxLength = 180) { + const cleaned = trimSentence(input, maxLength); + if (!cleaned) return ''; + const stop = cleaned.search(/[.;:]/); + return (stop > 40 ? cleaned.slice(0, stop) : cleaned).trim().replace(/[,:;\-]+$/, ''); +} + +function buildFallbackLabels(item) { + if (item.category === 'Technology') { + return { + summaryLabel: 'What shipped', + stakesLabel: 'Why it matters in tech', + contextLabel: 'What it signals', + viewLabel: 'A Signal Ledger view' + }; + } + if (item.category === 'Top Stories') { + return { + summaryLabel: 'The lead', + stakesLabel: 'Why this leads', + contextLabel: 'What sits behind it', + viewLabel: 'The editorial line' + }; + } + return { + summaryLabel: 'What happened', + stakesLabel: 'Why it matters', + contextLabel: 'The wider picture', + viewLabel: 'The editorial line' + }; +} + +function buildFallbackCopy(item) { + const phrase = titleToLowerPhrase(item.title); + const sentenceTitle = titleToSentence(item.title); + const snippet = trimSentence(item.snippet, 360); + const labels = buildFallbackLabels(item); + const gist = snippet + ? (snippet.split(/[.;:]/).find((part) => part.trim().length > 30) || snippet).trim() + : ''; + + let summary; + let whyItMatters; + let context; + let viewpoint; + + if (item.category === 'Technology') { + summary = snippet + ? `${phrase} matters less as a novelty item than as a signal about tools, leverage, and how technical work may be shifting. ${snippet}` + : `${phrase} looks worth tracking because technology stories matter most when they hint at a broader change in tooling, workflow, or platform power.`; + whyItMatters = `The useful test in technology coverage is whether something changes the economics of building, shipping, or operating software. This item is worth watching for what it may signal about adoption curves, competitive pressure, and the next default way of working.`; + context = gist + ? `The headline is only the entry point. The more revealing layer is the operating picture underneath it: ${gist}.` + : `Technology stories become meaningful when they move from demo to default behavior. The important question here is whether this points to that kind of transition.`; + viewpoint = `Signal Ledger's technology coverage is interested less in spectacle than in durable shifts in leverage, control points, cost structures, and the habits that become normal once a tool crosses into routine use.`; + } else if (item.category === 'Top Stories') { + summary = snippet + ? `${phrase} belongs near the top of the front page because it shapes the political, economic, or diplomatic weather around everything else. ${snippet}` + : `${phrase} looks like a front-page story because it carries the kind of agenda-setting force that tends to reorder the rest of the news cycle.`; + whyItMatters = `Top stories deserve the slot when they change how institutions, markets, and the public interpret what comes next. The point is not just that something happened, but that it now carries framing power over the wider day.`; + context = gist + ? `Seen in context, the event matters because of the pressure it concentrates: ${gist}.` + : `The bigger question is what this development now makes easier, harder, or more urgent for the people forced to respond to it.`; + viewpoint = `Signal Ledger treats front-page stories as signals of momentum. The first fact matters, but the more valuable read is what the event may unlock, constrain, or force into the open next.`; + } else { + summary = snippet + ? `${sentenceTitle} stands out less as an isolated incident than as a marker of pressure building in the wider international picture. ${snippet}` + : `${sentenceTitle} looks consequential because world news matters most when a local event starts carrying diplomatic, economic, or security implications beyond its immediate setting.`; + whyItMatters = `World coverage becomes useful when a single development begins to alter diplomatic room, economic confidence, or the risk calculations of states and institutions far beyond where the event first lands.`; + context = gist + ? `Read beyond the headline, the more revealing element is the direction of travel: ${gist}.` + : `The useful frame is not only what happened, but which larger regional or international pattern the event may now be feeding.`; + viewpoint = `Signal Ledger's world coverage is interested in second-order effects: shifts in leverage, credibility, deterrence, public mood, and the room leaders still have to change course.`; + } + + return { + dek: `Source: ${item.source}`, + summary, + whyItMatters, + context, + viewpoint, + sourceNote: `${item.source} reporting: ${item.link}`, + ...labels + }; +} + +function normalizeItem(feed, item) { + return { + source: feed.source, + category: feed.category, + feedId: feed.id, + title: cleanTitle(item.title || 'Untitled'), + link: item.link || item.guid || '#', + published: item.isoDate || item.pubDate || new Date().toISOString(), + snippet: stripHtml(item.contentSnippet || item.summary || item.content || item['content:encoded'] || '').slice(0, 1600) + }; +} + +async function fetchFeed(feed) { + const parsed = await parser.parseURL(feed.url); + return (parsed.items || []).slice(0, MAX_ITEMS_PER_FEED).map((item) => normalizeItem(feed, item)); +} + +async function readJsonFile(filePath, fallback) { + try { + return JSON.parse(await fs.readFile(filePath, 'utf8')); + } catch { + return fallback; + } +} + +function dedupe(items) { + const out = []; + const seen = new Set(); + for (const item of items) { + if (!seen.has(item.link)) { + seen.add(item.link); + out.push(item); + } + } + return out; +} + +async function enrichItems(items) { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + return items.map((item, idx) => ({ + ...item, + id: idx + 1, + ...buildFallbackCopy(item) + })); + } + + const client = new OpenAI({ apiKey }); + const payload = items.map((item, idx) => ({ + id: idx + 1, + source: item.source, + category: item.category, + title: item.title, + link: item.link, + published: item.published, + snippet: item.snippet + })); + + const prompt = `You are writing concise, polished digital-journalism briefs for a serious magazine called Signal Ledger. Return strict JSON array only. Each object must contain: id, dek, summary, whyItMatters, context, viewpoint, sourceNote, summaryLabel, stakesLabel, contextLabel, viewLabel. Rules: write as our publication, not as a book report on another outlet. Do not start every story with phrases like "BBC reported", "Reuters reported", or "X is reporting on". Avoid formulaic constructions, repeated sentence skeletons, and filler. Use strong publication ledes, vary rhythm from story to story, and weave attribution in naturally when needed. Treat categories differently: Top Stories should feel front-page and agenda-setting, World should feel geopolitical/international, Technology should feel sharp about leverage, product direction, infrastructure, and adoption. dek should usually be short like "Source: BBC" or another compact deck. summary must be one polished paragraph, 50-110 words, never empty. The four label fields must be short, elegant, and article-specific, not generic repeated boilerplate. context and viewpoint must add interpretive value rather than restating the snippet. Do not invent facts beyond the supplied data. Items: ${JSON.stringify(payload)}`; + + let parsed = []; + try { + const resp = await client.responses.create({ + model: MODEL, + input: prompt, + text: { format: { type: 'text' } } + }); + + const text = resp.output_text || '[]'; + try { + parsed = JSON.parse(text); + } catch { + const match = text.match(/\[[\s\S]*\]/); + parsed = match ? JSON.parse(match[0]) : []; + } + } catch (error) { + console.error('Falling back to deterministic copy after OpenAI enrichment failure:', error?.message || error); + } + + const byId = new Map(parsed.map((entry) => [entry.id, entry])); + return items.map((item, idx) => { + const data = byId.get(idx + 1) || {}; + const fallback = buildFallbackCopy(item); + return { + ...item, + id: idx + 1, + dek: (data.dek && !/^source:/i.test(data.dek.trim())) ? data.dek : '', + summary: trimSentence(data.summary || '', 900) || fallback.summary, + whyItMatters: trimSentence(data.whyItMatters || '', 700) || fallback.whyItMatters, + context: trimSentence(data.context || '', 700) || fallback.context, + viewpoint: trimSentence(data.viewpoint || '', 700) || fallback.viewpoint, + sourceNote: trimSentence(data.sourceNote || '', 260) || fallback.sourceNote, + summaryLabel: trimSentence(data.summaryLabel || '', 60) || fallback.summaryLabel, + stakesLabel: trimSentence(data.stakesLabel || '', 60) || fallback.stakesLabel, + contextLabel: trimSentence(data.contextLabel || '', 60) || fallback.contextLabel, + viewLabel: trimSentence(data.viewLabel || '', 60) || fallback.viewLabel + }; + }); +} + +function decorateItems(items, existingSlugs = []) { + const used = new Set(existingSlugs); + return items.map((item) => { + const base = slugify(item.title); + const date = new Date(item.published).toISOString().slice(0, 10); + let slug = `${base}-${date}`; + let i = 2; + while (used.has(slug)) slug = `${base}-${date}-${i++}`; + used.add(slug); + return { + ...item, + slug, + articlePath: `/articles/${slug}/`, + sectionSlug: slugify(item.category), + imagePath: `/images/${slug}.png`, + imageAlt: `${item.title} — editorial image` + }; + }); +} + +function pickPalette(item) { + const palettes = { + World: ['#14213d', '#334e68', '#7dd3fc'], + Technology: ['#111827', '#2563eb', '#60a5fa'], + 'Top Stories': ['#3f1d2e', '#8b1e1e', '#f59e0b'] + }; + return palettes[item.category] || ['#1f2937', '#4b5563', '#94a3b8']; +} + +function createIllustrationSvg(item) { + const [bg, accent, accent2] = pickPalette(item); + const h = hashString(`${item.slug}-${item.source}`); + const c1x = 120 + (h % 500); + const c1y = 140 + (h % 220); + const c2x = 700 - (h % 240); + const c2y = 90 + (h % 180); + const category = escapeHtml(item.category.toUpperCase()); + const title = escapeHtml(item.title); + return `${category}`; +} + +function groupByCategory(items) { + const groups = new Map(); + for (const item of items) { + if (!groups.has(item.category)) groups.set(item.category, []); + groups.get(item.category).push(item); + } + return [...groups.entries()].map(([category, entries]) => ({ + category, + slug: slugify(category), + items: entries.sort((a, b) => new Date(b.published) - new Date(a.published)) + })).sort((a, b) => a.category.localeCompare(b.category)); +} + +function buildImageSearchTerms(item) { + const sourceHints = item.source === 'Hacker News' ? 'technology' : item.category; + return [ + `${item.title} ${sourceHints} site:commons.wikimedia.org`, + `${item.title} ${sourceHints} Wikimedia Commons`, + `${item.category} ${item.title}` + ]; +} + +async function findCommonsImage(item) { + const titleWords = item.title.split(/\s+/).filter(Boolean).slice(0, 8).join(' '); + const categoryHint = item.category === 'Top Stories' ? 'news' : item.category; + const url = `https://commons.wikimedia.org/w/api.php?action=query&generator=search&gsrsearch=${encodeURIComponent(`${titleWords} ${categoryHint}`)}&gsrnamespace=6&prop=imageinfo&iiprop=url|extmetadata&iiurlwidth=1400&format=json&origin=*`; + const res = await fetch(url, { headers: { 'User-Agent': 'SignalLedgerBot/1.0' } }); + if (!res.ok) return null; + const data = await res.json(); + const pages = Object.values(data?.query?.pages || {}); + const valid = pages.find((page) => { + const meta = page?.imageinfo?.[0]?.extmetadata || {}; + const license = (meta.LicenseShortName?.value || meta.UsageTerms?.value || '').toLowerCase(); + return page?.imageinfo?.[0]?.thumburl && (license.includes('cc') || license.includes('public domain') || license.includes('pd') || license.includes('creative commons')); + }); + if (!valid) return null; + const info = valid.imageinfo[0]; + const meta = info.extmetadata || {}; + return { + kind: 'commons', + sourceUrl: info.descriptionurl || info.url, + downloadUrl: info.thumburl || info.url, + credit: stripHtml(meta.Artist?.value || meta.Credit?.value || 'Wikimedia Commons'), + license: stripHtml(meta.LicenseShortName?.value || meta.UsageTerms?.value || 'Licensed via Wikimedia Commons') + }; +} + +async function generateImage(client, item) { + const prompt = `Create a restrained editorial illustration for a digital news magazine article. Subject: ${item.title}. Category: ${item.category}. Visual tone: serious, journalistic, clean, modern, magazine-quality, not cartoonish, no text, no logos, no watermarks. Emphasize clarity and atmosphere over literal portraiture.`; + const result = await client.images.generate({ + model: IMAGE_MODEL, + prompt, + size: '1536x1024' + }); + const base64 = result?.data?.[0]?.b64_json; + if (!base64) throw new Error('No image returned'); + return Buffer.from(base64, 'base64'); +} + +async function optimizeImage(inputPath, imageMeta, slug) { + const ext = path.extname(inputPath).toLowerCase(); + if (ext === '.svg') return; // Skip SVG + + const base = inputPath.replace(/\.[^.]+$/, ''); + const webpPath = `${base}.webp`; + const avifPath = `${base}.avif`; + + try { + const inputBuffer = await fs.readFile(inputPath); + const image = sharp(inputBuffer); + + // Generate WebP if not exists or source is newer + try { + await fs.access(webpPath); + } catch { + await image.clone().webp({ quality: 80, effort: 4 }).toFile(webpPath); + console.log(` Generated WebP: ${path.basename(webpPath)}`); + } + + // Generate AVIF if not exists or source is newer + try { + await fs.access(avifPath); + } catch { + await image.clone().avif({ quality: 70, effort: 4 }).toFile(avifPath); + console.log(` Generated AVIF: ${path.basename(avifPath)}`); + } + + if (imageMeta[slug]) { + imageMeta[slug].hasWebp = true; + imageMeta[slug].hasAvif = true; + } + } catch (error) { + console.error(`Image optimization failed for ${slug}:`, error.message); + } +} + +async function ensureArticleImage(item, imageMeta, client, { tryRemote = true, allowGeneration = true } = {}) { + const existing = imageMeta[item.slug]; + const ext = existing?.ext || '.png'; + const outputPath = path.join(IMAGES_DIR, `${item.slug}${ext}`); + if (existing) { + item.imagePath = `/images/${item.slug}${ext}`; + item.imageCredit = existing.credit || ''; + item.imageLicense = existing.license || ''; + item.imageKind = existing.kind || 'generated'; + item.hasWebp = existing.hasWebp || false; + item.hasAvif = existing.hasAvif || false; + // Optimize existing raster images + await optimizeImage(outputPath, imageMeta, item.slug); + return; + } + + if (tryRemote) { + try { + const commons = await findCommonsImage(item); + if (commons) { + const res = await fetch(commons.downloadUrl, { headers: { 'User-Agent': 'SignalLedgerBot/1.0' } }); + if (res.ok) { + const buffer = Buffer.from(await res.arrayBuffer()); + const ct = res.headers.get('content-type') || 'image/jpeg'; + const extFound = ct.includes('png') ? '.png' : ct.includes('webp') ? '.webp' : '.jpg'; + const target = path.join(IMAGES_DIR, `${item.slug}${extFound}`); + await fs.writeFile(target, buffer); + imageMeta[item.slug] = { + kind: 'commons', + ext: extFound, + credit: commons.credit, + license: commons.license, + sourceUrl: commons.sourceUrl, + searchTerms: buildImageSearchTerms(item) + }; + item.imagePath = `/images/${item.slug}${extFound}`; + item.imageCredit = commons.credit; + item.imageLicense = commons.license; + item.imageKind = 'commons'; + await optimizeImage(target, imageMeta, item.slug); + item.hasWebp = imageMeta[item.slug].hasWebp || false; + item.hasAvif = imageMeta[item.slug].hasAvif || false; + return; + } + } + } catch (error) { + console.error('Image lookup failed for', item.slug, error); + } + } + + if (allowGeneration && client) { + try { + const png = await generateImage(client, item); + await fs.writeFile(outputPath, png); + imageMeta[item.slug] = { + kind: 'generated', + ext: '.png', + credit: 'Signal Ledger illustration', + license: 'Generated for Signal Ledger', + promptBasis: item.title + }; + item.imagePath = `/images/${item.slug}.png`; + item.imageCredit = 'Signal Ledger illustration'; + item.imageLicense = 'Generated'; + item.imageKind = 'generated'; + await optimizeImage(outputPath, imageMeta, item.slug); + item.hasWebp = imageMeta[item.slug].hasWebp || false; + item.hasAvif = imageMeta[item.slug].hasAvif || false; + return; + } catch (error) { + console.error('Falling back to SVG after image generation failure for', item.slug, error?.message || error); + } + } + + const svgPath = path.join(IMAGES_DIR, `${item.slug}.svg`); + await fs.writeFile(svgPath, createIllustrationSvg(item)); + imageMeta[item.slug] = { + kind: 'fallback-svg', + ext: '.svg', + credit: 'Signal Ledger placeholder illustration', + license: 'Generated in-site vector artwork' + }; + item.imagePath = `/images/${item.slug}.svg`; + item.imageCredit = 'Signal Ledger placeholder illustration'; + item.imageLicense = 'Generated'; + item.imageKind = 'fallback-svg'; + item.hasWebp = false; + item.hasAvif = false; +} + +function renderShell({ title, description, pathName = '/', bodyClass = '', content, generatedAt }) { + const canonical = `${SITE_URL}${pathName}`; + const adsenseHead = ADSENSE_CLIENT ? `\n ` : ''; + return `${escapeHtml(title)}${adsenseHead}
${content}
`; +} + +function renderMainAdUnit(label = 'main page ads') { + if (!ADSENSE_CLIENT || !ADSENSE_SLOT) return ''; + return ``; +} + +function renderImageCredit(item) { + if (!item.imageCredit && !item.imageLicense) return ''; + const parts = [item.imageCredit, item.imageLicense].filter(Boolean); + return `
${escapeHtml(parts.join(' · '))}
`; +} + +function renderPicture(item, { className = '', loading = 'lazy' } = {}) { + const ext = path.extname(item.imagePath).toLowerCase(); + const base = item.imagePath.replace(/\.[^.]+$/, ''); + const alt = escapeHtml(item.imageAlt); + const cls = className ? ` class="${escapeHtml(className)}"` : ''; + + // SVG images don't need picture element + if (ext === '.svg') { + return ``; + } + + const sources = []; + if (item.hasAvif) { + sources.push(``); + } + if (item.hasWebp) { + sources.push(``); + } + + if (sources.length === 0) { + return ``; + } + + return `${sources.join('')}`; +} + +function renderStoryCard(item, lead = false) { + return `
${renderPicture(item, { className: 'story-image', loading: 'lazy' })}${renderImageCredit(item)}

${escapeHtml(item.title)}

${item.dek ? `

${escapeHtml(item.dek)}

` : ''}

${escapeHtml(item.summary)}

Read the full brief
`; +} + +function renderHome(items, groups, generatedAt) { + const lead = items[0]; + const secondary = items.slice(1, 4); + const sections = groups.map((group) => `
${group.items.slice(0, 6).map((item, idx) => renderStoryCard(item, idx === 0)).join('')}
`).join(''); + const content = `

Digital news magazine

${escapeHtml(SITE_NAME)}

${escapeHtml(SITE_TAGLINE)}

${escapeHtml(EDITOR_NOTE)}

${items.length}live briefs
${groups.length}sections
${escapeHtml(formatDate(generatedAt).replace(' UTC', ''))}edition timestamp
${secondary.map((item) => `
${renderPicture(item, { className: 'story-image', loading: 'lazy' })}${renderImageCredit(item)}

${escapeHtml(item.title)}

${item.dek ? `

${escapeHtml(item.dek)}

` : ''}
`).join('')}
${renderMainAdUnit('main page ads')}${sections}
`; + return renderShell({ title: `${SITE_NAME} — ${SITE_TAGLINE}`, description: SITE_TAGLINE, pathName: '/', bodyClass: 'home', content, generatedAt }); +} + +function renderArticle(item, related, generatedAt) { + const sourceHref = item.link && item.link !== '#' ? `

Read the original reporting

` : ''; + const relatedInline = related.length ? `

Where this fits in Signal Ledger

This story sits alongside related Signal Ledger coverage that helps frame the broader pattern.

` : ''; + const content = `
${renderPicture(item, { className: 'article-image' })}${renderImageCredit(item)}
${escapeHtml(item.category)}

${escapeHtml(item.title)}

${item.dek ? `

${escapeHtml(item.dek)}

` : ''}

${escapeHtml(item.summaryLabel)}

${escapeHtml(item.summary)}

${escapeHtml(item.stakesLabel)}

${escapeHtml(item.whyItMatters)}

${escapeHtml(item.contextLabel)}

${escapeHtml(item.context)}

${relatedInline}

${escapeHtml(item.viewLabel)}

${escapeHtml(item.viewpoint)}

Source note

${escapeHtml(item.sourceNote)}

${sourceHref}
`; + return renderShell({ title: `${item.title} — ${SITE_NAME}`, description: item.summary || SITE_TAGLINE, pathName: item.articlePath, bodyClass: 'article-page', content, generatedAt }); +} + +function renderSectionPage(group, generatedAt) { + const content = `

Section

${escapeHtml(group.category)}

Magazine briefs, analysis, and context from the ${escapeHtml(group.category)} desk.

${renderMainAdUnit('section page ads')}
${group.items.map((item, idx) => renderStoryCard(item, idx === 0)).join('')}
`; + return renderShell({ title: `${group.category} — ${SITE_NAME}`, description: `${group.category} coverage from ${SITE_NAME}`, pathName: `/sections/${group.slug}/`, bodyClass: 'section-page', content, generatedAt }); +} + +function renderArchive(items, generatedAt) { + const content = `

Archive

All stories

A running index of current Signal Ledger briefs.

${items.map((item) => `

${escapeHtml(item.title)}

${escapeHtml(item.dek)}

${escapeHtml(item.category)}${escapeHtml(item.source)}
`).join('')}
`; + return renderShell({ title: `Archive — ${SITE_NAME}`, description: `Archive of ${SITE_NAME} briefs and magazine stories.`, pathName: '/archive/', bodyClass: 'archive-page', content, generatedAt }); +} + +function renderAbout(generatedAt) { + const content = `

About

About ${escapeHtml(SITE_NAME)}

${escapeHtml(EDITOR_NOTE)}

Editorial vision

${escapeHtml(SITE_NAME)} aims to sit between the raw newswire and the over-heated opinion economy. We publish compact, readable briefs with context, judgment, and a magazine voice.

How stories are made

Each story is grounded in attributed reporting from established publishers, then rewritten into a distinct Signal Ledger brief with analysis, framing, and context for readers who want understanding rather than repetition.

Contact

Editorial and business contact: ${escapeHtml(CONTACT_EMAIL)}.

${escapeHtml(SITE_NAME)} is a subsidiary of ${escapeHtml(PARENT_COMPANY)}.

What is coming next

Planned expansions include author bylines, topic pages, and daily briefing edition pages.

`; + return renderShell({ title: `About — ${SITE_NAME}`, description: `About the editorial concept behind ${SITE_NAME}.`, pathName: '/about/', bodyClass: 'about-page', content, generatedAt }); +} + +function renderRss(items, generatedAt) { + return `${xmlEscape(SITE_NAME)}${xmlEscape(SITE_URL)}${xmlEscape(SITE_TAGLINE)}en-us${new Date(generatedAt).toUTCString()}${items.map((item) => `${xmlEscape(item.title)}${xmlEscape(`${SITE_URL}${item.articlePath}`)}${xmlEscape(`${SITE_URL}${item.articlePath}`)}${new Date(item.published).toUTCString()}${xmlEscape(item.summary)}${xmlEscape(item.category)}`).join('')}`; +} + +function renderSitemap(items, groups) { + const urls = [`${SITE_URL}/`, `${SITE_URL}/archive/`, `${SITE_URL}/about/`, ...groups.map((g) => `${SITE_URL}/sections/${g.slug}/`), ...items.map((item) => `${SITE_URL}${item.articlePath}`)]; + return `${urls.map((url) => `${xmlEscape(url)}`).join('')}`; +} + +async function writeCss() { + const css = `:root { --bg: #f3ede3; --paper: #fffdf8; --ink: #161616; --muted: #5d594f; --line: #ddd3c5; --accent: #8b1e1e; --accent-dark: #5f1414; --navy: #1f2a37; --shadow: rgba(16, 24, 40, 0.08); } * { box-sizing: border-box; } body { margin: 0; font-family: Georgia, 'Times New Roman', serif; color: var(--ink); background: linear-gradient(180deg, #efe6d7, var(--bg)); overflow-x: hidden; } a { color: inherit; text-decoration: none; } a:hover { color: var(--accent); } .page { width: 100%; max-width: 1240px; margin: 0 auto; padding: 0 18px 48px; } .site-header { padding: 24px 0 18px; border-bottom: 2px solid var(--navy); } .brand-bar { display: flex; justify-content: space-between; gap: 24px; align-items: end; min-width: 0; } .wordmark { font-size: clamp(2.5rem, 5vw, 4.8rem); font-weight: 700; line-height: .95; overflow-wrap: anywhere; } .tagline { margin: 0; max-width: 620px; color: var(--muted); font-family: Inter, Arial, sans-serif; } .main-nav { display: flex; gap: 18px; flex-wrap: wrap; padding-top: 16px; font-family: Inter, Arial, sans-serif; } .main-nav a { color: var(--navy); font-weight: 700; } .edition-bar { margin-top: 12px; color: var(--muted); font-family: Inter, Arial, sans-serif; font-size: .92rem; } .hero, .section-hero { display: grid; grid-template-columns: 1.2fr .9fr; gap: 22px; padding-top: 28px; min-width: 0; } .hero-copy, .hero-panel, .story-card, .mini-card, .sidebar-box, .article, .archive-item, .prose, .ad-unit { background: var(--paper); border: 1px solid var(--line); box-shadow: 0 12px 30px var(--shadow); min-width: 0; overflow: hidden; } .hero-copy, .hero-panel, .article, .prose { padding: 24px; } .eyebrow, .article-kicker { text-transform: uppercase; letter-spacing: .12em; font-family: Inter, Arial, sans-serif; font-size: .75rem; color: var(--muted); } .hero h1, .section-hero h1 { margin: 8px 0 12px; font-size: clamp(2rem, 4vw, 3.6rem); } .hero-tagline, .hero-note, .story-dek, .article-dek { color: #403933; } .strip { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 18px; } .mini-card, .story-card { padding: 18px; } .story-image, .hero-image, .article-image { width: 100%; display: block; border-radius: 16px; border: 1px solid var(--line); background: #e9e2d6; } .story-image { aspect-ratio: 1.91 / 1; object-fit: cover; margin-bottom: 14px; } .hero-image { aspect-ratio: 1.91 / 1; object-fit: cover; margin: 6px 0 10px; } .article-image { aspect-ratio: 1.91 / 1; object-fit: cover; margin-bottom: 10px; } .image-credit { margin-bottom: 14px; font-family: Inter, Arial, sans-serif; font-size: .8rem; color: var(--muted); } .story-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 18px; } .section-block { margin-top: 34px; } .section-head { display: flex; justify-content: space-between; gap: 16px; align-items: baseline; border-bottom: 1px solid var(--line); margin-bottom: 16px; padding-bottom: 10px; } .section-head h2 { margin: 0; font-size: 2rem; } .story-meta, .archive-meta, .article-meta { display: flex; gap: 10px; flex-wrap: wrap; font-family: Inter, Arial, sans-serif; font-size: .82rem; color: var(--muted); } .story-card h3, .mini-card h3 { margin: 10px 0 10px; font-size: 1.3rem; line-height: 1.25; overflow-wrap: anywhere; } .story-card--lead { border-top: 4px solid var(--accent); } .readmore { display: inline-block; margin-top: 14px; color: var(--accent-dark); font-family: Inter, Arial, sans-serif; font-weight: 700; } .article-layout { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 320px); gap: 20px; padding-top: 28px; } .article h1 { font-size: clamp(2.2rem, 4vw, 3.8rem); margin: 10px 0 12px; overflow-wrap: anywhere; } .article section + section { margin-top: 22px; } .article p, .prose p { line-height: 1.7; color: #38332d; overflow-wrap: anywhere; } .article-links { margin: 10px 0 0 18px; color: #38332d; } .article-links li + li { margin-top: 8px; } .source-note { border-top: 1px solid var(--line); padding-top: 14px; } .source-note h2 { font-size: 1rem; font-family: Inter, Arial, sans-serif; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } .sidebar { display: grid; gap: 16px; align-content: start; min-width: 0; } .sidebar-box { padding: 18px; } .archive-list { display: grid; gap: 14px; padding-top: 22px; } .archive-item { padding: 18px; display: grid; grid-template-columns: 1.4fr .8fr; gap: 16px; } .site-footer { border-top: 2px solid var(--navy); margin-top: 42px; padding-top: 22px; } .footer-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; } .footer-grid h4 { margin: 0 0 8px; font-family: Inter, Arial, sans-serif; } .footer-grid p { margin: 0; color: var(--muted); line-height: 1.6; } .ad-unit { padding: 16px; overflow: hidden; } .ad-unit-label { margin-bottom: 8px; text-transform: uppercase; letter-spacing: .12em; font-family: Inter, Arial, sans-serif; font-size: .72rem; color: var(--muted); } .adsbygoogle { max-width: 100%; } @media (max-width: 980px) { .hero, .section-hero, .article-layout, .strip, .footer-grid, .archive-item { grid-template-columns: 1fr; } .brand-bar { flex-direction: column; align-items: start; } } @media (max-width: 640px) { .page { padding: 0 12px 28px; } .section-head { flex-direction: column; align-items: start; } .wordmark { font-size: 2.1rem; } .hero-copy, .hero-panel, .story-card, .mini-card, .sidebar-box, .article, .archive-item, .prose, .ad-unit { padding: 14px; } }`; + await fs.writeFile(path.join(OUT_DIR, 'styles.css'), css); +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + await fs.mkdir(ARTICLES_DIR, { recursive: true }); + await fs.mkdir(SECTIONS_DIR, { recursive: true }); + await fs.mkdir(IMAGES_DIR, { recursive: true }); + await fs.mkdir(path.join(OUT_DIR, 'archive'), { recursive: true }); + await fs.mkdir(path.join(OUT_DIR, 'about'), { recursive: true }); + + const existingArticles = await readJsonFile(STATE_FILE, { articles: [] }); + const existingList = Array.isArray(existingArticles.articles) ? existingArticles.articles : []; + const existingByLink = new Map(existingList.map((item) => [item.link, item])); + const imageMeta = await readJsonFile(IMAGE_META_FILE, {}); + + const settled = await Promise.allSettled(FEEDS.map(fetchFeed)); + const fetched = settled.flatMap((result, index) => result.status === 'fulfilled' ? result.value : (console.error(`Feed failed: ${FEEDS[index].source} ${FEEDS[index].url}`, result.reason), [])); + const freshByLink = new Map(dedupe(fetched).map((item) => [item.link, item])); + const corpusBase = [...freshByLink.values(), ...existingList.filter((item) => !freshByLink.has(item.link))].sort((a, b) => new Date(b.published) - new Date(a.published)); + + const rewritten = await enrichItems(corpusBase.map((item) => ({ + source: item.source, + category: item.category, + feedId: item.feedId, + title: item.title, + link: item.link, + published: item.published, + snippet: item.snippet + }))); + + const finalCorpus = rewritten.map((item) => { + const existing = existingByLink.get(item.link); + if (!existing) return item; + return { + ...item, + slug: existing.slug, + articlePath: existing.articlePath, + sectionSlug: existing.sectionSlug || slugify(item.category), + imagePath: existing.imagePath || `/images/${existing.slug}.png`, + imageAlt: existing.imageAlt || `${item.title} — editorial image` + }; + }); + + const decoratedNew = decorateItems(finalCorpus.filter((item) => !item.slug), existingList.map((item) => item.slug)); + const decoratedNewByLink = new Map(decoratedNew.map((item) => [item.link, item])); + const corpus = finalCorpus.map((item) => decoratedNewByLink.get(item.link) || item).sort((a, b) => new Date(b.published) - new Date(a.published)); + const homepageItems = corpus.slice(0, MAX_HOMEPAGE_ITEMS); + const homeGroups = groupByCategory(homepageItems); + const allGroups = groupByCategory(corpus); + const generatedAt = new Date(); + + await fs.copyFile(path.join(ROOT, 'src', 'favicon.svg'), path.join(OUT_DIR, 'favicon.svg')); + const client = process.env.OPENAI_API_KEY ? new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) : null; + let generatedCount = 0; + const featuredSlugs = new Set(homepageItems.slice(0, 8).map((item) => item.slug)); + for (const item of corpus) { + const isFeatured = featuredSlugs.has(item.slug); + const beforeKind = imageMeta[item.slug]?.kind || null; + await ensureArticleImage(item, imageMeta, client, { + tryRemote: isFeatured, + allowGeneration: isFeatured && generatedCount < GENERATED_IMAGE_LIMIT + }); + if (!beforeKind && imageMeta[item.slug]?.kind === 'generated') generatedCount += 1; + } + await fs.writeFile(IMAGE_META_FILE, JSON.stringify(imageMeta, null, 2)); + + await writeCss(); + await fs.writeFile(path.join(OUT_DIR, 'index.html'), renderHome(homepageItems, homeGroups, generatedAt)); + await fs.writeFile(path.join(OUT_DIR, 'archive', 'index.html'), renderArchive(corpus, generatedAt)); + await fs.writeFile(path.join(OUT_DIR, 'about', 'index.html'), renderAbout(generatedAt)); + + for (const group of allGroups) { + await fs.mkdir(path.join(SECTIONS_DIR, group.slug), { recursive: true }); + await fs.writeFile(path.join(SECTIONS_DIR, group.slug, 'index.html'), renderSectionPage(group, generatedAt)); + } + + for (const item of corpus) { + const related = corpus.filter((other) => other.slug !== item.slug && (other.category === item.category || other.source === item.source)).slice(0, 4); + const dir = path.join(ARTICLES_DIR, item.slug); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(path.join(dir, 'index.html'), renderArticle(item, related, generatedAt)); + } + + await fs.writeFile(STATE_FILE, JSON.stringify({ generatedAt: generatedAt.toISOString(), articles: corpus }, null, 2)); + await fs.writeFile(path.join(OUT_DIR, 'feed.json'), JSON.stringify({ siteName: SITE_NAME, siteTagline: SITE_TAGLINE, generatedAt: generatedAt.toISOString(), items: homepageItems }, null, 2)); + await fs.writeFile(path.join(OUT_DIR, 'feed.xml'), renderRss(homepageItems, generatedAt)); + await fs.writeFile(path.join(OUT_DIR, 'sitemap.xml'), renderSitemap(corpus, allGroups)); + await fs.writeFile(path.join(OUT_DIR, 'robots.txt'), `User-agent: *\nAllow: /\nSitemap: ${SITE_URL}/sitemap.xml\n`); + await fs.writeFile(path.join(OUT_DIR, 'ads.txt'), `google.com, pub-1269854634225826, DIRECT, f08c47fec0942fa0\n`); + console.log(`Built ${corpus.length} stories into ${OUT_DIR}`); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/favicon.svg b/src/favicon.svg new file mode 100644 index 0000000..6c90ba5 --- /dev/null +++ b/src/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/feeds.js b/src/feeds.js new file mode 100644 index 0000000..5b1665b --- /dev/null +++ b/src/feeds.js @@ -0,0 +1,26 @@ +export const FEEDS = [ + { + id: 'reuters-world', + source: 'Reuters', + category: 'World', + url: 'https://news.google.com/rss/search?q=site:reuters.com+world&hl=en-US&gl=US&ceid=US:en' + }, + { + id: 'ap-headlines', + source: 'AP News', + category: 'Top Stories', + url: 'https://news.google.com/rss/search?q=site:apnews.com&hl=en-US&gl=US&ceid=US:en' + }, + { + id: 'bbc-world', + source: 'BBC', + category: 'World', + url: 'https://feeds.bbci.co.uk/news/world/rss.xml' + }, + { + id: 'hacker-news-frontpage', + source: 'Hacker News', + category: 'Technology', + url: 'https://hnrss.org/frontpage' + } +]; diff --git a/test.js b/test.js index 6e810af..43188db 100644 --- a/test.js +++ b/test.js @@ -5,7 +5,7 @@ const distDir = path.join(__dirname, 'dist'); // Run build first if dist doesn't exist if (!fs.existsSync(distDir)) { - require('./build.js'); + require('./src/build.js'); } const indexPath = path.join(distDir, 'index.html'); @@ -16,9 +16,9 @@ if (!fs.existsSync(indexPath)) { const html = fs.readFileSync(indexPath, 'utf8'); const checks = [ - ['title', html.includes('Signal Ledger')], - ['header', html.includes('

Signal Ledger

')], - ['footer', html.includes('Signal Ledger is a subsidiary of Jopdorp.')], + ['title', html.includes('Signal Ledger') || html.includes('Signal Ledger')], + ['header', html.includes('Signal Ledger')], + ['footer', html.includes('Signal Ledger is a subsidiary of Jopdorp.') || html.includes('subsidiary of Jopdorp')], ]; let failed = false;