diff --git a/.github/workflows/build-publish.yaml b/.github/workflows/build-publish.yaml index 5df5355..7d54f81 100644 --- a/.github/workflows/build-publish.yaml +++ b/.github/workflows/build-publish.yaml @@ -1,4 +1,4 @@ -name: Docker build & publish +name: CI on: pull_request: @@ -36,47 +36,3 @@ jobs: - name: Test run: pnpm test - - docker-image: - runs-on: ubuntu-latest - needs: checks - - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Login to DockerHub - if: github.event_name == 'push' - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Work out tags - id: container - run: | - TAGS=$(cat release | awk '{print "phpdockerio/readability-js-server:latest,phpdockerio/readability-js-server:" $1 ",phpdockerio/readability-js-server:" $2 ",phpdockerio/readability-js-server:" $3}') - echo "tags=${TAGS}" >> $GITHUB_OUTPUT - echo "Docker tags to build: ${TAGS}" - - - name: Check if release version has been bumped - id: release_file_changed - uses: tj-actions/changed-files@v46 - with: - files: | - release - - - name: Build & push container image - uses: docker/build-push-action@v7 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: ${{ github.ref == 'refs/heads/master' && steps.release_file_changed.outputs.any_changed == 'true' }} - pull: true - tags: "${{ steps.container.outputs.tags }}" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..1082731 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,79 @@ +name: Release + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: write + env: + IMAGE_NAME: phpdockerio/readability-js-server + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Derive release metadata + id: meta + run: | + TAG_NAME="${GITHUB_REF_NAME}" + VERSION="${TAG_NAME#v}" + + if [ "${VERSION}" = "${TAG_NAME}" ]; then + echo "Tag must start with v" >&2 + exit 1 + fi + + IFS='.' read -r MAJOR MINOR PATCH <&2 + exit 1 + fi + + PACKAGE_VERSION="$(node -p "require('./package.json').version")" + if [ "${PACKAGE_VERSION}" != "${VERSION}" ]; then + echo "Tag version ${VERSION} does not match package.json version ${PACKAGE_VERSION}" >&2 + exit 1 + fi + + { + echo "version=${VERSION}" + echo "major=${MAJOR}" + echo "minor=${MINOR}" + echo "tags=${IMAGE_NAME}:latest,${IMAGE_NAME}:${VERSION},${IMAGE_NAME}:${MAJOR}.${MINOR},${IMAGE_NAME}:${MAJOR}" + } >> "${GITHUB_OUTPUT}" + + - name: Login to DockerHub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build & push container image + uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + pull: true + push: true + tags: ${{ steps.meta.outputs.tags }} + + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create "${GITHUB_REF_NAME}" --generate-notes --verify-tag diff --git a/AGENTS.md b/AGENTS.md index 8df9f23..9e6676f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,8 +16,11 @@ - Memory soak: `make soak` or `node scripts/memory-soak.js --requests 100 --concurrency 2 --sample-every 10` - Docker build: `docker build -t readability-js .` - Docker run: `docker run --rm -p 3000:3000 readability-js` +- Release tag: `make release-tag VERSION=1.8.0` -The Makefile mirrors those workflows with `make install`, `make start`, `make lint`, `make lint-fix`, `make build-container`, `make run-container`, and `make example-request`. +The Makefile mirrors those workflows with `make install`, `make start`, `make lint`, `make lint-fix`, `make build-container`, `make run-container`, `make release-tag`, and `make example-request`. + +`package.json` is the single source of truth for the service version. Release publishing is tag-driven: bump `package.json`'s `version`, commit it, create a matching `vX.Y.Z` tag, and push the tag to trigger Docker publish plus GitHub Release creation. ## Testing expectations diff --git a/Dockerfile b/Dockerfile index f1d053c..4ff62ca 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,14 +19,17 @@ WORKDIR /application ARG RUNTIME_USER=readability +RUN apk add --no-cache tini + RUN adduser -D ${RUNTIME_USER} \ && mkdir -p /home/${RUNTIME_USER} /application \ && chown -R ${RUNTIME_USER}:${RUNTIME_USER} /home/${RUNTIME_USER} /application COPY --from=deps /application/node_modules ./node_modules COPY src src -COPY release . +COPY package.json . USER ${RUNTIME_USER} +ENTRYPOINT ["/sbin/tini", "--"] CMD ["node", "src/server.js"] diff --git a/Makefile b/Makefile index cc3323a..06a9028 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,14 @@ build-container: run-container: docker run --rm -p3000:3000 readability-js +release-tag: + @if [ -z "$(VERSION)" ]; then \ + echo "Usage: make release-tag VERSION=x.y.z"; \ + exit 1; \ + fi + git tag v$(VERSION) + @echo "Created tag v$(VERSION). Push it with: git push origin v$(VERSION)" + example-request: curl -XPOST http://localhost:3000/ \ -H "Content-Type: application/json" \ diff --git a/README.md b/README.md index 65840d7..0fb5b04 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,8 @@ make install make start ``` +Release versions come from [`package.json`](package.json). To publish a release, bump `version`, commit the change, create a `vX.Y.Z` tag, and push that tag. The release workflow publishes Docker images for `X.Y.Z`, `X.Y`, `X`, and `latest`, and creates the matching GitHub Release with generated notes. + ## Testing Run the lint and test suites with pnpm: @@ -155,6 +157,12 @@ make lint make lint-fix ``` +For release tagging there is also: + +```bash +make release-tag VERSION=1.8.0 +``` + ## Docker Build and run the container locally: @@ -166,6 +174,8 @@ docker run --rm -p 3000:3000 readability-js The image is based on `node:24-alpine`, installs production dependencies only, and runs the service as a non-root user. +CI on pull requests and pushes to `master` runs lint and tests only. Container publishing happens from the tag-triggered release workflow. + For Docker Compose setup, see [`examples/compose.yaml`](examples/compose.yaml). ## Security posture diff --git a/eslint.config.cjs b/eslint.config.cjs new file mode 100644 index 0000000..11d0780 --- /dev/null +++ b/eslint.config.cjs @@ -0,0 +1,41 @@ +const js = require("@eslint/js"); +const n = require("eslint-plugin-n").default; +const eslintConfigPrettier = require("eslint-config-prettier/flat"); + +const nodeRecommendedScript = n.configs["flat/recommended-script"]; + +module.exports = [ + { + ignores: ["**/node_modules/**", "**/coverage/**", "**/dist/**"], + }, + js.configs.recommended, + { + files: ["src/**/*.js", "test/**/*.js", "scripts/**/*.js"], + ...nodeRecommendedScript, + settings: { + ...(nodeRecommendedScript.settings || {}), + node: { + version: ">=24.0.0", + }, + }, + rules: { + ...nodeRecommendedScript.rules, + "no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + }, + }, + { + files: ["test/**/*.js"], + rules: { + "n/no-unpublished-import": "off", + "n/no-unpublished-require": "off", + }, + }, + eslintConfigPrettier, +]; diff --git a/package.json b/package.json index d9eb72f..8f02de2 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "readability-js-server", + "version": "1.8.0", "description": "Mozilla's Readability.js as a service", "author": "Luis Pabon @ phpdocker.io", "homepage": "https://github.com/phpdocker-io/readability-js-server", @@ -15,12 +16,16 @@ }, "scripts": { "start": "nodemon src/server.js", - "test": "node --test", - "lint": "prettier -c src/ test/ scripts/", - "lint:fix": "prettier -w src/ test/ scripts/", + "test": "node --test test/*.test.js", + "lint": "eslint src/ test/ scripts/ && prettier -c src/ test/ scripts/", + "lint:fix": "eslint --fix src/ test/ scripts/ && prettier --write src/ test/ scripts/", "memory:soak": "node scripts/memory-soak.js" }, "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.5.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-n": "^18.1.0", "nodemon": "^3.1.14", "prettier": "^3.8.4", "supertest": "^7.1.4" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56feb26..fd7e05a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,18 @@ importers: specifier: ^1.0.2 version: 1.0.2 devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.5.0) + eslint: + specifier: ^10.5.0 + version: 10.5.0 + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.5.0) + eslint-plugin-n: + specifier: ^18.1.0 + version: 18.1.0(eslint@10.5.0) nodemon: specifier: ^3.1.14 version: 3.1.14 @@ -94,6 +106,45 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -103,6 +154,26 @@ packages: '@noble/hashes': optional: true + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} @@ -117,6 +188,15 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -124,6 +204,19 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -203,6 +296,10 @@ packages: cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -223,6 +320,9 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -248,6 +348,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + engines: {node: '>=10.13.0'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -271,6 +375,83 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-compat-utils@0.5.1: + resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} + engines: {node: '>=12'} + peerDependencies: + eslint: '>=6.0.0' + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-es-x@7.8.0: + resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '>=8' + + eslint-plugin-n@18.1.0: + resolution: {integrity: sha512-hkUm9EtnFV2h2fE16jNVUfCVUqvPzI7fGLsFdun5lFt/pbmf2kCgDx6ymi9rx+NCUSggBmurJCZOfG20JBs/kg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: '>=8.57.1' + ts-declaration-location: ^1.0.6 + typescript: '>=5.0.0' + peerDependenciesMeta: + ts-declaration-location: + optional: true + typescript: + optional: true + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.5.0: + resolution: {integrity: sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -279,9 +460,22 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -290,6 +484,17 @@ packages: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -322,14 +527,31 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@15.15.0: + resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} + engines: {node: '>=18'} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} @@ -361,6 +583,14 @@ packages: ignore-by-default@1.0.1: resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -390,6 +620,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -399,6 +632,26 @@ packages: canvas: optional: true + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + lru-cache@11.5.1: resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} engines: {node: 20 || >=22} @@ -450,6 +703,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -474,6 +730,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -481,6 +749,14 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} @@ -488,6 +764,10 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + prettier@3.8.4: resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} engines: {node: '>=14'} @@ -524,6 +804,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -551,6 +834,14 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -594,6 +885,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tldts-core@7.4.3: resolution: {integrity: sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==} @@ -628,6 +923,10 @@ packages: resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} engines: {node: '>=18', npm: '>=9'} + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -643,6 +942,9 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -663,6 +965,15 @@ packages: resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -673,6 +984,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + snapshots: '@asamuzakjp/css-color@5.1.11': @@ -723,10 +1038,60 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@eslint-community/eslint-utils@4.9.1(eslint@10.5.0)': + dependencies: + eslint: 10.5.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3(supports-color@5.5.0) + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.5.0)': + optionalDependencies: + eslint: 10.5.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + '@exodus/bytes@1.15.1(@noble/hashes@1.8.0)': optionalDependencies: '@noble/hashes': 1.8.0 + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + '@mixmark-io/domino@2.2.0': {} '@mozilla/readability@0.6.0': {} @@ -737,6 +1102,12 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + '@types/trusted-types@2.0.7': optional: true @@ -745,6 +1116,19 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -826,6 +1210,12 @@ snapshots: cookiejar@2.1.4: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -846,6 +1236,8 @@ snapshots: decimal.js@10.6.0: {} + deep-is@0.1.4: {} + delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -869,6 +1261,11 @@ snapshots: encodeurl@2.0.0: {} + enhanced-resolve@5.24.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@8.0.0: {} es-define-property@1.0.1: {} @@ -888,6 +1285,100 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@4.0.0: {} + + eslint-compat-utils@0.5.1(eslint@10.5.0): + dependencies: + eslint: 10.5.0 + semver: 7.8.4 + + eslint-config-prettier@10.1.8(eslint@10.5.0): + dependencies: + eslint: 10.5.0 + + eslint-plugin-es-x@7.8.0(eslint@10.5.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/regexpp': 4.12.2 + eslint: 10.5.0 + eslint-compat-utils: 0.5.1(eslint@10.5.0) + + eslint-plugin-n@18.1.0(eslint@10.5.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + enhanced-resolve: 5.24.0 + eslint: 10.5.0 + eslint-plugin-es-x: 7.8.0(eslint@10.5.0) + get-tsconfig: 4.14.0 + globals: 15.15.0 + globrex: 0.1.2 + ignore: 5.3.2 + semver: 7.8.4 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.5.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.5.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3(supports-color@5.5.0) + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + etag@1.8.1: {} express@5.2.1(supports-color@5.5.0): @@ -923,8 +1414,18 @@ snapshots: transitivePeerDependencies: - supports-color + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + fast-safe-stringify@2.1.1: {} + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -940,6 +1441,18 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -981,12 +1494,26 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@15.15.0: {} + + globrex@0.1.2: {} + gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-flag@3.0.0: {} has-symbols@1.1.0: {} @@ -1019,6 +1546,10 @@ snapshots: ignore-by-default@1.0.1: {} + ignore@5.3.2: {} + + imurmurhash@0.1.4: {} + inherits@2.0.4: {} ipaddr.js@1.9.1: {} @@ -1039,6 +1570,8 @@ snapshots: is-promise@4.0.0: {} + isexe@2.0.0: {} + jsdom@29.1.1(@noble/hashes@1.8.0): dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -1065,6 +1598,25 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + lru-cache@11.5.1: {} math-intrinsics@1.1.0: {} @@ -1097,6 +1649,8 @@ snapshots: ms@2.1.3: {} + natural-compare@1.4.0: {} + negotiator@1.0.0: {} nodemon@3.1.14: @@ -1124,16 +1678,39 @@ snapshots: dependencies: wrappy: 1.0.2 + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + parse5@8.0.1: dependencies: entities: 8.0.0 parseurl@1.3.3: {} + path-exists@4.0.0: {} + + path-key@3.1.1: {} + path-to-regexp@8.4.2: {} picomatch@2.3.2: {} + prelude-ls@1.2.1: {} + prettier@3.8.4: {} proxy-addr@2.0.7: @@ -1164,6 +1741,8 @@ snapshots: require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} + router@2.2.0(supports-color@5.5.0): dependencies: debug: 4.4.3(supports-color@5.5.0) @@ -1209,6 +1788,12 @@ snapshots: setprototypeof@1.2.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -1273,6 +1858,8 @@ snapshots: symbol-tree@3.2.4: {} + tapable@2.3.3: {} + tldts-core@7.4.3: {} tldts@7.4.3: @@ -1301,6 +1888,10 @@ snapshots: dependencies: '@mixmark-io/domino': 2.2.0 + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + type-is@2.1.0: dependencies: content-type: 2.0.0 @@ -1313,6 +1904,10 @@ snapshots: unpipe@1.0.0: {} + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + vary@1.1.2: {} w3c-xmlserializer@5.0.0: @@ -1331,8 +1926,16 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + wrappy@1.0.2: {} xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + + yocto-queue@0.1.0: {} diff --git a/release b/release deleted file mode 100644 index 300bf9b..0000000 --- a/release +++ /dev/null @@ -1 +0,0 @@ -1.8.0 1.8 1 diff --git a/src/markdown.js b/src/markdown.js index 0eea134..579d7c2 100644 --- a/src/markdown.js +++ b/src/markdown.js @@ -4,40 +4,83 @@ const TurndownService = require("turndown"); const { gfm } = require("turndown-plugin-gfm"); const KNOWN_EMBED_DOMAINS = [ - { pattern: /youtube\.com|youtu\.be/, label: "YouTube" }, - { pattern: /vimeo\.com/, label: "Vimeo" }, - { pattern: /dailymotion\.com/, label: "Dailymotion" }, + { + hosts: ["youtube.com", "youtube-nocookie.com", "youtu.be"], + label: "YouTube", + }, + { hosts: ["vimeo.com"], label: "Vimeo" }, + { hosts: ["dailymotion.com"], label: "Dailymotion" }, ]; const turndownService = new TurndownService(); turndownService.use(gfm); +function parseAbsoluteHttpUrl(rawUrl) { + try { + const parsedUrl = new URL(rawUrl); + + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return null; + } + + return parsedUrl; + } catch (_error) { + return null; + } +} + +function hostnameMatches(hostname, expectedHost) { + return hostname === expectedHost || hostname.endsWith(`.${expectedHost}`); +} + +function getSafeMediaUrl(rawUrl) { + return parseAbsoluteHttpUrl(rawUrl)?.toString() ?? null; +} + +function getKnownEmbedLabel(rawUrl) { + const parsedUrl = parseAbsoluteHttpUrl(rawUrl); + + if (!parsedUrl) { + return null; + } + + const hostname = parsedUrl.hostname.toLowerCase(); + const match = KNOWN_EMBED_DOMAINS.find(({ hosts }) => + hosts.some((host) => hostnameMatches(hostname, host)), + ); + + return match?.label ?? null; +} + turndownService.addRule("iframe", { filter: "iframe", - replacement(content, node) { - const src = node.getAttribute("src"); - if (!src) { + replacement(_content, node) { + const safeSrc = getSafeMediaUrl(node.getAttribute("src")); + + if (!safeSrc) { return ""; } - const match = KNOWN_EMBED_DOMAINS.find(({ pattern }) => pattern.test(src)); - if (match) { - return `[Video: ${match.label}](${src})`; + const label = getKnownEmbedLabel(safeSrc); + + if (label) { + return `[Video: ${label}](${safeSrc})`; } - return `[Embedded content](${src})`; + return `[Embedded content](${safeSrc})`; }, }); turndownService.addRule("video", { filter: "video", - replacement(content, node) { - const src = node.getAttribute("src"); - if (!src) { + replacement(_content, node) { + const safeSrc = getSafeMediaUrl(node.getAttribute("src")); + + if (!safeSrc) { return ""; } - return `[Video](${src})`; + return `[Video](${safeSrc})`; }, }); diff --git a/src/server.js b/src/server.js index 0c95a7e..f30d71b 100644 --- a/src/server.js +++ b/src/server.js @@ -1,19 +1,65 @@ -const fs = require("fs"); -const path = require("path"); - const { loadConfig } = require("./config"); const { createLogger } = require("./logger"); const app = require("./app"); +const { version } = require("../package.json"); const config = loadConfig(); const logger = createLogger(); -const version = fs - .readFileSync(path.join(__dirname, "..", "release")) - .toString() - .split(" ")[0]; -app.listen(config.port, () => { +const shutdownTimeoutMs = 10_000; +const server = app.listen(config.port, () => { logger.info( `Readability.js server v${version} listening on port ${config.port}!`, ); }); + +let isShuttingDown = false; + +function closeServer(signal) { + if (isShuttingDown) { + return; + } + + isShuttingDown = true; + process.exitCode = 0; + + logger.info( + `Received ${signal}, starting graceful shutdown with a ${shutdownTimeoutMs}ms timeout...`, + ); + + if (typeof server.closeIdleConnections === "function") { + server.closeIdleConnections(); + } + + const forceCloseTimer = setTimeout(() => { + logger.info( + `Graceful shutdown timed out after ${shutdownTimeoutMs}ms, closing remaining connections...`, + ); + + if (typeof server.closeAllConnections === "function") { + server.closeAllConnections(); + } + }, shutdownTimeoutMs); + + forceCloseTimer.unref(); + + server.close((error) => { + clearTimeout(forceCloseTimer); + + if (error) { + logger.error("HTTP server shutdown failed", error); + return; + } + + logger.info("HTTP server closed cleanly, exiting."); + process.exitCode = 0; + }); +} + +process.on("SIGINT", () => { + closeServer("SIGINT"); +}); + +process.on("SIGTERM", () => { + closeServer("SIGTERM"); +}); diff --git a/test/app.test.js b/test/app.test.js deleted file mode 100644 index ddd44d2..0000000 --- a/test/app.test.js +++ /dev/null @@ -1,982 +0,0 @@ -const test = require("node:test"); -const assert = require("node:assert/strict"); -const http = require("node:http"); -const dns = require("node:dns/promises"); -const supertest = require("supertest"); - -const { DEFAULTS, loadConfig } = require("../src/config"); -const { RESPONSE_FIELDS } = require("../src/response"); -const { createApp, createReadabilityOptions, messages } = require("../src/app"); -const { toMarkdown } = require("../src/markdown"); - -function createTestApp(configOverrides) { - return createApp( - { - ...loadConfig({}), - ...configOverrides, - }, - { - info() {}, - error() {}, - }, - ); -} - -function createFixtureServer(routes) { - const server = http.createServer((req, res) => { - const handler = routes[req.url] || routes.default; - - if (!handler) { - res.statusCode = 404; - res.end("not found"); - return; - } - - handler(req, res); - }); - - return new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, () => { - const address = server.address(); - const baseUrl = `http://127.0.0.1:${address.port}`; - - resolve({ - baseUrl, - close: () => - new Promise((closeResolve, closeReject) => { - server.close((error) => { - if (error) { - closeReject(error); - return; - } - - closeResolve(); - }); - }), - }); - }); - }); -} - -function postJson(server, payload) { - return new Promise((resolve, reject) => { - const address = server.address(); - const body = JSON.stringify(payload); - const request = http.request( - { - method: "POST", - host: "127.0.0.1", - port: address.port, - path: "/", - headers: { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(body), - }, - }, - (response) => { - const chunks = []; - - response.on("data", (chunk) => { - chunks.push(chunk); - }); - response.on("end", () => { - resolve({ - statusCode: response.statusCode, - body: JSON.parse(Buffer.concat(chunks).toString("utf8")), - }); - }); - }, - ); - - request.on("error", reject); - request.write(body); - request.end(); - }); -} - -function assertFetchFailureShape( - response, - expectedCode, - expectedError = "Some weird error fetching the content", -) { - assert.equal(response.body.error, expectedError); - assert.equal(typeof response.body.details, "object"); - assert.ok(response.body.details); - assert.equal(response.body.details.code, expectedCode); - assert.equal(typeof response.body.details.message, "string"); -} - -test("GET / returns guidance for POST JSON", async () => { - const response = await supertest(createTestApp()).get("/").expect(400); - - assert.deepEqual(response.body, { - error: messages.INVALID_GET_MESSAGE, - }); -}); - -test("POST / rejects missing and empty url values", async () => { - const app = createTestApp(); - - await supertest(app) - .post("/") - .send({}) - .expect(400) - .expect(({ body }) => { - assert.deepEqual(body, { - error: messages.INVALID_REQUEST_MESSAGE, - }); - }); - - await supertest(app) - .post("/") - .send({ url: "" }) - .expect(400) - .expect(({ body }) => { - assert.deepEqual(body, { - error: messages.INVALID_REQUEST_MESSAGE, - }); - }); -}); - -test("configuration defaults are loaded and validated", () => { - assert.deepEqual(loadConfig({}), { - port: DEFAULTS.PORT, - requestBodyLimit: DEFAULTS.REQUEST_BODY_LIMIT, - fetchTimeoutMs: DEFAULTS.FETCH_TIMEOUT_MS, - fetchMaxBytes: DEFAULTS.FETCH_MAX_BYTES, - fetchMaxRedirects: DEFAULTS.FETCH_MAX_REDIRECTS, - blockPrivateNetworks: DEFAULTS.BLOCK_PRIVATE_NETWORKS, - readabilityMaxElems: undefined, - maxConcurrentRequests: DEFAULTS.MAX_CONCURRENT_REQUESTS, - contentFormat: DEFAULTS.CONTENT_FORMAT, - }); - - assert.throws( - () => - loadConfig({ - PORT: "70000", - }), - /PORT must be <= 65535/, - ); -}); - -test("readability options are only applied when configured", () => { - assert.equal( - createReadabilityOptions({ readabilityMaxElems: undefined }), - undefined, - ); - assert.deepEqual(createReadabilityOptions({ readabilityMaxElems: 2500 }), { - maxElemsToParse: 2500, - }); -}); - -test("POST / blocks private-network targets by default", async (t) => { - const fixture = await createFixtureServer({ - "/article": (_req, res) => { - res.setHeader("content-type", "text/html; charset=utf-8"); - res.end(` - - - Readable headline - - - -
-
-

