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

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
}
}