Initial commit

This commit is contained in:
juergen committed 2026-11-13 11:50:48 +01:00
commit 2683eae6b3
36 files changed
+7369

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
node_modules
npm-debug.log*
.git
.gitignore
.DS_Store
*.pdf
wiki-print-test-*
+31
View File
@@ -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
+58
View File
@@ -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
+18
View File
@@ -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.
+23
View File
@@ -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
+264
View File
@@ -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.
+1553
View File
File diff suppressed because it is too large. Load diff
+24
View File
@@ -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"
}
}
+67
View File
@@ -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
View File
@@ -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()
+126
View File
@@ -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>
+473
View File
@@ -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;
}
}
+17
View File
@@ -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}."
+18
View File
@@ -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."
+10
View File
@@ -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"
+14
View File
@@ -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
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
}
+591
View File
@@ -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 &middot; 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">&middot;</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
View File
@@ -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
View File
@@ -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' } })
+210
View File
@@ -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,
}
}
+157
View File
@@ -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
}
}
}
+278
View File
@@ -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
View File
@@ -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
View File
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
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)
}
+304
View File
@@ -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
}
}
+201
View File
@@ -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)
}
})
+171
View File
@@ -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)
})
+81
View File
@@ -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)
})
+264
View File
@@ -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)),
}
}
+165
View File
@@ -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'],
)
})
+100
View File
@@ -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 })
}
},
)
+158
View File
@@ -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()
}
})