Initial commit
This commit is contained in:
commit
2683eae6b3
36 files changed
+7369
No files matched your search
Executable
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env node
|
||||
import { exportWikiToPdf } from './exporter.js'
|
||||
import { createLogger } from './logger.js'
|
||||
import { closeBrowser } from './pdf-renderer.js'
|
||||
import { toPositiveInt } from './utils.js'
|
||||
|
||||
// Keep the CLI's live progress line readable by default. Set LOG_LEVEL=info
|
||||
// or LOG_LEVEL=debug to see the structured export logs as well.
|
||||
const logger = createLogger({
|
||||
level: process.env.LOG_LEVEL || 'warn',
|
||||
bindings: { app: 'wiki-print', mode: 'cli' },
|
||||
})
|
||||
|
||||
function usage() {
|
||||
console.log(`Usage: npm run cli -- --url <wiki-url> --start <page> [options]
|
||||
|
||||
Required:
|
||||
--url <url> Wiki.js base URL (also WIKI_URL)
|
||||
--start <path|url> Starting page path or full page URL (also WIKI_START_PAGE)
|
||||
--api-key <key> Wiki.js API key (also WIKI_API_KEY)
|
||||
|
||||
Options:
|
||||
--locale <code> Page locale, e.g. en or de-ch
|
||||
--output <file> Output PDF path (default: a temp file)
|
||||
--max-pages <n> Safety limit (default: 500)
|
||||
--include-unpublished
|
||||
--no-toc
|
||||
--no-images
|
||||
-h, --help
|
||||
`)
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
url: process.env.WIKI_URL || '',
|
||||
start: process.env.WIKI_START_PAGE || '',
|
||||
apiKey: process.env.WIKI_API_KEY || '',
|
||||
locale: process.env.WIKI_LOCALE || '',
|
||||
output: '',
|
||||
maxPages: 500,
|
||||
includeUnpublished: false,
|
||||
includeToc: true,
|
||||
inlineImages: true,
|
||||
}
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index]
|
||||
const takeValue = () => {
|
||||
index += 1
|
||||
if (index >= argv.length) throw new Error(`Missing value for ${argument}`)
|
||||
return argv[index]
|
||||
}
|
||||
|
||||
switch (argument) {
|
||||
case '--url':
|
||||
args.url = takeValue()
|
||||
break
|
||||
case '--start':
|
||||
args.start = takeValue()
|
||||
break
|
||||
case '--api-key':
|
||||
args.apiKey = takeValue()
|
||||
break
|
||||
case '--locale':
|
||||
args.locale = takeValue()
|
||||
break
|
||||
case '--output':
|
||||
args.output = takeValue()
|
||||
break
|
||||
case '--max-pages':
|
||||
args.maxPages = toPositiveInt(takeValue(), 500, { min: 1, max: 5000 })
|
||||
break
|
||||
case '--include-unpublished':
|
||||
args.includeUnpublished = true
|
||||
break
|
||||
case '--no-toc':
|
||||
args.includeToc = false
|
||||
break
|
||||
case '--no-images':
|
||||
args.inlineImages = false
|
||||
break
|
||||
case '-h':
|
||||
case '--help':
|
||||
usage()
|
||||
process.exit(0)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unknown option: ${argument}`)
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let args
|
||||
try {
|
||||
args = parseArgs(process.argv.slice(2))
|
||||
} catch (error) {
|
||||
console.error(error.message)
|
||||
usage()
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
if (!args.url || !args.start || !args.apiKey) {
|
||||
console.error('Error: --url, --start and --api-key are required (or set WIKI_URL, WIKI_START_PAGE and WIKI_API_KEY).')
|
||||
usage()
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
let exitCode = 0
|
||||
try {
|
||||
const result = await exportWikiToPdf({
|
||||
baseUrl: args.url,
|
||||
apiKey: args.apiKey,
|
||||
startPage: args.start,
|
||||
locale: args.locale || null,
|
||||
includeUnpublished: args.includeUnpublished,
|
||||
includeToc: args.includeToc,
|
||||
inlineImages: args.inlineImages,
|
||||
maxPages: args.maxPages,
|
||||
outputPath: args.output || undefined,
|
||||
logger,
|
||||
onProgress: (progress) => {
|
||||
if (progress.message) process.stderr.write(`\r${progress.message}${' '.repeat(12)}`)
|
||||
},
|
||||
onWarning: (warning) => {
|
||||
process.stderr.write(`\nWarning: ${warning}\n`)
|
||||
},
|
||||
})
|
||||
process.stderr.write('\n')
|
||||
console.log(result.pdfPath)
|
||||
} catch (error) {
|
||||
process.stderr.write('\n')
|
||||
console.error(`Export failed: ${error.message}`)
|
||||
exitCode = 1
|
||||
} finally {
|
||||
await closeBrowser()
|
||||
}
|
||||
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
main()
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import MarkdownIt from 'markdown-it'
|
||||
import { logger as rootLogger } from './logger.js'
|
||||
import { WikiClient } from './wiki-client.js'
|
||||
import { selectSubtree } from './page-selection.js'
|
||||
import { buildExportHtml } from './html-builder.js'
|
||||
import { renderPdf } from './pdf-renderer.js'
|
||||
import {
|
||||
AppError,
|
||||
escapeHtml,
|
||||
mapLimit,
|
||||
normalizeBaseUrl,
|
||||
sanitizeFilename,
|
||||
stripBasePath,
|
||||
toPositiveInt,
|
||||
} from './utils.js'
|
||||
|
||||
const markdown = new MarkdownIt({
|
||||
html: true,
|
||||
linkify: true,
|
||||
breaks: false,
|
||||
})
|
||||
|
||||
function renderSource(content, contentType) {
|
||||
const type = String(contentType || '').toLowerCase()
|
||||
if (type === 'markdown' || type === 'md') {
|
||||
return markdown.render(content)
|
||||
}
|
||||
if (type === 'html' || type === 'ckeditor' || type === 'code') {
|
||||
return content
|
||||
}
|
||||
return `<pre>${escapeHtml(content)}</pre>`
|
||||
}
|
||||
|
||||
function inferNamespacing(startPage, baseUrl, locale) {
|
||||
const input = String(startPage || '').trim()
|
||||
if (!/^https?:\/\//i.test(input)) return null
|
||||
try {
|
||||
const url = new URL(input)
|
||||
const pathname = stripBasePath(url.pathname || '/', baseUrl.pathname)
|
||||
const first = pathname.split('/').filter(Boolean)[0]
|
||||
if (!first) return false
|
||||
return first.toLowerCase() === String(locale).toLowerCase()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function timestampForFilename(date = new Date()) {
|
||||
return date.toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15)
|
||||
}
|
||||
|
||||
export async function exportWikiToPdf({
|
||||
baseUrl: rawBaseUrl,
|
||||
apiKey,
|
||||
startPage,
|
||||
locale,
|
||||
includeUnpublished = false,
|
||||
includeToc = true,
|
||||
inlineImages = true,
|
||||
maxPages = 500,
|
||||
concurrency = 4,
|
||||
outputPath,
|
||||
logger = rootLogger,
|
||||
onProgress = () => {},
|
||||
onWarning = () => {},
|
||||
}) {
|
||||
const log = logger.child({ component: 'exporter' })
|
||||
const startedAt = Date.now()
|
||||
const baseUrl = normalizeBaseUrl(rawBaseUrl)
|
||||
const limit = toPositiveInt(maxPages, 500, { min: 1, max: 5000 })
|
||||
const warnings = []
|
||||
const warningSet = new Set()
|
||||
|
||||
function warn(message) {
|
||||
if (warningSet.has(message)) return
|
||||
warningSet.add(message)
|
||||
warnings.push(message)
|
||||
onWarning(message)
|
||||
}
|
||||
|
||||
let tempDir = null
|
||||
let finalPath = outputPath || null
|
||||
|
||||
log.info('export beginning', {
|
||||
wikiUrl: baseUrl.href,
|
||||
startPage,
|
||||
locale: locale || null,
|
||||
maxPages: limit,
|
||||
includeUnpublished,
|
||||
includeToc,
|
||||
inlineImages,
|
||||
pageFetchConcurrency: Math.max(1, Math.min(concurrency, 8)),
|
||||
})
|
||||
|
||||
try {
|
||||
onProgress({ stage: 'connect', message: 'Connecting to Wiki.js…' })
|
||||
const client = new WikiClient({ baseUrl, apiKey, logger: log })
|
||||
|
||||
const localization = await client.getLocalization()
|
||||
log.debug('wiki localization loaded', {
|
||||
defaultLocale: localization?.locale || null,
|
||||
namespacing: localization?.namespacing ?? null,
|
||||
namespaceCount: localization?.namespaces?.length || 0,
|
||||
})
|
||||
|
||||
onProgress({ stage: 'list', message: 'Reading the page list…' })
|
||||
const listStartedAt = Date.now()
|
||||
const listedPages = await client.listPages()
|
||||
log.info('page list loaded', {
|
||||
pageCount: listedPages.length,
|
||||
localeCount: new Set(listedPages.map((page) => page.locale)).size,
|
||||
durationMs: Date.now() - listStartedAt,
|
||||
})
|
||||
|
||||
onProgress({ stage: 'select', message: 'Finding the starting page and its subpages…' })
|
||||
const selection = selectSubtree({
|
||||
pages: listedPages,
|
||||
baseUrl,
|
||||
startPage,
|
||||
locale,
|
||||
defaultLocale: localization?.locale,
|
||||
includeUnpublished,
|
||||
maxPages: limit,
|
||||
})
|
||||
|
||||
if (selection.omittedUnpublished > 0) {
|
||||
warn(
|
||||
`Skipped ${selection.omittedUnpublished} unpublished page${
|
||||
selection.omittedUnpublished === 1 ? '' : 's'
|
||||
} below the starting page. Enable "Include unpublished pages" to export them.`,
|
||||
)
|
||||
}
|
||||
|
||||
const total = selection.pages.length
|
||||
log.info('subtree selected', {
|
||||
startPath: selection.startPath,
|
||||
locale: selection.locale,
|
||||
pageCount: total,
|
||||
omittedUnpublished: selection.omittedUnpublished,
|
||||
})
|
||||
onProgress({
|
||||
stage: 'fetch',
|
||||
message: `Found ${total} page${total === 1 ? '' : 's'}. Fetching content…`,
|
||||
pagesFound: total,
|
||||
pagesDone: 0,
|
||||
})
|
||||
|
||||
let pagesDone = 0
|
||||
const detailedPages = await mapLimit(selection.pages, Math.max(1, Math.min(concurrency, 8)), async (page) => {
|
||||
const isStartPage = page.path === selection.startPath
|
||||
const pageStartedAt = Date.now()
|
||||
let sourceFallback = false
|
||||
|
||||
try {
|
||||
const detail = await client.getPage(page.path, selection.locale)
|
||||
if (!detail) {
|
||||
throw new AppError(`Wiki.js returned no data for "${page.path}".`, {
|
||||
status: 404,
|
||||
code: 'PAGE_NOT_FOUND',
|
||||
})
|
||||
}
|
||||
|
||||
let render = detail.render || ''
|
||||
if (!render) {
|
||||
try {
|
||||
const source = await client.getPageSource(detail.id)
|
||||
if (source?.content) {
|
||||
render = renderSource(source.content, source.contentType || detail.contentType)
|
||||
sourceFallback = true
|
||||
}
|
||||
} catch (error) {
|
||||
// read:source is optional. Keep the placeholder below.
|
||||
log.debug('page source fallback unavailable', {
|
||||
path: page.path,
|
||||
code: error.code || null,
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!render) {
|
||||
warn(`Page "${page.path}" has no rendered content. It is included as a link-only page.`)
|
||||
}
|
||||
|
||||
log.debug('page fetched', {
|
||||
path: page.path,
|
||||
durationMs: Date.now() - pageStartedAt,
|
||||
renderBytes: render.length,
|
||||
sourceFallback,
|
||||
})
|
||||
|
||||
return {
|
||||
...page,
|
||||
...detail,
|
||||
path: page.path,
|
||||
locale: selection.locale,
|
||||
render,
|
||||
}
|
||||
} catch (error) {
|
||||
if (isStartPage) {
|
||||
throw new AppError(`Could not read the starting page "${page.path}": ${error.message}`, {
|
||||
status: error.status || 502,
|
||||
code: error.code || 'START_PAGE_READ_FAILED',
|
||||
})
|
||||
}
|
||||
log.warn('page skipped', {
|
||||
path: page.path,
|
||||
durationMs: Date.now() - pageStartedAt,
|
||||
code: error.code || null,
|
||||
error: error.message,
|
||||
})
|
||||
warn(`Could not export page "${page.path}": ${error.message}`)
|
||||
return {
|
||||
...page,
|
||||
locale: selection.locale,
|
||||
render: '',
|
||||
}
|
||||
} finally {
|
||||
pagesDone += 1
|
||||
onProgress({
|
||||
stage: 'fetch',
|
||||
message: `Fetched ${pagesDone} of ${total} pages…`,
|
||||
pagesFound: total,
|
||||
pagesDone,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onProgress({ stage: 'build', message: 'Building the printable document…' })
|
||||
const buildStartedAt = Date.now()
|
||||
const namespacing =
|
||||
typeof localization?.namespacing === 'boolean'
|
||||
? localization.namespacing
|
||||
: inferNamespacing(startPage, baseUrl, selection.locale)
|
||||
|
||||
const html = await buildExportHtml({
|
||||
pages: detailedPages,
|
||||
baseUrl,
|
||||
locale: selection.locale,
|
||||
startPath: selection.startPath,
|
||||
startPage: selection.startPage,
|
||||
localization,
|
||||
namespacing,
|
||||
includeToc,
|
||||
inlineImages,
|
||||
apiKey,
|
||||
onWarning: warn,
|
||||
logger: log,
|
||||
})
|
||||
log.info('printable document built', {
|
||||
pageCount: total,
|
||||
htmlBytes: Buffer.byteLength(html),
|
||||
namespacing,
|
||||
includeToc,
|
||||
inlineImages,
|
||||
durationMs: Date.now() - buildStartedAt,
|
||||
})
|
||||
|
||||
if (!finalPath) {
|
||||
tempDir = await mkdtemp(path.join(os.tmpdir(), 'wiki-print-'))
|
||||
const filename = `${sanitizeFilename(selection.startPath)}-wiki-${timestampForFilename()}.pdf`
|
||||
finalPath = path.join(tempDir, filename)
|
||||
} else {
|
||||
await mkdir(path.dirname(finalPath), { recursive: true })
|
||||
}
|
||||
|
||||
onProgress({ stage: 'render', message: 'Rendering the PDF with Chromium…' })
|
||||
const renderStartedAt = Date.now()
|
||||
await renderPdf({
|
||||
html,
|
||||
outputPath: finalPath,
|
||||
footerLabel: selection.startPage?.title || selection.startPath,
|
||||
logger: log,
|
||||
})
|
||||
log.info('PDF rendered', {
|
||||
filename: path.basename(finalPath),
|
||||
pageCount: total,
|
||||
durationMs: Date.now() - renderStartedAt,
|
||||
})
|
||||
|
||||
onProgress({
|
||||
stage: 'done',
|
||||
message: `PDF ready with ${total} page${total === 1 ? '' : 's'}.`,
|
||||
pagesFound: total,
|
||||
pagesDone: total,
|
||||
})
|
||||
log.debug('export complete', {
|
||||
durationMs: Date.now() - startedAt,
|
||||
pageCount: total,
|
||||
warningCount: warnings.length,
|
||||
})
|
||||
|
||||
return {
|
||||
pdfPath: finalPath,
|
||||
filename: path.basename(finalPath),
|
||||
tempDir,
|
||||
pageCount: total,
|
||||
locale: selection.locale,
|
||||
startPath: selection.startPath,
|
||||
title: selection.startPage?.title || selection.startPath,
|
||||
warnings,
|
||||
omittedUnpublished: selection.omittedUnpublished,
|
||||
}
|
||||
} catch (error) {
|
||||
if (finalPath) {
|
||||
await rm(finalPath, { force: true }).catch(() => {})
|
||||
}
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import * as cheerio from 'cheerio'
|
||||
import {
|
||||
escapeHtml,
|
||||
mapLimit,
|
||||
safeDecode,
|
||||
stripBasePath,
|
||||
} from './utils.js'
|
||||
import { PRINT_STYLES } from './print-styles.js'
|
||||
|
||||
const EMBED_SELECTOR = 'iframe, embed, object'
|
||||
const MEDIA_SELECTOR = 'video, audio'
|
||||
const DROPPED_SELECTOR = 'script, style, link, meta, base'
|
||||
const INTERACTIVE_SELECTOR = 'input, button, select, textarea'
|
||||
const REFERENCE_ATTRIBUTES = [
|
||||
'for',
|
||||
'aria-labelledby',
|
||||
'aria-describedby',
|
||||
'aria-controls',
|
||||
'headers',
|
||||
'list',
|
||||
]
|
||||
|
||||
function encodePath(path) {
|
||||
return String(path)
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
export function buildPageUrl({ baseUrl, page, locale, namespacing = true }) {
|
||||
if (namespacing) {
|
||||
if (page.path === 'home') {
|
||||
return `${baseUrl.href}/${encodeURIComponent(locale)}`
|
||||
}
|
||||
return `${baseUrl.href}/${encodeURIComponent(locale)}/${encodePath(page.path)}`
|
||||
}
|
||||
|
||||
if (page.path === 'home') {
|
||||
return `${baseUrl.href}/`
|
||||
}
|
||||
return `${baseUrl.href}/${encodePath(page.path)}`
|
||||
}
|
||||
|
||||
function slugifyHeading(text) {
|
||||
return String(text || '')
|
||||
.normalize('NFKC')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\p{L}\p{N}\s_-]/gu, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
function ensureHeadingIds($) {
|
||||
const used = new Set()
|
||||
$('h1, h2, h3, h4, h5, h6').each((_, element) => {
|
||||
const $heading = $(element)
|
||||
let id = $heading.attr('id')
|
||||
if (!id) {
|
||||
const base = slugifyHeading($heading.text()) || 'section'
|
||||
id = base
|
||||
let suffix = 2
|
||||
while (used.has(id)) {
|
||||
id = `${base}-${suffix}`
|
||||
suffix += 1
|
||||
}
|
||||
$heading.attr('id', id)
|
||||
}
|
||||
used.add(id)
|
||||
})
|
||||
}
|
||||
|
||||
function sanitizeContent($, { currentPageUrl }) {
|
||||
$(DROPPED_SELECTOR).remove()
|
||||
$('noscript').each((_, element) => {
|
||||
$(element).replaceWith($(element).contents())
|
||||
})
|
||||
|
||||
$(EMBED_SELECTOR).each((_, element) => {
|
||||
const $element = $(element)
|
||||
const rawSource = $element.attr('src') || $element.attr('data')
|
||||
let label = 'Embedded content'
|
||||
if (rawSource) {
|
||||
let href = rawSource
|
||||
try {
|
||||
href = new URL(rawSource, currentPageUrl).href
|
||||
} catch {
|
||||
// Keep the original source as text if it cannot be resolved.
|
||||
}
|
||||
label = `Embedded content: <a href="${escapeHtml(href)}">${escapeHtml(href)}</a>`
|
||||
}
|
||||
$element.replaceWith(`<p class="pdf-embed">${label}</p>`)
|
||||
})
|
||||
|
||||
$(MEDIA_SELECTOR).each((_, element) => {
|
||||
const $element = $(element)
|
||||
const rawSource = $element.attr('src') || $element.find('source').first().attr('src')
|
||||
let label = 'Media'
|
||||
if (rawSource) {
|
||||
let href = rawSource
|
||||
try {
|
||||
href = new URL(rawSource, currentPageUrl).href
|
||||
} catch {
|
||||
// Keep original source.
|
||||
}
|
||||
label = `Media: <a href="${escapeHtml(href)}">${escapeHtml(href)}</a>`
|
||||
}
|
||||
$element.replaceWith(`<p class="pdf-embed">${label}</p>`)
|
||||
})
|
||||
|
||||
$('form').each((_, element) => {
|
||||
$(element).replaceWith($(element).contents())
|
||||
})
|
||||
$(INTERACTIVE_SELECTOR).remove()
|
||||
|
||||
$('*').each((_, element) => {
|
||||
if (!element.attribs) return
|
||||
for (const attribute of Object.keys(element.attribs)) {
|
||||
if (/^on/i.test(attribute) || attribute === 'contenteditable' || attribute === 'srcdoc') {
|
||||
delete element.attribs[attribute]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
$('a[href]').each((_, element) => {
|
||||
const href = String($(element).attr('href') || '').trim()
|
||||
if (/^(javascript|vbscript):/i.test(href) || /^data:text\/html/i.test(href) || /^file:/i.test(href)) {
|
||||
$(element).removeAttr('href')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function prefixIds($, pageIndex) {
|
||||
const prefix = `wp${pageIndex}-`
|
||||
const idMap = new Map()
|
||||
|
||||
$('[id], a[name]').each((_, element) => {
|
||||
const $element = $(element)
|
||||
const originalId = $element.attr('id')
|
||||
if (originalId) {
|
||||
const prefixed = `${prefix}${originalId}`
|
||||
idMap.set(originalId, prefixed)
|
||||
$element.attr('id', prefixed)
|
||||
}
|
||||
const originalName = $element.attr('name')
|
||||
if (originalName) {
|
||||
const prefixed = `${prefix}${originalName}`
|
||||
$element.attr('name', prefixed)
|
||||
if (!idMap.has(originalName)) idMap.set(originalName, prefixed)
|
||||
}
|
||||
})
|
||||
|
||||
$('*').each((_, element) => {
|
||||
if (!element.attribs) return
|
||||
for (const attribute of REFERENCE_ATTRIBUTES) {
|
||||
const value = element.attribs[attribute]
|
||||
if (!value) continue
|
||||
const rewritten = value
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((token) => idMap.get(token) || token)
|
||||
.join(' ')
|
||||
element.attribs[attribute] = rewritten
|
||||
}
|
||||
})
|
||||
|
||||
return idMap
|
||||
}
|
||||
|
||||
function preparePage(page, index, context) {
|
||||
const sectionId = `wiki-page-${index}`
|
||||
const onlineUrl = buildPageUrl({
|
||||
baseUrl: context.baseUrl,
|
||||
page,
|
||||
locale: context.locale,
|
||||
namespacing: context.namespacing,
|
||||
})
|
||||
|
||||
const $ = cheerio.load(page.render || page.html || '', null, false)
|
||||
sanitizeContent($, { currentPageUrl: onlineUrl })
|
||||
ensureHeadingIds($)
|
||||
const idMap = prefixIds($, index)
|
||||
|
||||
return {
|
||||
...page,
|
||||
index,
|
||||
$,
|
||||
idMap,
|
||||
onlineUrl,
|
||||
sectionId,
|
||||
}
|
||||
}
|
||||
|
||||
function matchPageUrl(url, context) {
|
||||
if (url.origin !== context.baseUrl.origin) return null
|
||||
|
||||
let pathname = safeDecode(stripBasePath(url.pathname || '/', context.baseUrl.pathname))
|
||||
let segments = pathname.split('/').filter(Boolean)
|
||||
const candidates = []
|
||||
|
||||
function addCandidate(parts) {
|
||||
const candidate = parts.join('/') || 'home'
|
||||
if (!candidates.includes(candidate)) candidates.push(candidate)
|
||||
}
|
||||
|
||||
addCandidate(segments)
|
||||
|
||||
if (segments.length > 0) {
|
||||
const first = segments[0].toLowerCase()
|
||||
if (context.knownLocales.has(first) && first === context.locale) {
|
||||
addCandidate(segments.slice(1))
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const page = context.pageByPath.get(candidate)
|
||||
if (page) return page
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function rewriteLinks(preparedPages, context) {
|
||||
for (const page of preparedPages) {
|
||||
page.$('a[href]').each((_, element) => {
|
||||
const $link = page.$
|
||||
const $element = $link(element)
|
||||
const rawHref = String($element.attr('href') || '').trim()
|
||||
if (!rawHref) return
|
||||
|
||||
if (/^(mailto|tel|sms|ftp):/i.test(rawHref)) return
|
||||
if (/^(javascript|vbscript|file):/i.test(rawHref) || /^data:/i.test(rawHref)) {
|
||||
$element.removeAttr('href')
|
||||
return
|
||||
}
|
||||
|
||||
let resolved
|
||||
try {
|
||||
resolved = new URL(rawHref, page.onlineUrl)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') {
|
||||
return
|
||||
}
|
||||
|
||||
const target = matchPageUrl(resolved, context)
|
||||
if (!target) {
|
||||
// Keep links to pages outside this export (and all other web links)
|
||||
// as real, clickable online URLs.
|
||||
$element.attr('href', resolved.href)
|
||||
$element.removeAttr('target')
|
||||
$element.addClass('wiki-print-external')
|
||||
return
|
||||
}
|
||||
|
||||
let anchor = target.sectionId
|
||||
const fragment = safeDecode(resolved.hash.replace(/^#/, ''))
|
||||
if (fragment) {
|
||||
const mapped = target.idMap.get(fragment) || target.idMap.get(fragment.toLowerCase())
|
||||
if (mapped) anchor = mapped
|
||||
}
|
||||
|
||||
$element.attr('href', `#${anchor}`)
|
||||
$element.attr('data-wiki-path', target.path)
|
||||
$element.removeAttr('target')
|
||||
$element.addClass('wiki-print-internal')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function guessMimeFromUrl(url) {
|
||||
const pathname = url.pathname.toLowerCase()
|
||||
if (pathname.endsWith('.png')) return 'image/png'
|
||||
if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) return 'image/jpeg'
|
||||
if (pathname.endsWith('.gif')) return 'image/gif'
|
||||
if (pathname.endsWith('.webp')) return 'image/webp'
|
||||
if (pathname.endsWith('.svg')) return 'image/svg+xml'
|
||||
if (pathname.endsWith('.avif')) return 'image/avif'
|
||||
return null
|
||||
}
|
||||
|
||||
function createImageLoader({ baseUrl, apiKey, maxImageBytes, timeoutMs, onWarning }) {
|
||||
const cache = new Map()
|
||||
const warned = new Set()
|
||||
const stats = { embedded: 0, failed: 0, bytes: 0, external: 0 }
|
||||
let totalBytes = 0
|
||||
const totalLimit = 160 * 1024 * 1024
|
||||
|
||||
async function loadImage(rawSource, currentPageUrl) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(rawSource, currentPageUrl)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
||||
if (totalBytes >= totalLimit) return null
|
||||
|
||||
if (cache.has(url.href)) {
|
||||
return cache.get(url.href)
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const headers = {
|
||||
Accept: 'image/*,application/octet-stream;q=0.8,*/*;q=0.5',
|
||||
'User-Agent': 'wiki-print/1.0',
|
||||
}
|
||||
// Only send the wiki API key back to the wiki itself.
|
||||
if (url.origin === baseUrl.origin) {
|
||||
headers.Authorization = `Bearer ${apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(url.href, {
|
||||
headers,
|
||||
redirect: 'follow',
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const contentLength = Number(response.headers.get('content-length') || 0)
|
||||
if (contentLength > maxImageBytes) {
|
||||
throw new Error('image is too large')
|
||||
}
|
||||
|
||||
const contentType = (response.headers.get('content-type') || '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const mime = contentType.startsWith('image/')
|
||||
? contentType
|
||||
: guessMimeFromUrl(url)
|
||||
|
||||
if (!mime) {
|
||||
throw new Error('response is not an image')
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
if (buffer.length > maxImageBytes) {
|
||||
throw new Error('image is too large')
|
||||
}
|
||||
if (totalBytes + buffer.length > totalLimit) {
|
||||
throw new Error('image size budget exhausted')
|
||||
}
|
||||
|
||||
totalBytes += buffer.length
|
||||
stats.embedded += 1
|
||||
stats.bytes += buffer.length
|
||||
return `data:${mime};base64,${buffer.toString('base64')}`
|
||||
} catch (error) {
|
||||
stats.failed += 1
|
||||
if (onWarning && url.origin === baseUrl.origin && !warned.has(url.href)) {
|
||||
warned.add(url.href)
|
||||
onWarning(`Could not embed image ${url.href}: ${error.message}`)
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
})()
|
||||
|
||||
cache.set(url.href, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
return { loadImage, stats }
|
||||
}
|
||||
|
||||
async function processImages(preparedPages, context, shouldInline) {
|
||||
const loader = shouldInline ? createImageLoader(context) : null
|
||||
const loadImage = loader?.loadImage || null
|
||||
const stats = loader?.stats || { embedded: 0, failed: 0, bytes: 0, external: 0 }
|
||||
|
||||
for (const page of preparedPages) {
|
||||
page.$('picture source').remove()
|
||||
|
||||
const images = page.$('img').toArray()
|
||||
await mapLimit(images, 6, async (element) => {
|
||||
const $image = page.$(element)
|
||||
const rawSource =
|
||||
$image.attr('src') ||
|
||||
$image.attr('data-src') ||
|
||||
$image.attr('data-original') ||
|
||||
''
|
||||
|
||||
$image.removeAttr('srcset')
|
||||
$image.removeAttr('sizes')
|
||||
$image.removeAttr('loading')
|
||||
$image.removeAttr('decoding')
|
||||
$image.removeAttr('crossorigin')
|
||||
$image.removeAttr('integrity')
|
||||
|
||||
if (!rawSource) {
|
||||
$image.remove()
|
||||
return
|
||||
}
|
||||
|
||||
if (/^data:/i.test(rawSource)) {
|
||||
stats.embedded += 1
|
||||
$image.attr('src', rawSource)
|
||||
return
|
||||
}
|
||||
|
||||
let absolute = rawSource
|
||||
try {
|
||||
absolute = new URL(rawSource, page.onlineUrl).href
|
||||
} catch {
|
||||
$image.remove()
|
||||
return
|
||||
}
|
||||
|
||||
if (!/^https?:/i.test(absolute)) {
|
||||
$image.remove()
|
||||
return
|
||||
}
|
||||
|
||||
const dataUri = loadImage ? await loadImage(absolute, page.onlineUrl) : null
|
||||
if (!dataUri) stats.external += 1
|
||||
$image.attr('src', dataUri || absolute)
|
||||
})
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
function renderCover({ startPage, startPath, pages, baseUrl, locale }) {
|
||||
const title = startPage?.title || startPath
|
||||
const subtitle =
|
||||
pages.length > 1
|
||||
? `Starting page plus ${pages.length - 1} page${pages.length - 1 === 1 ? '' : 's'} below it`
|
||||
: 'Single page export'
|
||||
const generated = `${new Date().toISOString().replace('T', ' ').slice(0, 16)} UTC`
|
||||
|
||||
return `
|
||||
<section class="cover">
|
||||
<div class="eyebrow">Wiki.js · PDF export</div>
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p class="subtitle">${escapeHtml(subtitle)}</p>
|
||||
<div class="rule"></div>
|
||||
<dl>
|
||||
<dt>Source</dt>
|
||||
<dd>${escapeHtml(baseUrl.href)}</dd>
|
||||
<dt>Starting page</dt>
|
||||
<dd>${escapeHtml(startPath)}</dd>
|
||||
<dt>Locale</dt>
|
||||
<dd>${escapeHtml(locale)}</dd>
|
||||
<dt>Pages</dt>
|
||||
<dd>${pages.length}</dd>
|
||||
<dt>Generated</dt>
|
||||
<dd>${escapeHtml(generated)}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
|
||||
function renderToc(pages) {
|
||||
const entries = pages
|
||||
.map((page) => {
|
||||
const depth = Math.max(0, Math.min(page.depth || 0, 8))
|
||||
return `
|
||||
<li class="toc-entry" style="--depth:${depth}">
|
||||
<a href="#${page.sectionId}">${escapeHtml(page.title || page.path)}</a>
|
||||
<span class="toc-path">${escapeHtml(page.path)}</span>
|
||||
</li>
|
||||
`
|
||||
})
|
||||
.join('')
|
||||
|
||||
return `
|
||||
<section class="toc">
|
||||
<h1>Contents</h1>
|
||||
<ul class="toc-list">${entries}</ul>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
|
||||
function renderPageSection(page, total) {
|
||||
const title = page.title || page.path
|
||||
const updated = page.updatedAt
|
||||
? new Date(page.updatedAt).toLocaleDateString('en-CA', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
})
|
||||
: null
|
||||
|
||||
const metaParts = [`Path: ${escapeHtml(page.path)}`]
|
||||
if (updated) metaParts.push(`Updated: ${escapeHtml(updated)}`)
|
||||
metaParts.push(`<a href="${escapeHtml(page.onlineUrl)}">View online</a>`)
|
||||
|
||||
const content = page.$.html().trim()
|
||||
const body = content
|
||||
? content
|
||||
: `<div class="empty-page">This page has no rendered content. <a href="${escapeHtml(
|
||||
page.onlineUrl,
|
||||
)}">Open it in the wiki</a>.</div>`
|
||||
|
||||
return `
|
||||
<section class="wiki-page" id="${page.sectionId}" data-wiki-path="${escapeHtml(page.path)}">
|
||||
<header class="page-header">
|
||||
<div class="page-kicker">Page ${page.index + 1} of ${total}</div>
|
||||
<h1 class="page-title">${escapeHtml(title)}</h1>
|
||||
<div class="page-meta">${metaParts.join('<span class="dot">·</span>')}</div>
|
||||
</header>
|
||||
<div class="wiki-content">${body}</div>
|
||||
</section>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one self-contained HTML document for Chromium's print-to-PDF mode.
|
||||
* Internal wiki links become in-document anchors, while links to anything
|
||||
* outside the exported subtree remain clickable online URLs.
|
||||
*/
|
||||
export async function buildExportHtml({
|
||||
pages,
|
||||
baseUrl,
|
||||
locale,
|
||||
startPath,
|
||||
startPage,
|
||||
localization,
|
||||
namespacing: namespacingOverride,
|
||||
includeToc = true,
|
||||
inlineImages: shouldInlineImages = true,
|
||||
apiKey,
|
||||
onWarning,
|
||||
logger,
|
||||
}) {
|
||||
const namespacing =
|
||||
typeof namespacingOverride === 'boolean'
|
||||
? namespacingOverride
|
||||
: typeof localization?.namespacing === 'boolean'
|
||||
? localization.namespacing
|
||||
: true
|
||||
|
||||
const knownLocales = new Set(
|
||||
(localization?.namespaces?.length ? localization.namespaces : pages.map((page) => page.locale))
|
||||
.filter(Boolean)
|
||||
.map((value) => String(value).toLowerCase()),
|
||||
)
|
||||
knownLocales.add(String(locale).toLowerCase())
|
||||
|
||||
const context = {
|
||||
baseUrl,
|
||||
locale: String(locale).toLowerCase(),
|
||||
namespacing,
|
||||
knownLocales,
|
||||
pageByPath: new Map(),
|
||||
apiKey,
|
||||
maxImageBytes: 10 * 1024 * 1024,
|
||||
timeoutMs: 20_000,
|
||||
onWarning,
|
||||
}
|
||||
|
||||
const preparedPages = pages.map((page, index) => preparePage(page, index, context))
|
||||
for (const page of preparedPages) {
|
||||
context.pageByPath.set(page.path, page)
|
||||
}
|
||||
|
||||
rewriteLinks(preparedPages, context)
|
||||
const imageStats = await processImages(preparedPages, context, shouldInlineImages)
|
||||
logger?.debug('document images processed', imageStats)
|
||||
|
||||
const title = startPage?.title || startPath
|
||||
const sections = preparedPages.map((page) => renderPageSection(page, preparedPages.length)).join('\n')
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="${escapeHtml(locale)}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="generator" content="wiki-print">
|
||||
<title>${escapeHtml(title)} - Wiki export</title>
|
||||
<style>${PRINT_STYLES}</style>
|
||||
</head>
|
||||
<body>
|
||||
${renderCover({ startPage, startPath, pages: preparedPages, baseUrl, locale })}
|
||||
${includeToc ? renderToc(preparedPages) : ''}
|
||||
${sections}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import { logger as rootLogger } from './logger.js'
|
||||
import { AppError } from './utils.js'
|
||||
import { exportWikiToPdf } from './exporter.js'
|
||||
|
||||
const TERMINAL_STATUSES = new Set(['done', 'error'])
|
||||
|
||||
function publicJob(job) {
|
||||
return {
|
||||
id: job.id,
|
||||
status: job.status,
|
||||
stage: job.stage,
|
||||
message: job.message,
|
||||
pagesFound: job.pagesFound,
|
||||
pagesDone: job.pagesDone,
|
||||
warnings: job.warnings,
|
||||
error: job.error,
|
||||
createdAt: job.createdAt,
|
||||
updatedAt: job.updatedAt,
|
||||
finishedAt: job.finishedAt,
|
||||
result: job.result,
|
||||
request: job.request,
|
||||
}
|
||||
}
|
||||
|
||||
export class JobQueue {
|
||||
constructor({
|
||||
concurrency = 1,
|
||||
maxQueue = 5,
|
||||
ttlMs = 2 * 60 * 60 * 1000,
|
||||
logger = rootLogger,
|
||||
} = {}) {
|
||||
this.concurrency = Math.max(1, concurrency)
|
||||
this.maxQueue = Math.max(1, maxQueue)
|
||||
this.ttlMs = ttlMs
|
||||
this.logger = logger
|
||||
this.jobs = new Map()
|
||||
this.queue = []
|
||||
this.running = 0
|
||||
|
||||
this.cleanupTimer = setInterval(() => {
|
||||
this.cleanup().catch(() => {})
|
||||
}, 10 * 60 * 1000)
|
||||
this.cleanupTimer.unref?.()
|
||||
}
|
||||
|
||||
enqueue(input) {
|
||||
if (this.queue.length >= this.maxQueue) {
|
||||
throw new AppError('The export queue is full. Wait for a running export to finish and try again.', {
|
||||
status: 429,
|
||||
code: 'QUEUE_FULL',
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const jobId = randomUUID()
|
||||
const job = {
|
||||
id: jobId,
|
||||
status: 'queued',
|
||||
stage: 'queued',
|
||||
message: 'Waiting for the next free export slot…',
|
||||
pagesFound: 0,
|
||||
pagesDone: 0,
|
||||
warnings: [],
|
||||
error: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
finishedAt: null,
|
||||
result: null,
|
||||
request: {
|
||||
baseUrl: input.baseUrl,
|
||||
startPage: input.startPage,
|
||||
locale: input.locale || null,
|
||||
includeUnpublished: Boolean(input.includeUnpublished),
|
||||
includeToc: input.includeToc !== false,
|
||||
maxPages: input.maxPages,
|
||||
},
|
||||
filePath: null,
|
||||
tempDir: null,
|
||||
log: this.logger.child({ jobId }),
|
||||
}
|
||||
|
||||
this.jobs.set(job.id, job)
|
||||
this.queue.push({ job, input })
|
||||
job.log.info('export queued', {
|
||||
startPage: input.startPage,
|
||||
locale: input.locale || null,
|
||||
maxPages: input.maxPages,
|
||||
includeUnpublished: Boolean(input.includeUnpublished),
|
||||
includeToc: input.includeToc !== false,
|
||||
inlineImages: input.inlineImages !== false,
|
||||
queueDepth: this.queue.length,
|
||||
running: this.running,
|
||||
})
|
||||
this.pump()
|
||||
return publicJob(job)
|
||||
}
|
||||
|
||||
get(id) {
|
||||
const job = this.jobs.get(id)
|
||||
return job ? publicJob(job) : null
|
||||
}
|
||||
|
||||
getInternal(id) {
|
||||
return this.jobs.get(id) || null
|
||||
}
|
||||
|
||||
async pump() {
|
||||
while (this.running < this.concurrency && this.queue.length > 0) {
|
||||
const { job, input } = this.queue.shift()
|
||||
this.running += 1
|
||||
this.run(job, input)
|
||||
.catch((error) => {
|
||||
job.status = 'error'
|
||||
job.stage = 'error'
|
||||
job.message = 'The export failed.'
|
||||
job.error = {
|
||||
message: error.message || 'Unknown error',
|
||||
code: error.code || 'EXPORT_FAILED',
|
||||
details: error.details,
|
||||
}
|
||||
job.updatedAt = new Date().toISOString()
|
||||
job.finishedAt = job.updatedAt
|
||||
job.log.error('export failed', {
|
||||
durationMs: Date.parse(job.finishedAt) - Date.parse(job.createdAt),
|
||||
pagesDone: job.pagesDone,
|
||||
pagesFound: job.pagesFound,
|
||||
error,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
this.running -= 1
|
||||
this.pump()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async run(job, input) {
|
||||
const startedAt = Date.now()
|
||||
job.status = 'running'
|
||||
job.stage = 'connect'
|
||||
job.message = 'Starting export…'
|
||||
job.updatedAt = new Date().toISOString()
|
||||
job.log.info('export started')
|
||||
|
||||
const result = await exportWikiToPdf({
|
||||
...input,
|
||||
logger: job.log,
|
||||
onProgress: (progress) => {
|
||||
const previousStage = job.stage
|
||||
job.stage = progress.stage || job.stage
|
||||
job.message = progress.message || job.message
|
||||
if (Number.isFinite(progress.pagesFound)) job.pagesFound = progress.pagesFound
|
||||
if (Number.isFinite(progress.pagesDone)) job.pagesDone = progress.pagesDone
|
||||
job.updatedAt = new Date().toISOString()
|
||||
if (progress.stage && progress.stage !== previousStage) {
|
||||
job.log.debug('export stage', {
|
||||
stage: progress.stage,
|
||||
message: progress.message,
|
||||
pagesFound: job.pagesFound,
|
||||
pagesDone: job.pagesDone,
|
||||
})
|
||||
}
|
||||
},
|
||||
onWarning: (warning) => {
|
||||
if (!job.warnings.includes(warning)) {
|
||||
job.warnings.push(warning)
|
||||
job.log.warn('export warning', { warning })
|
||||
}
|
||||
job.updatedAt = new Date().toISOString()
|
||||
},
|
||||
})
|
||||
|
||||
job.filePath = result.pdfPath
|
||||
job.tempDir = result.tempDir
|
||||
job.result = {
|
||||
filename: result.filename,
|
||||
pageCount: result.pageCount,
|
||||
startPath: result.startPath,
|
||||
locale: result.locale,
|
||||
title: result.title,
|
||||
downloadUrl: `/api/exports/${job.id}/pdf`,
|
||||
}
|
||||
job.status = 'done'
|
||||
job.stage = 'done'
|
||||
job.message = result.warnings?.length
|
||||
? `PDF ready with ${result.pageCount} page${result.pageCount === 1 ? '' : 's'} and ${result.warnings.length} warning${result.warnings.length === 1 ? '' : 's'}.`
|
||||
: `PDF ready with ${result.pageCount} page${result.pageCount === 1 ? '' : 's'}.`
|
||||
job.updatedAt = new Date().toISOString()
|
||||
job.finishedAt = job.updatedAt
|
||||
job.log.info('export finished', {
|
||||
durationMs: Date.now() - startedAt,
|
||||
pageCount: result.pageCount,
|
||||
locale: result.locale,
|
||||
startPath: result.startPath,
|
||||
warningCount: result.warnings?.length || 0,
|
||||
filename: result.filename,
|
||||
})
|
||||
}
|
||||
|
||||
async cleanup(now = Date.now()) {
|
||||
for (const [id, job] of this.jobs) {
|
||||
if (!TERMINAL_STATUSES.has(job.status)) continue
|
||||
const finished = Date.parse(job.finishedAt || job.updatedAt || job.createdAt)
|
||||
if (!Number.isFinite(finished) || now - finished < this.ttlMs) continue
|
||||
|
||||
if (job.tempDir) {
|
||||
await rm(job.tempDir, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
this.jobs.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
clearInterval(this.cleanupTimer)
|
||||
for (const [, job] of this.jobs) {
|
||||
if (job.tempDir) {
|
||||
await rm(job.tempDir, { recursive: true, force: true }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { publicJob }
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
const LEVELS = Object.freeze({
|
||||
debug: 10,
|
||||
info: 20,
|
||||
warn: 30,
|
||||
error: 40,
|
||||
silent: 100,
|
||||
})
|
||||
|
||||
const REDACTED = '[redacted]'
|
||||
const SECRET_KEY_RE = /(^|_)(api[-_]?key|authorization|password|secret|token|credential)($|_)/i
|
||||
const MAX_STRING_LENGTH = 2000
|
||||
|
||||
function normalizeLevel(value, fallback = 'info') {
|
||||
const level = String(value ?? '').trim().toLowerCase()
|
||||
return Object.hasOwn(LEVELS, level) ? level : fallback
|
||||
}
|
||||
|
||||
function normalizeFormat(value) {
|
||||
return String(value ?? '').trim().toLowerCase() === 'json' ? 'json' : 'text'
|
||||
}
|
||||
|
||||
function sanitizeValue(value) {
|
||||
if (value instanceof Error) {
|
||||
return {
|
||||
name: value.name,
|
||||
message: value.message,
|
||||
code: value.code,
|
||||
status: value.status,
|
||||
stack: value.stack,
|
||||
}
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.length > MAX_STRING_LENGTH
|
||||
? `${value.slice(0, MAX_STRING_LENGTH)}… (${value.length - MAX_STRING_LENGTH} more characters)`
|
||||
: value
|
||||
}
|
||||
if (typeof value === 'bigint') return value.toString()
|
||||
if (Array.isArray(value)) return value.map(sanitizeValue)
|
||||
if (value && typeof value === 'object') {
|
||||
const result = {}
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
result[key] = SECRET_KEY_RE.test(key) ? REDACTED : sanitizeValue(entry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function formatScalar(value) {
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'string') {
|
||||
return /^[A-Za-z0-9_./:@[\]-]+$/.test(value) ? value : JSON.stringify(value)
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatText(timestamp, level, message, fields) {
|
||||
const parts = [timestamp, level.toUpperCase().padEnd(5), message]
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value === undefined) continue
|
||||
parts.push(`${key}=${formatScalar(value)}`)
|
||||
}
|
||||
return `${parts.join(' ')}\n`
|
||||
}
|
||||
|
||||
function formatJson(timestamp, level, message, fields) {
|
||||
return `${JSON.stringify({ timestamp, level, message, ...fields })}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a small dependency-free logger.
|
||||
*
|
||||
* LOG_LEVEL: debug | info | warn | error | silent (default: info)
|
||||
* LOG_FORMAT: text | json (default: text)
|
||||
*
|
||||
* All output goes to stderr so the CLI can keep stdout for the PDF path.
|
||||
*/
|
||||
export function createLogger({
|
||||
level = process.env.LOG_LEVEL,
|
||||
format = process.env.LOG_FORMAT,
|
||||
stream = process.stderr,
|
||||
bindings = {},
|
||||
} = {}) {
|
||||
const configuredLevel = normalizeLevel(level)
|
||||
const configuredFormat = normalizeFormat(format)
|
||||
const threshold = LEVELS[configuredLevel]
|
||||
const parentBindings = { ...bindings }
|
||||
|
||||
function write(logLevel, message, fields = {}) {
|
||||
if (LEVELS[logLevel] < threshold) return
|
||||
|
||||
const timestamp = new Date().toISOString()
|
||||
const merged = { ...parentBindings, ...fields }
|
||||
const sanitized = {}
|
||||
for (const [key, value] of Object.entries(merged)) {
|
||||
if (value === undefined) continue
|
||||
sanitized[key] = SECRET_KEY_RE.test(key) ? REDACTED : sanitizeValue(value)
|
||||
}
|
||||
|
||||
const line =
|
||||
configuredFormat === 'json'
|
||||
? formatJson(timestamp, logLevel, String(message), sanitized)
|
||||
: formatText(timestamp, logLevel, String(message), sanitized)
|
||||
|
||||
try {
|
||||
stream.write(line)
|
||||
} catch {
|
||||
// Logging must never break an export.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
level: configuredLevel,
|
||||
format: configuredFormat,
|
||||
bindings: parentBindings,
|
||||
isLevelEnabled(logLevel) {
|
||||
return LEVELS[logLevel] >= threshold
|
||||
},
|
||||
child(extraBindings = {}) {
|
||||
return createLogger({
|
||||
level: configuredLevel,
|
||||
format: configuredFormat,
|
||||
stream,
|
||||
bindings: { ...parentBindings, ...extraBindings },
|
||||
})
|
||||
},
|
||||
debug(message, fields) {
|
||||
write('debug', message, fields)
|
||||
},
|
||||
info(message, fields) {
|
||||
write('info', message, fields)
|
||||
},
|
||||
warn(message, fields) {
|
||||
write('warn', message, fields)
|
||||
},
|
||||
error(message, fields) {
|
||||
write('error', message, fields)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = createLogger({ bindings: { app: 'wiki-print' } })
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
AppError,
|
||||
comparePagePaths,
|
||||
looksLikeLocaleSegment,
|
||||
normalizePagePath,
|
||||
safeDecode,
|
||||
stripBasePath,
|
||||
} from './utils.js'
|
||||
|
||||
const ABSOLUTE_URL_RE = /^[a-z][a-z0-9+.-]*:\/\//i
|
||||
|
||||
function parseStartPage(startPage, baseUrl) {
|
||||
const input = String(startPage ?? '').trim()
|
||||
if (!input) {
|
||||
throw new AppError('The starting page is required.')
|
||||
}
|
||||
|
||||
let pathname
|
||||
let fromUrl = false
|
||||
|
||||
if (ABSOLUTE_URL_RE.test(input)) {
|
||||
let url
|
||||
try {
|
||||
url = new URL(input)
|
||||
} catch {
|
||||
throw new AppError('The starting page URL is not valid.')
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new AppError('The starting page URL must use http or https.')
|
||||
}
|
||||
fromUrl = true
|
||||
pathname = safeDecode(stripBasePath(url.pathname || '/', baseUrl.pathname))
|
||||
} else {
|
||||
pathname = input.split(/[?#]/, 1)[0]
|
||||
if (!pathname.startsWith('/')) {
|
||||
pathname = `/${pathname}`
|
||||
}
|
||||
pathname = safeDecode(stripBasePath(pathname, baseUrl.pathname))
|
||||
}
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
let localeHint = null
|
||||
if (segments.length > 0 && looksLikeLocaleSegment(segments[0])) {
|
||||
localeHint = segments[0].toLowerCase()
|
||||
}
|
||||
|
||||
return {
|
||||
path: normalizePagePath(pathname),
|
||||
pathname,
|
||||
segments,
|
||||
localeHint,
|
||||
fromUrl,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the requested starting page to one locale/path, then collect the
|
||||
* starting page plus every page whose path is below it.
|
||||
*
|
||||
* Wiki.js treats the locale as part of a page's identity, so a subtree never
|
||||
* crosses locales.
|
||||
*/
|
||||
export function selectSubtree({
|
||||
pages,
|
||||
baseUrl,
|
||||
startPage,
|
||||
locale,
|
||||
defaultLocale,
|
||||
includeUnpublished = false,
|
||||
maxPages = 500,
|
||||
}) {
|
||||
if (!Array.isArray(pages) || pages.length === 0) {
|
||||
throw new AppError('The API key can read no pages from this wiki.', {
|
||||
status: 403,
|
||||
code: 'WIKI_NO_PAGES',
|
||||
})
|
||||
}
|
||||
|
||||
const locales = [...new Set(pages.map((page) => page.locale).filter(Boolean))].sort()
|
||||
const explicitLocale = locale ? String(locale).trim().toLowerCase() : null
|
||||
|
||||
if (explicitLocale && !locales.includes(explicitLocale)) {
|
||||
throw new AppError(
|
||||
`Locale "${explicitLocale}" does not exist in this wiki. Available locales: ${locales.join(', ') || 'none'}.`,
|
||||
{ code: 'LOCALE_NOT_FOUND' },
|
||||
)
|
||||
}
|
||||
|
||||
const pageByKey = new Map()
|
||||
for (const page of pages) {
|
||||
if (!page?.path || !page?.locale) continue
|
||||
pageByKey.set(`${page.locale}\u0000${page.path}`, page)
|
||||
}
|
||||
|
||||
const parsed = parseStartPage(startPage, baseUrl)
|
||||
const candidates = []
|
||||
const seen = new Set()
|
||||
|
||||
function addCandidate(path, hintedLocale = null) {
|
||||
const normalized = normalizePagePath(path)
|
||||
const key = `${normalized}\u0000${hintedLocale || ''}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
candidates.push({ path: normalized, hintedLocale })
|
||||
}
|
||||
|
||||
addCandidate(parsed.path, parsed.localeHint)
|
||||
|
||||
const firstSegment = parsed.segments[0]
|
||||
if (firstSegment && looksLikeLocaleSegment(firstSegment)) {
|
||||
const remainder = parsed.segments.slice(1).join('/')
|
||||
addCandidate(remainder || 'home', firstSegment.toLowerCase())
|
||||
}
|
||||
|
||||
const localeHint = explicitLocale || parsed.localeHint || null
|
||||
const preferredLocale =
|
||||
localeHint ||
|
||||
(defaultLocale && locales.includes(String(defaultLocale).toLowerCase())
|
||||
? String(defaultLocale).toLowerCase()
|
||||
: null)
|
||||
|
||||
let startMatch = null
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const matches = []
|
||||
for (const candidateLocale of locales) {
|
||||
const page = pageByKey.get(`${candidateLocale}\u0000${candidate.path}`)
|
||||
if (page) matches.push({ page, locale: candidateLocale })
|
||||
}
|
||||
|
||||
if (matches.length === 0) continue
|
||||
|
||||
if (matches.length === 1) {
|
||||
startMatch = matches[0]
|
||||
break
|
||||
}
|
||||
|
||||
const hinted = candidate.hintedLocale || preferredLocale
|
||||
const preferredMatch = hinted ? matches.find((match) => match.locale === hinted) : null
|
||||
if (preferredMatch) {
|
||||
startMatch = preferredMatch
|
||||
break
|
||||
}
|
||||
|
||||
throw new AppError(
|
||||
`The page "${candidate.path}" exists in multiple locales (${matches
|
||||
.map((match) => match.locale)
|
||||
.join(', ')}). Choose a locale in the advanced options.`,
|
||||
{ code: 'AMBIGUOUS_LOCALE' },
|
||||
)
|
||||
}
|
||||
|
||||
if (!startMatch) {
|
||||
const localeText = explicitLocale ? ` in locale "${explicitLocale}"` : ''
|
||||
const shownPath = parsed.path
|
||||
throw new AppError(
|
||||
`Starting page "${shownPath}" was not found${localeText}. Use the page path as it appears in Wiki.js, without a leading slash. A browser URL like /en/docs/start becomes docs/start (or keep the full URL).`,
|
||||
{ status: 404, code: 'START_PAGE_NOT_FOUND' },
|
||||
)
|
||||
}
|
||||
|
||||
const selectedLocale = startMatch.locale
|
||||
const startPath = startMatch.page.path
|
||||
const prefix = `${startPath}/`
|
||||
|
||||
const allDescendants = pages
|
||||
.filter((page) => page.locale === selectedLocale)
|
||||
.filter((page) => page.path === startPath || page.path.startsWith(prefix))
|
||||
|
||||
const publishedDescendants = includeUnpublished
|
||||
? allDescendants
|
||||
: allDescendants.filter((page) => page.path === startPath || page.isPublished !== false)
|
||||
|
||||
const deduped = new Map()
|
||||
for (const page of publishedDescendants) {
|
||||
deduped.set(page.path, page)
|
||||
}
|
||||
|
||||
const selected = [...deduped.values()].sort((a, b) => {
|
||||
if (a.path === startPath) return -1
|
||||
if (b.path === startPath) return 1
|
||||
return comparePagePaths(a.path, b.path)
|
||||
})
|
||||
|
||||
if (selected.length > maxPages) {
|
||||
throw new AppError(
|
||||
`This export would contain ${selected.length} pages, which is above the configured limit of ${maxPages}. Raise the limit or choose a narrower starting page.`,
|
||||
{ status: 413, code: 'TOO_MANY_PAGES', details: { selected: selected.length, maxPages } },
|
||||
)
|
||||
}
|
||||
|
||||
const startDepth = startPath === 'home' ? 0 : startPath.split('/').length
|
||||
const enriched = selected.map((page) => {
|
||||
const depth = page.path === startPath ? 0 : Math.max(1, page.path.split('/').length - startDepth)
|
||||
return {
|
||||
...page,
|
||||
depth,
|
||||
relativePath: page.path === startPath ? '' : page.path.slice(prefix.length),
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
locale: selectedLocale,
|
||||
startPath,
|
||||
startPage: startMatch.page,
|
||||
pages: enriched,
|
||||
locales,
|
||||
omittedUnpublished: allDescendants.length - publishedDescendants.length,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { chromium } from 'playwright-core'
|
||||
import { logger as rootLogger } from './logger.js'
|
||||
import { AppError, escapeHtml } from './utils.js'
|
||||
|
||||
const CHROMIUM_CANDIDATES = [
|
||||
process.env.CHROMIUM_PATH,
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/opt/google/chrome/chrome',
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Chromium.app/Contents/MacOS/Chromium',
|
||||
].filter(Boolean)
|
||||
|
||||
function resolveChromiumPath() {
|
||||
for (const candidate of CHROMIUM_CANDIDATES) {
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
throw new AppError(
|
||||
'No Chromium executable was found. Set CHROMIUM_PATH or install Chromium in the container.',
|
||||
{ status: 500, code: 'CHROMIUM_NOT_FOUND' },
|
||||
)
|
||||
}
|
||||
|
||||
let browserPromise = null
|
||||
let closingBrowser = false
|
||||
|
||||
async function getBrowser(logger) {
|
||||
if (!browserPromise) {
|
||||
const executablePath = resolveChromiumPath()
|
||||
const startedAt = Date.now()
|
||||
logger.info('chromium launching', { executablePath })
|
||||
browserPromise = chromium
|
||||
.launch({
|
||||
executablePath,
|
||||
headless: true,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--font-render-hinting=none',
|
||||
],
|
||||
})
|
||||
.then((browser) => {
|
||||
logger.info('chromium ready', { durationMs: Date.now() - startedAt })
|
||||
browser.on('disconnected', () => {
|
||||
if (!closingBrowser) {
|
||||
logger.warn('chromium disconnected unexpectedly')
|
||||
}
|
||||
browserPromise = null
|
||||
})
|
||||
return browser
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('chromium launch failed', { executablePath, error })
|
||||
browserPromise = null
|
||||
throw error
|
||||
})
|
||||
}
|
||||
return browserPromise
|
||||
}
|
||||
|
||||
const FOOTER_TEMPLATE = `
|
||||
<div style="width:100%; padding:0 15mm; font-family: Arial, sans-serif; font-size:8px; color:#5b6775; display:flex; align-items:center; justify-content:space-between;">
|
||||
<span>__FOOTER_LABEL__</span>
|
||||
<span><span class="pageNumber"></span> / <span class="totalPages"></span></span>
|
||||
</div>
|
||||
`
|
||||
|
||||
export async function renderPdf({
|
||||
html,
|
||||
outputPath,
|
||||
footerLabel = 'Wiki.js export',
|
||||
logger = rootLogger,
|
||||
}) {
|
||||
const log = logger.child({ component: 'chromium' })
|
||||
const startedAt = Date.now()
|
||||
log.debug('pdf render started', { htmlBytes: Buffer.byteLength(html) })
|
||||
|
||||
const browser = await getBrowser(log)
|
||||
const context = await browser.newContext({
|
||||
javaScriptEnabled: true,
|
||||
viewport: { width: 1280, height: 1600 },
|
||||
})
|
||||
|
||||
try {
|
||||
const page = await context.newPage()
|
||||
page.setDefaultTimeout(120_000)
|
||||
|
||||
await page.setContent(html, { waitUntil: 'load', timeout: 120_000 })
|
||||
await page.emulateMedia({ media: 'print' })
|
||||
|
||||
// Images are mostly data URIs already. Wait for anything external that
|
||||
// could not be embedded, but never let a slow host stall the export.
|
||||
await page.evaluate(async () => {
|
||||
if (document.fonts?.ready) {
|
||||
await document.fonts.ready.catch(() => {})
|
||||
}
|
||||
|
||||
const pending = [...document.images].filter((image) => !image.complete)
|
||||
if (pending.length === 0) return
|
||||
|
||||
await Promise.race([
|
||||
Promise.all(
|
||||
pending.map(
|
||||
(image) =>
|
||||
new Promise((resolve) => {
|
||||
image.addEventListener('load', resolve, { once: true })
|
||||
image.addEventListener('error', resolve, { once: true })
|
||||
}),
|
||||
),
|
||||
),
|
||||
new Promise((resolve) => setTimeout(resolve, 12_000)),
|
||||
])
|
||||
})
|
||||
|
||||
const footer = FOOTER_TEMPLATE.replace('__FOOTER_LABEL__', escapeHtml(footerLabel))
|
||||
|
||||
await page.pdf({
|
||||
path: outputPath,
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
headerTemplate: '<div></div>',
|
||||
footerTemplate: footer,
|
||||
margin: {
|
||||
top: '16mm',
|
||||
right: '15mm',
|
||||
bottom: '18mm',
|
||||
left: '15mm',
|
||||
},
|
||||
outline: true,
|
||||
preferCSSPageSize: false,
|
||||
})
|
||||
log.debug('pdf written', {
|
||||
outputPath,
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
} finally {
|
||||
await context.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeBrowser() {
|
||||
if (!browserPromise) return
|
||||
const browser = await browserPromise.catch(() => null)
|
||||
browserPromise = null
|
||||
if (browser) {
|
||||
closingBrowser = true
|
||||
try {
|
||||
await browser.close().catch(() => {})
|
||||
} finally {
|
||||
closingBrowser = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
export const PRINT_STYLES = `
|
||||
:root {
|
||||
--ink: #17202a;
|
||||
--muted: #5b6775;
|
||||
--line: #d7dee7;
|
||||
--soft: #f4f7fa;
|
||||
--soft-2: #eef3f8;
|
||||
--accent: #0b63b6;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html {
|
||||
font-size: 10.5pt;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: "Liberation Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 1.5px;
|
||||
}
|
||||
|
||||
p { margin: 0 0 0.75em; orphans: 3; widows: 3; }
|
||||
ul, ol { margin: 0 0 0.9em; padding-left: 1.4em; }
|
||||
li { margin: 0.18em 0; }
|
||||
li > p { margin: 0.2em 0; }
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #0f1720;
|
||||
line-height: 1.22;
|
||||
margin: 1.3em 0 0.45em;
|
||||
break-after: avoid-page;
|
||||
}
|
||||
h1 { font-size: 1.9em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid var(--line); padding-bottom: 0.18em; }
|
||||
h3 { font-size: 1.22em; }
|
||||
h4 { font-size: 1.06em; }
|
||||
h5, h6 { font-size: 1em; }
|
||||
|
||||
strong { font-weight: 700; }
|
||||
small { font-size: 0.88em; }
|
||||
mark { background: #fff1a8; }
|
||||
|
||||
blockquote {
|
||||
margin: 1em 0;
|
||||
padding: 0.7em 1em;
|
||||
border-left: 4px solid #9fb6cf;
|
||||
background: var(--soft);
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
code, kbd, samp, pre {
|
||||
font-family: "DejaVu Sans Mono", "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.88em;
|
||||
background: #f0f3f7;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 3px;
|
||||
padding: 0.05em 0.3em;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0.9em 0;
|
||||
padding: 0.85em 1em;
|
||||
background: #f6f8fa;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.82em;
|
||||
line-height: 1.42;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
pre code {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
thead { display: table-header-group; }
|
||||
tr { break-inside: avoid; }
|
||||
th, td {
|
||||
border: 1px solid var(--line);
|
||||
padding: 0.42em 0.55em;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th { background: var(--soft-2); font-weight: 700; }
|
||||
|
||||
img, svg, video, canvas {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
img {
|
||||
break-inside: avoid;
|
||||
border-radius: 2px;
|
||||
}
|
||||
figure { margin: 1em 0; break-inside: avoid; }
|
||||
figcaption { color: var(--muted); font-size: 0.88em; margin-top: 0.35em; }
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
details { margin: 0.8em 0; }
|
||||
summary { cursor: default; font-weight: 600; }
|
||||
|
||||
.admonition, .ui.message, .notification, .alert {
|
||||
margin: 1em 0;
|
||||
padding: 0.75em 1em;
|
||||
border-left: 4px solid #4c8dd6;
|
||||
background: var(--soft);
|
||||
break-inside: avoid;
|
||||
}
|
||||
.admonition.warning, .ui.warning.message, .notification.is-warning, .alert-warning {
|
||||
border-left-color: #d38b00;
|
||||
background: #fff9e8;
|
||||
}
|
||||
.admonition.danger, .ui.error.message, .notification.is-danger, .alert-danger {
|
||||
border-left-color: #c0392b;
|
||||
background: #fff1ef;
|
||||
}
|
||||
.admonition.success, .ui.success.message, .notification.is-success, .alert-success {
|
||||
border-left-color: #2f8f55;
|
||||
background: #eefaf2;
|
||||
}
|
||||
|
||||
.task-list-item { list-style: none; }
|
||||
.task-list-item input { margin: 0 0.45em 0 -1.2em; }
|
||||
|
||||
.cover {
|
||||
min-height: 247mm;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
break-after: page;
|
||||
padding: 8mm 4mm;
|
||||
}
|
||||
.cover .eyebrow {
|
||||
color: var(--accent);
|
||||
font-size: 9pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 14mm;
|
||||
}
|
||||
.cover h1 {
|
||||
font-size: 34pt;
|
||||
line-height: 1.08;
|
||||
margin: 0 0 5mm;
|
||||
max-width: 150mm;
|
||||
}
|
||||
.cover .subtitle {
|
||||
color: var(--muted);
|
||||
font-size: 13pt;
|
||||
margin: 0 0 18mm;
|
||||
max-width: 150mm;
|
||||
}
|
||||
.cover .rule {
|
||||
width: 32mm;
|
||||
height: 4px;
|
||||
background: var(--accent);
|
||||
margin-bottom: 14mm;
|
||||
}
|
||||
.cover dl {
|
||||
display: grid;
|
||||
grid-template-columns: 28mm 1fr;
|
||||
gap: 2.2mm 5mm;
|
||||
margin: 0;
|
||||
font-size: 9.5pt;
|
||||
}
|
||||
.cover dt {
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
.cover dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.toc {
|
||||
break-after: page;
|
||||
}
|
||||
.toc h1 {
|
||||
font-size: 24pt;
|
||||
margin: 0 0 8mm;
|
||||
}
|
||||
.toc-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.toc-entry {
|
||||
padding: 0.3em 0 0.3em calc(var(--depth, 0) * 1.15em);
|
||||
border-bottom: 1px solid #edf1f5;
|
||||
break-inside: avoid;
|
||||
}
|
||||
.toc-entry a {
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.toc-entry .toc-path {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 8.3pt;
|
||||
margin-top: 0.1em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.wiki-page {
|
||||
break-before: page;
|
||||
}
|
||||
.page-header {
|
||||
border-bottom: 2px solid var(--ink);
|
||||
padding-bottom: 4mm;
|
||||
margin-bottom: 7mm;
|
||||
}
|
||||
.page-kicker {
|
||||
color: var(--accent);
|
||||
font-size: 8pt;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.09em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 23pt;
|
||||
margin: 0 0 2mm;
|
||||
line-height: 1.12;
|
||||
}
|
||||
.page-meta {
|
||||
color: var(--muted);
|
||||
font-size: 8.8pt;
|
||||
}
|
||||
.page-meta .dot { padding: 0 0.45em; }
|
||||
|
||||
.wiki-content > :first-child { margin-top: 0; }
|
||||
.wiki-content > :last-child { margin-bottom: 0; }
|
||||
|
||||
.empty-page {
|
||||
padding: 8mm;
|
||||
background: var(--soft);
|
||||
border: 1px dashed #b8c4d2;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.pdf-embed {
|
||||
color: var(--muted);
|
||||
font-size: 0.92em;
|
||||
padding: 0.6em 0.8em;
|
||||
background: var(--soft);
|
||||
border: 1px solid var(--line);
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.footnotes, .footnote-ref {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
`
|
||||
+390
@@ -0,0 +1,390 @@
|
||||
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import express from 'express'
|
||||
import { JobQueue } from './jobs.js'
|
||||
import { logger } from './logger.js'
|
||||
import { closeBrowser } from './pdf-renderer.js'
|
||||
import { WikiClient } from './wiki-client.js'
|
||||
import {
|
||||
AppError,
|
||||
comparePagePaths,
|
||||
isPlainObject,
|
||||
normalizeApiKey,
|
||||
normalizeBaseUrl,
|
||||
toPositiveInt,
|
||||
} from './utils.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const publicDir = path.join(__dirname, '..', 'public')
|
||||
|
||||
const PORT = toPositiveInt(process.env.PORT, 8080, { min: 1, max: 65535 })
|
||||
const HOST = process.env.HOST || '0.0.0.0'
|
||||
const MAX_PAGES = toPositiveInt(process.env.MAX_PAGES, 500, { min: 1, max: 5000 })
|
||||
const MAX_QUEUE = toPositiveInt(process.env.MAX_QUEUE, 5, { min: 1, max: 100 })
|
||||
const JOB_TTL_MS = toPositiveInt(process.env.JOB_TTL_MINUTES, 120, { min: 1, max: 1440 }) * 60 * 1000
|
||||
const EXPORT_CONCURRENCY = toPositiveInt(process.env.EXPORT_CONCURRENCY, 1, { min: 1, max: 3 })
|
||||
const PAGE_FETCH_CONCURRENCY = toPositiveInt(process.env.PAGE_FETCH_CONCURRENCY, 4, { min: 1, max: 12 })
|
||||
const AUTH_USER = process.env.AUTH_USER || ''
|
||||
const AUTH_PASSWORD = process.env.AUTH_PASSWORD || ''
|
||||
// The wiki connection is configured once on the server, never sent by the
|
||||
// browser and never included in job status responses or logs.
|
||||
const WIKI_API_KEY = normalizeApiKey(process.env.WIKI_API_KEY)
|
||||
const WIKI_URL_INPUT = String(process.env.WIKI_URL || '').trim()
|
||||
let WIKI_URL = null
|
||||
let WIKI_URL_ERROR = null
|
||||
if (WIKI_URL_INPUT) {
|
||||
try {
|
||||
WIKI_URL = normalizeBaseUrl(WIKI_URL_INPUT)
|
||||
} catch (error) {
|
||||
WIKI_URL_ERROR = error.message
|
||||
}
|
||||
}
|
||||
|
||||
function requireWikiConfig() {
|
||||
if (!WIKI_URL) {
|
||||
throw new AppError(
|
||||
WIKI_URL_ERROR
|
||||
? `The wiki URL from WIKI_URL is invalid: ${WIKI_URL_ERROR}`
|
||||
: 'The Wiki.js URL is not configured on the server. Set WIKI_URL and restart the container.',
|
||||
{ status: 503, code: 'WIKI_URL_MISSING' },
|
||||
)
|
||||
}
|
||||
if (!WIKI_API_KEY) {
|
||||
throw new AppError(
|
||||
'The Wiki.js API key is not configured on the server. Set WIKI_API_KEY and restart the container.',
|
||||
{ status: 503, code: 'WIKI_API_KEY_MISSING' },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const app = express()
|
||||
app.disable('x-powered-by')
|
||||
|
||||
const jobs = new JobQueue({
|
||||
concurrency: EXPORT_CONCURRENCY,
|
||||
maxQueue: MAX_QUEUE,
|
||||
ttlMs: JOB_TTL_MS,
|
||||
})
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.setHeader('Referrer-Policy', 'no-referrer')
|
||||
res.setHeader('X-Frame-Options', 'DENY')
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
||||
)
|
||||
next()
|
||||
})
|
||||
|
||||
app.use((req, res, next) => {
|
||||
const requestId = randomUUID()
|
||||
const startedAt = Date.now()
|
||||
req.requestId = requestId
|
||||
req.requestStartedAt = startedAt
|
||||
res.setHeader('X-Request-Id', requestId)
|
||||
|
||||
res.on('finish', () => {
|
||||
if (res.locals.errorLogged) return
|
||||
const fields = {
|
||||
requestId,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
status: res.statusCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
}
|
||||
// The UI polls the job status every second. Keep those lines out of the
|
||||
// info log so a single export does not flood the output.
|
||||
const isJobStatusPoll =
|
||||
req.method === 'GET' && /^\/api\/exports\/[0-9a-f-]+$/i.test(req.path)
|
||||
|
||||
if (res.statusCode >= 500) {
|
||||
logger.error('request failed', fields)
|
||||
} else if (res.statusCode >= 400) {
|
||||
logger.warn('request rejected', fields)
|
||||
} else if (!isJobStatusPoll && (req.path === '/' || req.path.startsWith('/api/'))) {
|
||||
logger.info('request', fields)
|
||||
} else {
|
||||
logger.debug('request', fields)
|
||||
}
|
||||
})
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
function safeEqual(left, right) {
|
||||
const leftBuffer = Buffer.from(String(left))
|
||||
const rightBuffer = Buffer.from(String(right))
|
||||
if (leftBuffer.length !== rightBuffer.length) return false
|
||||
return timingSafeEqual(leftBuffer, rightBuffer)
|
||||
}
|
||||
|
||||
function basicAuth(req, res, next) {
|
||||
if (!AUTH_USER || !AUTH_PASSWORD) return next()
|
||||
|
||||
const header = req.headers.authorization || ''
|
||||
if (header.startsWith('Basic ')) {
|
||||
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
|
||||
const separator = decoded.indexOf(':')
|
||||
const user = separator >= 0 ? decoded.slice(0, separator) : decoded
|
||||
const password = separator >= 0 ? decoded.slice(separator + 1) : ''
|
||||
if (safeEqual(user, AUTH_USER) && safeEqual(password, AUTH_PASSWORD)) {
|
||||
return next()
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('WWW-Authenticate', 'Basic realm="wiki-print", charset="UTF-8"')
|
||||
return res.status(401).send('Authentication required.')
|
||||
}
|
||||
|
||||
app.get('/healthz', (req, res) => {
|
||||
res.json({ status: 'ok' })
|
||||
})
|
||||
|
||||
app.use(basicAuth)
|
||||
app.use(express.json({ limit: '256kb' }))
|
||||
|
||||
app.get('/api/config', (req, res) => {
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.json({
|
||||
wikiUrl: WIKI_URL?.href || null,
|
||||
wikiUrlConfigured: Boolean(WIKI_URL),
|
||||
maxPages: MAX_PAGES,
|
||||
maxQueue: MAX_QUEUE,
|
||||
authEnabled: Boolean(AUTH_USER && AUTH_PASSWORD),
|
||||
apiKeyConfigured: Boolean(WIKI_API_KEY),
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/pages', async (req, res, next) => {
|
||||
const startedAt = Date.now()
|
||||
try {
|
||||
requireWikiConfig()
|
||||
|
||||
const client = new WikiClient({
|
||||
baseUrl: WIKI_URL,
|
||||
apiKey: WIKI_API_KEY,
|
||||
logger: logger.child({ requestId: req.requestId }),
|
||||
})
|
||||
const [listedPages, localization] = await Promise.all([
|
||||
client.listPages(),
|
||||
client.getLocalization(),
|
||||
])
|
||||
|
||||
const pages = listedPages
|
||||
.map((page) => ({
|
||||
path: String(page.path || ''),
|
||||
locale: String(page.locale || ''),
|
||||
title: page.title || '',
|
||||
description: page.description || '',
|
||||
isPublished: page.isPublished !== false,
|
||||
}))
|
||||
.filter((page) => page.path && page.locale)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.locale.localeCompare(b.locale, 'en') || comparePagePaths(a.path, b.path),
|
||||
)
|
||||
|
||||
logger.info('page list loaded', {
|
||||
requestId: req.requestId,
|
||||
pageCount: pages.length,
|
||||
publishedCount: pages.filter((page) => page.isPublished).length,
|
||||
locales: [...new Set(pages.map((page) => page.locale))],
|
||||
defaultLocale: localization?.locale || null,
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.json({
|
||||
baseUrl: WIKI_URL.href,
|
||||
defaultLocale: localization?.locale || null,
|
||||
namespacing: localization?.namespacing ?? null,
|
||||
pages,
|
||||
})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/api/exports', (req, res, next) => {
|
||||
try {
|
||||
const body = req.body
|
||||
if (!isPlainObject(body)) {
|
||||
throw new AppError('Expected a JSON request body.')
|
||||
}
|
||||
|
||||
requireWikiConfig()
|
||||
|
||||
const startPage = String(body.startPage ?? '').trim()
|
||||
|
||||
if (!startPage) throw new AppError('The starting page is required.')
|
||||
if (startPage.length > 2048) throw new AppError('The starting page is too long.')
|
||||
|
||||
const locale = body.locale ? String(body.locale).trim().toLowerCase() : null
|
||||
if (locale && !/^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/.test(locale)) {
|
||||
throw new AppError('The locale is not a valid language tag (for example "en" or "de-ch").')
|
||||
}
|
||||
|
||||
const maxPages = toPositiveInt(body.maxPages, MAX_PAGES, { min: 1, max: 5000 })
|
||||
|
||||
const job = jobs.enqueue({
|
||||
baseUrl: WIKI_URL.href,
|
||||
apiKey: WIKI_API_KEY,
|
||||
startPage,
|
||||
locale,
|
||||
includeUnpublished: body.includeUnpublished === true,
|
||||
includeToc: body.includeToc !== false,
|
||||
inlineImages: body.inlineImages !== false,
|
||||
maxPages,
|
||||
concurrency: PAGE_FETCH_CONCURRENCY,
|
||||
})
|
||||
|
||||
logger.info('export requested', {
|
||||
requestId: req.requestId,
|
||||
jobId: job.id,
|
||||
startPage,
|
||||
locale,
|
||||
maxPages,
|
||||
includeUnpublished: body.includeUnpublished === true,
|
||||
includeToc: body.includeToc !== false,
|
||||
inlineImages: body.inlineImages !== false,
|
||||
})
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.status(202).json({ job })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/exports/:id', (req, res) => {
|
||||
const job = jobs.get(req.params.id)
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: 'Export not found.' })
|
||||
}
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
return res.json({ job })
|
||||
})
|
||||
|
||||
app.get('/api/exports/:id/pdf', (req, res) => {
|
||||
const job = jobs.getInternal(req.params.id)
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: 'Export not found.' })
|
||||
}
|
||||
if (job.status !== 'done' || !job.filePath) {
|
||||
return res.status(409).json({ error: 'The PDF is not ready yet.' })
|
||||
}
|
||||
|
||||
res.setHeader('Cache-Control', 'no-store')
|
||||
res.setHeader('Content-Type', 'application/pdf')
|
||||
return res.download(job.filePath, job.result?.filename || 'wiki-export.pdf', (error) => {
|
||||
if (error && !res.headersSent) {
|
||||
res.status(404).json({ error: 'The PDF file is no longer available.' })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The UI files have no content hashes in their names, so caching them for an
|
||||
// hour means a browser can keep running an old app.js after a rebuild (for
|
||||
// example one that did not load the page list). Always revalidate the HTML,
|
||||
// JavaScript and CSS; static images may still be cached.
|
||||
app.use(
|
||||
express.static(publicDir, {
|
||||
index: 'index.html',
|
||||
etag: true,
|
||||
lastModified: true,
|
||||
setHeaders(res, filePath) {
|
||||
if (/\.(?:html|js|css)$/i.test(filePath)) {
|
||||
res.setHeader('Cache-Control', 'no-cache')
|
||||
} else {
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600')
|
||||
}
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
app.use('/api', (req, res) => {
|
||||
res.status(404).json({ error: 'Not found.' })
|
||||
})
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).send('Not found.')
|
||||
})
|
||||
|
||||
app.use((error, req, res, next) => {
|
||||
if (res.headersSent) return next(error)
|
||||
const status = Number.isInteger(error.status) ? error.status : 500
|
||||
res.locals.errorLogged = true
|
||||
const fields = {
|
||||
requestId: req.requestId,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
status,
|
||||
durationMs: Date.now() - (req.requestStartedAt || Date.now()),
|
||||
code: error.code || (status >= 500 ? 'INTERNAL_ERROR' : 'BAD_REQUEST'),
|
||||
error,
|
||||
}
|
||||
if (status >= 500) {
|
||||
logger.error('request error', fields)
|
||||
} else {
|
||||
logger.warn('request rejected', { ...fields, error: error.message })
|
||||
}
|
||||
res.status(status).json({
|
||||
error: error.message || 'Internal server error.',
|
||||
code: error.code || 'INTERNAL_ERROR',
|
||||
details: error.details,
|
||||
})
|
||||
})
|
||||
|
||||
const server = app.listen(PORT, HOST, () => {
|
||||
logger.info('server started', {
|
||||
host: HOST,
|
||||
port: PORT,
|
||||
maxPages: MAX_PAGES,
|
||||
maxQueue: MAX_QUEUE,
|
||||
exportConcurrency: EXPORT_CONCURRENCY,
|
||||
pageFetchConcurrency: PAGE_FETCH_CONCURRENCY,
|
||||
jobTtlMinutes: JOB_TTL_MS / 60_000,
|
||||
authEnabled: Boolean(AUTH_USER && AUTH_PASSWORD),
|
||||
apiKeyConfigured: Boolean(WIKI_API_KEY),
|
||||
wikiUrlConfigured: Boolean(WIKI_URL),
|
||||
})
|
||||
|
||||
if (WIKI_URL) {
|
||||
logger.info('wiki configured', { wikiUrl: WIKI_URL.href })
|
||||
} else {
|
||||
logger.warn('wiki URL is not configured', {
|
||||
reason: WIKI_URL_ERROR || 'WIKI_URL is not set',
|
||||
hint: 'Set WIKI_URL and restart the container.',
|
||||
})
|
||||
}
|
||||
if (!WIKI_API_KEY) {
|
||||
logger.warn('wiki API key is not configured', {
|
||||
hint: 'Set WIKI_API_KEY and restart the container.',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let shuttingDown = false
|
||||
async function shutdown(signal) {
|
||||
if (shuttingDown) return
|
||||
shuttingDown = true
|
||||
logger.info('shutting down', { signal })
|
||||
const forceTimer = setTimeout(() => process.exit(1), 15_000)
|
||||
forceTimer.unref?.()
|
||||
|
||||
server.close()
|
||||
await jobs.close().catch(() => {})
|
||||
await closeBrowser().catch(() => {})
|
||||
clearTimeout(forceTimer)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||
process.on('unhandledRejection', (error) => {
|
||||
logger.error('unhandled rejection', { error })
|
||||
})
|
||||
|
||||
export { app, server }
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
export class AppError extends Error {
|
||||
constructor(message, { status = 400, code = 'BAD_REQUEST', details } = {}) {
|
||||
super(message)
|
||||
this.name = 'AppError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.details = details
|
||||
}
|
||||
}
|
||||
|
||||
export class WikiError extends AppError {
|
||||
constructor(message, options = {}) {
|
||||
super(message, { status: options.status ?? 502, code: options.code ?? 'WIKI_ERROR', details: options.details })
|
||||
this.name = 'WikiError'
|
||||
}
|
||||
}
|
||||
|
||||
const LOCAL_HOST_RE = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i
|
||||
const ABSOLUTE_URL_RE = /^[a-z][a-z0-9+.-]*:\/\//i
|
||||
const LOCALE_SEGMENT_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})?$/i
|
||||
|
||||
/**
|
||||
* Normalize a Wiki.js API key from an environment variable or CLI option.
|
||||
* Users often paste keys together with a "Bearer " prefix; only the key itself
|
||||
* belongs in the Authorization header.
|
||||
*/
|
||||
export function normalizeApiKey(value) {
|
||||
return String(value ?? '')
|
||||
.trim()
|
||||
.replace(/^Bearer\s+/i, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn user input such as "wiki.example.com", "https://wiki.example.com/wiki/"
|
||||
* or "https://wiki.example.com/graphql" into a canonical base URL description.
|
||||
*/
|
||||
export function normalizeBaseUrl(input) {
|
||||
let raw = String(input ?? '').trim()
|
||||
if (!raw) {
|
||||
throw new AppError('The Wiki.js URL is required.')
|
||||
}
|
||||
|
||||
if (!ABSOLUTE_URL_RE.test(raw)) {
|
||||
raw = `${LOCAL_HOST_RE.test(raw) ? 'http' : 'https'}://${raw}`
|
||||
}
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(raw)
|
||||
} catch {
|
||||
throw new AppError('The Wiki.js URL is not a valid URL.')
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new AppError('The Wiki.js URL must use http or https.')
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new AppError('Do not put credentials into the Wiki.js URL. Set WIKI_API_KEY instead.')
|
||||
}
|
||||
|
||||
url.search = ''
|
||||
url.hash = ''
|
||||
|
||||
let pathname = url.pathname.replace(/\/+$/, '')
|
||||
if (pathname.toLowerCase().endsWith('/graphql')) {
|
||||
pathname = pathname.slice(0, -'/graphql'.length).replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
const canonical = url.origin + pathname
|
||||
return {
|
||||
href: canonical || url.origin,
|
||||
origin: url.origin,
|
||||
pathname: pathname || '',
|
||||
endpoint: `${canonical || url.origin}/graphql`,
|
||||
}
|
||||
}
|
||||
|
||||
export function stripBasePath(pathname, basePathname) {
|
||||
if (!basePathname) return pathname || '/'
|
||||
if (pathname === basePathname) return '/'
|
||||
if (pathname.startsWith(`${basePathname}/`)) {
|
||||
return pathname.slice(basePathname.length) || '/'
|
||||
}
|
||||
return pathname || '/'
|
||||
}
|
||||
|
||||
export function safeDecode(value) {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a URL pathname (without query/hash) to a Wiki.js page path.
|
||||
* The empty path and "/home" both refer to Wiki.js's reserved home page.
|
||||
*/
|
||||
export function pathnameToPagePath(pathname, basePathname = '') {
|
||||
let path = stripBasePath(pathname || '/', basePathname)
|
||||
path = safeDecode(path)
|
||||
path = path.replace(/^\/+/, '').replace(/\/+$/, '')
|
||||
if (!path || path.toLowerCase() === 'home') return 'home'
|
||||
return path
|
||||
}
|
||||
|
||||
export function normalizePagePath(value) {
|
||||
let path = String(value ?? '').trim()
|
||||
if (!path) return 'home'
|
||||
|
||||
if (ABSOLUTE_URL_RE.test(path)) {
|
||||
try {
|
||||
const url = new URL(path)
|
||||
path = safeDecode(url.pathname)
|
||||
} catch {
|
||||
// Keep the original input and let selection report a useful not-found error.
|
||||
}
|
||||
}
|
||||
|
||||
path = path.split(/[?#]/, 1)[0]
|
||||
path = path.replace(/^\/+/, '').replace(/\/+$/, '')
|
||||
return path || 'home'
|
||||
}
|
||||
|
||||
export function looksLikeLocaleSegment(segment) {
|
||||
return LOCALE_SEGMENT_RE.test(segment || '')
|
||||
}
|
||||
|
||||
export function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''')
|
||||
}
|
||||
|
||||
export function sanitizeFilename(value, fallback = 'wiki-export') {
|
||||
const sanitized = String(value || '')
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w.-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 120)
|
||||
return sanitized || fallback
|
||||
}
|
||||
|
||||
export function comparePagePaths(a, b) {
|
||||
return a.localeCompare(b, 'en', { numeric: true, sensitivity: 'base' })
|
||||
}
|
||||
|
||||
export async function mapLimit(items, limit, worker) {
|
||||
const results = new Array(items.length)
|
||||
let nextIndex = 0
|
||||
const workerCount = Math.max(1, Math.min(limit, items.length))
|
||||
|
||||
async function run() {
|
||||
while (true) {
|
||||
const index = nextIndex
|
||||
nextIndex += 1
|
||||
if (index >= items.length) return
|
||||
results[index] = await worker(items[index], index)
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: workerCount }, run))
|
||||
return results
|
||||
}
|
||||
|
||||
export function toPositiveInt(value, fallback, { min = 1, max = Number.MAX_SAFE_INTEGER } = {}) {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(parsed)) return fallback
|
||||
return Math.min(max, Math.max(min, parsed))
|
||||
}
|
||||
|
||||
export function isPlainObject(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { logger as rootLogger } from './logger.js'
|
||||
import { AppError, WikiError, normalizeApiKey } from './utils.js'
|
||||
|
||||
const LIST_PAGES_QUERY = `
|
||||
query WikiPrintPages {
|
||||
pages {
|
||||
list {
|
||||
id
|
||||
path
|
||||
locale
|
||||
title
|
||||
description
|
||||
contentType
|
||||
isPublished
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const LOCALIZATION_QUERY = `
|
||||
query WikiPrintLocalization {
|
||||
localization {
|
||||
config {
|
||||
locale
|
||||
namespacing
|
||||
namespaces
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// `toc` is intentionally not selected. Some Wiki.js versions declare it as a
|
||||
// String scalar but return an array, which makes the whole page query fail with
|
||||
// INTERNAL_SERVER_ERROR. The exporter builds its own table of contents.
|
||||
const PAGE_QUERY = `
|
||||
query WikiPrintPage($path: String!, $locale: String!) {
|
||||
pages {
|
||||
singleByPath(path: $path, locale: $locale) {
|
||||
id
|
||||
path
|
||||
locale
|
||||
title
|
||||
description
|
||||
contentType
|
||||
render
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const PAGE_SOURCE_QUERY = `
|
||||
query WikiPrintPageSource($id: Int!) {
|
||||
pages {
|
||||
single(id: $id) {
|
||||
content
|
||||
contentType
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
function operationName(query) {
|
||||
const match = String(query).match(/\b(?:query|mutation)\s+([A-Za-z0-9_]+)/)
|
||||
return match?.[1] || 'anonymous'
|
||||
}
|
||||
|
||||
function retryDelay(attempt) {
|
||||
return 400 * 2 ** (attempt - 1)
|
||||
}
|
||||
|
||||
function graphQlErrorMessage(errors) {
|
||||
return (errors || [])
|
||||
.map((error) => {
|
||||
const code = error?.extensions?.exception?.code || error?.extensions?.code
|
||||
return code ? `${error.message} (${code})` : error.message
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
export class WikiClient {
|
||||
constructor({ baseUrl, apiKey, timeoutMs = 45_000, logger = rootLogger }) {
|
||||
const key = normalizeApiKey(apiKey)
|
||||
if (!key) {
|
||||
throw new AppError('The Wiki.js API key is required.')
|
||||
}
|
||||
this.baseUrl = baseUrl
|
||||
this.apiKey = key
|
||||
this.timeoutMs = timeoutMs
|
||||
this.logger = logger.child({ component: 'wiki' })
|
||||
}
|
||||
|
||||
async query(query, variables = {}, { attempts = 3 } = {}) {
|
||||
let lastError
|
||||
const operation = operationName(query)
|
||||
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const attemptStartedAt = Date.now()
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs)
|
||||
let responseStatus = null
|
||||
|
||||
try {
|
||||
const response = await fetch(this.baseUrl.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'wiki-print/1.0',
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
responseStatus = response.status
|
||||
|
||||
const text = await response.text()
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
this.logger.warn('wiki request rejected', {
|
||||
operation,
|
||||
attempt,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - attemptStartedAt,
|
||||
code: 'WIKI_AUTH_FAILED',
|
||||
})
|
||||
throw new WikiError(
|
||||
`Wiki.js rejected the API key (HTTP ${response.status}). Check the key and make sure its group has the "read:pages" permission.`,
|
||||
{ status: 401, code: 'WIKI_AUTH_FAILED' },
|
||||
)
|
||||
}
|
||||
if (response.status === 404) {
|
||||
this.logger.warn('wiki GraphQL endpoint not found', {
|
||||
operation,
|
||||
attempt,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - attemptStartedAt,
|
||||
})
|
||||
throw new WikiError(
|
||||
'The Wiki.js GraphQL endpoint was not found. Check the wiki URL. If Wiki.js runs under a subpath, include it in the URL.',
|
||||
{ status: 502, code: 'WIKI_ENDPOINT_NOT_FOUND' },
|
||||
)
|
||||
}
|
||||
if (response.status === 429 || response.status >= 500) {
|
||||
lastError = new WikiError(`Wiki.js returned HTTP ${response.status}.`, {
|
||||
status: 502,
|
||||
code: 'WIKI_HTTP_ERROR',
|
||||
})
|
||||
if (attempt < attempts) {
|
||||
const delayMs = retryDelay(attempt)
|
||||
this.logger.warn('wiki request retry', {
|
||||
operation,
|
||||
attempt,
|
||||
nextAttempt: attempt + 1,
|
||||
delayMs,
|
||||
status: response.status,
|
||||
code: lastError.code,
|
||||
})
|
||||
await sleep(delayMs)
|
||||
continue
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
this.logger.warn('wiki request failed', {
|
||||
operation,
|
||||
attempt,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - attemptStartedAt,
|
||||
})
|
||||
throw new WikiError(`Wiki.js returned HTTP ${response.status}.`, {
|
||||
status: 502,
|
||||
code: 'WIKI_HTTP_ERROR',
|
||||
})
|
||||
}
|
||||
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(text)
|
||||
} catch {
|
||||
if (/^\s*</.test(text)) {
|
||||
throw new WikiError(
|
||||
'The configured URL answered with an HTML page instead of the Wiki.js GraphQL API. Check that the URL points to the root of a Wiki.js installation.',
|
||||
{ status: 502, code: 'WIKI_NOT_GRAPHQL' },
|
||||
)
|
||||
}
|
||||
throw new WikiError('Wiki.js returned an invalid JSON response.', {
|
||||
status: 502,
|
||||
code: 'WIKI_INVALID_RESPONSE',
|
||||
})
|
||||
}
|
||||
|
||||
if (Array.isArray(payload.errors) && payload.errors.length > 0) {
|
||||
const message = graphQlErrorMessage(payload.errors)
|
||||
const code = payload.errors[0]?.extensions?.exception?.code || payload.errors[0]?.extensions?.code
|
||||
const authRelated = /forbidden|unauthorized|permission|PageViewForbidden/i.test(`${code} ${message}`)
|
||||
this.logger.warn('wiki GraphQL error', {
|
||||
operation,
|
||||
attempt,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - attemptStartedAt,
|
||||
code: code || 'WIKI_GRAPHQL_ERROR',
|
||||
error: message,
|
||||
})
|
||||
throw new WikiError(message || 'Wiki.js returned a GraphQL error.', {
|
||||
status: authRelated ? 403 : 502,
|
||||
code: code || 'WIKI_GRAPHQL_ERROR',
|
||||
})
|
||||
}
|
||||
|
||||
this.logger.debug('wiki request completed', {
|
||||
operation,
|
||||
attempt,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - attemptStartedAt,
|
||||
})
|
||||
return payload.data
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error
|
||||
if (error?.name === 'AbortError') {
|
||||
lastError = new WikiError(`Wiki.js did not answer within ${Math.round(this.timeoutMs / 1000)} seconds.`, {
|
||||
status: 504,
|
||||
code: 'WIKI_TIMEOUT',
|
||||
})
|
||||
} else {
|
||||
lastError = new WikiError(`Could not reach Wiki.js: ${error.message}`, {
|
||||
status: 502,
|
||||
code: 'WIKI_UNREACHABLE',
|
||||
})
|
||||
}
|
||||
if (attempt < attempts) {
|
||||
const delayMs = retryDelay(attempt)
|
||||
this.logger.warn('wiki request retry', {
|
||||
operation,
|
||||
attempt,
|
||||
nextAttempt: attempt + 1,
|
||||
delayMs,
|
||||
status: responseStatus,
|
||||
code: lastError.code,
|
||||
error: lastError.message,
|
||||
})
|
||||
await sleep(delayMs)
|
||||
continue
|
||||
}
|
||||
this.logger.warn('wiki request failed', {
|
||||
operation,
|
||||
attempt,
|
||||
status: responseStatus,
|
||||
durationMs: Date.now() - attemptStartedAt,
|
||||
code: lastError.code,
|
||||
error: lastError.message,
|
||||
})
|
||||
throw lastError
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new WikiError('Could not query Wiki.js.')
|
||||
}
|
||||
|
||||
async listPages() {
|
||||
const data = await this.query(LIST_PAGES_QUERY)
|
||||
const pages = data?.pages?.list
|
||||
if (!Array.isArray(pages)) {
|
||||
throw new WikiError('Wiki.js did not return a page list. Is this a Wiki.js 2.x wiki?', {
|
||||
status: 502,
|
||||
code: 'WIKI_INVALID_PAGE_LIST',
|
||||
})
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
async getLocalization() {
|
||||
try {
|
||||
const data = await this.query(LOCALIZATION_QUERY, {}, { attempts: 1 })
|
||||
return data?.localization?.config || null
|
||||
} catch (error) {
|
||||
// Older Wiki.js versions or restricted setups may not expose this query.
|
||||
this.logger.debug('wiki localization unavailable', {
|
||||
code: error.code || null,
|
||||
error: error.message,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async getPage(path, locale) {
|
||||
const data = await this.query(PAGE_QUERY, { path, locale })
|
||||
return data?.pages?.singleByPath || null
|
||||
}
|
||||
|
||||
async getPageSource(id) {
|
||||
const data = await this.query(PAGE_SOURCE_QUERY, { id }, { attempts: 1 })
|
||||
return data?.pages?.single || null
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user