Initial commit

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

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
import assert from 'node:assert/strict'
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import path from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import { startMockWiki } from './mock-wiki.js'
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
function freePort() {
return new Promise((resolve, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const { port } = server.address()
server.close((error) => (error ? reject(error) : resolve(port)))
})
})
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function startApp({ port, wikiUrl, apiKey = 'test-key', extraEnv = {} }) {
const env = {
...process.env,
NODE_ENV: 'test',
HOST: '127.0.0.1',
PORT: String(port),
WIKI_URL: wikiUrl || '',
WIKI_API_KEY: apiKey,
...extraEnv,
}
delete env.MAX_PAGES
delete env.AUTH_USER
delete env.AUTH_PASSWORD
const child = spawn(process.execPath, ['src/server.js'], {
cwd: ROOT,
env,
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
child.stdout.on('data', (chunk) => {
output += chunk
})
child.stderr.on('data', (chunk) => {
output += chunk
})
const baseUrl = `http://127.0.0.1:${port}`
for (let attempt = 0; attempt < 100; attempt += 1) {
if (child.exitCode !== null) break
try {
const response = await fetch(`${baseUrl}/healthz`)
if (response.ok) return { baseUrl, child, output: () => output }
} catch {
// The server is not listening yet.
}
await sleep(50)
}
child.kill('SIGKILL')
throw new Error(`server did not start:\n${output}`)
}
async function stopApp(app) {
if (!app) return
if (app.child.exitCode === null) {
await new Promise((resolve) => {
app.child.once('exit', resolve)
app.child.kill('SIGTERM')
setTimeout(() => app.child.kill('SIGKILL'), 5_000).unref()
})
}
}
test('serves the wiki page list and keeps the connection details server-side', async () => {
const wiki = await startMockWiki()
const port = await freePort()
const app = await startApp({ port, wikiUrl: wiki.url })
try {
const configResponse = await fetch(`${app.baseUrl}/api/config`)
assert.equal(configResponse.status, 200)
assert.match(configResponse.headers.get('x-request-id') || '', /^[0-9a-f-]{36}$/)
const config = await configResponse.json()
assert.equal(config.wikiUrlConfigured, true)
assert.equal(config.wikiUrl, wiki.url)
assert.equal(config.apiKeyConfigured, true)
assert.equal('apiKey' in config, false, 'the API key must never be sent to the browser')
const pagesResponse = await fetch(`${app.baseUrl}/api/pages`)
assert.equal(pagesResponse.status, 200)
const pagesPayload = await pagesResponse.json()
assert.equal(pagesPayload.baseUrl, wiki.url)
assert.equal(pagesPayload.defaultLocale, 'en')
assert.equal(pagesPayload.namespacing, true)
assert.equal(pagesPayload.pages.length, wiki.pages.length)
const docs = pagesPayload.pages.find((page) => page.path === 'docs')
assert.deepEqual(docs, {
path: 'docs',
locale: 'en',
title: 'Documentation',
description: 'Documentation root',
isPublished: true,
})
assert.equal(
pagesPayload.pages.find((page) => page.path === 'docs/draft').isPublished,
false,
)
const paths = pagesPayload.pages.map((page) => page.path)
assert.deepEqual(paths, [...paths].sort((a, b) => a.localeCompare(b, 'en')))
} finally {
await stopApp(app)
await wiki.close()
}
})
test('rejects an export without a starting page and does not require a browser URL', async () => {
const wiki = await startMockWiki()
const port = await freePort()
const app = await startApp({ port, wikiUrl: wiki.url })
try {
const response = await fetch(`${app.baseUrl}/api/exports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ startPage: '' }),
})
assert.equal(response.status, 400)
const payload = await response.json()
assert.equal(payload.error, 'The starting page is required.')
// A browser-supplied baseUrl/API key must be ignored: the server uses
// WIKI_URL and WIKI_API_KEY only. A missing page fails before Chromium
// starts, so this test does not need a browser.
const accepted = await fetch(`${app.baseUrl}/api/exports`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
baseUrl: 'https://attacker.example',
apiKey: 'attacker-key',
startPage: 'missing-page',
maxPages: 1,
}),
})
assert.equal(accepted.status, 202)
const { job } = await accepted.json()
assert.equal(job.request.baseUrl, wiki.url)
assert.equal('apiKey' in job.request, false)
assert.equal('apiKey' in job, false)
let status = job
for (let attempt = 0; attempt < 100 && status.status !== 'error'; attempt += 1) {
await sleep(50)
status = (await (await fetch(`${app.baseUrl}/api/exports/${job.id}`)).json()).job
}
assert.equal(status.status, 'error')
assert.equal(status.error.code, 'START_PAGE_NOT_FOUND')
} finally {
await stopApp(app)
await wiki.close()
}
})
test('reports missing and invalid WIKI_URL without crashing', async () => {
const port = await freePort()
const app = await startApp({ port, wikiUrl: '' })
try {
const config = await (await fetch(`${app.baseUrl}/api/config`)).json()
assert.equal(config.wikiUrlConfigured, false)
assert.equal(config.wikiUrl, null)
const pagesResponse = await fetch(`${app.baseUrl}/api/pages`)
assert.equal(pagesResponse.status, 503)
const pagesPayload = await pagesResponse.json()
assert.equal(pagesPayload.code, 'WIKI_URL_MISSING')
} finally {
await stopApp(app)
}
const invalidPort = await freePort()
const invalidApp = await startApp({ port: invalidPort, wikiUrl: 'ftp://not-a-wiki' })
try {
const pagesResponse = await fetch(`${invalidApp.baseUrl}/api/pages`)
assert.equal(pagesResponse.status, 503)
const payload = await pagesResponse.json()
assert.equal(payload.code, 'WIKI_URL_MISSING')
assert.match(payload.error, /WIKI_URL is invalid/)
} finally {
await stopApp(invalidApp)
}
})
+171
View File
@@ -0,0 +1,171 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import * as cheerio from 'cheerio'
import { buildExportHtml } from '../src/html-builder.js'
import { normalizeBaseUrl } from '../src/utils.js'
const baseUrl = normalizeBaseUrl('https://wiki.example.com')
const pages = [
{
path: 'docs',
locale: 'en',
title: 'Docs',
depth: 0,
updatedAt: '2026-01-01T00:00:00.000Z',
render: `
<h1 id="welcome">Docs</h1>
<p><a href="#local">Local</a></p>
<p><a href="/en/docs/child#sub-heading">Child heading</a></p>
<p><a href="/en/docs/child">Child</a></p>
<p><a href="/en/docs-other">Outside subtree</a></p>
<p><a href="/de/docs">German docs</a></p>
<p><a href="https://example.com/page">External</a></p>
<span id="local">Anchor target</span>
`,
},
{
path: 'docs/child',
locale: 'en',
title: 'Child',
depth: 1,
updatedAt: '2026-01-01T00:00:00.000Z',
render: `
<h2 id="sub-heading">Sub heading</h2>
<h3>Auto heading</h3>
<p aria-labelledby="sub-heading other-id"><a name="old-anchor">Back</a>
<a href="/en/docs">Docs</a>
<a href="/en/docs/child/grand#child-heading">Grand</a></p>
<img src="/assets/pic.png" srcset="/assets/pic-2x.png 2x" loading="lazy" alt="Picture">
<script>window.bad = true</script>
<div onclick="alert(1)">Clickable</div>
<iframe src="/embedded"></iframe>
<form><input value="x"><p>Form content</p></form>
`,
},
{
path: 'docs/child/grand',
locale: 'en',
title: 'Grand',
depth: 2,
updatedAt: '2026-01-01T00:00:00.000Z',
render: `<h2 id="child-heading">Grand child heading</h2>`,
},
]
async function build(overrides = {}) {
return buildExportHtml({
pages,
baseUrl,
locale: 'en',
startPath: 'docs',
startPage: { title: 'Docs' },
localization: { namespacing: true, namespaces: ['en'] },
includeToc: true,
inlineImages: false,
apiKey: 'test-key',
...overrides,
})
}
test('rewrites internal links to PDF anchors and keeps external links online', async () => {
const html = await build()
const $ = cheerio.load(html)
assert.equal($('.wiki-content a[href="#wp1-sub-heading"]').length, 1)
assert.equal($('.wiki-content a[href="#wiki-page-1"]').length, 1)
assert.equal($('.wiki-content a[href="#wp2-child-heading"]').length, 1)
assert.equal($('.wiki-content a[href="#wp0-local"]').length, 1)
assert.ok(
$('.wiki-content a[data-wiki-path="docs"]')
.toArray()
.map((element) => $(element).attr('href'))
.includes('#wiki-page-0'),
)
assert.equal($('a[href="https://wiki.example.com/en/docs-other"]').length, 1)
assert.equal($('a[href="https://wiki.example.com/de/docs"]').length, 1)
assert.equal($('a[href="https://example.com/page"]').length, 1)
assert.equal($('#wp1-sub-heading').length, 1)
assert.equal($('#wp1-auto-heading').length, 1)
assert.equal($('a[name="wp1-old-anchor"]').length, 1)
assert.equal($('[aria-labelledby="wp1-sub-heading other-id"]').length, 1)
const childHrefs = $('.wiki-content a[data-wiki-path="docs/child"]')
.toArray()
.map((element) => $(element).attr('href'))
assert.ok(childHrefs.includes('#wp1-sub-heading'))
assert.ok(childHrefs.includes('#wiki-page-1'))
assert.equal(
$('.wiki-content a[data-wiki-path="docs/child/grand"]').attr('href'),
'#wp2-child-heading',
)
assert.ok(
$('.wiki-content a[data-wiki-path="docs"]')
.toArray()
.map((element) => $(element).attr('href'))
.includes('#wiki-page-0'),
)
})
test('sanitizes scripts, event handlers, forms and iframes', async () => {
const html = await build()
const $ = cheerio.load(html)
assert.equal($('script').length, 0)
assert.equal($('[onclick]').length, 0)
assert.equal($('form').length, 0)
assert.equal($('input').length, 0)
assert.equal($('iframe').length, 0)
assert.equal($('.pdf-embed a[href="https://wiki.example.com/embedded"]').length, 1)
assert.match($('.wiki-content').text(), /Form content/)
})
test('resolves images even when embedding is disabled', async () => {
const html = await build()
const $ = cheerio.load(html)
const image = $('img[alt="Picture"]')
assert.equal(image.attr('src'), 'https://wiki.example.com/assets/pic.png')
assert.equal(image.attr('srcset'), undefined)
assert.equal(image.attr('loading'), undefined)
})
test('supports wikis without URL locale namespacing', async () => {
const withoutNamespacing = pages.map((page) => ({
...page,
render: page.render.replaceAll('/en/', '/'),
}))
const html = await build({
pages: withoutNamespacing,
localization: { namespacing: false, namespaces: [] },
})
const $ = cheerio.load(html)
const childHrefs = $('.wiki-content a[data-wiki-path="docs/child"]')
.toArray()
.map((element) => $(element).attr('href'))
assert.ok(childHrefs.includes('#wp1-sub-heading'))
assert.ok(childHrefs.includes('#wiki-page-1'))
assert.equal(
$('.wiki-content a[data-wiki-path="docs/child/grand"]').attr('href'),
'#wp2-child-heading',
)
assert.ok(
$('.wiki-content a[data-wiki-path="docs"]')
.toArray()
.map((element) => $(element).attr('href'))
.includes('#wiki-page-0'),
)
})
test('includes only the exported pages in the table of contents', async () => {
const html = await build()
const $ = cheerio.load(html)
const tocHrefs = $('.toc-entry a').toArray().map((element) => $(element).attr('href'))
assert.deepEqual(tocHrefs, ['#wiki-page-0', '#wiki-page-1', '#wiki-page-2'])
assert.equal($('.wiki-page').length, 3)
})
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createLogger } from '../src/logger.js'
function capture() {
const lines = []
return {
lines,
stream: {
write(line) {
lines.push(line)
},
},
}
}
test('writes readable text logs and hides debug lines by default', () => {
const sink = capture()
const logger = createLogger({
level: 'info',
format: 'text',
stream: sink.stream,
bindings: { app: 'test' },
})
logger.debug('hidden detail', { value: 1 })
logger.info('server started', { port: 8080, name: 'a b' })
logger.warn('careful', { attempt: 2 })
assert.equal(sink.lines.length, 2)
assert.match(sink.lines[0], /^\d{4}-\d{2}-\d{2}T/)
assert.match(sink.lines[0], /INFO {2}server started app=test port=8080 name="a b"/)
assert.match(sink.lines[1], /WARN {2}careful app=test attempt=2/)
})
test('redacts credentials but keeps configuration booleans', () => {
const sink = capture()
const logger = createLogger({ level: 'info', stream: sink.stream })
logger.info('config', {
apiKey: 'secret-value',
WIKI_API_KEY: 'secret-value',
apiKeyConfigured: true,
password: 'hunter2',
normal: 'visible',
})
const line = sink.lines.join('')
assert.doesNotMatch(line, /secret-value|hunter2/)
assert.match(line, /apiKey=\[redacted\]/)
assert.match(line, /WIKI_API_KEY=\[redacted\]/)
assert.match(line, /apiKeyConfigured=true/)
assert.match(line, /normal=visible/)
})
test('emits one JSON object per line with error details', () => {
const sink = capture()
const logger = createLogger({ level: 'debug', format: 'json', stream: sink.stream })
const error = Object.assign(new Error('request failed'), { code: 'WIKI_ERROR', status: 502 })
logger.child({ jobId: 'job-1' }).error('export failed', { error, apiKey: 'nope' })
assert.equal(sink.lines.length, 1)
const record = JSON.parse(sink.lines[0])
assert.equal(record.level, 'error')
assert.equal(record.message, 'export failed')
assert.equal(record.jobId, 'job-1')
assert.equal(record.apiKey, '[redacted]')
assert.equal(record.error.message, 'request failed')
assert.equal(record.error.code, 'WIKI_ERROR')
assert.match(record.error.stack, /request failed/)
})
test('silent level disables all output', () => {
const sink = capture()
const logger = createLogger({ level: 'silent', stream: sink.stream })
logger.error('nothing here')
assert.equal(sink.lines.length, 0)
})
+264
View File
@@ -0,0 +1,264 @@
import { createServer } from 'node:http'
const logo = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">
<rect width="120" height="40" rx="6" fill="#0b63b6"/>
<text x="60" y="26" text-anchor="middle" font-family="Arial" font-size="16" fill="white">WIKI</text>
</svg>`
const pages = [
{
id: 1,
path: 'docs',
locale: 'en',
title: 'Documentation',
description: 'Documentation root',
contentType: 'markdown',
isPublished: true,
updatedAt: '2026-01-02T10:00:00.000Z',
render: `
<h1 id="welcome">Documentation</h1>
<p>Start with <a href="/en/docs/install">Install</a>. The Linux details are in
<a href="/en/docs/install/linux#requirements">Linux requirements</a>.</p>
<p><a href="#welcome">Jump to welcome</a></p>
<p><a href="https://example.com/docs">External docs</a></p>
<p><a href="/en/guide">Outside guide</a></p>
<p><a href="/en/docs-other">Excluded sibling</a></p>
<img src="/assets/logo.svg" alt="Wiki logo">
<script>window.evil = true</script>
<iframe src="https://example.com/embed"></iframe>
<p>DOCS_MARKER</p>
`,
},
{
id: 2,
path: 'docs/install',
locale: 'en',
title: 'Installation',
description: 'Install guide',
contentType: 'markdown',
isPublished: true,
updatedAt: '2026-01-03T10:00:00.000Z',
render: `
<h1 id="install">Installation</h1>
<p>Back to <a href="/en/docs">Documentation</a>.</p>
<p>Choose <a href="/en/docs/install/linux">Linux</a>.</p>
<p>INSTALL_MARKER</p>
`,
},
{
id: 3,
path: 'docs/install/linux',
locale: 'en',
title: 'Linux requirements',
description: 'Linux install',
contentType: 'markdown',
isPublished: true,
updatedAt: '2026-01-04T10:00:00.000Z',
render: `
<h1 id="linux">Linux</h1>
<h2 id="requirements">Requirements</h2>
<ul><li>Node.js 22</li><li>PostgreSQL</li></ul>
<p><a href="/en/docs/install">Back to installation</a></p>
<p>LINUX_MARKER</p>
`,
},
{
id: 4,
path: 'docs/other',
locale: 'en',
title: 'Other notes',
description: 'Other notes',
contentType: 'markdown',
isPublished: true,
updatedAt: '2026-01-05T10:00:00.000Z',
render: `<h1>Other notes</h1><p>OTHER_MARKER</p>`,
},
{
id: 5,
path: 'docs/fallback',
locale: 'en',
title: 'Fallback Markdown',
description: 'Rendered from source',
contentType: 'markdown',
isPublished: true,
updatedAt: '2026-01-06T10:00:00.000Z',
render: null,
content: `# Fallback Markdown
This page is rendered from its markdown source. See [Installation](install).
FALLBACK_MARKER
`,
},
{
id: 6,
path: 'docs/draft',
locale: 'en',
title: 'Draft page',
description: 'Unpublished',
contentType: 'markdown',
isPublished: false,
updatedAt: '2026-01-07T10:00:00.000Z',
render: `<h1>Draft page</h1><p>DRAFT_MARKER</p>`,
},
{
id: 7,
path: 'docs-other',
locale: 'en',
title: 'Excluded sibling',
description: 'Not below docs',
contentType: 'markdown',
isPublished: true,
updatedAt: '2026-01-08T10:00:00.000Z',
render: `<h1>Excluded sibling</h1><p>EXCLUDED_SIBLING_MARKER</p>`,
},
{
id: 8,
path: 'guide',
locale: 'en',
title: 'Outside guide page',
description: 'Outside the subtree',
contentType: 'markdown',
isPublished: true,
updatedAt: '2026-01-09T10:00:00.000Z',
render: `<h1>Outside guide page</h1><p>GUIDE_OUTSIDE_MARKER</p>`,
},
]
function pageListItem(page) {
return {
id: page.id,
path: page.path,
locale: page.locale,
title: page.title,
description: page.description,
contentType: page.contentType,
isPublished: page.isPublished,
updatedAt: page.updatedAt,
}
}
function sendJson(response, status, payload) {
const body = JSON.stringify(payload)
response.writeHead(status, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
})
response.end(body)
}
export async function startMockWiki() {
const state = { assetHits: 0, graphqlHits: 0 }
const server = createServer((request, response) => {
const url = new URL(request.url, 'http://127.0.0.1')
if (url.pathname === '/assets/logo.svg') {
state.assetHits += 1
response.writeHead(200, { 'Content-Type': 'image/svg+xml' })
response.end(logo)
return
}
if (url.pathname !== '/graphql' || request.method !== 'POST') {
response.writeHead(404, { 'Content-Type': 'text/plain' })
response.end('not found')
return
}
if (request.headers.authorization !== 'Bearer test-key') {
sendJson(response, 401, { error: 'unauthorized' })
return
}
state.graphqlHits += 1
let raw = ''
request.setEncoding('utf8')
request.on('data', (chunk) => {
raw += chunk
})
request.on('end', () => {
let payload
try {
payload = JSON.parse(raw)
} catch {
sendJson(response, 400, { error: 'bad json' })
return
}
const query = payload.query || ''
const variables = payload.variables || {}
if (query.includes('WikiPrintLocalization')) {
sendJson(response, 200, {
data: {
localization: {
config: { locale: 'en', namespacing: true, namespaces: ['en'] },
},
},
})
return
}
if (query.includes('WikiPrintPages')) {
sendJson(response, 200, {
data: { pages: { list: pages.map(pageListItem) } },
})
return
}
if (query.includes('WikiPrintPageSource')) {
const page = pages.find((candidate) => candidate.id === variables.id)
sendJson(response, 200, {
data: {
pages: {
single: page
? { content: page.content || '', contentType: page.contentType }
: null,
},
},
})
return
}
if (query.includes('WikiPrintPage')) {
const page = pages.find(
(candidate) =>
candidate.path === variables.path && candidate.locale === variables.locale,
)
sendJson(response, 200, {
data: {
pages: {
singleByPath: page
? {
id: page.id,
path: page.path,
locale: page.locale,
title: page.title,
description: page.description,
contentType: page.contentType,
render: page.render,
createdAt: page.updatedAt,
updatedAt: page.updatedAt,
}
: null,
},
},
})
return
}
sendJson(response, 400, { errors: [{ message: 'Unknown mock query' }] })
})
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
return {
url: `http://127.0.0.1:${address.port}`,
state,
pages,
close: () => new Promise((resolve) => server.close(resolve)),
}
}
+165
View File
@@ -0,0 +1,165 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { selectSubtree } from '../src/page-selection.js'
import { normalizeBaseUrl } from '../src/utils.js'
const base = normalizeBaseUrl('https://wiki.example.com')
const pages = [
{ id: 1, path: 'docs', locale: 'en', title: 'Docs', isPublished: true },
{ id: 2, path: 'docs/fallback', locale: 'en', title: 'Fallback', isPublished: true },
{ id: 3, path: 'docs/install', locale: 'en', title: 'Install', isPublished: true },
{ id: 4, path: 'docs/install/linux', locale: 'en', title: 'Linux', isPublished: true },
{ id: 5, path: 'docs/other', locale: 'en', title: 'Other', isPublished: true },
{ id: 6, path: 'docs/draft', locale: 'en', title: 'Draft', isPublished: false },
{ id: 7, path: 'docs-other', locale: 'en', title: 'Excluded sibling', isPublished: true },
{ id: 8, path: 'guide', locale: 'en', title: 'Guide', isPublished: true },
]
test('selects the starting page and path descendants, not similarly named siblings', () => {
const result = selectSubtree({
pages,
baseUrl: base,
startPage: 'docs',
locale: 'en',
})
assert.equal(result.locale, 'en')
assert.equal(result.startPath, 'docs')
assert.deepEqual(
result.pages.map((page) => page.path),
['docs', 'docs/fallback', 'docs/install', 'docs/install/linux', 'docs/other'],
)
assert.deepEqual(
result.pages.map((page) => page.depth),
[0, 1, 1, 2, 1],
)
assert.equal(result.omittedUnpublished, 1)
})
test('can include unpublished descendants explicitly', () => {
const result = selectSubtree({
pages,
baseUrl: base,
startPage: 'docs',
locale: 'en',
includeUnpublished: true,
})
assert.ok(result.pages.some((page) => page.path === 'docs/draft'))
assert.equal(result.omittedUnpublished, 0)
})
test('accepts a full Wiki.js page URL with a locale prefix', () => {
const result = selectSubtree({
pages,
baseUrl: base,
startPage: 'https://wiki.example.com/en/docs/install/',
})
assert.equal(result.startPath, 'docs/install')
assert.deepEqual(
result.pages.map((page) => page.path),
['docs/install', 'docs/install/linux'],
)
})
test('handles a wiki hosted below a URL subpath', () => {
const subpathBase = normalizeBaseUrl('https://example.com/wiki/')
const subpathPages = pages.map((page) => ({ ...page }))
const result = selectSubtree({
pages: subpathPages,
baseUrl: subpathBase,
startPage: 'https://example.com/wiki/en/docs/install',
})
assert.equal(result.startPath, 'docs/install')
assert.deepEqual(
result.pages.map((page) => page.path),
['docs/install', 'docs/install/linux'],
)
})
test('accepts a path with a locale prefix', () => {
const result = selectSubtree({
pages,
baseUrl: base,
startPage: '/en/docs',
})
assert.equal(result.startPath, 'docs')
assert.equal(result.pages.length, 5)
})
test('reports ambiguous locales and honors a default locale', () => {
const multilingual = [
...pages,
{ id: 20, path: 'docs', locale: 'de', title: 'Doku', isPublished: true },
]
assert.throws(
() =>
selectSubtree({
pages: multilingual,
baseUrl: base,
startPage: 'docs',
}),
/multiple locales/,
)
const result = selectSubtree({
pages: multilingual,
baseUrl: base,
startPage: 'docs',
defaultLocale: 'de',
})
assert.equal(result.locale, 'de')
assert.deepEqual(
result.pages.map((page) => page.path),
['docs'],
)
})
test('reports a missing starting page and unknown locales', () => {
assert.throws(
() => selectSubtree({ pages, baseUrl: base, startPage: 'missing', locale: 'en' }),
/was not found/,
)
assert.throws(
() => selectSubtree({ pages, baseUrl: base, startPage: 'docs', locale: 'fr' }),
/Locale "fr" does not exist/,
)
})
test('enforces the page limit', () => {
assert.throws(
() =>
selectSubtree({
pages,
baseUrl: base,
startPage: 'docs',
locale: 'en',
maxPages: 2,
}),
/above the configured limit/,
)
})
test('maps the root URL to Wiki.js home', () => {
const homePages = [
{ id: 1, path: 'home', locale: 'en', title: 'Home', isPublished: true },
{ id: 2, path: 'home/child', locale: 'en', title: 'Child', isPublished: true },
]
const result = selectSubtree({
pages: homePages,
baseUrl: base,
startPage: 'https://wiki.example.com/en/',
})
assert.equal(result.startPath, 'home')
assert.deepEqual(
result.pages.map((page) => page.path),
['home', 'home/child'],
)
})
+100
View File
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict'
import { existsSync } from 'node:fs'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs'
import { exportWikiToPdf } from '../src/exporter.js'
import { closeBrowser } from '../src/pdf-renderer.js'
import { startMockWiki } from './mock-wiki.js'
// The renderer keeps a Chromium instance alive between exports. Close it so
// the test process can exit after the suite finishes.
test.after(async () => {
await closeBrowser()
})
const chromiumPath = [
process.env.CHROMIUM_PATH,
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/opt/google/chrome/chrome',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
]
.filter(Boolean)
.find((candidate) => existsSync(candidate))
test(
'exports a subtree to a PDF with working internal and external links',
{ skip: chromiumPath ? false : 'No Chromium executable found for the smoke test.', timeout: 180_000 },
async () => {
const wiki = await startMockWiki()
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'wiki-print-test-'))
try {
const result = await exportWikiToPdf({
baseUrl: wiki.url,
apiKey: 'test-key',
startPage: 'docs',
maxPages: 50,
outputPath: path.join(tempDir, 'export.pdf'),
})
assert.equal(result.pageCount, 5)
assert.equal(result.locale, 'en')
assert.equal(wiki.state.assetHits, 1, 'the page image should be fetched once for embedding')
const pdfData = new Uint8Array(await readFile(result.pdfPath))
const pdf = await getDocument({
data: pdfData,
disableFontFace: true,
useSystemFonts: true,
}).promise
try {
let text = ''
const annotations = []
for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber += 1) {
const page = await pdf.getPage(pageNumber)
const content = await page.getTextContent()
text += `\n${content.items.map((item) => item.str).join(' ')}`
annotations.push(...(await page.getAnnotations()))
}
assert.ok(pdf.numPages >= 3, `expected several PDF pages, got ${pdf.numPages}`)
assert.match(text, /DOCS_MARKER/)
assert.match(text, /INSTALL_MARKER/)
assert.match(text, /LINUX_MARKER/)
assert.match(text, /OTHER_MARKER/)
assert.match(text, /FALLBACK_MARKER/)
assert.doesNotMatch(text, /DRAFT_MARKER/)
assert.doesNotMatch(text, /EXCLUDED_SIBLING_MARKER/)
assert.doesNotMatch(text, /GUIDE_OUTSIDE_MARKER/)
assert.doesNotMatch(text, /window\.evil/)
const links = annotations.filter((annotation) => annotation.subtype === 'Link')
assert.ok(links.length > 0, 'expected link annotations in the PDF')
assert.ok(
links.some((link) => /example\.com/.test(link.url || link.unsafeUrl || '')),
'expected the external link to remain a URL annotation',
)
assert.ok(
links.some(
(link) =>
link.dest ||
/^#/.test(link.url || '') ||
/^#/.test(link.unsafeUrl || '') ||
link.action === 'GoTo',
),
'expected internal wiki links to stay internal PDF links',
)
} finally {
await pdf.destroy()
}
} finally {
await wiki.close()
await rm(tempDir, { recursive: true, force: true })
}
},
)
+158
View File
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict'
import { createServer } from 'node:http'
import test from 'node:test'
import { WikiClient } from '../src/wiki-client.js'
import { normalizeApiKey, normalizeBaseUrl } from '../src/utils.js'
const listItem = {
id: 1,
path: 'docs',
locale: 'en',
title: 'Docs',
description: 'Documentation',
contentType: 'markdown',
isPublished: true,
updatedAt: '2026-01-02T10:00:00.000Z',
}
function startWiki({ expectedKey = 'test-key' } = {}) {
const requests = []
const server = createServer((request, response) => {
let raw = ''
request.on('data', (chunk) => {
raw += chunk
})
request.on('end', () => {
const authorization = request.headers.authorization || ''
const body = JSON.parse(raw || '{}')
requests.push({ authorization, query: body.query || '', variables: body.variables || {} })
if (authorization !== `Bearer ${expectedKey}`) {
response.writeHead(401, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ error: 'Unauthorized' }))
return
}
const send = (data) => {
response.writeHead(200, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ data }))
}
if (body.query.includes('WikiPrintPages')) {
send({ pages: { list: [listItem] } })
} else if (body.query.includes('WikiPrintLocalization')) {
send({
localization: {
config: { locale: 'en', namespacing: true, namespaces: ['en', 'de'] },
},
})
} else if (body.query.includes('WikiPrintPageSource')) {
send({ pages: { single: { content: '# Docs', contentType: 'markdown' } } })
} else if (body.query.includes('WikiPrintPage')) {
send({
pages: {
singleByPath: {
...listItem,
render: '<h1>Docs</h1>',
createdAt: listItem.updatedAt,
},
},
})
} else {
response.writeHead(400, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ errors: [{ message: 'Unknown query' }] }))
}
})
})
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
resolve({
url: `http://127.0.0.1:${server.address().port}`,
requests,
close: () => new Promise((done) => server.close(done)),
})
})
})
}
test('normalizes API keys from environment-style input', () => {
assert.equal(normalizeApiKey(' test-key '), 'test-key')
assert.equal(normalizeApiKey('Bearer test-key'), 'test-key')
assert.equal(normalizeApiKey('bearer test-key'), 'test-key')
assert.equal(normalizeApiKey(undefined), '')
})
test('uses Wiki.js 2.x queries and Bearer authentication', async () => {
const wiki = await startWiki()
try {
const client = new WikiClient({
baseUrl: normalizeBaseUrl(wiki.url),
apiKey: 'Bearer test-key',
})
assert.equal((await client.listPages())[0].path, 'docs')
assert.equal((await client.getLocalization()).namespacing, true)
assert.equal((await client.getPage('docs', 'en')).render, '<h1>Docs</h1>')
assert.equal((await client.getPageSource(1)).content, '# Docs')
assert.ok(wiki.requests.length >= 4)
assert.ok(wiki.requests.every((request) => request.authorization === 'Bearer test-key'))
const queryFor = (marker) =>
wiki.requests.find((request) => request.query.includes(marker))?.query || ''
// These fields exist in the Wiki.js 2.x schema but require permissions
// beyond read:pages (read:source or write:pages), so requests protected
// with read:pages must not select them.
const listQuery = queryFor('WikiPrintPages')
assert.match(listQuery, /\bpath\b/)
assert.match(listQuery, /\blocale\b/)
assert.match(listQuery, /\bisPublished\b/)
assert.doesNotMatch(listQuery, /\bisPrivate\b/)
assert.doesNotMatch(listQuery, /\bauthorEmail\b/)
assert.doesNotMatch(listQuery, /\bprivateNS\b/)
assert.doesNotMatch(listQuery, /\bcontent\b/)
const pageQuery = queryFor('WikiPrintPage(')
assert.match(pageQuery, /\brender\b/)
// Wiki.js may expose `toc` as a String scalar while returning an array,
// which makes the whole page query fail with INTERNAL_SERVER_ERROR.
// The exporter does not use it.
assert.doesNotMatch(pageQuery, /\btoc\b/)
assert.doesNotMatch(pageQuery, /\bisPublished\b/)
assert.doesNotMatch(pageQuery, /\bisPrivate\b/)
assert.doesNotMatch(pageQuery, /\bauthorEmail\b/)
assert.doesNotMatch(pageQuery, /\bcontent\b/)
const sourceQuery = queryFor('WikiPrintPageSource')
assert.match(sourceQuery, /\bcontent\b/)
assert.doesNotMatch(sourceQuery, /\brender\b/)
const localizationQuery = queryFor('WikiPrintLocalization')
assert.match(localizationQuery, /\blocale\b/)
assert.match(localizationQuery, /\bnamespacing\b/)
assert.match(localizationQuery, /\bnamespaces\b/)
} finally {
await wiki.close()
}
})
test('reports a rejected Wiki.js 2.x API key', async () => {
const wiki = await startWiki({ expectedKey: 'different-key' })
try {
const client = new WikiClient({
baseUrl: normalizeBaseUrl(wiki.url),
apiKey: 'test-key',
timeoutMs: 2_000,
})
await assert.rejects(client.listPages(), (error) => {
assert.equal(error.code, 'WIKI_AUTH_FAILED')
assert.equal(error.status, 401)
return true
})
} finally {
await wiki.close()
}
})