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