Honor text block positions in exports

This commit is contained in:
2026-06-23 16:44:28 -05:00
parent 2b27428849
commit ffaa123893
8 changed files with 285 additions and 55 deletions
+43 -8
View File
@@ -56,17 +56,52 @@ function stepBlocks(step) {
} }
/** /**
* Split a step's blocks into the groups exporters lay out around the * Split a step's blocks into the layout buckets exporters use around the
* description/image: text blocks pinned to 'before-description', and * step title, description, and image. Text blocks keep their block-list
* everything else (code/table blocks plus 'after-description' and * ordering inside each bucket; code/table blocks stay in `rest`.
* 'after-image' text blocks) in the same relative order they appear in the
* editor's Blocks list.
*/ */
function stepContentGroups(step) { function stepContentGroups(step) {
const all = stepBlocks(step); const all = stepBlocks(step);
const before = all.filter((b) => b.kind === 'text' && b.position === 'before-description'); const groups = {
const rest = all.filter((b) => !(b.kind === 'text' && b.position === 'before-description')); beforeTitle: [],
return { before, rest }; afterTitle: [],
beforeDescription: [],
afterDescription: [],
beforeImage: [],
afterImage: [],
rest: [],
};
for (const block of all) {
if (block.kind !== 'text') {
groups.rest.push(block);
continue;
}
switch (block.position) {
case 'before-title':
groups.beforeTitle.push(block);
break;
case 'after-title':
groups.afterTitle.push(block);
break;
case 'before-image':
groups.beforeImage.push(block);
break;
case 'after-image':
groups.afterImage.push(block);
break;
case 'before-description':
groups.beforeDescription.push(block);
break;
case 'after-description':
default:
groups.afterDescription.push(block);
break;
}
}
return groups;
} }
function codeBlockText(block) { function codeBlockText(block) {
+29 -4
View File
@@ -62,11 +62,25 @@ function exportConfluence(ast, outDir, template = {}) {
} }
const stepXml = ast.steps.map((step) => { const stepXml = ast.steps.map((step) => {
const parts = [`<a id="${anchorFor(step)}"></a>`, `<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`]; const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
const parts = [`<a id="${anchorFor(step)}"></a>`];
for (const tb of beforeTitle) {
parts.push(blockMacro(tb, ast));
}
parts.push(`<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`);
if (step.skipped) parts.push('<p><em>(skipped)</em></p>'); if (step.skipped) parts.push('<p><em>(skipped)</em></p>');
for (const tb of afterTitle) {
const { before, rest } = stepContentGroups(step); parts.push(blockMacro(tb, ast));
for (const tb of before) { }
for (const tb of beforeDescription) {
parts.push(blockMacro(tb, ast)); parts.push(blockMacro(tb, ast));
} }
@@ -74,11 +88,22 @@ function exportConfluence(ast, outDir, template = {}) {
parts.push(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`); parts.push(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
} }
for (const tb of afterDescription) {
parts.push(blockMacro(tb, ast));
}
for (const tb of beforeImage) {
parts.push(blockMacro(tb, ast));
}
const attachment = attachmentNames.get(step.stepId); const attachment = attachmentNames.get(step.stepId);
if (attachment) { if (attachment) {
parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`); parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`);
} }
for (const tb of afterImage) {
parts.push(blockMacro(tb, ast));
}
for (const block of rest) { for (const block of rest) {
if (block.kind === 'text') { if (block.kind === 'text') {
parts.push(blockMacro(block, ast)); parts.push(blockMacro(block, ast));
+18 -5
View File
@@ -10,8 +10,8 @@ const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout'
/** /**
* DOCX exporter: WordprocessingML built directly (no dependency), one * DOCX exporter: WordprocessingML built directly (no dependency), one
* heading + description + screenshot per step, text blocks, code blocks * heading per step with positioned text blocks around the description and
* (Courier), and tables. * screenshot, plus code blocks (Courier) and tables.
*/ */
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
@@ -274,16 +274,27 @@ function exportDocx(ast, outDir, template = {}) {
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22; const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
const bookmarkId = ++bookmarkCounter; const bookmarkId = ++bookmarkCounter;
const anchor = bookmarkName(step); const anchor = bookmarkName(step);
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of beforeTitle) emitTextBlock(tb);
body.push(p( body.push(p(
`<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` + `<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` +
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) + run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) +
`<w:bookmarkEnd w:id="${bookmarkId}"/>`, `<w:bookmarkEnd w:id="${bookmarkId}"/>`,
headingParagraphProps(step.depth, step.forceNewPage) headingParagraphProps(step.depth, step.forceNewPage)
)); ));
for (const tb of afterTitle) emitTextBlock(tb);
const { before, rest } = stepContentGroups(step); for (const tb of beforeDescription) emitTextBlock(tb);
for (const tb of before) emitTextBlock(tb);
if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' }))); if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' })));
for (const tb of afterDescription) emitTextBlock(tb);
for (const tb of beforeImage) emitTextBlock(tb);
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img) { if (img) {
@@ -295,6 +306,8 @@ function exportDocx(ast, outDir, template = {}) {
stepImageCount += 1; stepImageCount += 1;
} }
for (const tb of afterImage) emitTextBlock(tb);
for (const block of rest) { for (const block of rest) {
if (block.kind === 'text') { if (block.kind === 'text') {
emitTextBlock(block); emitTextBlock(block);
+28 -4
View File
@@ -40,6 +40,10 @@ function blockHtml(tb) {
return `<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>`; return `<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>`;
} }
function renderBlockListHtml(blocks) {
return blocks.map((block) => blockHtml(block)).join('\n');
}
function renderMetaChips(ast) { function renderMetaChips(ast) {
return [ return [
...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`), ...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`),
@@ -85,11 +89,22 @@ function renderStepCard(step, ast, images, tpl, { rich = false, selected = false
<span class="step-title">${escapeHtml(step.title || 'Untitled step')}</span> <span class="step-title">${escapeHtml(step.title || 'Untitled step')}</span>
<span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span> <span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
</h2>` : `<h2>${title}</h2>`; </h2>` : `<h2>${title}</h2>`;
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
return ` return `
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}"> <section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
${renderBlockListHtml(beforeTitle)}
${head} ${head}
${renderBlockListHtml(afterTitle)}
<div class="step-body"> <div class="step-body">
${stepBodyHtml(step, ast, images, tpl)} ${renderStepBodyHtml({ beforeDescription, afterDescription, beforeImage, afterImage, rest }, step, ast, images, tpl)}
</div> </div>
</section>`; </section>`;
} }
@@ -117,15 +132,24 @@ function bodyStyle(tpl) {
return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`; return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`;
} }
function stepBodyHtml(step, ast, images, tpl) { function renderStepBodyHtml(groups, step, ast, images, tpl) {
const parts = []; const parts = [];
const { before, rest } = stepContentGroups(step); const {
for (const tb of before) parts.push(blockHtml(tb)); beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = groups;
for (const tb of beforeDescription) parts.push(blockHtml(tb));
if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`); if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
for (const tb of afterDescription) parts.push(blockHtml(tb));
for (const tb of beforeImage) parts.push(blockHtml(tb));
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img && tpl.includeImages) { if (img && tpl.includeImages) {
parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`); parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
} }
for (const tb of afterImage) parts.push(blockHtml(tb));
for (const block of rest) { for (const block of rest) {
if (block.kind === 'text') { if (block.kind === 'text') {
parts.push(blockHtml(block)); parts.push(blockHtml(block));
+15 -4
View File
@@ -96,15 +96,25 @@ function renderMarkdownGuide(ast, outDir, template = {}, {
for (const step of ast.steps) { for (const step of ast.steps) {
const heading = step.depth > 0 ? '###' : '##'; const heading = step.depth > 0 ? '###' : '##';
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
lines.push(`<a id="${anchorFor(step)}"></a>`, ''); lines.push(`<a id="${anchorFor(step)}"></a>`, '');
for (const tb of beforeTitle) emitBlock(lines, tb, { alertStyle });
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, ''); lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
if (step.skipped) lines.push('*(skipped)*', ''); if (step.skipped) lines.push('*(skipped)*', '');
for (const tb of afterTitle) emitBlock(lines, tb, { alertStyle });
const { before, rest } = stepContentGroups(step); for (const tb of beforeDescription) emitBlock(lines, tb, { alertStyle });
for (const tb of before) emitBlock(lines, tb, { alertStyle });
if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), ''); if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
for (const tb of afterDescription) emitBlock(lines, tb, { alertStyle });
for (const tb of beforeImage) emitBlock(lines, tb, { alertStyle });
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img) { if (img) {
if (tpl.azureWiki && tpl.imageMaxWidth > 0) { if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
@@ -113,6 +123,7 @@ function renderMarkdownGuide(ast, outDir, template = {}, {
lines.push(`![Step ${step.number}](${img.relPath})`, ''); lines.push(`![Step ${step.number}](${img.relPath})`, '');
} }
} }
for (const tb of afterImage) emitBlock(lines, tb, { alertStyle });
for (const block of rest) { for (const block of rest) {
if (block.kind === 'text') { if (block.kind === 'text') {
+31 -8
View File
@@ -203,10 +203,10 @@ function exportPdf(ast, outDir, template = {}) {
/** /**
* Compute the vertical space a step will need: `head` is the title, the * Compute the vertical space a step will need: `head` is the title, the
* accent rule, any before-description blocks, the description, and the * accent rule, positioned text blocks, the description, and the image —
* image — kept together on one page — and `total` adds the remaining * kept together on one page — and `total` adds the remaining blocks plus
* blocks plus the trailing gap, used to decide whether the whole step * the trailing gap, used to decide whether the whole step fits on a
* fits on a fresh page. * fresh page.
*/ */
const measureStep = (step) => { const measureStep = (step) => {
const headSize = step.depth > 0 ? 12 : 14; const headSize = step.depth > 0 ? 12 : 14;
@@ -214,8 +214,18 @@ function exportPdf(ast, outDir, template = {}) {
const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2'); const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2');
let head = Math.max(40, titleLines.length * headSize * 1.35 + 8); let head = Math.max(40, titleLines.length * headSize * 1.35 + 8);
const { before, rest } = stepContentGroups(step); const {
for (const tb of before) head += measureBlock(tb); beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of [...beforeTitle, ...afterTitle, ...beforeDescription, ...afterDescription, ...beforeImage, ...afterImage]) {
head += measureBlock(tb);
}
if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height; if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height;
head += measureImage(step.stepId); head += measureImage(step.stepId);
@@ -298,14 +308,26 @@ function exportPdf(ast, outDir, template = {}) {
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`); pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
const tocTarget = tocTargets.get(step.stepId); const tocTarget = tocTargets.get(step.stepId);
if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1; if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1;
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of beforeTitle) emitBlock(tb);
const headSize = step.depth > 0 ? 12 : 14; const headSize = step.depth > 0 ? 12 : 14;
writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' }); writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' });
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] }); pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
y += 8; y += 8;
const { before, rest } = stepContentGroups(step); for (const tb of afterTitle) emitBlock(tb);
for (const tb of before) emitBlock(tb); for (const tb of beforeDescription) emitBlock(tb);
if (step.descriptionHtml) writeDescription(step.descriptionHtml); if (step.descriptionHtml) writeDescription(step.descriptionHtml);
for (const tb of afterDescription) emitBlock(tb);
for (const tb of beforeImage) emitBlock(tb);
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img) { if (img) {
@@ -317,6 +339,7 @@ function exportPdf(ast, outDir, template = {}) {
pdf.image(img, M, y, w, h); pdf.image(img, M, y, w, h);
y += h + 10; y += h + 10;
} }
for (const tb of afterImage) emitBlock(tb);
for (const block of rest) { for (const block of rest) {
if (block.kind === 'text') { if (block.kind === 'text') {
+56 -22
View File
@@ -9,8 +9,9 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups } = require('
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout'); const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
/** /**
* PPTX exporter: a title slide plus one 16:9 slide per step (title bar + * PPTX exporter: a title slide plus one 16:9 slide per step, with
* screenshot + description). PresentationML written directly. * positioned text blocks laid out around the step title, description, and
* screenshot. PresentationML written directly.
*/ */
const DEFAULT_TEMPLATE = { const DEFAULT_TEMPLATE = {
@@ -208,24 +209,63 @@ function exportPptx(ast, outDir, template = {}) {
let mediaCounter = 0; let mediaCounter = 0;
for (const step of ast.steps) { for (const step of ast.steps) {
const { before, rest } = stepContentGroups(step); const {
const beforeBlocks = before.filter((block) => block.kind === 'text'); beforeTitle,
const restBlocks = rest.filter((block) => block.kind === 'text'); afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
} = stepContentGroups(step);
let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB'); let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
content += textBox(457200, TITLE_Y, SLIDE_W - 914400, TITLE_H, const beforeTitleReserve = beforeTitle.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true })); const titleY = beforeTitle.length ? 220000 + beforeTitleReserve + 120000 : TITLE_Y;
content += rectShape(457200, TITLE_RULE_Y, 2400000, 12000, '2563EB'); const titleRuleY = beforeTitle.length ? titleY + TITLE_H + 120000 : TITLE_RULE_Y;
const contentY = beforeTitle.length ? Math.max(CONTENT_Y, titleRuleY + 180000) : CONTENT_Y;
const beforeTitleY = beforeTitle.length ? 220000 : CONTENT_Y;
let y = CONTENT_Y; let y = beforeTitleY;
for (const tb of beforeBlocks) { for (const tb of beforeTitle) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml; content += block.xml;
y += block.height + CALL_OUT_GAP; y += block.height + CALL_OUT_GAP;
} }
const descReserve = step.descriptionText ? descriptionHeight(step.descriptionText) + 120000 : 0; content += textBox(457200, titleY, SLIDE_W - 914400, TITLE_H,
const restReserve = restBlocks.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0); para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true }));
content += rectShape(457200, titleRuleY, 2400000, 12000, '2563EB');
y = contentY;
for (const tb of afterTitle) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
for (const tb of beforeDescription) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
if (step.descriptionText) {
const descH = descriptionHeight(step.descriptionText);
content += textBox(457200, y, SLIDE_W - 914400, Math.max(360000, descH || 420000),
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
y += Math.max(360000, descH || 420000) + 90000;
}
for (const tb of afterDescription) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
for (const tb of beforeImage) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
const postImageReserve = afterImage.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
const rels = []; const rels = [];
const media = []; const media = [];
@@ -236,23 +276,17 @@ function exportPptx(ast, outDir, template = {}) {
media.push({ name, data: encodePng(img) }); media.push({ name, data: encodePng(img) });
const relId = 2; // rId1 = layout, rId2 = image const relId = 2; // rId1 = layout, rId2 = image
rels.push({ id: relId, name }); rels.push({ id: relId, name });
// Fit image into a centered region below the title. // Fit image into the remaining centered region before the trailing blocks.
const maxW = SLIDE_W - 1219200; const maxW = SLIDE_W - 1219200;
const maxH = Math.max(0, SLIDE_H - y - descReserve - restReserve - 100000); const maxH = Math.max(0, SLIDE_H - y - postImageReserve - 100000);
let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX; let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX;
const scale = Math.min(maxW / w, maxH / h, 1); const scale = Math.min(maxW / w, maxH / h, 1);
w = Math.round(w * scale); h = Math.round(h * scale); w = Math.round(w * scale); h = Math.round(h * scale);
content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h); content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h);
y += h + 100000; y += h + 100000;
} }
if (step.descriptionText) {
const descH = Math.max(360000, descReserve || 420000);
content += textBox(457200, Math.max(y, SLIDE_H - CONTENT_FOOTER_Y - descH), SLIDE_W - 914400, descH,
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
y = Math.max(y, SLIDE_H - CONTENT_FOOTER_Y - descH) + descH + 90000;
}
for (const tb of restBlocks) { for (const tb of afterImage) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400); const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml; content += block.xml;
y += block.height + CALL_OUT_GAP; y += block.height + CALL_OUT_GAP;
+65
View File
@@ -12,6 +12,7 @@ const { exportWikiJs } = require('../../exporters/wikijs');
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html'); const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
const { exportConfluence } = require('../../exporters/confluence'); const { exportConfluence } = require('../../exporters/confluence');
const { htmlToMarkdown } = require('../../exporters/htmlmd'); const { htmlToMarkdown } = require('../../exporters/htmlmd');
const { stepContentGroups } = require('../../exporters/common');
const { decodePng } = require('../../core/png'); const { decodePng } = require('../../core/png');
const { buildFixtureGuide } = require('./fixture-guide'); const { buildFixtureGuide } = require('./fixture-guide');
const { makeTmpDir, rmrf } = require('./helpers'); const { makeTmpDir, rmrf } = require('./helpers');
@@ -146,6 +147,70 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t)
assert.ok(md.includes('<p>Admins only.</p>')); assert.ok(md.includes('<p>Admins only.</p>'));
}); });
test('text block positions render around the title, description, and image', (t) => {
const root = makeTmpDir('exppositions');
t.after(() => rmrf(root));
const { store, guide, s1 } = buildFixtureGuide(path.join(root, 'data'));
const step = store.getStep(guide.guideId, s1.stepId);
step.textBlocks = [
{ id: 'tb-before-title', order: 1, position: 'before-title', level: 'info', title: 'Before title', descriptionHtml: '<p>Before title.</p>' },
{ id: 'tb-after-title', order: 2, position: 'after-title', level: 'info', title: 'After title', descriptionHtml: '<p>After title.</p>' },
{ id: 'tb-before-description', order: 3, position: 'before-description', level: 'info', title: 'Before description', descriptionHtml: '<p>Before description.</p>' },
{ id: 'tb-after-description', order: 4, position: 'after-description', level: 'info', title: 'After description', descriptionHtml: '<p>After description.</p>' },
{ id: 'tb-before-image', order: 5, position: 'before-image', level: 'info', title: 'Before image', descriptionHtml: '<p>Before image.</p>' },
{ id: 'tb-after-image', order: 6, position: 'after-image', level: 'info', title: 'After image', descriptionHtml: '<p>After image.</p>' },
];
store.saveStep(guide.guideId, step);
const ast = buildRenderAst(store, guide.guideId);
const groups = stepContentGroups(ast.steps[0]);
assert.equal(groups.beforeTitle.length, 1);
assert.equal(groups.afterTitle.length, 1);
assert.equal(groups.beforeDescription.length, 1);
assert.equal(groups.afterDescription.length, 1);
assert.equal(groups.beforeImage.length, 1);
assert.equal(groups.afterImage.length, 1);
const assertIncreasingOrder = (text, markers) => {
let previous = -1;
for (const marker of markers) {
const index = text.indexOf(marker);
assert.ok(index >= 0, `missing marker: ${marker}`);
assert.ok(index > previous, `marker out of order: ${marker}`);
previous = index;
}
};
const mdOut = path.join(root, 'md');
const md = fs.readFileSync(exportMarkdown(ast, mdOut).file, 'utf8');
assertIncreasingOrder(md, [
'Before title',
'## 1. Open AcmeSync settings',
'After title',
'Before description',
'docs.example.com',
'After description',
'Before image',
'![Step 1](',
'After image',
]);
const htmlOut = path.join(root, 'html');
const html = fs.readFileSync(exportHtmlSimple(ast, htmlOut).file, 'utf8');
assertIncreasingOrder(html, [
'Before title',
'<h2>1. Open AcmeSync settings</h2>',
'After title',
'Before description',
'docs.example.com',
'After description',
'Before image',
'alt="Step 1"',
'After image',
]);
});
test('Wiki.js export: TOC is included, wiki callouts render, images exist', (t) => { test('Wiki.js export: TOC is included, wiki callouts render, images exist', (t) => {
const root = makeTmpDir('expwikijs'); const root = makeTmpDir('expwikijs');
t.after(() => rmrf(root)); t.after(() => rmrf(root));