From 2683eae6b3e211c5368ac3550507f94583eb47df Mon Sep 17 00:00:00 2001 From: Juergen Holly Date: Fri, 13 Nov 2026 11:50:48 +0100 Subject: [PATCH] Initial commit --- .containerignore | 7 + .gitignore | 31 + Containerfile | 58 ++ LICENSE | 18 + Makefile | 23 + README.md | 264 ++++++ package-lock.json | 1553 ++++++++++++++++++++++++++++++++++++ package.json | 24 + podman/wiki-print.pod.yaml | 67 ++ public/app.js | 503 ++++++++++++ public/index.html | 126 +++ public/styles.css | 473 +++++++++++ scripts/build.sh | 17 + scripts/down.sh | 18 + scripts/logs.sh | 10 + scripts/test.sh | 14 + scripts/up.sh | 70 ++ scripts/up_with_env.sh | 18 + src/cli.js | 144 ++++ src/exporter.js | 316 ++++++++ src/html-builder.js | 591 ++++++++++++++ src/jobs.js | 225 ++++++ src/logger.js | 143 ++++ src/page-selection.js | 210 +++++ src/pdf-renderer.js | 157 ++++ src/print-styles.js | 278 +++++++ src/server.js | 390 +++++++++ src/utils.js | 177 ++++ src/wiki-client.js | 304 +++++++ test/api.test.js | 201 +++++ test/html-builder.test.js | 171 ++++ test/logger.test.js | 81 ++ test/mock-wiki.js | 264 ++++++ test/selection.test.js | 165 ++++ test/smoke.test.js | 100 +++ test/wiki-client.test.js | 158 ++++ 36 files changed, 7369 insertions(+) create mode 100644 .containerignore create mode 100644 .gitignore create mode 100644 Containerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 podman/wiki-print.pod.yaml create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css create mode 100755 scripts/build.sh create mode 100755 scripts/down.sh create mode 100755 scripts/logs.sh create mode 100755 scripts/test.sh create mode 100755 scripts/up.sh create mode 100755 scripts/up_with_env.sh create mode 100755 src/cli.js create mode 100644 src/exporter.js create mode 100644 src/html-builder.js create mode 100644 src/jobs.js create mode 100644 src/logger.js create mode 100644 src/page-selection.js create mode 100644 src/pdf-renderer.js create mode 100644 src/print-styles.js create mode 100644 src/server.js create mode 100644 src/utils.js create mode 100644 src/wiki-client.js create mode 100644 test/api.test.js create mode 100644 test/html-builder.test.js create mode 100644 test/logger.test.js create mode 100644 test/mock-wiki.js create mode 100644 test/selection.test.js create mode 100644 test/smoke.test.js create mode 100644 test/wiki-client.test.js diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..77c604a --- /dev/null +++ b/.containerignore @@ -0,0 +1,7 @@ +node_modules +npm-debug.log* +.git +.gitignore +.DS_Store +*.pdf +wiki-print-test-* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..980f80f --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Dependencies +node_modules/ + +# Local configuration and secrets +.env +.env.* +!.env.example + +# Generated output +*.pdf +out/ +dist/ +tmp/ +temp/ + +# Test artifacts +wiki-print-test-* +coverage/ +*.log +npm-debug.log* + +# Editor and OS files +.DS_Store +Thumbs.db +*.swp +.idea/ +.vscode/ + +# Podman/build leftovers +*.tar +*.tar.gz diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..094d4cb --- /dev/null +++ b/Containerfile @@ -0,0 +1,58 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-bookworm-slim AS runtime + +ENV NODE_ENV=production \ + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ + CHROMIUM_PATH=/usr/bin/chromium \ + PORT=8080 \ + HOME=/home/appuser \ + LANG=C.UTF-8 \ + LC_ALL=C.UTF-8 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + chromium \ + curl \ + fonts-dejavu-core \ + fonts-liberation \ + fonts-noto-cjk \ + fonts-noto-color-emoji \ + fonts-noto-core \ + tini \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force + +COPY src ./src +COPY public ./public + +RUN useradd --create-home --uid 10001 appuser \ + && chown -R appuser:appuser /app /home/appuser + +USER appuser + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["node", "src/server.js"] + +# Test image: includes dev dependencies and the test suite. +FROM runtime AS test +USER root +# NODE_ENV=production is inherited from the runtime stage, which would make +# npm skip dev dependencies such as pdfjs-dist that the smoke test needs. +RUN npm ci --include=dev && npm cache clean --force +COPY --chown=appuser:appuser test ./test +USER appuser +CMD ["npm", "test"] + +# Default production target. +FROM runtime AS production diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..efe9321 --- /dev/null +++ b/LICENSE @@ -0,0 +1,18 @@ +MIT License + +Copyright (c) 2026 juergen + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2239410 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +.PHONY: build up down logs test cli + +build: + ./scripts/build.sh + +up: + ./scripts/up.sh + +down: + ./scripts/down.sh + +logs: + ./scripts/logs.sh + +test: + ./scripts/test.sh + +cli: + @if [ -z "$$WIKI_URL" ] || [ -z "$$WIKI_API_KEY" ] || [ -z "$$WIKI_START_PAGE" ]; then \ + echo "Set WIKI_URL, WIKI_API_KEY and WIKI_START_PAGE first."; \ + exit 2; \ + fi + npm run cli diff --git a/README.md b/README.md new file mode 100644 index 0000000..052208a --- /dev/null +++ b/README.md @@ -0,0 +1,264 @@ +# wiki-print + +Export a [Wiki.js](https://js.wiki/) page and every page below it into one PDF. + +The exporter talks to the Wiki.js GraphQL API, collects the page subtree, renders all pages into a +single printable HTML document, and prints it with Chromium. It is packaged as a small web app that +runs in a Podman pod. + +## What you get + +- One PDF containing the starting page and every page whose path is below it + (`docs` → `docs`, `docs/install`, `docs/install/linux`, …). +- **Working links:** links between exported pages become internal PDF links. Links to pages outside + the exported subtree and external websites stay clickable online URLs. +- The table of contents, heading anchors and same-page `#anchor` links keep working. +- Images are fetched from the wiki and embedded as data URIs, so the PDF is self-contained. +- A cover page, table of contents, page numbers and PDF bookmarks (from the page headings). +- A simple web form that lists every page from the wiki so you can pick a starting page; the wiki URL + and API key are configured on the server. +- Optional unpublished-page export and image embedding. + +## Quick start + +Requirements: Podman 4+. Node.js is only needed for local development. + +```bash +export WIKI_URL='https://wiki.example.com' +export WIKI_API_KEY='your-wiki-js-api-key' +./scripts/up.sh +``` + +`up.sh` stores both values in the Podman secret `wiki-print-config`, passes them to the container as +`WIKI_URL` and `WIKI_API_KEY`, builds `localhost/wiki-print:latest` on first run, and creates the pod +`wiki-print` with the web UI on [http://localhost:8080](http://localhost:8080). + +On macOS the script starts the Podman machine if it is not running. + +Then open the UI and choose a **starting page** from the list of pages loaded from your wiki. The +list can be filtered by title or path, and unpublished pages are hidden unless **Include unpublished +pages** is enabled. Press **Create PDF**. The export runs in the background and the download link +appears when it is finished. + +Useful commands: + +```bash +./scripts/build.sh # rebuild the image +./scripts/up.sh # create/recreate the pod (reads WIKI_URL and WIKI_API_KEY) +./scripts/logs.sh # follow the app logs +./scripts/down.sh # remove the pod and the configuration secret +./scripts/test.sh # build the test image and run all tests +``` + +## API key + +In Wiki.js go to **Administration → API Access**, create a key and assign it to a group that can read +the pages you want to export. The group needs the **read:pages** permission. + +Pass the key to the container as the environment variable `WIKI_API_KEY`: + +```bash +export WIKI_API_KEY='eyJhbGc...' +./scripts/up.sh +``` + +The web form does not ask for the key and the browser never receives it. `up.sh` keeps both the URL +and the key in the Podman secret `wiki-print-config`; the pod manifest exposes them as `WIKI_URL` and +`WIKI_API_KEY` inside the container. When either value changes, export the new value and run +`./scripts/up.sh` again. + +`read:source` is optional. It is only used as a fallback for pages whose rendered HTML is missing: +the exporter can then fetch the markdown source and render it itself. Without it, such a page is +included as a link-only page and a warning is shown. + +The key is used only for server-side Wiki.js requests and for fetching images from the wiki. It is +never sent to the browser or included in job status responses, PDFs, or log output. + +## How the subtree is selected + +The UI loads the page list from the Wiki.js GraphQL API. Each entry carries its path and locale, so +selecting a page also selects the right locale even when the same path exists in several languages. +Pages are grouped by locale in the list. + +After you pick a starting page, every page in the same locale whose path starts with +`/` is included. Paths are compared on segment boundaries, so `docs` includes +`docs/install` but not `docs-other`. + +Examples: + +| Starting page | Result | +| --- | --- | +| `docs` | `docs`, `docs/install`, `docs/install/linux`, … | +| `docs/install` | `docs/install`, `docs/install/linux`, … | +| `docs-other` | not part of the `docs` subtree | +| `docs/draft` | only exported when **Include unpublished pages** is enabled | + +## Link behaviour + +The renderer rewrites links before printing: + +- Link target is an exported page → `#internal-anchor` in the PDF. +- Link target is a heading on an exported page → the matching prefixed heading anchor. +- Link target is another wiki page outside the subtree → absolute wiki URL, still clickable. +- Link target is an external website → unchanged, still clickable. +- Link target is another locale → absolute wiki URL, still clickable. +- `mailto:`, `tel:` and similar links are left alone. +- Scripts, event handlers, forms and iframes are removed for safe printing; embedded content is + replaced with a clickable link. + +Chromium's print-to-PDF produces URI actions for external links and GoTo actions for internal +anchors. Most PDF readers support both. + +## Podman networking + +A container cannot reach the host's `localhost`. Depending on where Wiki.js runs: + +| Wiki.js location | `WIKI_URL` to use | +| --- | --- | +| On the same Mac/host | `http://host.containers.internal:3000` | +| On the LAN | `http://192.168.1.50:3000` | +| On a public server | `https://wiki.example.com` | +| In another container | the container name, if both containers share a Podman network | + +Set `WIKI_URL` before running `./scripts/up.sh`. + +If Wiki.js uses HTTPS with a self-signed certificate, the container's Chromium/Node trust store must +trust it. For a quick local setup, use the plain HTTP URL of the wiki. + +The pod publishes port `8080` on the host. To change it, edit `podman/wiki-print.pod.yaml` and the +health check port in the same file. + +## Configuration + +The pod manifest sets the most important values. You can also pass environment variables to +`podman play kube` by editing `podman/wiki-print.pod.yaml`: + +| Variable | Default | Meaning | +| --- | --- | --- | +| `WIKI_URL` | required | Root URL of the Wiki.js installation | +| `WIKI_API_KEY` | required | Wiki.js API key used by the server for all exports | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` or `silent` | +| `LOG_FORMAT` | `text` | `text` for humans, `json` for log collectors | +| `PORT` | `8080` | HTTP port inside the container | +| `MAX_PAGES` | `500` | Default subtree size limit (the form can lower it) | +| `MAX_QUEUE` | `5` | Maximum number of waiting exports | +| `EXPORT_CONCURRENCY` | `1` | Number of PDFs rendered at once | +| `PAGE_FETCH_CONCURRENCY` | `4` | Parallel GraphQL page requests | +| `JOB_TTL_MINUTES` | `120` | How long finished PDFs stay available | +| `AUTH_USER` / `AUTH_PASSWORD` | unset | Optional HTTP Basic authentication for the UI | +| `CHROMIUM_PATH` | `/usr/bin/chromium` | Chromium executable | + +Example with a password-protected UI: add `AUTH_USER` and `AUTH_PASSWORD` to the container's `env` +list in the manifest and recreate the pod. + +## Logging + +All logs go to stderr, which Podman captures. `./scripts/logs.sh` follows them. + +The default text format is one line per event: + +```text +2026-11-12T11:16:18.549Z INFO server started app=wiki-print host=0.0.0.0 port=8080 maxPages=500 apiKeyConfigured=true +2026-11-12T11:16:19.195Z INFO request app=wiki-print requestId=ab9d6a31 method=POST path=/api/exports status=202 durationMs=4 +2026-11-12T11:16:19.195Z INFO export queued app=wiki-print jobId=... startPage=docs locale=en queueDepth=1 +2026-11-12T11:16:24.720Z INFO PDF rendered app=wiki-print jobId=... filename=docs-wiki-....pdf pageCount=5 durationMs=3120 +2026-11-12T11:16:24.741Z INFO export finished app=wiki-print jobId=... durationMs=5546 pageCount=5 warningCount=1 +``` + +What is logged: + +- Server startup configuration, without the API key. +- Every API request with method, path, status, duration and a request ID. The same ID is returned in + the `X-Request-Id` response header. +- Job lifecycle: queued, started, stage changes, warnings, completion and failures with a job ID. +- Export timings for the page list, content fetching, HTML building and PDF rendering. +- Wiki.js GraphQL requests and retries at `debug` level. +- Image embedding counts at `debug` level. +- Errors with stack traces in the logs; the browser only receives the message and error code. + +Set `LOG_LEVEL=debug` to include per-request Wiki.js timings and per-page fetch details. Set +`LOG_FORMAT=json` to get one JSON object per line for Loki, Elastic, journald or similar collectors: + +```json +{"timestamp":"2026-11-12T11:16:19.195Z","level":"info","message":"export finished","app":"wiki-print","jobId":"...","pageCount":5} +``` + +Credential-like fields are redacted automatically, and the API key is never logged. The CLI keeps its +live progress display and shows logs from `warn` upward by default; set `LOG_LEVEL=info` to see the +full export log there too. + +## CLI + +The same exporter is available without the web UI: + +```bash +export WIKI_URL=https://wiki.example.com +export WIKI_API_KEY=... +export WIKI_START_PAGE=docs/getting-started + +npm run cli -- --output ./docs.pdf +# or +node src/cli.js --url https://wiki.example.com --start docs --output docs.pdf +``` + +`WIKI_API_KEY` is read from the environment; `--api-key` remains available for one-off local runs. + +Use `npm run cli -- --help` for all options. The container includes the CLI too: + +```bash +podman exec -it wiki-print-wiki-print node src/cli.js --help +``` + +## Development and tests + +```bash +npm install +npm test +``` + +The tests cover path/locale selection, subtree boundaries, link rewriting and sanitization. An API +test starts the server against a mock Wiki.js and checks `/api/config`, `/api/pages`, the page +sorting and the missing-`WIKI_URL` handling. The smoke test runs a real export and inspects the +resulting PDF (text, internal link annotations and external URL annotations). It needs Chromium; +`CHROMIUM_PATH` can point to a local installation. + +To run the full suite in the container: + +```bash +./scripts/test.sh +``` + +This uses the `test` target in the `Containerfile`, which includes dev dependencies. + +## Architecture + +```text +public/ web UI (vanilla HTML/CSS/JS) +src/server.js Express server, page list, job API, optional Basic auth +src/jobs.js in-memory queue and job status +src/exporter.js orchestration: GraphQL → subtree → HTML → PDF +src/wiki-client.js Wiki.js GraphQL client +src/page-selection.js starting page, locale and subtree resolution +src/html-builder.js sanitization, link rewriting, image embedding, print HTML +src/pdf-renderer.js Chromium/Playwright print-to-PDF +src/print-styles.js print stylesheet +podman/ Podman pod manifest +scripts/ build/run/test helpers +test/ unit tests, mock wiki and PDF smoke test +``` + +Jobs are stored in memory. Restarting the container removes queued jobs and makes old download links +unavailable; finished PDFs live in a temporary directory inside the container and expire after +`JOB_TTL_MINUTES`. + +## Limitations + +- Wiki.js 2.x GraphQL API only. +- The exporter uses Wiki.js's rendered page HTML. Client-side-only widgets (for example embedded + editors or scripts) will not run inside the PDF. Server-rendered diagrams and images work. +- Page order is lexicographic by path, with the starting page first. +- Very large exports are limited by `MAX_PAGES` and container memory. Chromium needs roughly + 200–500 MB for typical documents. +- PDF internal links depend on the reader; external URLs are standard PDF URI actions. +- The UI itself has no login unless `AUTH_USER`/`AUTH_PASSWORD` is set. Run it only on a trusted + network or enable the optional Basic auth. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..37ee775 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1553 @@ +{ + "name": "wiki-print", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wiki-print", + "version": "1.0.0", + "dependencies": { + "cheerio": "^1.2.0", + "express": "^5.3.0", + "markdown-it": "^14.3.3", + "playwright-core": "^1.65.0" + }, + "devDependencies": { + "pdfjs-dist": "^5.7.284" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz", + "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==", + "dev": true, + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.100", + "@napi-rs/canvas-darwin-arm64": "0.1.100", + "@napi-rs/canvas-darwin-x64": "0.1.100", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.100", + "@napi-rs/canvas-linux-arm64-musl": "0.1.100", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-gnu": "0.1.100", + "@napi-rs/canvas-linux-x64-musl": "0.1.100", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.100", + "@napi-rs/canvas-win32-x64-msvc": "0.1.100" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz", + "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz", + "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz", + "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz", + "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz", + "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz", + "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz", + "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz", + "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz", + "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz", + "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.100", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz", + "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/body-parser": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.1.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", + "on-finished": "^2.4.1", + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/content-disposition": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/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/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.2.tgz", + "integrity": "sha512-ddmxiyS6ETq/v5/+mdA4fykK7JgEmqcShrGmQFTaKf5o43tL8XLuMzNLLsspuIC3/qGupS7Ci2TF+feJRkYY0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/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.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.1.0", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "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/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/markdown-it": { + "version": "14.3.3", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.3.tgz", + "integrity": "sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "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/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT" + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.7.284", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz", + "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.100" + } + }, + "node_modules/playwright-core": { + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.17.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.17.1.tgz", + "integrity": "sha512-jd3COkaKVgGUW+ZHyEHCG9bH7RoaaiWuMgzsIBjUMVjLAaWGWpoUAVwljVvBHxRu/2qEHnadLGZrkutYUBPQJA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.30.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.2.tgz", + "integrity": "sha512-mLyPQnHwUxaM/rideoK/+KL8DaAJLRl4aBCfmX444aCSLnIaomqPLtNRe2jdCYcsk5wZzXrDIQC8ZeYNRAYyIA==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0092b08 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "wiki-print", + "version": "1.0.0", + "private": true, + "description": "Export a Wiki.js page and all pages below it to a single, link-preserving PDF.", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/server.js", + "cli": "node src/cli.js", + "test": "node --test --test-concurrency=1 test/*.test.js" + }, + "dependencies": { + "cheerio": "^1.2.0", + "express": "^5.3.0", + "markdown-it": "^14.3.3", + "playwright-core": "^1.65.0" + }, + "devDependencies": { + "pdfjs-dist": "^5.7.284" + } +} diff --git a/podman/wiki-print.pod.yaml b/podman/wiki-print.pod.yaml new file mode 100644 index 0000000..2b9549e --- /dev/null +++ b/podman/wiki-print.pod.yaml @@ -0,0 +1,67 @@ +apiVersion: v1 +kind: Pod +metadata: + name: wiki-print + labels: + app: wiki-print +spec: + restartPolicy: Always + terminationGracePeriodSeconds: 20 + containers: + - name: wiki-print + image: localhost/wiki-print:latest + imagePullPolicy: Never + ports: + - name: http + containerPort: 8080 + hostPort: 8080 + protocol: TCP + env: + - name: PORT + value: "8080" + - name: NODE_ENV + value: production + - name: LOG_LEVEL + value: info + - name: LOG_FORMAT + value: text + - name: MAX_PAGES + value: "500" + - name: WIKI_URL + valueFrom: + secretKeyRef: + name: wiki-print-config + key: WIKI_URL + optional: true + - name: WIKI_API_KEY + valueFrom: + secretKeyRef: + name: wiki-print-config + key: WIKI_API_KEY + optional: true + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + resources: + requests: + memory: "256Mi" + limits: + memory: "1536Mi" + readinessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + livenessProbe: + httpGet: + path: /healthz + port: 8080 + initialDelaySeconds: 15 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..f7d39d1 --- /dev/null +++ b/public/app.js @@ -0,0 +1,503 @@ +const form = document.getElementById('export-form') +const submitButton = document.getElementById('submit-button') +const statusPanel = document.getElementById('status-panel') +const statusTitle = document.getElementById('status-title') +const statusEyebrow = document.getElementById('status-eyebrow') +const statusBadge = document.getElementById('status-badge') +const statusMessage = document.getElementById('status-message') +const progressBar = document.querySelector('.progress') +const progressFill = document.getElementById('progress-fill') +const statusPages = document.getElementById('status-pages') +const errorBox = document.getElementById('error-box') +const errorMessage = document.getElementById('error-message') +const resultBox = document.getElementById('result-box') +const resultMeta = document.getElementById('result-meta') +const downloadButton = document.getElementById('download-button') +const warningsBox = document.getElementById('warnings') +const warningsCount = document.getElementById('warnings-count') +const warningsList = document.getElementById('warnings-list') +const configNotice = document.getElementById('config-notice') +const pageFilter = document.getElementById('page-filter') +const startPageSelect = document.getElementById('start-page') +const pageStatus = document.getElementById('page-status') +const includeUnpublishedInput = document.getElementById('include-unpublished') +const includeTocInput = document.getElementById('include-toc') +const inlineImagesInput = document.getElementById('inline-images') +const maxPagesInput = document.getElementById('max-pages') + +let pollTimer = null +let networkFailures = 0 +let activeJobId = null +let serverConfigured = true +let allPages = [] +let wikiBaseUrl = '' +let savedStartPage = null +let savedLocale = null + +function setSubmitBusy(busy, label = 'Create PDF') { + submitButton.disabled = busy || !serverConfigured + submitButton.textContent = busy ? 'Working…' : label +} + +function showConfigNotice(message) { + configNotice.hidden = false + configNotice.textContent = message +} + +function stagePercent(job) { + if (job.status === 'done') return 100 + + const found = Number(job.pagesFound) || 0 + const done = Number(job.pagesDone) || 0 + + switch (job.stage) { + case 'queued': + return 2 + case 'connect': + return 6 + case 'list': + return 12 + case 'select': + return 20 + case 'fetch': + return found > 0 ? 24 + Math.round((Math.min(done, found) / found) * 62) : 24 + case 'build': + return 89 + case 'render': + return 95 + case 'done': + return 100 + default: + return job.status === 'running' ? 12 : 0 + } +} + +function renderJob(job) { + statusPanel.hidden = false + statusPanel.dataset.status = job.status + activeJobId = job.id + + const titles = { + queued: 'Export queued', + running: 'Exporting…', + done: 'PDF ready', + error: 'Export failed', + } + statusTitle.textContent = titles[job.status] || 'Export' + statusBadge.textContent = job.status === 'running' ? 'working' : job.status + statusMessage.textContent = job.message || '' + statusEyebrow.textContent = job.request?.startPage + ? `Starting page · ${job.request.startPage}` + : 'Export' + + const percent = stagePercent(job) + progressFill.style.width = `${percent}%` + progressBar.setAttribute('aria-valuenow', String(percent)) + + if (job.pagesFound > 0) { + const warningText = job.warnings?.length + ? ` · ${job.warnings.length} warning${job.warnings.length === 1 ? '' : 's'}` + : '' + statusPages.textContent = `Pages: ${job.pagesDone} / ${job.pagesFound}${warningText}` + } else { + statusPages.textContent = '' + } + + if (job.status === 'error' && job.error) { + errorBox.hidden = false + errorMessage.textContent = job.error.code + ? `${job.error.message} (${job.error.code})` + : job.error.message + } else { + errorBox.hidden = true + errorMessage.textContent = '' + } + + if (job.status === 'done' && job.result) { + resultBox.hidden = false + const result = job.result + resultMeta.textContent = `${result.pageCount} page${result.pageCount === 1 ? '' : 's'} · locale ${result.locale} · starting at ${result.startPath}` + downloadButton.href = result.downloadUrl + downloadButton.setAttribute('download', result.filename) + } else { + resultBox.hidden = true + downloadButton.removeAttribute('href') + } + + if (job.warnings?.length) { + warningsBox.hidden = false + warningsCount.textContent = String(job.warnings.length) + warningsList.replaceChildren() + for (const warning of job.warnings) { + const item = document.createElement('li') + item.textContent = warning + warningsList.append(item) + } + } else { + warningsBox.hidden = true + warningsList.replaceChildren() + } + + const finished = job.status === 'done' || job.status === 'error' + setSubmitBusy(!finished) +} + +function showRequestError(message) { + statusPanel.hidden = false + statusPanel.dataset.status = 'error' + statusTitle.textContent = 'Export failed' + statusBadge.textContent = 'error' + statusMessage.textContent = message + statusEyebrow.textContent = 'Export' + progressFill.style.width = '0%' + progressBar.setAttribute('aria-valuenow', '0') + statusPages.textContent = '' + resultBox.hidden = true + errorBox.hidden = false + errorMessage.textContent = message + setSubmitBusy(false) +} + +function stopPolling() { + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } +} + +async function pollJob(jobId) { + stopPolling() + + const tick = async () => { + try { + const response = await fetch(`/api/exports/${encodeURIComponent(jobId)}`, { + cache: 'no-store', + }) + + if (response.status === 404) { + showRequestError('The export job disappeared. It may have expired. Start a new export.') + return + } + + if (!response.ok) { + let message = `The server answered with HTTP ${response.status}.` + try { + const payload = await response.json() + if (payload.error) message = payload.error + } catch { + // Keep the status message. + } + throw new Error(message) + } + + networkFailures = 0 + const payload = await response.json() + renderJob(payload.job) + + if (payload.job.status === 'done' || payload.job.status === 'error') { + return + } + + pollTimer = setTimeout(tick, 1000) + } catch (error) { + networkFailures += 1 + if (networkFailures >= 10) { + showRequestError(`Lost contact with the server: ${error.message}`) + return + } + statusMessage.textContent = `Connection problem, retrying… (${error.message})` + pollTimer = setTimeout(tick, 2000) + } + } + + tick() +} + +function readSessionForm() { + try { + const saved = JSON.parse(sessionStorage.getItem('wikiPrintForm') || 'null') + if (!saved) return + if (saved.startPage) { + savedStartPage = saved.startPage + savedLocale = saved.locale || '' + } + if (saved.maxPages) maxPagesInput.value = saved.maxPages + if (typeof saved.includeUnpublished === 'boolean') { + includeUnpublishedInput.checked = saved.includeUnpublished + } + if (typeof saved.includeToc === 'boolean') { + includeTocInput.checked = saved.includeToc + } + if (typeof saved.inlineImages === 'boolean') { + inlineImagesInput.checked = saved.inlineImages + } + } catch { + // Ignore malformed session storage. + } +} + +function saveSessionForm(payload) { + try { + sessionStorage.setItem( + 'wikiPrintForm', + JSON.stringify({ + startPage: payload.startPage, + locale: payload.locale, + maxPages: payload.maxPages, + includeUnpublished: payload.includeUnpublished, + includeToc: payload.includeToc, + inlineImages: payload.inlineImages, + }), + ) + } catch { + // Session storage is optional. + } +} + +function pageMatches(page, query) { + if (!query) return true + const haystack = `${page.title || ''} ${page.path} ${page.locale}`.toLowerCase() + return haystack.includes(query) +} + +function appendPageOption(parent, page) { + const option = document.createElement('option') + option.value = page.path + option.dataset.locale = page.locale + const title = String(page.title || '').trim() + option.textContent = title && title !== page.path ? `${title} — ${page.path}` : page.path + if (!page.isPublished) option.textContent += ' (unpublished)' + parent.append(option) +} + +function renderPageOptions() { + const query = pageFilter.value.trim().toLowerCase() + const includeUnpublished = includeUnpublishedInput.checked + const previous = startPageSelect.selectedOptions[0] + const previousPath = previous?.value || '' + const previousLocale = previous?.dataset.locale || '' + + const filteredPages = allPages.filter( + (page) => (includeUnpublished || page.isPublished) && pageMatches(page, query), + ) + + startPageSelect.replaceChildren() + + const placeholder = document.createElement('option') + placeholder.value = '' + placeholder.textContent = filteredPages.length ? 'Select a starting page…' : 'No matching pages' + placeholder.disabled = true + placeholder.selected = true + startPageSelect.append(placeholder) + + const locales = [...new Set(filteredPages.map((page) => page.locale))] + if (locales.length > 1) { + for (const locale of locales) { + const group = document.createElement('optgroup') + group.label = locale + for (const page of filteredPages.filter((candidate) => candidate.locale === locale)) { + appendPageOption(group, page) + } + startPageSelect.append(group) + } + } else { + for (const page of filteredPages) appendPageOption(startPageSelect, page) + } + + let restore = null + if (savedStartPage) { + restore = { path: savedStartPage, locale: savedLocale } + savedStartPage = null + savedLocale = null + } else if (previousPath) { + restore = { path: previousPath, locale: previousLocale } + } + + if (restore) { + const options = [...startPageSelect.options] + const match = options.find( + (option) => + option.value === restore.path && + (!restore.locale || option.dataset.locale === restore.locale), + ) + if (match) startPageSelect.selectedIndex = options.indexOf(match) + } + + startPageSelect.disabled = false + pageFilter.disabled = false + pageStatus.classList.toggle('error', filteredPages.length === 0) + + const host = (() => { + try { + return new URL(wikiBaseUrl).host + } catch { + return 'Wiki.js' + } + })() + + if (filteredPages.length === 0) { + pageStatus.textContent = allPages.length + ? 'No pages match this filter.' + : 'This wiki returned no pages.' + return + } + + const hidden = includeUnpublished + ? '' + : ' · unpublished pages hidden' + pageStatus.textContent = `Showing ${filteredPages.length} of ${allPages.length} pages from ${host}${hidden}` +} + +async function loadPages() { + pageStatus.classList.remove('error') + pageStatus.textContent = 'Loading the page list from Wiki.js…' + pageFilter.disabled = true + startPageSelect.disabled = true + + try { + const response = await fetch('/api/pages', { cache: 'no-store' }) + let payload = null + try { + payload = await response.json() + } catch { + // Keep the HTTP status as the error message. + } + + if (!response.ok) { + const error = new Error( + payload?.error || `The server answered with HTTP ${response.status}.`, + ) + error.code = payload?.code + throw error + } + + allPages = Array.isArray(payload.pages) ? payload.pages : [] + wikiBaseUrl = payload.baseUrl || '' + renderPageOptions() + } catch (error) { + allPages = [] + pageFilter.disabled = true + startPageSelect.disabled = true + startPageSelect.replaceChildren(new Option('Could not load pages', '')) + pageStatus.classList.add('error') + pageStatus.textContent = error.message + + if (error.code === 'WIKI_URL_MISSING' || error.code === 'WIKI_API_KEY_MISSING') { + serverConfigured = false + showConfigNotice(error.message) + setSubmitBusy(false) + } + } +} + +async function loadConfig() { + try { + const response = await fetch('/api/config', { cache: 'no-store' }) + if (!response.ok) return true + const config = await response.json() + if (config.maxPages) { + maxPagesInput.value = String(config.maxPages) + maxPagesInput.max = String(Math.max(5000, config.maxPages)) + } + + const missing = [] + if (!config.wikiUrlConfigured) missing.push('WIKI_URL') + if (!config.apiKeyConfigured) missing.push('WIKI_API_KEY') + if (missing.length > 0) { + serverConfigured = false + showConfigNotice( + `No wiki connection is configured on this server. Set ${missing.join(' and ')} and restart the container, then reload this page.`, + ) + setSubmitBusy(false) + return false + } + + return true + } catch { + // Let the page-list request report the actual connection problem. + return true + } +} + +pageFilter.addEventListener('input', renderPageOptions) +includeUnpublishedInput.addEventListener('change', renderPageOptions) + +form.addEventListener('submit', async (event) => { + event.preventDefault() + stopPolling() + + if (!form.reportValidity()) return + + const selected = startPageSelect.selectedOptions[0] + const startPage = selected?.value?.trim() || '' + const locale = selected?.dataset.locale || '' + + const payload = { + startPage, + locale, + maxPages: Number(maxPagesInput.value) || 500, + includeUnpublished: includeUnpublishedInput.checked, + includeToc: includeTocInput.checked, + inlineImages: inlineImagesInput.checked, + } + + if (!serverConfigured) { + showRequestError( + 'The wiki connection is not configured on the server. Set WIKI_URL and WIKI_API_KEY, then restart the container.', + ) + return + } + + if (!startPage) { + showRequestError('Select a starting page from the list.') + return + } + + saveSessionForm(payload) + errorBox.hidden = true + resultBox.hidden = true + warningsBox.hidden = true + warningsList.replaceChildren() + statusPanel.hidden = false + statusPanel.dataset.status = 'queued' + statusTitle.textContent = 'Starting export…' + statusBadge.textContent = 'queued' + statusMessage.textContent = 'Sending the request to the server…' + statusPages.textContent = '' + progressFill.style.width = '2%' + progressBar.setAttribute('aria-valuenow', '2') + setSubmitBusy(true) + + try { + const response = await fetch('/api/exports', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + + let body = null + try { + body = await response.json() + } catch { + // Ignore non-JSON error bodies. + } + + if (!response.ok) { + throw new Error(body?.error || `The server answered with HTTP ${response.status}.`) + } + + renderJob(body.job) + networkFailures = 0 + pollJob(body.job.id) + } catch (error) { + showRequestError(error.message) + } +}) + +async function init() { + readSessionForm() + const configured = await loadConfig() + if (configured) await loadPages() +} + +init() diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..32017d9 --- /dev/null +++ b/public/index.html @@ -0,0 +1,126 @@ + + + + + + + wiki-print · Wiki.js to PDF + + + +
+ + +
+
+