Readable headline

-

This is the lead paragraph.

-

This is the second paragraph.

-
-
- - `); - }, - }); - - t.after(async () => { - await fixture.close(); - }); - - const response = await supertest(createTestApp()) - .post("/") - .send({ url: `${fixture.baseUrl}/article` }) - .expect(500); - - assertFetchFailureShape(response, "FETCH_PRIVATE_NETWORK_BLOCKED"); - assert.equal(response.body.details.address, "127.0.0.1"); -}); - -test("POST / returns only the explicit response fields for a readable article", async (t) => { - const fixture = await createFixtureServer({ - "/article": (_req, res) => { - res.setHeader("content-type", "text/html; charset=utf-8"); - res.end(` - - - Readable headline - - - -
-
-

Readable headline

-

This is the lead paragraph.

-

This is the second paragraph.

-
-
- - `); - }, - }); - - t.after(async () => { - await fixture.close(); - }); - - const response = await supertest( - createTestApp({ - blockPrivateNetworks: false, - }), - ) - .post("/") - .send({ url: `${fixture.baseUrl}/article` }) - .expect(200); - - assert.deepEqual(Object.keys(response.body), RESPONSE_FIELDS); - assert.equal(response.body.url, `${fixture.baseUrl}/article`); - assert.equal(response.body.title, "Readable headline"); - assert.equal(response.body.byline, null); - assert.equal(response.body.dir, null); - assert.equal(typeof response.body.content, "string"); - assert.ok(response.body.content.includes("This is the lead paragraph.")); - assert.equal(typeof response.body.length, "number"); - assert.ok(response.body.length > 0); - assert.equal(response.body.excerpt, "This is the lead paragraph."); - assert.equal(response.body.siteName, null); - assert.equal(typeof response.body.textContent, "string"); - assert.ok( - response.body.textContent.includes("This is the second paragraph."), - ); - assert.equal(response.body.lang, "en"); - assert.equal(response.body.publishedTime, "2024-01-02T03:04:05Z"); -}); - -test("POST / sanitizes returned article content", async (t) => { - const fixture = await createFixtureServer({ - "/article": (_req, res) => { - res.setHeader("content-type", "text/html; charset=utf-8"); - res.end(` - - - Unsafe headline - - -
-

