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