'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, stepBlocks, codeBlockText } = require('./common'); /** * 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, }; const MACRO_FOR_LEVEL = { info: 'info', warn: 'warning', error: 'note', success: 'tip', }; function anchorFor(step) { return `step-${step.number.replace(/\./g, '-')}`; } 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 parts = [``, `

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

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

(skipped)

'); for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'before-description')) { parts.push(blockMacro(tb, ast)); } if (step.descriptionHtml) { parts.push(`
${stepLinkRewrite(step.descriptionHtml, ast)}
`); } const attachment = attachmentNames.get(step.stepId); if (attachment) { parts.push(`

`); } for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { 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('')}
`); } } for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-description')) { parts.push(blockMacro(tb, ast)); } for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-image')) { parts.push(blockMacro(tb, ast)); } return `
${parts.join('\n')}
`; }).join('\n'); const html = ` ${escapeXml(ast.guide.title)}

${escapeXml(ast.guide.title)}

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