Unsafe headline

-

Lead paragraph.

- - - -

Tail paragraph.

-
- - `); - }, - }); - - t.after(async () => { - await fixture.close(); - }); - - const response = await supertest( - createTestApp({ - blockPrivateNetworks: false, - }), - ) - .post("/") - .send({ url: `${fixture.baseUrl}/article` }) - .expect(200); - - assert.doesNotMatch(response.body.content, / -

Clean tail paragraph.

- - - `); - }, - }); - - t.after(async () => { - await fixture.close(); - }); - - const response = await supertest( - createTestApp({ - blockPrivateNetworks: false, - }), - ) - .post("/") - .send({ url: `${fixture.baseUrl}/article` }) - .expect(200); - - // Markdown output — no script tags, no event handlers - assert.doesNotMatch(response.body.content, / +

Clean tail paragraph.

+ + + diff --git a/test/fixtures/article-table.html b/test/fixtures/article-table.html new file mode 100644 index 0000000..e10916f --- /dev/null +++ b/test/fixtures/article-table.html @@ -0,0 +1,30 @@ + + + + Table article + + +
+

Table article

+

See the data below.

+ + + + + + + + + + + + + + + + + +
NameScore
Alice95
Bob87
+
+ + diff --git a/test/fixtures/article-unsafe.html b/test/fixtures/article-unsafe.html new file mode 100644 index 0000000..612977d --- /dev/null +++ b/test/fixtures/article-unsafe.html @@ -0,0 +1,22 @@ + + + + Unsafe headline + + +
+

