Initial commit
This commit is contained in:
commit
2683eae6b3
36 files changed
+7369
No files matched your search
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
*.pdf
|
||||
wiki-print-test-*
|
||||
+31
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
`<start-path>/` 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.
|
||||
Generated
+1553
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+503
@@ -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()
|
||||
@@ -0,0 +1,126 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>wiki-print · Wiki.js to PDF</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<header class="site-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark" aria-hidden="true">W</div>
|
||||
<div>
|
||||
<div class="brand-name">wiki-print</div>
|
||||
<div class="brand-subtitle">Wiki.js subtree → one linked PDF</div>
|
||||
</div>
|
||||
</div>
|
||||
<a class="header-link" href="/healthz">health</a>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<h1>Export a Wiki.js page tree to PDF</h1>
|
||||
<p>
|
||||
The wiki URL and API key are configured on the server through <code>WIKI_URL</code> and
|
||||
<code>WIKI_API_KEY</code>. 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.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<form id="export-form" class="card" novalidate>
|
||||
<p id="config-notice" class="config-notice" role="alert" hidden></p>
|
||||
|
||||
<div class="field">
|
||||
<label for="page-filter">Starting page</label>
|
||||
<input
|
||||
id="page-filter"
|
||||
type="search"
|
||||
placeholder="Filter pages by title or path…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
disabled
|
||||
>
|
||||
<select id="start-page" required aria-label="Starting page" disabled>
|
||||
<option value="">Loading pages…</option>
|
||||
</select>
|
||||
<p id="page-status" class="hint">Loading the page list from Wiki.js…</p>
|
||||
</div>
|
||||
|
||||
<details class="advanced">
|
||||
<summary>Advanced options</summary>
|
||||
<div class="field max-pages-field">
|
||||
<label for="max-pages">Maximum pages</label>
|
||||
<input id="max-pages" name="maxPages" type="number" min="1" max="5000" value="500">
|
||||
<p class="hint">The export stops with an error if the subtree is larger.</p>
|
||||
</div>
|
||||
|
||||
<div class="checks">
|
||||
<label class="check">
|
||||
<input id="include-unpublished" name="includeUnpublished" type="checkbox">
|
||||
<span>Include unpublished pages</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input id="include-toc" name="includeToc" type="checkbox" checked>
|
||||
<span>Add a table of contents</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input id="inline-images" name="inlineImages" type="checkbox" checked>
|
||||
<span>Embed images in the PDF</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="actions">
|
||||
<button id="submit-button" class="button button-primary" type="submit">Create PDF</button>
|
||||
<span class="actions-note">Exports run one at a time and can take a few minutes.</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section id="status-panel" class="card status-card" hidden aria-live="polite">
|
||||
<div class="status-heading">
|
||||
<div>
|
||||
<div id="status-eyebrow" class="status-eyebrow">Export</div>
|
||||
<h2 id="status-title">Queued</h2>
|
||||
</div>
|
||||
<span id="status-badge" class="badge">queued</span>
|
||||
</div>
|
||||
|
||||
<p id="status-message" class="status-message"></p>
|
||||
|
||||
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div id="progress-fill" class="progress-fill"></div>
|
||||
</div>
|
||||
<div id="status-pages" class="status-pages"></div>
|
||||
|
||||
<div id="error-box" class="error-box" hidden>
|
||||
<strong>Export failed</strong>
|
||||
<p id="error-message"></p>
|
||||
</div>
|
||||
|
||||
<div id="result-box" class="result-box" hidden>
|
||||
<div>
|
||||
<div class="result-title">Your PDF is ready</div>
|
||||
<div id="result-meta" class="result-meta"></div>
|
||||
</div>
|
||||
<a id="download-button" class="button button-primary" href="#">Download PDF</a>
|
||||
</div>
|
||||
|
||||
<details id="warnings" class="warnings" hidden>
|
||||
<summary><span id="warnings-count">0</span> warning(s)</summary>
|
||||
<ul id="warnings-list"></ul>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<span>Links are preserved with Chromium's PDF renderer. The produced file works offline for the exported subtree.</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+17
@@ -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}."
|
||||
Executable
+18
@@ -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."
|
||||
Executable
+10
@@ -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"
|
||||
Executable
+14
@@ -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"
|
||||
Executable
+70
@@ -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
|
||||
Executable
+18
@@ -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
|
||||
Executable
+144
@@ -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 <wiki-url> --start <page> [options]
|
||||
|
||||
Required:
|
||||
--url <url> Wiki.js base URL (also WIKI_URL)
|
||||
--start <path|url> Starting page path or full page URL (also WIKI_START_PAGE)
|
||||
--api-key <key> Wiki.js API key (also WIKI_API_KEY)
|
||||
|
||||
Options:
|
||||
--locale <code> Page locale, e.g. en or de-ch
|
||||
--output <file> Output PDF path (default: a temp file)
|
||||
--max-pages <n> 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()
|
||||
+316
@@ -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 `<pre>${escapeHtml(content)}</pre>`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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: <a href="${escapeHtml(href)}">${escapeHtml(href)}</a>`
|
||||
}
|
||||
$element.replaceWith(`<p class="pdf-embed">${label}</p>`)
|
||||
})
|
||||
|
||||
$(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: <a href="${escapeHtml(href)}">${escapeHtml(href)}</a>`
|
||||
}
|
||||
$element.replaceWith(`<p class="pdf-embed">${label}</p>`)
|
||||
})
|
||||
|
||||
$('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 `
|
||||
<section class="cover">
|
||||
<div class="eyebrow">Wiki.js · PDF export</div>
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p class="subtitle">${escapeHtml(subtitle)}</p>
|
||||
<div class="rule"></div>
|
||||
<dl>
|
||||
<dt>Source</dt>
|
||||
<dd>${escapeHtml(baseUrl.href)}</dd>
|
||||
<dt>Starting page</dt>
|
||||
<dd>${escapeHtml(startPath)}</dd>
|
||||
<dt>Locale</dt>
|
||||
<dd>${escapeHtml(locale)}</dd>
|
||||
<dt>Pages</dt>
|
||||
<dd>${pages.length}</dd>
|
||||
<dt>Generated</dt>
|
||||
<dd>${escapeHtml(generated)}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
|
||||
function renderToc(pages) {
|
||||
const entries = pages
|
||||
.map((page) => {
|
||||
const depth = Math.max(0, Math.min(page.depth || 0, 8))
|
||||
return `
|
||||
<li class="toc-entry" style="--depth:${depth}">
|
||||
<a href="#${page.sectionId}">${escapeHtml(page.title || page.path)}</a>
|
||||
<span class="toc-path">${escapeHtml(page.path)}</span>
|
||||
</li>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<section class="toc">
|
||||
<h1>Contents</h1>
|
||||
<ul class="toc-list">${entries}</ul>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
|
||||
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(`<a href="${escapeHtml(page.onlineUrl)}">View online</a>`)
|
||||
|
||||
const content = page.$.html().trim()
|
||||
const body = content
|
||||
? content
|
||||
: `<div class="empty-page">This page has no rendered content. <a href="${escapeHtml(
|
||||
page.onlineUrl,
|
||||
)}">Open it in the wiki</a>.</div>`
|
||||
|
||||
return `
|
||||
<section class="wiki-page" id="${page.sectionId}" data-wiki-path="${escapeHtml(page.path)}">
|
||||
<header class="page-header">
|
||||
<div class="page-kicker">Page ${page.index + 1} of ${total}</div>
|
||||
<h1 class="page-title">${escapeHtml(title)}</h1>
|
||||
<div class="page-meta">${metaParts.join('<span class="dot">·</span>')}</div>
|
||||
</header>
|
||||
<div class="wiki-content">${body}</div>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `<!doctype html>
|
||||
<html lang="${escapeHtml(locale)}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="generator" content="wiki-print">
|
||||
<title>${escapeHtml(title)} - Wiki export</title>
|
||||
<style>${PRINT_STYLES}</style>
|
||||
</head>
|
||||
<body>
|
||||
${renderCover({ startPage, startPath, pages: preparedPages, baseUrl, locale })}
|
||||
${includeToc ? renderToc(preparedPages) : ''}
|
||||
${sections}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
+225
@@ -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 }
|
||||
+143
@@ -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' } })
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 = `
|
||||
<div style="width:100%; padding:0 15mm; font-family: Arial, sans-serif; font-size:8px; color:#5b6775; display:flex; align-items:center; justify-content:space-between;">
|
||||
<span>__FOOTER_LABEL__</span>
|
||||
<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>
|
||||
</div>
|
||||
`
|
||||
|
||||
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: '<div></div>',
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
`
|
||||
+390
@@ -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 }
|
||||
+177
@@ -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)
|
||||
}
|
||||
@@ -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*</.test(text)) {
|
||||
throw new WikiError(
|
||||
'The configured URL answered with an HTML page instead of the Wiki.js GraphQL API. Check that the URL points to the root of a Wiki.js installation.',
|
||||
{ status: 502, code: 'WIKI_NOT_GRAPHQL' },
|
||||
)
|
||||
}
|
||||
throw new WikiError('Wiki.js returned an invalid JSON response.', {
|
||||
status: 502,
|
||||
code: 'WIKI_INVALID_RESPONSE',
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.errors) && payload.errors.length > 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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: `
|
||||
<h1 id="welcome">Docs</h1>
|
||||
<p><a href="#local">Local</a></p>
|
||||
<p><a href="/en/docs/child#sub-heading">Child heading</a></p>
|
||||
<p><a href="/en/docs/child">Child</a></p>
|
||||
<p><a href="/en/docs-other">Outside subtree</a></p>
|
||||
<p><a href="/de/docs">German docs</a></p>
|
||||
<p><a href="https://example.com/page">External</a></p>
|
||||
<span id="local">Anchor target</span>
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: 'docs/child',
|
||||
locale: 'en',
|
||||
title: 'Child',
|
||||
depth: 1,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
render: `
|
||||
<h2 id="sub-heading">Sub heading</h2>
|
||||
<h3>Auto heading</h3>
|
||||
<p aria-labelledby="sub-heading other-id"><a name="old-anchor">Back</a>
|
||||
<a href="/en/docs">Docs</a>
|
||||
<a href="/en/docs/child/grand#child-heading">Grand</a></p>
|
||||
<img src="/assets/pic.png" srcset="/assets/pic-2x.png 2x" loading="lazy" alt="Picture">
|
||||
<script>window.bad = true</script>
|
||||
<div onclick="alert(1)">Clickable</div>
|
||||
<iframe src="/embedded"></iframe>
|
||||
<form><input value="x"><p>Form content</p></form>
|
||||
`,
|
||||
},
|
||||
{
|
||||
path: 'docs/child/grand',
|
||||
locale: 'en',
|
||||
title: 'Grand',
|
||||
depth: 2,
|
||||
updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
render: `<h2 id="child-heading">Grand child heading</h2>`,
|
||||
},
|
||||
]
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,264 @@
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
const logo = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
|
||||
<rect width="120" height="40" rx="6" fill="#0b63b6"/>
|
||||
<text x="60" y="26" text-anchor="middle" font-family="Arial" font-size="16" fill="white">WIKI</text>
|
||||
</svg>`
|
||||
|
||||
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: `
|
||||
<h1 id="welcome">Documentation</h1>
|
||||
<p>Start with <a href="/en/docs/install">Install</a>. The Linux details are in
|
||||
<a href="/en/docs/install/linux#requirements">Linux requirements</a>.</p>
|
||||
<p><a href="#welcome">Jump to welcome</a></p>
|
||||
<p><a href="https://example.com/docs">External docs</a></p>
|
||||
<p><a href="/en/guide">Outside guide</a></p>
|
||||
<p><a href="/en/docs-other">Excluded sibling</a></p>
|
||||
<img src="/assets/logo.svg" alt="Wiki logo">
|
||||
<script>window.evil = true</script>
|
||||
<iframe src="https://example.com/embed"></iframe>
|
||||
<p>DOCS_MARKER</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
path: 'docs/install',
|
||||
locale: 'en',
|
||||
title: 'Installation',
|
||||
description: 'Install guide',
|
||||
contentType: 'markdown',
|
||||
isPublished: true,
|
||||
updatedAt: '2026-01-03T10:00:00.000Z',
|
||||
render: `
|
||||
<h1 id="install">Installation</h1>
|
||||
<p>Back to <a href="/en/docs">Documentation</a>.</p>
|
||||
<p>Choose <a href="/en/docs/install/linux">Linux</a>.</p>
|
||||
<p>INSTALL_MARKER</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
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: `
|
||||
<h1 id="linux">Linux</h1>
|
||||
<h2 id="requirements">Requirements</h2>
|
||||
<ul><li>Node.js 22</li><li>PostgreSQL</li></ul>
|
||||
<p><a href="/en/docs/install">Back to installation</a></p>
|
||||
<p>LINUX_MARKER</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
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: `<h1>Other notes</h1><p>OTHER_MARKER</p>`,
|
||||
},
|
||||
{
|
||||
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: `<h1>Draft page</h1><p>DRAFT_MARKER</p>`,
|
||||
},
|
||||
{
|
||||
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: `<h1>Excluded sibling</h1><p>EXCLUDED_SIBLING_MARKER</p>`,
|
||||
},
|
||||
{
|
||||
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: `<h1>Outside guide page</h1><p>GUIDE_OUTSIDE_MARKER</p>`,
|
||||
},
|
||||
]
|
||||
|
||||
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)),
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
)
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -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: '<h1>Docs</h1>',
|
||||
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, '<h1>Docs</h1>')
|
||||
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()
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user