Fix guide editor issues 4-10
Template tests / tests (push) Waiting to run
Template tests / tests (pull_request) Waiting to run

This commit is contained in:
Iisyourdad
2026-06-12 11:07:57 -05:00
parent d966ac762d
commit f88ff0259e
22 changed files with 598 additions and 174 deletions
+11 -6
View File
@@ -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 youre 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();
+3 -2
View File
@@ -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',
+8 -4
View File
@@ -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 });
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') {
+164 -57
View File
@@ -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,13 +488,62 @@ 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, [
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' },
@@ -472,59 +551,51 @@ class GuideEditor {
{ value: 'before-description', label: 'Before description' },
{ value: 'after-description', label: 'After description' },
]);
const level = makeSelect(tb.level, [
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: tb.title || '', placeholder: 'Block title' });
const title = el('input', { type: 'text', value: block.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 = `<p>${escapeHtml(body.value)}</p>`; 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); })),
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 = `<p>${escapeHtml(body.value)}</p>`; save(); });
card.append(
el('div.row', {}, level, position),
title, body,
));
}
for (const cb of step.codeBlocks || []) {
const lang = el('input', { type: 'text', value: cb.language || '', placeholder: 'Language (e.g. bash)' });
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 = cb.code || '';
code.value = blockText(block);
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 || []) {
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 = (tbl.rows || []).map((r) => r.join(' | ')).join('\n');
grid.value = (block.rows || []).map((r) => r.join(' | ')).join('\n');
grid.addEventListener('input', () => {
tbl.rows = grid.value.split('\n').filter((l) => l.trim() !== '')
block.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); })),
card.append(
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.'));
this.dom.blocksList.append(card);
}
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);
+13
View File
@@ -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;
+41
View File
@@ -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,
};
+19 -2
View File
@@ -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,
};
+34 -5
View File
@@ -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,
+2 -1
View File
@@ -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}`] = {
+3 -4
View File
@@ -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;
+1 -1
View File
@@ -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) {
+18 -1
View File
@@ -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,
};
+130
View File
@@ -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 `<![CDATA[${String(text || '').replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
}
function blockMacro(tb, ast) {
const macro = MACRO_FOR_LEVEL[tb.level] || 'note';
const title = tb.title ? `<ac:parameter ac:name="title">${escapeXml(tb.title)}</ac:parameter>` : '';
const body = tb.descriptionHtml ? `<div>${stepLinkRewrite(tb.descriptionHtml, ast)}</div>` : '<p />';
return `<ac:structured-macro ac:name="${macro}">${title}<ac:rich-text-body>${body}</ac:rich-text-body></ac:structured-macro>`;
}
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 = [`<a id="${anchorFor(step)}"></a>`, `<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`];
if (step.skipped) parts.push('<p><em>(skipped)</em></p>');
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(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
}
const attachment = attachmentNames.get(step.stepId);
if (attachment) {
parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`);
}
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
if (block.kind === 'code') {
const lang = block.language ? `<ac:parameter ac:name="language">${escapeXml(block.language)}</ac:parameter>` : '';
parts.push(`<ac:structured-macro ac:name="code">${lang}<ac:plain-text-body>${cdata(codeBlockText(block))}</ac:plain-text-body></ac:structured-macro>`);
} 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) => (
`<tr>${Array.from({ length: width }, (_, i) => {
const cell = escapeXml(row[i] ?? '');
return rowIndex === 0 ? `<th>${cell}</th>` : `<td>${cell}</td>`;
}).join('')}</tr>`
));
parts.push(`<table><tbody>${rows.join('')}</tbody></table>`);
}
}
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 `<div class="step">${parts.join('\n')}</div>`;
}).join('\n');
const html = `<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:ac="http://atlassian.com/content"
xmlns:ri="http://atlassian.com/resource/identifier">
<head>
<title>${escapeXml(ast.guide.title)}</title>
</head>
<body>
<h1>${escapeXml(ast.guide.title)}</h1>
${ast.guide.descriptionHtml ? `<div>${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
${stepXml}
</body>
</html>
`;
const file = path.join(outDir, `${guideSlug(ast)}.confluence.xml`);
fs.writeFileSync(file, html);
return { file, attachmentCount: images.size };
}
module.exports = { exportConfluence, DEFAULT_TEMPLATE };
+7 -6
View File
@@ -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 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' }),
'<w:shd w:val="clear" w:fill="F3F4F6"/>'));
} else if (block.kind === 'table') {
if (block.rows && block.rows.length) body.push(table(block.rows), p(''));
}
for (const tb of step.tableBlocks || []) {
if (tb.rows && tb.rows.length) body.push(table(tb.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 }) : ''),
+9 -8
View File
@@ -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) => `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`)
.join('\n');
@@ -53,16 +53,17 @@ function stepBodyHtml(step, ast, images, tpl) {
if (img && tpl.includeImages) {
parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
}
for (const cb of step.codeBlocks) {
parts.push(`<pre class="code"><code>${escapeHtml(cb.code || '')}</code></pre>`);
}
for (const tb of step.tableBlocks || []) {
if (!tb.rows || !tb.rows.length) continue;
const [head, ...rest] = tb.rows;
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
if (block.kind === 'code') {
parts.push(`<pre class="code"><code>${escapeHtml(codeBlockText(block))}</code></pre>`);
} else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue;
const [head, ...rest] = block.rows;
parts.push('<table><thead><tr>' + head.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr></thead><tbody>'
+ rest.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('')
+ '</tbody></table>');
}
}
parts.push(blocksHtml(step, 'after-description'));
parts.push(blocksHtml(step, 'after-image'));
return parts.filter(Boolean).join('\n');
+2
View File
@@ -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,
+9 -2
View File
@@ -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,
})),
};
+11 -10
View File
@@ -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,18 +58,19 @@ 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));
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(tb.rows[0]).join(' | ')} |`);
lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
lines.push(`|${' --- |'.repeat(width)}`);
for (const row of tb.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
lines.push('');
}
}
emitBlocks(lines, step, 'after-description');
emitBlocks(lines, step, 'after-image');
@@ -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);
+11 -11
View File
@@ -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,8 +104,9 @@ function exportPdf(ast, outDir, template = {}) {
y += h + 10;
}
for (const cb of step.codeBlocks) {
const lines = String(cb.code || '').split('\n');
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;
@@ -117,19 +118,17 @@ function exportPdf(ast, outDir, template = {}) {
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));
} 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 < tb.rows.length; r++) {
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(tb.rows[r][c] ?? '').slice(0, Math.floor(colW / 5)), M + c * colW + 4, y + 3, {
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',
});
}
@@ -137,6 +136,7 @@ function exportPdf(ast, outDir, template = {}) {
}
y += 8;
}
}
emitBlocks(step, 'after-description');
emitBlocks(step, 'after-image');
@@ -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;
+22
View File
@@ -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: '<p>Text</p>' }],
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']);
});
+1 -1
View File
@@ -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')));
});
+26
View File
@@ -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('<ac:structured-macro ac:name="code">'));
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));