Fix guide editor issues 4-10
Template tests / tests (pull_request) Has been cancelled
Template tests / tests (push) Has been cancelled

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
+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 };
+9 -8
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' }),
'<w:shd w:val="clear" w:fill="F3F4F6"/>'));
}
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' }),
'<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(''));
}
}
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 }) : ''),
+12 -11
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,15 +53,16 @@ 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;
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>');
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'));
+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,
})),
};
+14 -13
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,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);
+32 -32
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,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;