Export a Wiki.js page tree to PDF

+

+ The wiki URL and API key are configured on the server through WIKI_URL and + WIKI_API_KEY. Pick a starting page from the list below. That page and every + page beneath it are combined into a single PDF. Links between exported pages become + internal PDF links; links to other pages and websites stay clickable online links. +

+
+ +
+ + +
+ + + +

Loading the page list from Wiki.js…

+
+ +
+ Advanced options +
+ + +

The export stops with an error if the subtree is larger.

+
+ +
+ + + +
+
+ +
+ + Exports run one at a time and can take a few minutes. +
+
+ + +
+ +
+ Links are preserved with Chromium's PDF renderer. The produced file works offline for the exported subtree. +
+
+ + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..f7d1750 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,473 @@ +:root { + --ink: #16202b; + --muted: #607080; + --line: #d9e1ea; + --line-strong: #c2ceda; + --canvas: #f5f7fa; + --card: #ffffff; + --accent: #0b63b6; + --accent-dark: #084b8b; + --accent-soft: #eaf3fc; + --danger: #b42318; + --danger-soft: #fff1ef; + --success: #217a46; + --success-soft: #eefaf2; + --warning: #8a5b00; + --warning-soft: #fff8e6; + --shadow: 0 18px 50px rgba(20, 38, 58, 0.08); + --radius: 16px; +} + +* { box-sizing: border-box; } + +html { + background: var(--canvas); + color: var(--ink); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + line-height: 1.5; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 10% 0%, rgba(11, 99, 182, 0.10), transparent 28rem), + radial-gradient(circle at 100% 5%, rgba(80, 150, 215, 0.09), transparent 24rem), + var(--canvas); +} + +code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.88em; + background: #edf2f7; + border: 1px solid #e0e8f0; + border-radius: 5px; + padding: 0.08em 0.32em; +} + +.page-shell { + width: min(920px, calc(100% - 40px)); + margin: 0 auto; + padding: 32px 0 48px; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 54px; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; +} + +.brand-mark { + display: grid; + place-items: center; + width: 42px; + height: 42px; + border-radius: 12px; + background: var(--accent); + color: #fff; + font-weight: 800; + font-size: 20px; + box-shadow: 0 8px 18px rgba(11, 99, 182, 0.24); +} + +.brand-name { + font-size: 18px; + font-weight: 750; + letter-spacing: -0.02em; +} + +.brand-subtitle { + color: var(--muted); + font-size: 12.5px; +} + +.header-link { + color: var(--muted); + font-size: 12px; + text-decoration: none; + border: 1px solid var(--line); + border-radius: 999px; + padding: 5px 11px; + background: rgba(255, 255, 255, 0.65); +} + +.header-link:hover { color: var(--accent); } + +.hero { + max-width: 760px; + margin-bottom: 28px; +} + +.hero h1 { + margin: 0 0 12px; + font-size: clamp(30px, 5vw, 46px); + line-height: 1.08; + letter-spacing: -0.04em; +} + +.hero p { + margin: 0; + color: var(--muted); + font-size: 16px; + max-width: 700px; +} + +.card { + background: var(--card); + border: 1px solid rgba(194, 206, 218, 0.75); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 28px; +} + +form.card { + display: grid; + gap: 22px; +} + +.field { + display: grid; + gap: 7px; +} + +.field > label { + font-weight: 650; + font-size: 14px; +} + +.optional { + color: var(--muted); + font-weight: 450; + font-size: 12px; +} + +input[type="text"], +input[type="url"], +input[type="password"], +input[type="number"], +input[type="search"], +select { + width: 100%; + border: 1px solid var(--line-strong); + border-radius: 10px; + background: #fbfcfe; + color: var(--ink); + font: inherit; + font-size: 15px; + padding: 12px 13px; + outline: none; + transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease; +} + +select { cursor: pointer; } + +input:disabled, +select:disabled { + background: #f1f4f7; + color: var(--muted); + cursor: not-allowed; +} + +input:focus { + border-color: var(--accent); + background: #fff; + box-shadow: 0 0 0 4px rgba(11, 99, 182, 0.12); +} + +input::placeholder { color: #93a1b0; } + +.hint { + margin: 0; + color: var(--muted); + font-size: 12.5px; +} + +.hint.error { color: var(--danger); } + +.max-pages-field { + margin-top: 18px; + max-width: 240px; +} + +.config-notice { + margin: 0; + border: 1px solid #f0c7c2; + border-radius: 12px; + background: var(--danger-soft); + color: #7b1c14; + padding: 12px 14px; + font-size: 13.5px; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid transparent; + border-radius: 10px; + font: inherit; + font-size: 14px; + font-weight: 650; + padding: 11px 17px; + cursor: pointer; + text-decoration: none; + transition: background 120ms ease, border-color 120ms ease, transform 120ms ease; + white-space: nowrap; +} + +.button:active { transform: translateY(1px); } + +.button-primary { + background: var(--accent); + color: #fff; + box-shadow: 0 8px 20px rgba(11, 99, 182, 0.18); +} + +.button-primary:hover { background: var(--accent-dark); } + +.button-primary:disabled { + background: #9db8d2; + cursor: not-allowed; + box-shadow: none; +} + +.button-quiet { + background: #f2f6fa; + border-color: var(--line); + color: #405263; +} + +.button-quiet:hover { border-color: var(--line-strong); background: #eaf1f8; } + +.advanced { + border-top: 1px solid var(--line); + padding-top: 18px; +} + +.advanced summary { + cursor: pointer; + color: var(--muted); + font-size: 13.5px; + font-weight: 650; + user-select: none; +} + +.advanced summary:hover { color: var(--accent); } + +.advanced-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + margin-top: 18px; +} + +.checks { + display: grid; + gap: 10px; + margin-top: 18px; +} + +.check { + display: flex; + align-items: center; + gap: 9px; + color: #334556; + font-size: 14px; +} + +.check input { + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.actions { + display: flex; + align-items: center; + gap: 16px; + padding-top: 4px; +} + +.button-primary { min-width: 130px; } + +.actions-note { + color: var(--muted); + font-size: 12.5px; +} + +.status-card { + margin-top: 24px; +} + +.status-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.status-eyebrow { + color: var(--accent); + font-size: 11px; + font-weight: 750; + letter-spacing: 0.12em; + text-transform: uppercase; + margin-bottom: 3px; +} + +.status-heading h2 { + margin: 0; + font-size: 22px; + letter-spacing: -0.02em; +} + +.badge { + display: inline-flex; + align-items: center; + border-radius: 999px; + background: #edf2f7; + color: #526476; + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 5px 10px; +} + +.status-card[data-status="running"] .badge { + background: var(--accent-soft); + color: var(--accent-dark); +} + +.status-card[data-status="done"] .badge { + background: var(--success-soft); + color: var(--success); +} + +.status-card[data-status="error"] .badge { + background: var(--danger-soft); + color: var(--danger); +} + +.status-message { + margin: 14px 0 16px; + color: #405263; +} + +.progress { + height: 9px; + overflow: hidden; + border-radius: 999px; + background: #e9eef4; +} + +.progress-fill { + height: 100%; + width: 0; + border-radius: inherit; + background: linear-gradient(90deg, #0b63b6, #4b9fe0); + transition: width 350ms ease; +} + +.status-pages { + min-height: 18px; + margin-top: 8px; + color: var(--muted); + font-size: 12.5px; +} + +.error-box { + margin-top: 18px; + border: 1px solid #f0c7c2; + border-radius: 12px; + background: var(--danger-soft); + color: #7b1c14; + padding: 14px 16px; +} + +.error-box strong { font-size: 13.5px; } +.error-box p { margin: 5px 0 0; font-size: 14px; } + +.result-box { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-top: 18px; + border: 1px solid #c5e7d3; + border-radius: 12px; + background: var(--success-soft); + padding: 16px; +} + +.result-title { + color: #175c36; + font-weight: 700; +} + +.result-meta { + margin-top: 2px; + color: #3d6b52; + font-size: 12.5px; +} + +.warnings { + margin-top: 16px; + border-top: 1px solid var(--line); + padding-top: 14px; +} + +.warnings summary { + color: var(--warning); + cursor: pointer; + font-size: 13px; + font-weight: 650; +} + +.warnings ul { + margin: 10px 0 0; + padding-left: 20px; + color: #5b5140; + font-size: 13px; +} + +.warnings li { margin: 4px 0; overflow-wrap: anywhere; } + +.site-footer { + margin-top: 28px; + color: #7b8896; + font-size: 12px; + text-align: center; +} + +@media (max-width: 640px) { + .page-shell { + width: min(100% - 24px, 920px); + padding-top: 20px; + } + + .site-header { margin-bottom: 36px; } + + .card { padding: 20px; } + + .advanced-grid { grid-template-columns: 1fr; } + + .actions { + align-items: flex-start; + flex-direction: column; + gap: 10px; + } + + .result-box { + align-items: flex-start; + flex-direction: column; + } +} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..b68970b --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE="${IMAGE:-localhost/wiki-print:latest}" + +cd "$ROOT_DIR" + +if ! podman info >/dev/null 2>&1; then + echo "Podman is not ready. Starting the Podman machine…" + podman machine start +fi + +echo "Building ${IMAGE}…" +podman build --target production --tag "$IMAGE" . + +echo "Built ${IMAGE}." diff --git a/scripts/down.sh b/scripts/down.sh new file mode 100755 index 0000000..0c54ae5 --- /dev/null +++ b/scripts/down.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MANIFEST="$ROOT_DIR/podman/wiki-print.pod.yaml" + +podman play kube --down "$MANIFEST" >/dev/null 2>&1 || true +if podman pod exists wiki-print >/dev/null 2>&1; then + podman pod rm -f wiki-print +fi +if podman secret exists wiki-print-config >/dev/null 2>&1; then + podman secret rm wiki-print-config >/dev/null 2>&1 || true +fi +if podman secret exists wiki-print-api-key >/dev/null 2>&1; then + podman secret rm wiki-print-api-key >/dev/null 2>&1 || true +fi + +echo "Pod wiki-print removed." diff --git a/scripts/logs.sh b/scripts/logs.sh new file mode 100755 index 0000000..2c2bfe6 --- /dev/null +++ b/scripts/logs.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONTAINER="$(podman ps --filter pod=wiki-print --format '{{.Names}}' | head -n 1)" +if [ -z "$CONTAINER" ]; then + echo "No running container found in pod wiki-print." + exit 1 +fi + +exec podman logs -f "$CONTAINER" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..15823fc --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_IMAGE="${TEST_IMAGE:-localhost/wiki-print:test}" + +if ! podman info >/dev/null 2>&1; then + echo "Podman is not ready. Starting the Podman machine…" + podman machine start +fi + +cd "$ROOT_DIR" +podman build --target test --tag "$TEST_IMAGE" . +podman run --rm "$TEST_IMAGE" diff --git a/scripts/up.sh b/scripts/up.sh new file mode 100755 index 0000000..264a236 --- /dev/null +++ b/scripts/up.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +IMAGE="${IMAGE:-localhost/wiki-print:latest}" +MANIFEST="$ROOT_DIR/podman/wiki-print.pod.yaml" +SECRET_NAME="wiki-print-config" + +if [ -z "${WIKI_URL:-}" ]; then + echo "WIKI_URL is not set." >&2 + echo "Export the Wiki.js URL before starting the pod, for example:" >&2 + echo " export WIKI_URL='https://wiki.example.com'" >&2 + exit 1 +fi + +if [ -z "${WIKI_API_KEY:-}" ]; then + echo "WIKI_API_KEY is not set." >&2 + echo "Export the Wiki.js API key before starting the pod, for example:" >&2 + echo " export WIKI_API_KEY='your-wiki-js-api-key'" >&2 + exit 1 +fi + +if ! podman info >/dev/null 2>&1; then + echo "Podman is not ready. Starting the Podman machine…" + podman machine start +fi + +if ! podman image exists "$IMAGE"; then + "$ROOT_DIR/scripts/build.sh" +fi + +# Recreate the pod before replacing the secret: a running container still +# references the old secret, and Podman refuses to remove a secret in use. +if podman pod exists wiki-print >/dev/null 2>&1; then + podman play kube --down "$MANIFEST" >/dev/null 2>&1 || podman pod rm -f wiki-print >/dev/null 2>&1 || true +fi + +if podman secret exists "$SECRET_NAME" >/dev/null 2>&1; then + podman secret rm "$SECRET_NAME" >/dev/null +fi +# Remove the secret used by older versions of these scripts. +if podman secret exists wiki-print-api-key >/dev/null 2>&1; then + podman secret rm wiki-print-api-key >/dev/null 2>&1 || true +fi + +# podman play kube expects the secret content to be a JSON map of raw values +# (base64-encoded) or a Kubernetes Secret object. +printf '{"WIKI_URL":"%s","WIKI_API_KEY":"%s"}' \ + "$(printf %s "$WIKI_URL" | base64 | tr -d '\n')" \ + "$(printf %s "$WIKI_API_KEY" | base64 | tr -d '\n')" \ + | podman secret create "$SECRET_NAME" - >/dev/null + +echo "Creating pod wiki-print…" +podman play kube --replace "$MANIFEST" + +echo -n "Waiting for the web UI" +for _ in $(seq 1 60); do + if curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1; then + echo + echo "wiki-print is ready at http://localhost:8080" + exit 0 + fi + echo -n "." + sleep 1 +done + +echo +echo "The pod was created, but the health check did not become ready in time." +echo "Inspect it with: ./scripts/logs.sh" +exit 1 diff --git a/scripts/up_with_env.sh b/scripts/up_with_env.sh new file mode 100755 index 0000000..b120cb8 --- /dev/null +++ b/scripts/up_with_env.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Real Wiki.js connection details live outside the repository in a +# permission-protected file. Override the path with WIKI_PRINT_TEST_ENV. +ENV_FILE="${WIKI_PRINT_TEST_ENV:-.env}" +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + source "$ENV_FILE" +fi + +if [ -z "${WIKI_URL:-}" ] || [ -z "${WIKI_API_KEY:-}" ]; then + echo "WIKI_URL and WIKI_API_KEY must be set (for example in $ENV_FILE)." >&2 + exit 1 +fi + +export LOG_LEVEL="${LOG_LEVEL:-debug}" +exec ./scripts/up.sh diff --git a/src/cli.js b/src/cli.js new file mode 100755 index 0000000..929142d --- /dev/null +++ b/src/cli.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node +import { exportWikiToPdf } from './exporter.js' +import { createLogger } from './logger.js' +import { closeBrowser } from './pdf-renderer.js' +import { toPositiveInt } from './utils.js' + +// Keep the CLI's live progress line readable by default. Set LOG_LEVEL=info +// or LOG_LEVEL=debug to see the structured export logs as well. +const logger = createLogger({ + level: process.env.LOG_LEVEL || 'warn', + bindings: { app: 'wiki-print', mode: 'cli' }, +}) + +function usage() { + console.log(`Usage: npm run cli -- --url --start [options] + +Required: + --url Wiki.js base URL (also WIKI_URL) + --start Starting page path or full page URL (also WIKI_START_PAGE) + --api-key Wiki.js API key (also WIKI_API_KEY) + +Options: + --locale Page locale, e.g. en or de-ch + --output Output PDF path (default: a temp file) + --max-pages Safety limit (default: 500) + --include-unpublished + --no-toc + --no-images + -h, --help +`) +} + +function parseArgs(argv) { + const args = { + url: process.env.WIKI_URL || '', + start: process.env.WIKI_START_PAGE || '', + apiKey: process.env.WIKI_API_KEY || '', + locale: process.env.WIKI_LOCALE || '', + output: '', + maxPages: 500, + includeUnpublished: false, + includeToc: true, + inlineImages: true, + } + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + const takeValue = () => { + index += 1 + if (index >= argv.length) throw new Error(`Missing value for ${argument}`) + return argv[index] + } + + switch (argument) { + case '--url': + args.url = takeValue() + break + case '--start': + args.start = takeValue() + break + case '--api-key': + args.apiKey = takeValue() + break + case '--locale': + args.locale = takeValue() + break + case '--output': + args.output = takeValue() + break + case '--max-pages': + args.maxPages = toPositiveInt(takeValue(), 500, { min: 1, max: 5000 }) + break + case '--include-unpublished': + args.includeUnpublished = true + break + case '--no-toc': + args.includeToc = false + break + case '--no-images': + args.inlineImages = false + break + case '-h': + case '--help': + usage() + process.exit(0) + break + default: + throw new Error(`Unknown option: ${argument}`) + } + } + + return args +} + +async function main() { + let args + try { + args = parseArgs(process.argv.slice(2)) + } catch (error) { + console.error(error.message) + usage() + process.exit(2) + } + + if (!args.url || !args.start || !args.apiKey) { + console.error('Error: --url, --start and --api-key are required (or set WIKI_URL, WIKI_START_PAGE and WIKI_API_KEY).') + usage() + process.exit(2) + } + + let exitCode = 0 + try { + const result = await exportWikiToPdf({ + baseUrl: args.url, + apiKey: args.apiKey, + startPage: args.start, + locale: args.locale || null, + includeUnpublished: args.includeUnpublished, + includeToc: args.includeToc, + inlineImages: args.inlineImages, + maxPages: args.maxPages, + outputPath: args.output || undefined, + logger, + onProgress: (progress) => { + if (progress.message) process.stderr.write(`\r${progress.message}${' '.repeat(12)}`) + }, + onWarning: (warning) => { + process.stderr.write(`\nWarning: ${warning}\n`) + }, + }) + process.stderr.write('\n') + console.log(result.pdfPath) + } catch (error) { + process.stderr.write('\n') + console.error(`Export failed: ${error.message}`) + exitCode = 1 + } finally { + await closeBrowser() + } + + process.exit(exitCode) +} + +main() diff --git a/src/exporter.js b/src/exporter.js new file mode 100644 index 0000000..5f4e017 --- /dev/null +++ b/src/exporter.js @@ -0,0 +1,316 @@ +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import MarkdownIt from 'markdown-it' +import { logger as rootLogger } from './logger.js' +import { WikiClient } from './wiki-client.js' +import { selectSubtree } from './page-selection.js' +import { buildExportHtml } from './html-builder.js' +import { renderPdf } from './pdf-renderer.js' +import { + AppError, + escapeHtml, + mapLimit, + normalizeBaseUrl, + sanitizeFilename, + stripBasePath, + toPositiveInt, +} from './utils.js' + +const markdown = new MarkdownIt({ + html: true, + linkify: true, + breaks: false, +}) + +function renderSource(content, contentType) { + const type = String(contentType || '').toLowerCase() + if (type === 'markdown' || type === 'md') { + return markdown.render(content) + } + if (type === 'html' || type === 'ckeditor' || type === 'code') { + return content + } + return `
${escapeHtml(content)}
` +} + +function inferNamespacing(startPage, baseUrl, locale) { + const input = String(startPage || '').trim() + if (!/^https?:\/\//i.test(input)) return null + try { + const url = new URL(input) + const pathname = stripBasePath(url.pathname || '/', baseUrl.pathname) + const first = pathname.split('/').filter(Boolean)[0] + if (!first) return false + return first.toLowerCase() === String(locale).toLowerCase() + } catch { + return null + } +} + +function timestampForFilename(date = new Date()) { + return date.toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15) +} + +export async function exportWikiToPdf({ + baseUrl: rawBaseUrl, + apiKey, + startPage, + locale, + includeUnpublished = false, + includeToc = true, + inlineImages = true, + maxPages = 500, + concurrency = 4, + outputPath, + logger = rootLogger, + onProgress = () => {}, + onWarning = () => {}, +}) { + const log = logger.child({ component: 'exporter' }) + const startedAt = Date.now() + const baseUrl = normalizeBaseUrl(rawBaseUrl) + const limit = toPositiveInt(maxPages, 500, { min: 1, max: 5000 }) + const warnings = [] + const warningSet = new Set() + + function warn(message) { + if (warningSet.has(message)) return + warningSet.add(message) + warnings.push(message) + onWarning(message) + } + + let tempDir = null + let finalPath = outputPath || null + + log.info('export beginning', { + wikiUrl: baseUrl.href, + startPage, + locale: locale || null, + maxPages: limit, + includeUnpublished, + includeToc, + inlineImages, + pageFetchConcurrency: Math.max(1, Math.min(concurrency, 8)), + }) + + try { + onProgress({ stage: 'connect', message: 'Connecting to Wiki.js…' }) + const client = new WikiClient({ baseUrl, apiKey, logger: log }) + + const localization = await client.getLocalization() + log.debug('wiki localization loaded', { + defaultLocale: localization?.locale || null, + namespacing: localization?.namespacing ?? null, + namespaceCount: localization?.namespaces?.length || 0, + }) + + onProgress({ stage: 'list', message: 'Reading the page list…' }) + const listStartedAt = Date.now() + const listedPages = await client.listPages() + log.info('page list loaded', { + pageCount: listedPages.length, + localeCount: new Set(listedPages.map((page) => page.locale)).size, + durationMs: Date.now() - listStartedAt, + }) + + onProgress({ stage: 'select', message: 'Finding the starting page and its subpages…' }) + const selection = selectSubtree({ + pages: listedPages, + baseUrl, + startPage, + locale, + defaultLocale: localization?.locale, + includeUnpublished, + maxPages: limit, + }) + + if (selection.omittedUnpublished > 0) { + warn( + `Skipped ${selection.omittedUnpublished} unpublished page${ + selection.omittedUnpublished === 1 ? '' : 's' + } below the starting page. Enable "Include unpublished pages" to export them.`, + ) + } + + const total = selection.pages.length + log.info('subtree selected', { + startPath: selection.startPath, + locale: selection.locale, + pageCount: total, + omittedUnpublished: selection.omittedUnpublished, + }) + onProgress({ + stage: 'fetch', + message: `Found ${total} page${total === 1 ? '' : 's'}. Fetching content…`, + pagesFound: total, + pagesDone: 0, + }) + + let pagesDone = 0 + const detailedPages = await mapLimit(selection.pages, Math.max(1, Math.min(concurrency, 8)), async (page) => { + const isStartPage = page.path === selection.startPath + const pageStartedAt = Date.now() + let sourceFallback = false + + try { + const detail = await client.getPage(page.path, selection.locale) + if (!detail) { + throw new AppError(`Wiki.js returned no data for "${page.path}".`, { + status: 404, + code: 'PAGE_NOT_FOUND', + }) + } + + let render = detail.render || '' + if (!render) { + try { + const source = await client.getPageSource(detail.id) + if (source?.content) { + render = renderSource(source.content, source.contentType || detail.contentType) + sourceFallback = true + } + } catch (error) { + // read:source is optional. Keep the placeholder below. + log.debug('page source fallback unavailable', { + path: page.path, + code: error.code || null, + error: error.message, + }) + } + } + + if (!render) { + warn(`Page "${page.path}" has no rendered content. It is included as a link-only page.`) + } + + log.debug('page fetched', { + path: page.path, + durationMs: Date.now() - pageStartedAt, + renderBytes: render.length, + sourceFallback, + }) + + return { + ...page, + ...detail, + path: page.path, + locale: selection.locale, + render, + } + } catch (error) { + if (isStartPage) { + throw new AppError(`Could not read the starting page "${page.path}": ${error.message}`, { + status: error.status || 502, + code: error.code || 'START_PAGE_READ_FAILED', + }) + } + log.warn('page skipped', { + path: page.path, + durationMs: Date.now() - pageStartedAt, + code: error.code || null, + error: error.message, + }) + warn(`Could not export page "${page.path}": ${error.message}`) + return { + ...page, + locale: selection.locale, + render: '', + } + } finally { + pagesDone += 1 + onProgress({ + stage: 'fetch', + message: `Fetched ${pagesDone} of ${total} pages…`, + pagesFound: total, + pagesDone, + }) + } + }) + + onProgress({ stage: 'build', message: 'Building the printable document…' }) + const buildStartedAt = Date.now() + const namespacing = + typeof localization?.namespacing === 'boolean' + ? localization.namespacing + : inferNamespacing(startPage, baseUrl, selection.locale) + + const html = await buildExportHtml({ + pages: detailedPages, + baseUrl, + locale: selection.locale, + startPath: selection.startPath, + startPage: selection.startPage, + localization, + namespacing, + includeToc, + inlineImages, + apiKey, + onWarning: warn, + logger: log, + }) + log.info('printable document built', { + pageCount: total, + htmlBytes: Buffer.byteLength(html), + namespacing, + includeToc, + inlineImages, + durationMs: Date.now() - buildStartedAt, + }) + + if (!finalPath) { + tempDir = await mkdtemp(path.join(os.tmpdir(), 'wiki-print-')) + const filename = `${sanitizeFilename(selection.startPath)}-wiki-${timestampForFilename()}.pdf` + finalPath = path.join(tempDir, filename) + } else { + await mkdir(path.dirname(finalPath), { recursive: true }) + } + + onProgress({ stage: 'render', message: 'Rendering the PDF with Chromium…' }) + const renderStartedAt = Date.now() + await renderPdf({ + html, + outputPath: finalPath, + footerLabel: selection.startPage?.title || selection.startPath, + logger: log, + }) + log.info('PDF rendered', { + filename: path.basename(finalPath), + pageCount: total, + durationMs: Date.now() - renderStartedAt, + }) + + onProgress({ + stage: 'done', + message: `PDF ready with ${total} page${total === 1 ? '' : 's'}.`, + pagesFound: total, + pagesDone: total, + }) + log.debug('export complete', { + durationMs: Date.now() - startedAt, + pageCount: total, + warningCount: warnings.length, + }) + + return { + pdfPath: finalPath, + filename: path.basename(finalPath), + tempDir, + pageCount: total, + locale: selection.locale, + startPath: selection.startPath, + title: selection.startPage?.title || selection.startPath, + warnings, + omittedUnpublished: selection.omittedUnpublished, + } + } catch (error) { + if (finalPath) { + await rm(finalPath, { force: true }).catch(() => {}) + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }).catch(() => {}) + } + throw error + } +} diff --git a/src/html-builder.js b/src/html-builder.js new file mode 100644 index 0000000..372c869 --- /dev/null +++ b/src/html-builder.js @@ -0,0 +1,591 @@ +import * as cheerio from 'cheerio' +import { + escapeHtml, + mapLimit, + safeDecode, + stripBasePath, +} from './utils.js' +import { PRINT_STYLES } from './print-styles.js' + +const EMBED_SELECTOR = 'iframe, embed, object' +const MEDIA_SELECTOR = 'video, audio' +const DROPPED_SELECTOR = 'script, style, link, meta, base' +const INTERACTIVE_SELECTOR = 'input, button, select, textarea' +const REFERENCE_ATTRIBUTES = [ + 'for', + 'aria-labelledby', + 'aria-describedby', + 'aria-controls', + 'headers', + 'list', +] + +function encodePath(path) { + return String(path) + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/') +} + +export function buildPageUrl({ baseUrl, page, locale, namespacing = true }) { + if (namespacing) { + if (page.path === 'home') { + return `${baseUrl.href}/${encodeURIComponent(locale)}` + } + return `${baseUrl.href}/${encodeURIComponent(locale)}/${encodePath(page.path)}` + } + + if (page.path === 'home') { + return `${baseUrl.href}/` + } + return `${baseUrl.href}/${encodePath(page.path)}` +} + +function slugifyHeading(text) { + return String(text || '') + .normalize('NFKC') + .toLowerCase() + .trim() + .replace(/[^\p{L}\p{N}\s_-]/gu, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, '') +} + +function ensureHeadingIds($) { + const used = new Set() + $('h1, h2, h3, h4, h5, h6').each((_, element) => { + const $heading = $(element) + let id = $heading.attr('id') + if (!id) { + const base = slugifyHeading($heading.text()) || 'section' + id = base + let suffix = 2 + while (used.has(id)) { + id = `${base}-${suffix}` + suffix += 1 + } + $heading.attr('id', id) + } + used.add(id) + }) +} + +function sanitizeContent($, { currentPageUrl }) { + $(DROPPED_SELECTOR).remove() + $('noscript').each((_, element) => { + $(element).replaceWith($(element).contents()) + }) + + $(EMBED_SELECTOR).each((_, element) => { + const $element = $(element) + const rawSource = $element.attr('src') || $element.attr('data') + let label = 'Embedded content' + if (rawSource) { + let href = rawSource + try { + href = new URL(rawSource, currentPageUrl).href + } catch { + // Keep the original source as text if it cannot be resolved. + } + label = `Embedded content: ${escapeHtml(href)}` + } + $element.replaceWith(`

${label}

`) + }) + + $(MEDIA_SELECTOR).each((_, element) => { + const $element = $(element) + const rawSource = $element.attr('src') || $element.find('source').first().attr('src') + let label = 'Media' + if (rawSource) { + let href = rawSource + try { + href = new URL(rawSource, currentPageUrl).href + } catch { + // Keep original source. + } + label = `Media: ${escapeHtml(href)}` + } + $element.replaceWith(`

${label}

`) + }) + + $('form').each((_, element) => { + $(element).replaceWith($(element).contents()) + }) + $(INTERACTIVE_SELECTOR).remove() + + $('*').each((_, element) => { + if (!element.attribs) return + for (const attribute of Object.keys(element.attribs)) { + if (/^on/i.test(attribute) || attribute === 'contenteditable' || attribute === 'srcdoc') { + delete element.attribs[attribute] + } + } + }) + + $('a[href]').each((_, element) => { + const href = String($(element).attr('href') || '').trim() + if (/^(javascript|vbscript):/i.test(href) || /^data:text\/html/i.test(href) || /^file:/i.test(href)) { + $(element).removeAttr('href') + } + }) +} + +function prefixIds($, pageIndex) { + const prefix = `wp${pageIndex}-` + const idMap = new Map() + + $('[id], a[name]').each((_, element) => { + const $element = $(element) + const originalId = $element.attr('id') + if (originalId) { + const prefixed = `${prefix}${originalId}` + idMap.set(originalId, prefixed) + $element.attr('id', prefixed) + } + const originalName = $element.attr('name') + if (originalName) { + const prefixed = `${prefix}${originalName}` + $element.attr('name', prefixed) + if (!idMap.has(originalName)) idMap.set(originalName, prefixed) + } + }) + + $('*').each((_, element) => { + if (!element.attribs) return + for (const attribute of REFERENCE_ATTRIBUTES) { + const value = element.attribs[attribute] + if (!value) continue + const rewritten = value + .split(/\s+/) + .filter(Boolean) + .map((token) => idMap.get(token) || token) + .join(' ') + element.attribs[attribute] = rewritten + } + }) + + return idMap +} + +function preparePage(page, index, context) { + const sectionId = `wiki-page-${index}` + const onlineUrl = buildPageUrl({ + baseUrl: context.baseUrl, + page, + locale: context.locale, + namespacing: context.namespacing, + }) + + const $ = cheerio.load(page.render || page.html || '', null, false) + sanitizeContent($, { currentPageUrl: onlineUrl }) + ensureHeadingIds($) + const idMap = prefixIds($, index) + + return { + ...page, + index, + $, + idMap, + onlineUrl, + sectionId, + } +} + +function matchPageUrl(url, context) { + if (url.origin !== context.baseUrl.origin) return null + + let pathname = safeDecode(stripBasePath(url.pathname || '/', context.baseUrl.pathname)) + let segments = pathname.split('/').filter(Boolean) + const candidates = [] + + function addCandidate(parts) { + const candidate = parts.join('/') || 'home' + if (!candidates.includes(candidate)) candidates.push(candidate) + } + + addCandidate(segments) + + if (segments.length > 0) { + const first = segments[0].toLowerCase() + if (context.knownLocales.has(first) && first === context.locale) { + addCandidate(segments.slice(1)) + } + } + + for (const candidate of candidates) { + const page = context.pageByPath.get(candidate) + if (page) return page + } + + return null +} + +function rewriteLinks(preparedPages, context) { + for (const page of preparedPages) { + page.$('a[href]').each((_, element) => { + const $link = page.$ + const $element = $link(element) + const rawHref = String($element.attr('href') || '').trim() + if (!rawHref) return + + if (/^(mailto|tel|sms|ftp):/i.test(rawHref)) return + if (/^(javascript|vbscript|file):/i.test(rawHref) || /^data:/i.test(rawHref)) { + $element.removeAttr('href') + return + } + + let resolved + try { + resolved = new URL(rawHref, page.onlineUrl) + } catch { + return + } + + if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') { + return + } + + const target = matchPageUrl(resolved, context) + if (!target) { + // Keep links to pages outside this export (and all other web links) + // as real, clickable online URLs. + $element.attr('href', resolved.href) + $element.removeAttr('target') + $element.addClass('wiki-print-external') + return + } + + let anchor = target.sectionId + const fragment = safeDecode(resolved.hash.replace(/^#/, '')) + if (fragment) { + const mapped = target.idMap.get(fragment) || target.idMap.get(fragment.toLowerCase()) + if (mapped) anchor = mapped + } + + $element.attr('href', `#${anchor}`) + $element.attr('data-wiki-path', target.path) + $element.removeAttr('target') + $element.addClass('wiki-print-internal') + }) + } +} + +function guessMimeFromUrl(url) { + const pathname = url.pathname.toLowerCase() + if (pathname.endsWith('.png')) return 'image/png' + if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg' + if (pathname.endsWith('.gif')) return 'image/gif' + if (pathname.endsWith('.webp')) return 'image/webp' + if (pathname.endsWith('.svg')) return 'image/svg+xml' + if (pathname.endsWith('.avif')) return 'image/avif' + return null +} + +function createImageLoader({ baseUrl, apiKey, maxImageBytes, timeoutMs, onWarning }) { + const cache = new Map() + const warned = new Set() + const stats = { embedded: 0, failed: 0, bytes: 0, external: 0 } + let totalBytes = 0 + const totalLimit = 160 * 1024 * 1024 + + async function loadImage(rawSource, currentPageUrl) { + let url + try { + url = new URL(rawSource, currentPageUrl) + } catch { + return null + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null + if (totalBytes >= totalLimit) return null + + if (cache.has(url.href)) { + return cache.get(url.href) + } + + const promise = (async () => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const headers = { + Accept: 'image/*,application/octet-stream;q=0.8,*/*;q=0.5', + 'User-Agent': 'wiki-print/1.0', + } + // Only send the wiki API key back to the wiki itself. + if (url.origin === baseUrl.origin) { + headers.Authorization = `Bearer ${apiKey}` + } + + const response = await fetch(url.href, { + headers, + redirect: 'follow', + signal: controller.signal, + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + + const contentLength = Number(response.headers.get('content-length') || 0) + if (contentLength > maxImageBytes) { + throw new Error('image is too large') + } + + const contentType = (response.headers.get('content-type') || '') + .split(';')[0] + .trim() + .toLowerCase() + const mime = contentType.startsWith('image/') + ? contentType + : guessMimeFromUrl(url) + + if (!mime) { + throw new Error('response is not an image') + } + + const buffer = Buffer.from(await response.arrayBuffer()) + if (buffer.length > maxImageBytes) { + throw new Error('image is too large') + } + if (totalBytes + buffer.length > totalLimit) { + throw new Error('image size budget exhausted') + } + + totalBytes += buffer.length + stats.embedded += 1 + stats.bytes += buffer.length + return `data:${mime};base64,${buffer.toString('base64')}` + } catch (error) { + stats.failed += 1 + if (onWarning && url.origin === baseUrl.origin && !warned.has(url.href)) { + warned.add(url.href) + onWarning(`Could not embed image ${url.href}: ${error.message}`) + } + return null + } finally { + clearTimeout(timer) + } + })() + + cache.set(url.href, promise) + return promise + } + + return { loadImage, stats } +} + +async function processImages(preparedPages, context, shouldInline) { + const loader = shouldInline ? createImageLoader(context) : null + const loadImage = loader?.loadImage || null + const stats = loader?.stats || { embedded: 0, failed: 0, bytes: 0, external: 0 } + + for (const page of preparedPages) { + page.$('picture source').remove() + + const images = page.$('img').toArray() + await mapLimit(images, 6, async (element) => { + const $image = page.$(element) + const rawSource = + $image.attr('src') || + $image.attr('data-src') || + $image.attr('data-original') || + '' + + $image.removeAttr('srcset') + $image.removeAttr('sizes') + $image.removeAttr('loading') + $image.removeAttr('decoding') + $image.removeAttr('crossorigin') + $image.removeAttr('integrity') + + if (!rawSource) { + $image.remove() + return + } + + if (/^data:/i.test(rawSource)) { + stats.embedded += 1 + $image.attr('src', rawSource) + return + } + + let absolute = rawSource + try { + absolute = new URL(rawSource, page.onlineUrl).href + } catch { + $image.remove() + return + } + + if (!/^https?:/i.test(absolute)) { + $image.remove() + return + } + + const dataUri = loadImage ? await loadImage(absolute, page.onlineUrl) : null + if (!dataUri) stats.external += 1 + $image.attr('src', dataUri || absolute) + }) + } + + return stats +} + +function renderCover({ startPage, startPath, pages, baseUrl, locale }) { + const title = startPage?.title || startPath + const subtitle = + pages.length > 1 + ? `Starting page plus ${pages.length - 1} page${pages.length - 1 === 1 ? '' : 's'} below it` + : 'Single page export' + const generated = `${new Date().toISOString().replace('T', ' ').slice(0, 16)} UTC` + + return ` +
+
Wiki.js · PDF export
+

${escapeHtml(title)}

+

${escapeHtml(subtitle)}

+
+
+
Source
+
${escapeHtml(baseUrl.href)}
+
Starting page
+
${escapeHtml(startPath)}
+
Locale
+
${escapeHtml(locale)}
+
Pages
+
${pages.length}
+
Generated
+
${escapeHtml(generated)}
+
+
+ ` +} + +function renderToc(pages) { + const entries = pages + .map((page) => { + const depth = Math.max(0, Math.min(page.depth || 0, 8)) + return ` +
  • + ${escapeHtml(page.title || page.path)} + ${escapeHtml(page.path)} +
  • + ` + }) + .join('') + + return ` +
    +

    Contents

    +
      ${entries}
    +
    + ` +} + +function renderPageSection(page, total) { + const title = page.title || page.path + const updated = page.updatedAt + ? new Date(page.updatedAt).toLocaleDateString('en-CA', { + year: 'numeric', + month: 'short', + day: '2-digit', + timeZone: 'UTC', + }) + : null + + const metaParts = [`Path: ${escapeHtml(page.path)}`] + if (updated) metaParts.push(`Updated: ${escapeHtml(updated)}`) + metaParts.push(`View online`) + + const content = page.$.html().trim() + const body = content + ? content + : `
    This page has no rendered content. Open it in the wiki.
    ` + + return ` +
    + +
    ${body}
    +
    + ` +} + +/** + * Build one self-contained HTML document for Chromium's print-to-PDF mode. + * Internal wiki links become in-document anchors, while links to anything + * outside the exported subtree remain clickable online URLs. + */ +export async function buildExportHtml({ + pages, + baseUrl, + locale, + startPath, + startPage, + localization, + namespacing: namespacingOverride, + includeToc = true, + inlineImages: shouldInlineImages = true, + apiKey, + onWarning, + logger, +}) { + const namespacing = + typeof namespacingOverride === 'boolean' + ? namespacingOverride + : typeof localization?.namespacing === 'boolean' + ? localization.namespacing + : true + + const knownLocales = new Set( + (localization?.namespaces?.length ? localization.namespaces : pages.map((page) => page.locale)) + .filter(Boolean) + .map((value) => String(value).toLowerCase()), + ) + knownLocales.add(String(locale).toLowerCase()) + + const context = { + baseUrl, + locale: String(locale).toLowerCase(), + namespacing, + knownLocales, + pageByPath: new Map(), + apiKey, + maxImageBytes: 10 * 1024 * 1024, + timeoutMs: 20_000, + onWarning, + } + + const preparedPages = pages.map((page, index) => preparePage(page, index, context)) + for (const page of preparedPages) { + context.pageByPath.set(page.path, page) + } + + rewriteLinks(preparedPages, context) + const imageStats = await processImages(preparedPages, context, shouldInlineImages) + logger?.debug('document images processed', imageStats) + + const title = startPage?.title || startPath + const sections = preparedPages.map((page) => renderPageSection(page, preparedPages.length)).join('\n') + + return ` + + + + + ${escapeHtml(title)} - Wiki export + + + + ${renderCover({ startPage, startPath, pages: preparedPages, baseUrl, locale })} + ${includeToc ? renderToc(preparedPages) : ''} + ${sections} + +` +} diff --git a/src/jobs.js b/src/jobs.js new file mode 100644 index 0000000..edbe9eb --- /dev/null +++ b/src/jobs.js @@ -0,0 +1,225 @@ +import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' +import { logger as rootLogger } from './logger.js' +import { AppError } from './utils.js' +import { exportWikiToPdf } from './exporter.js' + +const TERMINAL_STATUSES = new Set(['done', 'error']) + +function publicJob(job) { + return { + id: job.id, + status: job.status, + stage: job.stage, + message: job.message, + pagesFound: job.pagesFound, + pagesDone: job.pagesDone, + warnings: job.warnings, + error: job.error, + createdAt: job.createdAt, + updatedAt: job.updatedAt, + finishedAt: job.finishedAt, + result: job.result, + request: job.request, + } +} + +export class JobQueue { + constructor({ + concurrency = 1, + maxQueue = 5, + ttlMs = 2 * 60 * 60 * 1000, + logger = rootLogger, + } = {}) { + this.concurrency = Math.max(1, concurrency) + this.maxQueue = Math.max(1, maxQueue) + this.ttlMs = ttlMs + this.logger = logger + this.jobs = new Map() + this.queue = [] + this.running = 0 + + this.cleanupTimer = setInterval(() => { + this.cleanup().catch(() => {}) + }, 10 * 60 * 1000) + this.cleanupTimer.unref?.() + } + + enqueue(input) { + if (this.queue.length >= this.maxQueue) { + throw new AppError('The export queue is full. Wait for a running export to finish and try again.', { + status: 429, + code: 'QUEUE_FULL', + }) + } + + const now = new Date().toISOString() + const jobId = randomUUID() + const job = { + id: jobId, + status: 'queued', + stage: 'queued', + message: 'Waiting for the next free export slot…', + pagesFound: 0, + pagesDone: 0, + warnings: [], + error: null, + createdAt: now, + updatedAt: now, + finishedAt: null, + result: null, + request: { + baseUrl: input.baseUrl, + startPage: input.startPage, + locale: input.locale || null, + includeUnpublished: Boolean(input.includeUnpublished), + includeToc: input.includeToc !== false, + maxPages: input.maxPages, + }, + filePath: null, + tempDir: null, + log: this.logger.child({ jobId }), + } + + this.jobs.set(job.id, job) + this.queue.push({ job, input }) + job.log.info('export queued', { + startPage: input.startPage, + locale: input.locale || null, + maxPages: input.maxPages, + includeUnpublished: Boolean(input.includeUnpublished), + includeToc: input.includeToc !== false, + inlineImages: input.inlineImages !== false, + queueDepth: this.queue.length, + running: this.running, + }) + this.pump() + return publicJob(job) + } + + get(id) { + const job = this.jobs.get(id) + return job ? publicJob(job) : null + } + + getInternal(id) { + return this.jobs.get(id) || null + } + + async pump() { + while (this.running < this.concurrency && this.queue.length > 0) { + const { job, input } = this.queue.shift() + this.running += 1 + this.run(job, input) + .catch((error) => { + job.status = 'error' + job.stage = 'error' + job.message = 'The export failed.' + job.error = { + message: error.message || 'Unknown error', + code: error.code || 'EXPORT_FAILED', + details: error.details, + } + job.updatedAt = new Date().toISOString() + job.finishedAt = job.updatedAt + job.log.error('export failed', { + durationMs: Date.parse(job.finishedAt) - Date.parse(job.createdAt), + pagesDone: job.pagesDone, + pagesFound: job.pagesFound, + error, + }) + }) + .finally(() => { + this.running -= 1 + this.pump() + }) + } + } + + async run(job, input) { + const startedAt = Date.now() + job.status = 'running' + job.stage = 'connect' + job.message = 'Starting export…' + job.updatedAt = new Date().toISOString() + job.log.info('export started') + + const result = await exportWikiToPdf({ + ...input, + logger: job.log, + onProgress: (progress) => { + const previousStage = job.stage + job.stage = progress.stage || job.stage + job.message = progress.message || job.message + if (Number.isFinite(progress.pagesFound)) job.pagesFound = progress.pagesFound + if (Number.isFinite(progress.pagesDone)) job.pagesDone = progress.pagesDone + job.updatedAt = new Date().toISOString() + if (progress.stage && progress.stage !== previousStage) { + job.log.debug('export stage', { + stage: progress.stage, + message: progress.message, + pagesFound: job.pagesFound, + pagesDone: job.pagesDone, + }) + } + }, + onWarning: (warning) => { + if (!job.warnings.includes(warning)) { + job.warnings.push(warning) + job.log.warn('export warning', { warning }) + } + job.updatedAt = new Date().toISOString() + }, + }) + + job.filePath = result.pdfPath + job.tempDir = result.tempDir + job.result = { + filename: result.filename, + pageCount: result.pageCount, + startPath: result.startPath, + locale: result.locale, + title: result.title, + downloadUrl: `/api/exports/${job.id}/pdf`, + } + job.status = 'done' + job.stage = 'done' + job.message = result.warnings?.length + ? `PDF ready with ${result.pageCount} page${result.pageCount === 1 ? '' : 's'} and ${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'}.` + : `PDF ready with ${result.pageCount} page${result.pageCount === 1 ? '' : 's'}.` + job.updatedAt = new Date().toISOString() + job.finishedAt = job.updatedAt + job.log.info('export finished', { + durationMs: Date.now() - startedAt, + pageCount: result.pageCount, + locale: result.locale, + startPath: result.startPath, + warningCount: result.warnings?.length || 0, + filename: result.filename, + }) + } + + async cleanup(now = Date.now()) { + for (const [id, job] of this.jobs) { + if (!TERMINAL_STATUSES.has(job.status)) continue + const finished = Date.parse(job.finishedAt || job.updatedAt || job.createdAt) + if (!Number.isFinite(finished) || now - finished < this.ttlMs) continue + + if (job.tempDir) { + await rm(job.tempDir, { recursive: true, force: true }).catch(() => {}) + } + this.jobs.delete(id) + } + } + + async close() { + clearInterval(this.cleanupTimer) + for (const [, job] of this.jobs) { + if (job.tempDir) { + await rm(job.tempDir, { recursive: true, force: true }).catch(() => {}) + } + } + } +} + +export { publicJob } diff --git a/src/logger.js b/src/logger.js new file mode 100644 index 0000000..f2aa889 --- /dev/null +++ b/src/logger.js @@ -0,0 +1,143 @@ +const LEVELS = Object.freeze({ + debug: 10, + info: 20, + warn: 30, + error: 40, + silent: 100, +}) + +const REDACTED = '[redacted]' +const SECRET_KEY_RE = /(^|_)(api[-_]?key|authorization|password|secret|token|credential)($|_)/i +const MAX_STRING_LENGTH = 2000 + +function normalizeLevel(value, fallback = 'info') { + const level = String(value ?? '').trim().toLowerCase() + return Object.hasOwn(LEVELS, level) ? level : fallback +} + +function normalizeFormat(value) { + return String(value ?? '').trim().toLowerCase() === 'json' ? 'json' : 'text' +} + +function sanitizeValue(value) { + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + code: value.code, + status: value.status, + stack: value.stack, + } + } + if (typeof value === 'string') { + return value.length > MAX_STRING_LENGTH + ? `${value.slice(0, MAX_STRING_LENGTH)}… (${value.length - MAX_STRING_LENGTH} more characters)` + : value + } + if (typeof value === 'bigint') return value.toString() + if (Array.isArray(value)) return value.map(sanitizeValue) + if (value && typeof value === 'object') { + const result = {} + for (const [key, entry] of Object.entries(value)) { + result[key] = SECRET_KEY_RE.test(key) ? REDACTED : sanitizeValue(entry) + } + return result + } + return value +} + +function formatScalar(value) { + if (value === null) return 'null' + if (typeof value === 'string') { + return /^[A-Za-z0-9_./:@[\]-]+$/.test(value) ? value : JSON.stringify(value) + } + if (typeof value === 'object') return JSON.stringify(value) + return String(value) +} + +function formatText(timestamp, level, message, fields) { + const parts = [timestamp, level.toUpperCase().padEnd(5), message] + for (const [key, value] of Object.entries(fields)) { + if (value === undefined) continue + parts.push(`${key}=${formatScalar(value)}`) + } + return `${parts.join(' ')}\n` +} + +function formatJson(timestamp, level, message, fields) { + return `${JSON.stringify({ timestamp, level, message, ...fields })}\n` +} + +/** + * Create a small dependency-free logger. + * + * LOG_LEVEL: debug | info | warn | error | silent (default: info) + * LOG_FORMAT: text | json (default: text) + * + * All output goes to stderr so the CLI can keep stdout for the PDF path. + */ +export function createLogger({ + level = process.env.LOG_LEVEL, + format = process.env.LOG_FORMAT, + stream = process.stderr, + bindings = {}, +} = {}) { + const configuredLevel = normalizeLevel(level) + const configuredFormat = normalizeFormat(format) + const threshold = LEVELS[configuredLevel] + const parentBindings = { ...bindings } + + function write(logLevel, message, fields = {}) { + if (LEVELS[logLevel] < threshold) return + + const timestamp = new Date().toISOString() + const merged = { ...parentBindings, ...fields } + const sanitized = {} + for (const [key, value] of Object.entries(merged)) { + if (value === undefined) continue + sanitized[key] = SECRET_KEY_RE.test(key) ? REDACTED : sanitizeValue(value) + } + + const line = + configuredFormat === 'json' + ? formatJson(timestamp, logLevel, String(message), sanitized) + : formatText(timestamp, logLevel, String(message), sanitized) + + try { + stream.write(line) + } catch { + // Logging must never break an export. + } + } + + return { + level: configuredLevel, + format: configuredFormat, + bindings: parentBindings, + isLevelEnabled(logLevel) { + return LEVELS[logLevel] >= threshold + }, + child(extraBindings = {}) { + return createLogger({ + level: configuredLevel, + format: configuredFormat, + stream, + bindings: { ...parentBindings, ...extraBindings }, + }) + }, + debug(message, fields) { + write('debug', message, fields) + }, + info(message, fields) { + write('info', message, fields) + }, + warn(message, fields) { + write('warn', message, fields) + }, + error(message, fields) { + write('error', message, fields) + }, + } +} + +export const logger = createLogger({ bindings: { app: 'wiki-print' } }) diff --git a/src/page-selection.js b/src/page-selection.js new file mode 100644 index 0000000..1bf218a --- /dev/null +++ b/src/page-selection.js @@ -0,0 +1,210 @@ +import { + AppError, + comparePagePaths, + looksLikeLocaleSegment, + normalizePagePath, + safeDecode, + stripBasePath, +} from './utils.js' + +const ABSOLUTE_URL_RE = /^[a-z][a-z0-9+.-]*:\/\//i + +function parseStartPage(startPage, baseUrl) { + const input = String(startPage ?? '').trim() + if (!input) { + throw new AppError('The starting page is required.') + } + + let pathname + let fromUrl = false + + if (ABSOLUTE_URL_RE.test(input)) { + let url + try { + url = new URL(input) + } catch { + throw new AppError('The starting page URL is not valid.') + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new AppError('The starting page URL must use http or https.') + } + fromUrl = true + pathname = safeDecode(stripBasePath(url.pathname || '/', baseUrl.pathname)) + } else { + pathname = input.split(/[?#]/, 1)[0] + if (!pathname.startsWith('/')) { + pathname = `/${pathname}` + } + pathname = safeDecode(stripBasePath(pathname, baseUrl.pathname)) + } + + const segments = pathname.split('/').filter(Boolean) + let localeHint = null + if (segments.length > 0 && looksLikeLocaleSegment(segments[0])) { + localeHint = segments[0].toLowerCase() + } + + return { + path: normalizePagePath(pathname), + pathname, + segments, + localeHint, + fromUrl, + } +} + +/** + * Resolve the requested starting page to one locale/path, then collect the + * starting page plus every page whose path is below it. + * + * Wiki.js treats the locale as part of a page's identity, so a subtree never + * crosses locales. + */ +export function selectSubtree({ + pages, + baseUrl, + startPage, + locale, + defaultLocale, + includeUnpublished = false, + maxPages = 500, +}) { + if (!Array.isArray(pages) || pages.length === 0) { + throw new AppError('The API key can read no pages from this wiki.', { + status: 403, + code: 'WIKI_NO_PAGES', + }) + } + + const locales = [...new Set(pages.map((page) => page.locale).filter(Boolean))].sort() + const explicitLocale = locale ? String(locale).trim().toLowerCase() : null + + if (explicitLocale && !locales.includes(explicitLocale)) { + throw new AppError( + `Locale "${explicitLocale}" does not exist in this wiki. Available locales: ${locales.join(', ') || 'none'}.`, + { code: 'LOCALE_NOT_FOUND' }, + ) + } + + const pageByKey = new Map() + for (const page of pages) { + if (!page?.path || !page?.locale) continue + pageByKey.set(`${page.locale}\u0000${page.path}`, page) + } + + const parsed = parseStartPage(startPage, baseUrl) + const candidates = [] + const seen = new Set() + + function addCandidate(path, hintedLocale = null) { + const normalized = normalizePagePath(path) + const key = `${normalized}\u0000${hintedLocale || ''}` + if (seen.has(key)) return + seen.add(key) + candidates.push({ path: normalized, hintedLocale }) + } + + addCandidate(parsed.path, parsed.localeHint) + + const firstSegment = parsed.segments[0] + if (firstSegment && looksLikeLocaleSegment(firstSegment)) { + const remainder = parsed.segments.slice(1).join('/') + addCandidate(remainder || 'home', firstSegment.toLowerCase()) + } + + const localeHint = explicitLocale || parsed.localeHint || null + const preferredLocale = + localeHint || + (defaultLocale && locales.includes(String(defaultLocale).toLowerCase()) + ? String(defaultLocale).toLowerCase() + : null) + + let startMatch = null + + for (const candidate of candidates) { + const matches = [] + for (const candidateLocale of locales) { + const page = pageByKey.get(`${candidateLocale}\u0000${candidate.path}`) + if (page) matches.push({ page, locale: candidateLocale }) + } + + if (matches.length === 0) continue + + if (matches.length === 1) { + startMatch = matches[0] + break + } + + const hinted = candidate.hintedLocale || preferredLocale + const preferredMatch = hinted ? matches.find((match) => match.locale === hinted) : null + if (preferredMatch) { + startMatch = preferredMatch + break + } + + throw new AppError( + `The page "${candidate.path}" exists in multiple locales (${matches + .map((match) => match.locale) + .join(', ')}). Choose a locale in the advanced options.`, + { code: 'AMBIGUOUS_LOCALE' }, + ) + } + + if (!startMatch) { + const localeText = explicitLocale ? ` in locale "${explicitLocale}"` : '' + const shownPath = parsed.path + throw new AppError( + `Starting page "${shownPath}" was not found${localeText}. Use the page path as it appears in Wiki.js, without a leading slash. A browser URL like /en/docs/start becomes docs/start (or keep the full URL).`, + { status: 404, code: 'START_PAGE_NOT_FOUND' }, + ) + } + + const selectedLocale = startMatch.locale + const startPath = startMatch.page.path + const prefix = `${startPath}/` + + const allDescendants = pages + .filter((page) => page.locale === selectedLocale) + .filter((page) => page.path === startPath || page.path.startsWith(prefix)) + + const publishedDescendants = includeUnpublished + ? allDescendants + : allDescendants.filter((page) => page.path === startPath || page.isPublished !== false) + + const deduped = new Map() + for (const page of publishedDescendants) { + deduped.set(page.path, page) + } + + const selected = [...deduped.values()].sort((a, b) => { + if (a.path === startPath) return -1 + if (b.path === startPath) return 1 + return comparePagePaths(a.path, b.path) + }) + + if (selected.length > maxPages) { + throw new AppError( + `This export would contain ${selected.length} pages, which is above the configured limit of ${maxPages}. Raise the limit or choose a narrower starting page.`, + { status: 413, code: 'TOO_MANY_PAGES', details: { selected: selected.length, maxPages } }, + ) + } + + const startDepth = startPath === 'home' ? 0 : startPath.split('/').length + const enriched = selected.map((page) => { + const depth = page.path === startPath ? 0 : Math.max(1, page.path.split('/').length - startDepth) + return { + ...page, + depth, + relativePath: page.path === startPath ? '' : page.path.slice(prefix.length), + } + }) + + return { + locale: selectedLocale, + startPath, + startPage: startMatch.page, + pages: enriched, + locales, + omittedUnpublished: allDescendants.length - publishedDescendants.length, + } +} diff --git a/src/pdf-renderer.js b/src/pdf-renderer.js new file mode 100644 index 0000000..3046b05 --- /dev/null +++ b/src/pdf-renderer.js @@ -0,0 +1,157 @@ +import { existsSync } from 'node:fs' +import { chromium } from 'playwright-core' +import { logger as rootLogger } from './logger.js' +import { AppError, escapeHtml } from './utils.js' + +const CHROMIUM_CANDIDATES = [ + process.env.CHROMIUM_PATH, + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/opt/google/chrome/chrome', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', +].filter(Boolean) + +function resolveChromiumPath() { + for (const candidate of CHROMIUM_CANDIDATES) { + if (existsSync(candidate)) return candidate + } + throw new AppError( + 'No Chromium executable was found. Set CHROMIUM_PATH or install Chromium in the container.', + { status: 500, code: 'CHROMIUM_NOT_FOUND' }, + ) +} + +let browserPromise = null +let closingBrowser = false + +async function getBrowser(logger) { + if (!browserPromise) { + const executablePath = resolveChromiumPath() + const startedAt = Date.now() + logger.info('chromium launching', { executablePath }) + browserPromise = chromium + .launch({ + executablePath, + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-gpu', + '--font-render-hinting=none', + ], + }) + .then((browser) => { + logger.info('chromium ready', { durationMs: Date.now() - startedAt }) + browser.on('disconnected', () => { + if (!closingBrowser) { + logger.warn('chromium disconnected unexpectedly') + } + browserPromise = null + }) + return browser + }) + .catch((error) => { + logger.error('chromium launch failed', { executablePath, error }) + browserPromise = null + throw error + }) + } + return browserPromise +} + +const FOOTER_TEMPLATE = ` +
    + __FOOTER_LABEL__ + / +
    +` + +export async function renderPdf({ + html, + outputPath, + footerLabel = 'Wiki.js export', + logger = rootLogger, +}) { + const log = logger.child({ component: 'chromium' }) + const startedAt = Date.now() + log.debug('pdf render started', { htmlBytes: Buffer.byteLength(html) }) + + const browser = await getBrowser(log) + const context = await browser.newContext({ + javaScriptEnabled: true, + viewport: { width: 1280, height: 1600 }, + }) + + try { + const page = await context.newPage() + page.setDefaultTimeout(120_000) + + await page.setContent(html, { waitUntil: 'load', timeout: 120_000 }) + await page.emulateMedia({ media: 'print' }) + + // Images are mostly data URIs already. Wait for anything external that + // could not be embedded, but never let a slow host stall the export. + await page.evaluate(async () => { + if (document.fonts?.ready) { + await document.fonts.ready.catch(() => {}) + } + + const pending = [...document.images].filter((image) => !image.complete) + if (pending.length === 0) return + + await Promise.race([ + Promise.all( + pending.map( + (image) => + new Promise((resolve) => { + image.addEventListener('load', resolve, { once: true }) + image.addEventListener('error', resolve, { once: true }) + }), + ), + ), + new Promise((resolve) => setTimeout(resolve, 12_000)), + ]) + }) + + const footer = FOOTER_TEMPLATE.replace('__FOOTER_LABEL__', escapeHtml(footerLabel)) + + await page.pdf({ + path: outputPath, + format: 'A4', + printBackground: true, + displayHeaderFooter: true, + headerTemplate: '
    ', + footerTemplate: footer, + margin: { + top: '16mm', + right: '15mm', + bottom: '18mm', + left: '15mm', + }, + outline: true, + preferCSSPageSize: false, + }) + log.debug('pdf written', { + outputPath, + durationMs: Date.now() - startedAt, + }) + } finally { + await context.close().catch(() => {}) + } +} + +export async function closeBrowser() { + if (!browserPromise) return + const browser = await browserPromise.catch(() => null) + browserPromise = null + if (browser) { + closingBrowser = true + try { + await browser.close().catch(() => {}) + } finally { + closingBrowser = false + } + } +} diff --git a/src/print-styles.js b/src/print-styles.js new file mode 100644 index 0000000..5a14259 --- /dev/null +++ b/src/print-styles.js @@ -0,0 +1,278 @@ +export const PRINT_STYLES = ` + :root { + --ink: #17202a; + --muted: #5b6775; + --line: #d7dee7; + --soft: #f4f7fa; + --soft-2: #eef3f8; + --accent: #0b63b6; + } + + * { box-sizing: border-box; } + + html { + font-size: 10.5pt; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + + body { + margin: 0; + color: var(--ink); + font-family: "Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + line-height: 1.55; + } + + a { + color: var(--accent); + text-decoration: underline; + text-underline-offset: 1.5px; + } + + p { margin: 0 0 0.75em; orphans: 3; widows: 3; } + ul, ol { margin: 0 0 0.9em; padding-left: 1.4em; } + li { margin: 0.18em 0; } + li > p { margin: 0.2em 0; } + + h1, h2, h3, h4, h5, h6 { + color: #0f1720; + line-height: 1.22; + margin: 1.3em 0 0.45em; + break-after: avoid-page; + } + h1 { font-size: 1.9em; } + h2 { font-size: 1.5em; border-bottom: 1px solid var(--line); padding-bottom: 0.18em; } + h3 { font-size: 1.22em; } + h4 { font-size: 1.06em; } + h5, h6 { font-size: 1em; } + + strong { font-weight: 700; } + small { font-size: 0.88em; } + mark { background: #fff1a8; } + + blockquote { + margin: 1em 0; + padding: 0.7em 1em; + border-left: 4px solid #9fb6cf; + background: var(--soft); + color: #334155; + } + + code, kbd, samp, pre { + font-family: "DejaVu Sans Mono", "SFMono-Regular", Consolas, monospace; + } + + code { + font-size: 0.88em; + background: #f0f3f7; + border: 1px solid #e2e8f0; + border-radius: 3px; + padding: 0.05em 0.3em; + } + + pre { + margin: 0.9em 0; + padding: 0.85em 1em; + background: #f6f8fa; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 0.82em; + line-height: 1.42; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + pre code { + padding: 0; + border: 0; + background: transparent; + font-size: 1em; + } + + table { + width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 0.9em; + } + thead { display: table-header-group; } + tr { break-inside: avoid; } + th, td { + border: 1px solid var(--line); + padding: 0.42em 0.55em; + text-align: left; + vertical-align: top; + } + th { background: var(--soft-2); font-weight: 700; } + + img, svg, video, canvas { + max-width: 100%; + height: auto; + } + img { + break-inside: avoid; + border-radius: 2px; + } + figure { margin: 1em 0; break-inside: avoid; } + figcaption { color: var(--muted); font-size: 0.88em; margin-top: 0.35em; } + + hr { + border: 0; + border-top: 1px solid var(--line); + margin: 1.5em 0; + } + + details { margin: 0.8em 0; } + summary { cursor: default; font-weight: 600; } + + .admonition, .ui.message, .notification, .alert { + margin: 1em 0; + padding: 0.75em 1em; + border-left: 4px solid #4c8dd6; + background: var(--soft); + break-inside: avoid; + } + .admonition.warning, .ui.warning.message, .notification.is-warning, .alert-warning { + border-left-color: #d38b00; + background: #fff9e8; + } + .admonition.danger, .ui.error.message, .notification.is-danger, .alert-danger { + border-left-color: #c0392b; + background: #fff1ef; + } + .admonition.success, .ui.success.message, .notification.is-success, .alert-success { + border-left-color: #2f8f55; + background: #eefaf2; + } + + .task-list-item { list-style: none; } + .task-list-item input { margin: 0 0.45em 0 -1.2em; } + + .cover { + min-height: 247mm; + display: flex; + flex-direction: column; + justify-content: center; + break-after: page; + padding: 8mm 4mm; + } + .cover .eyebrow { + color: var(--accent); + font-size: 9pt; + font-weight: 700; + letter-spacing: 0.14em; + text-transform: uppercase; + margin-bottom: 14mm; + } + .cover h1 { + font-size: 34pt; + line-height: 1.08; + margin: 0 0 5mm; + max-width: 150mm; + } + .cover .subtitle { + color: var(--muted); + font-size: 13pt; + margin: 0 0 18mm; + max-width: 150mm; + } + .cover .rule { + width: 32mm; + height: 4px; + background: var(--accent); + margin-bottom: 14mm; + } + .cover dl { + display: grid; + grid-template-columns: 28mm 1fr; + gap: 2.2mm 5mm; + margin: 0; + font-size: 9.5pt; + } + .cover dt { + color: var(--muted); + font-weight: 700; + } + .cover dd { + margin: 0; + overflow-wrap: anywhere; + } + + .toc { + break-after: page; + } + .toc h1 { + font-size: 24pt; + margin: 0 0 8mm; + } + .toc-list { + list-style: none; + margin: 0; + padding: 0; + } + .toc-entry { + padding: 0.3em 0 0.3em calc(var(--depth, 0) * 1.15em); + border-bottom: 1px solid #edf1f5; + break-inside: avoid; + } + .toc-entry a { + font-weight: 600; + text-decoration: none; + } + .toc-entry .toc-path { + display: block; + color: var(--muted); + font-size: 8.3pt; + margin-top: 0.1em; + overflow-wrap: anywhere; + } + + .wiki-page { + break-before: page; + } + .page-header { + border-bottom: 2px solid var(--ink); + padding-bottom: 4mm; + margin-bottom: 7mm; + } + .page-kicker { + color: var(--accent); + font-size: 8pt; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + margin-bottom: 2mm; + } + .page-title { + font-size: 23pt; + margin: 0 0 2mm; + line-height: 1.12; + } + .page-meta { + color: var(--muted); + font-size: 8.8pt; + } + .page-meta .dot { padding: 0 0.45em; } + + .wiki-content > :first-child { margin-top: 0; } + .wiki-content > :last-child { margin-bottom: 0; } + + .empty-page { + padding: 8mm; + background: var(--soft); + border: 1px dashed #b8c4d2; + color: var(--muted); + } + + .pdf-embed { + color: var(--muted); + font-size: 0.92em; + padding: 0.6em 0.8em; + background: var(--soft); + border: 1px solid var(--line); + break-inside: avoid; + } + + .footnotes, .footnote-ref { + font-size: 0.9em; + } +` diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..d7c5ea8 --- /dev/null +++ b/src/server.js @@ -0,0 +1,390 @@ +import { randomUUID, timingSafeEqual } from 'node:crypto' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import express from 'express' +import { JobQueue } from './jobs.js' +import { logger } from './logger.js' +import { closeBrowser } from './pdf-renderer.js' +import { WikiClient } from './wiki-client.js' +import { + AppError, + comparePagePaths, + isPlainObject, + normalizeApiKey, + normalizeBaseUrl, + toPositiveInt, +} from './utils.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const publicDir = path.join(__dirname, '..', 'public') + +const PORT = toPositiveInt(process.env.PORT, 8080, { min: 1, max: 65535 }) +const HOST = process.env.HOST || '0.0.0.0' +const MAX_PAGES = toPositiveInt(process.env.MAX_PAGES, 500, { min: 1, max: 5000 }) +const MAX_QUEUE = toPositiveInt(process.env.MAX_QUEUE, 5, { min: 1, max: 100 }) +const JOB_TTL_MS = toPositiveInt(process.env.JOB_TTL_MINUTES, 120, { min: 1, max: 1440 }) * 60 * 1000 +const EXPORT_CONCURRENCY = toPositiveInt(process.env.EXPORT_CONCURRENCY, 1, { min: 1, max: 3 }) +const PAGE_FETCH_CONCURRENCY = toPositiveInt(process.env.PAGE_FETCH_CONCURRENCY, 4, { min: 1, max: 12 }) +const AUTH_USER = process.env.AUTH_USER || '' +const AUTH_PASSWORD = process.env.AUTH_PASSWORD || '' +// The wiki connection is configured once on the server, never sent by the +// browser and never included in job status responses or logs. +const WIKI_API_KEY = normalizeApiKey(process.env.WIKI_API_KEY) +const WIKI_URL_INPUT = String(process.env.WIKI_URL || '').trim() +let WIKI_URL = null +let WIKI_URL_ERROR = null +if (WIKI_URL_INPUT) { + try { + WIKI_URL = normalizeBaseUrl(WIKI_URL_INPUT) + } catch (error) { + WIKI_URL_ERROR = error.message + } +} + +function requireWikiConfig() { + if (!WIKI_URL) { + throw new AppError( + WIKI_URL_ERROR + ? `The wiki URL from WIKI_URL is invalid: ${WIKI_URL_ERROR}` + : 'The Wiki.js URL is not configured on the server. Set WIKI_URL and restart the container.', + { status: 503, code: 'WIKI_URL_MISSING' }, + ) + } + if (!WIKI_API_KEY) { + throw new AppError( + 'The Wiki.js API key is not configured on the server. Set WIKI_API_KEY and restart the container.', + { status: 503, code: 'WIKI_API_KEY_MISSING' }, + ) + } +} + +const app = express() +app.disable('x-powered-by') + +const jobs = new JobQueue({ + concurrency: EXPORT_CONCURRENCY, + maxQueue: MAX_QUEUE, + ttlMs: JOB_TTL_MS, +}) + +app.use((req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff') + res.setHeader('Referrer-Policy', 'no-referrer') + res.setHeader('X-Frame-Options', 'DENY') + res.setHeader( + 'Content-Security-Policy', + "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'", + ) + next() +}) + +app.use((req, res, next) => { + const requestId = randomUUID() + const startedAt = Date.now() + req.requestId = requestId + req.requestStartedAt = startedAt + res.setHeader('X-Request-Id', requestId) + + res.on('finish', () => { + if (res.locals.errorLogged) return + const fields = { + requestId, + method: req.method, + path: req.path, + status: res.statusCode, + durationMs: Date.now() - startedAt, + } + // The UI polls the job status every second. Keep those lines out of the + // info log so a single export does not flood the output. + const isJobStatusPoll = + req.method === 'GET' && /^\/api\/exports\/[0-9a-f-]+$/i.test(req.path) + + if (res.statusCode >= 500) { + logger.error('request failed', fields) + } else if (res.statusCode >= 400) { + logger.warn('request rejected', fields) + } else if (!isJobStatusPoll && (req.path === '/' || req.path.startsWith('/api/'))) { + logger.info('request', fields) + } else { + logger.debug('request', fields) + } + }) + + next() +}) + +function safeEqual(left, right) { + const leftBuffer = Buffer.from(String(left)) + const rightBuffer = Buffer.from(String(right)) + if (leftBuffer.length !== rightBuffer.length) return false + return timingSafeEqual(leftBuffer, rightBuffer) +} + +function basicAuth(req, res, next) { + if (!AUTH_USER || !AUTH_PASSWORD) return next() + + const header = req.headers.authorization || '' + if (header.startsWith('Basic ')) { + const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8') + const separator = decoded.indexOf(':') + const user = separator >= 0 ? decoded.slice(0, separator) : decoded + const password = separator >= 0 ? decoded.slice(separator + 1) : '' + if (safeEqual(user, AUTH_USER) && safeEqual(password, AUTH_PASSWORD)) { + return next() + } + } + + res.setHeader('WWW-Authenticate', 'Basic realm="wiki-print", charset="UTF-8"') + return res.status(401).send('Authentication required.') +} + +app.get('/healthz', (req, res) => { + res.json({ status: 'ok' }) +}) + +app.use(basicAuth) +app.use(express.json({ limit: '256kb' })) + +app.get('/api/config', (req, res) => { + res.setHeader('Cache-Control', 'no-store') + res.json({ + wikiUrl: WIKI_URL?.href || null, + wikiUrlConfigured: Boolean(WIKI_URL), + maxPages: MAX_PAGES, + maxQueue: MAX_QUEUE, + authEnabled: Boolean(AUTH_USER && AUTH_PASSWORD), + apiKeyConfigured: Boolean(WIKI_API_KEY), + }) +}) + +app.get('/api/pages', async (req, res, next) => { + const startedAt = Date.now() + try { + requireWikiConfig() + + const client = new WikiClient({ + baseUrl: WIKI_URL, + apiKey: WIKI_API_KEY, + logger: logger.child({ requestId: req.requestId }), + }) + const [listedPages, localization] = await Promise.all([ + client.listPages(), + client.getLocalization(), + ]) + + const pages = listedPages + .map((page) => ({ + path: String(page.path || ''), + locale: String(page.locale || ''), + title: page.title || '', + description: page.description || '', + isPublished: page.isPublished !== false, + })) + .filter((page) => page.path && page.locale) + .sort( + (a, b) => + a.locale.localeCompare(b.locale, 'en') || comparePagePaths(a.path, b.path), + ) + + logger.info('page list loaded', { + requestId: req.requestId, + pageCount: pages.length, + publishedCount: pages.filter((page) => page.isPublished).length, + locales: [...new Set(pages.map((page) => page.locale))], + defaultLocale: localization?.locale || null, + durationMs: Date.now() - startedAt, + }) + + res.setHeader('Cache-Control', 'no-store') + res.json({ + baseUrl: WIKI_URL.href, + defaultLocale: localization?.locale || null, + namespacing: localization?.namespacing ?? null, + pages, + }) + } catch (error) { + next(error) + } +}) + +app.post('/api/exports', (req, res, next) => { + try { + const body = req.body + if (!isPlainObject(body)) { + throw new AppError('Expected a JSON request body.') + } + + requireWikiConfig() + + const startPage = String(body.startPage ?? '').trim() + + if (!startPage) throw new AppError('The starting page is required.') + if (startPage.length > 2048) throw new AppError('The starting page is too long.') + + const locale = body.locale ? String(body.locale).trim().toLowerCase() : null + if (locale && !/^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/.test(locale)) { + throw new AppError('The locale is not a valid language tag (for example "en" or "de-ch").') + } + + const maxPages = toPositiveInt(body.maxPages, MAX_PAGES, { min: 1, max: 5000 }) + + const job = jobs.enqueue({ + baseUrl: WIKI_URL.href, + apiKey: WIKI_API_KEY, + startPage, + locale, + includeUnpublished: body.includeUnpublished === true, + includeToc: body.includeToc !== false, + inlineImages: body.inlineImages !== false, + maxPages, + concurrency: PAGE_FETCH_CONCURRENCY, + }) + + logger.info('export requested', { + requestId: req.requestId, + jobId: job.id, + startPage, + locale, + maxPages, + includeUnpublished: body.includeUnpublished === true, + includeToc: body.includeToc !== false, + inlineImages: body.inlineImages !== false, + }) + + res.setHeader('Cache-Control', 'no-store') + res.status(202).json({ job }) + } catch (error) { + next(error) + } +}) + +app.get('/api/exports/:id', (req, res) => { + const job = jobs.get(req.params.id) + if (!job) { + return res.status(404).json({ error: 'Export not found.' }) + } + res.setHeader('Cache-Control', 'no-store') + return res.json({ job }) +}) + +app.get('/api/exports/:id/pdf', (req, res) => { + const job = jobs.getInternal(req.params.id) + if (!job) { + return res.status(404).json({ error: 'Export not found.' }) + } + if (job.status !== 'done' || !job.filePath) { + return res.status(409).json({ error: 'The PDF is not ready yet.' }) + } + + res.setHeader('Cache-Control', 'no-store') + res.setHeader('Content-Type', 'application/pdf') + return res.download(job.filePath, job.result?.filename || 'wiki-export.pdf', (error) => { + if (error && !res.headersSent) { + res.status(404).json({ error: 'The PDF file is no longer available.' }) + } + }) +}) + +// The UI files have no content hashes in their names, so caching them for an +// hour means a browser can keep running an old app.js after a rebuild (for +// example one that did not load the page list). Always revalidate the HTML, +// JavaScript and CSS; static images may still be cached. +app.use( + express.static(publicDir, { + index: 'index.html', + etag: true, + lastModified: true, + setHeaders(res, filePath) { + if (/\.(?:html|js|css)$/i.test(filePath)) { + res.setHeader('Cache-Control', 'no-cache') + } else { + res.setHeader('Cache-Control', 'public, max-age=3600') + } + }, + }), +) + +app.use('/api', (req, res) => { + res.status(404).json({ error: 'Not found.' }) +}) + +app.use((req, res) => { + res.status(404).send('Not found.') +}) + +app.use((error, req, res, next) => { + if (res.headersSent) return next(error) + const status = Number.isInteger(error.status) ? error.status : 500 + res.locals.errorLogged = true + const fields = { + requestId: req.requestId, + method: req.method, + path: req.path, + status, + durationMs: Date.now() - (req.requestStartedAt || Date.now()), + code: error.code || (status >= 500 ? 'INTERNAL_ERROR' : 'BAD_REQUEST'), + error, + } + if (status >= 500) { + logger.error('request error', fields) + } else { + logger.warn('request rejected', { ...fields, error: error.message }) + } + res.status(status).json({ + error: error.message || 'Internal server error.', + code: error.code || 'INTERNAL_ERROR', + details: error.details, + }) +}) + +const server = app.listen(PORT, HOST, () => { + logger.info('server started', { + host: HOST, + port: PORT, + maxPages: MAX_PAGES, + maxQueue: MAX_QUEUE, + exportConcurrency: EXPORT_CONCURRENCY, + pageFetchConcurrency: PAGE_FETCH_CONCURRENCY, + jobTtlMinutes: JOB_TTL_MS / 60_000, + authEnabled: Boolean(AUTH_USER && AUTH_PASSWORD), + apiKeyConfigured: Boolean(WIKI_API_KEY), + wikiUrlConfigured: Boolean(WIKI_URL), + }) + + if (WIKI_URL) { + logger.info('wiki configured', { wikiUrl: WIKI_URL.href }) + } else { + logger.warn('wiki URL is not configured', { + reason: WIKI_URL_ERROR || 'WIKI_URL is not set', + hint: 'Set WIKI_URL and restart the container.', + }) + } + if (!WIKI_API_KEY) { + logger.warn('wiki API key is not configured', { + hint: 'Set WIKI_API_KEY and restart the container.', + }) + } +}) + +let shuttingDown = false +async function shutdown(signal) { + if (shuttingDown) return + shuttingDown = true + logger.info('shutting down', { signal }) + const forceTimer = setTimeout(() => process.exit(1), 15_000) + forceTimer.unref?.() + + server.close() + await jobs.close().catch(() => {}) + await closeBrowser().catch(() => {}) + clearTimeout(forceTimer) + process.exit(0) +} + +process.on('SIGTERM', () => shutdown('SIGTERM')) +process.on('SIGINT', () => shutdown('SIGINT')) +process.on('unhandledRejection', (error) => { + logger.error('unhandled rejection', { error }) +}) + +export { app, server } diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..3a00e25 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,177 @@ +export class AppError extends Error { + constructor(message, { status = 400, code = 'BAD_REQUEST', details } = {}) { + super(message) + this.name = 'AppError' + this.status = status + this.code = code + this.details = details + } +} + +export class WikiError extends AppError { + constructor(message, options = {}) { + super(message, { status: options.status ?? 502, code: options.code ?? 'WIKI_ERROR', details: options.details }) + this.name = 'WikiError' + } +} + +const LOCAL_HOST_RE = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i +const ABSOLUTE_URL_RE = /^[a-z][a-z0-9+.-]*:\/\//i +const LOCALE_SEGMENT_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/i + +/** + * Normalize a Wiki.js API key from an environment variable or CLI option. + * Users often paste keys together with a "Bearer " prefix; only the key itself + * belongs in the Authorization header. + */ +export function normalizeApiKey(value) { + return String(value ?? '') + .trim() + .replace(/^Bearer\s+/i, '') +} + +/** + * Turn user input such as "wiki.example.com", "https://wiki.example.com/wiki/" + * or "https://wiki.example.com/graphql" into a canonical base URL description. + */ +export function normalizeBaseUrl(input) { + let raw = String(input ?? '').trim() + if (!raw) { + throw new AppError('The Wiki.js URL is required.') + } + + if (!ABSOLUTE_URL_RE.test(raw)) { + raw = `${LOCAL_HOST_RE.test(raw) ? 'http' : 'https'}://${raw}` + } + + let url + try { + url = new URL(raw) + } catch { + throw new AppError('The Wiki.js URL is not a valid URL.') + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new AppError('The Wiki.js URL must use http or https.') + } + if (url.username || url.password) { + throw new AppError('Do not put credentials into the Wiki.js URL. Set WIKI_API_KEY instead.') + } + + url.search = '' + url.hash = '' + + let pathname = url.pathname.replace(/\/+$/, '') + if (pathname.toLowerCase().endsWith('/graphql')) { + pathname = pathname.slice(0, -'/graphql'.length).replace(/\/+$/, '') + } + + const canonical = url.origin + pathname + return { + href: canonical || url.origin, + origin: url.origin, + pathname: pathname || '', + endpoint: `${canonical || url.origin}/graphql`, + } +} + +export function stripBasePath(pathname, basePathname) { + if (!basePathname) return pathname || '/' + if (pathname === basePathname) return '/' + if (pathname.startsWith(`${basePathname}/`)) { + return pathname.slice(basePathname.length) || '/' + } + return pathname || '/' +} + +export function safeDecode(value) { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +/** + * Convert a URL pathname (without query/hash) to a Wiki.js page path. + * The empty path and "/home" both refer to Wiki.js's reserved home page. + */ +export function pathnameToPagePath(pathname, basePathname = '') { + let path = stripBasePath(pathname || '/', basePathname) + path = safeDecode(path) + path = path.replace(/^\/+/, '').replace(/\/+$/, '') + if (!path || path.toLowerCase() === 'home') return 'home' + return path +} + +export function normalizePagePath(value) { + let path = String(value ?? '').trim() + if (!path) return 'home' + + if (ABSOLUTE_URL_RE.test(path)) { + try { + const url = new URL(path) + path = safeDecode(url.pathname) + } catch { + // Keep the original input and let selection report a useful not-found error. + } + } + + path = path.split(/[?#]/, 1)[0] + path = path.replace(/^\/+/, '').replace(/\/+$/, '') + return path || 'home' +} + +export function looksLikeLocaleSegment(segment) { + return LOCALE_SEGMENT_RE.test(segment || '') +} + +export function escapeHtml(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +export function sanitizeFilename(value, fallback = 'wiki-export') { + const sanitized = String(value || '') + .normalize('NFKD') + .replace(/[^\w.-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 120) + return sanitized || fallback +} + +export function comparePagePaths(a, b) { + return a.localeCompare(b, 'en', { numeric: true, sensitivity: 'base' }) +} + +export async function mapLimit(items, limit, worker) { + const results = new Array(items.length) + let nextIndex = 0 + const workerCount = Math.max(1, Math.min(limit, items.length)) + + async function run() { + while (true) { + const index = nextIndex + nextIndex += 1 + if (index >= items.length) return + results[index] = await worker(items[index], index) + } + } + + await Promise.all(Array.from({ length: workerCount }, run)) + return results +} + +export function toPositiveInt(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) { + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed)) return fallback + return Math.min(max, Math.max(min, parsed)) +} + +export function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/src/wiki-client.js b/src/wiki-client.js new file mode 100644 index 0000000..77a2994 --- /dev/null +++ b/src/wiki-client.js @@ -0,0 +1,304 @@ +import { logger as rootLogger } from './logger.js' +import { AppError, WikiError, normalizeApiKey } from './utils.js' + +const LIST_PAGES_QUERY = ` + query WikiPrintPages { + pages { + list { + id + path + locale + title + description + contentType + isPublished + updatedAt + } + } + } +` + +const LOCALIZATION_QUERY = ` + query WikiPrintLocalization { + localization { + config { + locale + namespacing + namespaces + } + } + } +` + +// `toc` is intentionally not selected. Some Wiki.js versions declare it as a +// String scalar but return an array, which makes the whole page query fail with +// INTERNAL_SERVER_ERROR. The exporter builds its own table of contents. +const PAGE_QUERY = ` + query WikiPrintPage($path: String!, $locale: String!) { + pages { + singleByPath(path: $path, locale: $locale) { + id + path + locale + title + description + contentType + render + createdAt + updatedAt + } + } + } +` + +const PAGE_SOURCE_QUERY = ` + query WikiPrintPageSource($id: Int!) { + pages { + single(id: $id) { + content + contentType + } + } + } +` + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +function operationName(query) { + const match = String(query).match(/\b(?:query|mutation)\s+([A-Za-z0-9_]+)/) + return match?.[1] || 'anonymous' +} + +function retryDelay(attempt) { + return 400 * 2 ** (attempt - 1) +} + +function graphQlErrorMessage(errors) { + return (errors || []) + .map((error) => { + const code = error?.extensions?.exception?.code || error?.extensions?.code + return code ? `${error.message} (${code})` : error.message + }) + .filter(Boolean) + .join('; ') +} + +export class WikiClient { + constructor({ baseUrl, apiKey, timeoutMs = 45_000, logger = rootLogger }) { + const key = normalizeApiKey(apiKey) + if (!key) { + throw new AppError('The Wiki.js API key is required.') + } + this.baseUrl = baseUrl + this.apiKey = key + this.timeoutMs = timeoutMs + this.logger = logger.child({ component: 'wiki' }) + } + + async query(query, variables = {}, { attempts = 3 } = {}) { + let lastError + const operation = operationName(query) + + for (let attempt = 1; attempt <= attempts; attempt += 1) { + const attemptStartedAt = Date.now() + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), this.timeoutMs) + let responseStatus = null + + try { + const response = await fetch(this.baseUrl.endpoint, { + method: 'POST', + headers: { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'User-Agent': 'wiki-print/1.0', + }, + body: JSON.stringify({ query, variables }), + signal: controller.signal, + }) + responseStatus = response.status + + const text = await response.text() + + if (!response.ok) { + if (response.status === 401 || response.status === 403) { + this.logger.warn('wiki request rejected', { + operation, + attempt, + status: response.status, + durationMs: Date.now() - attemptStartedAt, + code: 'WIKI_AUTH_FAILED', + }) + throw new WikiError( + `Wiki.js rejected the API key (HTTP ${response.status}). Check the key and make sure its group has the "read:pages" permission.`, + { status: 401, code: 'WIKI_AUTH_FAILED' }, + ) + } + if (response.status === 404) { + this.logger.warn('wiki GraphQL endpoint not found', { + operation, + attempt, + status: response.status, + durationMs: Date.now() - attemptStartedAt, + }) + throw new WikiError( + 'The Wiki.js GraphQL endpoint was not found. Check the wiki URL. If Wiki.js runs under a subpath, include it in the URL.', + { status: 502, code: 'WIKI_ENDPOINT_NOT_FOUND' }, + ) + } + if (response.status === 429 || response.status >= 500) { + lastError = new WikiError(`Wiki.js returned HTTP ${response.status}.`, { + status: 502, + code: 'WIKI_HTTP_ERROR', + }) + if (attempt < attempts) { + const delayMs = retryDelay(attempt) + this.logger.warn('wiki request retry', { + operation, + attempt, + nextAttempt: attempt + 1, + delayMs, + status: response.status, + code: lastError.code, + }) + await sleep(delayMs) + continue + } + throw lastError + } + this.logger.warn('wiki request failed', { + operation, + attempt, + status: response.status, + durationMs: Date.now() - attemptStartedAt, + }) + throw new WikiError(`Wiki.js returned HTTP ${response.status}.`, { + status: 502, + code: 'WIKI_HTTP_ERROR', + }) + } + + let payload + try { + payload = JSON.parse(text) + } catch { + if (/^\s* 0) { + const message = graphQlErrorMessage(payload.errors) + const code = payload.errors[0]?.extensions?.exception?.code || payload.errors[0]?.extensions?.code + const authRelated = /forbidden|unauthorized|permission|PageViewForbidden/i.test(`${code} ${message}`) + this.logger.warn('wiki GraphQL error', { + operation, + attempt, + status: response.status, + durationMs: Date.now() - attemptStartedAt, + code: code || 'WIKI_GRAPHQL_ERROR', + error: message, + }) + throw new WikiError(message || 'Wiki.js returned a GraphQL error.', { + status: authRelated ? 403 : 502, + code: code || 'WIKI_GRAPHQL_ERROR', + }) + } + + this.logger.debug('wiki request completed', { + operation, + attempt, + status: response.status, + durationMs: Date.now() - attemptStartedAt, + }) + return payload.data + } catch (error) { + if (error instanceof AppError) throw error + if (error?.name === 'AbortError') { + lastError = new WikiError(`Wiki.js did not answer within ${Math.round(this.timeoutMs / 1000)} seconds.`, { + status: 504, + code: 'WIKI_TIMEOUT', + }) + } else { + lastError = new WikiError(`Could not reach Wiki.js: ${error.message}`, { + status: 502, + code: 'WIKI_UNREACHABLE', + }) + } + if (attempt < attempts) { + const delayMs = retryDelay(attempt) + this.logger.warn('wiki request retry', { + operation, + attempt, + nextAttempt: attempt + 1, + delayMs, + status: responseStatus, + code: lastError.code, + error: lastError.message, + }) + await sleep(delayMs) + continue + } + this.logger.warn('wiki request failed', { + operation, + attempt, + status: responseStatus, + durationMs: Date.now() - attemptStartedAt, + code: lastError.code, + error: lastError.message, + }) + throw lastError + } finally { + clearTimeout(timer) + } + } + + throw lastError || new WikiError('Could not query Wiki.js.') + } + + async listPages() { + const data = await this.query(LIST_PAGES_QUERY) + const pages = data?.pages?.list + if (!Array.isArray(pages)) { + throw new WikiError('Wiki.js did not return a page list. Is this a Wiki.js 2.x wiki?', { + status: 502, + code: 'WIKI_INVALID_PAGE_LIST', + }) + } + return pages + } + + async getLocalization() { + try { + const data = await this.query(LOCALIZATION_QUERY, {}, { attempts: 1 }) + return data?.localization?.config || null + } catch (error) { + // Older Wiki.js versions or restricted setups may not expose this query. + this.logger.debug('wiki localization unavailable', { + code: error.code || null, + error: error.message, + }) + return null + } + } + + async getPage(path, locale) { + const data = await this.query(PAGE_QUERY, { path, locale }) + return data?.pages?.singleByPath || null + } + + async getPageSource(id) { + const data = await this.query(PAGE_SOURCE_QUERY, { id }, { attempts: 1 }) + return data?.pages?.single || null + } +} diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..eaa5d58 --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,201 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { createServer } from 'node:net' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import { startMockWiki } from './mock-wiki.js' + +const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))) + +function freePort() { + return new Promise((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() + server.close((error) => (error ? reject(error) : resolve(port))) + }) + }) +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function startApp({ port, wikiUrl, apiKey = 'test-key', extraEnv = {} }) { + const env = { + ...process.env, + NODE_ENV: 'test', + HOST: '127.0.0.1', + PORT: String(port), + WIKI_URL: wikiUrl || '', + WIKI_API_KEY: apiKey, + ...extraEnv, + } + delete env.MAX_PAGES + delete env.AUTH_USER + delete env.AUTH_PASSWORD + + const child = spawn(process.execPath, ['src/server.js'], { + cwd: ROOT, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + let output = '' + child.stdout.on('data', (chunk) => { + output += chunk + }) + child.stderr.on('data', (chunk) => { + output += chunk + }) + + const baseUrl = `http://127.0.0.1:${port}` + for (let attempt = 0; attempt < 100; attempt += 1) { + if (child.exitCode !== null) break + try { + const response = await fetch(`${baseUrl}/healthz`) + if (response.ok) return { baseUrl, child, output: () => output } + } catch { + // The server is not listening yet. + } + await sleep(50) + } + + child.kill('SIGKILL') + throw new Error(`server did not start:\n${output}`) +} + +async function stopApp(app) { + if (!app) return + if (app.child.exitCode === null) { + await new Promise((resolve) => { + app.child.once('exit', resolve) + app.child.kill('SIGTERM') + setTimeout(() => app.child.kill('SIGKILL'), 5_000).unref() + }) + } +} + +test('serves the wiki page list and keeps the connection details server-side', async () => { + const wiki = await startMockWiki() + const port = await freePort() + const app = await startApp({ port, wikiUrl: wiki.url }) + + try { + const configResponse = await fetch(`${app.baseUrl}/api/config`) + assert.equal(configResponse.status, 200) + assert.match(configResponse.headers.get('x-request-id') || '', /^[0-9a-f-]{36}$/) + const config = await configResponse.json() + assert.equal(config.wikiUrlConfigured, true) + assert.equal(config.wikiUrl, wiki.url) + assert.equal(config.apiKeyConfigured, true) + assert.equal('apiKey' in config, false, 'the API key must never be sent to the browser') + + const pagesResponse = await fetch(`${app.baseUrl}/api/pages`) + assert.equal(pagesResponse.status, 200) + const pagesPayload = await pagesResponse.json() + assert.equal(pagesPayload.baseUrl, wiki.url) + assert.equal(pagesPayload.defaultLocale, 'en') + assert.equal(pagesPayload.namespacing, true) + assert.equal(pagesPayload.pages.length, wiki.pages.length) + + const docs = pagesPayload.pages.find((page) => page.path === 'docs') + assert.deepEqual(docs, { + path: 'docs', + locale: 'en', + title: 'Documentation', + description: 'Documentation root', + isPublished: true, + }) + assert.equal( + pagesPayload.pages.find((page) => page.path === 'docs/draft').isPublished, + false, + ) + + const paths = pagesPayload.pages.map((page) => page.path) + assert.deepEqual(paths, [...paths].sort((a, b) => a.localeCompare(b, 'en'))) + } finally { + await stopApp(app) + await wiki.close() + } +}) + +test('rejects an export without a starting page and does not require a browser URL', async () => { + const wiki = await startMockWiki() + const port = await freePort() + const app = await startApp({ port, wikiUrl: wiki.url }) + + try { + const response = await fetch(`${app.baseUrl}/api/exports`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ startPage: '' }), + }) + assert.equal(response.status, 400) + const payload = await response.json() + assert.equal(payload.error, 'The starting page is required.') + + // A browser-supplied baseUrl/API key must be ignored: the server uses + // WIKI_URL and WIKI_API_KEY only. A missing page fails before Chromium + // starts, so this test does not need a browser. + const accepted = await fetch(`${app.baseUrl}/api/exports`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + baseUrl: 'https://attacker.example', + apiKey: 'attacker-key', + startPage: 'missing-page', + maxPages: 1, + }), + }) + assert.equal(accepted.status, 202) + const { job } = await accepted.json() + assert.equal(job.request.baseUrl, wiki.url) + assert.equal('apiKey' in job.request, false) + assert.equal('apiKey' in job, false) + + let status = job + for (let attempt = 0; attempt < 100 && status.status !== 'error'; attempt += 1) { + await sleep(50) + status = (await (await fetch(`${app.baseUrl}/api/exports/${job.id}`)).json()).job + } + assert.equal(status.status, 'error') + assert.equal(status.error.code, 'START_PAGE_NOT_FOUND') + } finally { + await stopApp(app) + await wiki.close() + } +}) + +test('reports missing and invalid WIKI_URL without crashing', async () => { + const port = await freePort() + const app = await startApp({ port, wikiUrl: '' }) + + try { + const config = await (await fetch(`${app.baseUrl}/api/config`)).json() + assert.equal(config.wikiUrlConfigured, false) + assert.equal(config.wikiUrl, null) + + const pagesResponse = await fetch(`${app.baseUrl}/api/pages`) + assert.equal(pagesResponse.status, 503) + const pagesPayload = await pagesResponse.json() + assert.equal(pagesPayload.code, 'WIKI_URL_MISSING') + } finally { + await stopApp(app) + } + + const invalidPort = await freePort() + const invalidApp = await startApp({ port: invalidPort, wikiUrl: 'ftp://not-a-wiki' }) + + try { + const pagesResponse = await fetch(`${invalidApp.baseUrl}/api/pages`) + assert.equal(pagesResponse.status, 503) + const payload = await pagesResponse.json() + assert.equal(payload.code, 'WIKI_URL_MISSING') + assert.match(payload.error, /WIKI_URL is invalid/) + } finally { + await stopApp(invalidApp) + } +}) diff --git a/test/html-builder.test.js b/test/html-builder.test.js new file mode 100644 index 0000000..9fbe457 --- /dev/null +++ b/test/html-builder.test.js @@ -0,0 +1,171 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import * as cheerio from 'cheerio' +import { buildExportHtml } from '../src/html-builder.js' +import { normalizeBaseUrl } from '../src/utils.js' + +const baseUrl = normalizeBaseUrl('https://wiki.example.com') + +const pages = [ + { + path: 'docs', + locale: 'en', + title: 'Docs', + depth: 0, + updatedAt: '2026-01-01T00:00:00.000Z', + render: ` +

    Docs

    +

    Local

    +

    Child heading

    +

    Child

    +

    Outside subtree

    +

    German docs

    +

    External

    + Anchor target + `, + }, + { + path: 'docs/child', + locale: 'en', + title: 'Child', + depth: 1, + updatedAt: '2026-01-01T00:00:00.000Z', + render: ` +

    Sub heading

    +

    Auto heading

    +

    Back + Docs + Grand

    + Picture + +
    Clickable
    + +

    Form content

    + `, + }, + { + path: 'docs/child/grand', + locale: 'en', + title: 'Grand', + depth: 2, + updatedAt: '2026-01-01T00:00:00.000Z', + render: `

    Grand child heading

    `, + }, +] + +async function build(overrides = {}) { + return buildExportHtml({ + pages, + baseUrl, + locale: 'en', + startPath: 'docs', + startPage: { title: 'Docs' }, + localization: { namespacing: true, namespaces: ['en'] }, + includeToc: true, + inlineImages: false, + apiKey: 'test-key', + ...overrides, + }) +} + +test('rewrites internal links to PDF anchors and keeps external links online', async () => { + const html = await build() + const $ = cheerio.load(html) + + assert.equal($('.wiki-content a[href="#wp1-sub-heading"]').length, 1) + assert.equal($('.wiki-content a[href="#wiki-page-1"]').length, 1) + assert.equal($('.wiki-content a[href="#wp2-child-heading"]').length, 1) + assert.equal($('.wiki-content a[href="#wp0-local"]').length, 1) + assert.ok( + $('.wiki-content a[data-wiki-path="docs"]') + .toArray() + .map((element) => $(element).attr('href')) + .includes('#wiki-page-0'), + ) + + assert.equal($('a[href="https://wiki.example.com/en/docs-other"]').length, 1) + assert.equal($('a[href="https://wiki.example.com/de/docs"]').length, 1) + assert.equal($('a[href="https://example.com/page"]').length, 1) + + assert.equal($('#wp1-sub-heading').length, 1) + assert.equal($('#wp1-auto-heading').length, 1) + assert.equal($('a[name="wp1-old-anchor"]').length, 1) + assert.equal($('[aria-labelledby="wp1-sub-heading other-id"]').length, 1) + + const childHrefs = $('.wiki-content a[data-wiki-path="docs/child"]') + .toArray() + .map((element) => $(element).attr('href')) + assert.ok(childHrefs.includes('#wp1-sub-heading')) + assert.ok(childHrefs.includes('#wiki-page-1')) + assert.equal( + $('.wiki-content a[data-wiki-path="docs/child/grand"]').attr('href'), + '#wp2-child-heading', + ) + assert.ok( + $('.wiki-content a[data-wiki-path="docs"]') + .toArray() + .map((element) => $(element).attr('href')) + .includes('#wiki-page-0'), + ) +}) + +test('sanitizes scripts, event handlers, forms and iframes', async () => { + const html = await build() + const $ = cheerio.load(html) + + assert.equal($('script').length, 0) + assert.equal($('[onclick]').length, 0) + assert.equal($('form').length, 0) + assert.equal($('input').length, 0) + assert.equal($('iframe').length, 0) + assert.equal($('.pdf-embed a[href="https://wiki.example.com/embedded"]').length, 1) + assert.match($('.wiki-content').text(), /Form content/) +}) + +test('resolves images even when embedding is disabled', async () => { + const html = await build() + const $ = cheerio.load(html) + + const image = $('img[alt="Picture"]') + assert.equal(image.attr('src'), 'https://wiki.example.com/assets/pic.png') + assert.equal(image.attr('srcset'), undefined) + assert.equal(image.attr('loading'), undefined) +}) + +test('supports wikis without URL locale namespacing', async () => { + const withoutNamespacing = pages.map((page) => ({ + ...page, + render: page.render.replaceAll('/en/', '/'), + })) + + const html = await build({ + pages: withoutNamespacing, + localization: { namespacing: false, namespaces: [] }, + }) + const $ = cheerio.load(html) + + const childHrefs = $('.wiki-content a[data-wiki-path="docs/child"]') + .toArray() + .map((element) => $(element).attr('href')) + assert.ok(childHrefs.includes('#wp1-sub-heading')) + assert.ok(childHrefs.includes('#wiki-page-1')) + assert.equal( + $('.wiki-content a[data-wiki-path="docs/child/grand"]').attr('href'), + '#wp2-child-heading', + ) + assert.ok( + $('.wiki-content a[data-wiki-path="docs"]') + .toArray() + .map((element) => $(element).attr('href')) + .includes('#wiki-page-0'), + ) +}) + +test('includes only the exported pages in the table of contents', async () => { + const html = await build() + const $ = cheerio.load(html) + + const tocHrefs = $('.toc-entry a').toArray().map((element) => $(element).attr('href')) + assert.deepEqual(tocHrefs, ['#wiki-page-0', '#wiki-page-1', '#wiki-page-2']) + assert.equal($('.wiki-page').length, 3) +}) diff --git a/test/logger.test.js b/test/logger.test.js new file mode 100644 index 0000000..78c8451 --- /dev/null +++ b/test/logger.test.js @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { createLogger } from '../src/logger.js' + +function capture() { + const lines = [] + return { + lines, + stream: { + write(line) { + lines.push(line) + }, + }, + } +} + +test('writes readable text logs and hides debug lines by default', () => { + const sink = capture() + const logger = createLogger({ + level: 'info', + format: 'text', + stream: sink.stream, + bindings: { app: 'test' }, + }) + + logger.debug('hidden detail', { value: 1 }) + logger.info('server started', { port: 8080, name: 'a b' }) + logger.warn('careful', { attempt: 2 }) + + assert.equal(sink.lines.length, 2) + assert.match(sink.lines[0], /^\d{4}-\d{2}-\d{2}T/) + assert.match(sink.lines[0], /INFO {2}server started app=test port=8080 name="a b"/) + assert.match(sink.lines[1], /WARN {2}careful app=test attempt=2/) +}) + +test('redacts credentials but keeps configuration booleans', () => { + const sink = capture() + const logger = createLogger({ level: 'info', stream: sink.stream }) + + logger.info('config', { + apiKey: 'secret-value', + WIKI_API_KEY: 'secret-value', + apiKeyConfigured: true, + password: 'hunter2', + normal: 'visible', + }) + + const line = sink.lines.join('') + assert.doesNotMatch(line, /secret-value|hunter2/) + assert.match(line, /apiKey=\[redacted\]/) + assert.match(line, /WIKI_API_KEY=\[redacted\]/) + assert.match(line, /apiKeyConfigured=true/) + assert.match(line, /normal=visible/) +}) + +test('emits one JSON object per line with error details', () => { + const sink = capture() + const logger = createLogger({ level: 'debug', format: 'json', stream: sink.stream }) + const error = Object.assign(new Error('request failed'), { code: 'WIKI_ERROR', status: 502 }) + + logger.child({ jobId: 'job-1' }).error('export failed', { error, apiKey: 'nope' }) + + assert.equal(sink.lines.length, 1) + const record = JSON.parse(sink.lines[0]) + assert.equal(record.level, 'error') + assert.equal(record.message, 'export failed') + assert.equal(record.jobId, 'job-1') + assert.equal(record.apiKey, '[redacted]') + assert.equal(record.error.message, 'request failed') + assert.equal(record.error.code, 'WIKI_ERROR') + assert.match(record.error.stack, /request failed/) +}) + +test('silent level disables all output', () => { + const sink = capture() + const logger = createLogger({ level: 'silent', stream: sink.stream }) + + logger.error('nothing here') + + assert.equal(sink.lines.length, 0) +}) diff --git a/test/mock-wiki.js b/test/mock-wiki.js new file mode 100644 index 0000000..de6d497 --- /dev/null +++ b/test/mock-wiki.js @@ -0,0 +1,264 @@ +import { createServer } from 'node:http' + +const logo = ` + + WIKI +` + +const pages = [ + { + id: 1, + path: 'docs', + locale: 'en', + title: 'Documentation', + description: 'Documentation root', + contentType: 'markdown', + isPublished: true, + updatedAt: '2026-01-02T10:00:00.000Z', + render: ` +

    Documentation

    +

    Start with Install. The Linux details are in + Linux requirements.

    +

    Jump to welcome

    +

    External docs

    +

    Outside guide

    +

    Excluded sibling

    + Wiki logo + + +

    DOCS_MARKER

    + `, + }, + { + id: 2, + path: 'docs/install', + locale: 'en', + title: 'Installation', + description: 'Install guide', + contentType: 'markdown', + isPublished: true, + updatedAt: '2026-01-03T10:00:00.000Z', + render: ` +

    Installation

    +

    Back to Documentation.

    +

    Choose Linux.

    +

    INSTALL_MARKER

    + `, + }, + { + id: 3, + path: 'docs/install/linux', + locale: 'en', + title: 'Linux requirements', + description: 'Linux install', + contentType: 'markdown', + isPublished: true, + updatedAt: '2026-01-04T10:00:00.000Z', + render: ` +

    Linux

    +

    Requirements

    +
    • Node.js 22
    • PostgreSQL
    +

    Back to installation

    +

    LINUX_MARKER

    + `, + }, + { + id: 4, + path: 'docs/other', + locale: 'en', + title: 'Other notes', + description: 'Other notes', + contentType: 'markdown', + isPublished: true, + updatedAt: '2026-01-05T10:00:00.000Z', + render: `

    Other notes

    OTHER_MARKER

    `, + }, + { + id: 5, + path: 'docs/fallback', + locale: 'en', + title: 'Fallback Markdown', + description: 'Rendered from source', + contentType: 'markdown', + isPublished: true, + updatedAt: '2026-01-06T10:00:00.000Z', + render: null, + content: `# Fallback Markdown + +This page is rendered from its markdown source. See [Installation](install). + +FALLBACK_MARKER +`, + }, + { + id: 6, + path: 'docs/draft', + locale: 'en', + title: 'Draft page', + description: 'Unpublished', + contentType: 'markdown', + isPublished: false, + updatedAt: '2026-01-07T10:00:00.000Z', + render: `

    Draft page

    DRAFT_MARKER

    `, + }, + { + id: 7, + path: 'docs-other', + locale: 'en', + title: 'Excluded sibling', + description: 'Not below docs', + contentType: 'markdown', + isPublished: true, + updatedAt: '2026-01-08T10:00:00.000Z', + render: `

    Excluded sibling

    EXCLUDED_SIBLING_MARKER

    `, + }, + { + id: 8, + path: 'guide', + locale: 'en', + title: 'Outside guide page', + description: 'Outside the subtree', + contentType: 'markdown', + isPublished: true, + updatedAt: '2026-01-09T10:00:00.000Z', + render: `

    Outside guide page

    GUIDE_OUTSIDE_MARKER

    `, + }, +] + +function pageListItem(page) { + return { + id: page.id, + path: page.path, + locale: page.locale, + title: page.title, + description: page.description, + contentType: page.contentType, + isPublished: page.isPublished, + updatedAt: page.updatedAt, + } +} + +function sendJson(response, status, payload) { + const body = JSON.stringify(payload) + response.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }) + response.end(body) +} + +export async function startMockWiki() { + const state = { assetHits: 0, graphqlHits: 0 } + + const server = createServer((request, response) => { + const url = new URL(request.url, 'http://127.0.0.1') + + if (url.pathname === '/assets/logo.svg') { + state.assetHits += 1 + response.writeHead(200, { 'Content-Type': 'image/svg+xml' }) + response.end(logo) + return + } + + if (url.pathname !== '/graphql' || request.method !== 'POST') { + response.writeHead(404, { 'Content-Type': 'text/plain' }) + response.end('not found') + return + } + + if (request.headers.authorization !== 'Bearer test-key') { + sendJson(response, 401, { error: 'unauthorized' }) + return + } + + state.graphqlHits += 1 + let raw = '' + request.setEncoding('utf8') + request.on('data', (chunk) => { + raw += chunk + }) + request.on('end', () => { + let payload + try { + payload = JSON.parse(raw) + } catch { + sendJson(response, 400, { error: 'bad json' }) + return + } + + const query = payload.query || '' + const variables = payload.variables || {} + + if (query.includes('WikiPrintLocalization')) { + sendJson(response, 200, { + data: { + localization: { + config: { locale: 'en', namespacing: true, namespaces: ['en'] }, + }, + }, + }) + return + } + + if (query.includes('WikiPrintPages')) { + sendJson(response, 200, { + data: { pages: { list: pages.map(pageListItem) } }, + }) + return + } + + if (query.includes('WikiPrintPageSource')) { + const page = pages.find((candidate) => candidate.id === variables.id) + sendJson(response, 200, { + data: { + pages: { + single: page + ? { content: page.content || '', contentType: page.contentType } + : null, + }, + }, + }) + return + } + + if (query.includes('WikiPrintPage')) { + const page = pages.find( + (candidate) => + candidate.path === variables.path && candidate.locale === variables.locale, + ) + sendJson(response, 200, { + data: { + pages: { + singleByPath: page + ? { + id: page.id, + path: page.path, + locale: page.locale, + title: page.title, + description: page.description, + contentType: page.contentType, + render: page.render, + createdAt: page.updatedAt, + updatedAt: page.updatedAt, + } + : null, + }, + }, + }) + return + } + + sendJson(response, 400, { errors: [{ message: 'Unknown mock query' }] }) + }) + }) + + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + + return { + url: `http://127.0.0.1:${address.port}`, + state, + pages, + close: () => new Promise((resolve) => server.close(resolve)), + } +} diff --git a/test/selection.test.js b/test/selection.test.js new file mode 100644 index 0000000..6265c07 --- /dev/null +++ b/test/selection.test.js @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { selectSubtree } from '../src/page-selection.js' +import { normalizeBaseUrl } from '../src/utils.js' + +const base = normalizeBaseUrl('https://wiki.example.com') + +const pages = [ + { id: 1, path: 'docs', locale: 'en', title: 'Docs', isPublished: true }, + { id: 2, path: 'docs/fallback', locale: 'en', title: 'Fallback', isPublished: true }, + { id: 3, path: 'docs/install', locale: 'en', title: 'Install', isPublished: true }, + { id: 4, path: 'docs/install/linux', locale: 'en', title: 'Linux', isPublished: true }, + { id: 5, path: 'docs/other', locale: 'en', title: 'Other', isPublished: true }, + { id: 6, path: 'docs/draft', locale: 'en', title: 'Draft', isPublished: false }, + { id: 7, path: 'docs-other', locale: 'en', title: 'Excluded sibling', isPublished: true }, + { id: 8, path: 'guide', locale: 'en', title: 'Guide', isPublished: true }, +] + +test('selects the starting page and path descendants, not similarly named siblings', () => { + const result = selectSubtree({ + pages, + baseUrl: base, + startPage: 'docs', + locale: 'en', + }) + + assert.equal(result.locale, 'en') + assert.equal(result.startPath, 'docs') + assert.deepEqual( + result.pages.map((page) => page.path), + ['docs', 'docs/fallback', 'docs/install', 'docs/install/linux', 'docs/other'], + ) + assert.deepEqual( + result.pages.map((page) => page.depth), + [0, 1, 1, 2, 1], + ) + assert.equal(result.omittedUnpublished, 1) +}) + +test('can include unpublished descendants explicitly', () => { + const result = selectSubtree({ + pages, + baseUrl: base, + startPage: 'docs', + locale: 'en', + includeUnpublished: true, + }) + + assert.ok(result.pages.some((page) => page.path === 'docs/draft')) + assert.equal(result.omittedUnpublished, 0) +}) + +test('accepts a full Wiki.js page URL with a locale prefix', () => { + const result = selectSubtree({ + pages, + baseUrl: base, + startPage: 'https://wiki.example.com/en/docs/install/', + }) + + assert.equal(result.startPath, 'docs/install') + assert.deepEqual( + result.pages.map((page) => page.path), + ['docs/install', 'docs/install/linux'], + ) +}) + +test('handles a wiki hosted below a URL subpath', () => { + const subpathBase = normalizeBaseUrl('https://example.com/wiki/') + const subpathPages = pages.map((page) => ({ ...page })) + const result = selectSubtree({ + pages: subpathPages, + baseUrl: subpathBase, + startPage: 'https://example.com/wiki/en/docs/install', + }) + + assert.equal(result.startPath, 'docs/install') + assert.deepEqual( + result.pages.map((page) => page.path), + ['docs/install', 'docs/install/linux'], + ) +}) + +test('accepts a path with a locale prefix', () => { + const result = selectSubtree({ + pages, + baseUrl: base, + startPage: '/en/docs', + }) + + assert.equal(result.startPath, 'docs') + assert.equal(result.pages.length, 5) +}) + +test('reports ambiguous locales and honors a default locale', () => { + const multilingual = [ + ...pages, + { id: 20, path: 'docs', locale: 'de', title: 'Doku', isPublished: true }, + ] + + assert.throws( + () => + selectSubtree({ + pages: multilingual, + baseUrl: base, + startPage: 'docs', + }), + /multiple locales/, + ) + + const result = selectSubtree({ + pages: multilingual, + baseUrl: base, + startPage: 'docs', + defaultLocale: 'de', + }) + assert.equal(result.locale, 'de') + assert.deepEqual( + result.pages.map((page) => page.path), + ['docs'], + ) +}) + +test('reports a missing starting page and unknown locales', () => { + assert.throws( + () => selectSubtree({ pages, baseUrl: base, startPage: 'missing', locale: 'en' }), + /was not found/, + ) + + assert.throws( + () => selectSubtree({ pages, baseUrl: base, startPage: 'docs', locale: 'fr' }), + /Locale "fr" does not exist/, + ) +}) + +test('enforces the page limit', () => { + assert.throws( + () => + selectSubtree({ + pages, + baseUrl: base, + startPage: 'docs', + locale: 'en', + maxPages: 2, + }), + /above the configured limit/, + ) +}) + +test('maps the root URL to Wiki.js home', () => { + const homePages = [ + { id: 1, path: 'home', locale: 'en', title: 'Home', isPublished: true }, + { id: 2, path: 'home/child', locale: 'en', title: 'Child', isPublished: true }, + ] + const result = selectSubtree({ + pages: homePages, + baseUrl: base, + startPage: 'https://wiki.example.com/en/', + }) + + assert.equal(result.startPath, 'home') + assert.deepEqual( + result.pages.map((page) => page.path), + ['home', 'home/child'], + ) +}) diff --git a/test/smoke.test.js b/test/smoke.test.js new file mode 100644 index 0000000..cde8cca --- /dev/null +++ b/test/smoke.test.js @@ -0,0 +1,100 @@ +import assert from 'node:assert/strict' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' +import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs' +import { exportWikiToPdf } from '../src/exporter.js' +import { closeBrowser } from '../src/pdf-renderer.js' +import { startMockWiki } from './mock-wiki.js' + +// The renderer keeps a Chromium instance alive between exports. Close it so +// the test process can exit after the suite finishes. +test.after(async () => { + await closeBrowser() +}) + +const chromiumPath = [ + process.env.CHROMIUM_PATH, + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/opt/google/chrome/chrome', + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', +] + .filter(Boolean) + .find((candidate) => existsSync(candidate)) + +test( + 'exports a subtree to a PDF with working internal and external links', + { skip: chromiumPath ? false : 'No Chromium executable found for the smoke test.', timeout: 180_000 }, + async () => { + const wiki = await startMockWiki() + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wiki-print-test-')) + + try { + const result = await exportWikiToPdf({ + baseUrl: wiki.url, + apiKey: 'test-key', + startPage: 'docs', + maxPages: 50, + outputPath: path.join(tempDir, 'export.pdf'), + }) + + assert.equal(result.pageCount, 5) + assert.equal(result.locale, 'en') + assert.equal(wiki.state.assetHits, 1, 'the page image should be fetched once for embedding') + + const pdfData = new Uint8Array(await readFile(result.pdfPath)) + const pdf = await getDocument({ + data: pdfData, + disableFontFace: true, + useSystemFonts: true, + }).promise + + try { + let text = '' + const annotations = [] + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) { + const page = await pdf.getPage(pageNumber) + const content = await page.getTextContent() + text += `\n${content.items.map((item) => item.str).join(' ')}` + annotations.push(...(await page.getAnnotations())) + } + + assert.ok(pdf.numPages >= 3, `expected several PDF pages, got ${pdf.numPages}`) + assert.match(text, /DOCS_MARKER/) + assert.match(text, /INSTALL_MARKER/) + assert.match(text, /LINUX_MARKER/) + assert.match(text, /OTHER_MARKER/) + assert.match(text, /FALLBACK_MARKER/) + assert.doesNotMatch(text, /DRAFT_MARKER/) + assert.doesNotMatch(text, /EXCLUDED_SIBLING_MARKER/) + assert.doesNotMatch(text, /GUIDE_OUTSIDE_MARKER/) + assert.doesNotMatch(text, /window\.evil/) + + const links = annotations.filter((annotation) => annotation.subtype === 'Link') + assert.ok(links.length > 0, 'expected link annotations in the PDF') + assert.ok( + links.some((link) => /example\.com/.test(link.url || link.unsafeUrl || '')), + 'expected the external link to remain a URL annotation', + ) + assert.ok( + links.some( + (link) => + link.dest || + /^#/.test(link.url || '') || + /^#/.test(link.unsafeUrl || '') || + link.action === 'GoTo', + ), + 'expected internal wiki links to stay internal PDF links', + ) + } finally { + await pdf.destroy() + } + } finally { + await wiki.close() + await rm(tempDir, { recursive: true, force: true }) + } + }, +) diff --git a/test/wiki-client.test.js b/test/wiki-client.test.js new file mode 100644 index 0000000..1a62d77 --- /dev/null +++ b/test/wiki-client.test.js @@ -0,0 +1,158 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import test from 'node:test' +import { WikiClient } from '../src/wiki-client.js' +import { normalizeApiKey, normalizeBaseUrl } from '../src/utils.js' + +const listItem = { + id: 1, + path: 'docs', + locale: 'en', + title: 'Docs', + description: 'Documentation', + contentType: 'markdown', + isPublished: true, + updatedAt: '2026-01-02T10:00:00.000Z', +} + +function startWiki({ expectedKey = 'test-key' } = {}) { + const requests = [] + const server = createServer((request, response) => { + let raw = '' + request.on('data', (chunk) => { + raw += chunk + }) + request.on('end', () => { + const authorization = request.headers.authorization || '' + const body = JSON.parse(raw || '{}') + requests.push({ authorization, query: body.query || '', variables: body.variables || {} }) + + if (authorization !== `Bearer ${expectedKey}`) { + response.writeHead(401, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ error: 'Unauthorized' })) + return + } + + const send = (data) => { + response.writeHead(200, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ data })) + } + + if (body.query.includes('WikiPrintPages')) { + send({ pages: { list: [listItem] } }) + } else if (body.query.includes('WikiPrintLocalization')) { + send({ + localization: { + config: { locale: 'en', namespacing: true, namespaces: ['en', 'de'] }, + }, + }) + } else if (body.query.includes('WikiPrintPageSource')) { + send({ pages: { single: { content: '# Docs', contentType: 'markdown' } } }) + } else if (body.query.includes('WikiPrintPage')) { + send({ + pages: { + singleByPath: { + ...listItem, + render: '

    Docs

    ', + createdAt: listItem.updatedAt, + }, + }, + }) + } else { + response.writeHead(400, { 'Content-Type': 'application/json' }) + response.end(JSON.stringify({ errors: [{ message: 'Unknown query' }] })) + } + }) + }) + + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + resolve({ + url: `http://127.0.0.1:${server.address().port}`, + requests, + close: () => new Promise((done) => server.close(done)), + }) + }) + }) +} + +test('normalizes API keys from environment-style input', () => { + assert.equal(normalizeApiKey(' test-key '), 'test-key') + assert.equal(normalizeApiKey('Bearer test-key'), 'test-key') + assert.equal(normalizeApiKey('bearer test-key'), 'test-key') + assert.equal(normalizeApiKey(undefined), '') +}) + +test('uses Wiki.js 2.x queries and Bearer authentication', async () => { + const wiki = await startWiki() + try { + const client = new WikiClient({ + baseUrl: normalizeBaseUrl(wiki.url), + apiKey: 'Bearer test-key', + }) + + assert.equal((await client.listPages())[0].path, 'docs') + assert.equal((await client.getLocalization()).namespacing, true) + assert.equal((await client.getPage('docs', 'en')).render, '

    Docs

    ') + assert.equal((await client.getPageSource(1)).content, '# Docs') + + assert.ok(wiki.requests.length >= 4) + assert.ok(wiki.requests.every((request) => request.authorization === 'Bearer test-key')) + + const queryFor = (marker) => + wiki.requests.find((request) => request.query.includes(marker))?.query || '' + + // These fields exist in the Wiki.js 2.x schema but require permissions + // beyond read:pages (read:source or write:pages), so requests protected + // with read:pages must not select them. + const listQuery = queryFor('WikiPrintPages') + assert.match(listQuery, /\bpath\b/) + assert.match(listQuery, /\blocale\b/) + assert.match(listQuery, /\bisPublished\b/) + assert.doesNotMatch(listQuery, /\bisPrivate\b/) + assert.doesNotMatch(listQuery, /\bauthorEmail\b/) + assert.doesNotMatch(listQuery, /\bprivateNS\b/) + assert.doesNotMatch(listQuery, /\bcontent\b/) + + const pageQuery = queryFor('WikiPrintPage(') + assert.match(pageQuery, /\brender\b/) + // Wiki.js may expose `toc` as a String scalar while returning an array, + // which makes the whole page query fail with INTERNAL_SERVER_ERROR. + // The exporter does not use it. + assert.doesNotMatch(pageQuery, /\btoc\b/) + assert.doesNotMatch(pageQuery, /\bisPublished\b/) + assert.doesNotMatch(pageQuery, /\bisPrivate\b/) + assert.doesNotMatch(pageQuery, /\bauthorEmail\b/) + assert.doesNotMatch(pageQuery, /\bcontent\b/) + + const sourceQuery = queryFor('WikiPrintPageSource') + assert.match(sourceQuery, /\bcontent\b/) + assert.doesNotMatch(sourceQuery, /\brender\b/) + + const localizationQuery = queryFor('WikiPrintLocalization') + assert.match(localizationQuery, /\blocale\b/) + assert.match(localizationQuery, /\bnamespacing\b/) + assert.match(localizationQuery, /\bnamespaces\b/) + } finally { + await wiki.close() + } +}) + +test('reports a rejected Wiki.js 2.x API key', async () => { + const wiki = await startWiki({ expectedKey: 'different-key' }) + try { + const client = new WikiClient({ + baseUrl: normalizeBaseUrl(wiki.url), + apiKey: 'test-key', + timeoutMs: 2_000, + }) + + await assert.rejects(client.listPages(), (error) => { + assert.equal(error.code, 'WIKI_AUTH_FAILED') + assert.equal(error.status, 401) + return true + }) + } finally { + await wiki.close() + } +})