Unsafe headline

+

Lead paragraph.

+ + + +

Tail paragraph.

+
+ + diff --git a/test/fixtures/article-vimeo.html b/test/fixtures/article-vimeo.html new file mode 100644 index 0000000..946259c --- /dev/null +++ b/test/fixtures/article-vimeo.html @@ -0,0 +1,16 @@ + + + + Vimeo embed + + +
+

Vimeo embed

+

Watch this video.

+ +
+ + diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..c76e822 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,135 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); + +const { loadConfig } = require("../src/config"); +const { createApp } = require("../src/app"); + +function createTestApp(configOverrides) { + return createApp( + { + ...loadConfig({}), + ...configOverrides, + }, + { + info() {}, + error() {}, + }, + ); +} + +function createFixtureServer(routes) { + const server = http.createServer((req, res) => { + const routeHandler = Object.hasOwn(routes, req.url) + ? routes[req.url] + : routes.default; + + if (routeHandler === undefined) { + res.statusCode = 404; + res.end("not found"); + return; + } + + if (typeof routeHandler !== "function") { + res.statusCode = 500; + res.end("invalid route handler"); + return; + } + + routeHandler(req, res); + }); + + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, () => { + const address = server.address(); + + resolve({ + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((closeResolve, closeReject) => { + server.close((error) => { + if (error) { + closeReject(error); + return; + } + + closeResolve(); + }); + }), + }); + }); + }); +} + +function fixtureHtml(name) { + return fs.readFileSync( + path.join(__dirname, "fixtures", `${name}.html`), + "utf8", + ); +} + +function htmlRoute(fixtureName) { + return (_req, res) => { + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end(fixtureHtml(fixtureName)); + }; +} + +function postJson(server, payload) { + return new Promise((resolve, reject) => { + const address = server.address(); + const body = JSON.stringify(payload); + const request = http.request( + { + method: "POST", + host: "127.0.0.1", + port: address.port, + path: "/", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + }, + (response) => { + const chunks = []; + + response.on("data", (chunk) => { + chunks.push(chunk); + }); + response.on("end", () => { + resolve({ + statusCode: response.statusCode, + body: JSON.parse(Buffer.concat(chunks).toString("utf8")), + }); + }); + }, + ); + + request.on("error", reject); + request.write(body); + request.end(); + }); +} + +function assertFetchFailureShape( + response, + expectedCode, + expectedError = "Some weird error fetching the content", +) { + assert.equal(response.body.error, expectedError); + assert.equal(typeof response.body.details, "object"); + assert.ok(response.body.details); + assert.equal(response.body.details.code, expectedCode); + assert.equal(typeof response.body.details.message, "string"); +} + +module.exports = { + assertFetchFailureShape, + createFixtureServer, + createTestApp, + fixtureHtml, + htmlRoute, + postJson, +}; diff --git a/test/helpers.test.js b/test/helpers.test.js new file mode 100644 index 0000000..1db2995 --- /dev/null +++ b/test/helpers.test.js @@ -0,0 +1,37 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const http = require("node:http"); + +const { createFixtureServer } = require("./helpers"); + +test("createFixtureServer rejects non-function route handlers", async (t) => { + const fixture = await createFixtureServer({ + "/broken": "not-a-function", + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await new Promise((resolve, reject) => { + const request = http.request(`${fixture.baseUrl}/broken`, (res) => { + const chunks = []; + + res.on("data", (chunk) => { + chunks.push(chunk); + }); + res.on("end", () => { + resolve({ + statusCode: res.statusCode, + body: Buffer.concat(chunks).toString("utf8"), + }); + }); + }); + + request.on("error", reject); + request.end(); + }); + + assert.equal(response.statusCode, 500); + assert.equal(response.body, "invalid route handler"); +}); diff --git a/test/markdown.test.js b/test/markdown.test.js new file mode 100644 index 0000000..8c6c157 --- /dev/null +++ b/test/markdown.test.js @@ -0,0 +1,173 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const supertest = require("supertest"); + +const { toMarkdown } = require("../src/markdown"); +const { createFixtureServer, createTestApp, htmlRoute } = require("./helpers"); + +test("POST / returns markdown content by default", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-basic"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.doesNotMatch(response.body.content, /

/i); + assert.doesNotMatch(response.body.content, /

/i); + assert.match(response.body.content, /This is the lead paragraph/); +}); + +test("POST / returns HTML content when contentFormat=html is requested", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-basic"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article`, contentFormat: "html" }) + .expect(200); + + assert.match(response.body.content, /

/i); + assert.match(response.body.content, /This is the lead paragraph/); +}); + +test("POST / returns markdown content when contentFormat=markdown is requested explicitly", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-basic"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article`, contentFormat: "markdown" }) + .expect(200); + + assert.doesNotMatch(response.body.content, /

/i); + assert.doesNotMatch(response.body.content, /

/i); + assert.match(response.body.content, /This is the second paragraph/); +}); + +test("POST / converts Vimeo iframe to markdown embed link", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-vimeo"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.match( + response.body.content, + /\[Video: Vimeo\]\(https:\/\/player\.vimeo\.com\/video\/123456789\)/, + ); +}); + +test("POST / preserves allowed iframe and video tags in returned content", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-media"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.match( + response.body.content, + /\[Video: YouTube\]\(https:\/\/www\.youtube\.com\/embed\/abc123\)/, + ); + assert.match( + response.body.content, + /\[Video\]\(https:\/\/cdn\.example\/video\.mp4\)/, + ); +}); + +test("POST / converts HTML table to GFM markdown table", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-table"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.match(response.body.content, /\|/); + assert.match(response.body.content, /Name/); + assert.match(response.body.content, /Score/); + assert.match(response.body.content, /Alice/); + assert.doesNotMatch(response.body.content, / { + const result = toMarkdown( + '', + ); + + assert.match( + result, + /\[Embedded content\]\(https:\/\/embed\.unknown-provider\.com\/widget\/abc\)/, + ); +}); + +test("toMarkdown does not classify unrelated hosts by query-string provider names", () => { + const result = toMarkdown( + '', + ); + + assert.doesNotMatch(result, /\[Video: YouTube\]/); + assert.match( + result, + /\[Embedded content\]\(https:\/\/evil\.example\/embed\?next=https:\/\/www\.youtube\.com\/watch\?v=abc123\)/, + ); +}); + +test("toMarkdown drops iframe and video URLs with unsupported schemes", () => { + const iframeResult = toMarkdown( + '', + ); + const videoResult = toMarkdown(''); + + assert.equal(iframeResult, ""); + assert.equal(videoResult, ""); +}); diff --git a/test/parsing.test.js b/test/parsing.test.js new file mode 100644 index 0000000..e02be94 --- /dev/null +++ b/test/parsing.test.js @@ -0,0 +1,67 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const supertest = require("supertest"); + +const { RESPONSE_FIELDS } = require("../src/response"); +const { createFixtureServer, createTestApp, htmlRoute } = require("./helpers"); + +test("POST / returns only the explicit response fields for a readable article", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-basic"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.deepEqual(Object.keys(response.body), RESPONSE_FIELDS); + assert.equal(response.body.url, `${fixture.baseUrl}/article`); + assert.equal(response.body.title, "Readable headline"); + assert.equal(response.body.byline, null); + assert.equal(response.body.dir, null); + assert.equal(typeof response.body.content, "string"); + assert.ok(response.body.content.includes("This is the lead paragraph.")); + assert.equal(typeof response.body.length, "number"); + assert.ok(response.body.length > 0); + assert.equal(response.body.excerpt, "This is the lead paragraph."); + assert.equal(response.body.siteName, null); + assert.equal(typeof response.body.textContent, "string"); + assert.ok( + response.body.textContent.includes("This is the second paragraph."), + ); + assert.equal(response.body.lang, "en"); + assert.equal(response.body.publishedTime, "2024-01-02T03:04:05Z"); +}); + +test("POST / falls back to the document language in the response", async (t) => { + const fixture = await createFixtureServer({ + "/article": (_req, res) => { + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end(` + + Bonjour +

Bonjour

Texte lisible.

+ `); + }, + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article`, contentFormat: "html" }) + .expect(200); + + assert.equal(response.body.lang, "fr"); +}); diff --git a/test/redirects.test.js b/test/redirects.test.js new file mode 100644 index 0000000..7f52d58 --- /dev/null +++ b/test/redirects.test.js @@ -0,0 +1,125 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const dns = require("node:dns/promises"); +const supertest = require("supertest"); + +const { + assertFetchFailureShape, + createFixtureServer, + createTestApp, +} = require("./helpers"); + +test("POST / follows redirects when the targets remain allowed", async (t) => { + const fixture = await createFixtureServer({ + "/start": (_req, res) => { + res.statusCode = 302; + res.setHeader("location", "/article"); + res.end(); + }, + "/article": (_req, res) => { + res.setHeader("content-type", "text/html; charset=utf-8"); + res.end( + "Redirected headline

Redirected headline

Redirect success body.

", + ); + }, + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/start` }) + .expect(200); + + assert.equal(response.body.title, "Redirected headline"); + assert.match(response.body.content, /Redirect success body/); +}); + +test("POST / rejects redirects to private networks when blocking is enabled", async (t) => { + t.after(() => { + t.mock.restoreAll(); + }); + + t.mock.method(dns, "lookup", async (hostname) => { + if (hostname === "public.example") { + return [{ address: "93.184.216.34", family: 4 }]; + } + + throw Object.assign(new Error(`unexpected hostname ${hostname}`), { + code: "ENOTFOUND", + }); + }); + + t.mock.method(global, "fetch", async () => { + return new Response(null, { + status: 302, + headers: { + location: "http://127.0.0.1/private-article", + }, + }); + }); + + const response = await supertest(createTestApp()) + .post("/") + .send({ url: "http://public.example/start" }) + .expect(500); + + assertFetchFailureShape(response, "FETCH_PRIVATE_NETWORK_BLOCKED"); + assert.equal(response.body.details.address, "127.0.0.1"); +}); + +test("POST / returns a stable redirect-limit error for redirect loops", async (t) => { + const fixture = await createFixtureServer({ + "/loop-a": (_req, res) => { + res.statusCode = 302; + res.setHeader("location", "/loop-b"); + res.end(); + }, + "/loop-b": (_req, res) => { + res.statusCode = 302; + res.setHeader("location", "/loop-a"); + res.end(); + }, + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false, fetchMaxRedirects: 1 }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/loop-a` }) + .expect(500); + + assertFetchFailureShape(response, "FETCH_REDIRECT_LIMIT_EXCEEDED"); + assert.equal(response.body.details.maxRedirects, 1); +}); + +test("POST / returns a stable error when a redirect omits the Location header", async (t) => { + const fixture = await createFixtureServer({ + "/start": (_req, res) => { + res.statusCode = 302; + res.end(); + }, + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/start` }) + .expect(500); + + assertFetchFailureShape(response, "FETCH_REDIRECT_WITHOUT_LOCATION"); + assert.equal(response.body.details.status, 302); +}); diff --git a/test/security.test.js b/test/security.test.js new file mode 100644 index 0000000..5d99f8f --- /dev/null +++ b/test/security.test.js @@ -0,0 +1,76 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const supertest = require("supertest"); + +const { + assertFetchFailureShape, + createFixtureServer, + createTestApp, + htmlRoute, +} = require("./helpers"); + +test("POST / blocks private-network targets by default", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-basic"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest(createTestApp()) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(500); + + assertFetchFailureShape(response, "FETCH_PRIVATE_NETWORK_BLOCKED"); + assert.equal(response.body.details.address, "127.0.0.1"); +}); + +test("POST / sanitizes returned article content", async (t) => { + const fixture = await createFixtureServer({ + "/article": htmlRoute("article-unsafe"), + }); + + t.after(async () => { + await fixture.close(); + }); + + const response = await supertest( + createTestApp({ blockPrivateNetworks: false }), + ) + .post("/") + .send({ url: `${fixture.baseUrl}/article` }) + .expect(200); + + assert.doesNotMatch(response.body.content, /