diff --git a/app/capture.js b/app/capture.js index 7d7db89..fb5e293 100644 --- a/app/capture.js +++ b/app/capture.js @@ -184,6 +184,7 @@ class CaptureService { // the user explicitly presses "Start recording" in the capture bar, so // New Capture never makes the window vanish out from under them. this.session = { guideId, paused: true, count: 0, intervalSec: interval }; + this.sessionNotificationShown = false; if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher(); this.applyInterval(); this.notify('capture:state', this.state()); @@ -196,12 +197,6 @@ class CaptureService { // up — that's what `togglePause` uses to decide whether to tuck the // app away once the user actually starts recording. this.hiddenForSession = Boolean(win && !win.isDestroyed() && win.isVisible()); - try { - new Notification({ - title: 'StepForge is ready to capture', - body: 'Click "Start recording" in the red capture bar when you’re ready. The window tucks away and the red tray icon takes over.', - }).show(); - } catch { /* notifications unavailable on this desktop */ } } } @@ -387,6 +382,15 @@ class CaptureService { await new Promise((r) => setTimeout(r, Number.isFinite(settleMs) ? settleMs : 150)); } // Window hidden and buffer primed — clicks now count. + if (!process.env.STEPFORGE_SCREENSHOT && !this.sessionNotificationShown) { + try { + new Notification({ + title: 'StepForge is recording', + body: 'Use the red tray icon to pause or finish capture.', + }).show(); + this.sessionNotificationShown = true; + } catch { /* notifications unavailable on this desktop */ } + } this.warmingUp = false; }; run().catch(() => { this.warmingUp = false; }); @@ -403,6 +407,7 @@ class CaptureService { this.stopClickFrameBackend(); this.destroySessionTray(); this.session = null; + this.sessionNotificationShown = false; if (this.hiddenForSession) { this.hiddenForSession = false; this.showWindow(); diff --git a/app/main.js b/app/main.js index 14f6544..f929f09 100644 --- a/app/main.js +++ b/app/main.js @@ -424,8 +424,8 @@ function setupIpc() { const p = store.stepImagePath(guideId, stepId, which || 'working'); return p && fs.existsSync(p) ? `file://${p}?v=${fs.statSync(p).mtimeMs}` : null; }); - h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size }) => - store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size)); + h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size, step }) => + store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null)); h('step:resetWorkingImage', ({ guideId, stepId }) => { const p = store.stepImagePath(guideId, stepId, 'original'); const img = nativeImage.createFromPath(p); @@ -579,6 +579,7 @@ function setupIpc() { markdown: '../exporters/markdown', 'html-simple': '../exporters/html', 'html-rich': '../exporters/html', + confluence: '../exporters/confluence', pdf: '../exporters/pdf', gif: '../exporters/gif', 'image-bundle': '../exporters/image-bundle', diff --git a/app/renderer/app.js b/app/renderer/app.js index a57925b..de97e3b 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -611,6 +611,7 @@ class StepForgeApp { const moveItems = folderItems.length ? ['sep', ...folderItems] : []; contextMenu(event.clientX, event.clientY, [ { label: 'Open guide', action: () => this.openGuideAndArmCapture(guide.guideId) }, + { label: 'Rename guide…', action: () => this.renameGuide(guide) }, { label: guide.favorite ? 'Unfavorite' : 'Favorite', action: () => this.toggleFavorite(guide) }, { label: 'Duplicate guide', action: () => this.duplicateGuide(guide.guideId) }, { label: 'Export', action: () => this.openGuideExport(guide.guideId) }, @@ -843,15 +844,18 @@ class StepForgeApp { } } - async renameGuide() { - const guide = this.editorMeta?.guide; + async renameGuide(guide = this.editorMeta?.guide) { if (!guide) return; const title = await dialogs.promptText({ title: 'Rename guide', label: 'Title', value: guide.title }); if (title == null || !title.trim()) return; - guide.title = title.trim(); - await api.guide.save({ guide }); - await this.editor.reload(this.editor.selectedStepId); + const fullGuide = (await api.guide.get({ guideId: guide.guideId })).guide; + fullGuide.title = title.trim(); + await api.guide.save({ guide: fullGuide }); + if (this.state.view === 'editor' && this.editor.guideId === fullGuide.guideId) { + await this.editor.reload(this.editor.selectedStepId); + } await this.refreshLibrary(); + if (this.state.view === 'editor') this.renderTopbar(); } async importArchive(mode = 'copy') { diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 4754b6f..310a086 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -6,6 +6,35 @@ const api = window.stepforge; const dialogs = window.StepForgeDialogs || {}; const clone = (value) => JSON.parse(JSON.stringify(value)); +const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 }; + +function blockText(block) { + for (const key of ['code', 'text', 'body', 'value', 'content']) { + const value = block && block[key]; + if (value != null && value !== '') return String(value); + } + return ''; +} + +function orderedStepBlocks(step) { + const blocks = []; + for (const tb of step.textBlocks || []) blocks.push({ kind: 'text', block: tb }); + for (const cb of step.codeBlocks || []) blocks.push({ kind: 'code', block: cb }); + for (const tbl of step.tableBlocks || []) blocks.push({ kind: 'table', block: tbl }); + return blocks.sort((a, b) => ( + (Number.isFinite(a.block.order) ? a.block.order : 0) - (Number.isFinite(b.block.order) ? b.block.order : 0) + || BLOCK_KIND_ORDER[a.kind] - BLOCK_KIND_ORDER[b.kind] + || String(a.block.id || '').localeCompare(String(b.block.id || '')) + )); +} + +function nextBlockOrder(step) { + return orderedStepBlocks(step).reduce((max, entry) => Math.max(max, Number.isFinite(entry.block.order) ? entry.block.order : 0), 0) + 1; +} + +function blockLabel(kind) { + return kind === 'text' ? 'Text block' : kind === 'code' ? 'Code block' : 'Table'; +} function stepNumberMap(steps) { const numbers = new Map(); @@ -57,6 +86,7 @@ class GuideEditor { this.canvasHistory = []; this.canvasFuture = []; this.beforeCanvasSnapshot = null; + this.draggedBlock = null; this.stepLoadToken = 0; this.imageLoadToken = 0; this.shellMounted = false; @@ -265,7 +295,7 @@ class GuideEditor { // canvas interactions need to snapshot the current step before the drag // mutates it, so undo can restore the pre-edit annotations. this.dom.canvas.addEventListener('pointerdown', () => { - if (this.currentStep) this.beforeCanvasSnapshot = clone(this.currentStep); + if (this.currentStep) this.beforeCanvasSnapshot = { step: clone(this.currentStep) }; }, true); this.canvas = new AnnotationCanvas(this.dom.canvas, { @@ -434,13 +464,13 @@ class GuideEditor { const id = `blk-${Date.now().toString(36)}`; if (kind === 'text') { step.textBlocks = step.textBlocks || []; - step.textBlocks.push({ id, position: 'after-description', level: 'info', title: '', descriptionHtml: '' }); + step.textBlocks.push({ id, order: nextBlockOrder(step), position: 'after-description', level: 'info', title: '', descriptionHtml: '' }); } else if (kind === 'code') { step.codeBlocks = step.codeBlocks || []; - step.codeBlocks.push({ id, language: '', code: '' }); + step.codeBlocks.push({ id, order: nextBlockOrder(step), language: '', code: '' }); } else if (kind === 'table') { step.tableBlocks = step.tableBlocks || []; - step.tableBlocks.push({ id, rows: [['Column A', 'Column B'], ['', '']] }); + step.tableBlocks.push({ id, order: nextBlockOrder(step), rows: [['Column A', 'Column B'], ['', '']] }); } this.pendingSave = true; this.saveStepDebounced(); @@ -458,73 +488,114 @@ class GuideEditor { this.pendingSave = true; this.saveStepDebounced(); }; + const moveBlock = (source, target) => { + if (!source || !target || source.kind === target.kind && source.block.id === target.block.id) return; + const swap = source.block.order; + source.block.order = target.block.order; + target.block.order = swap; + save(); + this.renderBlocksPanel(); + }; const removeBtn = (onRemove) => el('button.icon.danger', { type: 'button', title: 'Remove block', onClick: () => { onRemove(); save(); this.renderBlocksPanel(); }, }, '✕'); - for (const tb of step.textBlocks || []) { - const position = makeSelect(tb.position, [ - { value: 'before-title', label: 'Before title' }, - { value: 'after-title', label: 'After title' }, - { value: 'before-image', label: 'Before image' }, - { value: 'after-image', label: 'After image' }, - { value: 'before-description', label: 'Before description' }, - { value: 'after-description', label: 'After description' }, - ]); - const level = makeSelect(tb.level, [ - { value: 'info', label: 'Note' }, - { value: 'warn', label: 'Warning' }, - { value: 'error', label: 'Important' }, - { value: 'success', label: 'Tip' }, - ]); - const title = el('input', { type: 'text', value: tb.title || '', placeholder: 'Block title' }); - const body = el('textarea', { rows: 2, placeholder: 'Block text' }); - body.value = (tb.descriptionHtml || '').replace(/<[^>]+>/g, ''); - position.addEventListener('change', () => { tb.position = position.value; save(); }); - level.addEventListener('change', () => { tb.level = level.value; save(); }); - title.addEventListener('input', () => { tb.title = title.value; save(); }); - body.addEventListener('input', () => { tb.descriptionHtml = `

${escapeHtml(body.value)}

`; save(); }); - this.dom.blocksList.append(el('div.block-card', {}, - el('div.row', {}, el('strong', {}, 'Text block'), el('span.spacer'), - removeBtn(() => { step.textBlocks = step.textBlocks.filter((b) => b !== tb); })), - el('div.row', {}, level, position), - title, body, - )); + const blocks = orderedStepBlocks(step); + for (const [index, entry] of blocks.entries()) { + const { kind, block } = entry; + const canMoveUp = index > 0; + const canMoveDown = index < blocks.length - 1; + const moveUp = () => moveBlock(entry, blocks[index - 1]); + const moveDown = () => moveBlock(entry, blocks[index + 1]); + + const header = el('div.row', {}, + el('strong', {}, blockLabel(kind)), + el('span.muted', {}, `#${Number.isFinite(block.order) ? block.order : index + 1}`), + el('span.spacer'), + el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp }, '↑'), + el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown }, '↓'), + removeBtn(() => { + if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block); + else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block); + else step.tableBlocks = (step.tableBlocks || []).filter((b) => b !== block); + }), + ); + + const card = el('div.block-card', { + draggable: true, + onDragStart: (e) => { + this.draggedBlock = entry; + if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move'; + }, + onDragOver: (e) => { + if (this.draggedBlock) e.preventDefault(); + }, + onDrop: (e) => { + e.preventDefault(); + if (!this.draggedBlock) return; + moveBlock(this.draggedBlock, entry); + this.draggedBlock = null; + }, + onDragEnd: () => { + this.draggedBlock = null; + }, + }, header); + + if (kind === 'text') { + const position = makeSelect(block.position, [ + { value: 'before-title', label: 'Before title' }, + { value: 'after-title', label: 'After title' }, + { value: 'before-image', label: 'Before image' }, + { value: 'after-image', label: 'After image' }, + { value: 'before-description', label: 'Before description' }, + { value: 'after-description', label: 'After description' }, + ]); + const level = makeSelect(block.level, [ + { value: 'info', label: 'Note' }, + { value: 'warn', label: 'Warning' }, + { value: 'error', label: 'Important' }, + { value: 'success', label: 'Tip' }, + ]); + const title = el('input', { type: 'text', value: block.title || '', placeholder: 'Block title' }); + const body = el('textarea', { rows: 2, placeholder: 'Block text' }); + body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, ''); + position.addEventListener('change', () => { block.position = position.value; save(); }); + level.addEventListener('change', () => { block.level = level.value; save(); }); + title.addEventListener('input', () => { block.title = title.value; save(); }); + body.addEventListener('input', () => { block.descriptionHtml = `

${escapeHtml(body.value)}

`; save(); }); + card.append( + el('div.row', {}, level, position), + title, + body, + ); + } else if (kind === 'code') { + const lang = el('input', { type: 'text', value: block.language || '', placeholder: 'Language (e.g. bash)' }); + const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false }); + code.value = blockText(block); + code.style.fontFamily = 'monospace'; + lang.addEventListener('input', () => { block.language = lang.value; save(); }); + code.addEventListener('input', () => { block.code = code.value; save(); }); + card.append(lang, code); + } else if (kind === 'table') { + const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false }); + grid.value = (block.rows || []).map((r) => r.join(' | ')).join('\n'); + grid.addEventListener('input', () => { + block.rows = grid.value.split('\n').filter((l) => l.trim() !== '') + .map((line) => line.split('|').map((c) => c.trim())); + save(); + }); + card.append( + el('div.muted', {}, 'First line is the header row.'), + grid, + ); + } + + this.dom.blocksList.append(card); } - for (const cb of step.codeBlocks || []) { - const lang = el('input', { type: 'text', value: cb.language || '', placeholder: 'Language (e.g. bash)' }); - const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false }); - code.value = cb.code || ''; - code.style.fontFamily = 'monospace'; - lang.addEventListener('input', () => { cb.language = lang.value; save(); }); - code.addEventListener('input', () => { cb.code = code.value; save(); }); - this.dom.blocksList.append(el('div.block-card', {}, - el('div.row', {}, el('strong', {}, 'Code block'), el('span.spacer'), - removeBtn(() => { step.codeBlocks = step.codeBlocks.filter((b) => b !== cb); })), - lang, code, - )); - } - - for (const tbl of step.tableBlocks || []) { - const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false }); - grid.value = (tbl.rows || []).map((r) => r.join(' | ')).join('\n'); - grid.addEventListener('input', () => { - tbl.rows = grid.value.split('\n').filter((l) => l.trim() !== '') - .map((line) => line.split('|').map((c) => c.trim())); - save(); - }); - this.dom.blocksList.append(el('div.block-card', {}, - el('div.row', {}, el('strong', {}, 'Table'), el('span.spacer'), - removeBtn(() => { step.tableBlocks = step.tableBlocks.filter((b) => b !== tbl); })), - el('div.muted', {}, 'First line is the header row.'), - grid, - )); - } - - if (!(step.textBlocks || []).length && !(step.codeBlocks || []).length && !(step.tableBlocks || []).length) { - this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks render in every export.')); + if (!blocks.length) { + this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks can be reordered with drag handles or arrows.')); } } @@ -906,27 +977,60 @@ class GuideEditor { if (mode === 1.5) this.dom.zoom150Btn.classList.add('active'); } - pushCanvasHistory(label = 'change') { + pushCanvasHistory(recordOrLabel = 'change') { if (!this.currentStep) return; - this.canvasHistory.push(clone(this.currentStep)); + const record = recordOrLabel && typeof recordOrLabel === 'object' && recordOrLabel.step + ? recordOrLabel + : { step: clone(this.currentStep) }; + this.canvasHistory.push(record); if (this.canvasHistory.length > 40) this.canvasHistory.shift(); this.canvasFuture.length = 0; this.beforeCanvasSnapshot = null; } + async snapshotCurrentStep(includeImage = false) { + if (!this.currentStep) return null; + const record = { step: clone(this.currentStep) }; + if (includeImage && this.currentStep.image) { + const image = await this.currentStepImageToBase64(this.currentStep); + if (image) record.image = image; + } + return record; + } + + async restoreHistoryRecord(record) { + if (!record || !record.step) return; + const step = clone(record.step); + this.selectedStepId = step.stepId; + this.beforeCanvasSnapshot = null; + this.saveStepDebounced.cancel(); + this.pendingSave = false; + if (record.image && step.image) { + const saved = await api.step.setWorkingImage({ + guideId: this.guideId, + stepId: step.stepId, + pngBase64: record.image.base64, + size: record.image.size, + step, + }); + this.stepMap.set(saved.stepId, saved); + const idx = this.steps.findIndex((s) => s.stepId === saved.stepId); + if (idx >= 0) this.steps[idx] = saved; + } else { + await this.flushStep(step); + } + } + async undo() { if (!this.currentStep) return; if (!this.canvasHistory.length) { this.onToast('Nothing to undo.'); return; } - this.canvasFuture.push(clone(this.currentStep)); + const current = await this.snapshotCurrentStep(true); + if (current) this.canvasFuture.push(current); const previous = this.canvasHistory.pop(); - this.stepMap.set(previous.stepId, previous); - const prevIdx = this.steps.findIndex((s) => s.stepId === previous.stepId); - if (prevIdx >= 0) this.steps[prevIdx] = previous; - this.selectedStepId = previous.stepId; - await this.flushStep(previous); + await this.restoreHistoryRecord(previous); this.renderAll(); } @@ -936,13 +1040,10 @@ class GuideEditor { this.onToast('Nothing to redo.'); return; } - this.canvasHistory.push(clone(this.currentStep)); + const current = await this.snapshotCurrentStep(true); + if (current) this.canvasHistory.push(current); const next = this.canvasFuture.pop(); - this.stepMap.set(next.stepId, next); - const nextIdx = this.steps.findIndex((s) => s.stepId === next.stepId); - if (nextIdx >= 0) this.steps[nextIdx] = next; - this.selectedStepId = next.stepId; - await this.flushStep(next); + await this.restoreHistoryRecord(next); this.renderAll(); } @@ -1388,8 +1489,7 @@ class GuideEditor { } } - async currentStepImageToBase64() { - const step = this.currentStep; + async currentStepImageToBase64(step = this.currentStep) { if (!step || !step.image) return null; const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' }); if (!file) return null; @@ -1437,6 +1537,13 @@ class GuideEditor { if (!step || !step.image) return; const ok = await confirmDialog('Crop the working image to the selected area?'); if (!ok) return; + this.saveStepDebounced.cancel(); + const snapshot = this.beforeCanvasSnapshot || await this.snapshotCurrentStep(true); + if (snapshot) { + if (!snapshot.image) snapshot.image = await this.currentStepImageToBase64(step); + this.pushCanvasHistory(snapshot); + } + this.beforeCanvasSnapshot = null; const src = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' }); if (!src) return; const img = await loadImage(src); diff --git a/app/renderer/style.css b/app/renderer/style.css index 54255c9..04a5457 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -430,7 +430,18 @@ kbd { pointer-events: none; } +.pane-props section { + display: flex; + flex-direction: column; + gap: 8px; + padding-bottom: 14px; + border-bottom: 1px solid color-mix(in srgb, var(--border) 75%, transparent); +} .pane-props section + section { margin-top: 16px; } +.pane-props section:last-child { + padding-bottom: 0; + border-bottom: 0; +} .pane-props input[type="text"], .pane-props input[type="number"], .pane-props input[type="color"], @@ -511,6 +522,8 @@ kbd { padding: 10px; background: var(--panel-solid); } +.block-card[draggable="true"] { cursor: grab; } +.block-card[draggable="true"]:active { cursor: grabbing; } .annotation-list { display: flex; flex-direction: column; diff --git a/core/blocks.js b/core/blocks.js new file mode 100644 index 0000000..beae4ea --- /dev/null +++ b/core/blocks.js @@ -0,0 +1,41 @@ +'use strict'; + +const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 }; + +function blockText(block) { + if (!block || typeof block !== 'object') return ''; + for (const key of ['code', 'text', 'body', 'value', 'content']) { + const value = block[key]; + if (value != null && value !== '') return String(value); + } + return ''; +} + +function orderedBlocks(step) { + const blocks = []; + for (const tb of step.textBlocks || []) { + blocks.push({ kind: 'text', ...tb }); + } + for (const cb of step.codeBlocks || []) { + blocks.push({ kind: 'code', ...cb, code: blockText(cb) }); + } + for (const tbl of step.tableBlocks || []) { + blocks.push({ kind: 'table', ...tbl }); + } + return blocks.sort((a, b) => ( + (Number.isFinite(a.order) ? a.order : 0) - (Number.isFinite(b.order) ? b.order : 0) + || (BLOCK_KIND_ORDER[a.kind] - BLOCK_KIND_ORDER[b.kind]) + || String(a.id || '').localeCompare(String(b.id || '')) + )); +} + +function nextBlockOrder(step) { + return orderedBlocks(step).reduce((max, block) => Math.max(max, Number.isFinite(block.order) ? block.order : 0), 0) + 1; +} + +module.exports = { + BLOCK_KIND_ORDER, + blockText, + orderedBlocks, + nextBlockOrder, +}; diff --git a/core/renderast.js b/core/renderast.js index 1cdca6d..a7cecef 100644 --- a/core/renderast.js +++ b/core/renderast.js @@ -7,6 +7,7 @@ const { htmlToText, deepClone } = require('./util'); const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders'); const { decodePng } = require('./png'); const { renderAnnotations, applyFocusedView } = require('./raster'); +const { orderedBlocks, blockText } = require('./blocks'); /** * The Render AST is the single normalized document model every exporter @@ -75,8 +76,24 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')), descriptionText: htmlToText(expand(tb.descriptionHtml || '')), })), - codeBlocks: step.codeBlocks || [], - tableBlocks: step.tableBlocks || [], + codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })), + tableBlocks: (step.tableBlocks || []).map((tb) => ({ + ...tb, + rows: Array.isArray(tb.rows) ? tb.rows.map((row) => [...row]) : [], + })), + blocks: orderedBlocks(step).map((block) => { + if (block.kind === 'text') { + return { + ...block, + title: expand(block.title || ''), + descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')), + descriptionText: htmlToText(expand(block.descriptionHtml || '')), + }; + } + if (block.kind === 'code') return { ...block }; + if (block.kind === 'table') return { ...block }; + return { ...block }; + }), links: step.links || [], image: null, }; diff --git a/core/schema.js b/core/schema.js index 907d5fd..84cd151 100644 --- a/core/schema.js +++ b/core/schema.js @@ -1,7 +1,8 @@ 'use strict'; -const { newId, nowIso, deepClone } = require('./util'); +const { newId, nowIso } = require('./util'); const { sanitizeHtml } = require('./sanitize'); +const { blockText } = require('./blocks'); const SCHEMA_VERSION = 1; @@ -49,6 +50,12 @@ function createGuide(fields = {}) { } function createStep(fields = {}) { + let nextOrder = 1; + const takeOrder = (block) => { + const order = Number.isFinite(block && block.order) ? block.order : nextOrder; + nextOrder = Math.max(nextOrder, order + 1); + return order; + }; return { stepId: fields.stepId || newId('step'), parentStepId: fields.parentStepId || null, @@ -69,9 +76,9 @@ function createStep(fields = {}) { image: fields.image || null, // { originalPath, workingPath, size:{width,height} } extraImages: fields.extraImages || [], // multi-image steps annotations: (fields.annotations || []).map(normalizeAnnotation), - textBlocks: (fields.textBlocks || []).map(normalizeTextBlock), - codeBlocks: fields.codeBlocks || [], // { id, language, code } - tableBlocks: fields.tableBlocks || [], // { id, rows: [[cellText,..],..], headerRow } + textBlocks: (fields.textBlocks || []).map((tb) => normalizeTextBlock(tb, takeOrder(tb))), + codeBlocks: (fields.codeBlocks || []).map((cb) => normalizeCodeBlock(cb, takeOrder(cb))), + tableBlocks: (fields.tableBlocks || []).map((tb) => normalizeTableBlock(tb, takeOrder(tb))), links: fields.links || [], // { id, label, targetStepId } }; } @@ -94,16 +101,36 @@ function normalizeAnnotation(a) { return ann; } -function normalizeTextBlock(tb) { +function normalizeTextBlock(tb, order = null) { return { id: tb.id || newId('tb'), position: TEXTBLOCK_POSITIONS.includes(tb.position) ? tb.position : 'after-description', level: TEXTBLOCK_LEVELS.includes(tb.level) ? tb.level : 'info', + order: Number.isFinite(tb.order) ? tb.order : order, title: tb.title || '', descriptionHtml: sanitizeHtml(tb.descriptionHtml || ''), }; } +function normalizeCodeBlock(cb, order = null) { + return { + id: cb.id || newId('cb'), + order: Number.isFinite(cb.order) ? cb.order : order, + language: typeof cb.language === 'string' ? cb.language : '', + code: blockText(cb), + }; +} + +function normalizeTableBlock(tb, order = null) { + return { + id: tb.id || newId('tbl'), + order: Number.isFinite(tb.order) ? tb.order : order, + rows: Array.isArray(tb.rows) + ? tb.rows.map((row) => (Array.isArray(row) ? row.map((cell) => String(cell ?? '')) : [])) + : [], + }; +} + function num(v, fallback) { return Number.isFinite(v) ? v : fallback; } @@ -173,6 +200,8 @@ module.exports = { createStep, normalizeAnnotation, normalizeTextBlock, + normalizeCodeBlock, + normalizeTableBlock, validateGuide, validateStep, normalizeGuide, diff --git a/core/search.js b/core/search.js index 3cf3fd8..21da140 100644 --- a/core/search.js +++ b/core/search.js @@ -2,6 +2,7 @@ const path = require('node:path'); const { writeJsonSync, readJsonIfExists, htmlToText } = require('./util'); +const { blockText } = require('./blocks'); /** * Local full-text search over guide titles, descriptions, step titles/ @@ -57,7 +58,7 @@ class SearchIndex { const parts = [ htmlToText(step.descriptionHtml), ...(step.textBlocks || []).map((tb) => `${tb.title} ${htmlToText(tb.descriptionHtml)}`), - ...(step.codeBlocks || []).map((cb) => cb.code || ''), + ...(step.codeBlocks || []).map((cb) => blockText(cb)), ...(step.annotations || []).map((a) => a.text || ''), ]; this.docs[`s:${guide.guideId}:${step.stepId}`] = { diff --git a/core/store.js b/core/store.js index 8c25661..c66e7da 100644 --- a/core/store.js +++ b/core/store.js @@ -221,9 +221,8 @@ class GuideStore { } saveStep(guideId, step) { - const stored = deepClone(step); + const stored = normalizeStep(deepClone(step)); stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml); - for (const tb of stored.textBlocks || []) tb.descriptionHtml = sanitizeHtml(tb.descriptionHtml); validateStep(stored); writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored); const guide = this.getGuide(guideId); @@ -263,8 +262,8 @@ class GuideStore { } /** Replace the working image (crop result). The original is never touched. */ - setWorkingImage(guideId, stepId, pngBuffer, size) { - const step = this.getStep(guideId, stepId); + setWorkingImage(guideId, stepId, pngBuffer, size, stepPatch = null) { + const step = stepPatch ? deepClone(stepPatch) : this.getStep(guideId, stepId); if (!step.image) throw new Error('step has no image'); atomicWriteFileSync(path.join(this.stepDir(guideId, stepId), step.image.workingPath), pngBuffer); step.image.size = size; diff --git a/core/templates.js b/core/templates.js index 87e4c94..1422383 100644 --- a/core/templates.js +++ b/core/templates.js @@ -11,7 +11,7 @@ const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./ * defaults, shareable as .sfglt zip files. */ -const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx']; +const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx']; class TemplateManager { constructor(templatesDir) { diff --git a/exporters/common.js b/exporters/common.js index 143878b..ee87bf8 100644 --- a/exporters/common.js +++ b/exporters/common.js @@ -5,6 +5,7 @@ const path = require('node:path'); const { slugify } = require('../core/util'); const { encodePng } = require('../core/png'); const { renderStepImage } = require('../core/renderast'); +const { orderedBlocks, blockText } = require('../core/blocks'); /** * Shared exporter helpers: every image-bearing exporter renders annotated @@ -50,6 +51,22 @@ function renderAllImages(ast) { return result; } +function stepBlocks(step) { + return step.blocks || orderedBlocks(step); +} + +function codeBlockText(block) { + return blockText(block); +} + const LEVEL_LABEL = { info: 'Note', warn: 'Warning', error: 'Important', success: 'Tip' }; -module.exports = { guideSlug, imagesDirName, writeStepImages, renderAllImages, LEVEL_LABEL }; +module.exports = { + guideSlug, + imagesDirName, + writeStepImages, + renderAllImages, + stepBlocks, + codeBlockText, + LEVEL_LABEL, +}; diff --git a/exporters/confluence.js b/exporters/confluence.js new file mode 100644 index 0000000..43e2a2b --- /dev/null +++ b/exporters/confluence.js @@ -0,0 +1,130 @@ +'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 }; diff --git a/exporters/docx.js b/exporters/docx.js index c67204f..5ca8972 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -5,7 +5,7 @@ const path = require('node:path'); const { zipSync } = require('../core/zip'); const { escapeXml } = require('../core/util'); const { encodePng } = require('../core/png'); -const { guideSlug, renderAllImages, LEVEL_LABEL } = require('./common'); +const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); /** * DOCX exporter: WordprocessingML built directly (no dependency), one @@ -102,19 +102,20 @@ function exportDocx(ast, outDir, template = {}) { body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips))); } - for (const cb of step.codeBlocks) { - body.push(p(run(cb.code || '', { size: 18, font: 'Courier New', color: '1F2937' }), - '')); - } - for (const tb of step.tableBlocks || []) { - if (tb.rows && tb.rows.length) body.push(table(tb.rows), p('')); + for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { + if (block.kind === 'code') { + body.push(p(run(codeBlockText(block), { size: 18, font: 'Courier New', color: '1F2937' }), + '')); + } else if (block.kind === 'table') { + if (block.rows && block.rows.length) body.push(table(block.rows), p('')); + } } emitTextBlocks(step, 'after-description'); emitTextBlocks(step, 'after-image'); } function emitTextBlocks(step, position) { - for (const tb of step.textBlocks.filter((b) => b.position === position)) { + for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; body.push(p( run(label, { bold: true, size: 20 }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''), diff --git a/exporters/html.js b/exporters/html.js index 6cab834..8b458c6 100644 --- a/exporters/html.js +++ b/exporters/html.js @@ -4,7 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { escapeHtml } = require('../core/util'); const { encodePng } = require('../core/png'); -const { guideSlug, renderAllImages, LEVEL_LABEL } = require('./common'); +const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); /** * HTML exporters. Both variants are fully self-contained single files: @@ -39,7 +39,7 @@ function stepLinkRewrite(html, ast) { } function blocksHtml(step, position) { - return step.textBlocks + return stepBlocks(step) .filter((tb) => tb.position === position) .map((tb) => `
${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}${tb.descriptionHtml ? `
${tb.descriptionHtml}
` : ''}
`) .join('\n'); @@ -53,15 +53,16 @@ function stepBodyHtml(step, ast, images, tpl) { if (img && tpl.includeImages) { parts.push(`Step ${escapeHtml(step.number)}`); } - for (const cb of step.codeBlocks) { - parts.push(`
${escapeHtml(cb.code || '')}
`); - } - for (const tb of step.tableBlocks || []) { - if (!tb.rows || !tb.rows.length) continue; - const [head, ...rest] = tb.rows; - parts.push('' + head.map((c) => ``).join('') + '' - + rest.map((r) => '' + r.map((c) => ``).join('') + '').join('') - + '
${escapeHtml(c)}
${escapeHtml(c)}
'); + for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { + if (block.kind === 'code') { + parts.push(`
${escapeHtml(codeBlockText(block))}
`); + } else if (block.kind === 'table') { + if (!block.rows || !block.rows.length) continue; + const [head, ...rest] = block.rows; + parts.push('' + head.map((c) => ``).join('') + '' + + rest.map((r) => '' + r.map((c) => ``).join('') + '').join('') + + '
${escapeHtml(c)}
${escapeHtml(c)}
'); + } } parts.push(blocksHtml(step, 'after-description')); parts.push(blocksHtml(step, 'after-image')); diff --git a/exporters/index.js b/exporters/index.js index 897feb3..92c5687 100644 --- a/exporters/index.js +++ b/exporters/index.js @@ -3,6 +3,7 @@ const { exportJson } = require('./json'); const { exportMarkdown } = require('./markdown'); const { exportHtmlSimple, exportHtmlRich } = require('./html'); +const { exportConfluence } = require('./confluence'); const { exportPdf } = require('./pdf'); const { exportGifGuide } = require('./gif'); const { exportImageBundle } = require('./image-bundle'); @@ -15,6 +16,7 @@ const EXPORTERS = { markdown: exportMarkdown, 'html-simple': exportHtmlSimple, 'html-rich': exportHtmlRich, + confluence: exportConfluence, pdf: exportPdf, gif: exportGifGuide, 'image-bundle': exportImageBundle, diff --git a/exporters/json.js b/exporters/json.js index 7bc8028..8e9d789 100644 --- a/exporters/json.js +++ b/exporters/json.js @@ -2,7 +2,7 @@ const fs = require('node:fs'); const path = require('node:path'); -const { guideSlug, writeStepImages } = require('./common'); +const { guideSlug, writeStepImages, stepBlocks, codeBlockText } = require('./common'); /** * JSON exporter: structured guide + steps, annotated screenshots written to @@ -42,8 +42,15 @@ function exportJson(ast, outDir, template = {}) { textBlocks: step.textBlocks.map((tb) => ({ position: tb.position, level: tb.level, title: tb.title, descriptionHtml: tb.descriptionHtml, })), - codeBlocks: step.codeBlocks, + codeBlocks: step.codeBlocks.map((cb) => ({ ...cb, code: codeBlockText(cb) })), tableBlocks: step.tableBlocks, + blocks: stepBlocks(step).map((block) => ( + block.kind === 'text' + ? { ...block } + : block.kind === 'code' + ? { ...block, code: codeBlockText(block) } + : { ...block } + )), links: step.links, })), }; diff --git a/exporters/markdown.js b/exporters/markdown.js index abd9bd0..3100f97 100644 --- a/exporters/markdown.js +++ b/exporters/markdown.js @@ -2,7 +2,7 @@ const fs = require('node:fs'); const path = require('node:path'); -const { guideSlug, writeStepImages, LEVEL_LABEL } = require('./common'); +const { guideSlug, writeStepImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); const { htmlToMarkdown } = require('./htmlmd'); /** @@ -58,17 +58,18 @@ function exportMarkdown(ast, outDir, template = {}) { } } - for (const cb of step.codeBlocks) { - lines.push(`\`\`\`${cb.language || ''}`, cb.code || '', '```', ''); - } - for (const tb of step.tableBlocks || []) { - if (!tb.rows || !tb.rows.length) continue; - const width = Math.max(...tb.rows.map((r) => r.length)); - const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; }; - lines.push(`| ${pad(tb.rows[0]).join(' | ')} |`); - lines.push(`|${' --- |'.repeat(width)}`); - for (const row of tb.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`); - lines.push(''); + for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { + if (block.kind === 'code') { + lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', ''); + } else if (block.kind === 'table') { + if (!block.rows || !block.rows.length) continue; + const width = Math.max(...block.rows.map((r) => r.length)); + const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; }; + lines.push(`| ${pad(block.rows[0]).join(' | ')} |`); + lines.push(`|${' --- |'.repeat(width)}`); + for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`); + lines.push(''); + } } emitBlocks(lines, step, 'after-description'); @@ -81,7 +82,7 @@ function exportMarkdown(ast, outDir, template = {}) { } function emitBlocks(lines, step, position) { - for (const tb of step.textBlocks.filter((b) => b.position === position)) { + for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { const label = LEVEL_LABEL[tb.level] || 'Note'; lines.push(`> **${label}${tb.title ? `: ${tb.title}` : ''}**`); const body = htmlToMarkdown(tb.descriptionHtml); diff --git a/exporters/pdf.js b/exporters/pdf.js index 89c862c..950c10e 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -3,7 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { PdfBuilder } = require('../core/pdf'); -const { guideSlug, renderAllImages, LEVEL_LABEL } = require('./common'); +const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); const { htmlToText } = require('../core/util'); /** @@ -104,38 +104,38 @@ function exportPdf(ast, outDir, template = {}) { y += h + 10; } - for (const cb of step.codeBlocks) { - const lines = String(cb.code || '').split('\n'); - const lineH = 9 * 1.3; - ensure(Math.min(lines.length, 4) * lineH + 12); - const boxH = lines.length * lineH + 10; - pdf.rect(M, y, usableW, Math.min(boxH, size.height - M - y), { fill: [243, 244, 246] }); - y += 6; - for (const line of lines) { - ensure(lineH); - pdf.text(line.slice(0, 95), M + 8, y, { size: 9, font: 'F3', color: [31, 41, 55] }); - y += lineH; - } - y += 10; - } - - for (const tb of step.tableBlocks || []) { - if (!tb.rows || !tb.rows.length) continue; - const cols = Math.max(...tb.rows.map((r) => r.length)); - const colW = usableW / cols; - for (let r = 0; r < tb.rows.length; r++) { - const rowH = 16; - ensure(rowH + 2); - if (r === 0) pdf.rect(M, y, usableW, rowH, { fill: [238, 240, 244] }); - pdf.rect(M, y, usableW, rowH, { stroke: [200, 204, 210], lineWidth: 0.6 }); - for (let c = 0; c < cols; c++) { - pdf.text(String(tb.rows[r][c] ?? '').slice(0, Math.floor(colW / 5)), M + c * colW + 4, y + 3, { - size: 9, font: r === 0 ? 'F2' : 'F1', - }); + for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { + if (block.kind === 'code') { + const lines = String(codeBlockText(block) || '').split('\n'); + const lineH = 9 * 1.3; + ensure(Math.min(lines.length, 4) * lineH + 12); + const boxH = lines.length * lineH + 10; + pdf.rect(M, y, usableW, Math.min(boxH, size.height - M - y), { fill: [243, 244, 246] }); + y += 6; + for (const line of lines) { + ensure(lineH); + pdf.text(line.slice(0, 95), M + 8, y, { size: 9, font: 'F3', color: [31, 41, 55] }); + y += lineH; } - y += rowH; + y += 10; + } else if (block.kind === 'table') { + if (!block.rows || !block.rows.length) continue; + const cols = Math.max(...block.rows.map((r) => r.length)); + const colW = usableW / cols; + for (let r = 0; r < block.rows.length; r++) { + const rowH = 16; + ensure(rowH + 2); + if (r === 0) pdf.rect(M, y, usableW, rowH, { fill: [238, 240, 244] }); + pdf.rect(M, y, usableW, rowH, { stroke: [200, 204, 210], lineWidth: 0.6 }); + for (let c = 0; c < cols; c++) { + pdf.text(String(block.rows[r][c] ?? '').slice(0, Math.floor(colW / 5)), M + c * colW + 4, y + 3, { + size: 9, font: r === 0 ? 'F2' : 'F1', + }); + } + y += rowH; + } + y += 8; } - y += 8; } emitBlocks(step, 'after-description'); @@ -144,7 +144,7 @@ function exportPdf(ast, outDir, template = {}) { } function emitBlocks(step, position) { - for (const tb of step.textBlocks.filter((b) => b.position === position)) { + for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; const bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : []; const blockH = 16 + bodyLines.length * 13; diff --git a/tests/unit/blocks.test.js b/tests/unit/blocks.test.js new file mode 100644 index 0000000..6070c2e --- /dev/null +++ b/tests/unit/blocks.test.js @@ -0,0 +1,22 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { normalizeStep } = require('../../core/schema'); +const { orderedBlocks, blockText } = require('../../core/blocks'); + +test('block normalization recovers legacy code fields and preserves order', () => { + const step = normalizeStep({ + stepId: 'step-1', + kind: 'content', + title: 'Block test', + textBlocks: [{ id: 'tb1', order: 2, position: 'after-description', level: 'info', title: 'Note', descriptionHtml: '

Text

' }], + codeBlocks: [{ id: 'cb1', order: 1, language: 'bash', text: 'echo hi' }], + tableBlocks: [{ id: 'tbl1', order: 3, rows: [['A', 'B'], ['1', '2']] }], + }); + + assert.equal(step.codeBlocks[0].code, 'echo hi'); + assert.equal(blockText(step.codeBlocks[0]), 'echo hi'); + assert.deepEqual(orderedBlocks(step).map((block) => block.kind), ['code', 'text', 'table']); +}); diff --git a/tests/unit/exporters-binary.test.js b/tests/unit/exporters-binary.test.js index e9060d2..df45d3f 100644 --- a/tests/unit/exporters-binary.test.js +++ b/tests/unit/exporters-binary.test.js @@ -237,6 +237,6 @@ test('a saved template changes exporter behavior through runExport', (t) => { const withTemplate = runExport('pdf', ast, path.join(root, 'out2'), tm.load('pdf', 'no-cover')); assert.ok(withTemplate.pageCount < withDefaults.pageCount, 'dropping cover+toc reduces pages'); - assert.equal(Object.keys(EXPORTERS).length, 9, 'all nine formats wired'); + assert.equal(Object.keys(EXPORTERS).length, 10, 'all ten formats wired'); assert.throws(() => runExport('exe', ast, path.join(root, 'out3'))); }); diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js index 8d18da5..45c2dc4 100644 --- a/tests/unit/exporters-text.test.js +++ b/tests/unit/exporters-text.test.js @@ -9,6 +9,7 @@ const { buildRenderAst, renderStepImage } = require('../../core/renderast'); const { exportJson } = require('../../exporters/json'); const { exportMarkdown } = require('../../exporters/markdown'); const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html'); +const { exportConfluence } = require('../../exporters/confluence'); const { htmlToMarkdown } = require('../../exporters/htmlmd'); const { decodePng } = require('../../core/png'); const { buildFixtureGuide } = require('./fixture-guide'); @@ -114,6 +115,31 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t) assert.equal(lines[warnIdx + 1], '> Admins only.'); }); +test('Confluence export writes storage-format XML and image attachments', (t) => { + const root = makeTmpDir('expconf'); + t.after(() => rmrf(root)); + const { store, guide } = buildFixtureGuide(path.join(root, 'data')); + const out = path.join(root, 'out'); + + const ast = buildRenderAst(store, guide.guideId); + const { file, attachmentCount } = exportConfluence(ast, out); + const xml = fs.readFileSync(file, 'utf8'); + + assert.equal(attachmentCount, 2); + assert.ok(xml.includes('')); + assert.ok(xml.includes('ri:attachment ri:filename=')); + assert.ok(xml.includes('0 2 * * * /usr/local/bin/acmesync --backup')); + + const attachmentsDir = path.join(out, 'configure-acmesync-backups-attachments'); + const files = fs.readdirSync(attachmentsDir); + assert.equal(files.length, 2); + for (const name of files) { + const img = decodePng(fs.readFileSync(path.join(attachmentsDir, name))); + assert.equal(img.width, 320); + assert.equal(img.height, 200); + } +}); + test('Simple HTML export is self-contained with valid embedded images', (t) => { const root = makeTmpDir('exphtml'); t.after(() => rmrf(root));