'use strict'; const fs = require('node:fs'); const path = require('node:path'); const { slugify, escapeXml } = require('../core/util'); const { encodePng } = require('../core/png'); const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require('./common'); const { anchorFor, guideMetaLines, guideSummary } = require('./document-layout'); /** * Confluence storage-format export. Writes a single XHTML document plus a * sidecar attachments folder containing the rendered screenshots referenced * by the page. */ const DEFAULT_TEMPLATE = { includeImages: true, toc: true, }; const MACRO_FOR_LEVEL = { info: 'info', warn: 'warning', error: 'note', success: 'tip', }; function stepLinkRewrite(html, ast) { return String(html || '').replace(/href="step:([^"]+)"/g, (m, id) => { const target = ast.steps.find((s) => s.stepId === id); return target ? `href="#${anchorFor(target)}"` : 'data-missing-step-link="true"'; }); } function cdata(text) { return `/g, ']]]]>')}]]>`; } function blockMacro(tb, ast) { const macro = MACRO_FOR_LEVEL[tb.level] || 'note'; const title = tb.title ? `${escapeXml(tb.title)}` : ''; const body = tb.descriptionHtml ? `
${stepLinkRewrite(tb.descriptionHtml, ast)}
` : '

'; return `${title}${body}`; } function exportConfluence(ast, outDir, template = {}) { const tpl = { ...DEFAULT_TEMPLATE, ...template }; fs.mkdirSync(outDir, { recursive: true }); const images = tpl.includeImages ? renderAllImages(ast) : new Map(); const attachmentDir = path.join(outDir, `${guideSlug(ast)}-attachments`); fs.mkdirSync(attachmentDir, { recursive: true }); let attachmentCount = 0; const attachmentNames = new Map(); for (const step of ast.steps) { const img = images.get(step.stepId); if (!img) continue; attachmentCount += 1; const fileName = `${String(attachmentCount).padStart(3, '0')}-${slugify(step.title || step.stepId, step.stepId)}.png`; fs.writeFileSync(path.join(attachmentDir, fileName), encodePng(img)); attachmentNames.set(step.stepId, fileName); } const stepXml = ast.steps.map((step) => { const { beforeTitle, afterTitle, beforeDescription, afterDescription, beforeImage, afterImage, rest, } = stepContentGroups(step); const parts = [``]; for (const tb of beforeTitle) { parts.push(blockMacro(tb, ast)); } parts.push(`

${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}

`); if (step.skipped) parts.push('

(skipped)

'); for (const tb of afterTitle) { parts.push(blockMacro(tb, ast)); } for (const tb of beforeDescription) { parts.push(blockMacro(tb, ast)); } if (step.descriptionHtml) { parts.push(`
${stepLinkRewrite(step.descriptionHtml, ast)}
`); } for (const tb of afterDescription) { parts.push(blockMacro(tb, ast)); } for (const tb of beforeImage) { parts.push(blockMacro(tb, ast)); } const attachment = attachmentNames.get(step.stepId); if (attachment) { parts.push(`

`); } for (const tb of afterImage) { parts.push(blockMacro(tb, ast)); } for (const block of rest) { if (block.kind === 'text') { parts.push(blockMacro(block, ast)); } else if (block.kind === 'code') { const lang = block.language ? `${escapeXml(block.language)}` : ''; parts.push(`${lang}${cdata(codeBlockText(block))}`); } else if (block.kind === 'table') { if (!block.rows || !block.rows.length) continue; const width = Math.max(...block.rows.map((row) => row.length)); const rows = block.rows.map((row, rowIndex) => ( `${Array.from({ length: width }, (_, i) => { const cell = escapeXml(row[i] ?? ''); return rowIndex === 0 ? `${cell}` : `${cell}`; }).join('')}` )); parts.push(`${rows.join('')}
`); } } return `
${parts.join('\n')}
`; }).join('\n'); const html = ` ${escapeXml(ast.guide.title)}

${escapeXml(ast.guide.title)}

${guideMetaLines(ast).map((line) => `

${escapeXml(line.split(': ')[0])}: ${escapeXml(line.slice(line.indexOf(': ') + 2))}

`).join('\n')}

${escapeXml(guideSummary(ast))}

${ast.guide.descriptionHtml ? `
${stepLinkRewrite(ast.guide.descriptionHtml, ast)}
` : ''} ${tpl.toc && ast.steps.length > 1 ? '' : ''} ${stepXml} `; const file = path.join(outDir, `${guideSlug(ast)}.confluence.xml`); fs.writeFileSync(file, html); return { file, attachmentCount: images.size }; } module.exports = { exportConfluence, DEFAULT_TEMPLATE };